webui: Add file paste upload support

- Add new /upload endpoint in loophttp.go to save pasted files to /tmp
- Make the implementation generic to handle any file type, not just images
- Implement paste event handling in sketch-chat-input.ts to detect files
- Add logic to upload files and insert file paths in chat input
- Improve random filename generation with better comments

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: sff2e40b9b3e4c05ak

webui: Improve file upload UI experience
- Use relative path for upload endpoint
- Add loading indicator during file upload
- Show error message if upload fails
- Improve cursor position handling
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index 1cd486a..20e1629 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -3,7 +3,9 @@
 
 import (
 	"context"
+	"crypto/rand"
 	"encoding/base64"
+	"encoding/hex"
 	"encoding/json"
 	"fmt"
 	"html"
@@ -14,6 +16,7 @@
 	"net/http/pprof"
 	"os"
 	"os/exec"
+	"path/filepath"
 	"strconv"
 	"strings"
 	"sync"
@@ -594,6 +597,62 @@
 		w.WriteHeader(http.StatusOK)
 	})
 
+	// Handler for POST /upload - uploads a file to /tmp
+	s.mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodPost {
+			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+			return
+		}
+
+		// Limit to 10MB file size
+		r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
+
+		// Parse the multipart form
+		if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
+			http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		// Get the file from the multipart form
+		file, handler, err := r.FormFile("file")
+		if err != nil {
+			http.Error(w, "Failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
+			return
+		}
+		defer file.Close()
+
+		// Generate a unique ID (8 random bytes converted to 16 hex chars)
+		randBytes := make([]byte, 8)
+		if _, err := rand.Read(randBytes); err != nil {
+			http.Error(w, "Failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		// Get file extension from the original filename
+		ext := filepath.Ext(handler.Filename)
+
+		// Create a unique filename in the /tmp directory
+		filename := fmt.Sprintf("/tmp/sketch_file_%s%s", hex.EncodeToString(randBytes), ext)
+
+		// Create the destination file
+		destFile, err := os.Create(filename)
+		if err != nil {
+			http.Error(w, "Failed to create destination file: "+err.Error(), http.StatusInternalServerError)
+			return
+		}
+		defer destFile.Close()
+
+		// Copy the file contents to the destination file
+		if _, err := io.Copy(destFile, file); err != nil {
+			http.Error(w, "Failed to save file: "+err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		// Return the path to the saved file
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(map[string]string{"path": filename})
+	})
+
 	// Handler for /cancel - cancels the current inner loop in progress
 	s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
 		if r.Method != http.MethodPost {
diff --git a/webui/src/web-components/sketch-chat-input.ts b/webui/src/web-components/sketch-chat-input.ts
index fb273bc..581c975 100644
--- a/webui/src/web-components/sketch-chat-input.ts
+++ b/webui/src/web-components/sketch-chat-input.ts
@@ -69,6 +69,71 @@
     window.addEventListener("diff-comment", this._handleDiffComment);
   }
 
+  // Handle paste events for files (including images)
+  private _handlePaste = async (event: ClipboardEvent) => {
+    // Check if the clipboard contains files
+    if (event.clipboardData && event.clipboardData.files.length > 0) {
+      const file = event.clipboardData.files[0];
+
+      // Handle the file upload (for any file type, not just images)
+      event.preventDefault(); // Prevent default paste behavior
+
+      // Create a FormData object to send the file
+      const formData = new FormData();
+      formData.append("file", file);
+
+      // Insert a placeholder at the current cursor position
+      const cursorPos = this.chatInput.selectionStart;
+      const textBefore = this.content.substring(0, cursorPos);
+      const textAfter = this.content.substring(cursorPos);
+
+      // Add a loading indicator
+      const loadingText = `[Uploading ${file.name}...]`;
+      this.content = `${textBefore}${loadingText}${textAfter}`;
+
+      // Adjust spacing immediately to show loading indicator
+      requestAnimationFrame(() => this.adjustChatSpacing());
+
+      try {
+        // Upload the file to the server using a relative path
+        const response = await fetch("./upload", {
+          method: "POST",
+          body: formData,
+        });
+
+        if (!response.ok) {
+          throw new Error(`Upload failed: ${response.statusText}`);
+        }
+
+        const data = await response.json();
+
+        // Replace the loading placeholder with the actual file path
+        this.content = this.content.replace(loadingText, `[${data.path}]`);
+
+        // Adjust the cursor position after the inserted text
+        requestAnimationFrame(() => {
+          this.adjustChatSpacing();
+          this.chatInput.focus();
+          const newPos = textBefore.length + data.path.length + 2; // +2 for the brackets
+          this.chatInput.selectionStart = newPos;
+          this.chatInput.selectionEnd = newPos;
+        });
+      } catch (error) {
+        console.error("Failed to upload file:", error);
+
+        // Replace loading indicator with error message
+        const errorText = `[Upload failed: ${error.message}]`;
+        this.content = this.content.replace(loadingText, errorText);
+
+        // Adjust spacing to show error message
+        requestAnimationFrame(() => {
+          this.adjustChatSpacing();
+          this.chatInput.focus();
+        });
+      }
+    }
+  };
+
   private _handleDiffComment(event: CustomEvent) {
     const { comment } = event.detail;
     if (!comment) return;
@@ -140,6 +205,9 @@
       this.chatInput.focus();
       // Initialize the input height
       this.adjustChatSpacing();
+
+      // Add paste event listener for image handling
+      this.chatInput.addEventListener("paste", this._handlePaste);
     }
 
     // Add window.onload handler to ensure the input is focused when the page fully loads