Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 89 additions & 1 deletion apps/web/src/components/ChatMarkdown.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(),
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<string> | undefined>(),
Expand Down Expand Up @@ -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<Parameters<typeof ChatMarkdown>[0]> = {}) {
Expand Down Expand Up @@ -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();
Expand Down
72 changes: 63 additions & 9 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -782,26 +799,63 @@ 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
// in flight.
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 = (
Expand Down
11 changes: 9 additions & 2 deletions apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1391,7 +1396,9 @@ export default function FilePreviewPanel({
</p>
{attemptedPath ? (
<p className="text-muted-foreground">
Attempted path
{remoteOpenState.mode !== "local-exec" && environmentLabel
? `Attempted path on ${environmentLabel}`
: "Attempted path"}
<code className="block break-all font-mono text-foreground select-all">
{attemptedPath}
</code>
Expand Down
7 changes: 5 additions & 2 deletions docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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

Expand Down
Loading