From a41aec81348f241f2434af0d6f0ccc43c56f24c4 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 22 Oct 2026 13:14:36 +0000 Subject: [PATCH 1/3] fix(web): file links in remote threads open on your computer when the file is only there An agent on another machine can write to the desktop's machine (over SSH, for example) and link the path. Clicking that link looked it up on the remote host and failed. Now the desktop app checks both machines and, when the path exists only on its own, opens it there in the preferred editor. The Files panel error also names the machine it checked. Co-Authored-By: Claude Opus 5.5 --- .../components/ChatMarkdown.browser.test.tsx | 80 ++++++++++++++++++- apps/web/src/components/ChatMarkdown.tsx | 69 ++++++++++++++-- .../src/components/files/FilePreviewPanel.tsx | 11 ++- docs/user/composer.md | 7 +- 4 files changed, 154 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index e30b81c5e5f93..370ff03375c3a 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,59 @@ 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 files = { + [desktop]: new Set(["/home/me/Desktop/notes.txt", "/srv/shared.md"]), + [threadRef.environmentId]: new Set(["/srv/shared.md"]), + }; + filesystemMetadata.mockImplementation( + async (request: { environmentId: EnvironmentId; input: { paths: string[] } }) => + AsyncResult.success({ + entries: request.input.paths.map((path) => + files[request.environmentId]?.has(path) ? { kind: "file" } : null, + ), + }), + ); + using openFile = vi + .spyOn(useRightPanelStore.getState(), "openFile") + .mockImplementation(() => {}); + await using screen = await renderText( + "[Notes](/home/me/Desktop/notes.txt:3), [Shared](/srv/shared.md) and [Gone](/srv/gone.md)", + { cwd: "/srv/project", threadRef }, + ); + const [notes, shared, gone] = 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), + ); + 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..78a84bdbd2375 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -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,38 @@ 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] }; + const [host, local] = await Promise.all([ + getFilesystemMetadata({ environmentId, input }), + getFilesystemMetadata({ environmentId: localEnvironmentId, input }), + ]); + // The host keeps the link unless it confirms the path is missing, so its + // panel can still report a permission or connection failure. + return ( + host._tag === "Success" && + host.value.entries[0] === null && + local._tag === "Success" && + (local.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 +838,23 @@ 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); + const mayBeLocal = localEnvironmentId !== null && fileLinkMeta.workspaceRelativePath === null; + 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 From d6809a492ebfe462cfb829e28e5cc3bdfb9a9062 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 22 Oct 2026 13:25:29 +0000 Subject: [PATCH 2/3] fix(web): host lookup decides first, and ~ paths stay on the host A reconnecting local backend no longer holds up a file the host has, and a ~/ link never falls back, since ~ is a different home on each machine. Co-Authored-By: Claude Opus 5.5 --- .../components/ChatMarkdown.browser.test.tsx | 31 ++++++++++++------- apps/web/src/components/ChatMarkdown.tsx | 29 ++++++++--------- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 370ff03375c3a..bb47d9033f2db 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -271,21 +271,26 @@ describe("ChatMarkdown remote thread file links", () => { [threadRef.environmentId]: new Set(["/srv/shared.md"]), }; filesystemMetadata.mockImplementation( - async (request: { environmentId: EnvironmentId; input: { paths: string[] } }) => - AsyncResult.success({ - entries: request.input.paths.map((path) => - files[request.environmentId]?.has(path) ? { kind: "file" } : null, - ), - }), + (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, + ), + }), + ), ); - using openFile = vi - .spyOn(useRightPanelStore.getState(), "openFile") - .mockImplementation(() => {}); await using screen = await renderText( - "[Notes](/home/me/Desktop/notes.txt:3), [Shared](/srv/shared.md) and [Gone](/srv/gone.md)", + "[Notes](/home/me/Desktop/notes.txt:3), [Shared](/srv/shared.md), [Gone](/srv/gone.md) and [Home](~/notes.txt)", { cwd: "/srv/project", threadRef }, ); - const [notes, shared, gone] = screen.container.querySelectorAll("a"); + 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(() => @@ -304,6 +309,10 @@ describe("ChatMarkdown remote thread file links", () => { await vi.waitFor(() => expect(openFile).toHaveBeenLastCalledWith(threadRef, "/srv/gone.md", undefined), ); + // `~` is the host's home, so it never reaches the local lookup. + files[desktop].add("~/notes.txt"); + await act(async () => home!.click()); + expect(openFile).toHaveBeenLastCalledWith(threadRef, "~/notes.txt", undefined); expect(machines.openInEditor).toHaveBeenCalledOnce(); }); }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 78a84bdbd2375..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 { @@ -802,18 +802,15 @@ function useChatMarkdownState({ const isOnlyOnLocalMachine = async (path: string) => { if (environmentId === null || localEnvironmentId === null) return false; const input = { paths: [path] }; - const [host, local] = await Promise.all([ - getFilesystemMetadata({ environmentId, input }), - getFilesystemMetadata({ environmentId: localEnvironmentId, input }), - ]); - // The host keeps the link unless it confirms the path is missing, so its - // panel can still report a permission or connection failure. - return ( - host._tag === "Success" && - host.value.entries[0] === null && - local._tag === "Success" && - (local.value.entries[0] ?? null) !== null - ); + // 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) => { @@ -843,7 +840,11 @@ function useChatMarkdownState({ !!cwd && (needsWorkspaceBasenameLookup(panelPath) || stripRepeatedWorkspacePrefix(panelPath, cwd) !== null); - const mayBeLocal = localEnvironmentId !== null && fileLinkMeta.workspaceRelativePath === null; + // `~` names a different home on each machine. + const mayBeLocal = + localEnvironmentId !== null && + fileLinkMeta.workspaceRelativePath === null && + isAbsolutePath(fileLinkMeta.filePath); if (!needsWorkspaceLookup && !mayBeLocal) { openAt(panelPath); return; From 08fe1e433ff86d3ff7de9a5696ad8afd8cb36aad Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 22 Oct 2026 13:28:48 +0000 Subject: [PATCH 3/3] test(web): keep the desktop file set typed Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatMarkdown.browser.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index bb47d9033f2db..aced46edf6f95 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -266,8 +266,9 @@ describe("ChatMarkdown remote thread file links", () => { setup(); machines.primaryEnvironmentId = desktop; machines.remoteEnvironmentIds.add(threadRef.environmentId); + const desktopFiles = new Set(["/home/me/Desktop/notes.txt", "/srv/shared.md"]); const files = { - [desktop]: new Set(["/home/me/Desktop/notes.txt", "/srv/shared.md"]), + [desktop]: desktopFiles, [threadRef.environmentId]: new Set(["/srv/shared.md"]), }; filesystemMetadata.mockImplementation( @@ -310,7 +311,7 @@ describe("ChatMarkdown remote thread file links", () => { expect(openFile).toHaveBeenLastCalledWith(threadRef, "/srv/gone.md", undefined), ); // `~` is the host's home, so it never reaches the local lookup. - files[desktop].add("~/notes.txt"); + desktopFiles.add("~/notes.txt"); await act(async () => home!.click()); expect(openFile).toHaveBeenLastCalledWith(threadRef, "~/notes.txt", undefined); expect(machines.openInEditor).toHaveBeenCalledOnce();