From 12a8d808738544b08290b56ba11315fcd7ae31eb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 19:05:31 -0700 Subject: [PATCH] fix(ui): decode artifact previews as UTF-8 (#117657) Co-authored-by: Peter Steinberger --- .../components/chat-session-workspace.test.ts | 89 +++++++++++++++++++ .../chat/components/chat-session-workspace.ts | 20 ++--- 2 files changed, 98 insertions(+), 11 deletions(-) diff --git a/ui/src/pages/chat/components/chat-session-workspace.test.ts b/ui/src/pages/chat/components/chat-session-workspace.test.ts index e1102a512752..f87c9384e42e 100644 --- a/ui/src/pages/chat/components/chat-session-workspace.test.ts +++ b/ui/src/pages/chat/components/chat-session-workspace.test.ts @@ -62,6 +62,95 @@ describe("custodian panel toggle", () => { }); }); +describe("session workspace artifacts", () => { + function createArtifactHost(params: { data: string; mimeType: string; title?: string }) { + const handleOpenSidebar = vi.fn(); + const request = vi.fn().mockResolvedValue({ + artifact: { + id: "artifact-1", + mimeType: params.mimeType, + title: params.title ?? "Unicode artifact", + }, + data: params.data, + encoding: "base64", + }); + const state = { + client: { request }, + connected: true, + handleOpenSidebar, + hello: gatewayHello([]), + sessionKey: "agent:main:current", + sessions: {}, + } as unknown as SessionWorkspaceHost; + return { handleOpenSidebar, request, state }; + } + + it.each([ + { + content: "Résumé 東京 🦀", + fence: "```", + mimeType: "text/plain", + }, + { + content: JSON.stringify({ message: "Résumé 東京 🦀" }), + fence: "```json", + mimeType: "application/json", + }, + ])( + "decodes UTF-8 $mimeType artifacts without corrupting visible or raw text", + async (testCase) => { + const data = btoa(String.fromCharCode(...new TextEncoder().encode(testCase.content))); + const { handleOpenSidebar, state } = createArtifactHost({ + data, + mimeType: testCase.mimeType, + }); + + createSessionWorkspaceProps(state).onOpenArtifact("artifact-1"); + + await vi.waitFor(() => expect(handleOpenSidebar).toHaveBeenCalledOnce()); + expect(handleOpenSidebar.mock.calls[0]?.[0]).toEqual({ + kind: "markdown", + content: `# Unicode artifact\n\n${testCase.fence}\n${testCase.content}\n\`\`\``, + rawText: testCase.content, + }); + }, + ); + + it("preserves inline image artifacts as their original base64 data URLs", async () => { + const data = "iVBORw0KGgo="; + const { handleOpenSidebar, state } = createArtifactHost({ + data, + mimeType: "image/png", + title: "preview.png", + }); + + createSessionWorkspaceProps(state).onOpenArtifact("artifact-1"); + + await vi.waitFor(() => expect(handleOpenSidebar).toHaveBeenCalledOnce()); + expect(handleOpenSidebar.mock.calls[0]?.[0]).toEqual({ + kind: "image", + mimeType: "image/png", + rawText: null, + src: `data:image/png;base64,${data}`, + title: "preview.png", + }); + }); + + it("reports malformed base64 artifact data as a visible workspace error", async () => { + const { handleOpenSidebar, state } = createArtifactHost({ + data: "not-base64!", + mimeType: "text/plain", + }); + + createSessionWorkspaceProps(state).onOpenArtifact("artifact-1"); + + await vi.waitFor(() => + expect(createSessionWorkspaceProps(state).error).toMatch(/InvalidCharacterError|invalid/i), + ); + expect(handleOpenSidebar).not.toHaveBeenCalled(); + }); +}); + describe("openSessionWorkspaceFile", () => { it("opens Markdown with a canonical Gateway- and pane-scoped draft identity", async () => { const handleOpenSidebar = vi.fn(); diff --git a/ui/src/pages/chat/components/chat-session-workspace.ts b/ui/src/pages/chat/components/chat-session-workspace.ts index 17a6e9d8cc3b..ee29fe73ced3 100644 --- a/ui/src/pages/chat/components/chat-session-workspace.ts +++ b/ui/src/pages/chat/components/chat-session-workspace.ts @@ -281,19 +281,17 @@ function artifactSidebarContent(params: { rawText: url ?? null, }; } - if (encoding === "base64" && data && mimeType === "application/json") { - const decoded = globalThis.atob(data); + if ( + encoding === "base64" && + data && + (mimeType === "application/json" || mimeType.startsWith("text/")) + ) { + const bytes = Uint8Array.from(globalThis.atob(data), (char) => char.charCodeAt(0)); + const decoded = new TextDecoder().decode(bytes); + const language = mimeType === "application/json" ? "json" : ""; return { kind: "markdown", - content: `# ${title}\n\n\`\`\`json\n${decoded}\n\`\`\``, - rawText: decoded, - }; - } - if (encoding === "base64" && data && mimeType.startsWith("text/")) { - const decoded = globalThis.atob(data); - return { - kind: "markdown", - content: `# ${title}\n\n\`\`\`\n${decoded}\n\`\`\``, + content: `# ${title}\n\n\`\`\`${language}\n${decoded}\n\`\`\``, rawText: decoded, }; }