From 9d2d0e6d2329afeaf77230c931aacd65371127ce Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 21 Oct 2026 11:03:57 -0700 Subject: [PATCH 1/4] feat(staged): add "Add image" to the branch card's overflow menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch timeline's "…" menu gains an "Add image" item, below "Write note", that picks images and adds them straight to the branch timeline. Like "Write note" it is offered on every branch: images live in the store, not the worktree. macOS (Tauri) opens the native open panel through the dialog plugin's `open()`, multi-select and filtered to PNG/JPEG/GIF/WebP. A file `` would not do here: wry's `runOpenPanelWithParameters` ignores `accept`, so its panel cannot be filtered to images. The plugin and its `dialog:default` capability (which grants `allow-open`) are already in place, and the unsandboxed app needs no entitlement or Info.plist key for NSOpenPanel, so no config changes. iOS has no Tauri target: Staged runs there as the web build in mobile Safari. The item clicks a hidden file input with an explicit image `accept` list, synchronously inside the menu item's click so the tap's user activation carries through. iOS then presents its own source sheet — Photo Library (PHPicker) or Files (the document picker, filtered to the accepted types), plus the Take Photo option iOS always adds for image inputs — so no custom action sheet is layered on top. Safari owns photo-library access, so no NSPhotoLibraryUsageDescription is involved. Picked images reuse the branch card's drag-and-drop pipeline, the existing way images reach a branch timeline directly: the drop handler's image branch is extracted into `addTimelineImages`, which both use. Desktop paths go through `create_image` exactly as a drop does; web files go through `create_image_from_data`, the endpoint the web build already uploads through (`create_image` is desktop-only). Both store via `store_sniffed_image` with no pending session, so a picked image appears on the timeline immediately, like a dropped one. The chat composer's attachment path was not reused: its images are pending, hidden until a session claims them. Cancelling either picker does nothing. Per-file storage failures toast like a rejected drop; failing to open the desktop panel goes through the card's `notifyError`. The chunked File→base64 loop, duplicated in ImageAttachment and SessionChatPane, moves to `fileToBase64` rather than gaining a third copy. `IMAGE_ACCEPT` is split out of `ATTACHMENT_ACCEPT` and `IMAGE_EXTENSIONS` is exported, so the picker filters come from the lists the store's other entry points already use. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- .../lib/features/branches/BranchCard.svelte | 92 +++++++++++++++---- .../branches/BranchImagePicker.svelte | 68 ++++++++++++++ .../features/branches/branchCardHelpers.ts | 2 +- .../branches/branchImagePicker.test.ts | 49 ++++++++++ .../features/branches/branchImagePicker.ts | 25 +++++ .../features/sessions/ImageAttachment.svelte | 10 +- .../features/sessions/SessionChatPane.svelte | 9 +- .../features/sessions/attachErrors.test.ts | 8 ++ .../src/lib/features/sessions/attachErrors.ts | 9 +- .../features/sessions/attachmentData.test.ts | 23 +++++ .../lib/features/sessions/attachmentData.ts | 13 +++ .../features/timeline/BranchTimeline.svelte | 30 ++++-- 12 files changed, 295 insertions(+), 43 deletions(-) create mode 100644 apps/staged/src/lib/features/branches/BranchImagePicker.svelte create mode 100644 apps/staged/src/lib/features/branches/branchImagePicker.test.ts create mode 100644 apps/staged/src/lib/features/branches/branchImagePicker.ts create mode 100644 apps/staged/src/lib/features/sessions/attachmentData.test.ts create mode 100644 apps/staged/src/lib/features/sessions/attachmentData.ts diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index fd987928a..813c318e0 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -63,6 +63,7 @@ import WriteNoteModal from '../notes/WriteNoteModal.svelte'; import { splitNoteMarkdown } from '../notes/noteMarkdown'; import { attachBasename } from '../sessions/attachErrors'; + import { fileToBase64 } from '../sessions/attachmentData'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; import { Button } from '$lib/components/ui/button'; import { @@ -81,6 +82,7 @@ import BranchCardHeaderInfo from './BranchCardHeaderInfo.svelte'; import BranchCardActionsBar from './BranchCardActionsBar.svelte'; import BranchCardPrButton from './BranchCardPrButton.svelte'; + import BranchImagePicker from './BranchImagePicker.svelte'; import BranchCardSessionManager from './BranchCardSessionManager.svelte'; import { addPendingSession, @@ -1701,28 +1703,70 @@ } if (imagePaths.length > 0) { - Promise.all( - imagePaths.map(async (filePath) => { - try { - await commands.createImage(branch.id, branch.projectId, filePath); - } catch (e) { - console.error('Failed to create image from drop:', e); - // Same shape as the text path, so a rejected PDF or oversized - // image ("could not open PDF", "media exceeds 10 MB limit") is - // not a silent no-op. The extension stays in the name, as in the - // composer's attach errors: it is what explains a PDF failure. - const reason = e instanceof Error ? e.message : typeof e === 'string' ? e : null; - toast.error('Error', { - description: `Could not attach "${attachBasename(filePath)}"${reason ? ` \u2014 ${reason}` : ''}`, - }); - } - }) - ).then(() => { - loadTimeline(); - }); + addImagePaths(imagePaths); } } + // ========================================================================= + // Images → timeline (drop, or the "…" menu's "Add image") + // ========================================================================= + + let imagePicker: BranchImagePicker | undefined = $state(); + + /** + * Stores images straight onto the timeline — not pending, so they show at + * once — then refreshes it. A drop and "Add image" both land here, so a + * picked image ends up exactly where a dropped one does. + */ + function addTimelineImages(images: { name: string; store: () => Promise }[]) { + Promise.all( + images.map(async ({ name, store }) => { + try { + await store(); + } catch (e) { + console.error('Failed to add image:', e); + // Same shape as the text drop path, so a rejected PDF or oversized + // image ("could not open PDF", "media exceeds 10 MB limit") is + // not a silent no-op. The extension stays in the name, as in the + // composer's attach errors: it is what explains a PDF failure. + const reason = e instanceof Error ? e.message : typeof e === 'string' ? e : null; + toast.error('Error', { + description: `Could not attach "${name}"${reason ? ` \u2014 ${reason}` : ''}`, + }); + } + }) + ).then(() => { + loadTimeline(); + }); + } + + /** Desktop: files dropped on the card or picked in the open panel. */ + function addImagePaths(paths: string[]) { + addTimelineImages( + paths.map((filePath) => ({ + name: attachBasename(filePath), + store: () => commands.createImage(branch.id, branch.projectId, filePath), + })) + ); + } + + /** Web: files picked through the file input (on iOS, Photo Library or Files). */ + function addImageFiles(files: File[]) { + addTimelineImages( + files.map((file) => ({ + name: file.name, + store: async () => + commands.createImageFromData( + branch.id, + branch.projectId, + file.name, + file.type, + await fileToBase64(file) + ), + })) + ); + } + $effect(() => { if (!isLocal) return; @@ -1994,6 +2038,7 @@ ? (e) => sessionMgr.openNewSession('review', e) : undefined} onWriteNote={() => (openWriteNote = { note: null })} + onAddImage={() => imagePicker?.open()} onPullOrigin={handlePullOrigin} onPushOrigin={handlePushOrigin} onOpenPushSession={pushSessionId && pushSessionId !== '__pending__' @@ -2077,6 +2122,15 @@ {/if} + + notifyError('Could not open the image picker', e)} +/> + {#if openNote}