diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 3ae002fa95454853119bf6b37b3331665a9df680..1df6d6780e1926ca54d0b377b7cdec3d5be5ab31 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -962,12 +962,14 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( : row.getFullDetail() : null; const outputImages = expanded && fetchedItem ? turnItemOutputImages(fetchedItem) : []; + const viewedImagePath = workEntryViewedImagePath(row.workEntry); const fetchedOutput = !expanded ? null : shownItem.type === "file_search" || shownItem.type === "web_search" ? turnItemOutputText(shownItem) : fetchedItem - ? (turnItemOutputText(fetchedItem) ?? (outputImages.length > 0 ? null : "No output.")) + ? (turnItemOutputText(fetchedItem) ?? + (outputImages.length > 0 || viewedImagePath ? null : "No output.")) : fetchedDetail.error ? `Couldn't load output: ${fetchedDetail.error}` : row.fetchesDetail @@ -975,7 +977,6 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( ? "Output is no longer available." : "Loading output…" : null; - const viewedImagePath = workEntryViewedImagePath(row.workEntry); const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); const previewText = workEntryRowLabel(row.workEntry); const answerPreview = row.workEntry.questionAnswer diff --git a/packages/client-runtime/src/work-log/itemDetail.test.ts b/packages/client-runtime/src/work-log/itemDetail.test.ts index 3570974a7df1dd52240f2c91961e45230039695c..b7a6360f5c47c9ab3fdc18ab56fd2f1d97e5ed60 100644 --- a/packages/client-runtime/src/work-log/itemDetail.test.ts +++ b/packages/client-runtime/src/work-log/itemDetail.test.ts @@ -52,4 +52,42 @@ describe("tool output images", () => { expect(turnItemOutputImages({ ...screenshot, output })).toEqual([]); expect(turnItemOutputText({ ...screenshot, output })).toBe("[image]"); }); + + it("omits Claude Read's image placeholder when the file has a preview", () => { + const output = { + type: "image", + file: { filePath: "/tmp/blk.png", type: "image/png", sizeBytes: 1024 }, + }; + const item = { ...screenshot, toolName: "Read", viewedImagePath: "/tmp/blk.png", output }; + + expect(turnItemOutputImages(item)).toEqual([]); + expect(turnItemOutputText(item)).toBeNull(); + expect(turnItemOutputText({ ...item, viewedImagePath: undefined })).toBe("[image]"); + }); + + it("keeps accompanying text and literal image markers when the file has a preview", () => { + const output = { + content: [ + { type: "text", text: "Read /tmp/blk.png. Literal marker: [image]" }, + screenshot.output.content[1], + ], + }; + expect(turnItemOutputText({ ...screenshot, viewedImagePath: "/tmp/blk.png", output })).toBe( + "Read /tmp/blk.png. Literal marker: [image]", + ); + }); + + it("keeps placeholders for unserved images in a list despite a file preview", () => { + const blocks = [ + { type: "text", text: "Read /tmp/blk.png." }, + { type: "image", file: { type: "image/png", sizeBytes: 1024 } }, + screenshot.output.content[1], + { type: "image", mimeType: "image/svg+xml" }, + ]; + for (const output of [blocks, { content: blocks }, { content: blocks, isError: false }]) { + const item = { ...screenshot, viewedImagePath: "/tmp/blk.png", output }; + expect(turnItemOutputText(item)).toBe("Read /tmp/blk.png.\n[image]\n[image]"); + expect(turnItemOutputImages(item)).toHaveLength(1); + } + }); }); diff --git a/packages/client-runtime/src/work-log/itemDetail.ts b/packages/client-runtime/src/work-log/itemDetail.ts index cad1bab22fbd4fbd3170fb91d27db5bb5e8cd7c0..548c96dd1c33ab9ea196b3504e7ea930534a9fb9 100644 --- a/packages/client-runtime/src/work-log/itemDetail.ts +++ b/packages/client-runtime/src/work-log/itemDetail.ts @@ -65,8 +65,10 @@ function prettyJsonText(text: string): string { } /** Formats a tool input or output for display: text blocks as text, the rest as JSON. */ -function formatToolValue(value: unknown): string | null { +function formatToolValue(value: unknown, hasImagePreview = false): string | null { if (value === undefined || value === null) return null; + // Claude Read returns one structured image; lists may contain other images. + if (hasImagePreview && isRecord(value) && value.type === "image") return null; const text = textFromBlocks(value, 0); if (text !== null) return text.trim() ? prettyJsonText(text) : null; let json: string | undefined; @@ -171,7 +173,9 @@ export function turnItemOutputText(item: OrchestrationV2TurnItem): string | null case "command_execution": return item.output?.trim() ? commandOutputText(item.output) || null : null; case "dynamic_tool": - return item.outputOmitted === true ? null : formatToolValue(item.output); + return item.outputOmitted === true + ? null + : formatToolValue(item.output, item.viewedImagePath !== undefined); case "file_search": return item.results?.length ? item.results