From 9c6f3075b4ca9206033168501015946efa217d02 Mon Sep 17 00:00:00 2001 From: Berend Kapelle Date: Fri, 31 Jul 2026 13:38:07 +0200 Subject: [PATCH 1/6] fix(web): render local images inline AI-Assisted: true AI-Agent: codex AI-Model: openai/gpt-5.6-sol --- .../src/provider/Layers/CodexAdapter.test.ts | 39 +++++++++++++ .../src/provider/Layers/CodexAdapter.ts | 1 + apps/web/src/components/ChatMarkdown.tsx | 57 +++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 14 +++++ apps/web/src/markdown-links.test.ts | 29 ++++++++++ apps/web/src/markdown-links.ts | 10 ++++ apps/web/src/session-logic.test.ts | 39 +++++++++++++ apps/web/src/session-logic.ts | 11 ++++ 8 files changed, 200 insertions(+) diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 7b8fbec5666..7bd7f367c3d 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -613,6 +613,45 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("preserves generated image paths in canonical lifecycle entries", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-image-complete"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/completed", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("image_1"), + payload: { + completedAtMs: 1_778_000_000_000, + threadId: "thread-1", + turnId: "turn-1", + item: { + type: "imageGeneration", + id: "image_1", + result: "generated", + revisedPrompt: null, + savedPath: "/repo/project/generated/cat.png", + status: "completed", + }, + }, + }); + const firstEvent = yield* Fiber.join(firstEventFiber); + + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "item.completed") { + return; + } + NodeAssert.equal(firstEvent.value.payload.itemType, "image_view"); + NodeAssert.equal(firstEvent.value.payload.detail, "/repo/project/generated/cat.png"); + }), + ); + it.effect("maps completed plan items to canonical proposed-plan completion events", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 4146121b147..b588e944907 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -281,6 +281,7 @@ function itemDetail(itemType: CanonicalItemType, item: CodexLifecycleItem): stri "summary" in item ? item.summary : undefined, "text" in item ? item.text : undefined, "path" in item ? item.path : undefined, + "savedPath" in item ? item.savedPath : undefined, "prompt" in item ? item.prompt : undefined, ]; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 985e943cb39..f58ee7d6804 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -60,6 +60,7 @@ import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings } from "../hooks/useSettings"; +import { useAssetUrlState } from "../assets/assetUrls"; import { chatMarkdownClipboardPayload, serializeTableElementToCsv, @@ -70,6 +71,7 @@ import { normalizeMarkdownLinkDestination, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, + resolveMarkdownImageFileLinkMeta, rewriteMarkdownFileUriHref, type MarkdownFileLinkMeta, } from "../markdown-links"; @@ -148,6 +150,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { protocols: { ...defaultSchema.protocols, href: [...(defaultSchema.protocols?.href ?? []), "file"], + src: [...(defaultSchema.protocols?.src ?? []), "file"], }, } satisfies Parameters[0]; @@ -846,6 +849,46 @@ const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; /** Hosts whose favicon request already failed this session — skip straight to the globe. */ const failedFaviconHosts = new Set(); +function MarkdownWorkspaceImage({ + filePath, + threadRef, + alt, + className, + onError, + ...props +}: Omit, "src"> & { + readonly filePath: string; + readonly threadRef: ScopedThreadRef; +}) { + const assetUrl = useAssetUrlState(threadRef.environmentId, { + _tag: "workspace-file", + threadId: threadRef.threadId, + path: filePath, + }); + const [failedUrl, setFailedUrl] = useState(null); + const label = alt?.trim() || filePath.split(/[\\/]/).at(-1) || "image"; + + if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { + return Unable to load {label}.; + } + if (assetUrl._tag !== "Success") { + return Loading {label}…; + } + + return ( + {alt} { + setFailedUrl(assetUrl.url); + onError?.(event); + }} + /> + ); +} + const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { const [failedHost, setFailedHost] = useState(null); return ( @@ -1448,6 +1491,20 @@ function ChatMarkdown({ /> ); }, + img({ node: _node, src, alt, ...props }) { + const fileLinkMeta = resolveMarkdownImageFileLinkMeta(src, cwd); + if (!fileLinkMeta || !threadRef) { + return {alt}; + } + return ( + + ); + }, a({ node, href, children, ...props }) { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a429b54deaf..f44e0cf8cb5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1927,6 +1927,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { }) { const { workEntry, workspaceRoot } = props; const activity = use(TimelineRowActivityCtx); + const timeline = use(TimelineRowCtx); const [expanded, setExpanded] = useState(false); const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; @@ -2065,6 +2066,19 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { + {workEntry.imagePath && timeline.threadRef ? ( +
+ +
+ ) : null} {expanded && canExpand && expandedBody ? (
{ }); }); +describe("resolveMarkdownImageFileLinkMeta", () => { + it("resolves relative workspace images", () => { + expect( + resolveMarkdownImageFileLinkMeta("screenshots/result.png", "/repo/project"), + ).toMatchObject({ + filePath: "/repo/project/screenshots/result.png", + workspaceRelativePath: "screenshots/result.png", + }); + }); + + it("accepts supported absolute and file URL images", () => { + expect(resolveMarkdownImageFileLinkMeta("/tmp/result.webp", "/repo/project")?.filePath).toBe( + "/tmp/result.webp", + ); + expect( + resolveMarkdownImageFileLinkMeta("file:///repo/project/result.svg", "/repo/project") + ?.filePath, + ).toBe("/repo/project/result.svg"); + }); + + it("leaves remote images and non-image files to the normal markdown renderer", () => { + expect( + resolveMarkdownImageFileLinkMeta("https://example.com/result.png", "/repo/project"), + ).toBeNull(); + expect(resolveMarkdownImageFileLinkMeta("notes/result.md", "/repo/project")).toBeNull(); + }); +}); + describe("resolveInlineCodeFileLinkMeta", () => { it("links relative paths with file extensions", () => { expect( diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index a6dba941b8a..f23accc9704 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -1,3 +1,5 @@ +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; + import { formatWorkspaceRelativePath } from "./filePathDisplay"; import { resolvePathLinkTarget, splitPathAndPosition } from "./terminal-links"; @@ -385,6 +387,14 @@ export function resolveMarkdownFileLinkMeta( return buildFileLinkMetaFromTarget(targetPath, cwd); } +export function resolveMarkdownImageFileLinkMeta( + src: string | undefined, + cwd?: string, +): MarkdownFileLinkMeta | null { + const meta = resolveMarkdownFileLinkMeta(src, cwd); + return meta && isWorkspaceImagePreviewPath(meta.filePath) ? meta : null; +} + function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): MarkdownFileLinkMeta { const { path, line, column } = splitPathAndPosition(targetPath); const parsedLine = line ? Number.parseInt(line, 10) : Number.NaN; diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 0f12e672f66..ac3bcf3294f 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -961,6 +961,45 @@ describe("deriveWorkLogEntries", () => { expect(entry?.toolData).toEqual(item); }); + it("preserves generated and viewed image paths for inline display", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "image-generation-complete", + kind: "tool.completed", + summary: "Image view", + payload: { + itemType: "image_view", + data: { + item: { + type: "imageGeneration", + savedPath: "/repo/project/generated/cat.png", + status: "completed", + }, + }, + }, + }), + makeActivity({ + id: "image-view-complete", + kind: "tool.completed", + summary: "Image view", + payload: { + itemType: "image_view", + data: { + item: { + type: "imageView", + path: "/repo/project/screenshots/app.webp", + }, + }, + }, + }), + ]; + + expect(deriveWorkLogEntries(activities).map((entry) => entry.imagePath)).toEqual([ + "/repo/project/generated/cat.png", + "/repo/project/screenshots/app.webp", + ]); + }); + it("keeps MCP payloads while collapsing lifecycle updates", () => { const item = { type: "mcpToolCall", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5d5051f748e..f72fe164545 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -72,6 +72,7 @@ export interface WorkLogEntry { tone: "thinking" | "tool" | "info" | "error"; toolTitle?: string; toolData?: unknown; + imagePath?: string; itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; /** From runtime item / task payload `status` when present (e.g. tool.updated). */ @@ -740,6 +741,14 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo entry.toolData = data.item; } } + if (itemType === "image_view") { + const data = asRecord(payload?.data); + const item = asRecord(data?.item); + const imagePath = asTrimmedString(item?.savedPath) ?? asTrimmedString(item?.path); + if (imagePath) { + entry.imagePath = imagePath; + } + } if (itemType) { entry.itemType = itemType; } @@ -818,6 +827,7 @@ function mergeDerivedWorkLogEntries( const toolCallId = next.toolCallId ?? previous.toolCallId; const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; const toolData = next.toolData ?? previous.toolData; + const imagePath = next.imagePath ?? previous.imagePath; return { ...previous, ...next, @@ -832,6 +842,7 @@ function mergeDerivedWorkLogEntries( ...(toolCallId ? { toolCallId } : {}), ...(toolLifecycleStatus !== undefined ? { toolLifecycleStatus } : {}), ...(toolData !== undefined ? { toolData } : {}), + ...(imagePath !== undefined ? { imagePath } : {}), }; } From b2aa32fe4a544dd6fd2214ddfb4723d80d15e52f Mon Sep 17 00:00:00 2001 From: Berend Kapelle Date: Fri, 31 Jul 2026 15:06:23 +0200 Subject: [PATCH 2/6] fix(web): retain projected image paths Use the preserved activity detail when nested provider image data is removed during transport projection. Add regression coverage for the projected production payload shape. Addresses https://github.com/pingdotgg/t3code/pull/5114#discussion_r3690419183 AI-Assisted: true AI-Agent: codex AI-Model: openai/gpt-5.6-sol --- .../test/ActivityPayloadProjection.test.ts | 32 ++++++++++++++++--- apps/web/src/session-logic.test.ts | 9 ++---- apps/web/src/session-logic.ts | 5 ++- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts index d6098937e7f..ed70b444a87 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -23,6 +23,7 @@ function makeActivity( id: string, itemType: string, data: Record, + detail = `${itemType} detail`, ): OrchestrationThreadActivity { return { id: EventId.make(id), @@ -32,7 +33,7 @@ function makeActivity( payload: { itemType, title: itemType, - detail: `${itemType} detail`, + detail, status: "completed", requestKind: "command", data, @@ -129,9 +130,18 @@ const fixtures = [ }, ignored: "top-level bulk", }), - makeActivity("image", "image_view", { - ignored: "top-level bulk", - }), + makeActivity( + "image", + "image_view", + { + item: { + type: "imageGeneration", + savedPath: "/repo/project/generated/cat.png", + }, + ignored: "top-level bulk", + }, + "/repo/project/generated/cat.png", + ), ] satisfies ReadonlyArray; describe("projectActivityPayload", () => { @@ -196,6 +206,20 @@ describe("projectActivityPayload", () => { } }); + it("keeps image paths available after projecting nested provider data", () => { + const projected = projectActivityPayload(fixtures[6]!); + + expect(projected.payload).toEqual({ + itemType: "image_view", + title: "image_view", + detail: "/repo/project/generated/cat.png", + status: "completed", + requestKind: "command", + data: {}, + }); + expect(deriveWorkLogEntries([projected])[0]?.imagePath).toBe("/repo/project/generated/cat.png"); + }); + it("projects snapshot and event transports without mutating their sources", () => { const activity = fixtures[0]!; const thread = makeThread([activity]); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index ac3bcf3294f..b30876a57aa 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -969,13 +969,8 @@ describe("deriveWorkLogEntries", () => { summary: "Image view", payload: { itemType: "image_view", - data: { - item: { - type: "imageGeneration", - savedPath: "/repo/project/generated/cat.png", - status: "completed", - }, - }, + detail: "/repo/project/generated/cat.png", + data: {}, }, }), makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index f72fe164545..bcc68af41e2 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -744,7 +744,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (itemType === "image_view") { const data = asRecord(payload?.data); const item = asRecord(data?.item); - const imagePath = asTrimmedString(item?.savedPath) ?? asTrimmedString(item?.path); + const imagePath = + asTrimmedString(item?.savedPath) ?? + asTrimmedString(item?.path) ?? + asTrimmedString(payload?.detail); if (imagePath) { entry.imagePath = imagePath; } From 58a2daa02667f7fa9c3eddad34a5fb658708e26d Mon Sep 17 00:00:00 2001 From: Berend Kapelle Date: Fri, 31 Jul 2026 16:06:55 +0200 Subject: [PATCH 3/6] fix(web): harden inline image handling Reject non-path image detail summaries and recover projected structured image paths before rendering. Preserve original Markdown image sources when copying so signed asset URLs do not leave the app. Addresses https://github.com/pingdotgg/t3code/pull/5114#discussion_r3690630779 Addresses https://github.com/pingdotgg/t3code/pull/5114#discussion_r3690639429 AI-Assisted: true AI-Agent: codex AI-Model: openai/gpt-5.6-sol --- .../test/ActivityPayloadProjection.test.ts | 34 +++++++++- apps/web/src/components/ChatMarkdown.tsx | 4 ++ apps/web/src/markdown-clipboard.test.ts | 62 +++++++++++++++++++ apps/web/src/markdown-clipboard.ts | 37 +++++++++-- apps/web/src/session-logic.test.ts | 35 ++++++++++- apps/web/src/session-logic.ts | 5 +- 6 files changed, 166 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/markdown-clipboard.test.ts diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts index ed70b444a87..9802fda21f3 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -207,9 +207,9 @@ describe("projectActivityPayload", () => { }); it("keeps image paths available after projecting nested provider data", () => { - const projected = projectActivityPayload(fixtures[6]!); + const projectedCodex = projectActivityPayload(fixtures[6]!); - expect(projected.payload).toEqual({ + expect(projectedCodex.payload).toEqual({ itemType: "image_view", title: "image_view", detail: "/repo/project/generated/cat.png", @@ -217,7 +217,35 @@ describe("projectActivityPayload", () => { requestKind: "command", data: {}, }); - expect(deriveWorkLogEntries([projected])[0]?.imagePath).toBe("/repo/project/generated/cat.png"); + expect(deriveWorkLogEntries([projectedCodex])[0]?.imagePath).toBe( + "/repo/project/generated/cat.png", + ); + + const projectedClaude = projectActivityPayload( + makeActivity( + "claude-image", + "image_view", + { + toolName: "ReadImage", + input: { path: "/repo/project/screenshots/claude.png" }, + }, + 'ReadImage: {"path":"/repo/project/screenshots/claude.png"}', + ), + ); + + expect(projectedClaude.payload).toEqual({ + itemType: "image_view", + title: "image_view", + detail: 'ReadImage: {"path":"/repo/project/screenshots/claude.png"}', + status: "completed", + requestKind: "command", + data: { + files: [{ path: "/repo/project/screenshots/claude.png" }], + }, + }); + expect(deriveWorkLogEntries([projectedClaude])[0]?.imagePath).toBe( + "/repo/project/screenshots/claude.png", + ); }); it("projects snapshot and event transports without mutating their sources", () => { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index f58ee7d6804..1a07085f725 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -851,6 +851,7 @@ const failedFaviconHosts = new Set(); function MarkdownWorkspaceImage({ filePath, + markdownSrc, threadRef, alt, className, @@ -858,6 +859,7 @@ function MarkdownWorkspaceImage({ ...props }: Omit, "src"> & { readonly filePath: string; + readonly markdownSrc: string; readonly threadRef: ScopedThreadRef; }) { const assetUrl = useAssetUrlState(threadRef.environmentId, { @@ -879,6 +881,7 @@ function MarkdownWorkspaceImage({ {alt} { @@ -1500,6 +1503,7 @@ function ChatMarkdown({ diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts new file mode 100644 index 00000000000..cda37cd166f --- /dev/null +++ b/apps/web/src/markdown-clipboard.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + restoreMarkdownImageSourcesForClipboard, + serializeMarkdownImageElement, +} from "./markdown-clipboard"; + +function makeImageAttributes(initial: Record) { + const attributes = new Map(Object.entries(initial)); + return { + attributes, + element: { + getAttribute(name: string) { + return attributes.get(name) ?? null; + }, + removeAttribute(name: string) { + attributes.delete(name); + }, + setAttribute(name: string, value: string) { + attributes.set(name, value); + }, + }, + }; +} + +describe("workspace image clipboard serialization", () => { + it("uses the original Markdown source instead of the signed rendering URL", () => { + const { element } = makeImageAttributes({ + alt: "Result", + "data-markdown-src": "screenshots/result.png", + src: "https://environment.example/api/assets/signed-token/result.png", + }); + + expect(serializeMarkdownImageElement(element)).toBe("![Result](screenshots/result.png)"); + }); + + it("restores original sources and removes signed-URL metadata before rich copy", () => { + const { attributes, element } = makeImageAttributes({ + alt: "Result", + "data-markdown-src": "screenshots/result.png", + src: "https://environment.example/api/assets/signed-token/result.png", + }); + + restoreMarkdownImageSourcesForClipboard([element]); + + expect(Object.fromEntries(attributes)).toEqual({ + alt: "Result", + src: "screenshots/result.png", + }); + }); + + it("keeps ordinary remote image sources unchanged", () => { + const { element } = makeImageAttributes({ + alt: "Remote", + src: "https://example.com/result.png", + }); + + expect(serializeMarkdownImageElement(element)).toBe( + "![Remote](https://example.com/result.png)", + ); + }); +}); diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 86965eebfa9..1af038c8492 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -8,6 +8,7 @@ const SKIPPED_TAGS = new Set(["BUTTON", "INPUT", "SCRIPT", "STYLE", "TEMPLATE"]); const SKIPPED_CLASS_NAMES = ["select-none", "sr-only"]; +const MARKDOWN_IMAGE_SOURCE_ATTRIBUTE = "data-markdown-src"; const SANITIZED_HTML_SELECTOR = [ "button", "input", @@ -29,6 +30,32 @@ function isSkippedElement(element: Element): boolean { return SKIPPED_CLASS_NAMES.some((className) => element.classList.contains(className)); } +type ClipboardImageElement = Pick; + +function copiedMarkdownImageSource(element: Pick): string { + return element.getAttribute(MARKDOWN_IMAGE_SOURCE_ATTRIBUTE) ?? element.getAttribute("src") ?? ""; +} + +export function serializeMarkdownImageElement(element: Pick): string { + const alt = element.getAttribute("alt") ?? ""; + const src = copiedMarkdownImageSource(element); + return alt && src ? `![${alt}](${src})` : ""; +} + +export function restoreMarkdownImageSourcesForClipboard( + images: Iterable, +): void { + for (const image of images) { + const markdownSrc = image.getAttribute(MARKDOWN_IMAGE_SOURCE_ATTRIBUTE); + if (markdownSrc) { + image.setAttribute("src", markdownSrc); + } else { + image.removeAttribute("src"); + } + image.removeAttribute(MARKDOWN_IMAGE_SOURCE_ATTRIBUTE); + } +} + /** Hoists surrounding whitespace outside the markers: "` bold `" → " **bold** ". */ function wrapInlineMarker(content: string, marker: string): string { const match = /^(\s*)([\s\S]*?)(\s*)$/.exec(content); @@ -214,11 +241,8 @@ function serializeNode(node: Node): string { return wrapInlineMarker(serializeChildren(element), "~~"); case "A": return serializeAnchor(element); - case "IMG": { - const alt = element.getAttribute("alt") ?? ""; - const src = element.getAttribute("src") ?? ""; - return alt && src ? `![${alt}](${src})` : ""; - } + case "IMG": + return serializeMarkdownImageElement(element); case "UL": return serializeList(element, false); case "OL": @@ -288,6 +312,9 @@ function sanitizedHtmlFrom(container: Element): string { } node.remove(); } + restoreMarkdownImageSourcesForClipboard( + container.querySelectorAll(`img[${MARKDOWN_IMAGE_SOURCE_ATTRIBUTE}]`), + ); return `${container.innerHTML}`; } diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index b30876a57aa..2c0418087ce 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -987,12 +987,43 @@ describe("deriveWorkLogEntries", () => { }, }, }), + makeActivity({ + id: "claude-image-view-complete", + kind: "tool.completed", + summary: "Image view", + payload: { + itemType: "image_view", + detail: 'ReadImage: {"path":"/repo/project/screenshots/claude.png"}', + data: { + files: [{ path: "/repo/project/screenshots/claude.png" }], + }, + }, + }), + makeActivity({ + id: "image-view-with-summary-only", + kind: "tool.completed", + summary: "Image view", + payload: { + itemType: "image_view", + detail: 'ReadImage: {"path":"/repo/project/screenshots/missing.png"}', + data: {}, + }, + }), ]; - expect(deriveWorkLogEntries(activities).map((entry) => entry.imagePath)).toEqual([ + const entries = deriveWorkLogEntries(activities); + expect(entries.find((entry) => entry.id === "image-generation-complete")?.imagePath).toBe( "/repo/project/generated/cat.png", + ); + expect(entries.find((entry) => entry.id === "image-view-complete")?.imagePath).toBe( "/repo/project/screenshots/app.webp", - ]); + ); + expect(entries.find((entry) => entry.id === "claude-image-view-complete")?.imagePath).toBe( + "/repo/project/screenshots/claude.png", + ); + expect( + entries.find((entry) => entry.id === "image-view-with-summary-only")?.imagePath, + ).toBeUndefined(); }); it("keeps MCP payloads while collapsing lifecycle updates", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index bcc68af41e2..d6fb9510bda 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -13,6 +13,7 @@ import { type TurnId, } from "@t3tools/contracts"; +import { resolveMarkdownImageFileLinkMeta } from "./markdown-links"; import type { ChatMessage, ProposedPlan, @@ -744,10 +745,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (itemType === "image_view") { const data = asRecord(payload?.data); const item = asRecord(data?.item); + const detailPath = asTrimmedString(payload?.detail); const imagePath = asTrimmedString(item?.savedPath) ?? asTrimmedString(item?.path) ?? - asTrimmedString(payload?.detail); + changedFiles.find((path) => resolveMarkdownImageFileLinkMeta(path, "/") !== null) ?? + (detailPath && resolveMarkdownImageFileLinkMeta(detailPath, "/") ? detailPath : null); if (imagePath) { entry.imagePath = imagePath; } From e513c88fb3d1606b3724f5af0b09669beab97d82 Mon Sep 17 00:00:00 2001 From: Berend Kapelle Date: Sat, 1 Aug 2026 16:53:00 +0200 Subject: [PATCH 4/6] test(web): verify existing Windows image handling Add integration coverage for the existing Windows work-log image path flow. This changes no production behavior; it confirms that Markdown URL sanitization already preserves the decoded path for signed asset requests. Documents https://github.com/pingdotgg/t3code/pull/5114#discussion_r3695458450 as a false positive. AI-Assisted: true AI-Agent: codex AI-Model: openai/gpt-5.6-sol --- .../components/chat/MessagesTimeline.test.tsx | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 83ca7d3e952..e7d95583a3d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,9 +1,20 @@ -import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; +import { CheckpointRef, EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; import { createRef, type ReactNode, type Ref } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; import type { LegendListRef } from "@legendapp/list/react"; +const assetUrlMocks = vi.hoisted(() => ({ + useAssetUrlState: vi.fn(() => ({ + _tag: "Success" as const, + url: "https://environment.test/api/assets/signed-token/result.png", + })), +})); + +vi.mock("../../assets/assetUrls", () => ({ + useAssetUrlState: assetUrlMocks.useAssetUrlState, +})); + vi.mock("@legendapp/list/react", async () => { const legendListTestId = "legend-list"; @@ -553,6 +564,40 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); }); + it("renders Windows work-log image paths after Markdown URL sanitization", () => { + const imagePath = "C:\\Users\\mike\\dev-stuff\\t3code\\result.png"; + assetUrlMocks.useAssetUrlState.mockClear(); + + const markup = renderToStaticMarkup( + , + ); + + expect(assetUrlMocks.useAssetUrlState).toHaveBeenCalledWith(ACTIVE_THREAD_ENVIRONMENT_ID, { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: imagePath, + }); + expect(markup).toContain('src="https://environment.test/api/assets/signed-token/result.png"'); + }); + it("renders review comment contexts as structured cards instead of raw tags", () => { const markup = renderToStaticMarkup( Date: Sat, 1 Aug 2026 22:59:43 +0200 Subject: [PATCH 5/6] test(web): verify copied image paths round-trip Add clipboard coverage proving that percent-encoded Windows image paths resolve back to the original workspace path. This changes no production behavior and confirms the copied Markdown remains functional. Documents https://github.com/pingdotgg/t3code/pull/5114#discussion_r3695863948 as a false positive. AI-Assisted: true AI-Agent: codex AI-Model: openai/gpt-5.6-sol --- apps/web/src/markdown-clipboard.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index cda37cd166f..769dbdbc5a8 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -4,6 +4,7 @@ import { restoreMarkdownImageSourcesForClipboard, serializeMarkdownImageElement, } from "./markdown-clipboard"; +import { resolveMarkdownImageFileLinkMeta } from "./markdown-links"; function makeImageAttributes(initial: Record) { const attributes = new Map(Object.entries(initial)); @@ -34,6 +35,22 @@ describe("workspace image clipboard serialization", () => { expect(serializeMarkdownImageElement(element)).toBe("![Result](screenshots/result.png)"); }); + it("round-trips an encoded Windows path copied from a work-log image", () => { + const imagePath = "C:\\Users\\mike\\dev-stuff\\t3code\\result.png"; + const encodedImagePath = encodeURIComponent(imagePath); + const { element } = makeImageAttributes({ + alt: "Generated image", + "data-markdown-src": encodedImagePath, + src: "https://environment.example/api/assets/signed-token/result.png", + }); + + const copiedMarkdown = serializeMarkdownImageElement(element); + const copiedSource = /^!\[[^\]]*\]\((.*)\)$/.exec(copiedMarkdown)?.[1]; + + expect(copiedMarkdown).toBe(`![Generated image](${encodedImagePath})`); + expect(resolveMarkdownImageFileLinkMeta(copiedSource)?.filePath).toBe(imagePath); + }); + it("restores original sources and removes signed-URL metadata before rich copy", () => { const { attributes, element } = makeImageAttributes({ alt: "Result", From b9f2090dda4268a2836c6c27bff275972305736a Mon Sep 17 00:00:00 2001 From: Berend Kapelle Date: Thu, 6 Aug 2026 07:04:27 +0200 Subject: [PATCH 6/6] fix(server): retain long generated image paths Preserve structured savedPath values in projected item data so generated images still render when the human-readable activity detail is truncated. Addresses https://github.com/pingdotgg/t3code/pull/5114#discussion_r3723836157 AI-Assisted: true AI-Agent: codex AI-Model: openai/gpt-5.6-sol --- .../ActivityPayloadProjection.ts | 7 +++-- .../test/ActivityPayloadProjection.test.ts | 27 ++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 67896961b38..1f7982cad42 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -80,7 +80,7 @@ function collectChangedFiles( } } -function projectCommandData(data: Record): Record | undefined { +function projectItemData(data: Record): Record | undefined { const item = asRecord(data.item); if (!item) { return undefined; @@ -90,6 +90,9 @@ function projectCommandData(data: Record): Record = {}; - const item = projectCommandData(data); + const item = projectItemData(data); if (item) { projectedData.item = item; } diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts index 9802fda21f3..3585eae7b96 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -215,7 +215,11 @@ describe("projectActivityPayload", () => { detail: "/repo/project/generated/cat.png", status: "completed", requestKind: "command", - data: {}, + data: { + item: { + savedPath: "/repo/project/generated/cat.png", + }, + }, }); expect(deriveWorkLogEntries([projectedCodex])[0]?.imagePath).toBe( "/repo/project/generated/cat.png", @@ -248,6 +252,27 @@ describe("projectActivityPayload", () => { ); }); + it("keeps generated image paths when the activity detail is truncated", () => { + const imagePath = `/repo/project/${"generated/".repeat(20)}cat.png`; + const truncatedDetail = `${imagePath.slice(0, 177)}...`; + const projected = projectActivityPayload( + makeActivity( + "long-image", + "image_view", + { + item: { + type: "imageGeneration", + savedPath: imagePath, + }, + }, + truncatedDetail, + ), + ); + + expect(imagePath.length).toBeGreaterThan(180); + expect(deriveWorkLogEntries([projected])[0]?.imagePath).toBe(imagePath); + }); + it("projects snapshot and event transports without mutating their sources", () => { const activity = fixtures[0]!; const thread = makeThread([activity]);