From 48fc15a1b013889e0aa1a4fd4e1f85f45b260c07 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 05:14:54 +0000 Subject: [PATCH 1/4] fix(web): open remote file links from the local computer when needed Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .../components/ChatMarkdown.browser.test.tsx | 90 ++++++++++++++++++- apps/web/src/components/ChatMarkdown.tsx | 67 ++++++++++++-- docs/user/composer.md | 3 + 3 files changed, 150 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 91d0abd6e341a..9972f56a8ff95 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -55,9 +55,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>(), @@ -133,6 +155,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]> = {}) { @@ -398,6 +423,69 @@ describe("ChatMarkdown skill chips", () => { }); }); +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 5e3c7ca96fd3b..4598aa2aaedc0 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -7,7 +7,7 @@ import { inlineCodeFilePathCandidate, } from "@t3tools/shared/markdownLinks"; import { taskListMarkerOffset } from "@t3tools/shared/markdownTaskList"; -import { isHostPath, resolveWorkspaceFilePath } from "@t3tools/shared/path"; +import { isAbsolutePath, isHostPath, resolveWorkspaceFilePath } from "@t3tools/shared/path"; import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; import { AuthFilesystemReadScope, @@ -153,6 +153,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"; @@ -554,6 +555,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, @@ -626,6 +639,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, }); @@ -788,25 +805,57 @@ 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 = await getFilesystemMetadata({ environmentId, input }); + if (host._tag !== "Success" || host.value.entries[0] !== null) return false; + const local = await getFilesystemMetadata({ environmentId: localEnvironmentId, input }); + return local._tag === "Success" && (local.value.entries[0] ?? null) !== null; + }; + const openOnLocalMachine = async (targetPath: string) => { + try { + const result = await openInLocalEditor(targetPath); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; + throw squashAtomCommandFailure(result); + } catch (cause) { + reportMarkdownActionFailure({ operation: "open-file-in-editor", target: targetPath }, cause); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file", + description: cause instanceof Error ? cause.message : "An error occurred.", + }), + ); + } + }; const openFileInPanel = (fileLinkMeta: MarkdownFileLinkMeta, panelPath: string) => { if (!threadRef) return; // Every open supersedes pending lookups, including synchronous opens. 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/docs/user/composer.md b/docs/user/composer.md index 84929ef861b04..77b5f58059594 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -383,6 +383,9 @@ 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. +For absolute paths outside the workspace, a file missing there can open in your +local editor if it exists on your computer and the app has a local connection. +Paths starting with `~/` always refer to the environment's home directory. 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. From 20ada6709e9716903236814ea21782ec90ed7f24 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 6 Nov 2026 04:16:43 +0000 Subject: [PATCH 2/4] fix(web): keep chat markdown compiled by React Compiler --- apps/web/src/components/ChatMarkdown.tsx | 25 +++++++++++------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4598aa2aaedc0..68944467484a7 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -814,20 +814,17 @@ function useChatMarkdownState({ return local._tag === "Success" && (local.value.entries[0] ?? null) !== null; }; const openOnLocalMachine = async (targetPath: string) => { - try { - const result = await openInLocalEditor(targetPath); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; - throw squashAtomCommandFailure(result); - } catch (cause) { - reportMarkdownActionFailure({ operation: "open-file-in-editor", target: targetPath }, cause); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file", - description: cause instanceof Error ? cause.message : "An error occurred.", - }), - ); - } + const result = await openInLocalEditor(targetPath); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; + const cause = squashAtomCommandFailure(result); + reportMarkdownActionFailure({ operation: "open-file-in-editor", target: targetPath }, cause); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file", + description: cause instanceof Error ? cause.message : "An error occurred.", + }), + ); }; const openFileInPanel = (fileLinkMeta: MarkdownFileLinkMeta, panelPath: string) => { if (!threadRef) return; From 3d3bda836bed8d1d435bcdbe946cb35cb21cc2a3 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 6 Nov 2026 04:46:25 +0000 Subject: [PATCH 3/4] fix(web): open the host copy when the local file lookup stalls --- .../components/ChatMarkdown.browser.test.tsx | 18 +++++++++++++----- apps/web/src/components/ChatMarkdown.tsx | 9 +++++++-- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 9972f56a8ff95..b1171f6a35e9e 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -4,7 +4,7 @@ import { type ReactElement } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; -import { act, render } from "../../test/render"; +import { act, fakeTimers, render } from "../../test/render"; import "../index.css"; import { filesystemEnvironment } from "../state/filesystem"; @@ -441,8 +441,9 @@ describe("ChatMarkdown remote thread file links", () => { }; 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" + // A reconnecting local backend never answers. + request.environmentId === desktop && + ["/srv/shared.md", "/srv/pending.md"].includes(request.input.paths[0]!) ? new Promise(() => {}) : Promise.resolve( AsyncResult.success({ @@ -453,13 +454,13 @@ describe("ChatMarkdown remote thread file links", () => { ), ); await using screen = await renderText( - "[Notes](/home/me/Desktop/notes.txt:3), [Shared](/srv/shared.md), [Gone](/srv/gone.md) and [Home](~/notes.txt)", + "[Notes](/home/me/Desktop/notes.txt:3), [Shared](/srv/shared.md), [Gone](/srv/gone.md), [Home](~/notes.txt) and [Pending](/srv/pending.md)", { cwd: "/srv/project", threadRef }, ); using openFile = vi .spyOn(useRightPanelStore.getState(), "openFile") .mockImplementation(() => {}); - const [notes, shared, gone, home] = screen.container.querySelectorAll("a"); + const [notes, shared, gone, home, pending] = screen.container.querySelectorAll("a"); await act(async () => notes!.click()); await vi.waitFor(() => @@ -483,6 +484,13 @@ describe("ChatMarkdown remote thread file links", () => { await act(async () => home!.click()); expect(openFile).toHaveBeenLastCalledWith(threadRef, "~/notes.txt", undefined); expect(machines.openInEditor).toHaveBeenCalledOnce(); + + using _timers = fakeTimers(); + await act(async () => pending!.click()); + expect(openFile).toHaveBeenLastCalledWith(threadRef, "~/notes.txt", undefined); + await act(() => vi.advanceTimersByTime(2_000)); + expect(openFile).toHaveBeenLastCalledWith(threadRef, "/srv/pending.md", undefined); + expect(machines.openInEditor).toHaveBeenCalledOnce(); }); }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 68944467484a7..bd019ccf4731d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -323,6 +323,7 @@ function CodexArtifactTemplateCard(props: { ); } +const LOCAL_FILE_LOOKUP_TIMEOUT_MS = 2_000; const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; function orderedListGutterStyle( @@ -810,8 +811,12 @@ function useChatMarkdownState({ const input = { paths: [path] }; const host = await getFilesystemMetadata({ environmentId, input }); if (host._tag !== "Success" || host.value.entries[0] !== null) return false; - const local = await getFilesystemMetadata({ environmentId: localEnvironmentId, input }); - return local._tag === "Success" && (local.value.entries[0] ?? null) !== null; + // A reconnecting local backend never answers; open the host's copy instead. + const local = await Promise.race([ + getFilesystemMetadata({ environmentId: localEnvironmentId, input }), + new Promise((resolve) => setTimeout(resolve, LOCAL_FILE_LOOKUP_TIMEOUT_MS, null)), + ]); + return local?._tag === "Success" && (local.value.entries[0] ?? null) !== null; }; const openOnLocalMachine = async (targetPath: string) => { const result = await openInLocalEditor(targetPath); From e91986f8b28b616a5ac9c0ffd9dc1cf9efd88e9e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 6 Nov 2026 05:07:23 +0000 Subject: [PATCH 4/4] fix(web): local-only media and stalled host lookups open the right copy --- .../components/ChatMarkdown.browser.test.tsx | 29 ++++++++---- apps/web/src/components/ChatMarkdown.tsx | 47 ++++++++++++------- 2 files changed, 52 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index b1171f6a35e9e..99a2b2bf948a5 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -434,16 +434,21 @@ 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 desktopFiles = new Set([ + "/home/me/Desktop/notes.txt", + "/home/me/Desktop/shot.png", + "/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. - request.environmentId === desktop && - ["/srv/shared.md", "/srv/pending.md"].includes(request.input.paths[0]!) + // A reconnecting backend never answers. + (request.environmentId === desktop && + ["/srv/shared.md", "/srv/pending.md"].includes(request.input.paths[0]!)) || + request.input.paths[0] === "/srv/stalled.md" ? new Promise(() => {}) : Promise.resolve( AsyncResult.success({ @@ -454,13 +459,14 @@ describe("ChatMarkdown remote thread file links", () => { ), ); await using screen = await renderText( - "[Notes](/home/me/Desktop/notes.txt:3), [Shared](/srv/shared.md), [Gone](/srv/gone.md), [Home](~/notes.txt) and [Pending](/srv/pending.md)", + "[Notes](/home/me/Desktop/notes.txt:3), [Shot](/home/me/Desktop/shot.png), [Shared](/srv/shared.md), [Gone](/srv/gone.md), [Home](~/notes.txt), [Pending](/srv/pending.md) and [Stalled](/srv/stalled.md)", { cwd: "/srv/project", threadRef }, ); using openFile = vi .spyOn(useRightPanelStore.getState(), "openFile") .mockImplementation(() => {}); - const [notes, shared, gone, home, pending] = screen.container.querySelectorAll("a"); + const [notes, shot, shared, gone, home, pending, stalled] = + screen.container.querySelectorAll("a"); await act(async () => notes!.click()); await vi.waitFor(() => @@ -470,6 +476,10 @@ describe("ChatMarkdown remote thread file links", () => { ), ); expect(openFile).not.toHaveBeenCalled(); + await act(async () => shot!.click()); + await vi.waitFor(() => + expect(machines.openInEditor).toHaveBeenLastCalledWith(desktop, "/home/me/Desktop/shot.png"), + ); await act(async () => shared!.click()); await vi.waitFor(() => @@ -483,14 +493,17 @@ describe("ChatMarkdown remote thread file links", () => { desktopFiles.add("~/notes.txt"); await act(async () => home!.click()); expect(openFile).toHaveBeenLastCalledWith(threadRef, "~/notes.txt", undefined); - expect(machines.openInEditor).toHaveBeenCalledOnce(); + expect(machines.openInEditor).toHaveBeenCalledTimes(2); using _timers = fakeTimers(); await act(async () => pending!.click()); expect(openFile).toHaveBeenLastCalledWith(threadRef, "~/notes.txt", undefined); await act(() => vi.advanceTimersByTime(2_000)); expect(openFile).toHaveBeenLastCalledWith(threadRef, "/srv/pending.md", undefined); - expect(machines.openInEditor).toHaveBeenCalledOnce(); + await act(async () => stalled!.click()); + await act(() => vi.advanceTimersByTime(2_000)); + expect(openFile).toHaveBeenLastCalledWith(threadRef, "/srv/stalled.md", undefined); + expect(machines.openInEditor).toHaveBeenCalledTimes(2); }); }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index bd019ccf4731d..eea28b2730dac 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -323,7 +323,7 @@ function CodexArtifactTemplateCard(props: { ); } -const LOCAL_FILE_LOOKUP_TIMEOUT_MS = 2_000; +const FILE_LOOKUP_TIMEOUT_MS = 2_000; const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; function orderedListGutterStyle( @@ -806,16 +806,23 @@ function useChatMarkdownState({ }); return metadata._tag === "Success" && metadata.value.entries[0] === null ? match : null; }; + // `~` names a different home on each machine. + const mayBeOnLocalMachine = (fileLinkMeta: MarkdownFileLinkMeta) => + localEnvironmentId !== null && + fileLinkMeta.workspaceRelativePath === null && + isAbsolutePath(fileLinkMeta.filePath); const isOnlyOnLocalMachine = async (path: string) => { if (environmentId === null || localEnvironmentId === null) return false; const input = { paths: [path] }; - const host = await getFilesystemMetadata({ environmentId, input }); - if (host._tag !== "Success" || host.value.entries[0] !== null) return false; - // A reconnecting local backend never answers; open the host's copy instead. - const local = await Promise.race([ - getFilesystemMetadata({ environmentId: localEnvironmentId, input }), - new Promise((resolve) => setTimeout(resolve, LOCAL_FILE_LOOKUP_TIMEOUT_MS, null)), - ]); + // A reconnecting backend never answers; open the host's copy instead. + const lookup = (id: EnvironmentId) => + Promise.race([ + getFilesystemMetadata({ environmentId: id, input }), + new Promise((resolve) => setTimeout(resolve, FILE_LOOKUP_TIMEOUT_MS, null)), + ]); + const host = await lookup(environmentId); + if (host?._tag !== "Success" || host.value.entries[0] !== null) return false; + const local = await lookup(localEnvironmentId); return local?._tag === "Success" && (local.value.entries[0] ?? null) !== null; }; const openOnLocalMachine = async (targetPath: string) => { @@ -842,11 +849,7 @@ function useChatMarkdownState({ !!cwd && (needsWorkspaceBasenameLookup(panelPath) || stripRepeatedWorkspacePrefix(panelPath, cwd) !== null); - // `~` names a different home on each machine. - const mayBeLocal = - localEnvironmentId !== null && - fileLinkMeta.workspaceRelativePath === null && - isAbsolutePath(fileLinkMeta.filePath); + const mayBeLocal = mayBeOnLocalMachine(fileLinkMeta); if (!needsWorkspaceLookup && !mayBeLocal) { openAt(panelPath); return; @@ -860,6 +863,20 @@ function useChatMarkdownState({ else openAt(match ?? panelPath); })(); }; + const openMediaLink = (fileLinkMeta: MarkdownFileLinkMeta, mediaPath: string) => { + const isLatestLookup = claimWorkspaceBasenameLookup(); + if (!mayBeOnLocalMachine(fileLinkMeta)) { + openMarkdownMedia(mediaPath, fileLinkMeta.filePath); + return; + } + const requestId = ++mediaRequestId.current; + void (async () => { + const isLocal = await isOnlyOnLocalMachine(fileLinkMeta.filePath); + if (!isLatestLookup() || mediaRequestId.current !== requestId) return; + if (isLocal) void openOnLocalMachine(fileLinkMeta.targetPath); + else openMarkdownMedia(mediaPath, fileLinkMeta.filePath); + })(); + }; const fileLinkChip = ( fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, @@ -913,9 +930,7 @@ function useChatMarkdownState({ } onOpenInPanel={(path) => openFileInPanel(fileLinkMeta, path)} onOpenMedia={ - threadRef && canPreviewMedia - ? () => openMarkdownMedia(mediaPath, fileLinkMeta.filePath) - : undefined + threadRef && canPreviewMedia ? () => openMediaLink(fileLinkMeta, mediaPath) : undefined } openInEditorMenuLabel={preferredEditorMenuLabel} onReveal={