feat(studio): open conversation file links in the workbench

This commit is contained in:
xiexian committed 2026-10-13 19:52:54 +08:00
1 parent 0876ccc422
commit 472f1a89ca
23 files changed
+467 -18

No files matched your search

+4 -1
View File
@@ -17,7 +17,7 @@ const { uiLanguage } = require("./uilang");
const { installFullScreenKey } = require("./fullscreen");
const { installReload } = require("./reload");
const { externalTarget } = require("./links");
const { reveal, revealWorkspace, revealLogs, openWorkspace } = require("./reveal");
const { reveal, revealWorkspace, revealLogs, openWorkspace, openFile } = require("./reveal");
const { appIcon } = require("./appicon");
const { setUnreadBadge } = require("./badge");
const layout = require("./layout");
@@ -436,6 +436,9 @@ ipcMain.handle("shell:open-workspace", (event, root) =>
ipcMain.handle("shell:reveal", (event, base, rel) =>
fromWindow(event) && client ? reveal(client, shell, String(base), String(rel)) : { code: "", error: "no window" },
);
ipcMain.handle("shell:open-file", (event, base, rel) =>
fromWindow(event) && client ? openFile(client, shell, String(base), String(rel)) : { code: "", error: "no window" },
);
// A dismissed dialog answers with "", which is what the page reads as "they
// said no". Only a failure to write is an error.
+1
View File
@@ -31,6 +31,7 @@ contextBridge.exposeInMainWorld("reasonixHost", {
openExternal: (url) => ipcRenderer.invoke("shell:open-external", String(url)),
// A pane and a path inside its workspace; main asks the kernel where that is.
revealPath: (base, rel) => ipcRenderer.invoke("shell:reveal", String(base), String(rel)),
openFile: (base, rel) => ipcRenderer.invoke("shell:open-file", String(base), String(rel)),
// A project the sidebar lists, which may have no pane at all.
revealWorkspace: (root) => ipcRenderer.invoke("shell:reveal-workspace", String(root)),
revealLogs: () => ipcRenderer.invoke("shell:reveal-logs"),
+21 -1
View File
@@ -5,6 +5,12 @@ const path = require("node:path");
// A pane's base as the hub publishes it. Nothing else may steer a request this
// process sends with the credential, and the unprefixed root is no pane.
const PANE_BASE = /^\/rt\/[A-Za-z0-9_-]+$/;
const DOCUMENT_MEDIA = new Set([
".pdf", ".txt", ".md", ".csv", ".tsv", ".json", ".yaml", ".yml", ".xml", ".rtf",
".docx", ".xlsx", ".pptx", ".odt", ".ods", ".odp",
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tif", ".tiff", ".avif", ".heic",
".mp3", ".wav", ".ogg", ".m4a", ".flac", ".mp4", ".mov", ".webm", ".mkv",
]);
function refused(error, code = "") {
return { code, error };
@@ -110,4 +116,18 @@ async function openWorkspace(client, shell, root, platform = process.platform, f
return why ? refused(why) : null;
}
module.exports = { reveal, revealWorkspace, revealLogs, openWorkspace };
async function openFile(client, shell, base, rel, platform = process.platform, fsApi = fs.promises) {
const found = await locate(client, String(base), String(rel), platform);
if (found.refusal) return found.refusal;
try {
if (!(await fsApi.stat(found.target)).isFile()) return refused("not a regular file");
const ext = (platform === "win32" ? path.win32 : path.posix).extname(found.target).toLowerCase();
if (!DOCUMENT_MEDIA.has(ext)) return refused("only documents and media can be opened");
const why = await shell.openPath(found.target);
return why ? refused(why) : null;
} catch (err) {
return refused(err.message);
}
}
module.exports = { reveal, revealWorkspace, revealLogs, openWorkspace, openFile };
+44
View File
@@ -290,6 +290,50 @@ test("openWorkspace refuses a folder the hub does not list", async () => {
const ROOT = path.resolve(os.tmpdir(), "rx-workspace");
test("file opening refuses scripts, executables and unknown types on every platform", async () => {
const { openFile } = require("../src/reveal.js");
const opened = [];
const shell = { openPath: async (target) => { opened.push(target); return ""; } };
for (const platform of ["darwin", "win32", "linux"]) {
for (const name of ["task.command", "task.sh", "task.bat", "task.cmd", "task.ps1", "task.lnk", "task.jar", "task.desktop", "task.scpt", "task.exe", "task.EXE", "report.pdf.exe", "task", "task.unknown", "page.html", "image.svg"]) {
const target = platform === "win32" ? `C:\\work\\${name}` : `/work/${name}`;
const client = { request: async () => ({ status: 200, body: JSON.stringify({ path: target }) }) };
const why = await openFile(client, shell, "/rt/r1", "report.pdf", platform, { stat: async () => ({ isFile: () => true }) });
assert.ok(why?.error, `${platform}: ${name} must be refused`);
}
}
assert.deepEqual(opened, []);
});
test("file opening accepts kernel-located documents and media on every platform", async () => {
const { openFile } = require("../src/reveal.js");
for (const platform of ["darwin", "win32", "linux"]) {
for (const name of ["report.pdf", "notes.TXT", "report.docx", "table.xlsx", "slides.pptx", "data.csv", "notes.md", "image.PNG", "photo.jpg", "video.mp4", "audio.mp3"]) {
const target = platform === "win32" ? `C:\\work\\${name}` : `/work/${name}`;
const opened = [];
const shell = { openPath: async (p) => { opened.push(p); return ""; } };
const client = { request: async () => ({ status: 200, body: JSON.stringify({ path: target }) }) };
assert.equal(await openFile(client, shell, "/rt/r1", name, platform, { stat: async () => ({ isFile: () => true }) }), null);
assert.deepEqual(opened, [target]);
}
}
});
test("file opening uses only a kernel-located regular file and reports shell failures", async () => {
const { openFile } = require("../src/reveal.js");
const opened = [];
const shell = { openPath: async (target) => { opened.push(target); return "no application"; } };
const client = { request: async () => ({ status: 200, body: JSON.stringify({ path: path.join(ROOT, "report.pdf") }) }) };
assert.deepEqual(await openFile(client, shell, "/rt/r1", "report.pdf", process.platform, { stat: async () => ({ isFile: () => true }) }), { error: "no application", code: "" });
assert.deepEqual(opened, [path.join(ROOT, "report.pdf")]);
opened.length = 0;
assert.ok(await openFile(client, shell, "/rt/r1", "folder", process.platform, { stat: async () => ({ isFile: () => false }) }));
assert.ok(await openFile(client, shell, "https://attacker.test", "report.pdf"));
const refused = { request: async () => ({ status: 400, body: JSON.stringify({ code: "workspace.path_outside_tree", error: "outside" }) }) };
assert.equal((await openFile(refused, shell, "/rt/r1", "../secret.pdf")).code, "workspace.path_outside_tree");
assert.deepEqual(opened, []);
});
test("reveal opens only what the kernel located inside the pane's workspace", async () => {
const rig = await revealRig((url) => {
const rel = url.searchParams.get("path") ?? "";
+1
View File
@@ -513,6 +513,7 @@ export const ACTIONS: UIAction[] = [
{ id: "workbench.files", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.hidden", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.reveal", kind: "shell-native", target: "optional", proof: "interaction" },
{ id: "workbench.open-file", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "workbench.copy-path", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "workbench.add-to-chat", kind: "view", target: "entity", proof: "interaction" },
{ id: "workbench.copy-ref", kind: "shell-native", target: "entity", proof: "interaction" },
@@ -4,6 +4,8 @@
export const EN_WINDOW: Record<string, string> = {
"这台机器的更多操作": "More actions for this machine",
"使用系统默认应用打开": "Open with default application",
"图片加载失败,它可能已被移动、删除或超过大小限制": "The image could not be loaded. It may have been moved, deleted, or exceed the size limit.",
"选择本机会话": "Select local sessions",
"选择会话:{name}": "Select session: {name}",
"已选 {n} 个会话": "{n} sessions selected",
+9
View File
@@ -53,6 +53,8 @@ export interface HostPort {
* root. The shell asks the kernel where that is and selects only the answer;
* null once shown, otherwise why it was not. */
revealPath(base: string, path: string): Promise<Refusal | null>;
opensFiles(): boolean;
openFile(base: string, path: string): Promise<Refusal | null>;
/** Show a project the sidebar lists, which needs no pane. The hub answers
* where it is and refuses any folder it does not list. */
revealWorkspace(root: string): Promise<Refusal | null>;
@@ -161,6 +163,7 @@ interface ElectronBridge {
saveBytes(name: string, bytes: Uint8Array): Promise<string>;
pickFolder(startIn: string): Promise<string>;
revealPath?(base: string, path: string): Promise<Refusal | null>;
openFile?(base: string, path: string): Promise<Refusal | null>;
revealWorkspace?(root: string): Promise<Refusal | null>;
revealLogs?(): Promise<Refusal | null>;
openWorkspace?(root: string): Promise<Refusal | null>;
@@ -237,6 +240,10 @@ class ElectronHost implements HostPort {
revealPath(base: string, path: string) {
return this.api.revealPath?.(base, path) ?? Promise.resolve({ error: "this shell cannot show files" });
}
opensFiles() { return typeof this.api.openFile === "function"; }
openFile(base: string, path: string) {
return this.api.openFile?.(base, path) ?? Promise.resolve({ error: "this shell cannot open files" });
}
revealWorkspace(root: string) {
return this.api.revealWorkspace?.(root) ?? Promise.resolve({ error: "this shell cannot show files" });
}
@@ -327,6 +334,8 @@ class BrowserHost implements HostPort {
revealPath() {
return Promise.resolve({ error: "a browser tab cannot show files" });
}
opensFiles() { return false; }
openFile() { return Promise.resolve({ error: "a browser tab cannot open files" }); }
revealWorkspace() {
return Promise.resolve({ error: "a browser tab cannot show files" });
}
@@ -13,6 +13,15 @@ it("offers reveal only when the shell can show both a pane's file and a project"
expect((await hostOver({ revealPath: async () => null })).revealsFiles()).toBe(false);
});
it("offers opening files only with a native bridge and preserves its refusal", async () => {
expect((await hostOver({})).opensFiles()).toBe(false);
const openFile = vi.fn().mockResolvedValue({ code: "workspace.file_missing", error: "missing" });
const host = await hostOver({ openFile });
expect(host.opensFiles()).toBe(true);
expect(await host.openFile("/rt/r1", "report.pdf")).toEqual({ code: "workspace.file_missing", error: "missing" });
expect(openFile).toHaveBeenCalledExactlyOnceWith("/rt/r1", "report.pdf");
});
it("offers the logs folder only when the shell can open it, and passes its refusal through", async () => {
expect((await hostOver({})).revealsLogs()).toBe(false);
const refusing = await hostOver({ revealLogs: async () => ({ error: "no such folder" }) });
@@ -3,6 +3,9 @@ import type { ChangeDiff, StageRequest, WorkspaceChange, WorkspaceChanges, Works
import { MockFeedback } from "./mock_feedback";
export class MockWorkspace extends MockFeedback {
opensFiles() { return false; }
async openFile() {}
// Held in a field so a switch is visible to the next read, as the kernel's endpoints keep.
branchState: WorkspaceGit = { repo: true, name: "reasonix", branch: "main", detached: false, added: 2, removed: 1, untracked: 3 };
+2
View File
@@ -435,6 +435,8 @@ export interface AgentPort {
/** Show a workspace entry, "" being the root, in the system file manager.
* Refuses with the kernel's code for anything outside the workspace. */
revealInFileManager(path: string): Promise<void>;
opensFiles(): boolean;
openFile(path: string): Promise<void>;
sessions(): Promise<SessionEntry[]>;
resume(path: string): Promise<void>;
newSession(): Promise<void>;
@@ -1,10 +1,19 @@
import type { ChangeDiff, StageRequest, WorkspaceBranches, WorkspaceChanges, WorkspaceGit } from "./port";
import { SseFeedback } from "./sse_feedback";
import { host } from "./host";
import { HttpError } from "./http_error";
// The workspace's tree facts: what it differs by and which branch git says it
// is on. The reads answer from the tree, so a frontend re-asks rather than
// deriving either from tool events.
export class SseWorkspace extends SseFeedback {
opensFiles() { return host().opensFiles(); }
async openFile(path: string) {
const why = await host().openFile(this.base, path);
if (why) throw new HttpError(0, why.error || "not opened", why, !!(why.code || why.error));
}
changes() {
return this.get<WorkspaceChanges>("/changes");
}
@@ -0,0 +1,14 @@
.workbench-image {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px;
display: grid;
place-items: center;
}
.workbench-image img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
+16 -4
View File
@@ -1,9 +1,10 @@
import { isValidElement, memo, useEffect, useState, type ReactNode } from "react";
import { isValidElement, memo, useEffect, useState, type ReactNode, type MouseEvent, type KeyboardEvent } from "react";
import ReactMarkdown from "react-markdown";
import { CodeBlock } from "./CodeBlock";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { remarkCodeSpanLink, remarkTrimAutolink } from "./autolink";
import { remarkFileLinks } from "./filelinks";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { useRevealed } from "./reveal";
@@ -155,13 +156,15 @@ function textOf(node: ReactNode): string {
return "";
}
/** How a document shown from the workspace reads its own references: a link to
* another file there, and an image stored beside it. Absent for a reply. */
/** File navigation for a workspace document or a conversation. */
export interface LocalRefs {
/** An action for href, or null when it is not a file in the workspace. */
link(href: string): (() => void) | null;
/** Where to load src from, or null to leave it as written. */
image(src: string): string | null;
path?(href: string): string | null;
title?(href: string): string;
menu?(event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>, href: string): void;
}
// live is the tail of a message still arriving; tail alone is only the last
@@ -171,7 +174,7 @@ const Block = memo(function Block({ src, math, emoji, code, tail, live, local, i
const body = normalizeMath(tail ? balanceFences(src) : src);
return (
<ReactMarkdown
remarkPlugins={(emoji ? [...BASE_REMARK, emoji] : BASE_REMARK) as never}
remarkPlugins={([...BASE_REMARK, [remarkFileLinks, local?.path], ...(emoji ? [emoji] : [])]) as never}
rehypePlugins={([...BASE_REHYPE, math, code].filter(Boolean)) as never}
components={{
// Every link here comes from model output; a webview navigating away
@@ -182,6 +185,15 @@ const Block = memo(function Block({ src, math, emoji, code, tail, live, local, i
return (
<a
href={href}
title={local?.title?.(href!)}
data-action-click="workbench.file" data-target={local?.path?.(href!) ?? href}
data-action-contextmenu={local?.menu ? "workbench.menu" : undefined}
data-action-keydown={local?.menu ? "workbench.menu" : undefined}
onContextMenu={(e) => local?.menu?.(e, href!)}
onKeyDown={(e) => {
if (e.key !== "ContextMenu" && !(e.key === "F10" && e.shiftKey)) return;
local?.menu?.(e, href!);
}}
onClick={(e) => {
e.preventDefault();
open();
+6 -1
View File
@@ -57,6 +57,7 @@ import { LiveWork, useLiveWork } from "../state/foldpref";
import { loadChangedFilesPref, useShowsChangedFiles } from "../state/changedfiles";
import { loadFoldWorkPref, useFoldsTurnWork } from "../state/foldwork";
import { ChangedFilesPill } from "./ChangedFilesPill";
import { useConversationFiles } from "./useConversationFiles";
export type { PaneReport };
@@ -107,6 +108,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
const fail = useCallback((e: unknown) => {
dispatch({ kind: "__error", text: reason(e) } as never);
}, []);
const conversationFiles = useConversationFiles(port, status?.workspaceRoot ?? rt.root, !!rt.host, visible && tab === "flow", revealBrowser, fail);
const compact = useCompactNow(port, running || s.items.some((item) => item.t === "compaction" && !item.done), fail);
// Elapsed is a clock reading and belongs on the tick. Throughput is not: it
// follows the deltas themselves, and expires rather than being re-derived.
@@ -364,6 +366,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
const onRunDetail = useCallback(() => showView("analysis"), [showView]);
const { quote, reply, onResend } = useReplyActions({ port, items: s.items, checkpoints, running, model: status?.label, submit, reloadSession, onSettings, onRunDetail, onError: fail, branchName: `${title} · ${t("分支")}`, onBranched: onOpenSession });
const fileReply = useMemo(() => ({ ...reply, files: conversationFiles.refs }), [reply, conversationFiles.refs]);
// Where the bottom is moves as blocks mount under it, so this only asks the
// transcript to follow again and lets it scroll itself into place.
@@ -427,7 +430,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
<Transcript port={port}
foldWork={foldWork}
turnRunning={running}
reply={reply}
reply={fileReply}
onResend={onResend}
items={s.items}
entering={s.entranceOwed}
@@ -461,6 +464,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
</LiveWork.Provider>
</PaneShown.Provider>
</ReceiptPrefContext.Provider>
{conversationFiles.menuView}
<div className="scroll" data-pane="analysis" hidden={tab !== "analysis"}>
{tab === "analysis" && (
@@ -482,6 +486,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
<div className="scroll" data-pane="browser" hidden={tab === "analysis"} inert={!workbench}>
<WorkbenchPanel
port={port}
fileRequest={conversationFiles.request}
tabs={pages}
wideMode={docked ? { wide: browserSize.size === "wide", onToggle: browserSize.toggle } : undefined}
manual={manualBrowser}
@@ -6,16 +6,17 @@ import { pinToViewport } from "./place";
export type FileMenu = { path: string; folder: boolean; x: number; y: number; anchor: HTMLElement };
function absoluteWorkspacePath(root: string, path: string): string {
export function absoluteWorkspacePath(root: string, path: string): string {
if (!root.startsWith("/") && !/^[a-z]:[\\/]/i.test(root) && !/^\\\\[^\\]+\\[^\\]+/.test(root)) return "";
const separator = root.startsWith("\\\\") || /^[a-z]:\\/i.test(root) ? "\\" : "/";
const base = root.replace(separator === "\\" ? /[\\/]+$/ : /\/+$/, "");
return base + separator + (separator === "\\" ? path.replaceAll("/", "\\") : path);
}
export function WorkbenchFileMenu({ menu, root, revealable, platform, onReveal, onAddToChat, onClose }: {
export function WorkbenchFileMenu({ menu, root, revealable, platform, onReveal, onOpen, onAddToChat, onClose }: {
menu: FileMenu; root: string; revealable: boolean; platform: string;
onReveal: (path: string) => void; onAddToChat?: (reference: string) => void; onClose: () => void;
onOpen?: (path: string) => void;
}) {
const [state, setState] = useState<"idle" | "copying" | "failed">("idle");
const live = useRef(false);
@@ -100,6 +101,10 @@ export function WorkbenchFileMenu({ menu, root, revealable, platform, onReveal,
onClick={() => { close(); onAddToChat(reference); }}
>{t("加入对话")}</button>
)}
{onOpen && !menu.folder && (
<button role="menuitem" data-action="workbench.open-file" data-target={menu.path}
onClick={() => { close(); onOpen(menu.path); }}>{t("使用系统默认应用打开")}</button>
)}
{state === "failed" && <div role="alert">{t("复制失败,请重试")}</div>}
</div>, document.body,
);
@@ -31,6 +31,7 @@ import { useHtmlPreview } from "./useHtmlPreview";
import { setShowsHiddenFiles, showsHiddenFiles } from "../state/prefs";
import { useCommitCard } from "./CommitCard";
import { BrowserWideToggle } from "./BrowserWideToggle";
import { WorkspaceImage, isWorkspaceImage } from "./WorkspaceImage";
// The editor and its grammars load with the first file opened, not with Studio.
const CodeEditor = lazy(() => import("./CodeEditor"));
@@ -117,6 +118,7 @@ export function WorkbenchPanel({
onExternal,
onAddToChat,
wideMode,
fileRequest,
}: {
port: AgentPort;
tabs: BrowserTab[];
@@ -143,6 +145,7 @@ export function WorkbenchPanel({
onExternal: (url: string) => void;
onAddToChat?: (reference: string) => void;
wideMode?: { wide: boolean; onToggle: () => void };
fileRequest?: { path: string };
}) {
const preview = useHtmlPreview(port, allTabs, remote);
const tabs = preview.tabs;
@@ -263,7 +266,8 @@ export function WorkbenchPanel({
const dropBlankStart = useCallback(() => setBrowsers((open) => open.filter((id) => !!visited.current[id])), []);
const agentNow = useRef(agentTarget);
agentNow.current = agentTarget;
useColumnOpening({ manual, browserAsk, filesAsk: revealChanges, port, agentNow, dropBlankStart, pick, follow, current, setFailed, setBrowsers, manualSelection: selected.startsWith("manual:") ? selected : `manual:${browsers[0] ?? "b0"}` });
const filesAsk = useMemo(() => ({ revealChanges, fileRequest }), [revealChanges, fileRequest]);
useColumnOpening({ manual, browserAsk, filesAsk, port, agentNow, dropBlankStart, pick, follow, current, setFailed, setBrowsers, manualSelection: selected.startsWith("manual:") ? selected : `manual:${browsers[0] ?? "b0"}` });
// A page that has just appeared is the one somebody wants to see, whoever
// opened it: the agent's popup, or a link the person followed, which opens
// beside the agent's tab without becoming the one it acts on.
@@ -327,7 +331,7 @@ export function WorkbenchPanel({
const previewTab = previewable ? preview.tabFor(filePath) : undefined;
const { open: openPreview, sync: syncPreview } = preview;
const { file, draft, diff, busy, error: fileFailed, setDraft, save } = useWorkbenchFiles({
port, workspaceRoot, path: filePath, openPaths: openFiles, shown, refresh: JSON.stringify([changeKey, glance, running, wrote]),
port, workspaceRoot, path: isWorkspaceImage(filePath) ? "" : filePath, openPaths: openFiles, shown, refresh: JSON.stringify([changeKey, glance, running, wrote]),
poll: (readable && mode === "read") || (previewable && mode === "preview"),
});
const revision = file?.revision;
@@ -355,7 +359,7 @@ export function WorkbenchPanel({
}, [activeKey, surfaces.length]);
// Picking a file is asking to read it. Docked, the list and the file share one
// column, so the list steps aside; side by side it stays where it is.
const openFile = (path: string) => {
const openFile = useCallback((path: string) => {
setOpenFiles((v) => (v.includes(path) ? v : [...v, path]));
setDismissed((v) => {
const n = new Set(v);
@@ -363,7 +367,8 @@ export function WorkbenchPanel({
return n;
});
pick(`file:${path}`);
};
}, [pick]);
useEffect(() => { if (fileRequest) openFile(fileRequest.path); }, [fileRequest, openFile]);
// A rendered document's own links open beside it and its images load from the
// tree. Held stable per document, so typing re-renders the text alone.
const openRef = useRef(openFile);
@@ -547,7 +552,7 @@ export function WorkbenchPanel({
showTabs={false}
/>
)}
{active?.kind === "file" && (
{active?.kind === "file" && (isWorkspaceImage(active.path) ? <WorkspaceImage key={active.path} port={port} path={active.path} /> : (
<>
<WorkbenchFileBar
path={active.path}
@@ -555,7 +560,7 @@ export function WorkbenchPanel({
onMode={setMode}
readable={readable}
previewable={previewable}
canSave={!busy && !!file && draft !== file.content}
canSave={!busy && file?.path === active.path && draft !== file.content}
onSave={() => void save()}
/>
{(fileFailed || failed) && !(mode === "preview" && previewTab) && (
@@ -592,7 +597,7 @@ export function WorkbenchPanel({
</Suspense>
) : null}
</>
)}
))}
</div>
</main>
<aside className="workbench-explorer">
@@ -0,0 +1,18 @@
import { useState } from "react";
import type { AgentPort } from "../port/port";
import { t } from "../i18n";
import "../styles/workspace-image.css";
export const isWorkspaceImage = (path: string) => /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i.test(path);
export function WorkspaceImage({ port, path }: { port: AgentPort; path: string }) {
const [state, setState] = useState<"loading" | "loaded" | "failed">("loading");
return <>
<div className="workbench-filebar"><strong title={path}>{path}</strong></div>
<div className="workbench-image" aria-busy={state === "loading"}>
{state === "loading" && <div className="workbench-empty">{t("正在读取…")}</div>}
{state === "failed" ? <div className="workbench-error" role="alert">{t("图片加载失败,它可能已被移动、删除或超过大小限制")}</div>
: <img src={port.workspaceImageURL(path)} alt={path} hidden={state !== "loaded"} onLoad={() => setState("loaded")} onError={() => setState("failed")} />}
</div>
</>;
}
@@ -15,6 +15,7 @@ import { useShown } from "../shown";
import { ReplyMenu } from "./ReplyMenu";
import { useDismiss } from "../dismiss";
import { pinToViewport } from "../place";
import type { LocalRefs } from "../Markdown";
// Folded, the only thing left of a thought is how much of the turn it was. The
// spec puts both halves there — how long, and how much — because either alone
@@ -61,6 +62,7 @@ export interface Quote {
/** What a finished reply can be acted on with. Regeneration belongs to the
* individual reply and requires a paired checkpoint. */
export interface ReplyActions {
files?: LocalRefs;
onQuote: (text: string, id: string) => void;
canRegenerate: (id: string) => boolean;
hasLaterTurns: (id: string) => boolean;
@@ -163,7 +165,7 @@ export function SayCard({ item, afterAnswer, reply, quiet }: { item: Extract<Ite
e.stopPropagation();
}}>
<Boundary retryKey={item.text} fallback={<div className="md" style={{ whiteSpace: "pre-wrap" }}>{item.text}</div>}>
<LazyMarkdown text={item.text} streaming={!item.done} />
<LazyMarkdown text={item.text} streaming={!item.done} local={reply?.files} />
</Boundary>
</div>
)}
@@ -0,0 +1,105 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { useState } from "react";
import "./testkit";
import { Pane } from "./Pane";
import { MockPort } from "../port/mock";
import type { RuntimeView } from "../port/hub";
import { HttpError } from "../port/http_error";
import { host } from "../port/host";
import { boot, STORAGE } from "../i18n";
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); localStorage.clear(); sessionStorage.clear(); });
async function mount(remote = false, existingBrowser = false) {
localStorage.setItem(STORAGE, "zh"); boot();
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
const port = new MockPort();
if (existingBrowser) vi.spyOn(port, "browserTabs").mockResolvedValue([{ id: "b1", target: "t1", url: "https://example.com", title: "Agent page", active: true }]);
const status = await port.status();
vi.spyOn(port, "status").mockResolvedValue({ ...status, workspaceRoot: "/work" });
vi.spyOn(port, "history").mockResolvedValue([{ role: "user", content: "Create a report" }, { role: "assistant", content: "[Report](/work/docs/report.md) and `/work/docs/missing.md` [Image](images/chart.png)" }]);
const read = vi.spyOn(port, "workspaceFile").mockImplementation(async (path) => {
if (path === "docs/missing.md") throw new HttpError(404, "missing", { code: "workspace.file_missing" }, true);
return { path, content: "# Generated report", revision: "1" };
});
const reveal = vi.spyOn(port, "revealInFileManager").mockResolvedValue();
const openFile = vi.spyOn(port, "openFile").mockResolvedValue();
vi.spyOn(port, "opensFiles").mockReturnValue(true);
vi.spyOn(port, "revealsFiles").mockReturnValue(true);
vi.spyOn(host(), "describe").mockResolvedValue({ ...await host().describe(), platform: "darwin" });
const browser = vi.spyOn(port, "browserOpen");
function Harness() {
const [manualBrowser, onManualBrowser] = useState(false);
return <Pane port={port} rt={{ id: "p1", root: "/work", name: "work", host: remote ? "server" : undefined } as RuntimeView}
title="work" active visible sideHost={null} side={false} onFocus={() => {}} onReport={() => {}} onSessionChanged={() => {}}
pulse={0} findPulse={0} onSettings={() => {}} needsProject={false} onOpenProject={() => {}} onKeepHere={() => {}}
theme="dark" dockW={560} dockMax={880} onDockW={() => {}} manualBrowser={manualBrowser} onManualBrowser={onManualBrowser} />;
}
await act(async () => { render(<Harness />); });
await screen.findByText("Report");
return { port, read, reveal, browser, openFile };
}
it("opens a restored reply's file in the right column and reuses its tab on repeated clicks", async () => {
const { read, browser } = await mount();
fireEvent.click(screen.getByText("Report"));
await screen.findByRole("heading", { name: "Generated report" });
expect(read).toHaveBeenCalledWith("docs/report.md");
expect(document.querySelector('[data-pane="browser"]')!.hasAttribute("inert")).toBe(false);
fireEvent.click(screen.getByText("Report"));
expect(screen.getAllByRole("tab", { name: "report.md" })).toHaveLength(1);
expect(browser).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("/work/docs/missing.md"));
await screen.findByText("找不到该文件,它可能已被移动或删除");
});
it("offers file actions on right-click and restores focus to the link", async () => {
const { reveal } = await mount();
fireEvent.contextMenu(screen.getByText("Report"));
fireEvent.click(await screen.findByRole("menuitem", { name: "在访达中显示" }));
await waitFor(() => expect(reveal).toHaveBeenCalledExactlyOnceWith("docs/report.md"));
expect(screen.queryByRole("menu")).toBeNull();
expect(document.activeElement).toBe(screen.getByText("Report"));
fireEvent.keyDown(screen.getByText("Report"), { key: "F10", shiftKey: true });
expect(screen.getByRole("menuitem", { name: "复制路径" })).toBeTruthy();
fireEvent.keyDown(window, { key: "Escape" });
expect(screen.queryByRole("menu")).toBeNull();
});
it("keeps remote paths out of the local file manager", async () => {
const { reveal } = await mount(true);
fireEvent.contextMenu(screen.getByText("Report"));
expect(await screen.findByRole("menuitem", { name: "复制路径" })).toBeTruthy();
expect(screen.queryByRole("menuitem", { name: "在访达中显示" })).toBeNull();
expect(reveal).not.toHaveBeenCalled();
expect(screen.queryByRole("menuitem", { name: "使用系统默认应用打开" })).toBeNull();
});
it("opens the selected file in its default application only on request", async () => {
const { openFile } = await mount();
expect(openFile).not.toHaveBeenCalled();
fireEvent.contextMenu(screen.getByText("Report"));
fireEvent.click(await screen.findByRole("menuitem", { name: "使用系统默认应用打开" }));
expect(openFile).toHaveBeenCalledExactlyOnceWith("docs/report.md");
});
it("previews images without attempting to edit their binary content", async () => {
const { read } = await mount();
fireEvent.click(screen.getByText("Image"));
const image = await screen.findByAltText("images/chart.png");
expect(image.getAttribute("src")).toBe("/fixture/images/chart.png");
expect(read).not.toHaveBeenCalledWith("images/chart.png");
fireEvent.load(image);
expect(image.hasAttribute("hidden")).toBe(false);
fireEvent.error(image);
expect(screen.getByRole("alert").textContent).toContain("图片加载失败");
});
it("keeps a conversation file selected when the column already has an agent browser", async () => {
await mount(false, true);
fireEvent.click(screen.getByText("Report"));
await screen.findByRole("heading", { name: "Generated report" });
expect(screen.getByRole("tab", { name: "report.md" }).getAttribute("aria-selected")).toBe("true");
});
@@ -0,0 +1,60 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { Markdown } from "./Markdown";
import { conversationFile } from "./filelinks";
afterEach(cleanup);
it.each([
["docs/report.md", "docs/report.md"], ["./docs/report.md", "docs/report.md"],
["/work/docs/report.md", "docs/report.md"], ["file:///work/docs/report%20one.md", "docs/report one.md"],
["docs/report.md#heading", "docs/report.md"], ["../secret.txt", null],
["/workspace/report.md", null], ["https://example.com/report.md", null],
["javascript:alert(1)", null], ["#heading", null], ["//host/share/report.md", null],
["/work/../secret.txt", null], ["file://host/work/report.md", null],
])("resolves %s inside the conversation's workspace", (href, expected) => {
expect(conversationFile("/work", href)).toBe(expected);
});
it("resolves Windows drive paths without treating web schemes or other drives as files", () => {
expect(conversationFile("C:\\Work", "c:\\work\\docs\\report.md")).toBe("docs/report.md");
expect(conversationFile("C:\\Work", "file:///C:/Work/docs/report.md")).toBe("docs/report.md");
expect(conversationFile("C:\\Work", "D:\\Work\\report.md")).toBeNull();
});
it.each(["fmt.Println", "e.g.", "report.pdf"])("leaves the bare code span %s as code", (value) => {
const path = (href: string) => conversationFile("/work", href);
const local = { path, link: (href: string) => path(href) ? vi.fn() : null, image: () => null };
const { container, getByText } = render(<Markdown local={local} text={`\`${value}\``} />);
expect(getByText(value).tagName).toBe("CODE");
expect(container.querySelector("a")).toBeNull();
});
it.each([
["/work", "./report.pdf", "report.pdf"],
["/work", "docs/report.md", "docs/report.md"],
["C:\\Work", "docs\\report.pdf", "docs/report.pdf"],
])("links the code span %s %s", (root, value, expected) => {
const opened = vi.fn();
const path = (href: string) => conversationFile(root, href);
const local = { path, link: (href: string) => path(href) ? () => opened(path(href)) : null, image: () => null };
const { getByText } = render(<Markdown local={local} text={`\`${value}\``} />);
fireEvent.click(getByText(value));
expect(opened).toHaveBeenCalledWith(expected);
});
it("links file references in prose and code spans while preserving web links, labels and code listings", () => {
const opened = vi.fn(), menu = vi.fn();
const path = (href: string) => conversationFile("/work", href);
const local = { path, link: (href: string) => path(href) ? () => opened(path(href)) : null, image: () => null, menu };
const { container, getByText } = render(<Markdown local={local} text={'[Report](/work/docs/report.md)\n\n`/work/docs/report one.md`\n\nCreated /work/docs/result.txt。\n\n[web](https://example.com)\n\n```sh\n/work/docs/listing.sh\n```'} />);
fireEvent.click(getByText("Report"));
fireEvent.click(getByText("/work/docs/report one.md"));
fireEvent.click(getByText("/work/docs/result.txt"));
expect(opened.mock.calls).toEqual([["docs/report.md"], ["docs/report one.md"], ["docs/result.txt"]]);
expect(getByText("web").getAttribute("target")).toBe("_blank");
expect(container.querySelector("pre a")).toBeNull();
fireEvent.contextMenu(getByText("Report"));
expect(menu).toHaveBeenCalledOnce();
});
+76
View File
@@ -0,0 +1,76 @@
import { docRef } from "./docrefs";
export function conversationFile(root: string, href: string): string | null {
let raw = href.trim();
if (/^file:/i.test(raw)) {
try {
const url = new URL(raw);
if (url.hostname && url.hostname !== "localhost") return null;
raw = url.pathname;
if (/^\/[a-z]:\//i.test(raw)) raw = raw.slice(1);
} catch { return null; }
} else if (/^[a-z][a-z0-9+.-]*:/i.test(raw) && !/^[a-z]:[\\/]/i.test(raw)) return null;
const windows = /^[a-z]:[\\/]/i.test(root);
const base = (windows ? root.replaceAll("\\", "/") : root).replace(/\/+$/, "");
if (windows) raw = raw.replaceAll("\\", "/");
raw = raw.replace(/[?#].*$/, "");
try { raw = decodeURIComponent(raw); } catch { return null; }
if (raw.startsWith("//")) return null;
if (raw.startsWith("/") || /^[a-z]:[\\/]/i.test(raw)) {
const prefix = base + "/";
if (!(windows ? raw.toLowerCase().startsWith(prefix.toLowerCase()) : raw.startsWith(prefix))) return null;
raw = raw.slice(prefix.length);
}
// docRef decodes URLs; encode literal filename characters before resolving.
return docRef("", raw.split("/").map(encodeURIComponent).join("/"));
}
interface Node {
type: string;
url?: string;
value?: string;
children?: Node[];
}
const TOKEN = /[^\s<>"'`()\[\]{},。;!?:]+/g;
const FILE = /(?:^|\/)[^/]+\.[a-z0-9]{1,16}$/i;
export function remarkFileLinks(resolve?: (href: string) => string | null) {
return (tree: Node) => {
if (!resolve) return;
const walk = (node: Node) => {
if (node.type === "link") {
const path = node.url && resolve(node.url);
if (path) node.url = path.split("/").map(encodeURIComponent).join("/");
return;
}
if (!node.children) return;
node.children = node.children.flatMap((child) => {
if (child.type === "inlineCode" && child.value && /[\\/]/.test(child.value) && FILE.test(child.value.replaceAll("\\", "/"))) {
const path = resolve(child.value);
if (path) return [{ type: "link", url: path.split("/").map(encodeURIComponent).join("/"), children: [child] }];
}
if (child.type === "text" && child.value) {
const out: Node[] = [];
let at = 0;
for (const match of child.value.matchAll(TOKEN)) {
const value = match[0].replace(/[.,;!?]+$/, "");
if (!/[\\/]/.test(value) || !FILE.test(value.replaceAll("\\", "/"))) continue;
const path = resolve(value);
if (!path) continue;
if (match.index > at) out.push({ type: "text", value: child.value.slice(at, match.index) });
out.push({ type: "link", url: path.split("/").map(encodeURIComponent).join("/"), children: [{ type: "text", value }] });
at = match.index + value.length;
}
if (out.length) {
if (at < child.value.length) out.push({ type: "text", value: child.value.slice(at) });
return out;
}
}
walk(child);
return [child];
});
};
walk(tree);
};
}
@@ -8,7 +8,7 @@ interface Opening {
manual: boolean;
/** Counts the title-bar Browser presses; a change asks for a browser, not just the column. */
browserAsk: number;
filesAsk: number;
filesAsk: unknown;
port: AgentPort;
agentNow: MutableRefObject<string>;
dropBlankStart: () => void;
@@ -0,0 +1,44 @@
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type KeyboardEvent } from "react";
import type { AgentPort } from "../port/port";
import { host } from "../port/host";
import type { LocalRefs } from "./Markdown";
import { conversationFile } from "./filelinks";
import { WorkbenchFileMenu, absoluteWorkspacePath, type FileMenu } from "./WorkbenchFileMenu";
export function useConversationFiles(port: AgentPort, root: string, remote: boolean, shown: boolean, reveal: () => void, fail: (e: unknown) => void) {
const [request, setRequest] = useState<{ path: string; port: AgentPort; root: string }>();
const [menu, setMenu] = useState<(FileMenu & { id: number; port: AgentPort; root: string }) | null>(null);
const menuID = useRef(0);
const [platform, setPlatform] = useState("");
const close = useCallback(() => setMenu(null), []);
useEffect(close, [port, root, shown, close]);
useEffect(() => { void host().describe().then((h) => setPlatform(h.platform)).catch(() => {}); }, []);
const refs = useMemo<LocalRefs>(() => {
const path = (href: string) => conversationFile(root, href);
const offer = (e: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>, href: string) => {
const file = path(href);
if (!file || document.getSelection()?.isCollapsed === false) return;
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
const keyed = !("clientX" in e) || (e.clientX === 0 && e.clientY === 0);
setMenu({ id: ++menuID.current, port, root, path: file, folder: false, anchor: e.currentTarget,
x: keyed ? rect.left : e.clientX, y: keyed ? rect.bottom : e.clientY });
};
return {
path,
title: (href) => { const file = path(href); return file ? absoluteWorkspacePath(root, file) : ""; },
link: (href) => {
const file = path(href);
return file ? () => { setRequest({ path: file, port, root }); reveal(); } : null;
},
image: () => null,
menu: offer,
};
}, [port, root, reveal]);
const menuView = shown && menu?.port === port && menu.root === root && <WorkbenchFileMenu key={menu.id} menu={menu} root={root} platform={platform}
revealable={!remote && port.revealsFiles()} onClose={close}
onOpen={!remote && port.opensFiles() ? (path) => { void port.openFile(path).catch(fail); } : undefined}
onReveal={(path) => { void port.revealInFileManager(path).catch(fail); }} />;
return { refs, request: request?.port === port && request.root === root ? request : undefined, menuView };
}