From d49d8b3ab7d06323c20ee0859f0a30a7943b8543 Mon Sep 17 00:00:00 2001 From: Thomas <31560900+0xtlt@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:38:45 +0100 Subject: [PATCH 1/3] feat(web): save files from chat and the file panel to your machine Files an agent creates on the server could be previewed but not copied to the viewing device. Add "Save file" to the chat file chip menu and to the file panel toolbar, for files that had no Save button yet. It downloads through the existing attachment-disposition asset URL, so desktop shows the native Save dialog and remote connections work. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatMarkdown.tsx | 13 ++++ .../src/components/chat/MarkdownFileLink.tsx | 10 +++- .../src/components/files/FilePreviewPanel.tsx | 18 ++++++ .../web/src/components/files/useSaveFileAs.ts | 59 +++++++++++++++++++ 4 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/files/useSaveFileAs.ts diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 06960b273a057..f9b580535f255 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -148,6 +148,7 @@ import { resolveMarkdownFileHostPath, } from "../markdownFileHostPath"; import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; +import { useSaveFileAs } from "./files/useSaveFileAs"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; @@ -552,6 +553,8 @@ function useChatMarkdownState({ const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null; const canOperateHost = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope); + const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope); + const saveFileAs = useSaveFileAs(); const remoteOpen = useRemoteOpenResolution(environmentId); const canUseShellActions = canOperateHost && @@ -875,6 +878,16 @@ function useChatMarkdownState({ : undefined } revealLabel={revealInFileManagerLabel} + onSave={ + threadRef && canReadFiles + ? () => + void saveFileAs({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + path: async () => (await resolveHostPath()).absolutePath, + }) + : undefined + } onOpenInBrowser={ threadRef && canOperatePreview && diff --git a/apps/web/src/components/chat/MarkdownFileLink.tsx b/apps/web/src/components/chat/MarkdownFileLink.tsx index a816760202979..0d90c7515b27f 100644 --- a/apps/web/src/components/chat/MarkdownFileLink.tsx +++ b/apps/web/src/components/chat/MarkdownFileLink.tsx @@ -44,6 +44,7 @@ interface MarkdownFileLinkProps { onOpenMedia?: (() => void) | undefined; onReveal?: (() => Promise>) | undefined; revealLabel?: string | undefined; + onSave?: (() => void) | undefined; } const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link"; @@ -68,6 +69,7 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({ onReveal, revealLabel, resolveCopyPaths, + onSave, }: MarkdownFileLinkProps) { const handleOpenInEditor = () => { if (!onOpen) { @@ -255,6 +257,7 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({ ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) : []), ...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []), + ...(onSave ? ([{ id: "save", label: "Save file" }] as const) : []), ...copyItems.map(({ id, label }) => ({ id, label })), ] as const, position, @@ -276,6 +279,10 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({ handleRevealInFileManager(); return; } + if (clicked === "save") { + onSave?.(); + return; + } if (clicked === "copy-relative" || clicked === "copy-full") { handleCopy( () => @@ -408,6 +415,7 @@ function areMarkdownFileLinkPropsEqual( previous.onOpenMedia === next.onOpenMedia && previous.onReveal === next.onReveal && previous.resolveCopyPaths === next.resolveCopyPaths && - previous.revealLabel === next.revealLabel + previous.revealLabel === next.revealLabel && + previous.onSave === next.onSave ); } diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index ed55da7c08b5c..e67555d1f29b1 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -71,6 +71,7 @@ import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { buildFileReviewComment } from "~/reviewCommentContext"; import { assetEnvironment } from "~/state/assets"; +import { useSaveFileAs } from "./useSaveFileAs"; import { usePreviewAvailable } from "~/browser/previewRuntime"; import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; @@ -1177,6 +1178,18 @@ export default function FilePreviewPanel({ : relativePath && attachment === undefined ? resolvePathLinkTarget(relativePath, cwd) : null; + const saveFileAs = useSaveFileAs(); + // Large text and binary or document files render their own Save button. + const rendersOwnSave = + !isMedia && + !renderBrowserFile && + (isDocument || + file.readError?.failure === "binary_file" || + (file.data?.truncated === true && !isMarkdown && !isMermaid && !isHtml)); + const handleSave = + !draft && previewPath !== null && absolutePath !== null && !rendersOwnSave + ? () => void saveFileAs({ environmentId, threadId: threadRef.threadId, path: absolutePath }) + : null; const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId); // Mounting the file read under the workspace mutation id refetches it after each mutation. useAtomMount( @@ -1329,6 +1342,11 @@ export default function FilePreviewPanel({ ) : null} + {handleSave ? ( + + + + ) : null} {!isHostFile && previewPath !== null ? ( Promise); + }) => { + const toast = toastManager.add({ type: "loading", title: "Preparing file…" }); + try { + const path = typeof input.path === "string" ? input.path : await input.path(); + const fileName = path.split(/[\\/]/).at(-1) || path; + const connection = readPreparedConnection(input.environmentId); + if (!connection) throw new Error("Reconnect to this environment and try again."); + const result = await createAssetUrl({ + environmentId: input.environmentId, + input: { + resource: { + _tag: "media-file", + threadId: input.threadId, + path, + disposition: "attachment", + }, + }, + }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); + if (!url) throw new Error("The environment returned an invalid file URL."); + await downloadMedia(url, fileName); + toastManager.update(toast, { type: "success", title: "Download started" }); + } catch (error) { + toastManager.update( + toast, + stackedThreadToast({ + type: "error", + title: "Could not save file", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }; +} From 28c92937d00a92662e05d0ad86925d888891ee8e Mon Sep 17 00:00:00 2001 From: Thomas <31560900+0xtlt@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:47:36 +0100 Subject: [PATCH 2/3] fix(web): keep the save-file hook compilable and cover Save in chat permissions React Compiler skips hooks that throw inside try/catch, so the download steps move to a plain async function. The chat permissions test now expects Save file, which needs only read access and stays after host revocation. Co-Authored-By: Claude Opus 5.5 --- .../ChatMarkdown.permissions.browser.test.tsx | 6 +- .../web/src/components/files/useSaveFileAs.ts | 85 ++++++++++--------- 2 files changed, 47 insertions(+), 44 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx index 61cf3c140278a..9e35abd413b97 100644 --- a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx @@ -181,15 +181,15 @@ async function setAllowed(allowed: boolean) { }); } -it("removes host actions after revocation while keeping file preview and copying", async () => { +it("removes host actions after revocation while keeping file preview, saving, and copying", async () => { using _setup = setup(); await using screen = await renderMarkdown("[Readme](docs/readme.md)"); await screen.openContextMenu(); - expect(offeredActions()).toEqual(["open", "reveal", "copy-relative", "copy-full"]); + expect(offeredActions()).toEqual(["open", "reveal", "save", "copy-relative", "copy-full"]); await setAllowed(false); state.choose.mockResolvedValue("copy-full"); await screen.openContextMenu(); - expect(offeredActions()).toEqual(["copy-relative", "copy-full"]); + expect(offeredActions()).toEqual(["save", "copy-relative", "copy-full"]); await vi.waitFor(() => expect(state.copy).toHaveBeenCalledWith("/work/docs/readme.md")); await act(async () => screen.anchor().click()); expect(state.openFile).toHaveBeenCalledWith(threadRef, "docs/readme.md", undefined); diff --git a/apps/web/src/components/files/useSaveFileAs.ts b/apps/web/src/components/files/useSaveFileAs.ts index 6a513a67af8b7..3830b6eb4f9d7 100644 --- a/apps/web/src/components/files/useSaveFileAs.ts +++ b/apps/web/src/components/files/useSaveFileAs.ts @@ -8,52 +8,55 @@ import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { downloadMedia } from "../media/mediaContent"; import { stackedThreadToast, toastManager } from "../ui/toast"; +interface SaveFileInput { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + /** Absolute host path, or a lookup that finds it. */ + readonly path: string | (() => Promise); +} + +async function downloadHostFile( + createAssetUrl: ReturnType, + input: SaveFileInput, +) { + const path = typeof input.path === "string" ? input.path : await input.path(); + const connection = readPreparedConnection(input.environmentId); + if (!connection) throw new Error("Reconnect to this environment and try again."); + const result = await createAssetUrl({ + environmentId: input.environmentId, + input: { + resource: { _tag: "media-file", threadId: input.threadId, path, disposition: "attachment" }, + }, + }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); + if (!url) throw new Error("The environment returned an invalid file URL."); + await downloadMedia(url, path.split(/[\\/]/).at(-1) || path); +} + +function useCreateAssetUrl() { + return useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true }); +} + /** * Copies a host file to the viewing device, wherever the environment runs. * Desktop asks where to save it; browsers follow their download settings. */ export function useSaveFileAs() { - const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { - reportFailure: false, - refresh: true, - }); - return async (input: { - readonly environmentId: EnvironmentId; - readonly threadId: ThreadId; - /** Absolute host path, or a lookup that finds it. */ - readonly path: string | (() => Promise); - }) => { + const createAssetUrl = useCreateAssetUrl(); + return (input: SaveFileInput) => { const toast = toastManager.add({ type: "loading", title: "Preparing file…" }); - try { - const path = typeof input.path === "string" ? input.path : await input.path(); - const fileName = path.split(/[\\/]/).at(-1) || path; - const connection = readPreparedConnection(input.environmentId); - if (!connection) throw new Error("Reconnect to this environment and try again."); - const result = await createAssetUrl({ - environmentId: input.environmentId, - input: { - resource: { - _tag: "media-file", - threadId: input.threadId, - path, - disposition: "attachment", - }, - }, - }); - if (result._tag === "Failure") throw squashAtomCommandFailure(result); - const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); - if (!url) throw new Error("The environment returned an invalid file URL."); - await downloadMedia(url, fileName); - toastManager.update(toast, { type: "success", title: "Download started" }); - } catch (error) { - toastManager.update( - toast, - stackedThreadToast({ - type: "error", - title: "Could not save file", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } + return downloadHostFile(createAssetUrl, input).then( + () => toastManager.update(toast, { type: "success", title: "Download started" }), + (error: unknown) => + toastManager.update( + toast, + stackedThreadToast({ + type: "error", + title: "Could not save file", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ), + ); }; } From 2437902e320a21b6fb7a62000e8e4b618591415d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 21:07:34 +0000 Subject: [PATCH 3/3] fix(web): hide Save file for directory links --- apps/web/src/components/ChatMarkdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 2b14df3f567ad..b23715d85d818 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -891,7 +891,7 @@ function useChatMarkdownState({ } revealLabel={revealInFileManagerLabel} onSave={ - threadRef && canReadFiles && !isAppBundle + threadRef && canReadFiles && !isAppBundle && !/[\\/]$/.test(fileLinkMeta.filePath) ? () => void saveFileAs({ environmentId: threadRef.environmentId,