diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index e30b81c5e5f93..aced46edf6f95 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -42,9 +42,31 @@ vi.mock(import("../state/session"), async (original) => { readEnvironmentScope: hasScope as never, }; }); +const machines = vi.hoisted(() => ({ + primaryEnvironmentId: null as string | null, + remoteEnvironmentIds: new Set(), + openInEditor: vi.fn(), +})); vi.mock(import("../remoteOpen"), async (original) => ({ ...(await original()), - useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }) as never, + useRemoteOpenResolution: ((environmentId: string | null) => ({ + state: { + mode: + environmentId !== null && machines.remoteEnvironmentIds.has(environmentId) + ? "remote-unavailable" + : "local-exec", + }, + isResolved: true, + })) as never, +})); +vi.mock(import("../state/environments"), async (original) => ({ + ...(await original()), + usePrimaryEnvironmentId: (() => machines.primaryEnvironmentId) as never, +})); +vi.mock(import("../editorPreferences"), async (original) => ({ + ...(await original()), + useOpenInPreferredEditor: ((environmentId: string | null) => (path: string) => + machines.openInEditor(environmentId, path)) as never, })); const { renderDiagram } = vi.hoisted(() => ({ renderDiagram: vi.fn<(source: string) => Promise | undefined>(), @@ -79,6 +101,9 @@ function setup() { renderDiagram.mockReset(); workspaceSearch.mockReset().mockResolvedValue(AsyncResult.success({ entries: [] })); filesystemMetadata.mockReset().mockResolvedValue(AsyncResult.success({ entries: [null] })); + machines.primaryEnvironmentId = null; + machines.remoteEnvironmentIds.clear(); + machines.openInEditor.mockReset().mockResolvedValue(AsyncResult.success("vscode")); } function renderText(text: string, props: Partial[0]> = {}) { @@ -230,6 +255,69 @@ describe("ChatMarkdown workspace file opens", () => { }); }); +describe("ChatMarkdown remote thread file links", () => { + const threadRef = { + environmentId: EnvironmentId.make("home-server"), + threadId: ThreadId.make("thread"), + }; + const desktop = EnvironmentId.make("desktop"); + + it("opens a path that exists only on this computer in its editor", async () => { + setup(); + machines.primaryEnvironmentId = desktop; + machines.remoteEnvironmentIds.add(threadRef.environmentId); + const desktopFiles = new Set(["/home/me/Desktop/notes.txt", "/srv/shared.md"]); + const files = { + [desktop]: desktopFiles, + [threadRef.environmentId]: new Set(["/srv/shared.md"]), + }; + filesystemMetadata.mockImplementation( + (request: { environmentId: EnvironmentId; input: { paths: string[] } }) => + // A reconnecting local backend never answers; the host's copy still opens. + request.environmentId === desktop && request.input.paths[0] === "/srv/shared.md" + ? new Promise(() => {}) + : Promise.resolve( + AsyncResult.success({ + entries: request.input.paths.map((path) => + files[request.environmentId]?.has(path) ? { kind: "file" } : null, + ), + }), + ), + ); + await using screen = await renderText( + "[Notes](/home/me/Desktop/notes.txt:3), [Shared](/srv/shared.md), [Gone](/srv/gone.md) and [Home](~/notes.txt)", + { cwd: "/srv/project", threadRef }, + ); + using openFile = vi + .spyOn(useRightPanelStore.getState(), "openFile") + .mockImplementation(() => {}); + const [notes, shared, gone, home] = screen.container.querySelectorAll("a"); + + await act(async () => notes!.click()); + await vi.waitFor(() => + expect(machines.openInEditor).toHaveBeenCalledExactlyOnceWith( + desktop, + "/home/me/Desktop/notes.txt:3", + ), + ); + expect(openFile).not.toHaveBeenCalled(); + + await act(async () => shared!.click()); + await vi.waitFor(() => + expect(openFile).toHaveBeenCalledExactlyOnceWith(threadRef, "/srv/shared.md", undefined), + ); + await act(async () => gone!.click()); + await vi.waitFor(() => + expect(openFile).toHaveBeenLastCalledWith(threadRef, "/srv/gone.md", undefined), + ); + // `~` is the host's home, so it never reaches the local lookup. + desktopFiles.add("~/notes.txt"); + await act(async () => home!.click()); + expect(openFile).toHaveBeenLastCalledWith(threadRef, "~/notes.txt", undefined); + expect(machines.openInEditor).toHaveBeenCalledOnce(); + }); +}); + describe("ChatMarkdown headings", () => { it("never gives two headings the same id, even when a suffix matches another heading", async () => { setup(); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 91acb81d17304..10215c61f1a96 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -6,7 +6,7 @@ import { extractMarkdownLinkHrefs, inlineCodeFilePathCandidate, } from "@t3tools/shared/markdownLinks"; -import { isHostPath, resolveWorkspaceFilePath } from "@t3tools/shared/path"; +import { isAbsolutePath, isHostPath, resolveWorkspaceFilePath } from "@t3tools/shared/path"; import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts"; import { @@ -146,6 +146,7 @@ import { cn } from "../lib/utils"; import { resolveRemoteOpenState, useRemoteOpenResolution } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; import { readThreadShell, useProjects } from "../state/entities"; +import { usePrimaryEnvironmentId } from "../state/environments"; import { serverEnvironment } from "../state/server"; import { shellEnvironment } from "../state/shell"; import { assetEnvironment } from "../state/assets"; @@ -511,6 +512,18 @@ function useChatMarkdownState({ const canUseShellActions = canOperateHost && canUseMarkdownFileShellActions(environmentId, remoteOpen.state.mode, remoteOpen.isResolved); + // An agent on another machine can write to this one (over SSH or a shared + // mount), so a path missing on its host may be here instead. + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const primaryOpen = useRemoteOpenResolution(primaryEnvironmentId); + const localEnvironmentId = + primaryEnvironmentId !== environmentId && + remoteOpen.isResolved && + remoteOpen.state.mode !== "local-exec" && + primaryOpen.isResolved && + primaryOpen.state.mode === "local-exec" + ? primaryEnvironmentId + : null; const openMarkdownMedia = ( source: string, resolvedFilePath?: string, @@ -583,6 +596,10 @@ function useChatMarkdownState({ const [preferredEditor] = usePreferredEditor(availableEditors); const preferredEditorMenuLabel = openInEditorMenuLabel(preferredEditor); const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors); + const { availableEditors: localEditors } = useAtomValue( + markdownServerConfigFieldsAtom(localEnvironmentId), + ); + const openInLocalEditor = useOpenInPreferredEditor(localEnvironmentId, localEditors); const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { reportFailure: false, }); @@ -782,6 +799,35 @@ function useChatMarkdownState({ }); return metadata._tag === "Success" && metadata.value.entries[0] === null ? match : null; }; + const isOnlyOnLocalMachine = async (path: string) => { + if (environmentId === null || localEnvironmentId === null) return false; + const input = { paths: [path] }; + // Both start now, but a reconnecting local backend must not hold up a + // file the host has. + const local = getFilesystemMetadata({ environmentId: localEnvironmentId, input }); + const host = await getFilesystemMetadata({ environmentId, input }); + // A null entry also covers an unreadable path, which the host's panel + // could not show either. A failed lookup keeps the panel and its error. + if (host._tag !== "Success" || host.value.entries[0] !== null) return false; + const localResult = await local; + return localResult._tag === "Success" && (localResult.value.entries[0] ?? null) !== null; + }; + const openOnLocalMachine = (targetPath: string) => + openInLocalEditor(targetPath).then((result) => { + if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; + reportMarkdownActionFailure( + { operation: "open-file-in-editor", target: targetPath }, + result.cause, + ); + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); const openFileInPanel = (fileLinkMeta: MarkdownFileLinkMeta, panelPath: string) => { if (!threadRef) return; // Claimed on every open so a synchronous one supersedes a lookup already @@ -789,19 +835,27 @@ function useChatMarkdownState({ const isLatestLookup = claimWorkspaceBasenameLookup(); const openAt = (path: string) => useRightPanelStore.getState().openFile(threadRef, path, fileLinkMeta.line); - if ( - fileLinkMeta.workspaceLookupPath === null || - !cwd || - (!needsWorkspaceBasenameLookup(panelPath) && - stripRepeatedWorkspacePrefix(panelPath, cwd) === null) - ) { + const needsWorkspaceLookup = + fileLinkMeta.workspaceLookupPath !== null && + !!cwd && + (needsWorkspaceBasenameLookup(panelPath) || + stripRepeatedWorkspacePrefix(panelPath, cwd) !== null); + // `~` names a different home on each machine. + const mayBeLocal = + localEnvironmentId !== null && + fileLinkMeta.workspaceRelativePath === null && + isAbsolutePath(fileLinkMeta.filePath); + if (!needsWorkspaceLookup && !mayBeLocal) { openAt(panelPath); return; } void (async () => { - const match = await findWorkspaceFileMatch(fileLinkMeta); + const match = needsWorkspaceLookup ? await findWorkspaceFileMatch(fileLinkMeta) : null; + const isLocal = + match === null && mayBeLocal && (await isOnlyOnLocalMachine(fileLinkMeta.filePath)); if (!isLatestLookup()) return; - openAt(match ?? panelPath); + if (isLocal) void openOnLocalMachine(fileLinkMeta.targetPath); + else openAt(match ?? panelPath); })(); }; const fileLinkChip = ( diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index a0ba43b931ca8..a6fc935fcfae5 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -66,7 +66,11 @@ import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { buildFileReviewComment } from "~/reviewCommentContext"; import { assetEnvironment } from "~/state/assets"; import { usePreviewAvailable } from "~/browser/previewRuntime"; -import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments"; +import { + useEnvironment, + useEnvironmentHttpBaseUrl, + usePrimaryEnvironmentId, +} from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; import { serverEnvironment } from "~/state/server"; import { useEnvironmentScope } from "~/state/session"; @@ -1013,6 +1017,7 @@ export default function FilePreviewPanel({ const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope); const primaryEnvironmentId = usePrimaryEnvironmentId(); const remoteOpenState = useRemoteOpenState(environmentId); + const environmentLabel = useEnvironment(environmentId)?.label; const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); const previewAvailable = usePreviewAvailable(environmentId); @@ -1391,7 +1396,9 @@ export default function FilePreviewPanel({

{attemptedPath ? (

- Attempted path + {remoteOpenState.mode !== "local-exec" && environmentLabel + ? `Attempted path on ${environmentLabel}` + : "Attempted path"} {attemptedPath} diff --git a/docs/user/composer.md b/docs/user/composer.md index bdb67cae4b31c..747bc74d9e13a 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -329,14 +329,17 @@ touch and hold an image or video thumbnail and choose **Save or share**. On iOS, return to the thumbnail to open this menu after watching a full-screen video. File links refer to the environment's machine, including when you connect remotely. +When the desktop app is connected to another computer and a linked file exists only +on yours, it uses your copy instead. Previews use the original file, even outside the workspace. Moving or deleting it can break the preview, so save a copy if you need to keep it. ## Files outside the workspace Follow an agent's file link to read a report or other file outside the workspace. -These files open read-only. An HTML file outside the workspace cannot load scripts, -styles, or images from neighboring files. +These files open read-only. If the agent runs on another computer and the file is +only on the one you're using, it opens in your editor there. An HTML file outside +the workspace cannot load scripts, styles, or images from neighboring files. ## HTML and PDF files in the file viewer