fix(ui): task-chat summary counts unique tool calls; composer shows image paste thumbnails

buildTurnSummary now dedupes tool_call entries by toolUseId (with the
parser's tool-${i} fallback) so the folded 'Worked · N tools' line matches
the expanded row count. Composer attachments carry an object-URL preview
created at attach time, rendered as a size-10 thumbnail in the chip and
revoked on clear/unmount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Fable 5 committed 2026-08-07 16:56:10 -07:00
1 parent 81ce69a790
commit a14d8992fa
4 files changed
+135 -6

No files matched your search

@@ -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(<TaskChatComposer onAdd={vi.fn()} workMode="standard" onAttachImage={onAttachImage} />);
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<HTMLImageElement>(
'[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(<TaskChatComposer onAdd={vi.fn()} workMode="standard" onAttachImage={onAttachImage} />);
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(<TaskChatComposer onAdd={vi.fn()} workMode="standard" />);
expect(container.querySelector('[data-testid="task-chat-composer-assignee"]')).toBeNull();
@@ -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<HTMLInputElement | null>(null);
const previewUrlsRef = useRef<Set<string>>(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 ? (
<img
src={attachment.previewUrl}
alt=""
className="size-10 shrink-0 rounded object-cover"
/>
) : null}
{attachment.status === "uploading" ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin" aria-hidden />
) : attachment.status === "error" ? (
@@ -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([
@@ -306,12 +306,15 @@ export function buildTurnSummary(
entries: readonly TranscriptEntry[],
opts: { durationMs?: number; failed?: boolean } = {},
): TaskChatTurnItem["summary"] {
let toolCount = 0;
const toolIds = new Set<string>();
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),