diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 91d0abd6e341a..99a2b2bf948a5 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"; @@ -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,90 @@ 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", + "/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 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({ + 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), [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, shot, shared, gone, home, pending, stalled] = + 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 () => shot!.click()); + await vi.waitFor(() => + expect(machines.openInEditor).toHaveBeenLastCalledWith(desktop, "/home/me/Desktop/shot.png"), + ); + + 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).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); + await act(async () => stalled!.click()); + await act(() => vi.advanceTimersByTime(2_000)); + expect(openFile).toHaveBeenLastCalledWith(threadRef, "/srv/stalled.md", undefined); + expect(machines.openInEditor).toHaveBeenCalledTimes(2); + }); +}); + 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..eea28b2730dac 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"; @@ -322,6 +323,7 @@ function CodexArtifactTemplateCard(props: { ); } +const FILE_LOOKUP_TIMEOUT_MS = 2_000; const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; function orderedListGutterStyle( @@ -554,6 +556,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 +640,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 +806,75 @@ 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] }; + // 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) => { + 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; // 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); + const mayBeLocal = mayBeOnLocalMachine(fileLinkMeta); + 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 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 = ( @@ -862,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={ 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.