From b7f3786884d6325d7ee151f8f5b64bc1015be4dc Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 14 May 2026 15:18:00 -0400 Subject: [PATCH 1/2] Improve pasted image attachment previews --- .../src/main/services/adeActions/registry.ts | 6 +- .../src/main/services/ipc/registerIpc.ts | 59 +++-- apps/desktop/src/preload/global.d.ts | 5 + apps/desktop/src/preload/preload.ts | 5 + apps/desktop/src/renderer/browserMock.ts | 1 + .../chat/AgentChatComposer.test.tsx | 106 ++++++++ .../components/chat/AgentChatComposer.tsx | 240 +++++++++++++++--- .../chat/ChatAttachmentTray.test.tsx | 36 +++ .../components/chat/ChatAttachmentTray.tsx | 85 ++++++- apps/desktop/src/shared/ipc.ts | 1 + docs/ARCHITECTURE.md | 2 +- docs/features/chat/README.md | 4 +- docs/features/chat/composer-and-ui.md | 22 +- 13 files changed, 506 insertions(+), 66 deletions(-) diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index a921a657f..a20b5c04f 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -970,7 +970,7 @@ async function getTurnFileDiffFromGit( }; } -function saveAgentChatTempAttachment(projectRoot: string, arg: { data?: string; filename?: string }): { path: string } { +async function saveAgentChatTempAttachment(projectRoot: string, arg: { data?: string; filename?: string }): Promise<{ path: string }> { const maxEncodedLength = Math.ceil(MAX_TEMP_ATTACHMENT_BYTES / 3) * 4; if (typeof arg.data !== "string") { throw new Error("Temporary attachment data is required."); @@ -983,11 +983,11 @@ function saveAgentChatTempAttachment(projectRoot: string, arg: { data?: string; throw new Error("Temporary attachments must be 10 MB or smaller."); } const baseDir = path.join(projectRoot, ".ade", "attachments"); - fs.mkdirSync(baseDir, { recursive: true }); + await fs.promises.mkdir(baseDir, { recursive: true }); const filename = typeof arg.filename === "string" ? arg.filename : ""; const ext = path.extname(filename) || ".png"; const destPath = path.join(baseDir, `${randomUUID()}${ext}`); - fs.writeFileSync(destPath, content); + await fs.promises.writeFile(destPath, content); return { path: destPath }; } diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 247c88caa..677f262cc 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -3398,6 +3398,7 @@ export function registerIpc({ }; const MAX_IMAGE_BYTES = 10 * 1024 * 1024; + const MAX_TEMP_ATTACHMENT_BYTES = 10 * 1024 * 1024; /** * Read an allow-listed image file from disk after a stat-based size check, @@ -3421,6 +3422,26 @@ export function registerIpc({ return { data, mimeType }; }; + const saveAgentChatTempAttachmentBuffer = async ( + content: Buffer, + filename: string, + ): Promise<{ path: string }> => { + if (content.byteLength > MAX_TEMP_ATTACHMENT_BYTES) { + throw new Error("Temporary attachments must be 10 MB or smaller."); + } + const ctx = getCtx(); + // Save within the project's .ade directory so CLI subprocesses have + // filesystem access. Fall back to system temp if no project is open. + const baseDir = ctx.project?.rootPath + ? path.join(ctx.project.rootPath, ".ade", "attachments") + : path.join(app.getPath("temp"), "ade-attachments"); + await fs.promises.mkdir(baseDir, { recursive: true }); + const ext = path.extname(filename) || ".png"; + const destPath = path.join(baseDir, `${randomUUID()}${ext}`); + await fs.promises.writeFile(destPath, content); + return { path: destPath }; + }; + ipcMain.handle(IPC.appRevealPath, async (_event, arg: { path: string }): Promise => { const raw = typeof arg?.path === "string" ? arg.path.trim() : ""; if (!raw) return; @@ -3476,12 +3497,11 @@ export function registerIpc({ }); ipcMain.handle(IPC.appReadClipboardImage, async (): Promise<{ data: string; filename: string; mimeType: string } | null> => { - const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; const image = clipboard.readImage(); if (image.isEmpty()) return null; const png = image.toPNG(); if (!png.byteLength) return null; - if (png.byteLength > MAX_ATTACHMENT_BYTES) { + if (png.byteLength > MAX_TEMP_ATTACHMENT_BYTES) { throw new Error("Clipboard image must be 10 MB or smaller."); } return { @@ -3491,6 +3511,23 @@ export function registerIpc({ }; }); + ipcMain.handle(IPC.appSaveClipboardImageAttachment, async (): Promise<{ path: string; mimeType: string; previewDataUrl: string | null } | null> => { + const image = clipboard.readImage(); + if (image.isEmpty()) return null; + const png = image.toPNG(); + if (!png.byteLength) return null; + if (png.byteLength > MAX_TEMP_ATTACHMENT_BYTES) { + throw new Error("Clipboard image must be 10 MB or smaller."); + } + const saved = await saveAgentChatTempAttachmentBuffer(png, "clipboard.png"); + const previewImage = image.resize({ width: 96, height: 96, quality: "best" }); + return { + path: saved.path, + mimeType: "image/png", + previewDataUrl: previewImage.isEmpty() ? null : previewImage.toDataURL(), + }; + }); + ipcMain.handle(IPC.appGetImageDataUrl, async (_event, arg: { path: string }): Promise<{ dataUrl: string }> => { const filePath = resolveAllowedRendererPath(arg?.path); // Use async fs APIs and a size pre-check so a 10 MB image read never @@ -6670,26 +6707,12 @@ export function registerIpc({ }); ipcMain.handle(IPC.agentChatSaveTempAttachment, async (_event, arg: { data: string; filename: string }): Promise<{ path: string }> => { - const ctx = getCtx(); - const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; - const maxEncodedLength = Math.ceil(MAX_ATTACHMENT_BYTES / 3) * 4; + const maxEncodedLength = Math.ceil(MAX_TEMP_ATTACHMENT_BYTES / 3) * 4; if (typeof arg.data === "string" && arg.data.length > maxEncodedLength) { throw new Error("Temporary attachments must be 10 MB or smaller."); } const content = Buffer.from(arg.data, "base64"); - if (content.byteLength > MAX_ATTACHMENT_BYTES) { - throw new Error("Temporary attachments must be 10 MB or smaller."); - } - // Save within the project's .ade directory so CLI subprocesses (Claude Code) - // have filesystem access. Fall back to system temp if no project is open. - const baseDir = ctx.project?.rootPath - ? path.join(ctx.project.rootPath, ".ade", "attachments") - : path.join(app.getPath("temp"), "ade-attachments"); - fs.mkdirSync(baseDir, { recursive: true }); - const ext = path.extname(arg.filename) || ".png"; - const destPath = path.join(baseDir, `${randomUUID()}${ext}`); - fs.writeFileSync(destPath, content); - return { path: destPath }; + return saveAgentChatTempAttachmentBuffer(content, arg.filename); }); ipcMain.handle(IPC.agentChatGetTurnFileDiff, async (_event, arg: AgentChatGetTurnFileDiffArgs) => { diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 8fb144004..97eeab375 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -778,6 +778,11 @@ declare global { filename: string; mimeType: string; } | null>; + saveClipboardImageAttachment: () => Promise<{ + path: string; + mimeType: string; + previewDataUrl: string | null; + } | null>; getImageDataUrl: (path: string) => Promise<{ dataUrl: string }>; writeClipboardImage: (path: string) => Promise; openPathInEditor: (args: { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 13712bd73..3958e0e87 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2549,6 +2549,11 @@ contextBridge.exposeInMainWorld("ade", { filename: string; mimeType: string; } | null> => ipcRenderer.invoke(IPC.appReadClipboardImage), + saveClipboardImageAttachment: async (): Promise<{ + path: string; + mimeType: string; + previewDataUrl: string | null; + } | null> => ipcRenderer.invoke(IPC.appSaveClipboardImageAttachment), getImageDataUrl: async (path: string): Promise<{ dataUrl: string }> => imageDataUrlCache.get(path), writeClipboardImage: async (path: string): Promise => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index d4d8dc9ca..2da148789 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -2833,6 +2833,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { writeClipboardText: resolvedArg(undefined), hasClipboardImage: resolved(false), readClipboardImage: resolved(null), + saveClipboardImageAttachment: resolved(null), getImageDataUrl: resolvedArg({ dataUrl: "" }), writeClipboardImage: resolvedArg(undefined), openPath: resolvedArg(undefined), diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 8867c9ca8..e4c5b052d 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -986,6 +986,112 @@ describe("AgentChatComposer", () => { } }); + it("uses the native clipboard attachment IPC for macOS Cmd+V fallback when available", async () => { + const originalPlatform = navigator.platform; + Object.defineProperty(navigator, "platform", { + configurable: true, + value: "MacIntel", + }); + const saveClipboardImageAttachment = vi.fn().mockResolvedValue({ + path: "/tmp/ade-native-clipboard.png", + mimeType: "image/png", + previewDataUrl: "data:image/png;base64,preview", + }); + const readClipboardImage = vi.fn(); + const saveTempAttachment = vi.fn(); + (window as any).ade = { + app: { saveClipboardImageAttachment, readClipboardImage }, + agentChat: { saveTempAttachment }, + }; + + try { + const props = renderComposer({ + turnActive: false, + draft: "", + }); + + fireEvent.keyDown(screen.getByPlaceholderText("Type to vibecode..."), { + key: "v", + metaKey: true, + }); + + await waitFor(() => expect(saveClipboardImageAttachment).toHaveBeenCalledTimes(1)); + expect(readClipboardImage).not.toHaveBeenCalled(); + expect(saveTempAttachment).not.toHaveBeenCalled(); + expect(props.onAddAttachment).toHaveBeenCalledWith({ + path: "/tmp/ade-native-clipboard.png", + type: "image", + }); + } finally { + Object.defineProperty(navigator, "platform", { + configurable: true, + value: originalPlatform, + }); + } + }); + + it("shows a pasted image preview while the temp attachment is still saving", async () => { + const createObjectURL = vi.fn().mockReturnValue("blob:ade-paste-preview"); + const revokeObjectURL = vi.fn(); + const previousCreateObjectURL = URL.createObjectURL; + const previousRevokeObjectURL = URL.revokeObjectURL; + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: createObjectURL, + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: revokeObjectURL, + }); + + let resolveSave: (value: { path: string }) => void = () => {}; + const saveTempAttachment = vi.fn(() => new Promise<{ path: string }>((resolve) => { + resolveSave = resolve; + })); + (window as any).ade = { + app: {}, + agentChat: { saveTempAttachment }, + }; + + try { + const props = renderComposer({ + turnActive: false, + draft: "", + }); + const file = new File([new Uint8Array([1, 2, 3])], "paste.png", { type: "image/png" }); + Object.defineProperty(file, "arrayBuffer", { + configurable: true, + value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer), + }); + const clipboardData = { + files: [file], + items: [], + getData: vi.fn(() => ""), + }; + + fireEvent.paste(screen.getByPlaceholderText("Type to vibecode..."), { clipboardData }); + + expect(await screen.findByRole("status", { name: "Attaching paste.png" })).toBeTruthy(); + expect(screen.getByAltText("paste.png preview").getAttribute("src")).toBe("blob:ade-paste-preview"); + expect(createObjectURL).toHaveBeenCalledWith(file); + + resolveSave({ path: "/tmp/ade-paste.png" }); + await waitFor(() => expect(props.onAddAttachment).toHaveBeenCalledWith({ + path: "/tmp/ade-paste.png", + type: "image", + })); + } finally { + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: previousCreateObjectURL, + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: previousRevokeObjectURL, + }); + } + }); + it("clears the drop highlight when a URL drop is rejected", async () => { const props = renderComposer({ turnActive: false, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index abf5d3860..b965cc39a 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -38,7 +38,7 @@ import { cn } from "../ui/cn"; import { ProviderModelSelector } from "../shared/ProviderModelSelector"; import { getPermissionOptions, safetyColors } from "../shared/permissionOptions"; import { CodexTokenInline } from "./codex/CodexTokenInline"; -import { ChatAttachmentTray } from "./ChatAttachmentTray"; +import { ChatAttachmentTray, type ChatAttachmentPendingImage } from "./ChatAttachmentTray"; import { ChatComposerShell } from "./ChatComposerShell"; import { LaneDialogShell } from "../lanes/LaneDialogShell"; import { LinearIssueBrowser, linearBrowserIssueToLaneIssue } from "../app/LinearIssueBrowser"; @@ -53,6 +53,7 @@ import { SmartTooltip } from "../ui/SmartTooltip"; const MAX_TEMP_ATTACHMENT_BYTES = 10 * 1024 * 1024; const CLIPBOARD_IMAGE_PASTE_FALLBACK_DELAY_MS = 80; +const BASE64_ENCODE_CHUNK_SIZE = 0x8000; const ISSUE_CONTEXT_MENU_WIDTH = 256; const ISSUE_CONTEXT_MENU_GAP = 8; const ISSUE_CONTEXT_MENU_VIEWPORT_GUTTER = 8; @@ -102,6 +103,16 @@ function normalizeImageAttachmentUrl(value: string | null | undefined): string | } } +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + const parts: string[] = []; + for (let i = 0; i < bytes.length; i += BASE64_ENCODE_CHUNK_SIZE) { + const chunk = bytes.subarray(i, i + BASE64_ENCODE_CHUNK_SIZE); + parts.push(String.fromCharCode(...chunk)); + } + return btoa(parts.join("")); +} + function getIssueContextMenuStyle(trigger: HTMLButtonElement): React.CSSProperties { const rect = trigger.getBoundingClientRect(); const maxLeft = Math.max( @@ -976,6 +987,8 @@ export function AgentChatComposer({ const [attachmentCursor, setAttachmentCursor] = useState(0); const [attachError, setAttachError] = useState(null); const [attachNotice, setAttachNotice] = useState<{ message: string; undoPath: string } | null>(null); + const [pendingImageAttachments, setPendingImageAttachments] = useState([]); + const [imagePreviewUrls, setImagePreviewUrls] = useState>({}); useEffect(() => { if (!attachNotice) return; @@ -1010,6 +1023,10 @@ export function AgentChatComposer({ const lastSerializedDraftRef = useRef(""); const lastPlainSelectionRef = useRef(null); const fileAddInProgressRef = useRef(false); + const objectPreviewUrlsRef = useRef>(new Set()); + const cancelledPendingImageAttachmentsRef = useRef>(new Set()); + const pendingImageAttachmentSequenceRef = useRef(0); + const previousAttachmentPathsRef = useRef>(new Set()); const clipboardImagePasteHandledRef = useRef(0); const clipboardImagePasteFallbackTimerRef = useRef(null); // Set when the keydown-driven fallback path actually attaches a clipboard @@ -1028,12 +1045,13 @@ export function AgentChatComposer({ : turnActive ? `Steer active turn: ${composerInputContextLabel}` : composerInputContextLabel; - const canAttach = !composerInputLocked && (!parallelChatMode || attachments.length < PARALLEL_CHAT_MAX_ATTACHMENTS); + const attachmentSlotsUsed = attachments.length + pendingImageAttachments.length; + const canAttach = !composerInputLocked && (!parallelChatMode || attachmentSlotsUsed < PARALLEL_CHAT_MAX_ATTACHMENTS); const attachBlockedReason = getAttachBlockedReason({ composerInputLocked, composerInputLockMessage, parallelChatMode, - attachmentCount: attachments.length, + attachmentCount: attachmentSlotsUsed, }); const contextAttachmentCount = contextAttachments.length; const canAttachIssueContext = !composerInputLocked && typeof onAddContextAttachment === "function"; @@ -1063,6 +1081,10 @@ export function AgentChatComposer({ window.clearTimeout(clipboardImagePasteFallbackTimerRef.current); clipboardImagePasteFallbackTimerRef.current = null; } + if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") { + for (const url of objectPreviewUrlsRef.current) URL.revokeObjectURL(url); + } + objectPreviewUrlsRef.current.clear(); }; }, []); useEffect(() => { @@ -1077,6 +1099,22 @@ export function AgentChatComposer({ clipboardImagePasteFallbackTimerRef.current = null; } clipboardImagePasteFallbackAttachedRef.current = false; + setPendingImageAttachments((current) => { + if (!current.length) return current; + for (const attachment of current) { + cancelledPendingImageAttachmentsRef.current.add(attachment.id); + if ( + attachment.previewUrl + && objectPreviewUrlsRef.current.has(attachment.previewUrl) + && typeof URL !== "undefined" + && typeof URL.revokeObjectURL === "function" + ) { + URL.revokeObjectURL(attachment.previewUrl); + objectPreviewUrlsRef.current.delete(attachment.previewUrl); + } + } + return []; + }); }, [composerInputLocked]); useLayoutEffect(() => { resizeTextarea(); @@ -1088,6 +1126,95 @@ export function AgentChatComposer({ [sdkSlashCommands, onClearEvents], ); + const releasePreviewUrl = useCallback((url: string | null | undefined) => { + if (!url || !objectPreviewUrlsRef.current.has(url)) return; + if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") { + URL.revokeObjectURL(url); + } + objectPreviewUrlsRef.current.delete(url); + }, []); + + const rememberPreviewUrl = useCallback((path: string, url: string | null | undefined) => { + if (!url) return; + setImagePreviewUrls((current) => { + const previous = current[path]; + if (previous === url) return current; + if (previous) releasePreviewUrl(previous); + return { ...current, [path]: url }; + }); + }, [releasePreviewUrl]); + + const clearPreviewForPath = useCallback((path: string) => { + setImagePreviewUrls((current) => { + const previous = current[path]; + if (!previous) return current; + releasePreviewUrl(previous); + const next = { ...current }; + delete next[path]; + return next; + }); + }, [releasePreviewUrl]); + + const createObjectPreviewUrl = useCallback((file: File): string | null => { + if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") return null; + try { + const url = URL.createObjectURL(file); + objectPreviewUrlsRef.current.add(url); + return url; + } catch { + return null; + } + }, []); + + const addPendingImageAttachment = useCallback((name: string, previewUrl: string | null): ChatAttachmentPendingImage => { + const pending = { + id: `pending-image-${Date.now()}-${++pendingImageAttachmentSequenceRef.current}`, + name, + previewUrl, + }; + setPendingImageAttachments((current) => [...current, pending]); + return pending; + }, []); + + const dropPendingImageAttachment = useCallback(( + id: string, + options: { markCancelled?: boolean; revokePreview?: boolean } = {}, + ) => { + if (options.markCancelled) cancelledPendingImageAttachmentsRef.current.add(id); + setPendingImageAttachments((current) => { + const pending = current.find((entry) => entry.id === id); + if (pending?.previewUrl && options.revokePreview !== false) { + releasePreviewUrl(pending.previewUrl); + } + return current.filter((entry) => entry.id !== id); + }); + }, [releasePreviewUrl]); + + const removePendingImageAttachment = useCallback((id: string) => { + dropPendingImageAttachment(id, { markCancelled: true, revokePreview: true }); + }, [dropPendingImageAttachment]); + + const handleRemoveAttachment = useCallback((path: string) => { + clearPreviewForPath(path); + onRemoveAttachment(path); + }, [clearPreviewForPath, onRemoveAttachment]); + + useEffect(() => { + const currentPaths = new Set(attachments.map((attachment) => attachment.path)); + const previousPaths = previousAttachmentPathsRef.current; + setImagePreviewUrls((current) => { + let next = current; + for (const [path, url] of Object.entries(current)) { + if (!previousPaths.has(path) || currentPaths.has(path)) continue; + releasePreviewUrl(url); + if (next === current) next = { ...current }; + delete next[path]; + } + return next; + }); + previousAttachmentPathsRef.current = currentPaths; + }, [attachments, releasePreviewUrl]); + /* ── Attachment picker effects ── */ useEffect(() => { if (!attachmentPickerOpen) { @@ -1131,7 +1258,7 @@ export function AgentChatComposer({ const selectAttachment = (attachment: AgentChatFileRef) => { setAttachError(null); - if (parallelChatMode && attachments.length >= PARALLEL_CHAT_MAX_ATTACHMENTS) { + if (parallelChatMode && attachmentSlotsUsed >= PARALLEL_CHAT_MAX_ATTACHMENTS) { setAttachError(`You can attach up to ${PARALLEL_CHAT_MAX_ATTACHMENTS} files for parallel launch.`); return; } @@ -1139,25 +1266,33 @@ export function AgentChatComposer({ setAttachmentPickerOpen(false); }; - const addFileAttachments = async (files: FileList | null | undefined) => { + const addFileAttachments = async (files: FileList | File[] | null | undefined) => { if (!files?.length) return; - if (parallelChatMode && attachments.length >= PARALLEL_CHAT_MAX_ATTACHMENTS) return; + if (parallelChatMode && attachmentSlotsUsed >= PARALLEL_CHAT_MAX_ATTACHMENTS) return; if (fileAddInProgressRef.current) return; fileAddInProgressRef.current = true; setAttachError(null); try { let addedInBatch = 0; + const initialSlotCount = attachmentSlotsUsed; for (const file of Array.from(files)) { - if (parallelChatMode && attachments.length + addedInBatch >= PARALLEL_CHAT_MAX_ATTACHMENTS) { + if (parallelChatMode && initialSlotCount + addedInBatch >= PARALLEL_CHAT_MAX_ATTACHMENTS) { setAttachError(`You can attach up to ${PARALLEL_CHAT_MAX_ATTACHMENTS} files for parallel launch.`); break; } const fileWithPath = file as File & { path?: string }; const hasRealPath = typeof fileWithPath.path === "string" && fileWithPath.path.trim().length > 0; + const attachmentName = file.name || "clipboard.png"; + const isImageAttachment = inferAttachmentType(attachmentName, file.type) === "image"; if (hasRealPath) { const filePath = fileWithPath.path!; - onAddAttachment({ path: filePath, type: inferAttachmentType(filePath, file.type) }); + const attachmentType = inferAttachmentType(filePath, file.type); + if (attachmentType === "image") { + const previewUrl = createObjectPreviewUrl(file); + if (previewUrl) rememberPreviewUrl(filePath, previewUrl); + } + onAddAttachment({ path: filePath, type: attachmentType }); addedInBatch += 1; continue; } @@ -1169,19 +1304,32 @@ export function AgentChatComposer({ continue; } + const pendingImage = isImageAttachment + ? addPendingImageAttachment(attachmentName, createObjectPreviewUrl(file)) + : null; try { const buf = await file.arrayBuffer(); - const bytes = new Uint8Array(buf); - let binary = ""; - for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); - const base64 = btoa(binary); + const base64 = arrayBufferToBase64(buf); const { path: tempPath } = await window.ade.agentChat.saveTempAttachment({ data: base64, - filename: file.name || "clipboard.png", + filename: attachmentName, }); - onAddAttachment({ path: tempPath, type: inferAttachmentType(tempPath, file.type) }); + if (pendingImage && cancelledPendingImageAttachmentsRef.current.has(pendingImage.id)) { + cancelledPendingImageAttachmentsRef.current.delete(pendingImage.id); + continue; + } + const attachmentType = inferAttachmentType(tempPath, file.type); + const storedPreview = Boolean(pendingImage?.previewUrl && attachmentType === "image"); + if (pendingImage?.previewUrl && storedPreview) { + rememberPreviewUrl(tempPath, pendingImage.previewUrl); + } + onAddAttachment({ path: tempPath, type: attachmentType }); + if (pendingImage) { + dropPendingImageAttachment(pendingImage.id, { revokePreview: !storedPreview }); + } addedInBatch += 1; } catch { + if (pendingImage) dropPendingImageAttachment(pendingImage.id, { revokePreview: true }); setAttachError(`Unable to attach "${file.name || "clipboard"}".`); } } @@ -1192,19 +1340,40 @@ export function AgentChatComposer({ const addNativeClipboardImageAttachment = async () => { if (!canAttach) return; - if (parallelChatMode && attachments.length >= PARALLEL_CHAT_MAX_ATTACHMENTS) return; + if (parallelChatMode && attachmentSlotsUsed >= PARALLEL_CHAT_MAX_ATTACHMENTS) return; if (fileAddInProgressRef.current) return; fileAddInProgressRef.current = true; setAttachError(null); + const pendingImage = addPendingImageAttachment("clipboard.png", null); try { - const payload = await window.ade.app.readClipboardImage(); - if (!payload) return; - const { path: tempPath } = await window.ade.agentChat.saveTempAttachment({ - data: payload.data, - filename: payload.filename || "clipboard.png", - }); - onAddAttachment({ path: tempPath, type: inferAttachmentType(tempPath, payload.mimeType) }); + const payload = window.ade.app.saveClipboardImageAttachment + ? await window.ade.app.saveClipboardImageAttachment() + : await (async () => { + const legacyPayload = await window.ade.app.readClipboardImage(); + if (!legacyPayload) return null; + const { path: tempPath } = await window.ade.agentChat.saveTempAttachment({ + data: legacyPayload.data, + filename: legacyPayload.filename || "clipboard.png", + }); + return { + path: tempPath, + mimeType: legacyPayload.mimeType, + previewDataUrl: `data:${legacyPayload.mimeType};base64,${legacyPayload.data}`, + }; + })(); + if (!payload) { + dropPendingImageAttachment(pendingImage.id, { revokePreview: true }); + return; + } + if (cancelledPendingImageAttachmentsRef.current.has(pendingImage.id)) { + cancelledPendingImageAttachmentsRef.current.delete(pendingImage.id); + return; + } + if (payload.previewDataUrl) rememberPreviewUrl(payload.path, payload.previewDataUrl); + onAddAttachment({ path: payload.path, type: inferAttachmentType(payload.path, payload.mimeType) }); + dropPendingImageAttachment(pendingImage.id, { revokePreview: false }); } catch { + dropPendingImageAttachment(pendingImage.id, { revokePreview: true }); setAttachError("Unable to attach clipboard image."); } finally { fileAddInProgressRef.current = false; @@ -1213,14 +1382,14 @@ export function AgentChatComposer({ const addImageUrlAttachment = useCallback((url: string): boolean => { if (!canAttach) return false; - if (parallelChatMode && attachments.length >= PARALLEL_CHAT_MAX_ATTACHMENTS) { + if (parallelChatMode && attachmentSlotsUsed >= PARALLEL_CHAT_MAX_ATTACHMENTS) { setAttachError(`You can attach up to ${PARALLEL_CHAT_MAX_ATTACHMENTS} files for parallel launch.`); return false; } setAttachError(null); onAddAttachment({ path: url, type: "image-url", url }); return true; - }, [attachments.length, canAttach, onAddAttachment, parallelChatMode]); + }, [attachmentSlotsUsed, canAttach, onAddAttachment, parallelChatMode]); const addImageUrlFromTransfer = useCallback(( data: DataTransfer | React.ClipboardEvent["clipboardData"], @@ -2335,9 +2504,7 @@ export function AgentChatComposer({ event.preventDefault(); if (fallbackAlreadyAttached) return; clipboardImagePasteHandledRef.current += 1; - const dt = new DataTransfer(); - for (const file of collected) dt.items.add(file); - void addFileAttachments(dt.files); + void addFileAttachments(collected); }; const handleDragOver = (event: React.DragEvent) => { @@ -2434,6 +2601,9 @@ export function AgentChatComposer({ if (pendingInput?.blocking) { return; } + if (pendingImageAttachments.length > 0) { + return; + } if (parallelChatMode) { if (busy || parallelLaunchBusy) return; if (parallelModelSlots.length < 2) return; @@ -2471,7 +2641,7 @@ export function AgentChatComposer({ } if (busy || !modelId || (!draft.trim().length && !hasContextSelection && contextAttachmentCount === 0)) return; onSubmit(); - }, [appControlContextItems.length, attachments, builtInBrowserContextItems.length, busy, contextAttachmentCount, contextAttachments, cursorCloudAvailable, cursorCloudCanLaunch, cursorCloudLaunchModeOpen, draft, iosElementContextItems.length, macosVmContextItems.length, modelId, onDraftChange, onSubmit, onSubmitToCloud, pendingInput, parallelChatMode, parallelLaunchBusy, parallelModelSlots.length]); + }, [appControlContextItems.length, attachments, builtInBrowserContextItems.length, busy, contextAttachmentCount, contextAttachments, cursorCloudAvailable, cursorCloudCanLaunch, cursorCloudLaunchModeOpen, draft, iosElementContextItems.length, macosVmContextItems.length, modelId, onDraftChange, onSubmit, onSubmitToCloud, pendingImageAttachments.length, pendingInput, parallelChatMode, parallelLaunchBusy, parallelModelSlots.length]); const pendingQuestionCount = getPendingInputQuestionCount(pendingInput); const showPendingInputOptionsHint = hasPendingInputOptions(pendingInput); @@ -2538,16 +2708,19 @@ export function AgentChatComposer({ || hasMacosVmContext || contextAttachmentCount > 0 ); - const sendEnabled = !busy && !backgroundLaunchBusy && !parallelLaunchBusy && !composerInputLocked && (parallelReady || singleReady); + const hasPendingImageAttachments = pendingImageAttachments.length > 0; + const sendEnabled = !busy && !backgroundLaunchBusy && !parallelLaunchBusy && !composerInputLocked && !hasPendingImageAttachments && (parallelReady || singleReady); const backgroundSendEnabled = Boolean(onSubmitInBackground) && !busy && !backgroundLaunchBusy && !parallelLaunchBusy && !composerInputLocked + && !hasPendingImageAttachments && singleReady; function sendButtonTitle(): string { if (composerInputLocked) return composerInputLockMessage ?? "Resolve the pending request before sending."; + if (hasPendingImageAttachments) return "Finish attaching images"; if (parallelChatMode) { if (parallelModelSlots.length < 2) return "Add at least two models"; if (draft.trim().length === 0 && attachments.length === 0 && contextAttachmentCount === 0) return "Add a message or at least one attachment"; @@ -2701,7 +2874,7 @@ export function AgentChatComposer({ ) ) : undefined} trays={ - attachments.length || contextAttachmentCount || attachError || attachNotice || selectedIosContext || selectedAppControlContext || selectedBuiltInBrowserContext || selectedMacosVmContext ? ( + attachments.length || pendingImageAttachments.length || contextAttachmentCount || attachError || attachNotice || selectedIosContext || selectedAppControlContext || selectedBuiltInBrowserContext || selectedMacosVmContext ? (
{selectedMacosVmContext ? (
@@ -2953,7 +3126,7 @@ export function AgentChatComposer({ type="button" className="rounded px-1.5 py-0.5 text-[length:calc(var(--chat-font-size)*10/14)] text-sky-200/65 underline-offset-2 transition-colors hover:text-sky-100 hover:underline" onClick={() => { - onRemoveAttachment(attachNotice.undoPath); + handleRemoveAttachment(attachNotice.undoPath); setAttachNotice(null); }} > @@ -2972,9 +3145,12 @@ export function AgentChatComposer({
diff --git a/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.test.tsx b/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.test.tsx index d845d1f08..c87387c13 100644 --- a/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.test.tsx @@ -79,6 +79,42 @@ describe("ChatAttachmentTray", () => { expect(screen.getByRole("dialog", { name: "screenshot.png" })).toBeTruthy(); }); + it("renders seeded image previews without reading the file back", () => { + render( + , + ); + + expect(screen.getByAltText("pasted-image.png").getAttribute("src")).toBe("blob:ade-paste-preview"); + expect(getImageDataUrl).not.toHaveBeenCalled(); + }); + + it("renders pending image attachments with cancellable previews", () => { + const onRemovePendingImageAttachment = vi.fn(); + + render( + , + ); + + expect(screen.getByRole("status", { name: "Attaching clipboard.png" })).toBeTruthy(); + expect(screen.getByAltText("clipboard.png preview").getAttribute("src")).toBe("blob:ade-pending-preview"); + + fireEvent.click(screen.getByRole("button", { name: "Cancel clipboard.png" })); + expect(onRemovePendingImageAttachment).toHaveBeenCalledWith("pending-1"); + }); + it("copies and removes image attachments from the preview controls", async () => { const onRemove = vi.fn(); diff --git a/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx b/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx index 073271612..f71f96c6f 100644 --- a/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx @@ -14,6 +14,12 @@ function attachmentName(path: string): string { return segments.pop() || path; } +export type ChatAttachmentPendingImage = { + id: string; + name: string; + previewUrl?: string | null; +}; + function LinearIssueContextChip({ attachment, onRemove, @@ -84,13 +90,15 @@ function LinearIssueContextChip({ function ImageAttachmentPreview({ attachment, toneClassName, + initialPreviewUrl, onRemove, }: { attachment: AgentChatFileRef; toneClassName: string; + initialPreviewUrl?: string | null; onRemove?: (path: string) => void; }) { - const [dataUrl, setDataUrl] = useState(null); + const [dataUrl, setDataUrl] = useState(initialPreviewUrl ?? null); const [previewFailed, setPreviewFailed] = useState(false); const [expanded, setExpanded] = useState(false); const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle"); @@ -98,8 +106,13 @@ function ImageAttachmentPreview({ useEffect(() => { let cancelled = false; - setDataUrl(null); + setDataUrl(initialPreviewUrl ?? null); setPreviewFailed(false); + if (initialPreviewUrl) { + return () => { + cancelled = true; + }; + } if (!window.ade?.app?.getImageDataUrl) { setPreviewFailed(true); return; @@ -114,7 +127,7 @@ function ImageAttachmentPreview({ return () => { cancelled = true; }; - }, [attachment.path]); + }, [attachment.path, initialPreviewUrl]); useEffect(() => { if (copyState === "idle") return; @@ -212,6 +225,55 @@ function ImageAttachmentPreview({ ); } +function PendingImageAttachmentPreview({ + attachment, + toneClassName, + onRemove, +}: { + attachment: ChatAttachmentPendingImage; + toneClassName: string; + onRemove?: (id: string) => void; +}) { + return ( +
+ {attachment.previewUrl ? ( + {`${attachment.name} + ) : ( + + + + )} + + Saving + + {onRemove ? ( + + ) : null} +
+ ); +} + function ImageUrlAttachmentChip({ path, url, @@ -366,19 +428,25 @@ function ImageLightbox({ export function ChatAttachmentTray({ attachments, contextAttachments = [], + pendingImageAttachments = [], + imagePreviewUrls = {}, mode, onRemove, onRemoveContext, + onRemovePendingImageAttachment, className, }: { attachments: AgentChatFileRef[]; contextAttachments?: AgentChatContextAttachment[]; + pendingImageAttachments?: ChatAttachmentPendingImage[]; + imagePreviewUrls?: Record; mode: ChatSurfaceMode; onRemove?: (path: string) => void; onRemoveContext?: (key: string) => void; + onRemovePendingImageAttachment?: (id: string) => void; className?: string; }) { - if (!attachments.length && !contextAttachments.length) return null; + if (!attachments.length && !contextAttachments.length && !pendingImageAttachments.length) return null; let chipTone: string; switch (mode) { @@ -405,6 +473,14 @@ export function ChatAttachmentTray({ onRemove={onRemoveContext} /> ))} + {pendingImageAttachments.map((attachment) => ( + + ))} {attachments.map((attachment) => { if (attachment.type === "image-url") { const label = (() => { @@ -432,6 +508,7 @@ export function ChatAttachmentTray({ key={attachment.path} attachment={attachment} toneClassName={chipTone} + initialPreviewUrl={imagePreviewUrls[attachment.path]} onRemove={onRemove} /> ); diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index b2d38e809..8c5bfdb34 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -15,6 +15,7 @@ export const IPC = { appWriteClipboardText: "ade.app.writeClipboardText", appHasClipboardImage: "ade.app.hasClipboardImage", appReadClipboardImage: "ade.app.readClipboardImage", + appSaveClipboardImageAttachment: "ade.app.saveClipboardImageAttachment", appGetImageDataUrl: "ade.app.getImageDataUrl", appWriteClipboardImage: "ade.app.writeClipboardImage", appOpenPathInEditor: "ade.app.openPathInEditor", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e758f08ae..21b2ff4e5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -366,7 +366,7 @@ Related feature docs: [Chat](./features/chat/README.md), [Agents](./features/age `apps/desktop/src/shared/ipc.ts` defines the single `IPC` const with ~550 named channel strings in a `ade..` namespace: ``` -ade.app.* # app lifecycle, clipboard text and image (writeClipboardText, writeClipboardImage), paths, image data-URL preview (getImageDataUrl) +ade.app.* # app lifecycle, clipboard text and image (writeClipboardText, writeClipboardImage, saveClipboardImageAttachment), paths, image data-URL preview (getImageDataUrl) ade.project.* # project open/close/switch/state, in-app directory browser (browseDirectories, getDetail), favicon resolver (resolveIcon) ade.onboarding.* ade.lanes.* # lane list/create/delete/stack/template/env/port/proxy/rebase diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index a65a0f428..ae31de660 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -35,7 +35,7 @@ machinery layered on top. | `apps/desktop/src/renderer/components/chat/RewindFilesConfirmDialog.tsx`, `rewindFilesPreview.ts` | Claude file-rewind confirmation surface. `rewindFilesPreview.ts` maps the selected user message to turn diff summaries and per-file SHA ranges; the dialog lists every restored file, expands rows into `AdeDiffViewer`, and confirms the SDK `rewindFiles` call without using browser-native confirm UI. | | `apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx` | Renderer panel for the in-app browser. Renders the address bar, tabs strip, navigation controls, an inspect/select toolbar, and a `BuiltInBrowserStatus`-derived empty/error state, then asks the main process to position the underlying `WebContentsView` over the panel's bounding rect through `ade.builtInBrowser.setBounds`. Mounted by `WorkSidebar` under the `browser` tab and (indirectly) by any renderer code that calls `openUrlInAdeBrowser()` — the helper opens the sidebar Browser tab and dispatches the URL into a fresh tab. Selections committed through inspect-mode hit-testing fan out via the `onAddContext` callback as `BuiltInBrowserContextItem` payloads. | | `apps/desktop/src/renderer/lib/openExternal.ts` | Renderer-side router for outbound URLs. Defines the `ADE_OPEN_BUILT_IN_BROWSER_EVENT` window event plus `openUrlInAdeBrowser(url)` and `openExternalUrl(url)`. `openUrlInAdeBrowser` dispatches the event (so any open `WorkSidebar` can flip to its Browser tab), then calls `window.ade.builtInBrowser.navigate({ url, newTab: true })`. Anything that is not a normal `http`/`https`/`about:blank` URL falls through to `window.ade.app.openExternal` (system browser). All in-renderer URL clicks (markdown links, lane-runtime open buttons, etc.) go through this helper so the user stays inside ADE. | -| `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` | Composer UI: single-session prompt entry, attachments, model/permission controls, slash commands, pending input answering, and parallel launch slot configuration. | +| `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` | Composer UI: single-session prompt entry, attachments, model/permission controls, slash commands, pending input answering, and parallel launch slot configuration. Pasted/dropped image attachments show pending thumbnails while temp files save, and native Electron clipboard images use `ade.app.saveClipboardImageAttachment` before falling back to `ade.agentChat.saveTempAttachment`. | | `apps/desktop/src/renderer/components/chat/ChatCursorCloudPanel.tsx` | Side panel for Cursor Cloud (background agents): lists existing cloud agents and runs for the lane, lets the user open an existing cloud chat in ADE, archive/unarchive/cancel, and stream run output. Backed by `ade.ai.cursorCloud.*` IPC. | | `apps/desktop/src/renderer/components/chat/CursorCloudInlineLaunch.tsx` | Inline composer affordance for "Send to Cursor Cloud": picks repo + branch + Cursor Cloud-eligible model, optionally targeting a detected PR, and dispatches the prompt to a fresh cloud agent. | | `apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx` | Shell that wraps every chat surface (desktop pane, mobile lane, CTO mission) with a unified header/footer slot and `--chat-accent` CSS variable. Supports a `layoutVariant="mobile"` mode that the iOS companion mirrors. | @@ -277,7 +277,7 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. | `ade.agentChat.claudeOutputStyles.list` / `.set` | invoke | Claude-only: list and select discovered output styles (user + project + plugin roots). Backs `/output-style`. | | `ade.agentChat.claudeSessions.list` / `.info` / `.messages` | invoke | Claude-only: enumerate SDK sessions, fetch session info, and stream messages for `forkSession` handoff and resume flows. | | `ade.agentChat.fileSearch` | invoke | Debounced attachment picker backend. | -| `ade.agentChat.saveTempAttachment` | invoke | Write pasted/dropped image bytes to a temp file (10 MB cap). | +| `ade.agentChat.saveTempAttachment` | invoke | Write pasted/dropped image bytes to a temp file (10 MB cap). Native clipboard image paste prefers `ade.app.saveClipboardImageAttachment` so Electron can save the clipboard PNG directly and return a compact preview. | | `ade.agentChat.listSubagents` | invoke | Claude subagent snapshot list. Snapshots are re-keyed on `agentId + parentToolUseId` (not just `taskId`) so multiple subagents spawned from the same parent tool call don't collide, and the renderer panel separates them into three tabs: Subagents, Teammates, and Background. | | `ade.agentChat.models` | invoke | `{ provider, activateRuntime? }`. For OpenCode `activateRuntime: true` is required to *launch* a probe server; otherwise the main process only returns the cached inventory (via `peekOpenCodeInventoryCache`) and an empty list until a real probe has been run. The renderer cache (`aiDiscoveryCache.ts`) keys on `(projectRoot, provider, activateRuntime)` so passive and active reads don't collide. | | `ade.agentChat.getSessionCapabilities` | invoke | Discover supported subagent/review features. | diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index f02081ca9..535c7899a 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -16,7 +16,7 @@ stream plus session metadata. | `AgentChatComposer.tsx` | Text input, attachments, model selector, permission controls, slash commands, pending-input answering, and parallel model-slot controls. | | `ChatSurfaceShell.tsx` | Floating chat header, body, footer layout. Backdrop-blur glass-morphism styling. | | `ChatComposerShell.tsx` | Input container chrome reused by the composer. | -| `ChatAttachmentTray.tsx` | Inline file/image attachment tray inside the composer. Image attachments render an inline thumbnail (loaded through `window.ade.app.getImageDataUrl`), open a full-size lightbox on click, and expose a copy-to-clipboard button that ships the image bytes via `window.ade.app.writeClipboardImage` so the user can paste them into another app. Non-image attachments fall back to the file glyph. | +| `ChatAttachmentTray.tsx` | Inline file/image attachment tray inside the composer. Image attachments render an inline thumbnail, open a full-size lightbox on click, and expose a copy-to-clipboard button that ships the image bytes via `window.ade.app.writeClipboardImage` so the user can paste them into another app. Pasted images can pass a seeded preview URL from the composer while the temp file is being saved; tray-only image refs fall back to `window.ade.app.getImageDataUrl`. Non-image attachments fall back to the file glyph. | | `ChatCommandMenu.tsx` | Popover for slash commands and `@`-prefixed file search. | | `ChatTasksPanel.tsx` | Todo list rendered from `todo_update` events. | | `ChatFileChangesPanel.tsx` | Turn-level file change summary with lazy diff expansion. | @@ -79,9 +79,14 @@ and a footer that contains the composer. enclosing `AgentChatPane` reports `isTileActive: true` (for packed grid tiles) or any equivalent active state — typing in the grid immediately targets the focused tile's composer. -- **Attachments** via drag-drop, paste, and an inline picker. Images are - written through `ade.agentChat.saveTempAttachment` (10 MB cap; MIME - validated per provider). +- **Attachments** via drag-drop, paste, and an inline picker. Pasted and + dropped image files show a pending thumbnail while ADE writes the + temp attachment. Electron clipboard images use + `ade.app.saveClipboardImageAttachment` when available so the main + process can save the PNG and return a small preview without sending + the full base64 payload through the renderer; the legacy + `ade.agentChat.saveTempAttachment` path remains as the fallback. + Temp images keep the 10 MB cap and provider-specific MIME validation. - **Linear issue context.** A Linear-branded chip in the composer opens `LinearIssueContextDialog`, which mounts the shared `LinearIssueBrowser` so the user can attach a Linear issue as @@ -220,8 +225,13 @@ and a footer that contains the composer. ### Attachment handling -- Pasted and dropped images are written to a temp location via - `ade.agentChat.saveTempAttachment` (10 MB cap). +- Pasted and dropped images are written to a temp location. File-backed + renderer payloads use `ade.agentChat.saveTempAttachment`; native + clipboard images prefer `ade.app.saveClipboardImageAttachment`, which + reads the Electron clipboard, writes the PNG beside other chat + attachments, and returns a downsized preview data URL. While either + save is in flight, the composer disables send and shows a cancellable + pending thumbnail in `ChatAttachmentTray`. - iOS Simulator selections add `IosElementContextItem` chips to the composer instead of plain attachments. Each chip is a `data-ios-context` node in a contenteditable rich-input variant; submission serialises From c78517c5c27dd751618b593005ed17989a4d2d7d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 14 May 2026 15:51:27 -0400 Subject: [PATCH 2/2] ship: iteration 1 - address pasted image preview review --- .../components/chat/AgentChatComposer.tsx | 83 ++++++++++--------- 1 file changed, 45 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index b965cc39a..972af9457 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1026,6 +1026,8 @@ export function AgentChatComposer({ const objectPreviewUrlsRef = useRef>(new Set()); const cancelledPendingImageAttachmentsRef = useRef>(new Set()); const pendingImageAttachmentSequenceRef = useRef(0); + const previousImagePreviewUrlsRef = useRef>({}); + const previousPendingImageAttachmentsRef = useRef([]); const previousAttachmentPathsRef = useRef>(new Set()); const clipboardImagePasteHandledRef = useRef(0); const clipboardImagePasteFallbackTimerRef = useRef(null); @@ -1099,23 +1101,14 @@ export function AgentChatComposer({ clipboardImagePasteFallbackTimerRef.current = null; } clipboardImagePasteFallbackAttachedRef.current = false; + for (const attachment of pendingImageAttachments) { + cancelledPendingImageAttachmentsRef.current.add(attachment.id); + } setPendingImageAttachments((current) => { if (!current.length) return current; - for (const attachment of current) { - cancelledPendingImageAttachmentsRef.current.add(attachment.id); - if ( - attachment.previewUrl - && objectPreviewUrlsRef.current.has(attachment.previewUrl) - && typeof URL !== "undefined" - && typeof URL.revokeObjectURL === "function" - ) { - URL.revokeObjectURL(attachment.previewUrl); - objectPreviewUrlsRef.current.delete(attachment.previewUrl); - } - } return []; }); - }, [composerInputLocked]); + }, [composerInputLocked, pendingImageAttachments]); useLayoutEffect(() => { resizeTextarea(); }, [draft, resizeTextarea]); @@ -1126,7 +1119,7 @@ export function AgentChatComposer({ [sdkSlashCommands, onClearEvents], ); - const releasePreviewUrl = useCallback((url: string | null | undefined) => { + const revokePreviewUrl = useCallback((url: string | null | undefined) => { if (!url || !objectPreviewUrlsRef.current.has(url)) return; if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") { URL.revokeObjectURL(url); @@ -1139,21 +1132,18 @@ export function AgentChatComposer({ setImagePreviewUrls((current) => { const previous = current[path]; if (previous === url) return current; - if (previous) releasePreviewUrl(previous); return { ...current, [path]: url }; }); - }, [releasePreviewUrl]); + }, []); const clearPreviewForPath = useCallback((path: string) => { setImagePreviewUrls((current) => { - const previous = current[path]; - if (!previous) return current; - releasePreviewUrl(previous); + if (!current[path]) return current; const next = { ...current }; delete next[path]; return next; }); - }, [releasePreviewUrl]); + }, []); const createObjectPreviewUrl = useCallback((file: File): string | null => { if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") return null; @@ -1178,20 +1168,16 @@ export function AgentChatComposer({ const dropPendingImageAttachment = useCallback(( id: string, - options: { markCancelled?: boolean; revokePreview?: boolean } = {}, + options: { markCancelled?: boolean } = {}, ) => { if (options.markCancelled) cancelledPendingImageAttachmentsRef.current.add(id); setPendingImageAttachments((current) => { - const pending = current.find((entry) => entry.id === id); - if (pending?.previewUrl && options.revokePreview !== false) { - releasePreviewUrl(pending.previewUrl); - } return current.filter((entry) => entry.id !== id); }); - }, [releasePreviewUrl]); + }, []); const removePendingImageAttachment = useCallback((id: string) => { - dropPendingImageAttachment(id, { markCancelled: true, revokePreview: true }); + dropPendingImageAttachment(id, { markCancelled: true }); }, [dropPendingImageAttachment]); const handleRemoveAttachment = useCallback((path: string) => { @@ -1199,21 +1185,42 @@ export function AgentChatComposer({ onRemoveAttachment(path); }, [clearPreviewForPath, onRemoveAttachment]); + useEffect(() => { + const previous = previousImagePreviewUrlsRef.current; + for (const [path, previousUrl] of Object.entries(previous)) { + if (imagePreviewUrls[path] !== previousUrl) { + revokePreviewUrl(previousUrl); + } + } + previousImagePreviewUrlsRef.current = imagePreviewUrls; + }, [imagePreviewUrls, revokePreviewUrl]); + + useEffect(() => { + const currentPendingIds = new Set(pendingImageAttachments.map((attachment) => attachment.id)); + const storedPreviewUrls = new Set(Object.values(imagePreviewUrls)); + for (const attachment of previousPendingImageAttachmentsRef.current) { + const pendingPreviewUrl = attachment.previewUrl ?? null; + if (!currentPendingIds.has(attachment.id) && (!pendingPreviewUrl || !storedPreviewUrls.has(pendingPreviewUrl))) { + revokePreviewUrl(attachment.previewUrl); + } + } + previousPendingImageAttachmentsRef.current = pendingImageAttachments; + }, [imagePreviewUrls, pendingImageAttachments, revokePreviewUrl]); + useEffect(() => { const currentPaths = new Set(attachments.map((attachment) => attachment.path)); const previousPaths = previousAttachmentPathsRef.current; setImagePreviewUrls((current) => { let next = current; - for (const [path, url] of Object.entries(current)) { + for (const path of Object.keys(current)) { if (!previousPaths.has(path) || currentPaths.has(path)) continue; - releasePreviewUrl(url); if (next === current) next = { ...current }; delete next[path]; } return next; }); previousAttachmentPathsRef.current = currentPaths; - }, [attachments, releasePreviewUrl]); + }, [attachments]); /* ── Attachment picker effects ── */ useEffect(() => { @@ -1319,17 +1326,17 @@ export function AgentChatComposer({ continue; } const attachmentType = inferAttachmentType(tempPath, file.type); - const storedPreview = Boolean(pendingImage?.previewUrl && attachmentType === "image"); - if (pendingImage?.previewUrl && storedPreview) { - rememberPreviewUrl(tempPath, pendingImage.previewUrl); + const pendingPreviewUrl = pendingImage?.previewUrl ?? null; + if (attachmentType === "image" && pendingPreviewUrl) { + rememberPreviewUrl(tempPath, pendingPreviewUrl); } onAddAttachment({ path: tempPath, type: attachmentType }); if (pendingImage) { - dropPendingImageAttachment(pendingImage.id, { revokePreview: !storedPreview }); + dropPendingImageAttachment(pendingImage.id); } addedInBatch += 1; } catch { - if (pendingImage) dropPendingImageAttachment(pendingImage.id, { revokePreview: true }); + if (pendingImage) dropPendingImageAttachment(pendingImage.id); setAttachError(`Unable to attach "${file.name || "clipboard"}".`); } } @@ -1362,7 +1369,7 @@ export function AgentChatComposer({ }; })(); if (!payload) { - dropPendingImageAttachment(pendingImage.id, { revokePreview: true }); + dropPendingImageAttachment(pendingImage.id); return; } if (cancelledPendingImageAttachmentsRef.current.has(pendingImage.id)) { @@ -1371,9 +1378,9 @@ export function AgentChatComposer({ } if (payload.previewDataUrl) rememberPreviewUrl(payload.path, payload.previewDataUrl); onAddAttachment({ path: payload.path, type: inferAttachmentType(payload.path, payload.mimeType) }); - dropPendingImageAttachment(pendingImage.id, { revokePreview: false }); + dropPendingImageAttachment(pendingImage.id); } catch { - dropPendingImageAttachment(pendingImage.id, { revokePreview: true }); + dropPendingImageAttachment(pendingImage.id); setAttachError("Unable to attach clipboard image."); } finally { fileAddInProgressRef.current = false;