diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index 1eef08e23..c6cd0813b 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -3061,6 +3061,7 @@ export const ContentPartsRenderer = memo(function ContentPartsRenderer({ return ( ` */ export const GeneratedImagesBlock = memo(function GeneratedImagesBlock({ + label, revisedPrompt, image, status, @@ -101,7 +108,7 @@ export const GeneratedImagesBlock = memo(function GeneratedImagesBlock({ >
- {t("imageGeneration")} + {label?.trim() || t("imageGeneration")}
diff --git a/src/lib/adapters/ai-elements-adapter.test.ts b/src/lib/adapters/ai-elements-adapter.test.ts index bc16c3c4a..27fae98c5 100644 --- a/src/lib/adapters/ai-elements-adapter.test.ts +++ b/src/lib/adapters/ai-elements-adapter.test.ts @@ -1198,6 +1198,7 @@ describe("adaptMessageTurn — image tool results", () => { expect(part.image?.data).toBe("QUJD") expect(part.image?.mime_type).toBe("image/png") expect(part.revisedPrompt).toBeNull() + expect(part.label).toBe("Clean V1") }) it("emits one generated-image part per image (multi-page PDF read)", () => { @@ -1233,6 +1234,45 @@ describe("adaptMessageTurn — image tool results", () => { "generated-image", "generated-image", ]) + expect( + adapted.content + .filter((p) => p.type === "generated-image") + .every((p) => p.type === "generated-image" && p.label === "Doc") + ).toBe(true) + }) + + it("names a fetched page from its URL, not Image generation", () => { + const adapted = adaptMessageTurn( + { + id: "fetch-page", + role: "assistant", + timestamp: "2026-06-02T00:00:00.000Z", + blocks: [ + { + type: "tool_use", + tool_use_id: "toolu_3", + tool_name: "WebFetch", + input_preview: JSON.stringify({ + url: "https://example.com/docs/getting-started", + }), + }, + { + type: "tool_result", + tool_use_id: "toolu_3", + output_preview: null, + is_error: false, + images: [{ data: "UAGE3", mime_type: "image/png" }], + }, + ], + }, + msgText, + false + ) + const part = adapted.content[0] + if (part.type !== "generated-image") { + throw new Error("expected a generated-image part") + } + expect(part.label).toBe("Getting Started") }) it("leaves a normal text Read result as a tool card (no regression)", () => { diff --git a/src/lib/adapters/ai-elements-adapter.ts b/src/lib/adapters/ai-elements-adapter.ts index 3aa6064b0..453b24ac2 100644 --- a/src/lib/adapters/ai-elements-adapter.ts +++ b/src/lib/adapters/ai-elements-adapter.ts @@ -28,6 +28,7 @@ import { unescapeReferenceLabel, unwrapReferenceDestination, } from "@/lib/reference-link" +import { imageCardLabel } from "@/lib/image-tool-label" /** * Adapted content part types for AI SDK Elements components @@ -92,6 +93,8 @@ export type AdaptedGeneratedImagePart = { /** `null` while the agent has emitted the ToolCall but no image yet. */ image: UserImageDisplay | null status: ToolCallStatus | null + /** Unset for Codex image generation; otherwise the tool or page name. */ + label?: string | null } export type AdaptedGoalRunPart = { @@ -1073,6 +1076,7 @@ function adaptContentBlock( revisedPrompt: block.revised_prompt ?? null, image: display, status: block.status ?? null, + label: block.label ?? null, } } @@ -1116,11 +1120,23 @@ function deriveImageNameFromImageData(img: { * through to the normal tool-card path. Images missing `data`/`mime_type` are * skipped; if that empties the list, `null` is returned too. */ -function adaptImageToolResultParts(result: { - images?: ImageData[] | null -}): AdaptedGeneratedImagePart[] | null { +function adaptImageToolResultParts( + result: { + images?: ImageData[] | null + }, + ctx?: { + toolName?: string | null + input?: string | null + title?: string | null + } +): AdaptedGeneratedImagePart[] | null { const images = result.images if (!images || images.length === 0) return null + const label = imageCardLabel({ + title: ctx?.title, + toolName: ctx?.toolName, + input: ctx?.input, + }) const parts: AdaptedGeneratedImagePart[] = [] for (const img of images) { if (!img.data || !img.mime_type) continue @@ -1137,6 +1153,7 @@ function adaptImageToolResultParts(result: { // Historical replay always carries a present image, so status is // irrelevant to the renderer; `null` is treated as success. status: null, + label, }) } return parts.length > 0 ? parts : null @@ -1817,7 +1834,10 @@ export function adaptMessageTurn( // mid-stream we keep the spinner via the normal tool-call path. const imageParts = isToolStillRunning ? null - : adaptImageToolResultParts(matchedResult) + : adaptImageToolResultParts(matchedResult, { + toolName: block.tool_name, + input: block.input_preview, + }) if (imageParts) { adaptedContent.push(...imageParts) continue @@ -1854,7 +1874,10 @@ export function adaptMessageTurn( positionMatchedIndices.add(index + 1) // Same image-result handling as the id-matched branch above: a Read // returning image bytes renders as image card(s) in-position. - const imageParts = adaptImageToolResultParts(positionalResult) + const imageParts = adaptImageToolResultParts(positionalResult, { + toolName: block.tool_name, + input: block.input_preview, + }) if (imageParts) { adaptedContent.push(...imageParts) continue diff --git a/src/lib/image-tool-label.test.ts b/src/lib/image-tool-label.test.ts new file mode 100644 index 000000000..b13520300 --- /dev/null +++ b/src/lib/image-tool-label.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest" + +import { + imageCardLabel, + isImageGenerationTitle, + pathFromToolInput, +} from "./image-tool-label" + +describe("isImageGenerationTitle", () => { + it("matches the hardcoded codex-acp title only", () => { + expect(isImageGenerationTitle("Image generation")).toBe(true) + expect(isImageGenerationTitle(" image generation ")).toBe(true) + expect(isImageGenerationTitle("Getting Started")).toBe(false) + expect(isImageGenerationTitle("Read")).toBe(false) + expect(isImageGenerationTitle("")).toBe(false) + expect(isImageGenerationTitle(null)).toBe(false) + }) +}) + +describe("pathFromToolInput", () => { + it("reads common path and url fields", () => { + expect( + pathFromToolInput( + JSON.stringify({ file_path: "shots/page-capture.png" }) + ) + ).toBe("shots/page-capture.png") + expect( + pathFromToolInput( + JSON.stringify({ + url: "https://example.com/docs/getting-started", + }) + ) + ).toBe("https://example.com/docs/getting-started") + expect(pathFromToolInput("not-json")).toBeNull() + }) +}) + +describe("imageCardLabel", () => { + it("keeps a real tool or page title", () => { + expect(imageCardLabel({ title: "Getting Started" })).toBe("Getting Started") + expect(imageCardLabel({ title: "Getting Started | Example Docs" })).toBe( + "Getting Started | Example Docs" + ) + }) + + it("does not treat the generation title as a label", () => { + expect(imageCardLabel({ title: "Image generation" })).toBeNull() + }) + + it("falls back to a humanized filename or URL slug", () => { + expect( + imageCardLabel({ + title: "Image generation", + input: JSON.stringify({ file_path: "page-capture.png" }), + }) + ).toBe("Page Capture") + expect( + imageCardLabel({ + input: JSON.stringify({ + url: "https://example.com/docs/getting-started", + }), + }) + ).toBe("Getting Started") + }) + + it("uses the tool name when nothing else is available", () => { + expect(imageCardLabel({ toolName: "Read" })).toBe("Read") + expect(imageCardLabel({ toolName: "Image generation" })).toBeNull() + }) +}) diff --git a/src/lib/image-tool-label.ts b/src/lib/image-tool-label.ts new file mode 100644 index 000000000..55f3ab615 --- /dev/null +++ b/src/lib/image-tool-label.ts @@ -0,0 +1,87 @@ +/** + * Label for an in-position image card. + * + * Codex image generation hardcodes the English title "Image generation" + * (codex-acp PR #271). Codeg also routes ANY image-bearing tool (Read of a + * PNG, a page screenshot, a fetched resource) through that same card, and + * the card used to print "Image generation" even when the tool already had + * a real name. Keep the dedicated copy only for actual generation; otherwise + * use the tool title, URL slug, or filename the agent already knew. + */ + +const IMAGE_GENERATION_TITLE = "image generation" + +export function isImageGenerationTitle( + title: string | null | undefined +): boolean { + return (title ?? "").trim().toLowerCase() === IMAGE_GENERATION_TITLE +} + +function lastPathSegment(raw: string): string { + const trimmed = raw.trim() + if (!trimmed) return "" + try { + if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) { + const url = new URL(trimmed) + const path = url.pathname.replace(/\/+$/, "") + const leaf = path.split("/").filter(Boolean).pop() + return decodeURIComponent(leaf || url.hostname) + } + } catch { + /* not a URL */ + } + const leaf = trimmed.split(/[\\/]/).filter(Boolean).pop() ?? trimmed + return leaf +} + +function humanizeSegment(segment: string): string { + const withoutExt = segment.replace(/\.[a-z0-9]{1,8}$/i, "") + const words = withoutExt.replace(/[-_]+/g, " ").trim() + if (!words) return segment + return words.replace(/\b\w/g, (c) => c.toUpperCase()) +} + +function stringField(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null +} + +/** Filename or URL from a tool's JSON input preview. */ +export function pathFromToolInput( + input: string | null | undefined +): string | null { + if (!input) return null + try { + const parsed: unknown = JSON.parse(input) + if (!parsed || typeof parsed !== "object") return null + const obj = parsed as Record + return ( + stringField(obj.file_path) || + stringField(obj.path) || + stringField(obj.filename) || + stringField(obj.url) || + stringField(obj.uri) + ) + } catch { + return null + } +} + +export function imageCardLabel(opts: { + title?: string | null + toolName?: string | null + input?: string | null +}): string | null { + const title = opts.title?.trim() || null + if (title && !isImageGenerationTitle(title)) return title + + const fromInput = pathFromToolInput(opts.input ?? null) + if (fromInput) { + const segment = lastPathSegment(fromInput) + if (segment) return humanizeSegment(segment) + } + + const toolName = opts.toolName?.trim() || null + if (toolName && !isImageGenerationTitle(toolName)) return toolName + + return null +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 8404ddc14..014fc610c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -182,6 +182,8 @@ export type ContentBlock = revised_prompt?: string | null image?: ImageData | null status?: ToolCallStatus | null + /** Real tool/page name when this card is not Codex image generation. */ + label?: string | null } | { type: "tool_use" diff --git a/src/stores/conversation-runtime-store.ts b/src/stores/conversation-runtime-store.ts index 9c0afea48..c193b0efb 100644 --- a/src/stores/conversation-runtime-store.ts +++ b/src/stores/conversation-runtime-store.ts @@ -36,6 +36,7 @@ import { collapseLiveCollabBlocks } from "@/lib/collab-collapse" import { kimiTodoWriteEntries } from "@/lib/plan-parse" import { toErrorMessage } from "@/lib/app-error" import { BACKGROUND_TASK_MARKER } from "@/lib/background-agent" +import { imageCardLabel } from "@/lib/image-tool-label" /** * Conversation-runtime shared state as a Zustand store — the per-conversation @@ -1093,6 +1094,10 @@ export function buildStreamingTurnsFromLiveMessage( // each renders as its own card. const imgs = block.info.images ?? [] const revisedPrompt = extractRevisedPrompt(block.info.content) + const label = imageCardLabel({ + title: block.info.title, + input: block.info.raw_input, + }) // Live ToolCallStatus is forwarded so the renderer can show a // failure slot when codex reports the call failed before any // image bytes arrived. Without this the in-flight skeleton would @@ -1106,6 +1111,7 @@ export function buildStreamingTurnsFromLiveMessage( revised_prompt: revisedPrompt, image: null, status, + label, }) } else { for (const img of imgs) { @@ -1118,6 +1124,7 @@ export function buildStreamingTurnsFromLiveMessage( uri: img.uri ?? null, }, status, + label, }) } }