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),