Skip to content
Open
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
113 changes: 111 additions & 2 deletions apps/web/src/components/ChatMarkdown.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<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 @@ -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<Parameters<typeof ChatMarkdown>[0]> = {}) {
Expand Down Expand Up @@ -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();
Expand Down
90 changes: 78 additions & 12 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -322,6 +323,7 @@ function CodexArtifactTemplateCard(props: {
);
}

const FILE_LOOKUP_TIMEOUT_MS = 2_000;
const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/;

function orderedListGutterStyle(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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<null>((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;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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 = (
Expand Down Expand Up @@ -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={
Expand Down
3 changes: 3 additions & 0 deletions docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading