shelley/ui: fix focus loss after pasting image

Philip Zeyliger and Shelley created

Prompt: In a new worktree, reset to a fetched origin/main and fix https://github.com/boldsoftware/shelley/issues/65

After pasting an image, the cursor would leave the input box because:
1. handlePaste was async and awaited uploadFile
2. React state updates during upload caused focus to be lost

Fix:
- Make handlePaste synchronous (don't await uploadFile)
- Use setTimeout(10ms) to restore focus after React commits the state update

This allows users to paste an image and immediately continue typing.

Fixes boldsoftware/shelley#65

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

ui/e2e/file-upload.spec.ts         | 50 ++++++++++++++++++++++++++++++++
ui/src/components/MessageInput.tsx | 11 +++++-
2 files changed, 59 insertions(+), 2 deletions(-)

Detailed changes

ui/e2e/file-upload.spec.ts 🔗

@@ -196,3 +196,53 @@ test.describe('File Upload via Paste and Drag', () => {
     expect(inputValue).toBeTruthy();
   });
 });
+
+  test('focus is retained in input after pasting image', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+
+    const messageInput = page.getByTestId('message-input');
+    await expect(messageInput).toBeVisible();
+
+    // Focus the input and add some text
+    await messageInput.focus();
+    await messageInput.fill('Testing paste focus: ');
+
+    // Simulate an image paste via clipboard event
+    await page.evaluate(async () => {
+      const input = document.querySelector('[data-testid="message-input"]') as HTMLTextAreaElement;
+      if (!input) return;
+
+      // Create a simple test image as a Blob
+      const blob = new Blob(['test'], { type: 'image/png' });
+      const file = new File([blob], 'test-paste.png', { type: 'image/png' });
+
+      // Create DataTransfer with the file
+      const dataTransfer = new DataTransfer();
+      dataTransfer.items.add(file);
+
+      // Dispatch paste event
+      const pasteEvent = new ClipboardEvent('paste', {
+        clipboardData: dataTransfer,
+        bubbles: true,
+        cancelable: true
+      });
+
+      input.dispatchEvent(pasteEvent);
+    });
+
+    // Wait for the upload to process and focus to be restored
+    await page.waitForTimeout(100);
+
+    // Verify focus is still on the input (or restored to it)
+    const isFocused = await page.evaluate(() => {
+      const input = document.querySelector('[data-testid="message-input"]');
+      return document.activeElement === input;
+    });
+
+    expect(isFocused).toBe(true);
+
+    // Verify the input has the uploaded file path
+    const inputValue = await messageInput.inputValue();
+    expect(inputValue).toContain('Testing paste focus:');
+  });

ui/src/components/MessageInput.tsx 🔗

@@ -224,7 +224,7 @@ function MessageInput({
     }
   };
 
-  const handlePaste = async (event: React.ClipboardEvent) => {
+  const handlePaste = (event: React.ClipboardEvent) => {
     // Check clipboard items (works on both desktop and mobile)
     // Mobile browsers often don't populate clipboardData.files, but items works
     const items = event.clipboardData?.items;
@@ -235,7 +235,14 @@ function MessageInput({
           const file = item.getAsFile();
           if (file) {
             event.preventDefault();
-            await uploadFile(file);
+            // Fire and forget - uploadFile handles state updates internally.
+            uploadFile(file);
+            // Restore focus after React updates. We use setTimeout(0) to ensure
+            // the focus happens after React's state update commits to the DOM.
+            // The timeout must be longer than 0 to reliably work across browsers.
+            setTimeout(() => {
+              textareaRef.current?.focus();
+            }, 10);
             return;
           }
         }