From 7f321ae145987abb9a2953210b0a493d5395c848 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:57:20 +0200 Subject: [PATCH] fix(app): render local Markdown images with encoded paths Decode assistant Markdown image destinations before resolving a filesystem read. Keep plain filesystem paths and direct image URIs unchanged so Markdown Preview and literal percent filenames are decoded only once. --- .../browser/agent-tab-image-stability.spec.ts | 37 ++++++++++++ .../e2e/support/helpers/assistant-images.ts | 3 +- packages/app/src/attachments/utils.test.ts | 12 ++++ packages/app/src/attachments/utils.ts | 11 +++- packages/app/src/components/message.tsx | 3 +- .../assistant-markdown-image-source.test.ts | 58 +++++++++++++++++++ 6 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 packages/app/src/utils/assistant-markdown-image-source.test.ts diff --git a/packages/app/e2e/browser/agent-tab-image-stability.spec.ts b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts index 5f70bdafe3..841314343f 100644 --- a/packages/app/e2e/browser/agent-tab-image-stability.spec.ts +++ b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts @@ -16,6 +16,7 @@ import { } from "../support/helpers/assistant-images"; import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import path from "node:path"; const test = base.extend<{ imageWorkspace: SeededWorkspace }>({ imageWorkspace: async ({ page: _page }, provide) => { @@ -28,6 +29,42 @@ const test = base.extend<{ imageWorkspace: SeededWorkspace }>({ }, }); +test("assistant images at paths with spaces, Unicode and literal percent escapes render", async ({ + imageWorkspace: workspace, + page, +}) => { + test.setTimeout(120_000); + const image = await createSmallAssistantPng(workspace, { + alt: "Angle destination image", + fileName: "My image Мои.png", + }); + const agent = await createSettledMockAgent(workspace, "Encoded image paths"); + await emitSettledAssistantImage(workspace.client, agent, image, `<${image.relativePath}>`); + await openAssistantImageTimeline(page, agent); + await expectAssistantImageRendered(page, image); + + const encodedImage = { ...image, alt: "Encoded destination image" }; + await emitSettledAssistantImage( + workspace.client, + agent, + encodedImage, + encodeURI(path.join(workspace.repoPath, image.relativePath)), + ); + await expectAssistantImageRendered(page, encodedImage); + + const literalPercentImage = await createSmallAssistantPng(workspace, { + alt: "Literal percent filename image", + fileName: "literal%20name.png", + }); + await emitSettledAssistantImage( + workspace.client, + agent, + literalPercentImage, + encodeURI(literalPercentImage.relativePath), + ); + await expectAssistantImageRendered(page, literalPercentImage); +}); + test("switching between settled agent tabs keeps a real assistant PNG rendered", async ({ imageWorkspace: workspace, page, diff --git a/packages/app/e2e/support/helpers/assistant-images.ts b/packages/app/e2e/support/helpers/assistant-images.ts index 63bc182eaf..c4200c7c75 100644 --- a/packages/app/e2e/support/helpers/assistant-images.ts +++ b/packages/app/e2e/support/helpers/assistant-images.ts @@ -87,10 +87,11 @@ export async function emitSettledAssistantImage( client: SeedDaemonClient, agent: ArchiveTabAgent, image: AssistantImageFixture, + destination: string = image.relativePath, ): Promise { await client.sendAgentMessage( agent.id, - `Emit settled assistant image Markdown: ![${image.alt}](${image.relativePath})`, + `Emit settled assistant image Markdown: ![${image.alt}](${destination})`, ); const result = await client.waitForFinish(agent.id, 30_000); if (result.status !== "idle" || result.final?.lastError) { diff --git a/packages/app/src/attachments/utils.test.ts b/packages/app/src/attachments/utils.test.ts index d6fce078f2..9bf982c6e8 100644 --- a/packages/app/src/attachments/utils.test.ts +++ b/packages/app/src/attachments/utils.test.ts @@ -42,6 +42,18 @@ describe("fileUriToPath", () => { }); describe("localFileSourceToPath", () => { + it.each([ + "https://example.com/image%20name.png", + "data:image/png;base64,abc%20", + "blob:https://example.com/image%20name", + ])("preserves a direct Markdown image URI: %s", (source) => { + expect(localFileSourceToPath(source, { decodeHref: true })).toBe(source); + }); + + it("preserves malformed percent escapes rather than throwing", () => { + expect(localFileSourceToPath("/tmp/100%.png", { decodeHref: true })).toBe("/tmp/100%.png"); + }); + it("decodes markdown-encoded Windows drive-letter paths", () => { expect(localFileSourceToPath("C:%5CUsers%5Cfile.txt")).toBe("C:/Users/file.txt"); }); diff --git a/packages/app/src/attachments/utils.ts b/packages/app/src/attachments/utils.ts index 5fa0334359..46483bf0e7 100644 --- a/packages/app/src/attachments/utils.ts +++ b/packages/app/src/attachments/utils.ts @@ -173,11 +173,18 @@ export function fileUriToPath(uri: string): string { return normalizeWindowsDrivePath(decodedPath.replace(/^\/([A-Za-z]:[\\/])/, "$1")); } -export function localFileSourceToPath(source: string): string { +export function localFileSourceToPath( + source: string, + // Markdown destinations are URI-encoded; plain filesystem paths are not. + { decodeHref = false }: { decodeHref?: boolean } = {}, +): string { + if (/^(https?:|data:|blob:)/i.test(source)) { + return source; + } let path = source; if (source.startsWith("file://")) { path = fileUriToPath(source); - } else if (isMarkdownEncodedWindowsDrivePath(source)) { + } else if (decodeHref || isMarkdownEncodedWindowsDrivePath(source)) { path = decodeFilePathSource(source); } return normalizeWindowsDrivePath(path); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 4587556ebe..7915832470 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -95,6 +95,7 @@ import { } from "@/assistant-file-links"; import { getCompactionMarkerLabel } from "./message-compaction-label"; import { useAssistantImage } from "@/assistant-image/use-assistant-image"; +import { localFileSourceToPath } from "@/attachments/utils"; import { AttachmentFrame, AttachmentLabel, @@ -1945,7 +1946,7 @@ export const AssistantMessage = memo(function AssistantMessage({ return ( { + let workspaceRoot: string; + const imageBytes = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==", + "base64", + ); + + beforeEach(async () => { + workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "assistant-markdown-image-")); + await mkdir(path.join(workspaceRoot, "My Apps", "Мои"), { recursive: true }); + await writeFile(path.join(workspaceRoot, "My Apps", "Мои", "test.png"), imageBytes); + await writeFile(path.join(workspaceRoot, "literal%20name.png"), imageBytes); + }); + + afterEach(async () => { + await rm(workspaceRoot, { recursive: true, force: true }); + }); + + it.each([ + "absolute angle", + "absolute encoded", + "relative angle", + "relative encoded", + "file URI", + "literal percent", + ])("reads the actual image for the %s destination", async (form) => { + const absolute = path.join(workspaceRoot, "My Apps", "Мои", "test.png"); + const destinations: Record = { + "absolute angle": `<${absolute}>`, + "absolute encoded": encodeURI(absolute), + "relative angle": "", + "relative encoded": encodeURI("My Apps/Мои/test.png"), + "file URI": `file://${encodeURI(absolute)}`, + "literal percent": `${workspaceRoot}/literal%2520name.png`, + }; + const tokens = createAssistantMarkdownParser().parseInline( + `![test](${destinations[form]})`, + {}, + ); + const image = tokens + .flatMap((token) => token.children ?? []) + .find((token) => token.type === "image"); + expect(image).toBeDefined(); + const source = localFileSourceToPath(image!.attrGet("src")!, { decodeHref: true }); + const target = resolveAssistantImageSource({ source, workspaceRoot }); + expect(target?.kind).toBe("file_rpc"); + if (target?.kind !== "file_rpc") throw new Error("Expected a local image read target"); + await expect(readFile(path.resolve(target.cwd, target.path))).resolves.toEqual(imageBytes); + }); +});