From 4354e9bd50e6ee004ed12f9569d323e8372baa37 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:10:54 +0000 Subject: [PATCH 01/43] fix(site/src/pages/AgentsPage): stop attachment downloads from trapping iOS PWAs iOS home-screen web apps open targets in a QuickLook preview that has no dismiss chrome, so tapping the download icon on a chat attachment locked the app until it was killed. Intercept those clicks and hand the file to the native share sheet instead, falling back to a dismissible in-app browser tab when file sharing is unavailable. Other platforms keep the native anchor download. --- .../ChatConversation/AttachmentBlocks.tsx | 29 +++- .../AgentsPage/utils/chatAttachments.test.ts | 164 +++++++++++++++++- .../pages/AgentsPage/utils/chatAttachments.ts | 91 ++++++++++ 3 files changed, 279 insertions(+), 5 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index 0ffc8b10a94..528c43d2023 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -17,6 +17,7 @@ import { type AttachmentFailure, attachmentFailureFromError, getChatFileURL, + handleAttachmentDownloadClick, isAbortError, probeAttachmentFailure, } from "../../utils/chatAttachments"; @@ -141,11 +142,19 @@ const DownloadOverlay: FC<{ href: string; displayName: string; downloadName: string; -}> = ({ href, displayName, downloadName }) => ( + mediaType: string; +}> = ({ href, displayName, downloadName, mediaType }) => ( event.stopPropagation()} + onClick={(event) => { + event.stopPropagation(); + void handleAttachmentDownloadClick(event, { + href, + fileName: downloadName, + mediaType, + }); + }} aria-label={`Download ${displayName}`} className="invisible absolute right-1 top-1 flex size-6 items-center justify-center rounded bg-surface-primary/80 text-content-secondary opacity-0 shadow-sm backdrop-blur-sm transition-opacity hover:text-content-primary group-hover/attachment:visible group-hover/attachment:opacity-100 group-focus-within/attachment:visible group-focus-within/attachment:opacity-100 [@media(hover:none)]:visible [@media(hover:none)]:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link" > @@ -157,8 +166,9 @@ const AttachmentPreviewFrame: FC<{ href: string | null; displayName: string; downloadName: string; + mediaType: string; children: ReactNode; -}> = ({ href, displayName, downloadName, children }) => { +}> = ({ href, displayName, downloadName, mediaType, children }) => { return (
{children} @@ -167,6 +177,7 @@ const AttachmentPreviewFrame: FC<{ href={href} displayName={displayName} downloadName={downloadName} + mediaType={mediaType} /> ) : null}
@@ -385,6 +396,7 @@ const RemoteTextAttachmentButton: FC<{ href={frameHref} displayName={fileName ?? "Pasted text"} downloadName={downloadName} + mediaType={mediaType ?? ""} > {button} @@ -530,7 +542,14 @@ const FileCard: FC<{
event.stopPropagation()} + onClick={(event) => { + event.stopPropagation(); + void handleAttachmentDownloadClick(event, { + href, + fileName: downloadName, + mediaType: block.media_type, + }); + }} aria-label={`Download ${displayName}`} className="inline-flex h-16 max-w-sm items-center gap-3 rounded-md border border-solid border-border-default bg-surface-tertiary px-3 py-2 no-underline transition-colors hover:bg-surface-quaternary" > @@ -616,6 +635,7 @@ export const AttachmentBlock: FC<{ href={href} displayName={displayName} downloadName={downloadName} + mediaType={block.media_type} > {button} @@ -641,6 +661,7 @@ export const AttachmentBlock: FC<{ href={href} displayName={displayName} downloadName={downloadName} + mediaType={block.media_type} > {image} diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts index 19647576869..5693ceb9188 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts @@ -1,10 +1,172 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { + handleAttachmentDownloadClick, isChatAttachmentFile, renameChatFileForUpload, sanitizeChatFileName, } from "./chatAttachments"; +describe("handleAttachmentDownloadClick", () => { + const overriddenNavigatorKeys = new Set(); + const overrideNavigator = (key: string, value: unknown) => { + Object.defineProperty(window.navigator, key, { + value, + configurable: true, + }); + overriddenNavigatorKeys.add(key); + }; + + const iPhoneUserAgent = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15"; + + const enterIOSStandalonePWA = () => { + overrideNavigator("userAgent", iPhoneUserAgent); + overrideNavigator("standalone", true); + }; + + const target = { + href: "/api/experimental/chats/files/file-1", + fileName: "01-agents-list.png", + mediaType: "image/png", + }; + + afterEach(() => { + for (const key of overriddenNavigatorKeys) { + Reflect.deleteProperty(window.navigator, key); + } + overriddenNavigatorKeys.clear(); + vi.restoreAllMocks(); + }); + + it("keeps the native anchor download outside iOS", () => { + const open = vi.spyOn(window, "open").mockReturnValue(null); + const event = { preventDefault: vi.fn() }; + + expect(handleAttachmentDownloadClick(event, target)).toBeUndefined(); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(open).not.toHaveBeenCalled(); + }); + + it("keeps the native anchor download in the iOS browser", () => { + overrideNavigator("userAgent", iPhoneUserAgent); + const event = { preventDefault: vi.fn() }; + + expect(handleAttachmentDownloadClick(event, target)).toBeUndefined(); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it("shares the attachment via the share sheet in an iOS standalone PWA", async () => { + enterIOSStandalonePWA(); + const share = vi.fn().mockResolvedValue(undefined); + overrideNavigator("share", share); + overrideNavigator("canShare", vi.fn().mockReturnValue(true)); + const open = vi.spyOn(window, "open").mockReturnValue(null); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(new Blob(["png-bytes"], { type: "image/png" }), { + status: 200, + }), + ); + const event = { preventDefault: vi.fn() }; + + await handleAttachmentDownloadClick(event, target); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(globalThis.fetch).toHaveBeenCalledWith(target.href); + expect(open).not.toHaveBeenCalled(); + expect(share).toHaveBeenCalledTimes(1); + const shared: { files: File[] } = share.mock.calls[0][0]; + expect(shared.files).toHaveLength(1); + expect(shared.files[0].name).toBe("01-agents-list.png"); + expect(shared.files[0].type).toBe("image/png"); + }); + + it("intercepts on iPadOS reporting a macOS user agent", () => { + overrideNavigator( + "userAgent", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15", + ); + overrideNavigator("maxTouchPoints", 5); + overrideNavigator("standalone", true); + const open = vi.spyOn(window, "open").mockReturnValue(null); + const event = { preventDefault: vi.fn() }; + + expect(handleAttachmentDownloadClick(event, target)).toBeUndefined(); + expect(event.preventDefault).toHaveBeenCalled(); + expect(open).toHaveBeenCalled(); + }); + + it("falls back to a dismissible tab when file sharing is unavailable", () => { + enterIOSStandalonePWA(); + const open = vi.spyOn(window, "open").mockReturnValue(null); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const event = { preventDefault: vi.fn() }; + + expect(handleAttachmentDownloadClick(event, target)).toBeUndefined(); + expect(event.preventDefault).toHaveBeenCalled(); + expect(open).toHaveBeenCalledWith(target.href, "_blank", "noopener"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("stays quiet when the user dismisses the share sheet", async () => { + enterIOSStandalonePWA(); + overrideNavigator( + "share", + vi.fn().mockRejectedValue(new DOMException("canceled", "AbortError")), + ); + overrideNavigator("canShare", vi.fn().mockReturnValue(true)); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(new Blob(["png-bytes"], { type: "image/png" })), + ); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const event = { preventDefault: vi.fn() }; + + await handleAttachmentDownloadClick(event, target); + + expect(warn).not.toHaveBeenCalled(); + }); + + it("warns without a late popup when the download fetch fails", async () => { + enterIOSStandalonePWA(); + const share = vi.fn().mockResolvedValue(undefined); + overrideNavigator("share", share); + overrideNavigator("canShare", vi.fn().mockReturnValue(true)); + const open = vi.spyOn(window, "open").mockReturnValue(null); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("nope", { status: 503 }), + ); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const event = { preventDefault: vi.fn() }; + + await handleAttachmentDownloadClick(event, target); + + expect(share).not.toHaveBeenCalled(); + expect(open).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalled(); + }); + + it("skips sharing when the fetched file turns out unshareable", async () => { + enterIOSStandalonePWA(); + const share = vi.fn().mockResolvedValue(undefined); + overrideNavigator("share", share); + overrideNavigator( + "canShare", + vi + .fn<(data: { files: File[] }) => boolean>() + .mockImplementation(({ files }) => files[0].size <= 1), + ); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(new Blob(["png-bytes"], { type: "image/png" })), + ); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const event = { preventDefault: vi.fn() }; + + await handleAttachmentDownloadClick(event, target); + + expect(share).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalled(); + }); +}); + describe("isChatAttachmentFile", () => { it("accepts allowlisted MIME types", () => { const file = new File(["png"], "image.png", { type: "image/png" }); diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index 956753ffea5..e2e65cb9fdd 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -62,6 +62,97 @@ export async function probeAttachmentFailure( return classifyAttachmentFailureResponse(response); } +type IOSNavigator = Navigator & { standalone?: boolean }; + +const isIOS = (): boolean => + /iPad|iPhone|iPod/.test(navigator.userAgent) || + // iPadOS 13+ reports a macOS user agent; the touchscreen is the tell. + (navigator.userAgent.includes("Mac") && navigator.maxTouchPoints > 1); + +const isStandaloneDisplayMode = (): boolean => { + const nav: IOSNavigator = navigator; + return ( + window.matchMedia("(display-mode: standalone)").matches || + nav.standalone === true + ); +}; + +const canShareFiles = (files: File[]): boolean => + typeof navigator.share === "function" && + typeof navigator.canShare === "function" && + navigator.canShare({ files }); + +type AttachmentDownloadTarget = { + href: string; + fileName: string; + mediaType: string; +}; + +const shareAttachmentFile = async ({ + href, + fileName, + mediaType, +}: AttachmentDownloadTarget): Promise => { + try { + const response = await fetch(href); + if (!response.ok) { + throw new Error( + response.statusText + ? `${response.status} ${response.statusText}` + : `HTTP ${response.status}`, + ); + } + const blob = await response.blob(); + const file = new File([blob], fileName, { + type: blob.type || mediaType || "application/octet-stream", + }); + if (!canShareFiles([file])) { + console.warn("Attachment cannot be shared:", fileName); + return; + } + await navigator.share({ files: [file] }); + } catch (error) { + // A dismissed share sheet rejects with an AbortError DOMException, + // which is not an Error subclass in every engine, so match the + // name structurally instead of using isAbortError. + if ( + typeof error === "object" && + error !== null && + "name" in error && + error.name === "AbortError" + ) { + return; + } + console.warn("Failed to share attachment:", error); + } +}; + +/** + * iOS home-screen web apps open `` targets in a QuickLook + * preview with no dismiss chrome, leaving the app stuck until it is + * killed. Intercept those clicks and hand the file to the native share + * sheet (Save to Files / Save Image) instead; when file sharing is + * unavailable, open a dismissible in-app browser tab. Everywhere else + * the anchor's native download behavior is kept. + */ +export const handleAttachmentDownloadClick = ( + event: { preventDefault: () => void }, + target: AttachmentDownloadTarget, +): Promise | undefined => { + if (!isIOS() || !isStandaloneDisplayMode()) { + return undefined; + } + event.preventDefault(); + const probe = new File(["0"], target.fileName, { type: target.mediaType }); + if (!canShareFiles([probe])) { + // Open synchronously; after an await the user activation that + // popup blockers require may already be consumed. + window.open(target.href, "_blank", "noopener"); + return undefined; + } + return shareAttachmentFile(target); +}; + // Filename extensions to list in the file-picker's `accept` attribute // alongside the MIME types. Browsers and operating systems do not always // map these extensions to a registered MIME type (Markdown is the common From 7546235e98711aef7836d5980f902bf7994570e1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:06:04 +0000 Subject: [PATCH 02/43] test(site/src/pages/AgentsPage): cover iOS standalone download interception in Storybook Adds play interactions for the share-sheet path, the no-share fallback tab, and the untouched native anchor outside iOS. --- .../ConversationTimeline.stories.tsx | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index a08c233e5d3..e98d3a68dbc 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -147,6 +147,10 @@ const ATTACHMENT_RESPONSES = new Map([ }, ], ["storybook-text-error", { body: "Temporary failure", status: 503 }], + [ + "storybook-ios-share-report", + { status: 200, body: "pdf-bytes", contentType: "application/pdf" }, + ], ]); let attachmentFetchCounts = new Map(); @@ -187,6 +191,31 @@ const mockAttachmentFetch = () => { }); }; +// Shadows the Navigator.prototype getters with own properties so the +// download handler sees an iOS standalone PWA; the returned cleanup +// restores the real values for subsequent stories. +const overrideNavigatorForIOSStandalone = ( + extras: Record = {}, +): (() => void) => { + const overrides: Record = { + userAgent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15", + standalone: true, + ...extras, + }; + for (const [key, value] of Object.entries(overrides)) { + Object.defineProperty(window.navigator, key, { + value, + configurable: true, + }); + } + return () => { + for (const key of Object.keys(overrides)) { + Reflect.deleteProperty(window.navigator, key); + } + }; +}; + const buildTextPart = (text: string): TypesGen.ChatTextPart => ({ type: "text", text, @@ -1294,6 +1323,112 @@ export const AssistantMessageWithUnnamedDownloadableFile: Story = { }, }; +const iosDownloadStoryArgs: Story["args"] = { + ...defaultArgs, + parsedMessages: parseMessagesWithMergedTools([ + { + ...baseMessage, + id: 1, + role: "user", + content: [ + { type: "text", text: "I attached the deployment report." }, + { + type: "file", + media_type: "application/pdf", + file_id: "storybook-ios-share-report", + name: "deployment-report.pdf", + }, + ], + }, + ]), +}; + +/** In an iOS standalone PWA the download click hands the file to the share sheet. */ +export const DownloadInIOSStandaloneSharesFile: Story = { + args: iosDownloadStoryArgs, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const share = fn().mockResolvedValue(undefined); + const restoreNavigator = overrideNavigatorForIOSStandalone({ + share, + canShare: fn().mockReturnValue(true), + }); + const open = spyOn(window, "open").mockReturnValue(null); + try { + await userEvent.click( + canvas.getByRole("link", { name: "Download deployment-report.pdf" }), + ); + await waitFor(() => expect(share).toHaveBeenCalledTimes(1)); + const shared: { files: File[] } = share.mock.calls[0][0]; + expect(shared.files).toHaveLength(1); + expect(shared.files[0].name).toBe("deployment-report.pdf"); + expect(shared.files[0].type).toBe("application/pdf"); + expect(getAttachmentFetchCount("storybook-ios-share-report")).toBe(1); + expect(open).not.toHaveBeenCalled(); + } finally { + restoreNavigator(); + } + }, +}; + +/** Without file sharing, an iOS standalone PWA gets a dismissible tab instead of QuickLook. */ +export const DownloadInIOSStandaloneWithoutShareOpensTab: Story = { + args: iosDownloadStoryArgs, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const restoreNavigator = overrideNavigatorForIOSStandalone({ + share: undefined, + canShare: undefined, + }); + const open = spyOn(window, "open").mockReturnValue(null); + try { + await userEvent.click( + canvas.getByRole("link", { name: "Download deployment-report.pdf" }), + ); + expect(open).toHaveBeenCalledWith( + getChatFileURL("storybook-ios-share-report"), + "_blank", + "noopener", + ); + expect(getAttachmentFetchCount("storybook-ios-share-report")).toBe(0); + } finally { + restoreNavigator(); + } + }, +}; + +/** Outside iOS standalone the anchor keeps its native download behavior. */ +export const DownloadOutsideIOSKeepsNativeAnchor: Story = { + args: iosDownloadStoryArgs, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const share = fn().mockResolvedValue(undefined); + Object.defineProperty(window.navigator, "share", { + value: share, + configurable: true, + }); + const open = spyOn(window, "open").mockReturnValue(null); + // Block the real download navigation the test browser would start; + // capture phase runs before the component's handler and does not + // affect whether that handler intercepts the click. + const blockDownload = (event: Event) => event.preventDefault(); + document.addEventListener("click", blockDownload, { capture: true }); + try { + const downloadLink = canvas.getByRole("link", { + name: "Download deployment-report.pdf", + }); + expect(downloadLink).toHaveAttribute("download", "deployment-report.pdf"); + await userEvent.click(downloadLink); + expect(share).not.toHaveBeenCalled(); + expect(open).not.toHaveBeenCalled(); + expect(getAttachmentFetchCount("storybook-ios-share-report")).toBe(0); + } finally { + document.removeEventListener("click", blockDownload, { capture: true }); + Reflect.deleteProperty(window.navigator, "share"); + } + }, +}; + /** Images and file-references coexist without interfering. */ export const UserMessageWithImagesAndFileRefs: Story = { args: { From 6ed7b29a30d12c8474da4763f33dad9139b7b763 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:13:53 +0000 Subject: [PATCH 03/43] refactor(site/src/pages/AgentsPage): access browser globals without window prefix --- .../ChatConversation/ConversationTimeline.stories.tsx | 8 ++++---- site/src/pages/AgentsPage/utils/chatAttachments.test.ts | 4 ++-- site/src/pages/AgentsPage/utils/chatAttachments.ts | 5 ++--- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index e98d3a68dbc..db43dbec42a 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -204,14 +204,14 @@ const overrideNavigatorForIOSStandalone = ( ...extras, }; for (const [key, value] of Object.entries(overrides)) { - Object.defineProperty(window.navigator, key, { + Object.defineProperty(navigator, key, { value, configurable: true, }); } return () => { for (const key of Object.keys(overrides)) { - Reflect.deleteProperty(window.navigator, key); + Reflect.deleteProperty(navigator, key); } }; }; @@ -1403,7 +1403,7 @@ export const DownloadOutsideIOSKeepsNativeAnchor: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); const share = fn().mockResolvedValue(undefined); - Object.defineProperty(window.navigator, "share", { + Object.defineProperty(navigator, "share", { value: share, configurable: true, }); @@ -1424,7 +1424,7 @@ export const DownloadOutsideIOSKeepsNativeAnchor: Story = { expect(getAttachmentFetchCount("storybook-ios-share-report")).toBe(0); } finally { document.removeEventListener("click", blockDownload, { capture: true }); - Reflect.deleteProperty(window.navigator, "share"); + Reflect.deleteProperty(navigator, "share"); } }, }; diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts index 5693ceb9188..59165af67be 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts @@ -9,7 +9,7 @@ import { describe("handleAttachmentDownloadClick", () => { const overriddenNavigatorKeys = new Set(); const overrideNavigator = (key: string, value: unknown) => { - Object.defineProperty(window.navigator, key, { + Object.defineProperty(navigator, key, { value, configurable: true, }); @@ -32,7 +32,7 @@ describe("handleAttachmentDownloadClick", () => { afterEach(() => { for (const key of overriddenNavigatorKeys) { - Reflect.deleteProperty(window.navigator, key); + Reflect.deleteProperty(navigator, key); } overriddenNavigatorKeys.clear(); vi.restoreAllMocks(); diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index e2e65cb9fdd..8a4468ff739 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -72,8 +72,7 @@ const isIOS = (): boolean => const isStandaloneDisplayMode = (): boolean => { const nav: IOSNavigator = navigator; return ( - window.matchMedia("(display-mode: standalone)").matches || - nav.standalone === true + matchMedia("(display-mode: standalone)").matches || nav.standalone === true ); }; @@ -147,7 +146,7 @@ export const handleAttachmentDownloadClick = ( if (!canShareFiles([probe])) { // Open synchronously; after an await the user activation that // popup blockers require may already be consumed. - window.open(target.href, "_blank", "noopener"); + open(target.href, "_blank", "noopener"); return undefined; } return shareAttachmentFile(target); From 88157eff3d3265f4c180985f7f2552a942f63534 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:23:29 +0000 Subject: [PATCH 04/43] fix(site/src/pages/AgentsPage): surface intercepted attachment download failures After the iOS standalone handler prevents the anchor's native action, a failed fetch or share left the user with no feedback. Show an error toast instead of only logging to the console. --- .../AgentsPage/utils/chatAttachments.test.ts | 26 ++++++++++++++----- .../pages/AgentsPage/utils/chatAttachments.ts | 9 +++++-- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts index 59165af67be..dff0fd3c616 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts @@ -1,4 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("sonner", () => ({ + toast: { + error: vi.fn(), + }, +})); + +import { toast } from "sonner"; import { handleAttachmentDownloadClick, isChatAttachmentFile, @@ -36,6 +44,7 @@ describe("handleAttachmentDownloadClick", () => { } overriddenNavigatorKeys.clear(); vi.restoreAllMocks(); + vi.mocked(toast.error).mockClear(); }); it("keeps the native anchor download outside iOS", () => { @@ -117,15 +126,14 @@ describe("handleAttachmentDownloadClick", () => { vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response(new Blob(["png-bytes"], { type: "image/png" })), ); - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); const event = { preventDefault: vi.fn() }; await handleAttachmentDownloadClick(event, target); - expect(warn).not.toHaveBeenCalled(); + expect(toast.error).not.toHaveBeenCalled(); }); - it("warns without a late popup when the download fetch fails", async () => { + it("shows an error toast without a late popup when the download fetch fails", async () => { enterIOSStandalonePWA(); const share = vi.fn().mockResolvedValue(undefined); overrideNavigator("share", share); @@ -134,14 +142,16 @@ describe("handleAttachmentDownloadClick", () => { vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response("nope", { status: 503 }), ); - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); const event = { preventDefault: vi.fn() }; await handleAttachmentDownloadClick(event, target); expect(share).not.toHaveBeenCalled(); expect(open).not.toHaveBeenCalled(); - expect(warn).toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith( + "Couldn't download 01-agents-list.png", + { description: "HTTP 503" }, + ); }); it("skips sharing when the fetched file turns out unshareable", async () => { @@ -157,13 +167,15 @@ describe("handleAttachmentDownloadClick", () => { vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response(new Blob(["png-bytes"], { type: "image/png" })), ); - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); const event = { preventDefault: vi.fn() }; await handleAttachmentDownloadClick(event, target); expect(share).not.toHaveBeenCalled(); - expect(warn).toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith( + "Couldn't download 01-agents-list.png", + { description: "This file cannot be shared on this device." }, + ); }); }); diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index 8a4468ff739..4d72c8a426a 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -1,3 +1,4 @@ +import { toast } from "sonner"; import { isApiErrorResponse } from "#/api/errors"; import { ChatAttachmentMediaTypes } from "#/api/typesGenerated"; @@ -106,7 +107,9 @@ const shareAttachmentFile = async ({ type: blob.type || mediaType || "application/octet-stream", }); if (!canShareFiles([file])) { - console.warn("Attachment cannot be shared:", fileName); + toast.error(`Couldn't download ${fileName}`, { + description: "This file cannot be shared on this device.", + }); return; } await navigator.share({ files: [file] }); @@ -122,7 +125,9 @@ const shareAttachmentFile = async ({ ) { return; } - console.warn("Failed to share attachment:", error); + toast.error(`Couldn't download ${fileName}`, { + description: error instanceof Error ? error.message : "Tap to try again.", + }); } }; From 6baa5cb6bd6bdf39764d1d29d4ca1468cf1225de Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:31:39 +0000 Subject: [PATCH 05/43] fix(site/src/pages/AgentsPage): recover when user activation expires before share A slow attachment fetch can outlive iOS's transient user activation, making navigator.share reject with NotAllowedError after the native anchor action was already prevented. Keep the fetched file and offer a Save action on the error toast; the action click is a fresh gesture, so retrying from it succeeds. --- .../AgentsPage/utils/chatAttachments.test.ts | 36 +++++++++++ .../pages/AgentsPage/utils/chatAttachments.ts | 61 +++++++++++++------ 2 files changed, 77 insertions(+), 20 deletions(-) diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts index dff0fd3c616..1b184dc1aff 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts @@ -154,6 +154,42 @@ describe("handleAttachmentDownloadClick", () => { ); }); + it("offers a fresh-gesture retry when user activation expired during the fetch", async () => { + enterIOSStandalonePWA(); + const share = vi + .fn() + .mockRejectedValueOnce( + new DOMException("activation expired", "NotAllowedError"), + ) + .mockResolvedValue(undefined); + overrideNavigator("share", share); + overrideNavigator("canShare", vi.fn().mockReturnValue(true)); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(new Blob(["png-bytes"], { type: "image/png" })), + ); + const event = { preventDefault: vi.fn() }; + + await handleAttachmentDownloadClick(event, target); + + expect(toast.error).toHaveBeenCalledWith( + "Couldn't download 01-agents-list.png", + expect.objectContaining({ + description: "The file is ready to save.", + action: expect.objectContaining({ label: "Save" }), + }), + ); + const action = vi.mocked(toast.error).mock.calls[0][1]?.action; + if (!action || typeof action !== "object" || !("onClick" in action)) { + throw new Error("expected the toast to carry a retry action"); + } + // The handler ignores the click event, so an empty stand-in works. + action.onClick({} as React.MouseEvent); + expect(share).toHaveBeenCalledTimes(2); + expect(share).toHaveBeenLastCalledWith({ + files: [expect.objectContaining({ name: "01-agents-list.png" })], + }); + }); + it("skips sharing when the fetched file turns out unshareable", async () => { enterIOSStandalonePWA(); const share = vi.fn().mockResolvedValue(undefined); diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index 4d72c8a426a..b72dd212888 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -88,11 +88,42 @@ type AttachmentDownloadTarget = { mediaType: string; }; +// Share failures are DOMExceptions, which are not Error subclasses in +// every engine, so match names structurally instead of instanceof. +const errorHasName = (error: unknown, name: string): boolean => + typeof error === "object" && + error !== null && + "name" in error && + error.name === name; + +const shareFileViaSheet = (file: File, fileName: string): Promise => + navigator.share({ files: [file] }).catch((error: unknown) => { + // A dismissed share sheet rejects with AbortError. + if (errorHasName(error, "AbortError")) { + return; + } + // A slow fetch can outlive iOS's transient user activation, making + // share() reject with NotAllowedError. The toast action click is a + // fresh gesture, so retrying from it shares the already-fetched file. + toast.error(`Couldn't download ${fileName}`, { + description: errorHasName(error, "NotAllowedError") + ? "The file is ready to save." + : error instanceof Error + ? error.message + : undefined, + action: { + label: "Save", + onClick: () => void shareFileViaSheet(file, fileName), + }, + }); + }); + const shareAttachmentFile = async ({ href, fileName, mediaType, }: AttachmentDownloadTarget): Promise => { + let file: File; try { const response = await fetch(href); if (!response.ok) { @@ -103,32 +134,22 @@ const shareAttachmentFile = async ({ ); } const blob = await response.blob(); - const file = new File([blob], fileName, { + file = new File([blob], fileName, { type: blob.type || mediaType || "application/octet-stream", }); - if (!canShareFiles([file])) { - toast.error(`Couldn't download ${fileName}`, { - description: "This file cannot be shared on this device.", - }); - return; - } - await navigator.share({ files: [file] }); } catch (error) { - // A dismissed share sheet rejects with an AbortError DOMException, - // which is not an Error subclass in every engine, so match the - // name structurally instead of using isAbortError. - if ( - typeof error === "object" && - error !== null && - "name" in error && - error.name === "AbortError" - ) { - return; - } toast.error(`Couldn't download ${fileName}`, { - description: error instanceof Error ? error.message : "Tap to try again.", + description: error instanceof Error ? error.message : undefined, + }); + return; + } + if (!canShareFiles([file])) { + toast.error(`Couldn't download ${fileName}`, { + description: "This file cannot be shared on this device.", }); + return; } + await shareFileViaSheet(file, fileName); }; /** From 54d31898b5a80f41b62d5783f2b623ff9b5b84f3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:38:08 +0000 Subject: [PATCH 06/43] fix(site/src/pages/AgentsPage): offer share retry only for expired activation A Save action on other share rejections would fail identically on every click; keep it only for NotAllowedError. --- .../AgentsPage/utils/chatAttachments.test.ts | 20 ++++++++++++++++ .../pages/AgentsPage/utils/chatAttachments.ts | 24 +++++++++++-------- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts index 1b184dc1aff..aecfa1b9140 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts @@ -190,6 +190,26 @@ describe("handleAttachmentDownloadClick", () => { }); }); + it("reports permanent share failures without a retry action", async () => { + enterIOSStandalonePWA(); + overrideNavigator( + "share", + vi.fn().mockRejectedValue(new DOMException("share failed", "DataError")), + ); + overrideNavigator("canShare", vi.fn().mockReturnValue(true)); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(new Blob(["png-bytes"], { type: "image/png" })), + ); + const event = { preventDefault: vi.fn() }; + + await handleAttachmentDownloadClick(event, target); + + expect(toast.error).toHaveBeenCalledTimes(1); + expect(vi.mocked(toast.error).mock.calls[0][1]).not.toHaveProperty( + "action", + ); + }); + it("skips sharing when the fetched file turns out unshareable", async () => { enterIOSStandalonePWA(); const share = vi.fn().mockResolvedValue(undefined); diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index b72dd212888..68545cb1424 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -104,17 +104,21 @@ const shareFileViaSheet = (file: File, fileName: string): Promise => } // A slow fetch can outlive iOS's transient user activation, making // share() reject with NotAllowedError. The toast action click is a - // fresh gesture, so retrying from it shares the already-fetched file. + // fresh gesture, so retrying from it shares the already-fetched + // file. Other rejections would fail a retry identically, so they + // get no action. + if (errorHasName(error, "NotAllowedError")) { + toast.error(`Couldn't download ${fileName}`, { + description: "The file is ready to save.", + action: { + label: "Save", + onClick: () => void shareFileViaSheet(file, fileName), + }, + }); + return; + } toast.error(`Couldn't download ${fileName}`, { - description: errorHasName(error, "NotAllowedError") - ? "The file is ready to save." - : error instanceof Error - ? error.message - : undefined, - action: { - label: "Save", - onClick: () => void shareFileViaSheet(file, fileName), - }, + description: error instanceof Error ? error.message : undefined, }); }); From 39b8727e487fe8821c9a3c98a5adb3c9d9ce84a4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:47:37 +0000 Subject: [PATCH 07/43] test(site/src/pages/AgentsPage): exercise the Save retry through the rendered toast Clicks the real toast action in a Storybook play instead of invoking the callback with a cast stand-in event in the unit test. --- .../ConversationTimeline.stories.tsx | 35 +++++++++++++++++++ .../AgentsPage/utils/chatAttachments.test.ts | 14 +++----- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index db43dbec42a..aa04c551987 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -10,6 +10,7 @@ import { within, } from "storybook/test"; import type * as TypesGen from "#/api/typesGenerated"; +import { withToaster } from "#/testHelpers/storybook"; import { getChatFileURL } from "../../utils/chatAttachments"; import { encodeInlineTextAttachment } from "../../utils/fetchTextAttachment"; import { ConversationTimeline } from "./ConversationTimeline"; @@ -1371,6 +1372,40 @@ export const DownloadInIOSStandaloneSharesFile: Story = { }, }; +/** When user activation expires before share(), the error toast's Save action retries with a fresh gesture. */ +export const DownloadInIOSStandaloneRecoversExpiredActivation: Story = { + decorators: [withToaster], + args: iosDownloadStoryArgs, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const share = fn() + .mockRejectedValueOnce( + new DOMException("activation expired", "NotAllowedError"), + ) + .mockResolvedValue(undefined); + const restoreNavigator = overrideNavigatorForIOSStandalone({ + share, + canShare: fn().mockReturnValue(true), + }); + const open = spyOn(window, "open").mockReturnValue(null); + try { + await userEvent.click( + canvas.getByRole("link", { name: "Download deployment-report.pdf" }), + ); + // The toast renders in a portal outside the story canvas. + const saveButton = await screen.findByRole("button", { name: "Save" }); + await userEvent.click(saveButton); + await waitFor(() => expect(share).toHaveBeenCalledTimes(2)); + const shared: { files: File[] } = share.mock.calls[1][0]; + expect(shared.files).toHaveLength(1); + expect(shared.files[0].name).toBe("deployment-report.pdf"); + expect(open).not.toHaveBeenCalled(); + } finally { + restoreNavigator(); + } + }, +}; + /** Without file sharing, an iOS standalone PWA gets a dismissible tab instead of QuickLook. */ export const DownloadInIOSStandaloneWithoutShareOpensTab: Story = { args: iosDownloadStoryArgs, diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts index aecfa1b9140..3d4797c3566 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts @@ -171,6 +171,10 @@ describe("handleAttachmentDownloadClick", () => { await handleAttachmentDownloadClick(event, target); + // Clicking the rendered Save button and verifying the second share + // attempt is covered by the DownloadInIOSStandaloneRecoversExpiredActivation + // Storybook interaction, which exercises the real toast UI. + expect(share).toHaveBeenCalledTimes(1); expect(toast.error).toHaveBeenCalledWith( "Couldn't download 01-agents-list.png", expect.objectContaining({ @@ -178,16 +182,6 @@ describe("handleAttachmentDownloadClick", () => { action: expect.objectContaining({ label: "Save" }), }), ); - const action = vi.mocked(toast.error).mock.calls[0][1]?.action; - if (!action || typeof action !== "object" || !("onClick" in action)) { - throw new Error("expected the toast to carry a retry action"); - } - // The handler ignores the click event, so an empty stand-in works. - action.onClick({} as React.MouseEvent); - expect(share).toHaveBeenCalledTimes(2); - expect(share).toHaveBeenLastCalledWith({ - files: [expect.objectContaining({ name: "01-agents-list.png" })], - }); }); it("reports permanent share failures without a retry action", async () => { From e5798239ca61ee93dbddf43dabdc103cec73ed10 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:55:43 +0000 Subject: [PATCH 08/43] chore(site/src/pages/AgentsPage): tighten comments added on this branch --- .../ConversationTimeline.stories.tsx | 12 ++--------- .../AgentsPage/utils/chatAttachments.test.ts | 5 ++--- .../pages/AgentsPage/utils/chatAttachments.ts | 20 +++++++------------ 3 files changed, 11 insertions(+), 26 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index aa04c551987..42ac8ba097c 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -192,9 +192,7 @@ const mockAttachmentFetch = () => { }); }; -// Shadows the Navigator.prototype getters with own properties so the -// download handler sees an iOS standalone PWA; the returned cleanup -// restores the real values for subsequent stories. +// Read-only Navigator values must be shadowed with removable own properties. const overrideNavigatorForIOSStandalone = ( extras: Record = {}, ): (() => void) => { @@ -1344,7 +1342,6 @@ const iosDownloadStoryArgs: Story["args"] = { ]), }; -/** In an iOS standalone PWA the download click hands the file to the share sheet. */ export const DownloadInIOSStandaloneSharesFile: Story = { args: iosDownloadStoryArgs, play: async ({ canvasElement }) => { @@ -1372,7 +1369,6 @@ export const DownloadInIOSStandaloneSharesFile: Story = { }, }; -/** When user activation expires before share(), the error toast's Save action retries with a fresh gesture. */ export const DownloadInIOSStandaloneRecoversExpiredActivation: Story = { decorators: [withToaster], args: iosDownloadStoryArgs, @@ -1406,7 +1402,6 @@ export const DownloadInIOSStandaloneRecoversExpiredActivation: Story = { }, }; -/** Without file sharing, an iOS standalone PWA gets a dismissible tab instead of QuickLook. */ export const DownloadInIOSStandaloneWithoutShareOpensTab: Story = { args: iosDownloadStoryArgs, play: async ({ canvasElement }) => { @@ -1432,7 +1427,6 @@ export const DownloadInIOSStandaloneWithoutShareOpensTab: Story = { }, }; -/** Outside iOS standalone the anchor keeps its native download behavior. */ export const DownloadOutsideIOSKeepsNativeAnchor: Story = { args: iosDownloadStoryArgs, play: async ({ canvasElement }) => { @@ -1443,9 +1437,7 @@ export const DownloadOutsideIOSKeepsNativeAnchor: Story = { configurable: true, }); const open = spyOn(window, "open").mockReturnValue(null); - // Block the real download navigation the test browser would start; - // capture phase runs before the component's handler and does not - // affect whether that handler intercepts the click. + // Prevent the test browser's native download without stopping the component handler. const blockDownload = (event: Event) => event.preventDefault(); document.addEventListener("click", blockDownload, { capture: true }); try { diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts index 3d4797c3566..d41ae9d812c 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts @@ -171,9 +171,8 @@ describe("handleAttachmentDownloadClick", () => { await handleAttachmentDownloadClick(event, target); - // Clicking the rendered Save button and verifying the second share - // attempt is covered by the DownloadInIOSStandaloneRecoversExpiredActivation - // Storybook interaction, which exercises the real toast UI. + // DownloadInIOSStandaloneRecoversExpiredActivation covers the Save click + // and retry through the real toast UI. expect(share).toHaveBeenCalledTimes(1); expect(toast.error).toHaveBeenCalledWith( "Couldn't download 01-agents-list.png", diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index 68545cb1424..ae27d6aec2f 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -88,8 +88,8 @@ type AttachmentDownloadTarget = { mediaType: string; }; -// Share failures are DOMExceptions, which are not Error subclasses in -// every engine, so match names structurally instead of instanceof. +// Web Share failures are DOMExceptions, which are not Error subclasses +// in jsdom, so match names structurally instead of via instanceof. const errorHasName = (error: unknown, name: string): boolean => typeof error === "object" && error !== null && @@ -102,11 +102,8 @@ const shareFileViaSheet = (file: File, fileName: string): Promise => if (errorHasName(error, "AbortError")) { return; } - // A slow fetch can outlive iOS's transient user activation, making - // share() reject with NotAllowedError. The toast action click is a - // fresh gesture, so retrying from it shares the already-fetched - // file. Other rejections would fail a retry identically, so they - // get no action. + // iOS transient activation can expire while the file is fetched. + // The toast action provides a fresh gesture, so only NotAllowedError gets a retry. if (errorHasName(error, "NotAllowedError")) { toast.error(`Couldn't download ${fileName}`, { description: "The file is ready to save.", @@ -157,12 +154,9 @@ const shareAttachmentFile = async ({ }; /** - * iOS home-screen web apps open `` targets in a QuickLook - * preview with no dismiss chrome, leaving the app stuck until it is - * killed. Intercept those clicks and hand the file to the native share - * sheet (Save to Files / Save Image) instead; when file sharing is - * unavailable, open a dismissible in-app browser tab. Everywhere else - * the anchor's native download behavior is kept. + * Avoids iOS standalone PWA QuickLook, which can leave no way back to the app. + * Uses the share sheet when possible, or a dismissible tab when file sharing + * is unavailable. Other environments keep native download behavior. */ export const handleAttachmentDownloadClick = ( event: { preventDefault: () => void }, From 28b395e5b81e0c6667fd51644b090d07d430ecd0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:39:30 +0000 Subject: [PATCH 09/43] fix(site/src/pages/AgentsPage): give extensionless attachment names a download extension iOS resolves the shared or saved file's type from the filename extension, so sharing an image named "About Page Screenshot" landed in the share sheet as a generic file with no Save Image option. Append the media-type-derived extension when the name lacks one. --- .../ChatConversation/AttachmentBlocks.tsx | 12 +++++- .../ConversationTimeline.stories.tsx | 37 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index 528c43d2023..2d9206694b1 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -120,14 +120,22 @@ const getAttachmentDisplayName = ( return "Attached file"; }; +const endsWithFileExtension = /\.[a-z0-9]{1,8}$/i; + const getAttachmentDownloadName = ( block: Pick, ): string => { const name = block.name?.trim(); + const extension = getAttachmentExtension(block); if (name) { - return name; + // iOS resolves the shared or saved file's type from the filename + // extension, so an extensionless name like "About Page Screenshot" + // would land as a generic file even when the media type is known. + if (endsWithFileExtension.test(name) || extension === "file") { + return name; + } + return `${name}.${extension}`; } - const extension = getAttachmentExtension(block); return extension === "file" ? "attachment" : `attachment.${extension}`; }; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 42ac8ba097c..39cbbda3abc 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1289,6 +1289,43 @@ export const AssistantMessageWithImage: Story = { }, }; +export const ExtensionlessImageNameGainsDownloadExtension: Story = { + args: { + ...defaultArgs, + parsedMessages: buildMessages([ + { + ...baseMessage, + id: 1, + role: "assistant", + content: [ + { type: "text", text: "Here is the screenshot:" }, + { + type: "file", + media_type: "image/png", + data: TEST_PNG_B64, + name: "About Page Screenshot", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const viewButton = canvas.getByRole("button", { + name: "View About Page Screenshot", + }); + viewButton.focus(); + await waitFor(() => { + expect( + canvas.getByRole("link", { name: "Download About Page Screenshot" }), + ).toBeVisible(); + }); + expect( + canvas.getByRole("link", { name: "Download About Page Screenshot" }), + ).toHaveAttribute("download", "About Page Screenshot.png"); + }, +}; + export const AssistantMessageWithUnnamedDownloadableFile: Story = { args: { ...defaultArgs, From 244ba7193d0002cbb417487fa9ef6d7f9d70c895 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:50:24 +0000 Subject: [PATCH 10/43] fix(site/src/pages/AgentsPage): derive download extensions from the media type A dotted qualifier like "report.final" or "screenshot.2026" was treated as a file extension, so the appended-extension fix did not apply and iOS still saved the file as a generic type. Validate the name suffix against the media type's canonical extension and aliases, and cover the failed-fetch error toast in a Storybook interaction. --- .../ChatConversation/AttachmentBlocks.tsx | 57 +++++++++++++--- .../ConversationTimeline.stories.tsx | 67 ++++++++++++++++++- 2 files changed, 111 insertions(+), 13 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index 2d9206694b1..6d6428ce954 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -120,23 +120,58 @@ const getAttachmentDisplayName = ( return "Attached file"; }; -const endsWithFileExtension = /\.[a-z0-9]{1,8}$/i; +const endsWithFileExtension = /\.([a-z0-9]{1,8})$/i; + +// Alternate name suffixes that identify the same type as the canonical +// media-type extension, so "photo.jpeg" is not renamed to "photo.jpeg.jpg". +const extensionAliases: Record = { + jpg: ["jpeg"], + md: ["markdown"], + txt: ["text", "log"], +}; + +// Returns the extension implied by the media type alone, ignoring the +// attachment name. Null means the type carries no usable extension. +const getMediaTypeExtension = (mediaType: string): string | null => { + if (mediaType === "application/octet-stream") { + return null; + } + const mapped = ATTACHMENT_FALLBACK_EXTENSIONS[mediaType]; + if (mapped) { + return mapped; + } + const subtype = mediaType.split("/")[1] ?? ""; + if (subtype.endsWith("+json")) { + return "json"; + } + const sanitized = sanitizeAttachmentExtension(subtype); + return sanitized === "file" ? null : sanitized; +}; const getAttachmentDownloadName = ( block: Pick, ): string => { const name = block.name?.trim(); - const extension = getAttachmentExtension(block); - if (name) { - // iOS resolves the shared or saved file's type from the filename - // extension, so an extensionless name like "About Page Screenshot" - // would land as a generic file even when the media type is known. - if (endsWithFileExtension.test(name) || extension === "file") { - return name; - } - return `${name}.${extension}`; + if (!name) { + const extension = getAttachmentExtension(block); + return extension === "file" ? "attachment" : `attachment.${extension}`; + } + const mediaExtension = getMediaTypeExtension(block.media_type); + if (mediaExtension === null) { + return name; + } + // iOS resolves the shared or saved file's type from the filename + // extension, so a name like "About Page Screenshot" or "report.final" + // would land as a generic file even when the media type is known. + const suffix = name.match(endsWithFileExtension)?.[1]?.toLowerCase(); + if ( + suffix !== undefined && + (suffix === mediaExtension || + (extensionAliases[mediaExtension] ?? []).includes(suffix)) + ) { + return name; } - return extension === "file" ? "attachment" : `attachment.${extension}`; + return `${name}.${mediaExtension}`; }; const getAttachmentBadgeLabel = ( diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 39cbbda3abc..b7d96a8cad1 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -152,6 +152,7 @@ const ATTACHMENT_RESPONSES = new Map([ "storybook-ios-share-report", { status: 200, body: "pdf-bytes", contentType: "application/pdf" }, ], + ["storybook-ios-error-report", { status: 500, body: "" }], ]); let attachmentFetchCounts = new Map(); @@ -1289,7 +1290,7 @@ export const AssistantMessageWithImage: Story = { }, }; -export const ExtensionlessImageNameGainsDownloadExtension: Story = { +export const DownloadNamesGainMediaTypeExtension: Story = { args: { ...defaultArgs, parsedMessages: buildMessages([ @@ -1298,13 +1299,25 @@ export const ExtensionlessImageNameGainsDownloadExtension: Story = { id: 1, role: "assistant", content: [ - { type: "text", text: "Here is the screenshot:" }, + { type: "text", text: "Here are the files:" }, { type: "file", media_type: "image/png", data: TEST_PNG_B64, name: "About Page Screenshot", }, + { + type: "file", + media_type: "application/pdf", + file_id: "storybook-ios-share-report", + name: "report.final", + }, + { + type: "file", + media_type: "application/pdf", + file_id: "storybook-unnamed-report", + name: "quarterly-report.pdf", + }, ], }, ]), @@ -1320,9 +1333,18 @@ export const ExtensionlessImageNameGainsDownloadExtension: Story = { canvas.getByRole("link", { name: "Download About Page Screenshot" }), ).toBeVisible(); }); + // An extensionless name gains the media-type extension. expect( canvas.getByRole("link", { name: "Download About Page Screenshot" }), ).toHaveAttribute("download", "About Page Screenshot.png"); + // A dotted qualifier is not mistaken for a file extension. + expect( + canvas.getByRole("link", { name: "Download report.final" }), + ).toHaveAttribute("download", "report.final.pdf"); + // A name that already carries the right extension is unchanged. + expect( + canvas.getByRole("link", { name: "Download quarterly-report.pdf" }), + ).toHaveAttribute("download", "quarterly-report.pdf"); }, }; @@ -1439,6 +1461,47 @@ export const DownloadInIOSStandaloneRecoversExpiredActivation: Story = { }, }; +export const DownloadInIOSStandaloneShowsErrorToastOnFailedFetch: Story = { + decorators: [withToaster], + args: { + ...defaultArgs, + parsedMessages: parseMessagesWithMergedTools([ + { + ...baseMessage, + id: 1, + role: "user", + content: [ + { + type: "file", + media_type: "application/pdf", + file_id: "storybook-ios-error-report", + name: "deployment-report.pdf", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const share = fn().mockResolvedValue(undefined); + const restoreNavigator = overrideNavigatorForIOSStandalone({ + share, + canShare: fn().mockReturnValue(true), + }); + const open = spyOn(window, "open").mockReturnValue(null); + try { + await userEvent.click( + canvas.getByRole("link", { name: "Download deployment-report.pdf" }), + ); + await screen.findByText("Couldn't download deployment-report.pdf"); + expect(share).not.toHaveBeenCalled(); + expect(open).not.toHaveBeenCalled(); + } finally { + restoreNavigator(); + } + }, +}; + export const DownloadInIOSStandaloneWithoutShareOpensTab: Story = { args: iosDownloadStoryArgs, play: async ({ canvasElement }) => { From 671bc3186aec7959eb18ccb91e8069fd43cb9d58 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:58:18 +0000 Subject: [PATCH 11/43] fix(site/src/pages/AgentsPage): suppress duplicate clicks while an iOS download is pending A second tap during a slow intercepted fetch started another share flow, which could stack share sheets or surface a misleading error toast. Track the in-flight download per anchor, show a spinner with aria-disabled, and ignore clicks until it settles. --- .../ChatConversation/AttachmentBlocks.tsx | 100 ++++++++++++------ .../ConversationTimeline.stories.tsx | 40 +++++++ .../pages/AgentsPage/utils/chatAttachments.ts | 2 +- 3 files changed, 110 insertions(+), 32 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index 6d6428ce954..98d6533a05b 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -4,7 +4,12 @@ import { FileIcon, FileTextIcon, } from "lucide-react"; -import { type FC, type ReactNode, useState } from "react"; +import { + type FC, + type MouseEvent as ReactMouseEvent, + type ReactNode, + useState, +} from "react"; import { Spinner } from "#/components/Spinner/Spinner"; import { Tooltip, @@ -14,6 +19,7 @@ import { import { cn } from "#/utils/cn"; import { useLatestAbortController } from "../../hooks/useLatestAbortController"; import { + type AttachmentDownloadTarget, type AttachmentFailure, attachmentFailureFromError, getChatFileURL, @@ -181,29 +187,54 @@ const getAttachmentBadgeLabel = ( return extension === "file" ? "" : extension.toUpperCase(); }; +// Suppresses repeated clicks while an intercepted iOS download is still +// fetching, so a second tap cannot open a second share sheet or surface +// a misleading error toast while the first sheet is open. +const useAttachmentDownloadClick = (target: AttachmentDownloadTarget) => { + const [isPending, setIsPending] = useState(false); + const onClick = (event: ReactMouseEvent) => { + event.stopPropagation(); + if (isPending) { + event.preventDefault(); + return; + } + const pending = handleAttachmentDownloadClick(event, target); + if (pending) { + setIsPending(true); + void pending.finally(() => setIsPending(false)); + } + }; + return { isPending, onClick }; +}; + const DownloadOverlay: FC<{ href: string; displayName: string; downloadName: string; mediaType: string; -}> = ({ href, displayName, downloadName, mediaType }) => ( - { - event.stopPropagation(); - void handleAttachmentDownloadClick(event, { - href, - fileName: downloadName, - mediaType, - }); - }} - aria-label={`Download ${displayName}`} - className="invisible absolute right-1 top-1 flex size-6 items-center justify-center rounded bg-surface-primary/80 text-content-secondary opacity-0 shadow-sm backdrop-blur-sm transition-opacity hover:text-content-primary group-hover/attachment:visible group-hover/attachment:opacity-100 group-focus-within/attachment:visible group-focus-within/attachment:opacity-100 [@media(hover:none)]:visible [@media(hover:none)]:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link" - > - -); +}> = ({ href, displayName, downloadName, mediaType }) => { + const { isPending, onClick } = useAttachmentDownloadClick({ + href, + fileName: downloadName, + mediaType, + }); + return ( + + {isPending ? ( + + ) : ( + + ); +}; const AttachmentPreviewFrame: FC<{ href: string | null; @@ -580,20 +611,19 @@ const FileCard: FC<{ const displayName = getAttachmentDisplayName(block); const downloadName = getAttachmentDownloadName(block); const badgeLabel = getAttachmentBadgeLabel(block); + const { isPending, onClick } = useAttachmentDownloadClick({ + href, + fileName: downloadName, + mediaType: block.media_type, + }); return ( { - event.stopPropagation(); - void handleAttachmentDownloadClick(event, { - href, - fileName: downloadName, - mediaType: block.media_type, - }); - }} + onClick={onClick} aria-label={`Download ${displayName}`} + aria-disabled={isPending} className="inline-flex h-16 max-w-sm items-center gap-3 rounded-md border border-solid border-border-default bg-surface-tertiary px-3 py-2 no-underline transition-colors hover:bg-surface-quaternary" >
@@ -614,10 +644,18 @@ const FileCard: FC<{
Download file
-
); }; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index b7d96a8cad1..9bc05feb5a4 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1461,6 +1461,46 @@ export const DownloadInIOSStandaloneRecoversExpiredActivation: Story = { }, }; +export const DownloadInIOSStandaloneSuppressesDuplicateClicks: Story = { + args: iosDownloadStoryArgs, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const share = fn().mockResolvedValue(undefined); + const restoreNavigator = overrideNavigatorForIOSStandalone({ + share, + canShare: fn().mockReturnValue(true), + }); + let releaseFetch = () => {}; + const fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + () => + new Promise((resolve) => { + releaseFetch = () => + resolve( + new Response("pdf-bytes", { + headers: { "Content-Type": "application/pdf" }, + }), + ); + }), + ); + try { + const downloadLink = canvas.getByRole("link", { + name: "Download deployment-report.pdf", + }); + await userEvent.click(downloadLink); + expect(downloadLink).toHaveAttribute("aria-disabled", "true"); + await userEvent.click(downloadLink); + expect(fetchSpy).toHaveBeenCalledTimes(1); + releaseFetch(); + await waitFor(() => expect(share).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(downloadLink).toHaveAttribute("aria-disabled", "false"), + ); + } finally { + restoreNavigator(); + } + }, +}; + export const DownloadInIOSStandaloneShowsErrorToastOnFailedFetch: Story = { decorators: [withToaster], args: { diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index ae27d6aec2f..12f31319592 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -82,7 +82,7 @@ const canShareFiles = (files: File[]): boolean => typeof navigator.canShare === "function" && navigator.canShare({ files }); -type AttachmentDownloadTarget = { +export type AttachmentDownloadTarget = { href: string; fileName: string; mediaType: string; From a5f53df05a559b4491af689b164891efd710c95c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:10:34 +0000 Subject: [PATCH 12/43] fix(site/src/pages/AgentsPage): preserve source-file suffixes for text/plain downloads The server classifier maps source files like main.go to text/plain, so appending .txt would rename them. Keep any existing suffix for that catch-all type; only extensionless text names gain .txt. --- .../ChatConversation/AttachmentBlocks.tsx | 14 +++++++++++--- .../ConversationTimeline.stories.tsx | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index 98d6533a05b..1378bd6192e 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -170,10 +170,18 @@ const getAttachmentDownloadName = ( // extension, so a name like "About Page Screenshot" or "report.final" // would land as a generic file even when the media type is known. const suffix = name.match(endsWithFileExtension)?.[1]?.toLowerCase(); + if (suffix === undefined) { + return `${name}.${mediaExtension}`; + } + // text/plain is the server classifier's catch-all for source files + // (main.go, config.yaml), whose suffixes identify them better than + // .txt would, so any existing suffix is kept. + if (block.media_type === "text/plain") { + return name; + } if ( - suffix !== undefined && - (suffix === mediaExtension || - (extensionAliases[mediaExtension] ?? []).includes(suffix)) + suffix === mediaExtension || + (extensionAliases[mediaExtension] ?? []).includes(suffix) ) { return name; } diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 9bc05feb5a4..eb9cd893431 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1318,6 +1318,12 @@ export const DownloadNamesGainMediaTypeExtension: Story = { file_id: "storybook-unnamed-report", name: "quarterly-report.pdf", }, + { + type: "file", + media_type: "text/plain", + file_id: "storybook-text-1", + name: "main.go", + }, ], }, ]), @@ -1345,6 +1351,15 @@ export const DownloadNamesGainMediaTypeExtension: Story = { expect( canvas.getByRole("link", { name: "Download quarterly-report.pdf" }), ).toHaveAttribute("download", "quarterly-report.pdf"); + // text/plain covers source files, so their suffixes are preserved. + // The overlay link joins the accessibility tree only while its + // attachment group has focus. + canvas.getByRole("button", { name: "View main.go" }).focus(); + await waitFor(() => { + expect( + canvas.getByRole("link", { name: "Download main.go" }), + ).toHaveAttribute("download", "main.go"); + }); }, }; From 36e4de81add20e5f96a2861fa76b1106c4c6b8e1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:20:04 +0000 Subject: [PATCH 13/43] fix(site/src/pages/AgentsPage): offer a tab fallback for unshareable fetched files The pre-fetch probe can pass while the real file fails canShare, and the native anchor action was already prevented, so the toast now carries an Open action that launches the dismissible-tab fallback with a fresh gesture. --- site/src/pages/AgentsPage/utils/chatAttachments.test.ts | 9 +++++++-- site/src/pages/AgentsPage/utils/chatAttachments.ts | 8 ++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts index d41ae9d812c..4b2acadcc7c 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts @@ -203,7 +203,7 @@ describe("handleAttachmentDownloadClick", () => { ); }); - it("skips sharing when the fetched file turns out unshareable", async () => { + it("offers a tab fallback when the fetched file turns out unshareable", async () => { enterIOSStandalonePWA(); const share = vi.fn().mockResolvedValue(undefined); overrideNavigator("share", share); @@ -216,14 +216,19 @@ describe("handleAttachmentDownloadClick", () => { vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response(new Blob(["png-bytes"], { type: "image/png" })), ); + const open = vi.spyOn(window, "open").mockReturnValue(null); const event = { preventDefault: vi.fn() }; await handleAttachmentDownloadClick(event, target); expect(share).not.toHaveBeenCalled(); + expect(open).not.toHaveBeenCalled(); expect(toast.error).toHaveBeenCalledWith( "Couldn't download 01-agents-list.png", - { description: "This file cannot be shared on this device." }, + expect.objectContaining({ + description: "This file cannot be shared on this device.", + action: expect.objectContaining({ label: "Open" }), + }), ); }); }); diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index 12f31319592..910f6bf5cb2 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -145,8 +145,16 @@ const shareAttachmentFile = async ({ return; } if (!canShareFiles([file])) { + // The pre-fetch probe can pass while the real file fails canShare + // (for example over the size limit). The native anchor action was + // already prevented, so offer the dismissible-tab fallback through + // a fresh gesture. toast.error(`Couldn't download ${fileName}`, { description: "This file cannot be shared on this device.", + action: { + label: "Open", + onClick: () => void open(href, "_blank", "noopener"), + }, }); return; } From bb64fb7fbe63fc3c1e2f5a60911893d9b7c8f78e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:26:12 +0000 Subject: [PATCH 14/43] test(site/src/pages/AgentsPage): exercise the unshareable-file Open action in Storybook --- .../ConversationTimeline.stories.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index eb9cd893431..db13f517fbc 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1516,6 +1516,37 @@ export const DownloadInIOSStandaloneSuppressesDuplicateClicks: Story = { }, }; +export const DownloadInIOSStandaloneOffersTabForUnshareableFile: Story = { + decorators: [withToaster], + args: iosDownloadStoryArgs, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const share = fn().mockResolvedValue(undefined); + const restoreNavigator = overrideNavigatorForIOSStandalone({ + share, + // The one-byte probe passes; the real fetched file fails. + canShare: fn(({ files }: { files: File[] }) => files[0].size <= 1), + }); + const open = spyOn(window, "open").mockReturnValue(null); + try { + await userEvent.click( + canvas.getByRole("link", { name: "Download deployment-report.pdf" }), + ); + const openButton = await screen.findByRole("button", { name: "Open" }); + expect(share).not.toHaveBeenCalled(); + expect(open).not.toHaveBeenCalled(); + await userEvent.click(openButton); + expect(open).toHaveBeenCalledWith( + getChatFileURL("storybook-ios-share-report"), + "_blank", + "noopener", + ); + } finally { + restoreNavigator(); + } + }, +}; + export const DownloadInIOSStandaloneShowsErrorToastOnFailedFetch: Story = { decorators: [withToaster], args: { From 954356ff27264639bf0bcf3e5e3197ca122199d7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:32:59 +0000 Subject: [PATCH 15/43] test(site/src/pages/AgentsPage): cover permanent share failures in Storybook --- .../ConversationTimeline.stories.tsx | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index db13f517fbc..1d88799f50b 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1516,6 +1516,35 @@ export const DownloadInIOSStandaloneSuppressesDuplicateClicks: Story = { }, }; +export const DownloadInIOSStandaloneReportsPermanentShareFailure: Story = { + decorators: [withToaster], + args: iosDownloadStoryArgs, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const restoreNavigator = overrideNavigatorForIOSStandalone({ + share: fn().mockRejectedValue( + new DOMException("share failed", "DataError"), + ), + canShare: fn().mockReturnValue(true), + }); + const open = spyOn(window, "open").mockReturnValue(null); + try { + await userEvent.click( + canvas.getByRole("link", { name: "Download deployment-report.pdf" }), + ); + await screen.findByText("Couldn't download deployment-report.pdf"); + // A permanent failure gets a plain toast: retrying would fail + // identically, so no Save action is offered. + expect( + screen.queryByRole("button", { name: "Save" }), + ).not.toBeInTheDocument(); + expect(open).not.toHaveBeenCalled(); + } finally { + restoreNavigator(); + } + }, +}; + export const DownloadInIOSStandaloneOffersTabForUnshareableFile: Story = { decorators: [withToaster], args: iosDownloadStoryArgs, From 8fcea6802af6f2172c71809a607a7fe741608797 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:41:21 +0000 Subject: [PATCH 16/43] fix(site/src/pages/AgentsPage): decode inline attachments locally and keep an Open fallback Production CSP limits connect-src to 'self', so fetching a data: href was blocked and inline attachments could not be shared. Decode the data URL locally instead. Permanent share failures now offer the same fresh-gesture Open fallback as unshareable files, except for data: hrefs, which iOS cannot open in a tab. --- .../ConversationTimeline.stories.tsx | 57 ++++++++++- .../AgentsPage/utils/chatAttachments.test.ts | 52 +++++++++- .../pages/AgentsPage/utils/chatAttachments.ts | 97 ++++++++++++++----- 3 files changed, 177 insertions(+), 29 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 1d88799f50b..cdbbe2af2fa 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1533,12 +1533,18 @@ export const DownloadInIOSStandaloneReportsPermanentShareFailure: Story = { canvas.getByRole("link", { name: "Download deployment-report.pdf" }), ); await screen.findByText("Couldn't download deployment-report.pdf"); - // A permanent failure gets a plain toast: retrying would fail - // identically, so no Save action is offered. + // Retrying a permanently failed share would fail identically, + // so the toast offers the dismissible tab instead of Save. expect( screen.queryByRole("button", { name: "Save" }), ).not.toBeInTheDocument(); expect(open).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole("button", { name: "Open" })); + expect(open).toHaveBeenCalledWith( + getChatFileURL("storybook-ios-share-report"), + "_blank", + "noopener", + ); } finally { restoreNavigator(); } @@ -1617,6 +1623,53 @@ export const DownloadInIOSStandaloneShowsErrorToastOnFailedFetch: Story = { }, }; +/** Inline data: attachments cannot be fetched under the production CSP. */ +export const DownloadInIOSStandaloneSharesInlineAttachment: Story = { + args: { + ...defaultArgs, + parsedMessages: parseMessagesWithMergedTools([ + { + ...baseMessage, + id: 1, + role: "assistant", + content: [ + { + type: "file", + media_type: "image/png", + data: TEST_PNG_B64, + name: "inline-screenshot.png", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const share = fn().mockResolvedValue(undefined); + const restoreNavigator = overrideNavigatorForIOSStandalone({ + share, + canShare: fn().mockReturnValue(true), + }); + const fetchSpy = spyOn(globalThis, "fetch"); + try { + canvas + .getByRole("button", { name: "View inline-screenshot.png" }) + .focus(); + const downloadLink = await canvas.findByRole("link", { + name: "Download inline-screenshot.png", + }); + await userEvent.click(downloadLink); + await waitFor(() => expect(share).toHaveBeenCalledTimes(1)); + const shared: { files: File[] } = share.mock.calls[0][0]; + expect(shared.files[0].name).toBe("inline-screenshot.png"); + expect(shared.files[0].type).toBe("image/png"); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + restoreNavigator(); + } + }, +}; + export const DownloadInIOSStandaloneWithoutShareOpensTab: Story = { args: iosDownloadStoryArgs, play: async ({ canvasElement }) => { diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts index 4b2acadcc7c..5a4032037fa 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts @@ -183,7 +183,7 @@ describe("handleAttachmentDownloadClick", () => { ); }); - it("reports permanent share failures without a retry action", async () => { + it("offers a tab fallback instead of a retry for permanent share failures", async () => { enterIOSStandalonePWA(); overrideNavigator( "share", @@ -198,8 +198,54 @@ describe("handleAttachmentDownloadClick", () => { await handleAttachmentDownloadClick(event, target); expect(toast.error).toHaveBeenCalledTimes(1); - expect(vi.mocked(toast.error).mock.calls[0][1]).not.toHaveProperty( - "action", + expect(vi.mocked(toast.error).mock.calls[0][1]).toEqual( + expect.objectContaining({ + action: expect.objectContaining({ label: "Open" }), + }), + ); + }); + + it("shares inline data: attachments without fetching", async () => { + enterIOSStandalonePWA(); + const share = vi.fn().mockResolvedValue(undefined); + overrideNavigator("share", share); + overrideNavigator("canShare", vi.fn().mockReturnValue(true)); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const event = { preventDefault: vi.fn() }; + const payload = btoa("png-bytes"); + + await handleAttachmentDownloadClick(event, { + href: `data:image/png;base64,${payload}`, + fileName: "inline.png", + mediaType: "image/png", + }); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(share).toHaveBeenCalledTimes(1); + const shared: { files: File[] } = share.mock.calls[0][0]; + expect(shared.files[0].name).toBe("inline.png"); + expect(shared.files[0].type).toBe("image/png"); + expect(shared.files[0].size).toBe("png-bytes".length); + }); + + it("keeps permanent share failure toasts for data: hrefs action-free", async () => { + enterIOSStandalonePWA(); + overrideNavigator( + "share", + vi.fn().mockRejectedValue(new DOMException("share failed", "DataError")), + ); + overrideNavigator("canShare", vi.fn().mockReturnValue(true)); + const event = { preventDefault: vi.fn() }; + + await handleAttachmentDownloadClick(event, { + href: `data:image/png;base64,${btoa("png-bytes")}`, + fileName: "inline.png", + mediaType: "image/png", + }); + + expect(toast.error).toHaveBeenCalledTimes(1); + expect(vi.mocked(toast.error).mock.calls[0][1]).toEqual( + expect.objectContaining({ action: undefined }), ); }); diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index 910f6bf5cb2..5892a63ff47 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -96,7 +96,21 @@ const errorHasName = (error: unknown, name: string): boolean => "name" in error && error.name === name; -const shareFileViaSheet = (file: File, fileName: string): Promise => +// iOS blocks top-level data: navigation, so the Open fallback is only +// offered for hrefs a new tab can actually load. +const openFallbackAction = (href: string) => + href.startsWith("data:") + ? undefined + : { + label: "Open", + onClick: () => void open(href, "_blank", "noopener"), + }; + +const shareFileViaSheet = ( + file: File, + fileName: string, + href: string, +): Promise => navigator.share({ files: [file] }).catch((error: unknown) => { // A dismissed share sheet rejects with AbortError. if (errorHasName(error, "AbortError")) { @@ -109,40 +123,78 @@ const shareFileViaSheet = (file: File, fileName: string): Promise => description: "The file is ready to save.", action: { label: "Save", - onClick: () => void shareFileViaSheet(file, fileName), + onClick: () => void shareFileViaSheet(file, fileName, href), }, }); return; } + // The share itself failed permanently, but the file was fetched, + // so the dismissible tab remains a way to reach it. toast.error(`Couldn't download ${fileName}`, { description: error instanceof Error ? error.message : undefined, + action: openFallbackAction(href), }); }); +// Production CSP limits connect-src to 'self', so inline data: hrefs +// cannot be fetched and are decoded locally instead. +const fileFromDataURL = ( + href: string, + fileName: string, + fallbackMediaType: string, +): File | null => { + const match = /^data:([^,]*?)(;base64)?,(.*)$/.exec(href); + if (!match) { + return null; + } + const [, type, isBase64, payload] = match; + try { + const bytes = isBase64 + ? Uint8Array.from(atob(payload), (char) => char.charCodeAt(0)) + : new TextEncoder().encode(decodeURIComponent(payload)); + return new File([bytes], fileName, { + type: type || fallbackMediaType || "application/octet-stream", + }); + } catch { + return null; + } +}; + const shareAttachmentFile = async ({ href, fileName, mediaType, }: AttachmentDownloadTarget): Promise => { let file: File; - try { - const response = await fetch(href); - if (!response.ok) { - throw new Error( - response.statusText - ? `${response.status} ${response.statusText}` - : `HTTP ${response.status}`, - ); + if (href.startsWith("data:")) { + const decoded = fileFromDataURL(href, fileName, mediaType); + if (!decoded) { + toast.error(`Couldn't download ${fileName}`, { + description: "The attachment data could not be decoded.", + }); + return; + } + file = decoded; + } else { + try { + const response = await fetch(href); + if (!response.ok) { + throw new Error( + response.statusText + ? `${response.status} ${response.statusText}` + : `HTTP ${response.status}`, + ); + } + const blob = await response.blob(); + file = new File([blob], fileName, { + type: blob.type || mediaType || "application/octet-stream", + }); + } catch (error) { + toast.error(`Couldn't download ${fileName}`, { + description: error instanceof Error ? error.message : undefined, + }); + return; } - const blob = await response.blob(); - file = new File([blob], fileName, { - type: blob.type || mediaType || "application/octet-stream", - }); - } catch (error) { - toast.error(`Couldn't download ${fileName}`, { - description: error instanceof Error ? error.message : undefined, - }); - return; } if (!canShareFiles([file])) { // The pre-fetch probe can pass while the real file fails canShare @@ -151,14 +203,11 @@ const shareAttachmentFile = async ({ // a fresh gesture. toast.error(`Couldn't download ${fileName}`, { description: "This file cannot be shared on this device.", - action: { - label: "Open", - onClick: () => void open(href, "_blank", "noopener"), - }, + action: openFallbackAction(href), }); return; } - await shareFileViaSheet(file, fileName); + await shareFileViaSheet(file, fileName, href); }; /** From d709502e2f0433425b9b49a8065a70fc50ecf17e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:53:48 +0000 Subject: [PATCH 17/43] fix(site/src/pages/AgentsPage): open inline attachments through blob URLs when sharing is unavailable --- .../ConversationTimeline.stories.tsx | 69 ++++++++++---- .../AgentsPage/utils/chatAttachments.test.ts | 95 ++++++++++++++++++- .../pages/AgentsPage/utils/chatAttachments.ts | 61 ++++++++---- 3 files changed, 187 insertions(+), 38 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index cdbbe2af2fa..b92fdd96aa2 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1623,26 +1623,28 @@ export const DownloadInIOSStandaloneShowsErrorToastOnFailedFetch: Story = { }, }; +const inlineAttachmentStoryArgs: Story["args"] = { + ...defaultArgs, + parsedMessages: parseMessagesWithMergedTools([ + { + ...baseMessage, + id: 1, + role: "assistant", + content: [ + { + type: "file", + media_type: "image/png", + data: TEST_PNG_B64, + name: "inline-screenshot.png", + }, + ], + }, + ]), +}; + /** Inline data: attachments cannot be fetched under the production CSP. */ export const DownloadInIOSStandaloneSharesInlineAttachment: Story = { - args: { - ...defaultArgs, - parsedMessages: parseMessagesWithMergedTools([ - { - ...baseMessage, - id: 1, - role: "assistant", - content: [ - { - type: "file", - media_type: "image/png", - data: TEST_PNG_B64, - name: "inline-screenshot.png", - }, - ], - }, - ]), - }, + args: inlineAttachmentStoryArgs, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const share = fn().mockResolvedValue(undefined); @@ -1670,6 +1672,37 @@ export const DownloadInIOSStandaloneSharesInlineAttachment: Story = { }, }; +/** iOS blocks data: tabs, so inline attachments open through a blob URL. */ +export const DownloadInIOSStandaloneOpensInlineAttachmentInTab: Story = { + args: inlineAttachmentStoryArgs, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const restoreNavigator = overrideNavigatorForIOSStandalone({ + share: undefined, + canShare: undefined, + }); + const open = spyOn(window, "open").mockReturnValue(null); + const fetchSpy = spyOn(globalThis, "fetch"); + try { + canvas + .getByRole("button", { name: "View inline-screenshot.png" }) + .focus(); + const downloadLink = await canvas.findByRole("link", { + name: "Download inline-screenshot.png", + }); + await userEvent.click(downloadLink); + expect(open).toHaveBeenCalledTimes(1); + const [blobUrl, target, features] = open.mock.calls[0]; + expect(blobUrl).toMatch(/^blob:/); + expect(target).toBe("_blank"); + expect(features).toBe("noopener"); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + restoreNavigator(); + } + }, +}; + export const DownloadInIOSStandaloneWithoutShareOpensTab: Story = { args: iosDownloadStoryArgs, play: async ({ canvasElement }) => { diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts index 5a4032037fa..4601b58515f 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts @@ -38,11 +38,43 @@ describe("handleAttachmentDownloadClick", () => { mediaType: "image/png", }; + // jsdom does not implement object URLs, so stub them on the URL constructor. + const originalURLDescriptors = new Map< + string, + PropertyDescriptor | undefined + >(); + const stubObjectURLs = () => { + const createObjectURL = vi.fn().mockReturnValue("blob:inline"); + const revokeObjectURL = vi.fn(); + for (const [key, value] of Object.entries({ + createObjectURL, + revokeObjectURL, + })) { + if (!originalURLDescriptors.has(key)) { + originalURLDescriptors.set( + key, + Object.getOwnPropertyDescriptor(URL, key), + ); + } + Object.defineProperty(URL, key, { value, configurable: true }); + } + return { createObjectURL, revokeObjectURL }; + }; + afterEach(() => { for (const key of overriddenNavigatorKeys) { Reflect.deleteProperty(navigator, key); } overriddenNavigatorKeys.clear(); + for (const [key, descriptor] of originalURLDescriptors) { + if (descriptor) { + Object.defineProperty(URL, key, descriptor); + } else { + Reflect.deleteProperty(URL, key); + } + } + originalURLDescriptors.clear(); + vi.useRealTimers(); vi.restoreAllMocks(); vi.mocked(toast.error).mockClear(); }); @@ -228,13 +260,16 @@ describe("handleAttachmentDownloadClick", () => { expect(shared.files[0].size).toBe("png-bytes".length); }); - it("keeps permanent share failure toasts for data: hrefs action-free", async () => { + it("offers a blob-backed Open fallback for data: hrefs on permanent share failure", async () => { enterIOSStandalonePWA(); overrideNavigator( "share", vi.fn().mockRejectedValue(new DOMException("share failed", "DataError")), ); overrideNavigator("canShare", vi.fn().mockReturnValue(true)); + const { createObjectURL, revokeObjectURL } = stubObjectURLs(); + const open = vi.spyOn(window, "open").mockReturnValue(null); + vi.useFakeTimers(); const event = { preventDefault: vi.fn() }; await handleAttachmentDownloadClick(event, { @@ -244,9 +279,61 @@ describe("handleAttachmentDownloadClick", () => { }); expect(toast.error).toHaveBeenCalledTimes(1); - expect(vi.mocked(toast.error).mock.calls[0][1]).toEqual( - expect.objectContaining({ action: undefined }), - ); + const options = vi.mocked(toast.error).mock.calls[0][1] as { + action: { label: string; onClick: () => void }; + }; + expect(options.action.label).toBe("Open"); + options.action.onClick(); + expect(createObjectURL).toHaveBeenCalledTimes(1); + expect(createObjectURL.mock.calls[0][0]).toBeInstanceOf(File); + expect(open).toHaveBeenCalledWith("blob:inline", "_blank", "noopener"); + vi.runAllTimers(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:inline"); + }); + + it("opens inline attachments through a blob tab when file sharing is unavailable", () => { + enterIOSStandalonePWA(); + const { createObjectURL, revokeObjectURL } = stubObjectURLs(); + const open = vi.spyOn(window, "open").mockReturnValue(null); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + vi.useFakeTimers(); + const event = { preventDefault: vi.fn() }; + + expect( + handleAttachmentDownloadClick(event, { + href: `data:image/png;base64,${btoa("png-bytes")}`, + fileName: "inline.png", + mediaType: "image/png", + }), + ).toBeUndefined(); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(createObjectURL).toHaveBeenCalledTimes(1); + const decoded: File = createObjectURL.mock.calls[0][0]; + expect(decoded.name).toBe("inline.png"); + expect(decoded.type).toBe("image/png"); + expect(open).toHaveBeenCalledWith("blob:inline", "_blank", "noopener"); + vi.runAllTimers(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:inline"); + }); + + it("shows a decode error instead of a tab when undecodable inline data cannot be shared", () => { + enterIOSStandalonePWA(); + stubObjectURLs(); + const open = vi.spyOn(window, "open").mockReturnValue(null); + const event = { preventDefault: vi.fn() }; + + handleAttachmentDownloadClick(event, { + href: "data:image/png;base64,%%%", + fileName: "inline.png", + mediaType: "image/png", + }); + + expect(open).not.toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith("Couldn't download inline.png", { + description: "The attachment data could not be decoded.", + }); }); it("offers a tab fallback when the fetched file turns out unshareable", async () => { diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index 5892a63ff47..bb3e81476a6 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -96,15 +96,33 @@ const errorHasName = (error: unknown, name: string): boolean => "name" in error && error.name === name; -// iOS blocks top-level data: navigation, so the Open fallback is only -// offered for hrefs a new tab can actually load. -const openFallbackAction = (href: string) => - href.startsWith("data:") - ? undefined - : { - label: "Open", - onClick: () => void open(href, "_blank", "noopener"), - }; +// iOS blocks top-level data: navigation, so inline attachments open through +// a short-lived blob URL instead of their data: href. +const openBlobFileInTab = (file: File): void => { + const blobUrl = URL.createObjectURL(file); + open(blobUrl, "_blank", "noopener"); + // Revoke after the new tab has had time to load the blob. + setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000); +}; + +const openAttachmentInTab = (href: string, file: File): void => { + if (href.startsWith("data:")) { + openBlobFileInTab(file); + } else { + open(href, "_blank", "noopener"); + } +}; + +const openFallbackAction = (href: string, file: File) => ({ + label: "Open", + onClick: () => openAttachmentInTab(href, file), +}); + +const showDecodeFailureToast = (fileName: string): void => { + toast.error(`Couldn't download ${fileName}`, { + description: "The attachment data could not be decoded.", + }); +}; const shareFileViaSheet = ( file: File, @@ -128,11 +146,11 @@ const shareFileViaSheet = ( }); return; } - // The share itself failed permanently, but the file was fetched, + // The share itself failed permanently, but the file is in hand, // so the dismissible tab remains a way to reach it. toast.error(`Couldn't download ${fileName}`, { description: error instanceof Error ? error.message : undefined, - action: openFallbackAction(href), + action: openFallbackAction(href, file), }); }); @@ -169,9 +187,7 @@ const shareAttachmentFile = async ({ if (href.startsWith("data:")) { const decoded = fileFromDataURL(href, fileName, mediaType); if (!decoded) { - toast.error(`Couldn't download ${fileName}`, { - description: "The attachment data could not be decoded.", - }); + showDecodeFailureToast(fileName); return; } file = decoded; @@ -203,7 +219,7 @@ const shareAttachmentFile = async ({ // a fresh gesture. toast.error(`Couldn't download ${fileName}`, { description: "This file cannot be shared on this device.", - action: openFallbackAction(href), + action: openFallbackAction(href, file), }); return; } @@ -227,7 +243,20 @@ export const handleAttachmentDownloadClick = ( if (!canShareFiles([probe])) { // Open synchronously; after an await the user activation that // popup blockers require may already be consumed. - open(target.href, "_blank", "noopener"); + if (!target.href.startsWith("data:")) { + open(target.href, "_blank", "noopener"); + return undefined; + } + const decoded = fileFromDataURL( + target.href, + target.fileName, + target.mediaType, + ); + if (decoded) { + openBlobFileInTab(decoded); + } else { + showDecodeFailureToast(target.fileName); + } return undefined; } return shareAttachmentFile(target); From 137d9ba45d9b1d95106d0c22aedf6ef80d146653 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:02:36 +0000 Subject: [PATCH 18/43] test(site/src/pages/AgentsPage): exercise inline Open action and decode error in Storybook --- .../ConversationTimeline.stories.tsx | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index b92fdd96aa2..41b40d31074 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1672,6 +1672,89 @@ export const DownloadInIOSStandaloneSharesInlineAttachment: Story = { }, }; +/** iOS blocks data: tabs, so the toast Open action uses a blob URL. */ +export const DownloadInIOSStandaloneOpensInlineAttachmentFromToast: Story = { + decorators: [withToaster], + args: inlineAttachmentStoryArgs, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const restoreNavigator = overrideNavigatorForIOSStandalone({ + share: fn().mockRejectedValue( + new DOMException("share failed", "DataError"), + ), + canShare: fn().mockReturnValue(true), + }); + const open = spyOn(window, "open").mockReturnValue(null); + const fetchSpy = spyOn(globalThis, "fetch"); + try { + canvas + .getByRole("button", { name: "View inline-screenshot.png" }) + .focus(); + const downloadLink = await canvas.findByRole("link", { + name: "Download inline-screenshot.png", + }); + await userEvent.click(downloadLink); + await screen.findByText("Couldn't download inline-screenshot.png"); + await userEvent.click(screen.getByRole("button", { name: "Open" })); + expect(open).toHaveBeenCalledTimes(1); + const [blobUrl, target, features] = open.mock.calls[0]; + expect(blobUrl).toMatch(/^blob:/); + expect(target).toBe("_blank"); + expect(features).toBe("noopener"); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + restoreNavigator(); + } + }, +}; + +export const DownloadInIOSStandaloneShowsErrorForCorruptInlineAttachment: Story = + { + decorators: [withToaster], + args: { + ...defaultArgs, + parsedMessages: parseMessagesWithMergedTools([ + { + ...baseMessage, + id: 1, + role: "assistant", + content: [ + { + type: "file", + media_type: "image/png", + data: "not-valid-base64", + name: "corrupt-screenshot.png", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const share = fn().mockResolvedValue(undefined); + const restoreNavigator = overrideNavigatorForIOSStandalone({ + share, + canShare: fn().mockReturnValue(true), + }); + const open = spyOn(window, "open").mockReturnValue(null); + try { + canvas + .getByRole("button", { name: "View corrupt-screenshot.png" }) + .focus(); + const downloadLink = await canvas.findByRole("link", { + name: "Download corrupt-screenshot.png", + }); + await userEvent.click(downloadLink); + await screen.findByText("Couldn't download corrupt-screenshot.png"); + await screen.findByText("The attachment data could not be decoded."); + expect(share).not.toHaveBeenCalled(); + expect(open).not.toHaveBeenCalled(); + } finally { + restoreNavigator(); + } + }, + }; + /** iOS blocks data: tabs, so inline attachments open through a blob URL. */ export const DownloadInIOSStandaloneOpensInlineAttachmentInTab: Story = { args: inlineAttachmentStoryArgs, From 20d169d3945a57dd49f954f890186ae61b5f63f6 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:13:19 +0000 Subject: [PATCH 19/43] fix(site/src/pages/AgentsPage): preserve long text/plain suffixes in download names --- .../ChatConversation/AttachmentBlocks.tsx | 12 ++++----- .../ConversationTimeline.stories.tsx | 27 ++++++++++++++++--- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index 1378bd6192e..b2a7ad5546d 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -166,6 +166,12 @@ const getAttachmentDownloadName = ( if (mediaExtension === null) { return name; } + // text/plain is the server classifier's catch-all for source files + // (main.go, config.properties), whose suffixes identify them better + // than .txt would, so any dotted suffix is kept regardless of length. + if (block.media_type === "text/plain") { + return /\.[^.\s]+$/.test(name) ? name : `${name}.${mediaExtension}`; + } // iOS resolves the shared or saved file's type from the filename // extension, so a name like "About Page Screenshot" or "report.final" // would land as a generic file even when the media type is known. @@ -173,12 +179,6 @@ const getAttachmentDownloadName = ( if (suffix === undefined) { return `${name}.${mediaExtension}`; } - // text/plain is the server classifier's catch-all for source files - // (main.go, config.yaml), whose suffixes identify them better than - // .txt would, so any existing suffix is kept. - if (block.media_type === "text/plain") { - return name; - } if ( suffix === mediaExtension || (extensionAliases[mediaExtension] ?? []).includes(suffix) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 41b40d31074..6a55bb6d6f7 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1324,6 +1324,18 @@ export const DownloadNamesGainMediaTypeExtension: Story = { file_id: "storybook-text-1", name: "main.go", }, + { + type: "file", + media_type: "text/plain", + file_id: "storybook-text-2", + name: "config.properties", + }, + { + type: "file", + media_type: "text/plain", + file_id: "storybook-text-3", + name: "meeting notes", + }, ], }, ]), @@ -1339,15 +1351,12 @@ export const DownloadNamesGainMediaTypeExtension: Story = { canvas.getByRole("link", { name: "Download About Page Screenshot" }), ).toBeVisible(); }); - // An extensionless name gains the media-type extension. expect( canvas.getByRole("link", { name: "Download About Page Screenshot" }), ).toHaveAttribute("download", "About Page Screenshot.png"); - // A dotted qualifier is not mistaken for a file extension. expect( canvas.getByRole("link", { name: "Download report.final" }), ).toHaveAttribute("download", "report.final.pdf"); - // A name that already carries the right extension is unchanged. expect( canvas.getByRole("link", { name: "Download quarterly-report.pdf" }), ).toHaveAttribute("download", "quarterly-report.pdf"); @@ -1360,6 +1369,18 @@ export const DownloadNamesGainMediaTypeExtension: Story = { canvas.getByRole("link", { name: "Download main.go" }), ).toHaveAttribute("download", "main.go"); }); + canvas.getByRole("button", { name: "View config.properties" }).focus(); + await waitFor(() => { + expect( + canvas.getByRole("link", { name: "Download config.properties" }), + ).toHaveAttribute("download", "config.properties"); + }); + canvas.getByRole("button", { name: "View meeting notes" }).focus(); + await waitFor(() => { + expect( + canvas.getByRole("link", { name: "Download meeting notes" }), + ).toHaveAttribute("download", "meeting notes.txt"); + }); }, }; From f6d16f918fbc8313d3f222df5bf0096cf61ea26d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:23:32 +0000 Subject: [PATCH 20/43] fix(site/src/pages/AgentsPage): recognize alternate JPEG and image suffixes in download names --- .../components/ChatConversation/AttachmentBlocks.tsx | 5 +++-- .../ConversationTimeline.stories.tsx | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index b2a7ad5546d..c5680d81d3a 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -131,9 +131,10 @@ const endsWithFileExtension = /\.([a-z0-9]{1,8})$/i; // Alternate name suffixes that identify the same type as the canonical // media-type extension, so "photo.jpeg" is not renamed to "photo.jpeg.jpg". const extensionAliases: Record = { - jpg: ["jpeg"], + html: ["htm"], + jpg: ["jpeg", "jfif", "jpe", "pjpeg", "pjp"], md: ["markdown"], - txt: ["text", "log"], + tiff: ["tif"], }; // Returns the extension implied by the media type alone, ignoring the diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 6a55bb6d6f7..a4c3d2d2a00 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1336,6 +1336,12 @@ export const DownloadNamesGainMediaTypeExtension: Story = { file_id: "storybook-text-3", name: "meeting notes", }, + { + type: "file", + media_type: "image/jpeg", + data: TEST_PNG_B64, + name: "photo.jfif", + }, ], }, ]), @@ -1381,6 +1387,12 @@ export const DownloadNamesGainMediaTypeExtension: Story = { canvas.getByRole("link", { name: "Download meeting notes" }), ).toHaveAttribute("download", "meeting notes.txt"); }); + canvas.getByRole("button", { name: "View photo.jfif" }).focus(); + await waitFor(() => { + expect( + canvas.getByRole("link", { name: "Download photo.jfif" }), + ).toHaveAttribute("download", "photo.jfif"); + }); }, }; From 2872ad85339f8caca6ac92eec84bdc64c826399a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:31:02 +0000 Subject: [PATCH 21/43] fix(site/src/pages/AgentsPage): preserve dotfile names in attachment downloads --- .../components/ChatConversation/AttachmentBlocks.tsx | 5 +++++ .../ConversationTimeline.stories.tsx | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index c5680d81d3a..ac7934723d0 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -167,6 +167,11 @@ const getAttachmentDownloadName = ( if (mediaExtension === null) { return name; } + // Leading-dot names are dotfiles (.eslintrc, .gitignore) whose whole + // name carries the meaning; appending an extension would rename them. + if (name.startsWith(".")) { + return name; + } // text/plain is the server classifier's catch-all for source files // (main.go, config.properties), whose suffixes identify them better // than .txt would, so any dotted suffix is kept regardless of length. diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index a4c3d2d2a00..3e2ed039db2 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1342,6 +1342,12 @@ export const DownloadNamesGainMediaTypeExtension: Story = { data: TEST_PNG_B64, name: "photo.jfif", }, + { + type: "file", + media_type: "application/json", + file_id: "storybook-json-1", + name: ".eslintrc", + }, ], }, ]), @@ -1393,6 +1399,12 @@ export const DownloadNamesGainMediaTypeExtension: Story = { canvas.getByRole("link", { name: "Download photo.jfif" }), ).toHaveAttribute("download", "photo.jfif"); }); + canvas.getByRole("button", { name: "View .eslintrc" }).focus(); + await waitFor(() => { + expect( + canvas.getByRole("link", { name: "Download .eslintrc" }), + ).toHaveAttribute("download", ".eslintrc"); + }); }, }; From b13ccbd94e9e6cfca01095e19784bf96160cb9f6 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:38:37 +0000 Subject: [PATCH 22/43] fix(site/src/pages/AgentsPage): preserve suffixes for all text-like attachment types --- .../components/ChatConversation/AttachmentBlocks.tsx | 10 ++++++---- .../ConversationTimeline.stories.tsx | 12 ++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index ac7934723d0..768e697a903 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -172,10 +172,12 @@ const getAttachmentDownloadName = ( if (name.startsWith(".")) { return name; } - // text/plain is the server classifier's catch-all for source files - // (main.go, config.properties), whose suffixes identify them better - // than .txt would, so any dotted suffix is kept regardless of length. - if (block.media_type === "text/plain") { + // The server classifies text-like uploads (text/plain, markdown, CSV, + // JSON) by content while preserving their names, so an existing suffix + // (main.go, config.properties, map.geojson) identifies the file better + // than the canonical extension would. Keep any dotted suffix and only + // append the extension to extensionless names. + if (isTextPreviewAttachmentMediaType(block.media_type)) { return /\.[^.\s]+$/.test(name) ? name : `${name}.${mediaExtension}`; } // iOS resolves the shared or saved file's type from the filename diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 3e2ed039db2..5745124cf9b 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1348,6 +1348,12 @@ export const DownloadNamesGainMediaTypeExtension: Story = { file_id: "storybook-json-1", name: ".eslintrc", }, + { + type: "file", + media_type: "application/json", + file_id: "storybook-json-2", + name: "map.geojson", + }, ], }, ]), @@ -1405,6 +1411,12 @@ export const DownloadNamesGainMediaTypeExtension: Story = { canvas.getByRole("link", { name: "Download .eslintrc" }), ).toHaveAttribute("download", ".eslintrc"); }); + canvas.getByRole("button", { name: "View map.geojson" }).focus(); + await waitFor(() => { + expect( + canvas.getByRole("link", { name: "Download map.geojson" }), + ).toHaveAttribute("download", "map.geojson"); + }); }, }; From f9ce07670f61eb05c295ff3dc7475176b196f40a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:50:17 +0000 Subject: [PATCH 23/43] fix(site/src/pages/AgentsPage): map structured MIME subtypes to real extensions --- .../ChatConversation/AttachmentBlocks.tsx | 31 ++++++++++++---- .../ConversationTimeline.stories.tsx | 37 +++++++++++++++++++ .../AgentsPage/utils/chatAttachments.test.ts | 24 +++++------- 3 files changed, 69 insertions(+), 23 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index 768e697a903..1cb4742b56e 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -74,6 +74,19 @@ const sanitizeAttachmentExtension = (value: string): string => { return sanitized || "file"; }; +// Structured MIME suffixes (RFC 6839) carry the real format before the +// plus sign, so image/svg+xml maps to svg rather than a mangled subtype. +const structuredSubtypeExtension = (subtype: string): string | null => { + if (subtype.endsWith("+json")) { + return "json"; + } + if (subtype.endsWith("+xml")) { + const base = subtype.slice(0, -"+xml".length); + return /^[a-z0-9]{1,8}$/i.test(base) ? base.toLowerCase() : "xml"; + } + return null; +}; + const getAttachmentExtension = ( block: Pick, ): string => { @@ -91,10 +104,9 @@ const getAttachmentExtension = ( } } const subtype = block.media_type.split("/")[1] ?? ""; - if (subtype.endsWith("+json")) { - return "json"; - } - return sanitizeAttachmentExtension(subtype); + return ( + structuredSubtypeExtension(subtype) ?? sanitizeAttachmentExtension(subtype) + ); }; const isTextPreviewAttachmentMediaType = (mediaType: string): boolean => @@ -148,11 +160,14 @@ const getMediaTypeExtension = (mediaType: string): string | null => { return mapped; } const subtype = mediaType.split("/")[1] ?? ""; - if (subtype.endsWith("+json")) { - return "json"; + const structured = structuredSubtypeExtension(subtype); + if (structured) { + return structured; } - const sanitized = sanitizeAttachmentExtension(subtype); - return sanitized === "file" ? null : sanitized; + // Only simple subtypes (png, gif, webp) map reliably onto an + // extension. Sanitizing structured or vendor subtypes would append + // a mangled suffix, so those keep the attachment's own name. + return /^[a-z0-9]{1,8}$/i.test(subtype) ? subtype.toLowerCase() : null; }; const getAttachmentDownloadName = ( diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 5745124cf9b..b5ca4d7b620 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -21,6 +21,10 @@ import type { ParsedMessageEntry } from "./types"; const TEST_PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4n539HwAHFwLVF8kc1wAAAABJRU5ErkJggg=="; +const TEST_SVG_B64 = btoa( + '', +); + const buildMessages = (messages: TypesGen.ChatMessage[]) => parseMessagesWithMergedTools(messages); @@ -1354,6 +1358,24 @@ export const DownloadNamesGainMediaTypeExtension: Story = { file_id: "storybook-json-2", name: "map.geojson", }, + { + type: "file", + media_type: "image/svg+xml", + data: TEST_SVG_B64, + name: "diagram.svg", + }, + { + type: "file", + media_type: "image/svg+xml", + data: TEST_SVG_B64, + name: "architecture sketch", + }, + { + type: "file", + media_type: "application/vnd.oasis.opendocument.text", + file_id: "storybook-odt-1", + name: "notes.odt", + }, ], }, ]), @@ -1417,6 +1439,21 @@ export const DownloadNamesGainMediaTypeExtension: Story = { canvas.getByRole("link", { name: "Download map.geojson" }), ).toHaveAttribute("download", "map.geojson"); }); + canvas.getByRole("button", { name: "View diagram.svg" }).focus(); + await waitFor(() => { + expect( + canvas.getByRole("link", { name: "Download diagram.svg" }), + ).toHaveAttribute("download", "diagram.svg"); + }); + canvas.getByRole("button", { name: "View architecture sketch" }).focus(); + await waitFor(() => { + expect( + canvas.getByRole("link", { name: "Download architecture sketch" }), + ).toHaveAttribute("download", "architecture sketch.svg"); + }); + expect( + canvas.getByRole("link", { name: "Download notes.odt" }), + ).toHaveAttribute("download", "notes.odt"); }, }; diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts index 4601b58515f..f5886b5162c 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts @@ -260,16 +260,13 @@ describe("handleAttachmentDownloadClick", () => { expect(shared.files[0].size).toBe("png-bytes".length); }); - it("offers a blob-backed Open fallback for data: hrefs on permanent share failure", async () => { + it("offers an Open fallback for data: hrefs on permanent share failure", async () => { enterIOSStandalonePWA(); overrideNavigator( "share", vi.fn().mockRejectedValue(new DOMException("share failed", "DataError")), ); overrideNavigator("canShare", vi.fn().mockReturnValue(true)); - const { createObjectURL, revokeObjectURL } = stubObjectURLs(); - const open = vi.spyOn(window, "open").mockReturnValue(null); - vi.useFakeTimers(); const event = { preventDefault: vi.fn() }; await handleAttachmentDownloadClick(event, { @@ -278,17 +275,14 @@ describe("handleAttachmentDownloadClick", () => { mediaType: "image/png", }); - expect(toast.error).toHaveBeenCalledTimes(1); - const options = vi.mocked(toast.error).mock.calls[0][1] as { - action: { label: string; onClick: () => void }; - }; - expect(options.action.label).toBe("Open"); - options.action.onClick(); - expect(createObjectURL).toHaveBeenCalledTimes(1); - expect(createObjectURL.mock.calls[0][0]).toBeInstanceOf(File); - expect(open).toHaveBeenCalledWith("blob:inline", "_blank", "noopener"); - vi.runAllTimers(); - expect(revokeObjectURL).toHaveBeenCalledWith("blob:inline"); + // Clicking Open in the rendered toast is exercised in Storybook + // (DownloadInIOSStandaloneOpensInlineAttachmentFromToast). + expect(toast.error).toHaveBeenCalledWith( + "Couldn't download inline.png", + expect.objectContaining({ + action: expect.objectContaining({ label: "Open" }), + }), + ); }); it("opens inline attachments through a blob tab when file sharing is unavailable", () => { From c7787a7a024d4783c3eda4fe19a1103cfb07ac70 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:58:21 +0000 Subject: [PATCH 24/43] fix(site/src/pages/AgentsPage): derive extensions only from image subtypes --- .../ChatConversation/AttachmentBlocks.tsx | 13 ++++++++----- .../ConversationTimeline.stories.tsx | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index 1cb4742b56e..cf8f4fa7c49 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -159,15 +159,18 @@ const getMediaTypeExtension = (mediaType: string): string | null => { if (mapped) { return mapped; } - const subtype = mediaType.split("/")[1] ?? ""; + const [type, subtype = ""] = mediaType.split("/"); const structured = structuredSubtypeExtension(subtype); if (structured) { return structured; } - // Only simple subtypes (png, gif, webp) map reliably onto an - // extension. Sanitizing structured or vendor subtypes would append - // a mangled suffix, so those keep the attachment's own name. - return /^[a-z0-9]{1,8}$/i.test(subtype) ? subtype.toLowerCase() : null; + // Only image subtypes reliably double as filename extensions (png, + // gif, webp). Other subtypes are MIME names, not extensions + // (application/msword, audio/mpeg), so without an explicit mapping + // the attachment keeps its own name. + return type === "image" && /^[a-z0-9]{1,8}$/i.test(subtype) + ? subtype.toLowerCase() + : null; }; const getAttachmentDownloadName = ( diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index b5ca4d7b620..c6f4d767132 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1376,6 +1376,18 @@ export const DownloadNamesGainMediaTypeExtension: Story = { file_id: "storybook-odt-1", name: "notes.odt", }, + { + type: "file", + media_type: "application/msword", + file_id: "storybook-doc-1", + name: "report.doc", + }, + { + type: "file", + media_type: "audio/mpeg", + file_id: "storybook-mp3-1", + name: "song.mp3", + }, ], }, ]), @@ -1454,6 +1466,12 @@ export const DownloadNamesGainMediaTypeExtension: Story = { expect( canvas.getByRole("link", { name: "Download notes.odt" }), ).toHaveAttribute("download", "notes.odt"); + expect( + canvas.getByRole("link", { name: "Download report.doc" }), + ).toHaveAttribute("download", "report.doc"); + expect( + canvas.getByRole("link", { name: "Download song.mp3" }), + ).toHaveAttribute("download", "song.mp3"); }, }; From 03b0872457bb1208745baca616ea5be254462fab Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:06:08 +0000 Subject: [PATCH 25/43] fix(site/src/pages/AgentsPage): map XML, CSV, and HTML media types to extensions --- .../ChatConversation/AttachmentBlocks.tsx | 4 ++++ .../ConversationTimeline.stories.tsx | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index cf8f4fa7c49..d3a48a8d9c0 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -61,9 +61,13 @@ const ATTACHMENT_FALLBACK_EXTENSIONS: Record = { "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx", "application/x-tar": "tar", + "application/xml": "xml", "image/jpeg": "jpg", + "text/csv": "csv", + "text/html": "html", "text/markdown": "md", "text/plain": "txt", + "text/xml": "xml", }; const sanitizeAttachmentExtension = (value: string): string => { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index c6f4d767132..364bffebeaf 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1388,6 +1388,18 @@ export const DownloadNamesGainMediaTypeExtension: Story = { file_id: "storybook-mp3-1", name: "song.mp3", }, + { + type: "file", + media_type: "application/xml", + file_id: "storybook-xml-1", + name: "build config", + }, + { + type: "file", + media_type: "text/csv", + file_id: "storybook-csv-1", + name: "export data", + }, ], }, ]), @@ -1472,6 +1484,15 @@ export const DownloadNamesGainMediaTypeExtension: Story = { expect( canvas.getByRole("link", { name: "Download song.mp3" }), ).toHaveAttribute("download", "song.mp3"); + expect( + canvas.getByRole("link", { name: "Download build config" }), + ).toHaveAttribute("download", "build config.xml"); + canvas.getByRole("button", { name: "View export data" }).focus(); + await waitFor(() => { + expect( + canvas.getByRole("link", { name: "Download export data" }), + ).toHaveAttribute("download", "export data.csv"); + }); }, }; From 10e509e12b309e03d031f85485e8f0657b5f53c0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:17:35 +0000 Subject: [PATCH 26/43] fix(site/src/pages/AgentsPage): share one data URL decoder and keep XML suffixes --- .../ChatConversation/AttachmentBlocks.tsx | 19 ++++++----- .../ConversationTimeline.stories.tsx | 9 +++++ .../pages/AgentsPage/utils/chatAttachments.ts | 19 ++++------- .../utils/chatDraftAttachmentStorage.ts | 34 +++++++------------ site/src/pages/AgentsPage/utils/dataUrls.ts | 25 ++++++++++++++ 5 files changed, 63 insertions(+), 43 deletions(-) create mode 100644 site/src/pages/AgentsPage/utils/dataUrls.ts diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index d3a48a8d9c0..8efdb7b9a67 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -116,6 +116,11 @@ const getAttachmentExtension = ( const isTextPreviewAttachmentMediaType = (mediaType: string): boolean => TEXT_ATTACHMENT_MEDIA_TYPES.has(mediaType); +const isSuffixPreservingMediaType = (mediaType: string): boolean => + mediaType.startsWith("text/") || + mediaType === "application/json" || + mediaType === "application/xml"; + const getAttachmentHref = (block: FileAttachmentBlock): string | null => { if (block.file_id) { return getChatFileURL(block.file_id); @@ -147,9 +152,7 @@ const endsWithFileExtension = /\.([a-z0-9]{1,8})$/i; // Alternate name suffixes that identify the same type as the canonical // media-type extension, so "photo.jpeg" is not renamed to "photo.jpeg.jpg". const extensionAliases: Record = { - html: ["htm"], jpg: ["jpeg", "jfif", "jpe", "pjpeg", "pjp"], - md: ["markdown"], tiff: ["tif"], }; @@ -194,12 +197,12 @@ const getAttachmentDownloadName = ( if (name.startsWith(".")) { return name; } - // The server classifies text-like uploads (text/plain, markdown, CSV, - // JSON) by content while preserving their names, so an existing suffix - // (main.go, config.properties, map.geojson) identifies the file better - // than the canonical extension would. Keep any dotted suffix and only - // append the extension to extensionless names. - if (isTextPreviewAttachmentMediaType(block.media_type)) { + // The server classifies text-like uploads (text/*, JSON, XML) by + // content while preserving their names, so an existing suffix + // (main.go, map.geojson, schema.xsd) identifies the file better + // than the canonical extension would. Keep any dotted suffix and + // only append the extension to extensionless names. + if (isSuffixPreservingMediaType(block.media_type)) { return /\.[^.\s]+$/.test(name) ? name : `${name}.${mediaExtension}`; } // iOS resolves the shared or saved file's type from the filename diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 364bffebeaf..2f9118221ce 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1394,6 +1394,12 @@ export const DownloadNamesGainMediaTypeExtension: Story = { file_id: "storybook-xml-1", name: "build config", }, + { + type: "file", + media_type: "application/xml", + file_id: "storybook-xml-2", + name: "schema.xsd", + }, { type: "file", media_type: "text/csv", @@ -1487,6 +1493,9 @@ export const DownloadNamesGainMediaTypeExtension: Story = { expect( canvas.getByRole("link", { name: "Download build config" }), ).toHaveAttribute("download", "build config.xml"); + expect( + canvas.getByRole("link", { name: "Download schema.xsd" }), + ).toHaveAttribute("download", "schema.xsd"); canvas.getByRole("button", { name: "View export data" }).focus(); await waitFor(() => { expect( diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index bb3e81476a6..eb90fbd6bf5 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -1,6 +1,7 @@ import { toast } from "sonner"; import { isApiErrorResponse } from "#/api/errors"; import { ChatAttachmentMediaTypes } from "#/api/typesGenerated"; +import { decodeDataURL } from "./dataUrls"; const undisplayableAttachmentDetail = "File exists but could not be displayed."; @@ -161,21 +162,13 @@ const fileFromDataURL = ( fileName: string, fallbackMediaType: string, ): File | null => { - const match = /^data:([^,]*?)(;base64)?,(.*)$/.exec(href); - if (!match) { - return null; - } - const [, type, isBase64, payload] = match; - try { - const bytes = isBase64 - ? Uint8Array.from(atob(payload), (char) => char.charCodeAt(0)) - : new TextEncoder().encode(decodeURIComponent(payload)); - return new File([bytes], fileName, { - type: type || fallbackMediaType || "application/octet-stream", - }); - } catch { + const decoded = decodeDataURL(href); + if (!decoded) { return null; } + return new File([decoded.bytes], fileName, { + type: decoded.mediaType || fallbackMediaType || "application/octet-stream", + }); }; const shareAttachmentFile = async ({ diff --git a/site/src/pages/AgentsPage/utils/chatDraftAttachmentStorage.ts b/site/src/pages/AgentsPage/utils/chatDraftAttachmentStorage.ts index 586ecd6267c..1a26ad1ab0b 100644 --- a/site/src/pages/AgentsPage/utils/chatDraftAttachmentStorage.ts +++ b/site/src/pages/AgentsPage/utils/chatDraftAttachmentStorage.ts @@ -1,3 +1,5 @@ +import { decodeDataURL } from "./dataUrls"; + type ChatDraftAttachmentRecord = { clientId: string; fileName: string; @@ -279,35 +281,23 @@ const fileFromDataURL = ( payload: string, metadata: { fileName: string; fileType: string; lastModified: number }, ): File | null => { - const commaIndex = payload.indexOf(","); - if (commaIndex === -1 || !payload.startsWith("data:")) { - return null; - } - const header = payload.slice(0, commaIndex); - if (!header.toLowerCase().includes(";base64")) { + const decoded = decodeDataURL(payload); + // Stored records are always base64 (fileToDataURL uses FileReader), + // so anything else is corruption. + if (!decoded?.isBase64) { return null; } - const payloadMediaType = header.slice("data:".length).split(";")[0]; if ( metadata.fileType && - payloadMediaType && - payloadMediaType.toLowerCase() !== metadata.fileType.toLowerCase() + decoded.mediaType && + decoded.mediaType.toLowerCase() !== metadata.fileType.toLowerCase() ) { return null; } - try { - const binary = atob(payload.slice(commaIndex + 1)); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index++) { - bytes[index] = binary.charCodeAt(index); - } - return new File([bytes], metadata.fileName, { - type: metadata.fileType, - lastModified: metadata.lastModified, - }); - } catch { - return null; - } + return new File([decoded.bytes], metadata.fileName, { + type: metadata.fileType, + lastModified: metadata.lastModified, + }); }; const fileForRecord = (record: ChatDraftAttachmentRecord): File | null => { diff --git a/site/src/pages/AgentsPage/utils/dataUrls.ts b/site/src/pages/AgentsPage/utils/dataUrls.ts new file mode 100644 index 00000000000..36d568ec51c --- /dev/null +++ b/site/src/pages/AgentsPage/utils/dataUrls.ts @@ -0,0 +1,25 @@ +type DecodedDataURL = { + mediaType: string; + isBase64: boolean; + bytes: Uint8Array; +}; + +export const decodeDataURL = (url: string): DecodedDataURL | null => { + const match = /^data:([^,]*?)(;base64)?,(.*)$/i.exec(url); + if (!match) { + return null; + } + const [, header, isBase64, payload] = match; + try { + const bytes = isBase64 + ? Uint8Array.from(atob(payload), (char) => char.charCodeAt(0)) + : new TextEncoder().encode(decodeURIComponent(payload)); + return { + mediaType: header.split(";")[0].trim(), + isBase64: Boolean(isBase64), + bytes, + }; + } catch { + return null; + } +}; From 0d5d8b0896f6eac68f3580c750087acbfb2d3e15 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:27:58 +0000 Subject: [PATCH 27/43] fix(site/src/pages/AgentsPage): abort pending attachment downloads on unmount --- .../ChatConversation/AttachmentBlocks.tsx | 17 +++++- .../AgentsPage/utils/chatAttachments.test.ts | 52 ++++++++++++++++++- .../pages/AgentsPage/utils/chatAttachments.ts | 22 +++++--- 3 files changed, 81 insertions(+), 10 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index 8efdb7b9a67..e8c36d9f985 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -233,16 +233,29 @@ const getAttachmentBadgeLabel = ( // a misleading error toast while the first sheet is open. const useAttachmentDownloadClick = (target: AttachmentDownloadTarget) => { const [isPending, setIsPending] = useState(false); + // Aborts on unmount so a slow fetch cannot surface the share sheet + // or an error toast after the user has navigated away. + const downloadRequest = useLatestAbortController(); const onClick = (event: ReactMouseEvent) => { event.stopPropagation(); if (isPending) { event.preventDefault(); return; } - const pending = handleAttachmentDownloadClick(event, target); + const controller = downloadRequest.start(); + const pending = handleAttachmentDownloadClick( + event, + target, + controller.signal, + ); if (pending) { setIsPending(true); - void pending.finally(() => setIsPending(false)); + void pending.finally(() => { + downloadRequest.clear(controller); + setIsPending(false); + }); + } else { + downloadRequest.clear(controller); } }; return { isPending, onClick }; diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts index f5886b5162c..f83b4117e45 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts @@ -112,7 +112,9 @@ describe("handleAttachmentDownloadClick", () => { await handleAttachmentDownloadClick(event, target); expect(event.preventDefault).toHaveBeenCalled(); - expect(globalThis.fetch).toHaveBeenCalledWith(target.href); + expect(globalThis.fetch).toHaveBeenCalledWith(target.href, { + signal: undefined, + }); expect(open).not.toHaveBeenCalled(); expect(share).toHaveBeenCalledTimes(1); const shared: { files: File[] } = share.mock.calls[0][0]; @@ -330,6 +332,54 @@ describe("handleAttachmentDownloadClick", () => { }); }); + it("stays quiet when the download is aborted mid-fetch", async () => { + enterIOSStandalonePWA(); + const share = vi.fn().mockResolvedValue(undefined); + overrideNavigator("share", share); + overrideNavigator("canShare", vi.fn().mockReturnValue(true)); + vi.spyOn(globalThis, "fetch").mockImplementation( + (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("aborted", "AbortError")), + ); + }), + ); + const controller = new AbortController(); + const event = { preventDefault: vi.fn() }; + + const pending = handleAttachmentDownloadClick( + event, + target, + controller.signal, + ); + controller.abort(); + await pending; + + expect(share).not.toHaveBeenCalled(); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("suppresses the share sheet when aborted after the fetch resolves", async () => { + enterIOSStandalonePWA(); + const share = vi.fn().mockResolvedValue(undefined); + overrideNavigator("share", share); + overrideNavigator("canShare", vi.fn().mockReturnValue(true)); + const controller = new AbortController(); + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + controller.abort(); + return new Response(new Blob(["png-bytes"], { type: "image/png" }), { + status: 200, + }); + }); + const event = { preventDefault: vi.fn() }; + + await handleAttachmentDownloadClick(event, target, controller.signal); + + expect(share).not.toHaveBeenCalled(); + expect(toast.error).not.toHaveBeenCalled(); + }); + it("offers a tab fallback when the fetched file turns out unshareable", async () => { enterIOSStandalonePWA(); const share = vi.fn().mockResolvedValue(undefined); diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index eb90fbd6bf5..fea7d9a99c3 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -171,11 +171,10 @@ const fileFromDataURL = ( }); }; -const shareAttachmentFile = async ({ - href, - fileName, - mediaType, -}: AttachmentDownloadTarget): Promise => { +const shareAttachmentFile = async ( + { href, fileName, mediaType }: AttachmentDownloadTarget, + signal?: AbortSignal, +): Promise => { let file: File; if (href.startsWith("data:")) { const decoded = fileFromDataURL(href, fileName, mediaType); @@ -186,7 +185,7 @@ const shareAttachmentFile = async ({ file = decoded; } else { try { - const response = await fetch(href); + const response = await fetch(href, { signal }); if (!response.ok) { throw new Error( response.statusText @@ -199,12 +198,20 @@ const shareAttachmentFile = async ({ type: blob.type || mediaType || "application/octet-stream", }); } catch (error) { + // An aborted fetch means the attachment unmounted; the user is + // elsewhere, so no follow-up UI. + if (errorHasName(error, "AbortError")) { + return; + } toast.error(`Couldn't download ${fileName}`, { description: error instanceof Error ? error.message : undefined, }); return; } } + if (signal?.aborted) { + return; + } if (!canShareFiles([file])) { // The pre-fetch probe can pass while the real file fails canShare // (for example over the size limit). The native anchor action was @@ -227,6 +234,7 @@ const shareAttachmentFile = async ({ export const handleAttachmentDownloadClick = ( event: { preventDefault: () => void }, target: AttachmentDownloadTarget, + signal?: AbortSignal, ): Promise | undefined => { if (!isIOS() || !isStandaloneDisplayMode()) { return undefined; @@ -252,7 +260,7 @@ export const handleAttachmentDownloadClick = ( } return undefined; } - return shareAttachmentFile(target); + return shareAttachmentFile(target, signal); }; // Filename extensions to list in the file-picker's `accept` attribute From 0d11cc196375910cf966a36c1946960e07f78be4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:38:03 +0000 Subject: [PATCH 28/43] fix(site/src/pages/AgentsPage): keep suffixes for structured +json and +xml types --- .../components/ChatConversation/AttachmentBlocks.tsx | 7 ++++++- .../ChatConversation/ConversationTimeline.stories.tsx | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index e8c36d9f985..c85d62c70c9 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -116,10 +116,15 @@ const getAttachmentExtension = ( const isTextPreviewAttachmentMediaType = (mediaType: string): boolean => TEXT_ATTACHMENT_MEDIA_TYPES.has(mediaType); +// Text-like types whose payloads the server classifies by content while +// preserving the name. Structured +json/+xml types (application/ld+json) +// belong here too: their registered extensions (.jsonld) differ from the +// base format's, so an existing suffix always wins. const isSuffixPreservingMediaType = (mediaType: string): boolean => mediaType.startsWith("text/") || mediaType === "application/json" || - mediaType === "application/xml"; + mediaType === "application/xml" || + /\+(json|xml)$/i.test(mediaType); const getAttachmentHref = (block: FileAttachmentBlock): string | null => { if (block.file_id) { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 2f9118221ce..bf8c8d7ea99 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1406,6 +1406,12 @@ export const DownloadNamesGainMediaTypeExtension: Story = { file_id: "storybook-csv-1", name: "export data", }, + { + type: "file", + media_type: "application/ld+json", + file_id: "storybook-jsonld-1", + name: "context.jsonld", + }, ], }, ]), @@ -1502,6 +1508,9 @@ export const DownloadNamesGainMediaTypeExtension: Story = { canvas.getByRole("link", { name: "Download export data" }), ).toHaveAttribute("download", "export data.csv"); }); + expect( + canvas.getByRole("link", { name: "Download context.jsonld" }), + ).toHaveAttribute("download", "context.jsonld"); }, }; From 75d12f22e32f6633a3d314f9264da9f3d5f21954 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:44:42 +0000 Subject: [PATCH 29/43] fix(site/src/pages/AgentsPage): suppress share rejection toasts after unmount --- .../AgentsPage/utils/chatAttachments.test.ts | 22 +++++++++++++++++++ .../pages/AgentsPage/utils/chatAttachments.ts | 9 +++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts index f83b4117e45..b2b0a13b1a8 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts @@ -380,6 +380,28 @@ describe("handleAttachmentDownloadClick", () => { expect(toast.error).not.toHaveBeenCalled(); }); + it("suppresses share rejection UI when aborted while the sheet is pending", async () => { + enterIOSStandalonePWA(); + const controller = new AbortController(); + const share = vi.fn().mockImplementation(() => { + controller.abort(); + return Promise.reject(new DOMException("expired", "NotAllowedError")); + }); + overrideNavigator("share", share); + overrideNavigator("canShare", vi.fn().mockReturnValue(true)); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(new Blob(["png-bytes"], { type: "image/png" }), { + status: 200, + }), + ); + const event = { preventDefault: vi.fn() }; + + await handleAttachmentDownloadClick(event, target, controller.signal); + + expect(share).toHaveBeenCalledTimes(1); + expect(toast.error).not.toHaveBeenCalled(); + }); + it("offers a tab fallback when the fetched file turns out unshareable", async () => { enterIOSStandalonePWA(); const share = vi.fn().mockResolvedValue(undefined); diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index fea7d9a99c3..973d21d1bb5 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -129,10 +129,13 @@ const shareFileViaSheet = ( file: File, fileName: string, href: string, + signal?: AbortSignal, ): Promise => navigator.share({ files: [file] }).catch((error: unknown) => { - // A dismissed share sheet rejects with AbortError. - if (errorHasName(error, "AbortError")) { + // A dismissed share sheet rejects with AbortError. An aborted + // signal means the attachment unmounted, so rejection UI would + // surface on an unrelated view. + if (errorHasName(error, "AbortError") || signal?.aborted) { return; } // iOS transient activation can expire while the file is fetched. @@ -223,7 +226,7 @@ const shareAttachmentFile = async ( }); return; } - await shareFileViaSheet(file, fileName, href); + await shareFileViaSheet(file, fileName, href, signal); }; /** From 37389296fd1938d514f108749ce1b6855e398855 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:50:23 +0000 Subject: [PATCH 30/43] test(site/src/pages/AgentsPage): cover dismissed share sheet in Storybook --- .../ConversationTimeline.stories.tsx | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index bf8c8d7ea99..0efdc6c3738 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1937,6 +1937,40 @@ export const DownloadInIOSStandaloneOpensInlineAttachmentInTab: Story = { }, }; +export const DownloadInIOSStandaloneStaysQuietOnDismissedShare: Story = { + decorators: [withToaster], + args: iosDownloadStoryArgs, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const share = fn().mockRejectedValue( + new DOMException("dismissed", "AbortError"), + ); + const restoreNavigator = overrideNavigatorForIOSStandalone({ + share, + canShare: fn().mockReturnValue(true), + }); + const open = spyOn(window, "open").mockReturnValue(null); + try { + await userEvent.click( + canvas.getByRole("link", { name: "Download deployment-report.pdf" }), + ); + await waitFor(() => expect(share).toHaveBeenCalledTimes(1)); + // The spinner clearing marks the flow as settled. + await waitFor(() => { + expect( + canvas.getByRole("link", { name: "Download deployment-report.pdf" }), + ).toHaveAttribute("aria-disabled", "false"); + }); + expect( + screen.queryByText("Couldn't download deployment-report.pdf"), + ).not.toBeInTheDocument(); + expect(open).not.toHaveBeenCalled(); + } finally { + restoreNavigator(); + } + }, +}; + export const DownloadInIOSStandaloneWithoutShareOpensTab: Story = { args: iosDownloadStoryArgs, play: async ({ canvasElement }) => { From 7aa5caba9aff045632727edbcef6857b3f9e8a5c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:37:39 +0000 Subject: [PATCH 31/43] test(site/src/pages/AgentsPage): compress attachment download tests into shared helpers and tables --- .../ConversationTimeline.stories.tsx | 602 ++---------------- .../AgentsPage/utils/chatAttachments.test.ts | 300 ++++----- 2 files changed, 180 insertions(+), 722 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 0efdc6c3738..8ccb4eb57f2 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -21,10 +21,6 @@ import type { ParsedMessageEntry } from "./types"; const TEST_PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4n539HwAHFwLVF8kc1wAAAABJRU5ErkJggg=="; -const TEST_SVG_B64 = btoa( - '', -); - const buildMessages = (messages: TypesGen.ChatMessage[]) => parseMessagesWithMergedTools(messages); @@ -1294,226 +1290,6 @@ export const AssistantMessageWithImage: Story = { }, }; -export const DownloadNamesGainMediaTypeExtension: Story = { - args: { - ...defaultArgs, - parsedMessages: buildMessages([ - { - ...baseMessage, - id: 1, - role: "assistant", - content: [ - { type: "text", text: "Here are the files:" }, - { - type: "file", - media_type: "image/png", - data: TEST_PNG_B64, - name: "About Page Screenshot", - }, - { - type: "file", - media_type: "application/pdf", - file_id: "storybook-ios-share-report", - name: "report.final", - }, - { - type: "file", - media_type: "application/pdf", - file_id: "storybook-unnamed-report", - name: "quarterly-report.pdf", - }, - { - type: "file", - media_type: "text/plain", - file_id: "storybook-text-1", - name: "main.go", - }, - { - type: "file", - media_type: "text/plain", - file_id: "storybook-text-2", - name: "config.properties", - }, - { - type: "file", - media_type: "text/plain", - file_id: "storybook-text-3", - name: "meeting notes", - }, - { - type: "file", - media_type: "image/jpeg", - data: TEST_PNG_B64, - name: "photo.jfif", - }, - { - type: "file", - media_type: "application/json", - file_id: "storybook-json-1", - name: ".eslintrc", - }, - { - type: "file", - media_type: "application/json", - file_id: "storybook-json-2", - name: "map.geojson", - }, - { - type: "file", - media_type: "image/svg+xml", - data: TEST_SVG_B64, - name: "diagram.svg", - }, - { - type: "file", - media_type: "image/svg+xml", - data: TEST_SVG_B64, - name: "architecture sketch", - }, - { - type: "file", - media_type: "application/vnd.oasis.opendocument.text", - file_id: "storybook-odt-1", - name: "notes.odt", - }, - { - type: "file", - media_type: "application/msword", - file_id: "storybook-doc-1", - name: "report.doc", - }, - { - type: "file", - media_type: "audio/mpeg", - file_id: "storybook-mp3-1", - name: "song.mp3", - }, - { - type: "file", - media_type: "application/xml", - file_id: "storybook-xml-1", - name: "build config", - }, - { - type: "file", - media_type: "application/xml", - file_id: "storybook-xml-2", - name: "schema.xsd", - }, - { - type: "file", - media_type: "text/csv", - file_id: "storybook-csv-1", - name: "export data", - }, - { - type: "file", - media_type: "application/ld+json", - file_id: "storybook-jsonld-1", - name: "context.jsonld", - }, - ], - }, - ]), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const viewButton = canvas.getByRole("button", { - name: "View About Page Screenshot", - }); - viewButton.focus(); - await waitFor(() => { - expect( - canvas.getByRole("link", { name: "Download About Page Screenshot" }), - ).toBeVisible(); - }); - expect( - canvas.getByRole("link", { name: "Download About Page Screenshot" }), - ).toHaveAttribute("download", "About Page Screenshot.png"); - expect( - canvas.getByRole("link", { name: "Download report.final" }), - ).toHaveAttribute("download", "report.final.pdf"); - expect( - canvas.getByRole("link", { name: "Download quarterly-report.pdf" }), - ).toHaveAttribute("download", "quarterly-report.pdf"); - // text/plain covers source files, so their suffixes are preserved. - // The overlay link joins the accessibility tree only while its - // attachment group has focus. - canvas.getByRole("button", { name: "View main.go" }).focus(); - await waitFor(() => { - expect( - canvas.getByRole("link", { name: "Download main.go" }), - ).toHaveAttribute("download", "main.go"); - }); - canvas.getByRole("button", { name: "View config.properties" }).focus(); - await waitFor(() => { - expect( - canvas.getByRole("link", { name: "Download config.properties" }), - ).toHaveAttribute("download", "config.properties"); - }); - canvas.getByRole("button", { name: "View meeting notes" }).focus(); - await waitFor(() => { - expect( - canvas.getByRole("link", { name: "Download meeting notes" }), - ).toHaveAttribute("download", "meeting notes.txt"); - }); - canvas.getByRole("button", { name: "View photo.jfif" }).focus(); - await waitFor(() => { - expect( - canvas.getByRole("link", { name: "Download photo.jfif" }), - ).toHaveAttribute("download", "photo.jfif"); - }); - canvas.getByRole("button", { name: "View .eslintrc" }).focus(); - await waitFor(() => { - expect( - canvas.getByRole("link", { name: "Download .eslintrc" }), - ).toHaveAttribute("download", ".eslintrc"); - }); - canvas.getByRole("button", { name: "View map.geojson" }).focus(); - await waitFor(() => { - expect( - canvas.getByRole("link", { name: "Download map.geojson" }), - ).toHaveAttribute("download", "map.geojson"); - }); - canvas.getByRole("button", { name: "View diagram.svg" }).focus(); - await waitFor(() => { - expect( - canvas.getByRole("link", { name: "Download diagram.svg" }), - ).toHaveAttribute("download", "diagram.svg"); - }); - canvas.getByRole("button", { name: "View architecture sketch" }).focus(); - await waitFor(() => { - expect( - canvas.getByRole("link", { name: "Download architecture sketch" }), - ).toHaveAttribute("download", "architecture sketch.svg"); - }); - expect( - canvas.getByRole("link", { name: "Download notes.odt" }), - ).toHaveAttribute("download", "notes.odt"); - expect( - canvas.getByRole("link", { name: "Download report.doc" }), - ).toHaveAttribute("download", "report.doc"); - expect( - canvas.getByRole("link", { name: "Download song.mp3" }), - ).toHaveAttribute("download", "song.mp3"); - expect( - canvas.getByRole("link", { name: "Download build config" }), - ).toHaveAttribute("download", "build config.xml"); - expect( - canvas.getByRole("link", { name: "Download schema.xsd" }), - ).toHaveAttribute("download", "schema.xsd"); - canvas.getByRole("button", { name: "View export data" }).focus(); - await waitFor(() => { - expect( - canvas.getByRole("link", { name: "Download export data" }), - ).toHaveAttribute("download", "export data.csv"); - }); - expect( - canvas.getByRole("link", { name: "Download context.jsonld" }), - ).toHaveAttribute("download", "context.jsonld"); - }, -}; - export const AssistantMessageWithUnnamedDownloadableFile: Story = { args: { ...defaultArgs, @@ -1547,24 +1323,44 @@ export const AssistantMessageWithUnnamedDownloadableFile: Story = { }, }; -const iosDownloadStoryArgs: Story["args"] = { - ...defaultArgs, - parsedMessages: parseMessagesWithMergedTools([ - { - ...baseMessage, - id: 1, - role: "user", - content: [ - { type: "text", text: "I attached the deployment report." }, - { - type: "file", - media_type: "application/pdf", - file_id: "storybook-ios-share-report", - name: "deployment-report.pdf", - }, - ], - }, - ]), +const iosDownloadStoryArgs: Story["args"] = buildStoryArgs( + buildUserMessage({ + text: "I attached the deployment report.", + files: [ + buildFilePart({ + media_type: "application/pdf", + file_id: "storybook-ios-share-report", + name: "deployment-report.pdf", + }), + ], + }), +); + +const setupIOSStandaloneDownload = (extras: Record) => ({ + open: spyOn(window, "open").mockReturnValue(null), + restoreNavigator: overrideNavigatorForIOSStandalone(extras), +}); + +const getIOSDownloadLink = (canvas: ReturnType) => + canvas.getByRole("link", { name: "Download deployment-report.pdf" }); + +const buildInlineDownloadStoryArgs = ( + data: string, + name: string, +): Story["args"] => + buildStoryArgs({ + ...baseMessage, + id: 1, + role: "assistant", + content: [buildFilePart({ media_type: "image/png", data, name })], + }); + +const getInlineDownloadLink = async ( + canvas: ReturnType, + name: string, +) => { + canvas.getByRole("button", { name: `View ${name}` }).focus(); + return canvas.findByRole("link", { name: `Download ${name}` }); }; export const DownloadInIOSStandaloneSharesFile: Story = { @@ -1572,15 +1368,12 @@ export const DownloadInIOSStandaloneSharesFile: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); const share = fn().mockResolvedValue(undefined); - const restoreNavigator = overrideNavigatorForIOSStandalone({ + const { open, restoreNavigator } = setupIOSStandaloneDownload({ share, canShare: fn().mockReturnValue(true), }); - const open = spyOn(window, "open").mockReturnValue(null); try { - await userEvent.click( - canvas.getByRole("link", { name: "Download deployment-report.pdf" }), - ); + await userEvent.click(getIOSDownloadLink(canvas)); await waitFor(() => expect(share).toHaveBeenCalledTimes(1)); const shared: { files: File[] } = share.mock.calls[0][0]; expect(shared.files).toHaveLength(1); @@ -1594,45 +1387,12 @@ export const DownloadInIOSStandaloneSharesFile: Story = { }, }; -export const DownloadInIOSStandaloneRecoversExpiredActivation: Story = { - decorators: [withToaster], - args: iosDownloadStoryArgs, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const share = fn() - .mockRejectedValueOnce( - new DOMException("activation expired", "NotAllowedError"), - ) - .mockResolvedValue(undefined); - const restoreNavigator = overrideNavigatorForIOSStandalone({ - share, - canShare: fn().mockReturnValue(true), - }); - const open = spyOn(window, "open").mockReturnValue(null); - try { - await userEvent.click( - canvas.getByRole("link", { name: "Download deployment-report.pdf" }), - ); - // The toast renders in a portal outside the story canvas. - const saveButton = await screen.findByRole("button", { name: "Save" }); - await userEvent.click(saveButton); - await waitFor(() => expect(share).toHaveBeenCalledTimes(2)); - const shared: { files: File[] } = share.mock.calls[1][0]; - expect(shared.files).toHaveLength(1); - expect(shared.files[0].name).toBe("deployment-report.pdf"); - expect(open).not.toHaveBeenCalled(); - } finally { - restoreNavigator(); - } - }, -}; - export const DownloadInIOSStandaloneSuppressesDuplicateClicks: Story = { args: iosDownloadStoryArgs, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const share = fn().mockResolvedValue(undefined); - const restoreNavigator = overrideNavigatorForIOSStandalone({ + const { restoreNavigator } = setupIOSStandaloneDownload({ share, canShare: fn().mockReturnValue(true), }); @@ -1649,9 +1409,7 @@ export const DownloadInIOSStandaloneSuppressesDuplicateClicks: Story = { }), ); try { - const downloadLink = canvas.getByRole("link", { - name: "Download deployment-report.pdf", - }); + const downloadLink = getIOSDownloadLink(canvas); await userEvent.click(downloadLink); expect(downloadLink).toHaveAttribute("aria-disabled", "true"); await userEvent.click(downloadLink); @@ -1672,17 +1430,14 @@ export const DownloadInIOSStandaloneReportsPermanentShareFailure: Story = { args: iosDownloadStoryArgs, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const restoreNavigator = overrideNavigatorForIOSStandalone({ + const { open, restoreNavigator } = setupIOSStandaloneDownload({ share: fn().mockRejectedValue( new DOMException("share failed", "DataError"), ), canShare: fn().mockReturnValue(true), }); - const open = spyOn(window, "open").mockReturnValue(null); try { - await userEvent.click( - canvas.getByRole("link", { name: "Download deployment-report.pdf" }), - ); + await userEvent.click(getIOSDownloadLink(canvas)); await screen.findByText("Couldn't download deployment-report.pdf"); // Retrying a permanently failed share would fail identically, // so the toast offers the dismissible tab instead of Save. @@ -1702,199 +1457,30 @@ export const DownloadInIOSStandaloneReportsPermanentShareFailure: Story = { }, }; -export const DownloadInIOSStandaloneOffersTabForUnshareableFile: Story = { - decorators: [withToaster], - args: iosDownloadStoryArgs, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const share = fn().mockResolvedValue(undefined); - const restoreNavigator = overrideNavigatorForIOSStandalone({ - share, - // The one-byte probe passes; the real fetched file fails. - canShare: fn(({ files }: { files: File[] }) => files[0].size <= 1), - }); - const open = spyOn(window, "open").mockReturnValue(null); - try { - await userEvent.click( - canvas.getByRole("link", { name: "Download deployment-report.pdf" }), - ); - const openButton = await screen.findByRole("button", { name: "Open" }); - expect(share).not.toHaveBeenCalled(); - expect(open).not.toHaveBeenCalled(); - await userEvent.click(openButton); - expect(open).toHaveBeenCalledWith( - getChatFileURL("storybook-ios-share-report"), - "_blank", - "noopener", - ); - } finally { - restoreNavigator(); - } - }, -}; - -export const DownloadInIOSStandaloneShowsErrorToastOnFailedFetch: Story = { - decorators: [withToaster], - args: { - ...defaultArgs, - parsedMessages: parseMessagesWithMergedTools([ - { - ...baseMessage, - id: 1, - role: "user", - content: [ - { - type: "file", - media_type: "application/pdf", - file_id: "storybook-ios-error-report", - name: "deployment-report.pdf", - }, - ], - }, - ]), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const share = fn().mockResolvedValue(undefined); - const restoreNavigator = overrideNavigatorForIOSStandalone({ - share, - canShare: fn().mockReturnValue(true), - }); - const open = spyOn(window, "open").mockReturnValue(null); - try { - await userEvent.click( - canvas.getByRole("link", { name: "Download deployment-report.pdf" }), - ); - await screen.findByText("Couldn't download deployment-report.pdf"); - expect(share).not.toHaveBeenCalled(); - expect(open).not.toHaveBeenCalled(); - } finally { - restoreNavigator(); - } - }, -}; - -const inlineAttachmentStoryArgs: Story["args"] = { - ...defaultArgs, - parsedMessages: parseMessagesWithMergedTools([ - { - ...baseMessage, - id: 1, - role: "assistant", - content: [ - { - type: "file", - media_type: "image/png", - data: TEST_PNG_B64, - name: "inline-screenshot.png", - }, - ], - }, - ]), -}; - -/** Inline data: attachments cannot be fetched under the production CSP. */ -export const DownloadInIOSStandaloneSharesInlineAttachment: Story = { - args: inlineAttachmentStoryArgs, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const share = fn().mockResolvedValue(undefined); - const restoreNavigator = overrideNavigatorForIOSStandalone({ - share, - canShare: fn().mockReturnValue(true), - }); - const fetchSpy = spyOn(globalThis, "fetch"); - try { - canvas - .getByRole("button", { name: "View inline-screenshot.png" }) - .focus(); - const downloadLink = await canvas.findByRole("link", { - name: "Download inline-screenshot.png", - }); - await userEvent.click(downloadLink); - await waitFor(() => expect(share).toHaveBeenCalledTimes(1)); - const shared: { files: File[] } = share.mock.calls[0][0]; - expect(shared.files[0].name).toBe("inline-screenshot.png"); - expect(shared.files[0].type).toBe("image/png"); - expect(fetchSpy).not.toHaveBeenCalled(); - } finally { - restoreNavigator(); - } - }, -}; - -/** iOS blocks data: tabs, so the toast Open action uses a blob URL. */ -export const DownloadInIOSStandaloneOpensInlineAttachmentFromToast: Story = { - decorators: [withToaster], - args: inlineAttachmentStoryArgs, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const restoreNavigator = overrideNavigatorForIOSStandalone({ - share: fn().mockRejectedValue( - new DOMException("share failed", "DataError"), - ), - canShare: fn().mockReturnValue(true), - }); - const open = spyOn(window, "open").mockReturnValue(null); - const fetchSpy = spyOn(globalThis, "fetch"); - try { - canvas - .getByRole("button", { name: "View inline-screenshot.png" }) - .focus(); - const downloadLink = await canvas.findByRole("link", { - name: "Download inline-screenshot.png", - }); - await userEvent.click(downloadLink); - await screen.findByText("Couldn't download inline-screenshot.png"); - await userEvent.click(screen.getByRole("button", { name: "Open" })); - expect(open).toHaveBeenCalledTimes(1); - const [blobUrl, target, features] = open.mock.calls[0]; - expect(blobUrl).toMatch(/^blob:/); - expect(target).toBe("_blank"); - expect(features).toBe("noopener"); - expect(fetchSpy).not.toHaveBeenCalled(); - } finally { - restoreNavigator(); - } - }, -}; +const inlineAttachmentStoryArgs = buildInlineDownloadStoryArgs( + TEST_PNG_B64, + "inline-screenshot.png", +); export const DownloadInIOSStandaloneShowsErrorForCorruptInlineAttachment: Story = { decorators: [withToaster], - args: { - ...defaultArgs, - parsedMessages: parseMessagesWithMergedTools([ - { - ...baseMessage, - id: 1, - role: "assistant", - content: [ - { - type: "file", - media_type: "image/png", - data: "not-valid-base64", - name: "corrupt-screenshot.png", - }, - ], - }, - ]), - }, + args: buildInlineDownloadStoryArgs( + "not-valid-base64", + "corrupt-screenshot.png", + ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const share = fn().mockResolvedValue(undefined); - const restoreNavigator = overrideNavigatorForIOSStandalone({ + const { open, restoreNavigator } = setupIOSStandaloneDownload({ share, canShare: fn().mockReturnValue(true), }); - const open = spyOn(window, "open").mockReturnValue(null); try { - canvas - .getByRole("button", { name: "View corrupt-screenshot.png" }) - .focus(); - const downloadLink = await canvas.findByRole("link", { - name: "Download corrupt-screenshot.png", - }); + const downloadLink = await getInlineDownloadLink( + canvas, + "corrupt-screenshot.png", + ); await userEvent.click(downloadLink); await screen.findByText("Couldn't download corrupt-screenshot.png"); await screen.findByText("The attachment data could not be decoded."); @@ -1911,19 +1497,16 @@ export const DownloadInIOSStandaloneOpensInlineAttachmentInTab: Story = { args: inlineAttachmentStoryArgs, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const restoreNavigator = overrideNavigatorForIOSStandalone({ + const { open, restoreNavigator } = setupIOSStandaloneDownload({ share: undefined, canShare: undefined, }); - const open = spyOn(window, "open").mockReturnValue(null); const fetchSpy = spyOn(globalThis, "fetch"); try { - canvas - .getByRole("button", { name: "View inline-screenshot.png" }) - .focus(); - const downloadLink = await canvas.findByRole("link", { - name: "Download inline-screenshot.png", - }); + const downloadLink = await getInlineDownloadLink( + canvas, + "inline-screenshot.png", + ); await userEvent.click(downloadLink); expect(open).toHaveBeenCalledTimes(1); const [blobUrl, target, features] = open.mock.calls[0]; @@ -1945,15 +1528,12 @@ export const DownloadInIOSStandaloneStaysQuietOnDismissedShare: Story = { const share = fn().mockRejectedValue( new DOMException("dismissed", "AbortError"), ); - const restoreNavigator = overrideNavigatorForIOSStandalone({ + const { open, restoreNavigator } = setupIOSStandaloneDownload({ share, canShare: fn().mockReturnValue(true), }); - const open = spyOn(window, "open").mockReturnValue(null); try { - await userEvent.click( - canvas.getByRole("link", { name: "Download deployment-report.pdf" }), - ); + await userEvent.click(getIOSDownloadLink(canvas)); await waitFor(() => expect(share).toHaveBeenCalledTimes(1)); // The spinner clearing marks the flow as settled. await waitFor(() => { @@ -1971,60 +1551,6 @@ export const DownloadInIOSStandaloneStaysQuietOnDismissedShare: Story = { }, }; -export const DownloadInIOSStandaloneWithoutShareOpensTab: Story = { - args: iosDownloadStoryArgs, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const restoreNavigator = overrideNavigatorForIOSStandalone({ - share: undefined, - canShare: undefined, - }); - const open = spyOn(window, "open").mockReturnValue(null); - try { - await userEvent.click( - canvas.getByRole("link", { name: "Download deployment-report.pdf" }), - ); - expect(open).toHaveBeenCalledWith( - getChatFileURL("storybook-ios-share-report"), - "_blank", - "noopener", - ); - expect(getAttachmentFetchCount("storybook-ios-share-report")).toBe(0); - } finally { - restoreNavigator(); - } - }, -}; - -export const DownloadOutsideIOSKeepsNativeAnchor: Story = { - args: iosDownloadStoryArgs, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const share = fn().mockResolvedValue(undefined); - Object.defineProperty(navigator, "share", { - value: share, - configurable: true, - }); - const open = spyOn(window, "open").mockReturnValue(null); - // Prevent the test browser's native download without stopping the component handler. - const blockDownload = (event: Event) => event.preventDefault(); - document.addEventListener("click", blockDownload, { capture: true }); - try { - const downloadLink = canvas.getByRole("link", { - name: "Download deployment-report.pdf", - }); - expect(downloadLink).toHaveAttribute("download", "deployment-report.pdf"); - await userEvent.click(downloadLink); - expect(share).not.toHaveBeenCalled(); - expect(open).not.toHaveBeenCalled(); - expect(getAttachmentFetchCount("storybook-ios-share-report")).toBe(0); - } finally { - document.removeEventListener("click", blockDownload, { capture: true }); - Reflect.deleteProperty(navigator, "share"); - } - }, -}; - /** Images and file-references coexist without interfering. */ export const UserMessageWithImagesAndFileRefs: Story = { args: { diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts index b2b0a13b1a8..121d3905af5 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts @@ -16,33 +16,51 @@ import { describe("handleAttachmentDownloadClick", () => { const overriddenNavigatorKeys = new Set(); + const originalURLDescriptors = new Map< + string, + PropertyDescriptor | undefined + >(); + const iPhoneUserAgent = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15"; + const target = { + href: "/api/experimental/chats/files/file-1", + fileName: "01-agents-list.png", + mediaType: "image/png", + }; + const overrideNavigator = (key: string, value: unknown) => { - Object.defineProperty(navigator, key, { - value, - configurable: true, - }); + Object.defineProperty(navigator, key, { value, configurable: true }); overriddenNavigatorKeys.add(key); }; - const iPhoneUserAgent = - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15"; - const enterIOSStandalonePWA = () => { overrideNavigator("userAgent", iPhoneUserAgent); overrideNavigator("standalone", true); }; - const target = { - href: "/api/experimental/chats/files/file-1", - fileName: "01-agents-list.png", - mediaType: "image/png", + const mockFileSharing = ( + share: ReturnType, + canShare: (data: { files: File[] }) => boolean = () => true, + ) => { + overrideNavigator("share", share); + overrideNavigator("canShare", vi.fn(canShare)); + }; + + const mockAttachmentFetch = (body = "png-bytes", mediaType = "image/png") => + vi + .spyOn(globalThis, "fetch") + .mockResolvedValue( + new Response(new Blob([body], { type: mediaType }), { status: 200 }), + ); + + const click = (downloadTarget = target, signal?: AbortSignal) => { + const event = { preventDefault: vi.fn() }; + return { + event, + pending: handleAttachmentDownloadClick(event, downloadTarget, signal), + }; }; - // jsdom does not implement object URLs, so stub them on the URL constructor. - const originalURLDescriptors = new Map< - string, - PropertyDescriptor | undefined - >(); const stubObjectURLs = () => { const createObjectURL = vi.fn().mockReturnValue("blob:inline"); const revokeObjectURL = vi.fn(); @@ -50,12 +68,10 @@ describe("handleAttachmentDownloadClick", () => { createObjectURL, revokeObjectURL, })) { - if (!originalURLDescriptors.has(key)) { - originalURLDescriptors.set( - key, - Object.getOwnPropertyDescriptor(URL, key), - ); - } + originalURLDescriptors.set( + key, + Object.getOwnPropertyDescriptor(URL, key), + ); Object.defineProperty(URL, key, { value, configurable: true }); } return { createObjectURL, revokeObjectURL }; @@ -79,51 +95,44 @@ describe("handleAttachmentDownloadClick", () => { vi.mocked(toast.error).mockClear(); }); - it("keeps the native anchor download outside iOS", () => { + it.each([ + ["outside iOS", () => {}], + [ + "in the iOS browser", + () => overrideNavigator("userAgent", iPhoneUserAgent), + ], + ])("keeps the native anchor download %s", (_label, setup) => { + setup(); const open = vi.spyOn(window, "open").mockReturnValue(null); - const event = { preventDefault: vi.fn() }; + const { event, pending } = click(); - expect(handleAttachmentDownloadClick(event, target)).toBeUndefined(); + expect(pending).toBeUndefined(); expect(event.preventDefault).not.toHaveBeenCalled(); expect(open).not.toHaveBeenCalled(); }); - it("keeps the native anchor download in the iOS browser", () => { - overrideNavigator("userAgent", iPhoneUserAgent); - const event = { preventDefault: vi.fn() }; - - expect(handleAttachmentDownloadClick(event, target)).toBeUndefined(); - expect(event.preventDefault).not.toHaveBeenCalled(); - }); - - it("shares the attachment via the share sheet in an iOS standalone PWA", async () => { + it("shares the fetched attachment in an iOS standalone PWA", async () => { enterIOSStandalonePWA(); const share = vi.fn().mockResolvedValue(undefined); - overrideNavigator("share", share); - overrideNavigator("canShare", vi.fn().mockReturnValue(true)); - const open = vi.spyOn(window, "open").mockReturnValue(null); - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(new Blob(["png-bytes"], { type: "image/png" }), { - status: 200, - }), - ); - const event = { preventDefault: vi.fn() }; + mockFileSharing(share); + mockAttachmentFetch(); - await handleAttachmentDownloadClick(event, target); + const { event, pending } = click(); + await pending; expect(event.preventDefault).toHaveBeenCalled(); expect(globalThis.fetch).toHaveBeenCalledWith(target.href, { signal: undefined, }); - expect(open).not.toHaveBeenCalled(); - expect(share).toHaveBeenCalledTimes(1); const shared: { files: File[] } = share.mock.calls[0][0]; expect(shared.files).toHaveLength(1); - expect(shared.files[0].name).toBe("01-agents-list.png"); - expect(shared.files[0].type).toBe("image/png"); + expect(shared.files[0]).toMatchObject({ + name: "01-agents-list.png", + type: "image/png", + }); }); - it("intercepts on iPadOS reporting a macOS user agent", () => { + it("recognizes iPadOS with a macOS user agent", () => { overrideNavigator( "userAgent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15", @@ -131,20 +140,22 @@ describe("handleAttachmentDownloadClick", () => { overrideNavigator("maxTouchPoints", 5); overrideNavigator("standalone", true); const open = vi.spyOn(window, "open").mockReturnValue(null); - const event = { preventDefault: vi.fn() }; - expect(handleAttachmentDownloadClick(event, target)).toBeUndefined(); + const { event, pending } = click(); + + expect(pending).toBeUndefined(); expect(event.preventDefault).toHaveBeenCalled(); - expect(open).toHaveBeenCalled(); + expect(open).toHaveBeenCalledWith(target.href, "_blank", "noopener"); }); - it("falls back to a dismissible tab when file sharing is unavailable", () => { + it("opens a tab synchronously when file sharing is unavailable", () => { enterIOSStandalonePWA(); const open = vi.spyOn(window, "open").mockReturnValue(null); const fetchSpy = vi.spyOn(globalThis, "fetch"); - const event = { preventDefault: vi.fn() }; - expect(handleAttachmentDownloadClick(event, target)).toBeUndefined(); + const { event, pending } = click(); + + expect(pending).toBeUndefined(); expect(event.preventDefault).toHaveBeenCalled(); expect(open).toHaveBeenCalledWith(target.href, "_blank", "noopener"); expect(fetchSpy).not.toHaveBeenCalled(); @@ -152,33 +163,26 @@ describe("handleAttachmentDownloadClick", () => { it("stays quiet when the user dismisses the share sheet", async () => { enterIOSStandalonePWA(); - overrideNavigator( - "share", + mockFileSharing( vi.fn().mockRejectedValue(new DOMException("canceled", "AbortError")), ); - overrideNavigator("canShare", vi.fn().mockReturnValue(true)); - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(new Blob(["png-bytes"], { type: "image/png" })), - ); - const event = { preventDefault: vi.fn() }; + mockAttachmentFetch(); - await handleAttachmentDownloadClick(event, target); + await click().pending; expect(toast.error).not.toHaveBeenCalled(); }); - it("shows an error toast without a late popup when the download fetch fails", async () => { + it("shows the fetch failure without opening a late popup", async () => { enterIOSStandalonePWA(); const share = vi.fn().mockResolvedValue(undefined); - overrideNavigator("share", share); - overrideNavigator("canShare", vi.fn().mockReturnValue(true)); + mockFileSharing(share); const open = vi.spyOn(window, "open").mockReturnValue(null); vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response("nope", { status: 503 }), ); - const event = { preventDefault: vi.fn() }; - await handleAttachmentDownloadClick(event, target); + await click().pending; expect(share).not.toHaveBeenCalled(); expect(open).not.toHaveBeenCalled(); @@ -188,7 +192,7 @@ describe("handleAttachmentDownloadClick", () => { ); }); - it("offers a fresh-gesture retry when user activation expired during the fetch", async () => { + it("offers a Save retry when user activation expires", async () => { enterIOSStandalonePWA(); const share = vi .fn() @@ -196,18 +200,11 @@ describe("handleAttachmentDownloadClick", () => { new DOMException("activation expired", "NotAllowedError"), ) .mockResolvedValue(undefined); - overrideNavigator("share", share); - overrideNavigator("canShare", vi.fn().mockReturnValue(true)); - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(new Blob(["png-bytes"], { type: "image/png" })), - ); - const event = { preventDefault: vi.fn() }; + mockFileSharing(share); + mockAttachmentFetch(); - await handleAttachmentDownloadClick(event, target); + await click().pending; - // DownloadInIOSStandaloneRecoversExpiredActivation covers the Save click - // and retry through the real toast UI. - expect(share).toHaveBeenCalledTimes(1); expect(toast.error).toHaveBeenCalledWith( "Couldn't download 01-agents-list.png", expect.objectContaining({ @@ -217,110 +214,70 @@ describe("handleAttachmentDownloadClick", () => { ); }); - it("offers a tab fallback instead of a retry for permanent share failures", async () => { + it("offers Open after a permanent share failure", async () => { enterIOSStandalonePWA(); - overrideNavigator( - "share", + mockFileSharing( vi.fn().mockRejectedValue(new DOMException("share failed", "DataError")), ); - overrideNavigator("canShare", vi.fn().mockReturnValue(true)); - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(new Blob(["png-bytes"], { type: "image/png" })), - ); - const event = { preventDefault: vi.fn() }; + mockAttachmentFetch(); - await handleAttachmentDownloadClick(event, target); + await click().pending; - expect(toast.error).toHaveBeenCalledTimes(1); - expect(vi.mocked(toast.error).mock.calls[0][1]).toEqual( + expect(toast.error).toHaveBeenCalledWith( + "Couldn't download 01-agents-list.png", expect.objectContaining({ action: expect.objectContaining({ label: "Open" }), }), ); }); - it("shares inline data: attachments without fetching", async () => { + it("shares inline data without fetching", async () => { enterIOSStandalonePWA(); const share = vi.fn().mockResolvedValue(undefined); - overrideNavigator("share", share); - overrideNavigator("canShare", vi.fn().mockReturnValue(true)); + mockFileSharing(share); const fetchSpy = vi.spyOn(globalThis, "fetch"); - const event = { preventDefault: vi.fn() }; - const payload = btoa("png-bytes"); - await handleAttachmentDownloadClick(event, { - href: `data:image/png;base64,${payload}`, + await click({ + href: `data:image/png;base64,${btoa("png-bytes")}`, fileName: "inline.png", mediaType: "image/png", - }); + }).pending; expect(fetchSpy).not.toHaveBeenCalled(); - expect(share).toHaveBeenCalledTimes(1); const shared: { files: File[] } = share.mock.calls[0][0]; - expect(shared.files[0].name).toBe("inline.png"); - expect(shared.files[0].type).toBe("image/png"); - expect(shared.files[0].size).toBe("png-bytes".length); - }); - - it("offers an Open fallback for data: hrefs on permanent share failure", async () => { - enterIOSStandalonePWA(); - overrideNavigator( - "share", - vi.fn().mockRejectedValue(new DOMException("share failed", "DataError")), - ); - overrideNavigator("canShare", vi.fn().mockReturnValue(true)); - const event = { preventDefault: vi.fn() }; - - await handleAttachmentDownloadClick(event, { - href: `data:image/png;base64,${btoa("png-bytes")}`, - fileName: "inline.png", - mediaType: "image/png", + expect(shared.files[0]).toMatchObject({ + name: "inline.png", + type: "image/png", + size: "png-bytes".length, }); - - // Clicking Open in the rendered toast is exercised in Storybook - // (DownloadInIOSStandaloneOpensInlineAttachmentFromToast). - expect(toast.error).toHaveBeenCalledWith( - "Couldn't download inline.png", - expect.objectContaining({ - action: expect.objectContaining({ label: "Open" }), - }), - ); }); - it("opens inline attachments through a blob tab when file sharing is unavailable", () => { + it("opens inline data through a temporary blob URL", () => { enterIOSStandalonePWA(); const { createObjectURL, revokeObjectURL } = stubObjectURLs(); const open = vi.spyOn(window, "open").mockReturnValue(null); - const fetchSpy = vi.spyOn(globalThis, "fetch"); vi.useFakeTimers(); - const event = { preventDefault: vi.fn() }; - expect( - handleAttachmentDownloadClick(event, { - href: `data:image/png;base64,${btoa("png-bytes")}`, - fileName: "inline.png", - mediaType: "image/png", - }), - ).toBeUndefined(); + const { pending } = click({ + href: `data:image/png;base64,${btoa("png-bytes")}`, + fileName: "inline.png", + mediaType: "image/png", + }); - expect(event.preventDefault).toHaveBeenCalled(); - expect(fetchSpy).not.toHaveBeenCalled(); - expect(createObjectURL).toHaveBeenCalledTimes(1); + expect(pending).toBeUndefined(); const decoded: File = createObjectURL.mock.calls[0][0]; - expect(decoded.name).toBe("inline.png"); - expect(decoded.type).toBe("image/png"); + expect(decoded).toMatchObject({ name: "inline.png", type: "image/png" }); expect(open).toHaveBeenCalledWith("blob:inline", "_blank", "noopener"); vi.runAllTimers(); expect(revokeObjectURL).toHaveBeenCalledWith("blob:inline"); }); - it("shows a decode error instead of a tab when undecodable inline data cannot be shared", () => { + it("shows a decode error for corrupt inline data", () => { enterIOSStandalonePWA(); stubObjectURLs(); const open = vi.spyOn(window, "open").mockReturnValue(null); - const event = { preventDefault: vi.fn() }; - handleAttachmentDownloadClick(event, { + click({ href: "data:image/png;base64,%%%", fileName: "inline.png", mediaType: "image/png", @@ -332,11 +289,10 @@ describe("handleAttachmentDownloadClick", () => { }); }); - it("stays quiet when the download is aborted mid-fetch", async () => { + it("passes the abort signal through the fetch and suppresses abort UI", async () => { enterIOSStandalonePWA(); const share = vi.fn().mockResolvedValue(undefined); - overrideNavigator("share", share); - overrideNavigator("canShare", vi.fn().mockReturnValue(true)); + mockFileSharing(share); vi.spyOn(globalThis, "fetch").mockImplementation( (_input, init) => new Promise((_resolve, reject) => { @@ -346,13 +302,8 @@ describe("handleAttachmentDownloadClick", () => { }), ); const controller = new AbortController(); - const event = { preventDefault: vi.fn() }; - const pending = handleAttachmentDownloadClick( - event, - target, - controller.signal, - ); + const { pending } = click(target, controller.signal); controller.abort(); await pending; @@ -360,65 +311,46 @@ describe("handleAttachmentDownloadClick", () => { expect(toast.error).not.toHaveBeenCalled(); }); - it("suppresses the share sheet when aborted after the fetch resolves", async () => { + it("suppresses sharing when unmounted after the fetch resolves", async () => { enterIOSStandalonePWA(); const share = vi.fn().mockResolvedValue(undefined); - overrideNavigator("share", share); - overrideNavigator("canShare", vi.fn().mockReturnValue(true)); + mockFileSharing(share); const controller = new AbortController(); vi.spyOn(globalThis, "fetch").mockImplementation(async () => { controller.abort(); - return new Response(new Blob(["png-bytes"], { type: "image/png" }), { - status: 200, - }); + return new Response(new Blob(["png-bytes"], { type: "image/png" })); }); - const event = { preventDefault: vi.fn() }; - await handleAttachmentDownloadClick(event, target, controller.signal); + await click(target, controller.signal).pending; expect(share).not.toHaveBeenCalled(); expect(toast.error).not.toHaveBeenCalled(); }); - it("suppresses share rejection UI when aborted while the sheet is pending", async () => { + it("suppresses share rejection UI after unmount", async () => { enterIOSStandalonePWA(); const controller = new AbortController(); const share = vi.fn().mockImplementation(() => { controller.abort(); return Promise.reject(new DOMException("expired", "NotAllowedError")); }); - overrideNavigator("share", share); - overrideNavigator("canShare", vi.fn().mockReturnValue(true)); - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(new Blob(["png-bytes"], { type: "image/png" }), { - status: 200, - }), - ); - const event = { preventDefault: vi.fn() }; + mockFileSharing(share); + mockAttachmentFetch(); - await handleAttachmentDownloadClick(event, target, controller.signal); + await click(target, controller.signal).pending; expect(share).toHaveBeenCalledTimes(1); expect(toast.error).not.toHaveBeenCalled(); }); - it("offers a tab fallback when the fetched file turns out unshareable", async () => { + it("offers Open when the fetched file cannot be shared", async () => { enterIOSStandalonePWA(); const share = vi.fn().mockResolvedValue(undefined); - overrideNavigator("share", share); - overrideNavigator( - "canShare", - vi - .fn<(data: { files: File[] }) => boolean>() - .mockImplementation(({ files }) => files[0].size <= 1), - ); - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(new Blob(["png-bytes"], { type: "image/png" })), - ); + mockFileSharing(share, ({ files }) => files[0].size <= 1); + mockAttachmentFetch(); const open = vi.spyOn(window, "open").mockReturnValue(null); - const event = { preventDefault: vi.fn() }; - await handleAttachmentDownloadClick(event, target); + await click().pending; expect(share).not.toHaveBeenCalled(); expect(open).not.toHaveBeenCalled(); From 5a242e8ba9c8888e21dee7bde17d3d8507caef64 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:43:49 +0000 Subject: [PATCH 32/43] refactor(site/src/pages/AgentsPage): simplify attachment download handling --- .../ChatConversation/AttachmentBlocks.tsx | 131 ++++++--------- .../pages/AgentsPage/utils/chatAttachments.ts | 149 ++++++++---------- 2 files changed, 110 insertions(+), 170 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index c85d62c70c9..e7d0bcddd58 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -70,17 +70,21 @@ const ATTACHMENT_FALLBACK_EXTENSIONS: Record = { "text/xml": "xml", }; -const sanitizeAttachmentExtension = (value: string): string => { - const sanitized = value +const sanitizeAttachmentExtension = (value: string): string => + value .replace(/[^a-z0-9]/gi, "") .slice(0, 4) - .toLowerCase(); - return sanitized || "file"; -}; + .toLowerCase() || "file"; -// Structured MIME suffixes (RFC 6839) carry the real format before the -// plus sign, so image/svg+xml maps to svg rather than a mangled subtype. -const structuredSubtypeExtension = (subtype: string): string | null => { +const getMediaTypeExtension = (mediaType: string): string | null => { + if (mediaType === "application/octet-stream") { + return null; + } + const mapped = ATTACHMENT_FALLBACK_EXTENSIONS[mediaType]; + if (mapped) { + return mapped; + } + const [type, subtype = ""] = mediaType.split("/"); if (subtype.endsWith("+json")) { return "json"; } @@ -88,7 +92,10 @@ const structuredSubtypeExtension = (subtype: string): string | null => { const base = subtype.slice(0, -"+xml".length); return /^[a-z0-9]{1,8}$/i.test(base) ? base.toLowerCase() : "xml"; } - return null; + // Only image subtypes reliably double as filename extensions. + return type === "image" && /^[a-z0-9]{1,8}$/i.test(subtype) + ? subtype.toLowerCase() + : null; }; const getAttachmentExtension = ( @@ -98,28 +105,20 @@ const getAttachmentExtension = ( if (mapped) { return mapped; } - const trimmedName = block.name?.trim(); - if (trimmedName) { - const lastDot = trimmedName.lastIndexOf("."); - // Keep dotfiles like `.env` out of the extension path, while still - // allowing ordinary `name.ext` filenames to contribute a fallback. - if (lastDot > 0 && lastDot < trimmedName.length - 1) { - return sanitizeAttachmentExtension(trimmedName.slice(lastDot + 1)); - } + const name = block.name?.trim(); + const lastDot = name?.lastIndexOf(".") ?? -1; + if (name && lastDot > 0 && lastDot < name.length - 1) { + return sanitizeAttachmentExtension(name.slice(lastDot + 1)); } - const subtype = block.media_type.split("/")[1] ?? ""; return ( - structuredSubtypeExtension(subtype) ?? sanitizeAttachmentExtension(subtype) + getMediaTypeExtension(block.media_type) ?? + sanitizeAttachmentExtension(block.media_type.split("/")[1] ?? "") ); }; const isTextPreviewAttachmentMediaType = (mediaType: string): boolean => TEXT_ATTACHMENT_MEDIA_TYPES.has(mediaType); -// Text-like types whose payloads the server classifies by content while -// preserving the name. Structured +json/+xml types (application/ld+json) -// belong here too: their registered extensions (.jsonld) differ from the -// base format's, so an existing suffix always wins. const isSuffixPreservingMediaType = (mediaType: string): boolean => mediaType.startsWith("text/") || mediaType === "application/json" || @@ -152,38 +151,14 @@ const getAttachmentDisplayName = ( return "Attached file"; }; -const endsWithFileExtension = /\.([a-z0-9]{1,8})$/i; - -// Alternate name suffixes that identify the same type as the canonical -// media-type extension, so "photo.jpeg" is not renamed to "photo.jpeg.jpg". -const extensionAliases: Record = { - jpg: ["jpeg", "jfif", "jpe", "pjpeg", "pjp"], - tiff: ["tif"], -}; - -// Returns the extension implied by the media type alone, ignoring the -// attachment name. Null means the type carries no usable extension. -const getMediaTypeExtension = (mediaType: string): string | null => { - if (mediaType === "application/octet-stream") { - return null; - } - const mapped = ATTACHMENT_FALLBACK_EXTENSIONS[mediaType]; - if (mapped) { - return mapped; - } - const [type, subtype = ""] = mediaType.split("/"); - const structured = structuredSubtypeExtension(subtype); - if (structured) { - return structured; - } - // Only image subtypes reliably double as filename extensions (png, - // gif, webp). Other subtypes are MIME names, not extensions - // (application/msword, audio/mpeg), so without an explicit mapping - // the attachment keeps its own name. - return type === "image" && /^[a-z0-9]{1,8}$/i.test(subtype) - ? subtype.toLowerCase() - : null; -}; +const extensionAliases = new Set([ + "jpg:jpeg", + "jpg:jfif", + "jpg:jpe", + "jpg:pjpeg", + "jpg:pjp", + "tiff:tif", +]); const getAttachmentDownloadName = ( block: Pick, @@ -194,36 +169,20 @@ const getAttachmentDownloadName = ( return extension === "file" ? "attachment" : `attachment.${extension}`; } const mediaExtension = getMediaTypeExtension(block.media_type); - if (mediaExtension === null) { - return name; - } - // Leading-dot names are dotfiles (.eslintrc, .gitignore) whose whole - // name carries the meaning; appending an extension would rename them. - if (name.startsWith(".")) { + if (!mediaExtension || name.startsWith(".")) { return name; } - // The server classifies text-like uploads (text/*, JSON, XML) by - // content while preserving their names, so an existing suffix - // (main.go, map.geojson, schema.xsd) identifies the file better - // than the canonical extension would. Keep any dotted suffix and - // only append the extension to extensionless names. - if (isSuffixPreservingMediaType(block.media_type)) { - return /\.[^.\s]+$/.test(name) ? name : `${name}.${mediaExtension}`; - } - // iOS resolves the shared or saved file's type from the filename - // extension, so a name like "About Page Screenshot" or "report.final" - // would land as a generic file even when the media type is known. - const suffix = name.match(endsWithFileExtension)?.[1]?.toLowerCase(); - if (suffix === undefined) { - return `${name}.${mediaExtension}`; - } if ( - suffix === mediaExtension || - (extensionAliases[mediaExtension] ?? []).includes(suffix) + isSuffixPreservingMediaType(block.media_type) && + /\.[^.\s]+$/.test(name) ) { return name; } - return `${name}.${mediaExtension}`; + const suffix = name.match(/\.([a-z0-9]{1,8})$/i)?.[1]?.toLowerCase(); + return suffix === mediaExtension || + extensionAliases.has(`${mediaExtension}:${suffix}`) + ? name + : `${name}.${mediaExtension}`; }; const getAttachmentBadgeLabel = ( @@ -253,15 +212,15 @@ const useAttachmentDownloadClick = (target: AttachmentDownloadTarget) => { target, controller.signal, ); - if (pending) { - setIsPending(true); - void pending.finally(() => { - downloadRequest.clear(controller); - setIsPending(false); - }); - } else { + if (!pending) { downloadRequest.clear(controller); + return; } + setIsPending(true); + void pending.finally(() => { + downloadRequest.clear(controller); + setIsPending(false); + }); }; return { isPending, onClick }; }; diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index 973d21d1bb5..3c26247efc6 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -78,10 +78,10 @@ const isStandaloneDisplayMode = (): boolean => { ); }; -const canShareFiles = (files: File[]): boolean => +const canShareFile = (file: File): boolean => typeof navigator.share === "function" && typeof navigator.canShare === "function" && - navigator.canShare({ files }); + navigator.canShare({ files: [file] }); export type AttachmentDownloadTarget = { href: string; @@ -97,98 +97,90 @@ const errorHasName = (error: unknown, name: string): boolean => "name" in error && error.name === name; -// iOS blocks top-level data: navigation, so inline attachments open through -// a short-lived blob URL instead of their data: href. -const openBlobFileInTab = (file: File): void => { - const blobUrl = URL.createObjectURL(file); - open(blobUrl, "_blank", "noopener"); - // Revoke after the new tab has had time to load the blob. - setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000); +const showDownloadFailure = ( + fileName: string, + options: { + description?: string; + action?: { label: string; onClick: () => void }; + }, +): void => { + toast.error(`Couldn't download ${fileName}`, options); }; +// iOS blocks top-level data: navigation, so inline attachments open through +// a short-lived blob URL instead of their data: href. const openAttachmentInTab = (href: string, file: File): void => { - if (href.startsWith("data:")) { - openBlobFileInTab(file); - } else { + if (!href.startsWith("data:")) { open(href, "_blank", "noopener"); + return; } + const blobUrl = URL.createObjectURL(file); + open(blobUrl, "_blank", "noopener"); + setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000); }; -const openFallbackAction = (href: string, file: File) => ({ +const openFallbackAction = (target: AttachmentDownloadTarget, file: File) => ({ label: "Open", - onClick: () => openAttachmentInTab(href, file), + onClick: () => openAttachmentInTab(target.href, file), }); -const showDecodeFailureToast = (fileName: string): void => { - toast.error(`Couldn't download ${fileName}`, { - description: "The attachment data could not be decoded.", - }); +// Production CSP limits connect-src to 'self', so inline data: hrefs +// cannot be fetched and are decoded locally instead. +const fileFromDataURL = ({ + href, + fileName, + mediaType, +}: AttachmentDownloadTarget): File | null => { + const decoded = decodeDataURL(href); + return decoded + ? new File([decoded.bytes], fileName, { + type: decoded.mediaType || mediaType || "application/octet-stream", + }) + : null; }; const shareFileViaSheet = ( file: File, - fileName: string, - href: string, + target: AttachmentDownloadTarget, signal?: AbortSignal, ): Promise => navigator.share({ files: [file] }).catch((error: unknown) => { - // A dismissed share sheet rejects with AbortError. An aborted - // signal means the attachment unmounted, so rejection UI would - // surface on an unrelated view. if (errorHasName(error, "AbortError") || signal?.aborted) { return; } - // iOS transient activation can expire while the file is fetched. - // The toast action provides a fresh gesture, so only NotAllowedError gets a retry. if (errorHasName(error, "NotAllowedError")) { - toast.error(`Couldn't download ${fileName}`, { + showDownloadFailure(target.fileName, { description: "The file is ready to save.", action: { label: "Save", - onClick: () => void shareFileViaSheet(file, fileName, href), + onClick: () => void shareFileViaSheet(file, target), }, }); return; } - // The share itself failed permanently, but the file is in hand, - // so the dismissible tab remains a way to reach it. - toast.error(`Couldn't download ${fileName}`, { + showDownloadFailure(target.fileName, { description: error instanceof Error ? error.message : undefined, - action: openFallbackAction(href, file), + action: openFallbackAction(target, file), }); }); -// Production CSP limits connect-src to 'self', so inline data: hrefs -// cannot be fetched and are decoded locally instead. -const fileFromDataURL = ( - href: string, - fileName: string, - fallbackMediaType: string, -): File | null => { - const decoded = decodeDataURL(href); - if (!decoded) { - return null; - } - return new File([decoded.bytes], fileName, { - type: decoded.mediaType || fallbackMediaType || "application/octet-stream", - }); -}; - const shareAttachmentFile = async ( - { href, fileName, mediaType }: AttachmentDownloadTarget, + target: AttachmentDownloadTarget, signal?: AbortSignal, ): Promise => { let file: File; - if (href.startsWith("data:")) { - const decoded = fileFromDataURL(href, fileName, mediaType); + if (target.href.startsWith("data:")) { + const decoded = fileFromDataURL(target); if (!decoded) { - showDecodeFailureToast(fileName); + showDownloadFailure(target.fileName, { + description: "The attachment data could not be decoded.", + }); return; } file = decoded; } else { try { - const response = await fetch(href, { signal }); + const response = await fetch(target.href, { signal }); if (!response.ok) { throw new Error( response.statusText @@ -197,16 +189,14 @@ const shareAttachmentFile = async ( ); } const blob = await response.blob(); - file = new File([blob], fileName, { - type: blob.type || mediaType || "application/octet-stream", + file = new File([blob], target.fileName, { + type: blob.type || target.mediaType || "application/octet-stream", }); } catch (error) { - // An aborted fetch means the attachment unmounted; the user is - // elsewhere, so no follow-up UI. if (errorHasName(error, "AbortError")) { return; } - toast.error(`Couldn't download ${fileName}`, { + showDownloadFailure(target.fileName, { description: error instanceof Error ? error.message : undefined, }); return; @@ -215,18 +205,14 @@ const shareAttachmentFile = async ( if (signal?.aborted) { return; } - if (!canShareFiles([file])) { - // The pre-fetch probe can pass while the real file fails canShare - // (for example over the size limit). The native anchor action was - // already prevented, so offer the dismissible-tab fallback through - // a fresh gesture. - toast.error(`Couldn't download ${fileName}`, { + if (!canShareFile(file)) { + showDownloadFailure(target.fileName, { description: "This file cannot be shared on this device.", - action: openFallbackAction(href, file), + action: openFallbackAction(target, file), }); return; } - await shareFileViaSheet(file, fileName, href, signal); + await shareFileViaSheet(file, target, signal); }; /** @@ -244,26 +230,21 @@ export const handleAttachmentDownloadClick = ( } event.preventDefault(); const probe = new File(["0"], target.fileName, { type: target.mediaType }); - if (!canShareFiles([probe])) { - // Open synchronously; after an await the user activation that - // popup blockers require may already be consumed. - if (!target.href.startsWith("data:")) { - open(target.href, "_blank", "noopener"); - return undefined; - } - const decoded = fileFromDataURL( - target.href, - target.fileName, - target.mediaType, - ); - if (decoded) { - openBlobFileInTab(decoded); - } else { - showDecodeFailureToast(target.fileName); - } - return undefined; + if (canShareFile(probe)) { + return shareAttachmentFile(target, signal); + } + // Opening before an await preserves the user activation required by popup blockers. + const file = target.href.startsWith("data:") + ? fileFromDataURL(target) + : probe; + if (file) { + openAttachmentInTab(target.href, file); + } else { + showDownloadFailure(target.fileName, { + description: "The attachment data could not be decoded.", + }); } - return shareAttachmentFile(target, signal); + return undefined; }; // Filename extensions to list in the file-picker's `accept` attribute From 1bc0410c0d9584d4934a23c115e0bea2a747e287 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:44:48 +0000 Subject: [PATCH 33/43] refactor(site/src/pages/AgentsPage): drop uncommon JPEG suffix aliases --- .../components/ChatConversation/AttachmentBlocks.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index e7d0bcddd58..500043c1602 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -151,14 +151,7 @@ const getAttachmentDisplayName = ( return "Attached file"; }; -const extensionAliases = new Set([ - "jpg:jpeg", - "jpg:jfif", - "jpg:jpe", - "jpg:pjpeg", - "jpg:pjp", - "tiff:tif", -]); +const extensionAliases = new Set(["jpg:jpeg", "tiff:tif"]); const getAttachmentDownloadName = ( block: Pick, From 4726abed514e46682f60849abbfdbb34824b8a38 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:46:24 +0000 Subject: [PATCH 34/43] refactor(site/src/pages/AgentsPage): drop JSON and XML suffix preservation --- .../components/ChatConversation/AttachmentBlocks.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index 500043c1602..5e6eaa8391e 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -120,10 +120,7 @@ const isTextPreviewAttachmentMediaType = (mediaType: string): boolean => TEXT_ATTACHMENT_MEDIA_TYPES.has(mediaType); const isSuffixPreservingMediaType = (mediaType: string): boolean => - mediaType.startsWith("text/") || - mediaType === "application/json" || - mediaType === "application/xml" || - /\+(json|xml)$/i.test(mediaType); + mediaType.startsWith("text/"); const getAttachmentHref = (block: FileAttachmentBlock): string | null => { if (block.file_id) { From ec388878d3ee33be8578b24fc4e53cfd4b864d79 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:47:26 +0000 Subject: [PATCH 35/43] refactor(site/src/pages/AgentsPage): drop unsupported text MIME mappings --- .../AgentsPage/components/ChatConversation/AttachmentBlocks.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index 5e6eaa8391e..08e753bfc9f 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -64,10 +64,8 @@ const ATTACHMENT_FALLBACK_EXTENSIONS: Record = { "application/xml": "xml", "image/jpeg": "jpg", "text/csv": "csv", - "text/html": "html", "text/markdown": "md", "text/plain": "txt", - "text/xml": "xml", }; const sanitizeAttachmentExtension = (value: string): string => From 9c46b3b847538aacb002d4b1cc5ea7b556b605a2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:58:08 +0000 Subject: [PATCH 36/43] fix(site/src/pages/AgentsPage): keep download abort state current --- .../ChatConversation/AttachmentBlocks.tsx | 5 +++-- .../AgentsPage/utils/chatAttachments.test.ts | 21 ++++++++++++++++--- .../pages/AgentsPage/utils/chatAttachments.ts | 9 +++++--- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index 08e753bfc9f..6f4af7d34c1 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -206,8 +206,9 @@ const useAttachmentDownloadClick = (target: AttachmentDownloadTarget) => { } setIsPending(true); void pending.finally(() => { - downloadRequest.clear(controller); - setIsPending(false); + if (downloadRequest.clear(controller)) { + setIsPending(false); + } }); }; return { isPending, onClick }; diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts index 121d3905af5..09356c7d642 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts @@ -311,14 +311,29 @@ describe("handleAttachmentDownloadClick", () => { expect(toast.error).not.toHaveBeenCalled(); }); - it("suppresses sharing when unmounted after the fetch resolves", async () => { + it.each([ + ["the fetch resolves", false, 200], + ["an HTTP error resolves", false, 503], + ["the response body resolves", true, 200], + ])("suppresses UI after unmount when %s", async (_label, abortInBlob, status) => { enterIOSStandalonePWA(); const share = vi.fn().mockResolvedValue(undefined); mockFileSharing(share); const controller = new AbortController(); vi.spyOn(globalThis, "fetch").mockImplementation(async () => { - controller.abort(); - return new Response(new Blob(["png-bytes"], { type: "image/png" })); + const response = new Response( + new Blob(["png-bytes"], { type: "image/png" }), + { status }, + ); + if (abortInBlob) { + vi.spyOn(response, "blob").mockImplementation(async () => { + controller.abort(); + return new Blob(["png-bytes"], { type: "image/png" }); + }); + } else { + controller.abort(); + } + return response; }); await click(target, controller.signal).pending; diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index 3c26247efc6..6df7b1ec8e0 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -124,8 +124,8 @@ const openFallbackAction = (target: AttachmentDownloadTarget, file: File) => ({ onClick: () => openAttachmentInTab(target.href, file), }); -// Production CSP limits connect-src to 'self', so inline data: hrefs -// cannot be fetched and are decoded locally instead. +// Production CSP excludes data: from connect-src, so inline hrefs are +// decoded locally instead. const fileFromDataURL = ({ href, fileName, @@ -181,6 +181,9 @@ const shareAttachmentFile = async ( } else { try { const response = await fetch(target.href, { signal }); + if (signal?.aborted) { + return; + } if (!response.ok) { throw new Error( response.statusText @@ -233,7 +236,7 @@ export const handleAttachmentDownloadClick = ( if (canShareFile(probe)) { return shareAttachmentFile(target, signal); } - // Opening before an await preserves the user activation required by popup blockers. + // Open the fallback tab during the click gesture to satisfy popup blockers. const file = target.href.startsWith("data:") ? fileFromDataURL(target) : probe; From 5e705a85ea61b9b54e7fb82bd0328af5130d3844 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:07:51 +0000 Subject: [PATCH 37/43] chore(site/src/pages/AgentsPage): clean up attachment comments --- .../components/ChatConversation/AttachmentBlocks.tsx | 8 ++------ .../ChatConversation/ConversationTimeline.stories.tsx | 4 ---- site/src/pages/AgentsPage/utils/chatAttachments.ts | 2 -- .../pages/AgentsPage/utils/chatDraftAttachmentStorage.ts | 3 +-- 4 files changed, 3 insertions(+), 14 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index 6f4af7d34c1..9a688e00e40 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -90,7 +90,7 @@ const getMediaTypeExtension = (mediaType: string): string | null => { const base = subtype.slice(0, -"+xml".length); return /^[a-z0-9]{1,8}$/i.test(base) ? base.toLowerCase() : "xml"; } - // Only image subtypes reliably double as filename extensions. + // Unmapped non-image subtypes are not assumed to be filename extensions. return type === "image" && /^[a-z0-9]{1,8}$/i.test(subtype) ? subtype.toLowerCase() : null; @@ -180,13 +180,9 @@ const getAttachmentBadgeLabel = ( return extension === "file" ? "" : extension.toUpperCase(); }; -// Suppresses repeated clicks while an intercepted iOS download is still -// fetching, so a second tap cannot open a second share sheet or surface -// a misleading error toast while the first sheet is open. const useAttachmentDownloadClick = (target: AttachmentDownloadTarget) => { const [isPending, setIsPending] = useState(false); - // Aborts on unmount so a slow fetch cannot surface the share sheet - // or an error toast after the user has navigated away. + // Prevents share sheets and error toasts from appearing after unmount. const downloadRequest = useLatestAbortController(); const onClick = (event: ReactMouseEvent) => { event.stopPropagation(); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 8ccb4eb57f2..39d23ccec93 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1439,8 +1439,6 @@ export const DownloadInIOSStandaloneReportsPermanentShareFailure: Story = { try { await userEvent.click(getIOSDownloadLink(canvas)); await screen.findByText("Couldn't download deployment-report.pdf"); - // Retrying a permanently failed share would fail identically, - // so the toast offers the dismissible tab instead of Save. expect( screen.queryByRole("button", { name: "Save" }), ).not.toBeInTheDocument(); @@ -1492,7 +1490,6 @@ export const DownloadInIOSStandaloneShowsErrorForCorruptInlineAttachment: Story }, }; -/** iOS blocks data: tabs, so inline attachments open through a blob URL. */ export const DownloadInIOSStandaloneOpensInlineAttachmentInTab: Story = { args: inlineAttachmentStoryArgs, play: async ({ canvasElement }) => { @@ -1535,7 +1532,6 @@ export const DownloadInIOSStandaloneStaysQuietOnDismissedShare: Story = { try { await userEvent.click(getIOSDownloadLink(canvas)); await waitFor(() => expect(share).toHaveBeenCalledTimes(1)); - // The spinner clearing marks the flow as settled. await waitFor(() => { expect( canvas.getByRole("link", { name: "Download deployment-report.pdf" }), diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index 6df7b1ec8e0..e577dfd076f 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -89,8 +89,6 @@ export type AttachmentDownloadTarget = { mediaType: string; }; -// Web Share failures are DOMExceptions, which are not Error subclasses -// in jsdom, so match names structurally instead of via instanceof. const errorHasName = (error: unknown, name: string): boolean => typeof error === "object" && error !== null && diff --git a/site/src/pages/AgentsPage/utils/chatDraftAttachmentStorage.ts b/site/src/pages/AgentsPage/utils/chatDraftAttachmentStorage.ts index 1a26ad1ab0b..aa38557407e 100644 --- a/site/src/pages/AgentsPage/utils/chatDraftAttachmentStorage.ts +++ b/site/src/pages/AgentsPage/utils/chatDraftAttachmentStorage.ts @@ -282,8 +282,7 @@ const fileFromDataURL = ( metadata: { fileName: string; fileType: string; lastModified: number }, ): File | null => { const decoded = decodeDataURL(payload); - // Stored records are always base64 (fileToDataURL uses FileReader), - // so anything else is corruption. + // FileReader stores drafts as base64, so other encodings indicate corruption. if (!decoded?.isBase64) { return null; } From a306915abfd03ffac43a85c957c20040d5f6af3d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:21:01 +0000 Subject: [PATCH 38/43] test(site/src/pages/AgentsPage): restore Save retry interaction in Storybook --- .../ConversationTimeline.stories.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 39d23ccec93..e66dd2b1e2e 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1387,6 +1387,36 @@ export const DownloadInIOSStandaloneSharesFile: Story = { }, }; +export const DownloadInIOSStandaloneRecoversExpiredActivation: Story = { + decorators: [withToaster], + args: iosDownloadStoryArgs, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const share = fn() + .mockRejectedValueOnce( + new DOMException("activation expired", "NotAllowedError"), + ) + .mockResolvedValue(undefined); + const { open, restoreNavigator } = setupIOSStandaloneDownload({ + share, + canShare: fn().mockReturnValue(true), + }); + try { + await userEvent.click(getIOSDownloadLink(canvas)); + // The toast renders in a portal outside the story canvas. + const saveButton = await screen.findByRole("button", { name: "Save" }); + await userEvent.click(saveButton); + await waitFor(() => expect(share).toHaveBeenCalledTimes(2)); + const shared: { files: File[] } = share.mock.calls[1][0]; + expect(shared.files).toHaveLength(1); + expect(shared.files[0].name).toBe("deployment-report.pdf"); + expect(open).not.toHaveBeenCalled(); + } finally { + restoreNavigator(); + } + }, +}; + export const DownloadInIOSStandaloneSuppressesDuplicateClicks: Story = { args: iosDownloadStoryArgs, play: async ({ canvasElement }) => { From f3290b6a3b15c14172f60d789a0605fce97708bc Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:08:38 +0000 Subject: [PATCH 39/43] refactor(site/src/pages/AgentsPage): reduce iOS PWA download to share-sheet core Drop the pending-state wiring, abort-signal threading, and the blob-URL tab fallback. iOS standalone now shares via the share sheet when file sharing is available and keeps the native anchor otherwise. Failures surface one toast, with a Save retry after expired activation. --- .../ChatConversation/AttachmentBlocks.tsx | 90 ++----- .../ConversationTimeline.stories.tsx | 225 +----------------- .../AgentsPage/utils/chatAttachments.test.ts | 202 +++------------- .../pages/AgentsPage/utils/chatAttachments.ts | 84 ++----- 4 files changed, 72 insertions(+), 529 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index 9a688e00e40..ccf9958245f 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -4,12 +4,7 @@ import { FileIcon, FileTextIcon, } from "lucide-react"; -import { - type FC, - type MouseEvent as ReactMouseEvent, - type ReactNode, - useState, -} from "react"; +import { type FC, type ReactNode, useState } from "react"; import { Spinner } from "#/components/Spinner/Spinner"; import { Tooltip, @@ -19,7 +14,6 @@ import { import { cn } from "#/utils/cn"; import { useLatestAbortController } from "../../hooks/useLatestAbortController"; import { - type AttachmentDownloadTarget, type AttachmentFailure, attachmentFailureFromError, getChatFileURL, @@ -180,61 +174,28 @@ const getAttachmentBadgeLabel = ( return extension === "file" ? "" : extension.toUpperCase(); }; -const useAttachmentDownloadClick = (target: AttachmentDownloadTarget) => { - const [isPending, setIsPending] = useState(false); - // Prevents share sheets and error toasts from appearing after unmount. - const downloadRequest = useLatestAbortController(); - const onClick = (event: ReactMouseEvent) => { - event.stopPropagation(); - if (isPending) { - event.preventDefault(); - return; - } - const controller = downloadRequest.start(); - const pending = handleAttachmentDownloadClick( - event, - target, - controller.signal, - ); - if (!pending) { - downloadRequest.clear(controller); - return; - } - setIsPending(true); - void pending.finally(() => { - if (downloadRequest.clear(controller)) { - setIsPending(false); - } - }); - }; - return { isPending, onClick }; -}; - const DownloadOverlay: FC<{ href: string; displayName: string; downloadName: string; mediaType: string; }> = ({ href, displayName, downloadName, mediaType }) => { - const { isPending, onClick } = useAttachmentDownloadClick({ - href, - fileName: downloadName, - mediaType, - }); return ( { + event.stopPropagation(); + void handleAttachmentDownloadClick(event, { + href, + fileName: downloadName, + mediaType, + }); + }} aria-label={`Download ${displayName}`} - aria-disabled={isPending} className="invisible absolute right-1 top-1 flex size-6 items-center justify-center rounded bg-surface-primary/80 text-content-secondary opacity-0 shadow-sm backdrop-blur-sm transition-opacity hover:text-content-primary group-hover/attachment:visible group-hover/attachment:opacity-100 group-focus-within/attachment:visible group-focus-within/attachment:opacity-100 [@media(hover:none)]:visible [@media(hover:none)]:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link" > - {isPending ? ( - - ) : ( - ); }; @@ -614,19 +575,20 @@ const FileCard: FC<{ const displayName = getAttachmentDisplayName(block); const downloadName = getAttachmentDownloadName(block); const badgeLabel = getAttachmentBadgeLabel(block); - const { isPending, onClick } = useAttachmentDownloadClick({ - href, - fileName: downloadName, - mediaType: block.media_type, - }); return ( { + event.stopPropagation(); + void handleAttachmentDownloadClick(event, { + href, + fileName: downloadName, + mediaType: block.media_type, + }); + }} aria-label={`Download ${displayName}`} - aria-disabled={isPending} className="inline-flex h-16 max-w-sm items-center gap-3 rounded-md border border-solid border-border-default bg-surface-tertiary px-3 py-2 no-underline transition-colors hover:bg-surface-quaternary" >
@@ -647,18 +609,10 @@ const FileCard: FC<{
Download file
- {isPending ? ( - - ) : ( -
); }; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index e66dd2b1e2e..a9e3b3fdcf3 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -10,7 +10,6 @@ import { within, } from "storybook/test"; import type * as TypesGen from "#/api/typesGenerated"; -import { withToaster } from "#/testHelpers/storybook"; import { getChatFileURL } from "../../utils/chatAttachments"; import { encodeInlineTextAttachment } from "../../utils/fetchTextAttachment"; import { ConversationTimeline } from "./ConversationTimeline"; @@ -1336,241 +1335,25 @@ const iosDownloadStoryArgs: Story["args"] = buildStoryArgs( }), ); -const setupIOSStandaloneDownload = (extras: Record) => ({ - open: spyOn(window, "open").mockReturnValue(null), - restoreNavigator: overrideNavigatorForIOSStandalone(extras), -}); - -const getIOSDownloadLink = (canvas: ReturnType) => - canvas.getByRole("link", { name: "Download deployment-report.pdf" }); - -const buildInlineDownloadStoryArgs = ( - data: string, - name: string, -): Story["args"] => - buildStoryArgs({ - ...baseMessage, - id: 1, - role: "assistant", - content: [buildFilePart({ media_type: "image/png", data, name })], - }); - -const getInlineDownloadLink = async ( - canvas: ReturnType, - name: string, -) => { - canvas.getByRole("button", { name: `View ${name}` }).focus(); - return canvas.findByRole("link", { name: `Download ${name}` }); -}; - export const DownloadInIOSStandaloneSharesFile: Story = { args: iosDownloadStoryArgs, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const share = fn().mockResolvedValue(undefined); - const { open, restoreNavigator } = setupIOSStandaloneDownload({ + const restoreNavigator = overrideNavigatorForIOSStandalone({ share, canShare: fn().mockReturnValue(true), }); try { - await userEvent.click(getIOSDownloadLink(canvas)); + await userEvent.click( + canvas.getByRole("link", { name: "Download deployment-report.pdf" }), + ); await waitFor(() => expect(share).toHaveBeenCalledTimes(1)); const shared: { files: File[] } = share.mock.calls[0][0]; expect(shared.files).toHaveLength(1); expect(shared.files[0].name).toBe("deployment-report.pdf"); expect(shared.files[0].type).toBe("application/pdf"); expect(getAttachmentFetchCount("storybook-ios-share-report")).toBe(1); - expect(open).not.toHaveBeenCalled(); - } finally { - restoreNavigator(); - } - }, -}; - -export const DownloadInIOSStandaloneRecoversExpiredActivation: Story = { - decorators: [withToaster], - args: iosDownloadStoryArgs, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const share = fn() - .mockRejectedValueOnce( - new DOMException("activation expired", "NotAllowedError"), - ) - .mockResolvedValue(undefined); - const { open, restoreNavigator } = setupIOSStandaloneDownload({ - share, - canShare: fn().mockReturnValue(true), - }); - try { - await userEvent.click(getIOSDownloadLink(canvas)); - // The toast renders in a portal outside the story canvas. - const saveButton = await screen.findByRole("button", { name: "Save" }); - await userEvent.click(saveButton); - await waitFor(() => expect(share).toHaveBeenCalledTimes(2)); - const shared: { files: File[] } = share.mock.calls[1][0]; - expect(shared.files).toHaveLength(1); - expect(shared.files[0].name).toBe("deployment-report.pdf"); - expect(open).not.toHaveBeenCalled(); - } finally { - restoreNavigator(); - } - }, -}; - -export const DownloadInIOSStandaloneSuppressesDuplicateClicks: Story = { - args: iosDownloadStoryArgs, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const share = fn().mockResolvedValue(undefined); - const { restoreNavigator } = setupIOSStandaloneDownload({ - share, - canShare: fn().mockReturnValue(true), - }); - let releaseFetch = () => {}; - const fetchSpy = spyOn(globalThis, "fetch").mockImplementation( - () => - new Promise((resolve) => { - releaseFetch = () => - resolve( - new Response("pdf-bytes", { - headers: { "Content-Type": "application/pdf" }, - }), - ); - }), - ); - try { - const downloadLink = getIOSDownloadLink(canvas); - await userEvent.click(downloadLink); - expect(downloadLink).toHaveAttribute("aria-disabled", "true"); - await userEvent.click(downloadLink); - expect(fetchSpy).toHaveBeenCalledTimes(1); - releaseFetch(); - await waitFor(() => expect(share).toHaveBeenCalledTimes(1)); - await waitFor(() => - expect(downloadLink).toHaveAttribute("aria-disabled", "false"), - ); - } finally { - restoreNavigator(); - } - }, -}; - -export const DownloadInIOSStandaloneReportsPermanentShareFailure: Story = { - decorators: [withToaster], - args: iosDownloadStoryArgs, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const { open, restoreNavigator } = setupIOSStandaloneDownload({ - share: fn().mockRejectedValue( - new DOMException("share failed", "DataError"), - ), - canShare: fn().mockReturnValue(true), - }); - try { - await userEvent.click(getIOSDownloadLink(canvas)); - await screen.findByText("Couldn't download deployment-report.pdf"); - expect( - screen.queryByRole("button", { name: "Save" }), - ).not.toBeInTheDocument(); - expect(open).not.toHaveBeenCalled(); - await userEvent.click(screen.getByRole("button", { name: "Open" })); - expect(open).toHaveBeenCalledWith( - getChatFileURL("storybook-ios-share-report"), - "_blank", - "noopener", - ); - } finally { - restoreNavigator(); - } - }, -}; - -const inlineAttachmentStoryArgs = buildInlineDownloadStoryArgs( - TEST_PNG_B64, - "inline-screenshot.png", -); - -export const DownloadInIOSStandaloneShowsErrorForCorruptInlineAttachment: Story = - { - decorators: [withToaster], - args: buildInlineDownloadStoryArgs( - "not-valid-base64", - "corrupt-screenshot.png", - ), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const share = fn().mockResolvedValue(undefined); - const { open, restoreNavigator } = setupIOSStandaloneDownload({ - share, - canShare: fn().mockReturnValue(true), - }); - try { - const downloadLink = await getInlineDownloadLink( - canvas, - "corrupt-screenshot.png", - ); - await userEvent.click(downloadLink); - await screen.findByText("Couldn't download corrupt-screenshot.png"); - await screen.findByText("The attachment data could not be decoded."); - expect(share).not.toHaveBeenCalled(); - expect(open).not.toHaveBeenCalled(); - } finally { - restoreNavigator(); - } - }, - }; - -export const DownloadInIOSStandaloneOpensInlineAttachmentInTab: Story = { - args: inlineAttachmentStoryArgs, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const { open, restoreNavigator } = setupIOSStandaloneDownload({ - share: undefined, - canShare: undefined, - }); - const fetchSpy = spyOn(globalThis, "fetch"); - try { - const downloadLink = await getInlineDownloadLink( - canvas, - "inline-screenshot.png", - ); - await userEvent.click(downloadLink); - expect(open).toHaveBeenCalledTimes(1); - const [blobUrl, target, features] = open.mock.calls[0]; - expect(blobUrl).toMatch(/^blob:/); - expect(target).toBe("_blank"); - expect(features).toBe("noopener"); - expect(fetchSpy).not.toHaveBeenCalled(); - } finally { - restoreNavigator(); - } - }, -}; - -export const DownloadInIOSStandaloneStaysQuietOnDismissedShare: Story = { - decorators: [withToaster], - args: iosDownloadStoryArgs, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const share = fn().mockRejectedValue( - new DOMException("dismissed", "AbortError"), - ); - const { open, restoreNavigator } = setupIOSStandaloneDownload({ - share, - canShare: fn().mockReturnValue(true), - }); - try { - await userEvent.click(getIOSDownloadLink(canvas)); - await waitFor(() => expect(share).toHaveBeenCalledTimes(1)); - await waitFor(() => { - expect( - canvas.getByRole("link", { name: "Download deployment-report.pdf" }), - ).toHaveAttribute("aria-disabled", "false"); - }); - expect( - screen.queryByText("Couldn't download deployment-report.pdf"), - ).not.toBeInTheDocument(); - expect(open).not.toHaveBeenCalled(); } finally { restoreNavigator(); } diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts index 09356c7d642..d312e68db0a 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts @@ -16,10 +16,6 @@ import { describe("handleAttachmentDownloadClick", () => { const overriddenNavigatorKeys = new Set(); - const originalURLDescriptors = new Map< - string, - PropertyDescriptor | undefined - >(); const iPhoneUserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15"; const target = { @@ -38,12 +34,12 @@ describe("handleAttachmentDownloadClick", () => { overrideNavigator("standalone", true); }; - const mockFileSharing = ( - share: ReturnType, - canShare: (data: { files: File[] }) => boolean = () => true, - ) => { + const mockFileSharing = (share: ReturnType) => { overrideNavigator("share", share); - overrideNavigator("canShare", vi.fn(canShare)); + overrideNavigator( + "canShare", + vi.fn(() => true), + ); }; const mockAttachmentFetch = (body = "png-bytes", mediaType = "image/png") => @@ -53,44 +49,19 @@ describe("handleAttachmentDownloadClick", () => { new Response(new Blob([body], { type: mediaType }), { status: 200 }), ); - const click = (downloadTarget = target, signal?: AbortSignal) => { + const click = (downloadTarget = target) => { const event = { preventDefault: vi.fn() }; return { event, - pending: handleAttachmentDownloadClick(event, downloadTarget, signal), + pending: handleAttachmentDownloadClick(event, downloadTarget), }; }; - const stubObjectURLs = () => { - const createObjectURL = vi.fn().mockReturnValue("blob:inline"); - const revokeObjectURL = vi.fn(); - for (const [key, value] of Object.entries({ - createObjectURL, - revokeObjectURL, - })) { - originalURLDescriptors.set( - key, - Object.getOwnPropertyDescriptor(URL, key), - ); - Object.defineProperty(URL, key, { value, configurable: true }); - } - return { createObjectURL, revokeObjectURL }; - }; - afterEach(() => { for (const key of overriddenNavigatorKeys) { Reflect.deleteProperty(navigator, key); } overriddenNavigatorKeys.clear(); - for (const [key, descriptor] of originalURLDescriptors) { - if (descriptor) { - Object.defineProperty(URL, key, descriptor); - } else { - Reflect.deleteProperty(URL, key); - } - } - originalURLDescriptors.clear(); - vi.useRealTimers(); vi.restoreAllMocks(); vi.mocked(toast.error).mockClear(); }); @@ -103,12 +74,10 @@ describe("handleAttachmentDownloadClick", () => { ], ])("keeps the native anchor download %s", (_label, setup) => { setup(); - const open = vi.spyOn(window, "open").mockReturnValue(null); const { event, pending } = click(); expect(pending).toBeUndefined(); expect(event.preventDefault).not.toHaveBeenCalled(); - expect(open).not.toHaveBeenCalled(); }); it("shares the fetched attachment in an iOS standalone PWA", async () => { @@ -121,9 +90,7 @@ describe("handleAttachmentDownloadClick", () => { await pending; expect(event.preventDefault).toHaveBeenCalled(); - expect(globalThis.fetch).toHaveBeenCalledWith(target.href, { - signal: undefined, - }); + expect(globalThis.fetch).toHaveBeenCalledWith(target.href); const shared: { files: File[] } = share.mock.calls[0][0]; expect(shared.files).toHaveLength(1); expect(shared.files[0]).toMatchObject({ @@ -132,32 +99,32 @@ describe("handleAttachmentDownloadClick", () => { }); }); - it("recognizes iPadOS with a macOS user agent", () => { + it("recognizes iPadOS with a macOS user agent", async () => { overrideNavigator( "userAgent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15", ); overrideNavigator("maxTouchPoints", 5); overrideNavigator("standalone", true); - const open = vi.spyOn(window, "open").mockReturnValue(null); + const share = vi.fn().mockResolvedValue(undefined); + mockFileSharing(share); + mockAttachmentFetch(); const { event, pending } = click(); + await pending; - expect(pending).toBeUndefined(); expect(event.preventDefault).toHaveBeenCalled(); - expect(open).toHaveBeenCalledWith(target.href, "_blank", "noopener"); + expect(share).toHaveBeenCalledTimes(1); }); - it("opens a tab synchronously when file sharing is unavailable", () => { + it("keeps the native anchor when file sharing is unavailable", () => { enterIOSStandalonePWA(); - const open = vi.spyOn(window, "open").mockReturnValue(null); const fetchSpy = vi.spyOn(globalThis, "fetch"); const { event, pending } = click(); expect(pending).toBeUndefined(); - expect(event.preventDefault).toHaveBeenCalled(); - expect(open).toHaveBeenCalledWith(target.href, "_blank", "noopener"); + expect(event.preventDefault).not.toHaveBeenCalled(); expect(fetchSpy).not.toHaveBeenCalled(); }); @@ -173,11 +140,10 @@ describe("handleAttachmentDownloadClick", () => { expect(toast.error).not.toHaveBeenCalled(); }); - it("shows the fetch failure without opening a late popup", async () => { + it("shows the fetch failure", async () => { enterIOSStandalonePWA(); const share = vi.fn().mockResolvedValue(undefined); mockFileSharing(share); - const open = vi.spyOn(window, "open").mockReturnValue(null); vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response("nope", { status: 503 }), ); @@ -185,7 +151,6 @@ describe("handleAttachmentDownloadClick", () => { await click().pending; expect(share).not.toHaveBeenCalled(); - expect(open).not.toHaveBeenCalled(); expect(toast.error).toHaveBeenCalledWith( "Couldn't download 01-agents-list.png", { description: "HTTP 503" }, @@ -214,20 +179,18 @@ describe("handleAttachmentDownloadClick", () => { ); }); - it("offers Open after a permanent share failure", async () => { + it("shows a plain failure toast after a permanent share failure", async () => { enterIOSStandalonePWA(); - mockFileSharing( - vi.fn().mockRejectedValue(new DOMException("share failed", "DataError")), - ); + // jsdom's DOMException is not instanceof Error, so a plain Error + // stands in for permanent failures like DataError. + mockFileSharing(vi.fn().mockRejectedValue(new Error("share failed"))); mockAttachmentFetch(); await click().pending; expect(toast.error).toHaveBeenCalledWith( "Couldn't download 01-agents-list.png", - expect.objectContaining({ - action: expect.objectContaining({ label: "Open" }), - }), + { description: "share failed" }, ); }); @@ -252,131 +215,22 @@ describe("handleAttachmentDownloadClick", () => { }); }); - it("opens inline data through a temporary blob URL", () => { - enterIOSStandalonePWA(); - const { createObjectURL, revokeObjectURL } = stubObjectURLs(); - const open = vi.spyOn(window, "open").mockReturnValue(null); - vi.useFakeTimers(); - - const { pending } = click({ - href: `data:image/png;base64,${btoa("png-bytes")}`, - fileName: "inline.png", - mediaType: "image/png", - }); - - expect(pending).toBeUndefined(); - const decoded: File = createObjectURL.mock.calls[0][0]; - expect(decoded).toMatchObject({ name: "inline.png", type: "image/png" }); - expect(open).toHaveBeenCalledWith("blob:inline", "_blank", "noopener"); - vi.runAllTimers(); - expect(revokeObjectURL).toHaveBeenCalledWith("blob:inline"); - }); - - it("shows a decode error for corrupt inline data", () => { + it("shows a decode error for corrupt inline data", async () => { enterIOSStandalonePWA(); - stubObjectURLs(); - const open = vi.spyOn(window, "open").mockReturnValue(null); + const share = vi.fn().mockResolvedValue(undefined); + mockFileSharing(share); - click({ + await click({ href: "data:image/png;base64,%%%", fileName: "inline.png", mediaType: "image/png", - }); + }).pending; - expect(open).not.toHaveBeenCalled(); + expect(share).not.toHaveBeenCalled(); expect(toast.error).toHaveBeenCalledWith("Couldn't download inline.png", { description: "The attachment data could not be decoded.", }); }); - - it("passes the abort signal through the fetch and suppresses abort UI", async () => { - enterIOSStandalonePWA(); - const share = vi.fn().mockResolvedValue(undefined); - mockFileSharing(share); - vi.spyOn(globalThis, "fetch").mockImplementation( - (_input, init) => - new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => - reject(new DOMException("aborted", "AbortError")), - ); - }), - ); - const controller = new AbortController(); - - const { pending } = click(target, controller.signal); - controller.abort(); - await pending; - - expect(share).not.toHaveBeenCalled(); - expect(toast.error).not.toHaveBeenCalled(); - }); - - it.each([ - ["the fetch resolves", false, 200], - ["an HTTP error resolves", false, 503], - ["the response body resolves", true, 200], - ])("suppresses UI after unmount when %s", async (_label, abortInBlob, status) => { - enterIOSStandalonePWA(); - const share = vi.fn().mockResolvedValue(undefined); - mockFileSharing(share); - const controller = new AbortController(); - vi.spyOn(globalThis, "fetch").mockImplementation(async () => { - const response = new Response( - new Blob(["png-bytes"], { type: "image/png" }), - { status }, - ); - if (abortInBlob) { - vi.spyOn(response, "blob").mockImplementation(async () => { - controller.abort(); - return new Blob(["png-bytes"], { type: "image/png" }); - }); - } else { - controller.abort(); - } - return response; - }); - - await click(target, controller.signal).pending; - - expect(share).not.toHaveBeenCalled(); - expect(toast.error).not.toHaveBeenCalled(); - }); - - it("suppresses share rejection UI after unmount", async () => { - enterIOSStandalonePWA(); - const controller = new AbortController(); - const share = vi.fn().mockImplementation(() => { - controller.abort(); - return Promise.reject(new DOMException("expired", "NotAllowedError")); - }); - mockFileSharing(share); - mockAttachmentFetch(); - - await click(target, controller.signal).pending; - - expect(share).toHaveBeenCalledTimes(1); - expect(toast.error).not.toHaveBeenCalled(); - }); - - it("offers Open when the fetched file cannot be shared", async () => { - enterIOSStandalonePWA(); - const share = vi.fn().mockResolvedValue(undefined); - mockFileSharing(share, ({ files }) => files[0].size <= 1); - mockAttachmentFetch(); - const open = vi.spyOn(window, "open").mockReturnValue(null); - - await click().pending; - - expect(share).not.toHaveBeenCalled(); - expect(open).not.toHaveBeenCalled(); - expect(toast.error).toHaveBeenCalledWith( - "Couldn't download 01-agents-list.png", - expect.objectContaining({ - description: "This file cannot be shared on this device.", - action: expect.objectContaining({ label: "Open" }), - }), - ); - }); }); describe("isChatAttachmentFile", () => { diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index e577dfd076f..52ae4148aa3 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -83,7 +83,7 @@ const canShareFile = (file: File): boolean => typeof navigator.canShare === "function" && navigator.canShare({ files: [file] }); -export type AttachmentDownloadTarget = { +type AttachmentDownloadTarget = { href: string; fileName: string; mediaType: string; @@ -105,23 +105,6 @@ const showDownloadFailure = ( toast.error(`Couldn't download ${fileName}`, options); }; -// iOS blocks top-level data: navigation, so inline attachments open through -// a short-lived blob URL instead of their data: href. -const openAttachmentInTab = (href: string, file: File): void => { - if (!href.startsWith("data:")) { - open(href, "_blank", "noopener"); - return; - } - const blobUrl = URL.createObjectURL(file); - open(blobUrl, "_blank", "noopener"); - setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000); -}; - -const openFallbackAction = (target: AttachmentDownloadTarget, file: File) => ({ - label: "Open", - onClick: () => openAttachmentInTab(target.href, file), -}); - // Production CSP excludes data: from connect-src, so inline hrefs are // decoded locally instead. const fileFromDataURL = ({ @@ -137,34 +120,30 @@ const fileFromDataURL = ({ : null; }; -const shareFileViaSheet = ( - file: File, - target: AttachmentDownloadTarget, - signal?: AbortSignal, -): Promise => +const shareFileViaSheet = (file: File, fileName: string): Promise => navigator.share({ files: [file] }).catch((error: unknown) => { - if (errorHasName(error, "AbortError") || signal?.aborted) { + if (errorHasName(error, "AbortError")) { return; } if (errorHasName(error, "NotAllowedError")) { - showDownloadFailure(target.fileName, { + // A slow fetch consumes the click's transient activation; the + // toast click provides the fresh gesture the retry needs. + showDownloadFailure(fileName, { description: "The file is ready to save.", action: { label: "Save", - onClick: () => void shareFileViaSheet(file, target), + onClick: () => void shareFileViaSheet(file, fileName), }, }); return; } - showDownloadFailure(target.fileName, { + showDownloadFailure(fileName, { description: error instanceof Error ? error.message : undefined, - action: openFallbackAction(target, file), }); }); const shareAttachmentFile = async ( target: AttachmentDownloadTarget, - signal?: AbortSignal, ): Promise => { let file: File; if (target.href.startsWith("data:")) { @@ -178,10 +157,7 @@ const shareAttachmentFile = async ( file = decoded; } else { try { - const response = await fetch(target.href, { signal }); - if (signal?.aborted) { - return; - } + const response = await fetch(target.href); if (!response.ok) { throw new Error( response.statusText @@ -194,58 +170,34 @@ const shareAttachmentFile = async ( type: blob.type || target.mediaType || "application/octet-stream", }); } catch (error) { - if (errorHasName(error, "AbortError")) { - return; - } showDownloadFailure(target.fileName, { description: error instanceof Error ? error.message : undefined, }); return; } } - if (signal?.aborted) { - return; - } - if (!canShareFile(file)) { - showDownloadFailure(target.fileName, { - description: "This file cannot be shared on this device.", - action: openFallbackAction(target, file), - }); - return; - } - await shareFileViaSheet(file, target, signal); + await shareFileViaSheet(file, target.fileName); }; /** - * Avoids iOS standalone PWA QuickLook, which can leave no way back to the app. - * Uses the share sheet when possible, or a dismissible tab when file sharing - * is unavailable. Other environments keep native download behavior. + * Avoids iOS standalone PWA QuickLook, which can leave no way back to the + * app, by routing the download through the share sheet. Everywhere else, + * and on iOS devices without file sharing, the native anchor download + * proceeds. */ export const handleAttachmentDownloadClick = ( event: { preventDefault: () => void }, target: AttachmentDownloadTarget, - signal?: AbortSignal, ): Promise | undefined => { if (!isIOS() || !isStandaloneDisplayMode()) { return undefined; } - event.preventDefault(); const probe = new File(["0"], target.fileName, { type: target.mediaType }); - if (canShareFile(probe)) { - return shareAttachmentFile(target, signal); - } - // Open the fallback tab during the click gesture to satisfy popup blockers. - const file = target.href.startsWith("data:") - ? fileFromDataURL(target) - : probe; - if (file) { - openAttachmentInTab(target.href, file); - } else { - showDownloadFailure(target.fileName, { - description: "The attachment data could not be decoded.", - }); + if (!canShareFile(probe)) { + return undefined; } - return undefined; + event.preventDefault(); + return shareAttachmentFile(target); }; // Filename extensions to list in the file-picker's `accept` attribute From 187f34405cdf33b61a7a2571a3d4621393becc86 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:16:18 +0000 Subject: [PATCH 40/43] chore(site/src/pages/AgentsPage): fold cleanup-gate findings into download thinning --- .../ConversationTimeline.stories.tsx | 39 +++++++------------ .../AgentsPage/utils/chatAttachments.test.ts | 2 - .../pages/AgentsPage/utils/chatAttachments.ts | 10 ++--- 3 files changed, 17 insertions(+), 34 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index a9e3b3fdcf3..27e3161e569 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -192,29 +192,6 @@ const mockAttachmentFetch = () => { }); }; -// Read-only Navigator values must be shadowed with removable own properties. -const overrideNavigatorForIOSStandalone = ( - extras: Record = {}, -): (() => void) => { - const overrides: Record = { - userAgent: - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15", - standalone: true, - ...extras, - }; - for (const [key, value] of Object.entries(overrides)) { - Object.defineProperty(navigator, key, { - value, - configurable: true, - }); - } - return () => { - for (const key of Object.keys(overrides)) { - Reflect.deleteProperty(navigator, key); - } - }; -}; - const buildTextPart = (text: string): TypesGen.ChatTextPart => ({ type: "text", text, @@ -1340,10 +1317,18 @@ export const DownloadInIOSStandaloneSharesFile: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); const share = fn().mockResolvedValue(undefined); - const restoreNavigator = overrideNavigatorForIOSStandalone({ + // Read-only Navigator values must be shadowed with removable own + // properties. + const overrides: Record = { + userAgent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15", + standalone: true, share, canShare: fn().mockReturnValue(true), - }); + }; + for (const [key, value] of Object.entries(overrides)) { + Object.defineProperty(navigator, key, { value, configurable: true }); + } try { await userEvent.click( canvas.getByRole("link", { name: "Download deployment-report.pdf" }), @@ -1355,7 +1340,9 @@ export const DownloadInIOSStandaloneSharesFile: Story = { expect(shared.files[0].type).toBe("application/pdf"); expect(getAttachmentFetchCount("storybook-ios-share-report")).toBe(1); } finally { - restoreNavigator(); + for (const key of Object.keys(overrides)) { + Reflect.deleteProperty(navigator, key); + } } }, }; diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts index d312e68db0a..417f1520809 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts @@ -181,8 +181,6 @@ describe("handleAttachmentDownloadClick", () => { it("shows a plain failure toast after a permanent share failure", async () => { enterIOSStandalonePWA(); - // jsdom's DOMException is not instanceof Error, so a plain Error - // stands in for permanent failures like DataError. mockFileSharing(vi.fn().mockRejectedValue(new Error("share failed"))); mockAttachmentFetch(); diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index 52ae4148aa3..a53438233e2 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -126,8 +126,8 @@ const shareFileViaSheet = (file: File, fileName: string): Promise => return; } if (errorHasName(error, "NotAllowedError")) { - // A slow fetch consumes the click's transient activation; the - // toast click provides the fresh gesture the retry needs. + // Fetching may outlast transient user activation. The toast action + // supplies a fresh gesture for the retry. showDownloadFailure(fileName, { description: "The file is ready to save.", action: { @@ -180,10 +180,8 @@ const shareAttachmentFile = async ( }; /** - * Avoids iOS standalone PWA QuickLook, which can leave no way back to the - * app, by routing the download through the share sheet. Everywhere else, - * and on iOS devices without file sharing, the native anchor download - * proceeds. + * Uses the share sheet in iOS standalone mode to avoid a QuickLook navigation + * with no reliable return path. Otherwise, the native anchor handles the download. */ export const handleAttachmentDownloadClick = ( event: { preventDefault: () => void }, From d9d7b7bc1323a985a223004d609d233ad7b12d9a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:06:11 +0000 Subject: [PATCH 41/43] refactor(site/src/pages/AgentsPage): simplify download naming and share error handling --- .../ChatConversation/AttachmentBlocks.tsx | 36 ++++++++----------- .../ConversationTimeline.stories.tsx | 29 +++++++++++++++ .../pages/AgentsPage/utils/chatAttachments.ts | 12 ++----- site/src/pages/AgentsPage/utils/dataUrls.ts | 16 ++++++--- 4 files changed, 56 insertions(+), 37 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index ccf9958245f..0e63cd6614e 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -90,6 +90,13 @@ const getMediaTypeExtension = (mediaType: string): string | null => { : null; }; +const getNameExtension = (name: string): string | null => { + const lastDot = name.lastIndexOf("."); + return lastDot > 0 && lastDot < name.length - 1 + ? name.slice(lastDot + 1) + : null; +}; + const getAttachmentExtension = ( block: Pick, ): string => { @@ -97,10 +104,9 @@ const getAttachmentExtension = ( if (mapped) { return mapped; } - const name = block.name?.trim(); - const lastDot = name?.lastIndexOf(".") ?? -1; - if (name && lastDot > 0 && lastDot < name.length - 1) { - return sanitizeAttachmentExtension(name.slice(lastDot + 1)); + const nameExtension = getNameExtension(block.name?.trim() ?? ""); + if (nameExtension) { + return sanitizeAttachmentExtension(nameExtension); } return ( getMediaTypeExtension(block.media_type) ?? @@ -111,9 +117,6 @@ const getAttachmentExtension = ( const isTextPreviewAttachmentMediaType = (mediaType: string): boolean => TEXT_ATTACHMENT_MEDIA_TYPES.has(mediaType); -const isSuffixPreservingMediaType = (mediaType: string): boolean => - mediaType.startsWith("text/"); - const getAttachmentHref = (block: FileAttachmentBlock): string | null => { if (block.file_id) { return getChatFileURL(block.file_id); @@ -140,8 +143,6 @@ const getAttachmentDisplayName = ( return "Attached file"; }; -const extensionAliases = new Set(["jpg:jpeg", "tiff:tif"]); - const getAttachmentDownloadName = ( block: Pick, ): string => { @@ -150,21 +151,12 @@ const getAttachmentDownloadName = ( const extension = getAttachmentExtension(block); return extension === "file" ? "attachment" : `attachment.${extension}`; } - const mediaExtension = getMediaTypeExtension(block.media_type); - if (!mediaExtension || name.startsWith(".")) { + // Kept even when the name's extension disagrees with the media type. + if (name.startsWith(".") || getNameExtension(name)) { return name; } - if ( - isSuffixPreservingMediaType(block.media_type) && - /\.[^.\s]+$/.test(name) - ) { - return name; - } - const suffix = name.match(/\.([a-z0-9]{1,8})$/i)?.[1]?.toLowerCase(); - return suffix === mediaExtension || - extensionAliases.has(`${mediaExtension}:${suffix}`) - ? name - : `${name}.${mediaExtension}`; + const mediaExtension = getMediaTypeExtension(block.media_type); + return mediaExtension ? `${name}.${mediaExtension}` : name; }; const getAttachmentBadgeLabel = ( diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 27e3161e569..c525904099c 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1299,6 +1299,35 @@ export const AssistantMessageWithUnnamedDownloadableFile: Story = { }, }; +export const AssistantMessageWithMismatchedExtensionFile: Story = { + args: { + ...defaultArgs, + parsedMessages: buildMessages([ + { + ...baseMessage, + id: 1, + role: "assistant", + content: [ + { type: "text", text: "Here are the release notes." }, + { + type: "file", + media_type: "application/pdf", + file_id: "storybook-mismatched-notes", + name: "release-notes.txt", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const downloadLink = canvas.getByRole("link", { + name: "Download release-notes.txt", + }); + expect(downloadLink).toHaveAttribute("download", "release-notes.txt"); + }, +}; + const iosDownloadStoryArgs: Story["args"] = buildStoryArgs( buildUserMessage({ text: "I attached the deployment report.", diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index a53438233e2..2eaea18a790 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -89,12 +89,6 @@ type AttachmentDownloadTarget = { mediaType: string; }; -const errorHasName = (error: unknown, name: string): boolean => - typeof error === "object" && - error !== null && - "name" in error && - error.name === name; - const showDownloadFailure = ( fileName: string, options: { @@ -105,8 +99,6 @@ const showDownloadFailure = ( toast.error(`Couldn't download ${fileName}`, options); }; -// Production CSP excludes data: from connect-src, so inline hrefs are -// decoded locally instead. const fileFromDataURL = ({ href, fileName, @@ -122,10 +114,10 @@ const fileFromDataURL = ({ const shareFileViaSheet = (file: File, fileName: string): Promise => navigator.share({ files: [file] }).catch((error: unknown) => { - if (errorHasName(error, "AbortError")) { + if (error instanceof DOMException && error.name === "AbortError") { return; } - if (errorHasName(error, "NotAllowedError")) { + if (error instanceof DOMException && error.name === "NotAllowedError") { // Fetching may outlast transient user activation. The toast action // supplies a fresh gesture for the retry. showDownloadFailure(fileName, { diff --git a/site/src/pages/AgentsPage/utils/dataUrls.ts b/site/src/pages/AgentsPage/utils/dataUrls.ts index 36d568ec51c..e4280c3601b 100644 --- a/site/src/pages/AgentsPage/utils/dataUrls.ts +++ b/site/src/pages/AgentsPage/utils/dataUrls.ts @@ -4,19 +4,25 @@ type DecodedDataURL = { bytes: Uint8Array; }; +// Data URLs are decoded by hand because the production CSP excludes data: +// from connect-src (so fetch cannot read them) and the Safari 16 support +// baseline predates Uint8Array.fromBase64. export const decodeDataURL = (url: string): DecodedDataURL | null => { - const match = /^data:([^,]*?)(;base64)?,(.*)$/i.exec(url); - if (!match) { + const commaIndex = url.indexOf(","); + const scheme = url.slice(0, "data:".length).toLowerCase(); + if (scheme !== "data:" || commaIndex === -1) { return null; } - const [, header, isBase64, payload] = match; + const params = url.slice("data:".length, commaIndex).split(";"); + const payload = url.slice(commaIndex + 1); + const isBase64 = params.at(-1)?.toLowerCase() === "base64"; try { const bytes = isBase64 ? Uint8Array.from(atob(payload), (char) => char.charCodeAt(0)) : new TextEncoder().encode(decodeURIComponent(payload)); return { - mediaType: header.split(";")[0].trim(), - isBase64: Boolean(isBase64), + mediaType: params[0].trim(), + isBase64, bytes, }; } catch { From a2e88ea95b9166dcd1c8d907a573ec8299a40733 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:34:40 +0000 Subject: [PATCH 42/43] refactor(site/src/pages/AgentsPage): inline toast failures and reuse getErrorMessage --- .../ChatConversation/AttachmentBlocks.tsx | 8 +------ .../pages/AgentsPage/utils/chatAttachments.ts | 24 ++++++------------- 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx index 0e63cd6614e..0516cabf2c6 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AttachmentBlocks.tsx @@ -57,6 +57,7 @@ const ATTACHMENT_FALLBACK_EXTENSIONS: Record = { "application/x-tar": "tar", "application/xml": "xml", "image/jpeg": "jpg", + "image/svg+xml": "svg", "text/csv": "csv", "text/markdown": "md", "text/plain": "txt", @@ -77,13 +78,6 @@ const getMediaTypeExtension = (mediaType: string): string | null => { return mapped; } const [type, subtype = ""] = mediaType.split("/"); - if (subtype.endsWith("+json")) { - return "json"; - } - if (subtype.endsWith("+xml")) { - const base = subtype.slice(0, -"+xml".length); - return /^[a-z0-9]{1,8}$/i.test(base) ? base.toLowerCase() : "xml"; - } // Unmapped non-image subtypes are not assumed to be filename extensions. return type === "image" && /^[a-z0-9]{1,8}$/i.test(subtype) ? subtype.toLowerCase() diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index 2eaea18a790..9f3c65ce33c 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -1,5 +1,5 @@ import { toast } from "sonner"; -import { isApiErrorResponse } from "#/api/errors"; +import { getErrorMessage, isApiErrorResponse } from "#/api/errors"; import { ChatAttachmentMediaTypes } from "#/api/typesGenerated"; import { decodeDataURL } from "./dataUrls"; @@ -89,16 +89,6 @@ type AttachmentDownloadTarget = { mediaType: string; }; -const showDownloadFailure = ( - fileName: string, - options: { - description?: string; - action?: { label: string; onClick: () => void }; - }, -): void => { - toast.error(`Couldn't download ${fileName}`, options); -}; - const fileFromDataURL = ({ href, fileName, @@ -120,7 +110,7 @@ const shareFileViaSheet = (file: File, fileName: string): Promise => if (error instanceof DOMException && error.name === "NotAllowedError") { // Fetching may outlast transient user activation. The toast action // supplies a fresh gesture for the retry. - showDownloadFailure(fileName, { + toast.error(`Couldn't download ${fileName}`, { description: "The file is ready to save.", action: { label: "Save", @@ -129,8 +119,8 @@ const shareFileViaSheet = (file: File, fileName: string): Promise => }); return; } - showDownloadFailure(fileName, { - description: error instanceof Error ? error.message : undefined, + toast.error(`Couldn't download ${fileName}`, { + description: getErrorMessage(error, "Sharing failed."), }); }); @@ -141,7 +131,7 @@ const shareAttachmentFile = async ( if (target.href.startsWith("data:")) { const decoded = fileFromDataURL(target); if (!decoded) { - showDownloadFailure(target.fileName, { + toast.error(`Couldn't download ${target.fileName}`, { description: "The attachment data could not be decoded.", }); return; @@ -162,8 +152,8 @@ const shareAttachmentFile = async ( type: blob.type || target.mediaType || "application/octet-stream", }); } catch (error) { - showDownloadFailure(target.fileName, { - description: error instanceof Error ? error.message : undefined, + toast.error(`Couldn't download ${target.fileName}`, { + description: getErrorMessage(error, "The file could not be fetched."), }); return; } From 65454a0ddd05269a4b8801280627cb33967eb86f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:51:41 +0000 Subject: [PATCH 43/43] test(site/src/pages/AgentsPage/utils): exercise the Save retry action in the share failure test --- .../AgentsPage/utils/chatAttachments.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts index 417f1520809..12d1cffd5f8 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts @@ -177,6 +177,21 @@ describe("handleAttachmentDownloadClick", () => { action: expect.objectContaining({ label: "Save" }), }), ); + + const action: unknown = vi.mocked(toast.error).mock.calls[0][1]?.action; + if ( + action === null || + typeof action !== "object" || + !("onClick" in action) || + typeof action.onClick !== "function" + ) { + throw new Error("expected the toast to carry a Save action"); + } + action.onClick(); + expect(share).toHaveBeenCalledTimes(2); + const first: { files: File[] } = share.mock.calls[0][0]; + const retry: { files: File[] } = share.mock.calls[1][0]; + expect(retry.files[0]).toBe(first.files[0]); }); it("shows a plain failure toast after a permanent share failure", async () => {