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