diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx index 90b57b909f2..2860cc9ba1a 100644 --- a/ui/src/components/task-chat/TaskChatComposer.test.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx @@ -13,6 +13,9 @@ beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); + // jsdom has no object-URL support; the composer uses it for image previews. + URL.createObjectURL = vi.fn(() => "blob:mock-preview"); + URL.revokeObjectURL = vi.fn(); }); afterEach(() => { @@ -183,6 +186,56 @@ describe("TaskChatComposer", () => { expect(chips?.textContent).toContain("shot.png"); }); + it("shows an object-URL thumbnail for a pasted image attachment", async () => { + const onAttachImage = vi.fn().mockResolvedValue({ + contentPath: "/attachments/shot.png", + originalFilename: "shot.png", + }); + render(); + + const file = new File(["png-bytes"], "shot.png", { type: "image/png" }); + const paste = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(paste, "clipboardData", { value: { files: [file] } }); + flushSync(() => { + input().dispatchEvent(paste); + }); + await flushAsync(); + + expect(URL.createObjectURL).toHaveBeenCalledWith(file); + const thumb = container.querySelector( + '[data-testid="task-chat-composer-attachments"] img', + ); + expect(thumb?.src).toBe("blob:mock-preview"); + expect(thumb?.alt).toBe(""); + }); + + it("renders no thumbnail for non-image attachments and revokes previews on unmount", async () => { + const onAttachImage = vi.fn().mockResolvedValue({ + contentPath: "/attachments/notes.txt", + originalFilename: "notes.txt", + }); + render(); + + const image = new File(["png-bytes"], "shot.png", { type: "image/png" }); + const text = new File(["plain"], "notes.txt", { type: "text/plain" }); + const paste = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(paste, "clipboardData", { value: { files: [image, text] } }); + flushSync(() => { + input().dispatchEvent(paste); + }); + await flushAsync(); + + // Only the image chip carries a thumbnail; the text chip stays text-only. + const chips = container.querySelector('[data-testid="task-chat-composer-attachments"]')!; + expect(chips.textContent).toContain("notes.txt"); + expect(chips.querySelectorAll("img")).toHaveLength(1); + expect(URL.createObjectURL).toHaveBeenCalledTimes(1); + + flushSync(() => root!.unmount()); + root = null; + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-preview"); + }); + it("shows the assignee combobox only when reassign is enabled, with the current label", () => { render(); expect(container.querySelector('[data-testid="task-chat-composer-assignee"]')).toBeNull(); diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx index 79b8acf47ff..58c1c2a1d72 100644 --- a/ui/src/components/task-chat/TaskChatComposer.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.tsx @@ -1,4 +1,5 @@ import { + useEffect, useRef, useState, type ChangeEvent, @@ -75,6 +76,9 @@ type ComposerAttachment = { name: string; status: "uploading" | "attached" | "error"; error?: string; + /** Object URL for an immediate thumbnail while (and after) uploading. */ + previewUrl?: string; + isImage?: boolean; }; /** Local duplicate of IssueChatThread's module-private helper (same rule). */ @@ -129,6 +133,23 @@ export function TaskChatComposer({ const [isDragOver, setIsDragOver] = useState(false); const dragDepthRef = useRef(0); const fileInputRef = useRef(null); + const previewUrlsRef = useRef>(new Set()); + + // Object URLs created for image previews must be revoked or they leak the + // underlying blobs for the page's lifetime. + useEffect(() => { + const urls = previewUrlsRef.current; + return () => { + for (const url of urls) URL.revokeObjectURL(url); + urls.clear(); + }; + }, []); + + function releasePreview(url: string | undefined) { + if (!url) return; + URL.revokeObjectURL(url); + previewUrlsRef.current.delete(url); + } const modeMeta = workModeMetaFor(pendingMode); const canAcceptFiles = Boolean(onAttachImage || onImageUpload); @@ -147,7 +168,15 @@ export function TaskChatComposer({ async function attachFile(file: File) { const id = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2)}`; - setAttachments((prev) => [...prev, { id, name: file.name, status: "uploading" }]); + const isImage = file.type.startsWith("image/"); + // Create the preview up front so the thumbnail appears while uploading. + const previewUrl = + isImage && typeof URL.createObjectURL === "function" ? URL.createObjectURL(file) : undefined; + if (previewUrl) previewUrlsRef.current.add(previewUrl); + setAttachments((prev) => [ + ...prev, + { id, name: file.name, status: "uploading", previewUrl, isImage: isImage || undefined }, + ]); try { if (onAttachImage) { const attachment = await onAttachImage(file); @@ -248,7 +277,10 @@ export function TaskChatComposer({ await onWorkModeChange(pendingMode); } await onAdd(trimmed, reopen, reassignment); - setAttachments([]); + setAttachments((prev) => { + for (const item of prev) releasePreview(item.previewUrl); + return []; + }); setPendingAssignee(null); } catch { setBody(trimmed); // restore on failure @@ -304,6 +336,13 @@ export function TaskChatComposer({ : "border-border bg-muted/30 text-muted-foreground", )} > + {attachment.isImage && attachment.previewUrl ? ( + + ) : null} {attachment.status === "uploading" ? ( ) : attachment.status === "error" ? ( diff --git a/ui/src/components/task-chat/transcript-adapter.test.ts b/ui/src/components/task-chat/transcript-adapter.test.ts index ff7ae53bd72..8a64d06d52b 100644 --- a/ui/src/components/task-chat/transcript-adapter.test.ts +++ b/ui/src/components/task-chat/transcript-adapter.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { TranscriptEntry } from "@/adapters"; import { + buildTurnSummary, deriveRunStatusLabel, toolDisplayName, transcriptToTaskChatItems, @@ -149,6 +150,39 @@ describe("transcriptToTaskChatItems tool_call updates", () => { }); }); +describe("buildTurnSummary tool counting", () => { + function statusEntry(toolUseId: string | undefined, status: string): TranscriptEntry { + return { + kind: "tool_call", + ts: TS, + name: "Bash", + toolUseId, + input: { text: `tool call (${status})`, status }, + } as TranscriptEntry; + } + + it("counts unique tool calls, not per-status transcript entries", () => { + // 4 real calls × 4 status changes each = 16 entries; the summary must + // match the 4 rows the expanded list renders. + const entries = ["tc-1", "tc-2", "tc-3", "tc-4"].flatMap((id) => + ["pending", "in_progress", "in_progress", "completed"].map((status) => + statusEntry(id, status), + ), + ); + expect(entries).toHaveLength(16); + expect(buildTurnSummary(entries).toolCount).toBe(4); + }); + + it("counts id-less legacy entries once each", () => { + const entries = [ + statusEntry(undefined, "completed"), + statusEntry(undefined, "completed"), + toolCall("Read"), + ]; + expect(buildTurnSummary(entries).toolCount).toBe(3); + }); +}); + describe("deriveRunStatusLabel with generic tail updates", () => { it("recovers the real tool name from the call's initial entry", () => { const status = deriveRunStatusLabel([ diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index eef8f0d3741..b75e00e4fa5 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -306,12 +306,15 @@ export function buildTurnSummary( entries: readonly TranscriptEntry[], opts: { durationMs?: number; failed?: boolean } = {}, ): TaskChatTurnItem["summary"] { - let toolCount = 0; + const toolIds = new Set(); let added = 0; let removed = 0; let tokens = 0; - for (const entry of entries) { - if (entry.kind === "tool_call") toolCount += 1; + for (const [i, entry] of entries.entries()) { + // Each status change of a call logs its own tool_call entry sharing the + // toolUseId; count unique calls so the folded summary matches the rows the + // expanded list renders (same `tool-${i}` fallback as the parser above). + if (entry.kind === "tool_call") toolIds.add(entry.toolUseId || `tool-${i}`); else if (entry.kind === "diff") { if (entry.changeType === "add") added += 1; else if (entry.changeType === "remove") removed += 1; @@ -327,7 +330,7 @@ export function buildTurnSummary( } return { durationLabel: durationMs != null ? formatDurationLabel(durationMs) : undefined, - toolCount, + toolCount: toolIds.size, added, removed, tokensLabel: formatTokensLabel(tokens),