mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
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:
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),
|
||||
|
||||
Reference in new issue
Block a user