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/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 662acde7aad83..b23715d85d818 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -149,6 +149,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"; @@ -554,6 +555,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 isClientHost = useAtomValue(clientHostEnvironmentAtom(environmentId)); const remoteOpen = useRemoteOpenResolution(environmentId); const canUseShellActions = @@ -887,6 +890,16 @@ function useChatMarkdownState({ : undefined } revealLabel={revealInFileManagerLabel} + onSave={ + threadRef && canReadFiles && !isAppBundle && !/[\\/]$/.test(fileLinkMeta.filePath) + ? () => + 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 0fa8eaf4f30f6..6d20ba293a43d 100644 --- a/apps/web/src/components/chat/MarkdownFileLink.tsx +++ b/apps/web/src/components/chat/MarkdownFileLink.tsx @@ -46,6 +46,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"; @@ -71,6 +72,7 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({ onReveal, revealLabel, resolveCopyPaths, + onSave, }: MarkdownFileLinkProps) { const handleOpenInEditor = () => { if (!onOpen) { @@ -258,6 +260,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, @@ -279,6 +282,10 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({ handleRevealInFileManager(); return; } + if (clicked === "save") { + onSave?.(); + return; + } if (clicked === "copy-relative" || clicked === "copy-full") { handleCopy( () => @@ -422,7 +429,8 @@ 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); +} + +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 = useCreateAssetUrl(); + return (input: SaveFileInput) => { + const toast = toastManager.add({ type: "loading", title: "Preparing file…" }); + 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.", + }), + ), + ); + }; +} diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index e2334489302fb..c9a9d51a1ce65 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { type AssetCreateUrlResult, type AssetResource, + AssetWorkspaceAssetNotFoundError, EnvironmentId, ThreadId, WS_METHODS, @@ -237,6 +238,112 @@ describe("asset URL requests", () => { ); }); +describe("local media fallback", () => { + const remote = EnvironmentId.make("fallback-remote"); + const local = EnvironmentId.make("fallback-local"); + const threadId = ThreadId.make("fallback-thread"); + + // The remote never has the file; the desktop's own environment always does. + const makeFallbackHarness = Effect.fn("TestAssetUrl.makeFallbackHarness")(function* () { + const calls: Array = []; + const makeSupervisor = (environmentId: EnvironmentId) => + Effect.gen(function* () { + const session = { + client: { + [WS_METHODS.assetsCreateUrl]: (input: { readonly resource: AssetResource }) => { + calls.push(environmentId); + return environmentId === remote + ? Effect.fail(new AssetWorkspaceAssetNotFoundError({ resource: input.resource })) + : Effect.succeed({ relativeUrl: "/api/assets/local.png", expiresAt: 4e12 }); + }, + }, + } as unknown as RpcSession.RpcSession; + return EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: new PrimaryConnectionTarget({ + environmentId, + label: environmentId, + httpBaseUrl: "https://local.example.test", + wsBaseUrl: "wss://local.example.test", + }), + state: yield* SubscriptionRef.make({ + ...AVAILABLE_CONNECTION_STATE, + desired: true, + network: "online", + phase: "connected", + attempt: 1, + generation: 1, + }), + session: yield* SubscriptionRef.make(Option.some(session)), + prepared: yield* SubscriptionRef.make>(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + }); + const supervisors = new Map([ + [remote, yield* makeSupervisor(remote)], + [local, yield* makeSupervisor(local)], + ]); + const run: EnvironmentRegistry.EnvironmentRegistry["Service"]["run"] = ( + environmentId, + effect, + ) => + Effect.provideService( + effect, + EnvironmentSupervisor.EnvironmentSupervisor, + supervisors.get(environmentId)!, + ); + const followStream: EnvironmentRegistry.EnvironmentRegistry["Service"]["followStream"] = ( + environmentId, + stream, + ) => + Stream.provideService( + stream, + EnvironmentSupervisor.EnvironmentSupervisor, + supervisors.get(environmentId)!, + ); + const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({ + run, + followStream, + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const registry = AtomRegistry.make(); + yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); + const atoms = createAssetEnvironmentAtoms( + Atom.runtime(Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry)), + Atom.make({ environmentId: local, httpBaseUrl: "https://local.example.test" }), + ); + const createUrl = (resource: AssetResource) => + AtomRegistry.getResult( + registry, + atoms.createUrl({ environmentId: remote, input: { resource } }), + ).pipe(Effect.result); + return { calls, createUrl }; + }); + + it.effect("previews a missing remote image from this machine but never saves it", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeFallbackHarness(); + const preview = yield* harness.createUrl({ + _tag: "media-file", + threadId, + path: "/Users/me/shot.png", + }); + expect(preview._tag).toBe("Success"); + + const save = yield* harness.createUrl({ + _tag: "media-file", + threadId, + path: "/Users/me/shot.png", + disposition: "attachment", + }); + expect(save._tag).toBe("Failure"); + expect(harness.calls).toEqual([remote, local, remote]); + }), + ), + ); +}); + const revisionedEnvironmentId = EnvironmentId.make("environment-1"); const revisionedResource: AssetResource = { _tag: "workspace-file", diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index a8b552ef06bbf..24ab0e297011b 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -276,6 +276,8 @@ export function createAssetEnvironmentAtoms( error._tag === "AssetWorkspaceContextNotFoundError" ) || resource._tag !== "media-file" || + // A save must come from the environment the user picked, not a same-path local file. + resource.disposition === "attachment" || !(resource.path.startsWith("/") || isWindowsAbsolutePath(resource.path)) || mediaMimeTypeFromExtension(resource.path.slice(resource.path.lastIndexOf("."))) === null )