Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/components/message/content-parts-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3061,6 +3061,7 @@ export const ContentPartsRenderer = memo(function ContentPartsRenderer({
return (
<GeneratedImagesBlock
key={`gimg-${keyId}`}
label={part.label}
revisedPrompt={part.revisedPrompt}
image={part.image}
status={part.status}
Expand Down
9 changes: 8 additions & 1 deletion src/components/message/generated-images-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import { toErrorMessage } from "@/lib/app-error"
import { cn } from "@/lib/utils"

interface GeneratedImagesBlockProps {
/**
* Card heading. Codex image generation leaves this unset so the
* translated "Image generation" copy is used. A Read, screenshot, or
* fetched page passes the tool/page name instead.
*/
label?: string | null
/**
* codex's revised prompt — what the model rewrote the user's request
* into before passing to the image API. `null` when codex didn't echo
Expand Down Expand Up @@ -60,6 +66,7 @@ interface GeneratedImagesBlockProps {
* - web: blob `<a download>`
*/
export const GeneratedImagesBlock = memo(function GeneratedImagesBlock({
label,
revisedPrompt,
image,
status,
Expand Down Expand Up @@ -101,7 +108,7 @@ export const GeneratedImagesBlock = memo(function GeneratedImagesBlock({
>
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<ImagePlus className="h-3.5 w-3.5 text-primary" />
<span>{t("imageGeneration")}</span>
<span>{label?.trim() || t("imageGeneration")}</span>
</div>

<div className="mt-2.5 flex flex-col gap-3 @[28rem]/genimg:flex-row @[28rem]/genimg:items-start">
Expand Down
40 changes: 40 additions & 0 deletions src/lib/adapters/ai-elements-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down Expand Up @@ -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)", () => {
Expand Down
33 changes: 28 additions & 5 deletions src/lib/adapters/ai-elements-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -1073,6 +1076,7 @@ function adaptContentBlock(
revisedPrompt: block.revised_prompt ?? null,
image: display,
status: block.status ?? null,
label: block.label ?? null,
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions src/lib/image-tool-label.test.ts
Original file line number Diff line number Diff line change
@@ -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(

Check failure on line 23 in src/lib/image-tool-label.test.ts

View workflow job for this annotation

GitHub Actions / Frontend (lint + vitest + build)

Replace `⏎········JSON.stringify({·file_path:·"shots/page-capture.png"·})⏎······` with `JSON.stringify({·file_path:·"shots/page-capture.png"·})`
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()
})
})
87 changes: 87 additions & 0 deletions src/lib/image-tool-label.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
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
}
2 changes: 2 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions src/stores/conversation-runtime-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -1106,6 +1111,7 @@ export function buildStreamingTurnsFromLiveMessage(
revised_prompt: revisedPrompt,
image: null,
status,
label,
})
} else {
for (const img of imgs) {
Expand All @@ -1118,6 +1124,7 @@ export function buildStreamingTurnsFromLiveMessage(
uri: img.uri ?? null,
},
status,
label,
})
}
}
Expand Down
Loading