Merge pull request #14031 from SivanCola/feature/file-delivery-preview

Add file deliveries and document previews / 新增文件交付卡片与文档预览
This commit is contained in:
YHH authored and GitHub committed 2026-10-20 14:49:11 -07:00
commit 366f3e69f0
129 files changed
+3659 -304

No files matched your search

+1
View File
@@ -30,3 +30,4 @@ Makefile text eol=lf
.githooks/** text eol=lf
benchmarks/**/*.sh text eol=lf
internal/runtime/agent/testdata/perseveration/** -text
desktop/frontend-next/perf/media/delivery.pdf binary
@@ -0,0 +1,81 @@
"use strict";
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const { app } = require("electron");
const { checker, scriptedModel, listen, seedHome, settledWindow, until } = require("./livekit");
const { pdf } = require("../../frontend-next/perf/document-fixtures.cjs");
const { check, failures } = checker();
async function main() {
const workspace = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "rx-documents-")));
fs.writeFileSync(path.join(workspace, "方案.md"), "# Delivery report\n\nThe file opens in the workbench.");
fs.writeFileSync(path.join(workspace, "table.csv"), 'id,value\n001,"hello\nworld"\n002,=SUM(A1:A2)');
fs.writeFileSync(path.join(workspace, "page.html"), '<h1>Static document</h1><script>parent.__unsafe=true</script>');
fs.writeFileSync(path.join(workspace, "report.pdf"), pdf());
let issued = false;
const provider = scriptedModel(() => {
if (issued) return null;
issued = true;
return { name: "present_files", arguments: { files: [
{ path: "方案.md", description: "Delivery report" },
{ path: "table.csv", description: "Data" },
{ path: "page.html", description: "Static document" },
{ path: "report.pdf", description: "PDF document" },
] } };
});
const model = await listen(provider.handler);
const home = fs.mkdtempSync(path.join(os.tmpdir(), "rx-document-home-"));
process.env.REASONIX_HOME = home;
seedHome(home, model.url, workspace);
const configPath = path.join(home, "config.toml");
fs.writeFileSync(configPath, fs.readFileSync(configPath, "utf8").replace("[desktop]", '[desktop]\nlanguage = "zh"') + "\n[tools]\npresent_files = true\n");
const { current } = require("../src/main.js");
const win = await settledWindow();
win.webContents.session.webRequest.onBeforeRequest((details, callback) => {
const url = new URL(details.url);
callback({ cancel: ["http:", "https:"].includes(url.protocol) && url.hostname !== "127.0.0.1" && url.hostname !== "localhost" });
});
const { client } = current();
const runtimes = await until("workspace runtime", async () => {
const rows = await client.json("GET", "/runtimes");
return rows?.length ? rows : null;
});
const base = runtimes[0].base;
const js = (code) => win.webContents.executeJavaScript(code);
await until("visible application", () => js("!document.getElementById('boot') && !!document.querySelector('.compose')"));
await client.request("POST", `${base}/submit`, { input: "Present the four existing files: 方案.md, table.csv, page.html, report.pdf." });
await until("delivery cards", () => js("document.querySelectorAll('.delivery-open').length === 4"), 60000);
check("real tool execution presents four files", true);
await js("document.querySelector('.delivery-open').click()");
await until("rendered Markdown", () => js("document.querySelector('.workbench-read h1')?.textContent === 'Delivery report'"));
check("card opens the actual workspace document", true);
const history = await client.json("GET", `${base}/history`);
check("history includes confirmed file deliveries", history.some((row) => row.deliveries?.length === 4));
await js("[...document.querySelectorAll('.delivery-open')].find(b => b.textContent.includes('table.csv')).click()");
await until("CSV table from raw host bytes", () => js("document.querySelector('.document-table')?.textContent.includes('001')"));
check("packaged CSV worker renders literal cells", await js("document.querySelector('.document-table').textContent.includes('=SUM(A1:A2)')"));
await js("[...document.querySelectorAll('.delivery-open')].find(b => b.textContent.includes('page.html')).click()");
await until("static HTML frame", () => js("!!document.querySelector('iframe[title=\"HTML 静态预览\"]')"));
check("static HTML keeps scripts disabled", await js("!document.querySelector('iframe[title=\"HTML 静态预览\"]').sandbox.contains('allow-scripts') && !window.__unsafe"));
await js("[...document.querySelectorAll('.delivery-open')].find(b => b.textContent.includes('report.pdf')).click()");
await until("offline PDF worker and text layer", () => js("document.querySelector('.textLayer')?.textContent.includes('Report preview')"));
check("production PDF worker and fonts work without external network", true);
fs.writeFileSync("/tmp/reasonix-native-document-preview.png", (await win.webContents.capturePage()).toPNG());
const loaded = new Promise((resolve) => win.webContents.once("did-finish-load", resolve));
win.webContents.reload();
await loaded;
await until("visible application after reload", () => js("!document.getElementById('boot') && !!document.querySelector('.compose')").catch(() => false));
await until("restored delivery cards", () => js("document.querySelectorAll('.delivery-open').length === 4").catch(() => false));
check("delivery cards survive window reload", true);
await js("document.querySelector('.delivery-open').click()");
await until("reopened report", () => js("document.querySelector('.workbench-read h1')?.textContent === 'Delivery report'"));
check("restored card opens the same file", true);
model.server.close();
app.exit(failures.length ? 1 : 0);
}
app.whenReady().then(main).catch((err) => {
process.stderr.write(`${err.stack || err}\n`);
app.exit(1);
});
+5
View File
@@ -37,10 +37,14 @@
"@mantine/hooks": "9.7.2",
"@noble/curves": "2.4.0",
"codemirror": "^6.0.2",
"dompurify": "^3.4.17",
"katex": "^0.19.0",
"lucide-react": "^1.60.0",
"maplibre-gl": "6.14.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"papaparse": "^5.7.0",
"pdfjs-dist": "^6.4.299",
"react-map-gl": "8.1.3",
"react-markdown": "^10.1.0",
"recharts": "3.10.1",
@@ -57,6 +61,7 @@
"@babel/parser": "^8.0.7",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.8",
"@types/papaparse": "^5.5.2",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
@@ -0,0 +1,23 @@
import type { HistoryMessage } from "../src/port/session";
import pdfText from "./media/delivery.pdf?raw";
export const deliveryFiles = [
{ path: "方案.md", description: "Markdown 方案文档,包含正文、表格和指向其他示例文件的链接。" },
{ path: "数据.csv", description: "CSV 数据示例,含表头、多行字段、引号转义、公式样式和空字段。" },
{ path: "报告.html", description: "静态 HTML 报告页面,可用于对比静态预览与本地交互运行效果。" },
{ path: "示例.pdf", description: "PDF 示例文件,可测试分页、缩放和文字选择等预览能力。" },
];
export const deliveryContents: Record<string, string> = {
"方案.md": "# 文件交付与预览体验\n\n点击聊天中的交付卡片,可在右侧打开对应文件。\n\n| 示例文件 | 体验内容 |\n| --- | --- |\n| 方案.md | Markdown 正文与表格 |\n| 数据.csv | 表格与源码 |\n| 报告.html | 静态 HTML |\n| 示例.pdf | 分页与文字选择 |",
"数据.csv": '编号,内容\n001,"第一行\n第二行"\n002,=SUM(A1:A2)',
"报告.html": "<!doctype html><meta charset='utf-8'><h1>交付文件可以直接阅读</h1><p>文件交付预览示例。</p>",
"示例.pdf": pdfText,
};
export function deliveryHistory(): HistoryMessage[] {
return [
{ role: "user", content: "请展示这四份文件,方便我直接预览。", msgIndex: 0 },
{ role: "assistant", content: "", toolCalls: [{ id: "delivery", name: "present_files" }] },
{ role: "tool", content: "Presented files", toolCallId: "delivery", toolName: "present_files", deliveries: deliveryFiles },
{ role: "assistant", content: "四份文件已准备好。点击卡片在右侧预览,其他操作可从卡片的更多菜单进入。" },
];
}
@@ -0,0 +1,24 @@
"use strict";
function pdf() {
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 500] /Resources << /Font << /F1 7 0 R >> >> /Contents 4 0 R >>",
"BT /F1 24 Tf 40 440 Td (Report preview) Tj ET",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 500] /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >>",
"BT /F1 24 Tf 40 440 Td (Second page) Tj ET",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
];
let data = "%PDF-1.4\n";
const offsets = [0];
objects.forEach((body, i) => {
offsets.push(Buffer.byteLength(data));
if (i === 3 || i === 5) body = `<< /Length ${body.length} >>\nstream\n${body}\nendstream`;
data += `${i + 1} 0 obj\n${body}\nendobj\n`;
});
const xref = Buffer.byteLength(data);
data += `xref\n0 8\n0000000000 65535 f \n`;
data += offsets.slice(1).map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("");
return data + `trailer\n<< /Size 8 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`;
}
module.exports = { pdf };
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { chromium } from "playwright";
import { pdf } from "./document-fixtures.cjs";
const browser = await chromium.launch();
let page;
try {
page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
const errors = [];
const requests = [];
page.on("pageerror", (err) => errors.push(err.message));
page.on("console", (msg) => { if (msg.type() === "error") console.error(msg.text()); });
page.on("request", (req) => requests.push(req.url()));
await page.goto(process.env.PERF_URL ?? "http://127.0.0.1:4399/perf.html?pref=zh", { waitUntil: "networkidle" });
await page.waitForFunction(() => window.__panes?.() > 0);
await page.evaluate(async (pdf) => {
const data = {
"report.pdf": pdf,
"broken.pdf": "%PDF-1.7\ninvalid document",
"table.csv": 'id,value\n001,"hello\nworld"\n002,"=SUM(A1:A2)"\n' + Array.from({ length: 500 }, (_, n) => `${n},row ${n}`).join("\n"),
"page.html": '<style>h1{color:#385b80}</style><h1>Static report</h1><script>parent.__unsafe=true</script><img src="https://preview-test.invalid/leak"><p>Preview stays in the workbench.</p>',
};
window.__previewFiles(Object.fromEntries(Object.entries(data).map(([path, text]) => [
path, { text, kind: path.endsWith("pdf") ? "pdf" : path.endsWith("csv") ? "table" : "html" },
])));
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "Create reports" });
window.__feed({ kind: "tool_dispatch", tool: { id: "present", name: "present_files", readOnly: true } });
window.__feed({ kind: "tool_result", tool: { id: "present", name: "present_files", readOnly: true,
output: "Presented files", deliveries: Object.keys(data).map((path) => ({ path, description: "Preview fixture" })) } });
window.__feed({ kind: "text", text: "Reports are ready." });
window.__feed({ kind: "turn_done" });
}, pdf());
await page.locator(".delivery-open").filter({ hasText: "report.pdf" }).click();
await page.locator(".textLayer").getByText("Report preview", { exact: true }).waitFor();
assert.equal(await page.locator(".document-pdf-page canvas").evaluate((el) => el.width > 0 && el.height > 0), true);
for (const [selector, label] of [
['[data-action="share.card"]', "设备访问"],
['[data-action="transcript.find"]', "在这段对话里查找"],
['[data-action="appearance.theme"]', "切换深色主题"],
['.chrome [data-action="settings.section"][data-value="tools"]', "沙盒与运行边界"],
['[data-action="pane.view"][data-value="analysis"]', "打开运行分析"],
['[data-action="pane.view"][data-value="browser"]', "关闭工作台"],
['[data-action="browser.wide"]', "加宽工作台"],
['[data-action="browser.full"]', "全屏工作台"],
['[data-action="workbench.files"]', "展开文件树"],
['[data-action="workbench.new"]', "新建标签"],
[".acts .copy", "复制回复"],
['[data-action="reply.quote"]', "引用到输入框"],
['[data-action="reply.more"]', "更多"],
]) {
const button = page.locator(selector).last();
await button.hover();
const tip = page.locator(".icon-tooltip");
await tip.filter({ hasText: label }).waitFor();
assert.equal(await button.getAttribute("title"), null);
const box = await tip.boundingBox();
assert.ok(box.x >= 0 && box.y >= 0 && box.x + box.width <= 1440 && box.y + box.height <= 960);
if (selector.includes("browser.wide")) await page.screenshot({ path: "/tmp/reasonix-tooltip-workbench.png" });
if (selector.includes("reply.quote")) await page.screenshot({ path: "/tmp/reasonix-tooltip-reply.png" });
await page.mouse.move(1, 1);
await tip.waitFor({ state: "detached" });
}
await page.locator('[data-action="browser.wide"]').focus();
await page.getByRole("tooltip").filter({ hasText: "加宽工作台" }).waitFor();
await page.keyboard.press("Escape");
assert.equal(await page.locator(".icon-tooltip").count(), 0);
await page.getByRole("button", { name: "下一页", exact: true }).click();
await page.locator(".textLayer").getByText("Second page", { exact: true }).waitFor();
await page.screenshot({ path: "/tmp/reasonix-preview-pdf.png" });
await page.locator(".delivery-open").filter({ hasText: "table.csv" }).click();
await page.getByRole("table", { name: "table.csv" }).waitFor();
assert.equal(await page.locator(".document-table td").filter({ hasText: "=SUM(A1:A2)" }).count(), 1);
assert.ok(await page.locator(".document-table tr").count() < 100);
await page.locator(".document-table").evaluate((el) => { el.scrollTop = 6000; });
await page.waitForFunction(() => document.querySelector(".document-table th")?.textContent !== "1");
await page.screenshot({ path: "/tmp/reasonix-preview-csv.png" });
await page.locator(".delivery-open").filter({ hasText: "page.html" }).click();
const frame = page.frameLocator('iframe[title="HTML 静态预览"]');
await frame.getByRole("heading", { name: "Static report" }).waitFor();
assert.equal(await page.evaluate(() => window.__unsafe), undefined);
assert.equal(requests.some((url) => url.includes("preview-test.invalid")), false);
await page.screenshot({ path: "/tmp/reasonix-preview-html.png" });
await page.locator(".delivery-open").filter({ hasText: "report.pdf" }).click();
await page.locator(".textLayer").getByText("Second page", { exact: true }).waitFor();
assert.equal(await page.locator('[role="tab"][data-target="file:report.pdf"]').count(), 1);
await page.locator(".delivery-open").filter({ hasText: "broken.pdf" }).click();
await page.getByRole("alert").filter({ hasText: "PDF 无法显示" }).waitFor();
assert.equal(await page.locator(".document-pdf-page").isVisible(), false);
assert.deepEqual(errors, []);
console.log("Document previews: PDF text/navigation/tab reuse/corruption, virtual CSV, isolated HTML and delivery clicks passed.");
} catch (error) {
if (page) {
await page.screenshot({ path: "/tmp/reasonix-preview-failure.png" });
console.error(await page.locator(".document-preview").innerText().catch(() => "No preview"));
}
throw error;
} finally {
await browser.close();
}
+16 -1
View File
@@ -56,6 +56,8 @@ import type { UpdateProgress } from "../src/port/version";
import type { ThemePack } from "../src/port/look";
import type { RuntimeView, TreeWindow, TreeWorkspace } from "../src/port/hub";
import type { WireEvent } from "../src/port/wire";
import type { PreviewInfo } from "../src/port/workspace";
import { deliveryContents, deliveryHistory } from "./delivery-data";
// Saves are coalesced (src/ui/trailing.ts), and a fixture answers too fast for
// that to show: coalesced and not look identical at zero latency. Counting the
@@ -96,6 +98,8 @@ function benchPack(): ThemePack | null {
}
class BenchPort extends MockPort {
opensFiles() { return query.has("deliveries") || super.opensFiles(); }
revealsFiles() { return query.has("deliveries") || super.revealsFiles(); }
// ?mcpactions=1 adds a server whose tools are withheld and one that is followed.
constructor() {
super();
@@ -184,6 +188,7 @@ class BenchPort extends MockPort {
}
async workspaceFile(path: string, root?: string) {
if (query.has("deliveries") && path in deliveryContents) return { path, content: deliveryContents[path], revision: "delivery-fixture" };
if (root) {
const content = "# Release plan\n\nThe plan lives in the library folder you granted this project.\n\n- freeze on Friday\n- tag after the checks are green\n- announce the next morning\n";
return { path, content, revision: "r1", root, readOnly: true };
@@ -309,7 +314,7 @@ class BenchPort extends MockPort {
// that read from a file already on disk, so the size of the answer — not the
// stream — is what a switch costs.
async history(): Promise<HistoryMessage[]> {
return storedHistory();
return query.has("deliveries") ? deliveryHistory() : storedHistory();
}
// ?queue= fills the pending queue to the count under measurement. Seeded
@@ -552,6 +557,7 @@ declare global {
// conversations really cost.
__feed: (ev: WireEvent) => void;
__panes: () => number;
__previewFiles: (files: Record<string, { kind: PreviewInfo["kind"]; text: string }>) => void;
// What folding a stored transcript into cards costs on its own, with no
// frame in it: the step between the read answering and the first paint.
__foldCost: () => number;
@@ -568,6 +574,15 @@ declare global {
window.__toast = pushToast;
window.__feed = (ev) => hub.feeds.forEach((p) => p.feed(ev));
window.__panes = () => hub.feeds.size;
window.__previewFiles = (files) => hub.feeds.forEach((port) => {
port.workspacePreviewInfo = async (ref) => ({
ref, kind: files[ref.path].kind, size: new TextEncoder().encode(files[ref.path].text).length,
version: "fixture", limit: 32 << 20, readOnly: false,
});
port.workspacePreviewContent = async (ref) => ({
data: new TextEncoder().encode(files[ref.path].text).buffer, version: "fixture",
});
});
window.__refuse = (method, message) => {
(hub as unknown as Record<string, unknown>)[method] = () => Promise.reject(new Error(message));
};
@@ -0,0 +1,43 @@
%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 500] /Resources << /Font << /F1 7 0 R >> >> /Contents 4 0 R >>
endobj
4 0 obj
<< /Length 45 >>
stream
BT /F1 24 Tf 40 440 Td (Report preview) Tj ET
endstream
endobj
5 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 500] /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >>
endobj
6 0 obj
<< /Length 42 >>
stream
BT /F1 24 Tf 40 440 Td (Second page) Tj ET
endstream
endobj
7 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 8
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000121 00000 n
0000000247 00000 n
0000000342 00000 n
0000000468 00000 n
0000000560 00000 n
trailer
<< /Size 8 /Root 1 0 R >>
startxref
630
%%EOF
+205 -11
View File
@@ -83,12 +83,24 @@ importers:
codemirror:
specifier: ^6.0.2
version: 6.0.2
dompurify:
specifier: ^3.4.17
version: 3.4.17
katex:
specifier: ^0.19.0
version: 0.19.0
lucide-react:
specifier: ^1.60.0
version: 1.60.0(@types/[email protected])([email protected])
maplibre-gl:
specifier: 6.14.0
version: 6.14.0
papaparse:
specifier: ^5.7.0
version: 5.7.0
pdfjs-dist:
specifier: ^6.4.299
version: 6.4.299
react:
specifier: ^19.3.1
version: 19.3.1
@@ -138,6 +150,9 @@ importers:
'@testing-library/user-event':
specifier: ^14.6.8
version: 14.6.8(@testing-library/[email protected])
'@types/papaparse':
specifier: ^5.5.2
version: 5.5.2
'@types/react':
specifier: ^19.3.0
version: 19.3.0
@@ -146,7 +161,7 @@ importers:
version: 19.3.0(@types/[email protected])
'@vitejs/plugin-react':
specifier: ^6.1.2
version: 6.1.2([email protected]([email protected]))
version: 6.1.2([email protected](@types/[email protected])([email protected]))
jsdom:
specifier: ^30.1.3
version: 30.1.3(@noble/[email protected])
@@ -158,10 +173,10 @@ importers:
version: 7.0.2
vite:
specifier: ^8.3.4
version: 8.3.4([email protected])
version: 8.3.4(@types/[email protected])([email protected])
vitest:
specifier: ^5.0.4
version: 5.0.4([email protected](@noble/[email protected]))([email protected]([email protected]))
version: 5.0.4(@types/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected]))
packages:
@@ -625,6 +640,81 @@ packages:
'@marijn/[email protected]':
resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
'@napi-rs/[email protected]':
resolution: {integrity: sha512-Z8hg9BD7ABaZNhdM8O96IrfBaYLM7KzO/YyKA8eU9BDdc5fZJRTWyOhSAyOv59/ari6bOYa+p96h7zN6wbJRDA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-dRtZCi/u1IOZyRjEDCA73PqgWakf5689K2jfv4uPRYGMJvEqtV+Eq7MIQkBJLmdfUPe7AktSiBZRml2pggnjRQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-YwvMgK5NBXwPY1AjZGXfPCG3hp/OFGM0Kw+JZHCOR/FYaeSOftaZRyxaDEGkJQSLEUZZIMNJXKCWgZYSzgWZEA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-LlOsZy8qh+qJq1euEvDZqa9muACS1fL1Ag38c7J+axAnA8NeXazMtqmUyKb4NVyft6DqbU/MesEj3C0bAFnv4Q==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-nvuqhi85GzaFl/6sid4qf7wwjHuGU6gfIuDyywUb/fB+kp6Fp3h8GyIAKss/QPil3zVO2r8g6O9ah+IOUoBrIw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-Bf6+9PoEr9K06P/oyMab7zZOpW90KimbIuNsy/vL0VhZZKsb0B0rCC78cp0jXhK6PIM7v8t6uX1sn7isOZ8KEw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-j7VcZduLSRx60nv/wbdyH+fyB5QriWGuO8GrvMDlFlt7GNUKAw5lHAHpcuuQW3QwLOe+3P+3qryzyT3sW5pCqQ==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-twDb8ZNgoCF8EG6ka+Lk4etU+ea9lv6NiV5ptIf+thz3LgWuHBbt85c+YgL1s05VsHe4WYWK0f+GhR45IuqARg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-mtZbBeNCdGA0MYQxivPmMOp5JMH80LaJLQeEMg7Nne517fvgGb+DWTJTmoE/qUW8YJ4nkiP2vQyr1XVGrH/Ktg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-HgGRuBUkFL8U99ogwUnTz3SmwUu7WKjmj0eOVWRqVZTPPyfWGEIMr+ycb8oDoNZcKM9vSiF1rpy3r+UIqqaKqw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-SEEfblIAnYeS4UkeFPp9jw6ZWuHxaTqumUuksMgnMXmPLW8Ofb21uu7cujhw+lk4E9V3aWgGAE9f8g+CdoAU0Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-PSwhRdvizfuiSDlhE4Ld2jl/JqkrbSFFyZO0ycamE6mBpf1H0uUF3Tm8yQaVDeAwCNNDzxjRfrN22KmWLTDwcw==}
engines: {node: '>= 10'}
'@noble/[email protected]':
resolution: {integrity: sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==}
engines: {node: '>= 20.19.0'}
@@ -840,6 +930,12 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/[email protected]':
resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==}
'@types/[email protected]':
resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==}
'@types/[email protected]':
resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
peerDependencies:
@@ -1204,6 +1300,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
[email protected]:
resolution: {integrity: sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==}
[email protected]:
resolution: {integrity: sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==}
@@ -1514,6 +1613,15 @@ packages:
resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==}
engines: {node: 20 || >=22}
[email protected]:
resolution: {integrity: sha512-UqxOW0FgAfDyb9VmjKb6/BkOzW6yfhf/QNboKtS2NDsscnNRW2CDzlyhbMKBQ1ontmcxuIDqHJ2AVgFR3NI+GA==}
peerDependencies:
'@types/react': '*'
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
[email protected]:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -1691,6 +1799,9 @@ packages:
resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==}
engines: {node: '>=12.20.0'}
[email protected]:
resolution: {integrity: sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==}
[email protected]:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
@@ -1704,6 +1815,10 @@ packages:
resolution: {integrity: sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==}
engines: {node: '>=22.13.0 || >=24'}
[email protected]:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -2021,6 +2136,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
[email protected]:
resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
[email protected]:
resolution: {integrity: sha512-usz3OVQBeh1LxNn0DzU0voLXMgn5CU/WUdpBsY6Ti5afNao+vTkmn4H/NxoXolc10cOqe2+YIG0Hspq2rsl64w==}
engines: {node: '>=22.19.0'}
@@ -2740,6 +2858,54 @@ snapshots:
'@marijn/[email protected]': {}
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optionalDependencies:
'@napi-rs/canvas-android-arm64': 1.0.12
'@napi-rs/canvas-darwin-arm64': 1.0.12
'@napi-rs/canvas-darwin-x64': 1.0.12
'@napi-rs/canvas-linux-arm-gnueabihf': 1.0.12
'@napi-rs/canvas-linux-arm64-gnu': 1.0.12
'@napi-rs/canvas-linux-arm64-musl': 1.0.12
'@napi-rs/canvas-linux-riscv64-gnu': 1.0.12
'@napi-rs/canvas-linux-x64-gnu': 1.0.12
'@napi-rs/canvas-linux-x64-musl': 1.0.12
'@napi-rs/canvas-win32-arm64-msvc': 1.0.12
'@napi-rs/canvas-win32-x64-msvc': 1.0.12
optional: true
'@noble/[email protected]':
dependencies:
'@noble/hashes': 2.4.0
@@ -2895,6 +3061,14 @@ snapshots:
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
undici-types: 8.9.0
'@types/[email protected]':
dependencies:
'@types/node': 26.6.5
'@types/[email protected](@types/[email protected])':
dependencies:
'@types/react': 19.3.0
@@ -2986,19 +3160,19 @@ snapshots:
optionalDependencies:
maplibre-gl: 6.14.0
'@vitejs/[email protected]([email protected]([email protected]))':
'@vitejs/[email protected]([email protected](@types/[email protected])([email protected]))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.3.4([email protected])
vite: 8.3.4(@types/[email protected])([email protected])
'@vitest/[email protected]([email protected]([email protected]))':
'@vitest/[email protected]([email protected](@types/[email protected])([email protected]))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.4
estree-walker: 3.0.3
magic-string: 1.4.3
optionalDependencies:
vite: 8.3.4([email protected])
vite: 8.3.4(@types/[email protected])([email protected])
'@vitest/[email protected]': {}
@@ -3145,6 +3319,10 @@ snapshots:
optionalDependencies:
'@types/trusted-types': 2.0.7
[email protected]:
optionalDependencies:
'@types/trusted-types': 2.0.7
[email protected]: {}
[email protected]: {}
@@ -3504,6 +3682,12 @@ snapshots:
[email protected]: {}
[email protected](@types/[email protected])([email protected]):
dependencies:
react: 19.3.1
optionalDependencies:
'@types/react': 19.3.0
[email protected]: {}
[email protected]:
@@ -3922,6 +4106,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
'@types/unist': 2.0.11
@@ -3944,6 +4130,10 @@ snapshots:
dependencies:
resolve-protobuf-schema: 2.1.0
[email protected]:
optionalDependencies:
'@napi-rs/canvas': 1.0.12
[email protected]: {}
[email protected]: {}
@@ -4323,6 +4513,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -4428,7 +4620,7 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
[email protected]([email protected]):
[email protected](@types/[email protected])([email protected]):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
@@ -4436,13 +4628,14 @@ snapshots:
rolldown: 1.2.12
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 26.6.5
esbuild: 0.28.2
fsevents: 2.3.3
[email protected]([email protected](@noble/[email protected]))([email protected]([email protected])):
[email protected](@types/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.4([email protected]([email protected]))
'@vitest/mocker': 5.0.4([email protected](@types/[email protected])([email protected]))
chai: 6.3.0
es-module-lexer: 2.3.2
expect-type: 1.4.0
@@ -4453,9 +4646,10 @@ snapshots:
tinybench: 6.2.2
tinyexec: 1.3.1
tinyglobby: 0.2.17
vite: 8.3.4([email protected])
vite: 8.3.4(@types/[email protected])([email protected])
why-is-node-running: 3.2.1
optionalDependencies:
'@types/node': 26.6.5
jsdom: 30.1.3(@noble/[email protected])
transitivePeerDependencies:
- msw
+10 -1
View File
@@ -56,7 +56,7 @@ const undeclared = census.roots.filter((r) => !r.actions.length);
// denominator that grows because the instrument was corrected is not the same
// event as one that grows because debt was added, and numbers either side of
// that change do not compare.
const UNDECLARED_CEILING = 218;
const UNDECLARED_CEILING = 217;
/** One file, for holding the discovery rules themselves. */
const only = (src: string) => certifyRoots(new Map([["src/ui/X.tsx", src]]), { resolve });
@@ -169,6 +169,15 @@ describe("what counts as an input a person performs", () => {
// name instead is what left every Picker call site outside the universe — with
// a kernel write among them — while Seal A read as held.
describe("what a certified primitive's call site counts as", () => {
it("keeps tooltip-wrapped button actions at the call site", () => {
const c = among({
"src/ui/X.tsx": `import { TooltipButton } from "./TooltipButton";
export const A = () => <TooltipButton data-action="a.b" onClick={() => save()} />;`,
"src/ui/TooltipButton.tsx": "export function TooltipButton() { return null }",
});
expect(c.roots.map((r) => [r.event, r.actions])).toEqual([["click", ["a.b"]]]);
expect(c.uncertified).toEqual([]);
});
it("makes the call site the input, on the contract rather than the spelling", () => {
const c = among({
"src/ui/X.tsx": `import { Picker } from "./Menu";
+2
View File
@@ -1,5 +1,6 @@
import { MARKET_ACTIONS } from "./actions_market";
import { SCHEDULE_ACTIONS } from "./actions_schedule";
import { DOCUMENT_ACTIONS } from "./actions_document";
// The census oracle is independent of production literals so disagreements fail.
// An id names the intent; data-target and data-value name its entity and choice.
@@ -764,6 +765,7 @@ export const ACTIONS: UIAction[] = [
// gutter's own grip, which this pass has not annotated yet.
{ id: "rail.toggle", kind: "view", target: "none", proof: "interaction" },
...SCHEDULE_ACTIONS,
...DOCUMENT_ACTIONS,
];
// No action in Studio is reachable from the keyboard alone: every shortcut in
@@ -0,0 +1,12 @@
import type { UIAction } from "./actions";
export const DOCUMENT_ACTIONS: UIAction[] = [
{ id: "document.zoom", kind: "view", target: "entity", proof: "interaction" },
{ id: "document.page", kind: "view", target: "entity", proof: "interaction" },
{ id: "document.scroll", kind: "view", target: "entity", proof: "interaction" },
{ id: "document.refresh", kind: "repeatable", target: "entity", proof: "interaction" },
{ id: "delivery.preview", kind: "navigation", target: "entity", proof: "interaction" },
{ id: "delivery.more", kind: "view", target: "entity", proof: "interaction" },
{ id: "delivery.menu-navigation", kind: "view", target: "none", proof: "interaction" },
{ id: "delivery.add-to-chat", kind: "view", target: "entity", proof: "interaction" },
];
+2
View File
@@ -1,5 +1,6 @@
import { EN_SURFACES } from "./en_surfaces";
import { EN_COMPOSITION } from "./en_composition";
import { EN_DOCUMENT } from "./en_document";
// English catalogue, keyed by the Chinese source text. A key that is missing
// here renders as Chinese rather than as a blank, so an incomplete translation
// degrades instead of breaking — and i18n.test.ts is what stops it from
@@ -25,6 +26,7 @@ import { EN_SPONSOR } from "./en_sponsor";
export const EN: Record<string, string> = {
...EN_COMPOSITION,
...EN_DOCUMENT,
"此检查点不包含文件快照": "This checkpoint contains no file snapshots",
...EN_SURFACES,
"重命名聊天": "Rename chat",
@@ -0,0 +1,44 @@
export const EN_DOCUMENT: Record<string, string> = {
"预览 {name}": "Preview {name}",
"更多操作:{name}": "More actions for {name}",
"文件操作:{name}": "File actions for {name}",
"查看中": "Viewing",
"项目根目录": "Project root",
"已添加到输入框": "Added to the composer",
"已请求系统应用打开": "Requested opening in the system application",
"已请求显示文件位置": "Requested showing the file location",
"已复制路径": "Path copied",
"加宽工作台": "Widen workbench",
"恢复分栏": "Restore split view",
"全屏工作台": "Expand workbench",
"退出工作台全屏": "Exit expanded workbench",
"展开文件树": "Show file tree",
"收起文件树": "Hide file tree",
"打开运行分析": "Open run analysis",
"关闭运行分析": "Close run analysis",
"打开工作台": "Open workbench",
"关闭工作台": "Close workbench",
"磁盘内容已变化,未保存的编辑已保留": "The file changed on disk. Your unsaved edits have been preserved.",
"静态预览,脚本和外部资源未加载": "Static preview. Scripts and external resources are not loaded.",
"HTML 静态预览": "Static HTML preview",
"表格无法解析或超过 10 万个单元格,请切换源码查看": "The table is invalid or exceeds 100,000 cells. Switch to source to inspect it.",
"PDF 无法显示,文件可能损坏或需要密码": "Cannot display this PDF. It may be damaged or require a password.",
"此文件暂不支持预览": "Preview is not available for this file.",
"文件超过预览大小限制": "This file exceeds the preview size limit.",
"用系统应用打开": "Open in system application",
"交付文件": "Delivered files",
"添加到对话": "Add to chat",
"在文件夹中显示": "Show in folder",
"交互运行": "Run interactively",
"上一页": "Previous page",
"下一页": "Next page",
"缩放": "Zoom",
"适应宽度": "Fit width",
"缩小": "Zoom out",
"放大": "Zoom in",
"没有访问此文件的权限": "You do not have access to this file.",
"只能预览普通文件": "Only regular files can be previewed.",
"文件不是有效的 UTF-8 文本": "The file is not valid UTF-8 text.",
"读取期间文件发生变化,请刷新": "The file changed while being read. Refresh to try again.",
"文件预览读取失败,请重试": "Could not read the file preview. Try again.",
};
+7
View File
@@ -35,6 +35,13 @@ const SAID: Record<string, string> = {
"agent_composition.conflict": "预设已被其他操作修改,草稿已保留,请重新加载后再保存",
"agent_composition.not_found": "找不到该 Agent 预设",
"agent_composition.read_only": "内置预设只读,请复制后编辑",
"workspace.preview_denied": "没有访问此文件的权限",
"workspace.preview_not_file": "只能预览普通文件",
"workspace.preview_too_large": "文件超过预览大小限制",
"workspace.preview_not_text": "文件不是有效的 UTF-8 文本",
"workspace.preview_unsupported": "此文件暂不支持预览",
"workspace.preview_changed": "读取期间文件发生变化,请刷新",
"workspace.preview_failed": "文件预览读取失败,请重试",
"surface.value_invalid": "填写内容不符合字段要求,请检查后重试",
"surface.expired": "界面已失效,请等待新的请求",
"surface.schema_invalid": "界面描述不符合约定",
-2
View File
@@ -386,8 +386,6 @@ export class MockPort extends MockWorkspace implements AgentPort {
if (path === ".reasonix") return { files: [".reasonix/REASONIX.local.md"], directories: [] };
return hidden ? { files: [".gitignore", "README.md"], directories: [".reasonix", "internal"] } : { files: ["README.md"], directories: ["internal"] };
}
async workspaceFile(path: string, _root?: string): Promise<import("./port").WorkspaceFile> { return { path, content: `// ${path}\n`, revision: "fixture" }; }
workspaceImageURL(path: string) { return `/fixture/${path}`; }
async saveWorkspaceFile(file: import("./port").WorkspaceFile) { return { ...file, revision: file.revision + "-saved" }; }
async trajectory(): Promise<TrajectoryRead> {
@@ -20,6 +20,17 @@ export function mockWorktreeSources(root: string, branch: string): import("./wor
}
export class MockWorkspace extends MockToolOutput {
async workspaceFile(path: string, _root?: string): Promise<import("./workspace").WorkspaceFile> { return { path, content: `// ${path}\n`, revision: "fixture" }; }
async workspacePreviewInfo(ref: import("./workspace").FileRef): Promise<import("./workspace").PreviewInfo> {
const file = await this.workspaceFile(ref.path, ref.root);
const { previewKind } = await import("../ui/document/registry");
return { ref, kind: previewKind(ref.path), size: new TextEncoder().encode(file.content).length, version: file.revision, readOnly: !!ref.root, limit: 2 << 20 };
}
async workspacePreviewContent(ref: import("./workspace").FileRef) {
const file = await this.workspaceFile(ref.path, ref.root);
return { data: new TextEncoder().encode(file.content).buffer, version: file.revision };
}
workspaceImageURL(path: string) { return `/fixture/${path}`; }
opensFiles() { return false; }
async openFile() {}
+3 -3
View File
@@ -31,7 +31,7 @@ import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
import type { SkillCatalog, SkillEntry, SkillUpdateChange, SkillUpdatePlan } from "./skill";
import type { SubagentRun, SubagentTranscript } from "./subagent";
import type { StudioCrash, StudioLogs, UpdateMode, UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, DiscardRequest, GitCommitFiles, GitRepos, PushRequest, PushResult, PushPlan, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo } from "./workspace";
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, DiscardRequest, GitCommitFiles, GitRepos, PushRequest, PushResult, PushPlan, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo, WorkspacePreviewPort } from "./workspace";
// The port is one contract; its subjects each keep their own file, the way the
// wire and the layers below already do. This is where a reader still finds
@@ -43,7 +43,7 @@ export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, Approval
MemoryEntry, ModelEntry, ModelMode, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan,
RewindResult, RewindUndo, RewindScope, RoleAssignments, RoleOverride, ScopeLayer, SessionEntry, SessionStatus, SessionUsage, SourceUsage,
ShellOption, ShellSettings, SkillCatalog, SkillEntry, SkillUpdateChange, SkillUpdatePlan, UpdateProgress, VersionEntry,
StudioCrash, StudioLogs, UpdateMode, VersionHub, VersionNotes, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, DiscardRequest, GitCommitFiles, GitRepos, PushRequest, PushResult, PushPlan, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo };
StudioCrash, StudioLogs, UpdateMode, VersionHub, VersionNotes, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, DiscardRequest, GitCommitFiles, GitRepos, PushRequest, PushResult, PushPlan, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo, WorkspacePreviewPort };
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
@@ -185,7 +185,7 @@ export interface TrayPrefs {
closeToTray: boolean;
}
export interface AgentPort {
export interface AgentPort extends WorkspacePreviewPort {
providerSetup(): Promise<ProviderSetup | null>;
saveProviderKey(apiKey: string): Promise<void>;
// The models that answer the job asked about; "chat" when none is named.
@@ -35,6 +35,7 @@ export interface HistoryMessage {
// The host's own account of a result that did not succeed. Without it a
// rebuilt card has only the words, which a tool's own output can imitate.
toolFailed?: boolean;
deliveries?: import("./workspace").DeliveredFile[];
toolRefusalCode?: string;
// outputDiff marks a rebuilt shell result whose whole output is a unified
// diff, re-derived by the host so a reopened transcript renders it as one.
+14
View File
@@ -408,6 +408,20 @@ export class SsePort extends SseWorkspace implements AgentPort {
return this.get<WorkspaceFile>(`/workspace/file?path=${encodeURIComponent(path)}${root ? `&root=${encodeURIComponent(root)}` : ""}`);
}
async workspacePreviewInfo(ref: import("./workspace").FileRef, signal?: AbortSignal) {
const query = new URLSearchParams({ path: ref.path, ...(ref.root ? { root: ref.root } : {}) });
const res = await fetch(`${this.base}/workspace/preview/info?${query}`, { credentials: "same-origin", signal });
if (!res.ok) await SsePort.fail("/workspace/preview/info", res);
return await res.json() as import("./workspace").PreviewInfo;
}
async workspacePreviewContent(ref: import("./workspace").FileRef, signal?: AbortSignal) {
const query = new URLSearchParams({ path: ref.path, ...(ref.root ? { root: ref.root } : {}) });
const res = await fetch(`${this.base}/workspace/preview/content?${query}`, { credentials: "same-origin", signal });
if (!res.ok) await SsePort.fail("/workspace/preview/content", res);
return { data: await res.arrayBuffer(), version: res.headers.get("X-Preview-Version") ?? "" };
}
workspaceImageURL(path: string, root?: string) {
return `${this.base}/workspace/image?path=${encodeURIComponent(path)}${root ? `&root=${encodeURIComponent(root)}` : ""}`;
}
+1
View File
@@ -106,6 +106,7 @@ export interface Bound {
export type ToolIssuer = "model" | "host" | "user" | "provider";
export interface Tool {
deliveries?: import("./workspace").DeliveredFile[];
id?: string;
name: string;
args?: string;
@@ -92,12 +92,36 @@ export interface WorkspaceFile {
path: string;
content: string;
revision: string;
observedVersion?: string;
// The granted folder the file was read from; absent for the workspace itself.
root?: string;
// True for a file read from a granted folder: it can be read here, never saved.
readOnly?: boolean;
}
export interface FileRef {
path: string;
root?: string;
}
export interface PreviewInfo {
ref: FileRef;
kind: "markdown" | "text" | "image" | "pdf" | "table" | "html" | "unsupported";
size: number;
version: string;
limit: number;
readOnly: boolean;
}
export interface DeliveredFile extends FileRef {
description?: string;
}
export interface WorkspacePreviewPort {
workspacePreviewInfo(ref: FileRef, signal?: AbortSignal): Promise<PreviewInfo>;
workspacePreviewContent(ref: FileRef, signal?: AbortSignal): Promise<{ data: ArrayBuffer; version: string }>;
}
export interface WorkspaceEntry {
path: string;
name: string;
+1 -2
View File
@@ -121,8 +121,6 @@ function sealTurn(items: Item[], err?: string): Item[] {
// the flag is the identity, and its err only repeats the sentinel.
return [...sealed, { t: "notice" as const, id: nextId(), level: "error", text: err }];
}
// The card reads the call's own arguments: they carry what was saved and how it
// will be recalled, which the tool's textual receipt does not.
function parseRemembered(tool: Tool): RememberedFact | null {
@@ -782,6 +780,7 @@ export function fromHistory(msgs: HistoryMessage[]): { items: Item[]; executions
tool: capToolOutput({
...prev.tool,
output: m.content,
deliveries: m.deliveries,
...(m.outputDiff ? { outputDiff: true } : {}),
...(m.toolFailed ? { err: m.content, refusalCode: m.toolRefusalCode } : {}),
}),
@@ -1,14 +0,0 @@
.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;
}
@@ -1,14 +1,15 @@
import { t } from "../i18n";
import { StudioIcon } from "./StudioIcon";
import { ariaChord, chord } from "./keys";
import { TooltipButton } from "./TooltipButton";
export function BrowserFullToggle({ full, onToggle }: { full: boolean; onToggle: () => void }) {
const label = full ? t("退出全屏") : t("全屏浏览器");
const label = full ? t("退出工作台全屏") : t("全屏工作台");
return (
<button className="workbench-files" data-action="browser.full" aria-pressed={full}
<TooltipButton className="workbench-files" data-action="browser.full" aria-pressed={full}
aria-label={label} aria-keyshortcuts={`${ariaChord("F")}+Shift`}
title={`${label} (${chord("Shift+F")})`} onClick={onToggle}>
title={label} shortcut={chord("Shift+F")} onClick={onToggle}>
<StudioIcon name="expand" />
</button>
</TooltipButton>
);
}
@@ -1,14 +1,15 @@
import { t } from "../i18n";
import { StudioIcon } from "./StudioIcon";
import { ariaChord, chord } from "./keys";
import { TooltipButton } from "./TooltipButton";
export function BrowserWideToggle({ wide, onToggle }: { wide: boolean; onToggle: () => void }) {
const label = wide ? t("恢复浏览器分栏") : t("加宽浏览器");
const label = wide ? t("恢复分栏") : t("加宽工作台");
return (
<button className="workbench-files" data-action="browser.wide" aria-pressed={wide}
<TooltipButton className="workbench-files" data-action="browser.wide" aria-pressed={wide}
aria-label={label} aria-keyshortcuts={`${ariaChord("B")}+Shift`}
title={`${label} (${chord("Shift+B")})`} onClick={onToggle}>
title={label} shortcut={chord("Shift+B")} onClick={onToggle}>
<StudioIcon name="panel" />
</button>
</TooltipButton>
);
}
+8 -7
View File
@@ -9,6 +9,7 @@ import { useFolded } from "./usefold";
import { ariaChord, chord } from "./keys";
import { PhonePop } from "./PhonePop";
import { ViewSwitch } from "./ViewSwitch";
import { TooltipButton } from "./TooltipButton";
import { WindowControls, zoomOnTitleBar } from "./WindowControls";
import type { PaneView } from "../state/paneview";
@@ -123,19 +124,19 @@ export function Chrome({ port, status, title, steer, onSettings, account, host,
<div className="r-fold" id="chrome-fold" data-open={more ? "" : undefined}>
{hub && onError && <PhonePop hub={hub} />}
{port && (
<button
<TooltipButton
className="thbtn find-action"
data-action="transcript.find"
data-value="open"
onClick={onFind}
aria-label={t("在这段对话里查找")}
aria-keyshortcuts={ariaChord("f")}
title={`${t("在这段对话里查找")} ${chord("F")}`}
title={t("在这段对话里查找")} shortcut={chord("F")}
>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M7.2 3.1a4.1 4.1 0 1 0 0 8.2 4.1 4.1 0 0 0 0-8.2M10.3 10.3 13 13" /></svg>
</button>
</TooltipButton>
)}
<button
<TooltipButton
className="thbtn theme-toggle"
data-action="appearance.theme"
onClick={onTheme}
@@ -147,10 +148,10 @@ export function Chrome({ port, status, title, steer, onSettings, account, host,
) : (
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="2.7" /><path d="M8 1.7v1.4M8 12.9v1.4M1.7 8h1.4M12.9 8h1.4M3.5 3.5l1 1M11.5 11.5l1 1M3.5 12.5l1-1M11.5 4.5l1-1" /></svg>
)}
</button>
<button className="top-action" data-action="settings.section" data-value="tools" onClick={() => onSettings("tools")} aria-label={t("沙盒")} title={t("沙盒与运行边界")}>
</TooltipButton>
<TooltipButton className="top-action" data-action="settings.section" data-value="tools" onClick={() => onSettings("tools")} aria-label={t("沙盒")} title={t("沙盒与运行边界")}>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m8 2.5 5 2.7v5.6l-5 2.7-5-2.7V5.2Z" /><path d="m3 5.2 5 2.7 5-2.7M8 7.9v5.6" /></svg><span>{t("沙盒")}</span>
</button>
</TooltipButton>
{port && <ViewSwitch view={view} rows={rows} surfaces={surfaces} onView={onView} />}
</div>
<span className="account-presence" title={account?.signedIn ? account.user?.label : t("未登录")} />
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { t } from "../i18n";
@@ -15,7 +15,8 @@ describe("a copy button", () => {
it("on a code block, offers to copy the code rather than the response", () => {
render(<CodeBlock lang="go" source={"x := 1\n"}><code>x := 1</code></CodeBlock>);
const button = screen.getByRole("button", { name: t("复制这段代码") });
expect(button.getAttribute("title")).toBe(t("复制这段代码"));
fireEvent.focus(button);
expect(screen.getByRole("tooltip").textContent).toBe(t("复制这段代码"));
});
it("with a label and visible text, says that label instead of the response's", () => {
@@ -27,7 +28,8 @@ describe("a copy button", () => {
it("without a label, still offers to copy the response", () => {
render(<CopyButton text="an answer" iconOnly />);
expect(screen.getByRole("button").getAttribute("title")).toBe(t("复制回复"));
fireEvent.focus(screen.getByRole("button"));
expect(screen.getByRole("tooltip").textContent).toBe(t("复制回复"));
});
});
+4 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { t } from "../i18n";
import { StudioIcon } from "./StudioIcon";
import { TooltipButton } from "./TooltipButton";
// Wails serves the window over a custom scheme on macOS and Linux, so those two
// hosts are not a secure context and navigator.clipboard is undefined there.
@@ -39,9 +40,10 @@ export function CopyButton({ text, iconOnly = false, showFeedback = false, class
// A clipboard the host denied is not the same as nothing happening, and the
// reader is about to try again — so the failure says so instead of staying idle.
const label = state === "done" ? t("已复制") : state === "failed" ? t("复制失败,请重试") : what ?? t("复制回复");
const Button = iconOnly ? TooltipButton : "button";
return (
<button
<Button
className={className ? `copy ${className}` : "copy"}
type="button"
role={role}
@@ -54,6 +56,6 @@ export function CopyButton({ text, iconOnly = false, showFeedback = false, class
>
{iconOnly && <StudioIcon name={state === "done" ? "check" : state === "failed" ? "warning" : "copy"} />}
<span className={iconOnly && !(showFeedback && state !== "idle") ? "sr-only" : undefined} aria-live="polite">{label}</span>
</button>
</Button>
);
}
@@ -159,6 +159,7 @@ function textOf(node: ReactNode): string {
/** File navigation for a workspace document or a conversation. */
export interface LocalRefs {
file?(ref: import("../port/workspace").FileRef): () => void;
/** 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. */
+14 -1
View File
@@ -429,8 +429,20 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
const find = useFind(s.items, findPulse, active, useCallback(() => showView("flow"), [showView]), receiptPref, findTogglePulse);
const onRunDetail = useCallback(() => showView("analysis"), [showView]);
const previewScope = JSON.stringify([rt.id, rt.root, status?.sessionPath ?? rt.sessionPath]);
const [previewFile, setPreviewFile] = useState<{ port: typeof port; scope: string; path?: string }>();
const onActiveFile = useCallback((path: string | undefined) => {
setPreviewFile((old) => old?.port === port && old.scope === previewScope && old.path === path
? old : { port, scope: previewScope, path });
}, [port, previewScope]);
const activeFile = visible && workbench && previewFile?.port === port && previewFile.scope === previewScope ? previewFile.path : undefined;
const { quote, reply, onResend } = useReplyActions({ port, items: s.items, checkpoints, running, model: status?.label, submit: (line) => submit(line).then((r) => r === true), reloadSession, onSettings, onRunDetail, onError: fail, branchName: `${title} · ${t("分支")}`, onBranched: onOpenSession });
const fileReply = useMemo(() => ({ ...reply, files: conversationFiles.refs }), [reply, conversationFiles.refs]);
const fileReply = useMemo(() => ({
...reply, files: conversationFiles.refs, activeFile,
onAddFile: (reference: string) => setInserted((was) => ({ n: was.n + 1, text: reference })),
openDeliveredFile: !rt.host && port.opensFiles() ? (path: string) => port.openDocument(path) : undefined,
revealDeliveredFile: !rt.host && port.revealsFiles() ? (path: string) => port.revealInFileManager(path) : undefined,
}), [reply, conversationFiles.refs, rt.host, port, activeFile]);
// 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.
@@ -563,6 +575,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
manual={manualBrowser}
shown={visible && workbench}
onSurfaces={setSurfaces}
onActiveFile={onActiveFile}
scheme={theme === "light" ? "light" : "dark"}
changes={tree?.changes ?? []} onTreeChanged={refreshTree} revealChanges={revealChanges} toggleFiles={toggleFiles}
running={running}
+3 -2
View File
@@ -7,6 +7,7 @@ import { copyText } from "./CopyButton";
import { useDismiss } from "./dismiss";
import { clock, deviceLabel, type Share, useShare } from "./PhoneAccess";
import { Switch } from "./Switch";
import { TooltipButton } from "./TooltipButton";
/** The chrome's way to put a phone on this window: a code on a card, one
* press from anywhere. Drawn only where the kernel has a window to share —
@@ -62,7 +63,7 @@ export function PhonePop({ hub }: { hub: HubPort }) {
const online = share.st.devices.filter((d) => d.online).length + share.st.cloudDevices.length;
return (
<div className="phonepop" ref={box}>
<button
<TooltipButton
className="thbtn phone-action"
data-action="share.card"
data-live={shareOpen ? "" : undefined}
@@ -79,7 +80,7 @@ export function PhonePop({ hub }: { hub: HubPort }) {
<path d="M9.5 9.5h1.6v1.6M13.5 9.5v.01M9.5 13.5h.01M12 12h1.5v1.5H12Z" />
</svg>
{online > 0 && <b className="pc-count">{online}</b>}
</button>
</TooltipButton>
{open && <PhoneCard share={share} failure={failure} />}
{(cloudNote || localNote) && <div className="pc-note-pop" role="status">{cloudNote || localNote}</div>}
</div>
@@ -0,0 +1,80 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { createRef } from "react";
import { TooltipButton } from "./TooltipButton";
import { ViewSwitch } from "./ViewSwitch";
afterEach(() => { cleanup(); vi.useRealTimers(); });
it("waits 400ms for hover and cancels both pending and visible hints on leave", () => {
vi.useFakeTimers();
render(<TooltipButton title="复制回复">Copy</TooltipButton>);
const button = screen.getByRole("button");
expect(button.hasAttribute("title")).toBe(false);
fireEvent.pointerEnter(button);
act(() => vi.advanceTimersByTime(399));
expect(screen.queryByRole("tooltip")).toBeNull();
fireEvent.pointerLeave(button);
act(() => vi.advanceTimersByTime(400));
expect(screen.queryByRole("tooltip")).toBeNull();
fireEvent.pointerEnter(button);
act(() => vi.advanceTimersByTime(400));
expect(screen.getByRole("tooltip").textContent).toBe("复制回复");
expect(screen.getByRole("tooltip").parentElement).toBe(document.body);
expect(button.getAttribute("aria-describedby")).toBe(screen.getByRole("tooltip").id);
fireEvent.pointerLeave(button);
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("supports keyboard focus, Escape, current-state labels and the existing button ref", () => {
const ref = createRef<HTMLButtonElement>();
const clicked = vi.fn();
const view = render(<TooltipButton ref={ref} title="加宽工作台" shortcut="⌘Shift+B" onClick={clicked}>Wide</TooltipButton>);
const button = screen.getByRole("button");
expect(ref.current).toBe(button);
fireEvent.focus(button);
expect(screen.getByRole("tooltip").textContent).toContain("⌘Shift+B");
view.rerender(<TooltipButton ref={ref} title="恢复分栏" onClick={clicked}>Wide</TooltipButton>);
expect(screen.getByRole("tooltip").textContent).toBe("恢复分栏");
fireEvent.keyDown(button, { key: "Escape" });
expect(screen.queryByRole("tooltip")).toBeNull();
fireEvent.blur(button);
fireEvent.focus(button);
fireEvent.click(button);
expect(clicked).toHaveBeenCalledOnce();
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("cancels delayed work when the button unmounts", () => {
vi.useFakeTimers();
const view = render(<TooltipButton title="文件">Files</TooltipButton>);
fireEvent.pointerEnter(screen.getByRole("button"));
view.unmount();
act(() => vi.advanceTimersByTime(400));
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("shows only the latest hint when hover moves away from a keyboard-focused control", () => {
vi.useFakeTimers();
render(<><TooltipButton title="引用">Quote</TooltipButton><TooltipButton title="更多">More</TooltipButton></>);
fireEvent.focus(screen.getByRole("button", { name: "Quote" }));
fireEvent.pointerEnter(screen.getByRole("button", { name: "More" }));
act(() => vi.advanceTimersByTime(400));
expect(screen.getAllByRole("tooltip")).toHaveLength(1);
expect(screen.getByRole("tooltip").textContent).toBe("更多");
});
it("describes opening and closing the top-bar workbench without changing its control identity", () => {
const onView = vi.fn();
const view = render(<ViewSwitch view="flow" rows={1} surfaces={2} onView={onView} />);
const button = screen.getByRole("button", { name: "工作台" });
fireEvent.focus(button);
expect(screen.getByRole("tooltip").textContent).toContain("打开工作台");
expect(screen.getByRole("tooltip").textContent).toContain("3");
view.rerender(<ViewSwitch view="browser" rows={1} surfaces={2} onView={onView} />);
expect(screen.getByRole("button", { name: "工作台" })).toBe(button);
expect(screen.getByRole("tooltip").textContent).toContain("关闭工作台");
fireEvent.click(button);
expect(onView).toHaveBeenCalledWith("flow");
});
@@ -0,0 +1,74 @@
import { useCallback, useEffect, useId, useImperativeHandle, useRef, useState, type ComponentPropsWithRef } from "react";
import { createPortal } from "react-dom";
import { beside, pinToViewport, useFollow } from "./place";
import "./tooltip.css";
import { listenAction } from "./listen";
type Props = Omit<ComponentPropsWithRef<"button">, "onBlur"> & { shortcut?: string };
let dismissActive: (() => void) | undefined;
export function TooltipButton({
title, shortcut, ref, children, onPointerEnter, onPointerLeave, onPointerDown,
onFocus, onClick, "aria-describedby": describedBy, ...props
}: Props) {
const anchor = useRef<HTMLButtonElement>(null);
const tip = useRef<HTMLDivElement>(null);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [open, setOpen] = useState(false);
const id = useId();
useImperativeHandle(ref, () => anchor.current!, []);
const clear = useCallback(() => { clearTimeout(timer.current); }, []);
const close = useCallback(() => {
clear(); setOpen(false);
if (dismissActive === close) dismissActive = undefined;
}, [clear]);
const show = () => {
clear();
if (!title || props.disabled) return;
dismissActive?.();
dismissActive = close;
setOpen(true);
};
useEffect(() => () => {
clear();
if (dismissActive === close) dismissActive = undefined;
}, [clear, close]);
useEffect(() => {
if (!open) return;
const key = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.stopPropagation();
close();
};
const offKey = listenAction(window, "keydown", { action: "layer.dismiss", listener: key as EventListener }, true);
addEventListener("blur", close);
return () => { offKey(); removeEventListener("blur", close); };
}, [open, close]);
const place = useCallback(() => {
if (!anchor.current || !tip.current) return;
const rect = anchor.current.getBoundingClientRect();
const box = tip.current.getBoundingClientRect();
pinToViewport(tip.current, rect.left + (rect.width - box.width) / 2, beside(rect, box.height, innerHeight, "above", 6));
}, []);
useFollow(open, place, `${title}:${shortcut}`);
return <>
<button {...props} ref={anchor} type={props.type ?? "button"}
aria-describedby={[describedBy, open ? id : undefined].filter(Boolean).join(" ") || undefined}
onPointerEnter={(event) => {
onPointerEnter?.(event);
if (event.pointerType !== "touch") { clear(); timer.current = setTimeout(show, 400); }
}}
onPointerLeave={(event) => { close(); onPointerLeave?.(event); }}
onPointerDown={(event) => { close(); onPointerDown?.(event); }}
onFocus={(event) => { show(); onFocus?.(event); }}
onBlur={close}
onClick={(event) => { close(); onClick?.(event); }}>
{children}
</button>
{open && title && !props.disabled && createPortal(
<div className="icon-tooltip" id={id} role="tooltip" ref={tip}>
<span>{title}</span>{shortcut && <kbd>{shortcut}</kbd>}
</div>, document.body,
)}
</>;
}
+9 -2
View File
@@ -16,6 +16,8 @@ import { revealNested } from "./cards/nestreveal";
import { SurfaceCard, surfaceTool } from "./surface/SurfaceCard";
import { ElicitCard } from "./cards/ElicitCard";
import { SayCard, type ReplyActions } from "./cards/SayCard";
import { DeliveryCard } from "./document/DeliveryCard";
import { deliveriesByReply } from "./document/deliveries";
import { CompactionCard } from "./cards/CompactionCard";
import { WorkedLine } from "./cards/WorkedLine";
import { ReadsCard } from "./cards/ReadsCard";
@@ -564,6 +566,8 @@ const Block = memo(function Block({
// messages; transcriptRows folds it and keeps each sentence above its own.
const rows = transcriptRows(items, foldWork ? { running: tail } : undefined);
const newAnswerAt = useNewAnswerAnchor();
const deliveries = useMemo(() => deliveriesByReply(items), [items]);
const delivery = (id: string) => deliveries.has(id) ? <DeliveryCard files={deliveries.get(id)!} reply={rowProps.reply} /> : null;
// Which runs of work are open, held here so re-hosting one under its sentence
// does not close it.
const [opened, setOpened] = useState<Record<string, boolean>>({});
@@ -586,11 +590,14 @@ const Block = memo(function Block({
{...rowProps}
cp={checkpoints.get(row.item.id)}
settled={!row.activity?.some((item) => item.t === "tool" && item.running)}
afterAnswer={row.activity ? <ActivityGroup items={row.activity} checkpoints={checkpoints} opened={opened} onOpened={setOpened} {...rowProps} /> : undefined}
afterAnswer={row.activity || deliveries.has(row.item.id) ? <>{delivery(row.item.id)}{row.activity && <ActivityGroup items={row.activity} checkpoints={checkpoints} opened={opened} onOpened={setOpened} {...rowProps} />}</> : undefined}
/>
</Fragment>
) : (
<ActivityGroup key={`activity:${row.activity[0].id}`} items={row.activity} checkpoints={checkpoints} opened={opened} onOpened={setOpened} {...rowProps} />
<Fragment key={`activity:${row.activity[0].id}`}>
{row.activity.map((item) => <Fragment key={item.id}>{delivery(item.id)}</Fragment>)}
<ActivityGroup items={row.activity} checkpoints={checkpoints} opened={opened} onOpened={setOpened} {...rowProps} />
</Fragment>
);
});
+7 -6
View File
@@ -2,6 +2,7 @@ import { t } from "../i18n";
import type { PaneView } from "../state/paneview";
import { ariaChord, chord } from "./keys";
import { StudioIcon } from "./StudioIcon";
import { TooltipButton } from "./TooltipButton";
/** The focused pane's two side views, drawn on the window bar. The conversation
* is where a pane rests, so it has no button: pressing the view that is showing
@@ -17,22 +18,22 @@ export function ViewSwitch({ view, rows, surfaces, onView }: {
return (
<>
{rows > 0 && (
<button
<TooltipButton
type="button" className="thbtn view-action" data-action="pane.view" data-value="analysis"
aria-pressed={view === "analysis"} aria-label={t("运行分析")} aria-keyshortcuts={ariaChord("2")}
title={`${t("运行分析")} ${chord("2")}`} onClick={() => press("analysis")}
title={view === "analysis" ? t("关闭运行分析") : t("打开运行分析")} shortcut={chord("2")} onClick={() => press("analysis")}
>
<StudioIcon name="gauge" />
</button>
</TooltipButton>
)}
<button
<TooltipButton
type="button" className="thbtn view-action" data-action="pane.view" data-value="browser"
aria-pressed={view === "browser"} aria-label={t("工作台")} aria-keyshortcuts={ariaChord("3")}
title={`${t("工作台")} ${chord("3")}`} onClick={() => press("browser")}
title={view === "browser" ? t("关闭工作台") : t("打开工作台")} shortcut={chord("3")} onClick={() => press("browser")}
>
<StudioIcon name="panel" />
{surfaces > 0 && <span className="view-count">{surfaces}</span>}
</button>
</TooltipButton>
</>
);
}
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
import { t } from "../i18n";
import { StudioIcon } from "./StudioIcon";
export type FileMode = "file" | "diff" | "read" | "preview";
export type FileMode = "file" | "diff" | "read" | "preview" | "document";
/** The open file's path, the views it can be seen in, and save. A document that
* renders (Markdown, a page) leads with that view and calls the editor
@@ -18,6 +18,8 @@ export function WorkbenchFileBar({
onSave,
openWith,
onReveal,
document = false,
source = true,
}: {
path: string;
mode: FileMode;
@@ -31,6 +33,8 @@ export function WorkbenchFileBar({
openWith?: ReactNode;
// Present only where the kernel's own machine can show the file.
onReveal?: () => void;
document?: boolean;
source?: boolean;
}) {
const view = (value: FileMode, label: string) => (
<button data-action="workbench.mode" data-value={value} aria-pressed={mode === value} onClick={() => onMode(value)}>
@@ -47,19 +51,20 @@ export function WorkbenchFileBar({
)}
<div role="group">
{readable && view("read", t("阅读"))}
{previewable && view("preview", t("预览"))}
{view("file", !readOnlyIn && (readable || previewable) ? t("编辑") : t("文件"))}
{!readOnlyIn && view("diff", "Diff")}
{document && view("document", t("预览"))}
{previewable && view("preview", t("交互运行"))}
{source && view("file", !readOnlyIn && (readable || previewable || document) ? t("编辑") : t("文件"))}
{source && !readOnlyIn && view("diff", "Diff")}
</div>
{openWith}
{readOnlyIn ? (
<span className="workbench-readonly" title={readOnlyIn}>{t("只读 · 来自你授权的文件夹")}</span>
) : (
) : source ? (
<button className="workbench-save" data-action="workbench.save" data-target={path} disabled={!canSave} onClick={onSave}>
<StudioIcon name="check" />
{t("保存")}
</button>
)}
) : null}
</div>
);
}
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
import { t } from "../i18n";
import { copyText } from "./CopyButton";
import { pinToViewport } from "./place";
import { fileReference } from "./fileReference";
export type FileMenu = { path: string; folder: boolean; x: number; y: number; anchor: HTMLElement };
@@ -53,7 +54,7 @@ export function WorkbenchFileMenu({ menu, root, revealable, platform, onReveal,
// Keep the same token spelling as control.EscapeRefPath and RefDirItems.
const path = menu.path + (menu.folder ? "/" : "");
const ambiguous = path.includes(":");
const reference = ambiguous || path.endsWith("\\") || path.trimEnd() !== path || /[\r\n\f]|[.,;!?)}\]]$/.test(path) ? "" : "@" + path.replace(/[ \t]/g, "\\$&");
const reference = fileReference(path);
return createPortal(
<div
className="tabmenu"
+44 -77
View File
@@ -36,73 +36,15 @@ import { useHtmlPreview } from "./useHtmlPreview";
import { setShowsHiddenFiles, showsHiddenFiles } from "../state/prefs";
import { BrowserWideToggle } from "./BrowserWideToggle";
import { BrowserFullToggle } from "./BrowserFullToggle";
import { WorkspaceImage, isWorkspaceImage } from "./WorkspaceImage";
import { documentView, sourceView } from "./document/registry";
import { TooltipButton } from "./TooltipButton";
import { documentState, type DocumentViewState } from "./document/viewState";
const DocumentPreview = lazy(() => import("./document/DocumentPreview"));
// The editor and its grammars load with the first file opened, not with Studio.
const CodeEditor = lazy(() => import("./CodeEditor"));
type TreeRow = {
kind: "folder" | "file";
path: string;
name: string;
depth: number;
};
/** Folders first at every level, with the tree still reading as a tree. Sorting
* flat paths as text interleaves `bin/` with `boot.test.exe`, because text is
* the only thing it compares. Segment by segment, whichever row is a directory
* where the two paths part wins there, and its children stay under it. */
function before(a: TreeRow, b: TreeRow): number {
const x = a.path.split("/"), y = b.path.split("/");
for (let i = 0; i < Math.min(x.length, y.length); i++) {
if (x[i] === y[i]) continue;
const dirs = [i < x.length - 1 || a.kind === "folder", i < y.length - 1 || b.kind === "folder"];
return dirs[0] === dirs[1] ? x[i].localeCompare(y[i]) : dirs[0] ? -1 : 1;
}
return x.length - y.length;
}
function treeRows(
files: string[],
directories: string[],
query: string,
closed: Set<string>,
): TreeRow[] {
const matches = query
? files.filter((path) =>
path.toLocaleLowerCase().includes(query.toLocaleLowerCase()),
)
: files;
const folders = new Set<string>(directories);
for (const path of matches)
for (let i = 1, bits = path.split("/"); i < bits.length; i++)
folders.add(bits.slice(0, i).join("/"));
const hidden = (path: string) =>
[...closed].some(
(folder) => path === folder || path.startsWith(`${folder}/`),
);
const rows: TreeRow[] = [...folders]
.filter(
(path) => ![...closed].some((parent) => path.startsWith(`${parent}/`)),
)
.map((path) => ({
kind: "folder",
path,
name: path.split("/").at(-1)!,
depth: path.split("/").length - 1,
}));
rows.push(
...matches
.filter((path) => !hidden(path.split("/").slice(0, -1).join("/")))
.map((path) => ({
kind: "file" as const,
path,
name: path.split("/").at(-1)!,
depth: path.split("/").length - 1,
})),
);
return rows.sort(before);
}
import { treeRows } from "./workbench_tree";
export function WorkbenchPanel({
port,
@@ -121,6 +63,7 @@ export function WorkbenchPanel({
toggleFiles = 0,
onCloseManual,
onSurfaces,
onActiveFile,
onExternal,
onAddToChat,
onQuoteSelection,
@@ -152,6 +95,7 @@ export function WorkbenchPanel({
onCloseManual: () => void;
// How many surfaces the strip holds, for the pane's own tab to count.
onSurfaces: (n: number) => void;
onActiveFile?: (path: string | undefined) => void;
onExternal: (url: string) => void;
onAddToChat?: (reference: string) => void;
// Text selected in an open file, with the file it came from, on its way to the composer.
@@ -206,6 +150,10 @@ export function WorkbenchPanel({
const [platform, setPlatform] = useState("");
useEffect(() => void host().describe().then((h) => setPlatform(h.platform)), []);
const scope = useMemo(() => ({ port, workspaceRoot }), [port, workspaceRoot]);
const documentStates = useMemo(() => new Map<string, DocumentViewState>(), [scope]);
useEffect(() => {
for (const path of documentStates.keys()) if (!openFiles.includes(path)) documentStates.delete(path);
}, [documentStates, openFiles]);
const listedScope = useRef(scope);
const currentScope = useRef(scope);
currentScope.current = scope;
@@ -339,6 +287,10 @@ export function WorkbenchPanel({
// A document opens rendered: it is read far more often than edited, and the
// source view is one click away.
const filePath = active?.kind === "file" ? active.path : "";
useEffect(() => {
onActiveFile?.(shown && !launcher && !showFiles && filePath ? filePath : undefined);
return () => onActiveFile?.(undefined);
}, [filePath, shown, launcher, showFiles, onActiveFile]);
const [selectionMenu, setSelectionMenu] = useState<SelectionMenu | null>(null);
// A right-click over text selected in an open file offers to bring that passage into the
// conversation; anywhere else, or with nothing selected, the browser's own menu stays.
@@ -352,19 +304,21 @@ export function WorkbenchPanel({
};
const grantedFile = parseGrantedFile(filePath);
const readable = /\.(md|markdown|mdx)$/i.test(grantedFile?.rel ?? filePath);
const hasDocument = documentView(grantedFile?.rel ?? filePath);
const source = sourceView(grantedFile?.rel ?? filePath);
const previewable = !!filePath && !grantedFile && preview.can(filePath);
useEffect(() => {
setMode((m) => {
const kept = (m === "read" && !readable) || (m === "preview" && !previewable) ? "file" : m;
return kept === "file" ? (readable ? "read" : previewable ? "preview" : "file") : kept;
return m === "diff" && source ? m : hasDocument ? "document" : readable ? "read" : "file";
});
}, [filePath, readable, previewable]);
}, [filePath, readable, hasDocument, source]);
const previewTab = previewable ? preview.tabFor(filePath) : undefined;
const { open: openPreview, sync: syncPreview } = preview;
const { file, draft, diff, busy, error: fileFailed, missing, setDraft, save } = useWorkbenchFiles({
port, workspaceRoot, repos: gitRepos.repos, path: isWorkspaceImage(filePath) ? "" : filePath, openPaths: openFiles, shown, refresh: JSON.stringify([changeKey, glance, running, wrote]),
const { file, draft, diff, busy, error: fileFailed, errorCode, missing, setDraft, save } = useWorkbenchFiles({
port, workspaceRoot, repos: gitRepos.repos, path: !source || (hasDocument && mode === "document") ? "" : filePath, openPaths: openFiles, shown, refresh: JSON.stringify([changeKey, glance, running, wrote]),
poll: (readable && mode === "read") || (previewable && mode === "preview"),
});
const unpreviewableText = !hasDocument && errorCode === "workspace.file_unreadable";
const revision = file?.revision;
useEffect(() => {
if (mode !== "preview" || !previewable || !shown || showFiles || launcher || previewTab || (!revision && !fileFailed && !failed)) return;
@@ -566,17 +520,17 @@ export function WorkbenchPanel({
</div>
{wideMode && <BrowserWideToggle {...wideMode} />}
{fullMode && <BrowserFullToggle {...fullMode} />}
<button
<TooltipButton
className="workbench-files"
data-action="workbench.files"
aria-pressed={showFiles && !launcher}
aria-label={t("文件")}
title={t("文件")}
title={showFiles && !launcher ? t("收起文件树") : t("展开文件树")}
onClick={toggleExplorer}
>
<StudioIcon name="folder" />
</button>
<button
</TooltipButton>
<TooltipButton
className="workbench-new"
data-action="workbench.new"
ref={newTab}
@@ -586,7 +540,7 @@ export function WorkbenchPanel({
onClick={toggleLauncher}
>
<StudioIcon name="plus" />
</button>
</TooltipButton>
</header>
{closeFailure && <div className="workbench-error workbench-close-error" role="alert">{closeFailure}</div>}
<div className="workbench-body" data-files={showFiles && !launcher ? "" : undefined}>
@@ -624,7 +578,7 @@ export function WorkbenchPanel({
onOpenDocument={(path) => port.openDocument(path)}
/>
)}
{active?.kind === "file" && (isWorkspaceImage(active.path) ? <WorkspaceImage key={active.path} port={port} path={active.path} /> : (
{active?.kind === "file" && (
<>
<WorkbenchFileBar
path={grantedFile ? `${grantedFile.root.replace(/[\\/]+$/, "")}/${grantedFile.rel}` : active.path}
@@ -632,18 +586,31 @@ export function WorkbenchPanel({
onMode={setMode}
readable={readable}
previewable={previewable}
document={hasDocument}
source={source}
canSave={!busy && !file?.readOnly && file?.path === (grantedFile?.rel ?? active.path) && draft !== file.content}
readOnlyIn={grantedFile?.root ?? file?.root}
onSave={() => void save()}
openWith={hub && !remote ? <OpenWithMenu hub={hub} root={workspaceRoot} onError={(e) => setFailed(reason(e))} /> : undefined}
onReveal={revealable && !grantedFile ? () => { setFailed(""); port.revealInFileManager(active.path).catch((e) => setFailed(reason(e))); } : undefined}
/>
{(fileFailed || failed) && !(mode === "preview" && previewTab) && (
{(fileFailed || failed) && mode !== "document" && !unpreviewableText && !(mode === "preview" && previewTab) && (
<div className="workbench-error" role="alert">
{fileFailed || failed}
</div>
)}
{mode === "preview" ? (
{mode === "document" || unpreviewableText ? (
<Suspense fallback={<div className="workbench-empty">{t("正在读取…")}</div>}>
<DocumentPreview key={filePath} port={port} path={filePath} shown={shown && !showFiles && !launcher}
view={documentState(documentStates, filePath)}
refresh={JSON.stringify([changeKey, glance, running, wrote])}
onOpenExternal={!remote && port.opensFiles() && /\.(pdf|html?|svg|docx?|xlsx?|pptx?)$/i.test(filePath) ? () => {
const path = grantedFile ? `${grantedFile.root}/${grantedFile.rel}` : filePath;
void port.openDocument(path).catch((e) => setFailed(reason(e)));
} : undefined} />
{failed && <div className="workbench-error" role="alert">{failed}</div>}
</Suspense>
) : mode === "preview" ? (
previewTab ? (
<AgentBrowserPanel tabs={[previewTab]} shown={shown && !launcher} showTabs={false} onOpenDocument={(path) => port.openDocument(path)} />
) : (
@@ -673,7 +640,7 @@ export function WorkbenchPanel({
</Suspense>
) : null}
</>
))}
)}
</div>
</main>
<aside className="workbench-explorer">
@@ -1,18 +0,0 @@
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>
</>;
}
@@ -23,6 +23,7 @@ it.each(['event-first', 'reply-first'])('preserves newer Files when preview work
const open = vi.spyOn(port,'browserOpen').mockReturnValue(new Promise((_,reject)=>{fail=reject;}));
const {rerender} = render(<WorkbenchPanel {...props} port={port} tabs={[agent]} />);
fireEvent.click(await screen.findByRole('button',{name:'index.html'}));
fireEvent.click(await screen.findByRole('button',{name:'交互运行'}));
await waitFor(()=>expect(workspaces).toHaveBeenCalled());
expect(open).not.toHaveBeenCalled();
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
@@ -124,6 +125,7 @@ it('follows a genuine agent tab while an older preview is still preparing its wo
const open = vi.spyOn(port, 'browserOpen').mockResolvedValue(page);
const {rerender} = render(<WorkbenchPanel {...props} port={port} tabs={[agent]} />);
fireEvent.click(await screen.findByRole('button',{name:'index.html'}));
fireEvent.click(await screen.findByRole('button',{name:'交互运行'}));
await waitFor(()=>expect(lookup).toHaveBeenCalled());
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
const fresh = {...agent,id:'fresh',target:'tf',title:'Fresh',active:false};
@@ -40,16 +40,16 @@ describe("browser wide mode", () => {
const { container, width } = open();
const frame = await screen.findByLabelText("网页地址");
const composer = container.querySelector(".compose");
const button = screen.getByRole("button", { name: "加宽浏览器" });
const button = screen.getByRole("button", { name: "加宽工作台" });
expect(button.getAttribute("aria-pressed")).toBe("false");
fireEvent.click(button);
expect(container.querySelector(".pane")?.getAttribute("data-browser-size")).toBe("wide");
expect(screen.getByRole("button", { name: "恢复浏览器分栏" }).getAttribute("aria-pressed")).toBe("true");
expect(screen.getByRole("button", { name: "恢复分栏" }).getAttribute("aria-pressed")).toBe("true");
expect(screen.getByLabelText("网页地址")).toBe(frame);
expect(container.querySelector(".compose")).toBe(composer);
expect(composer?.hasAttribute("hidden")).toBe(false);
expect(container.querySelector(".pbody")?.hasAttribute("data-full")).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "恢复浏览器分栏" }));
fireEvent.click(screen.getByRole("button", { name: "恢复分栏" }));
expect(container.querySelector(".pane")?.getAttribute("data-browser-size")).toBe("split");
expect(screen.getByLabelText("网页地址")).toBe(frame);
expect(width).not.toHaveBeenCalled();
@@ -90,17 +90,17 @@ describe("browser wide mode", () => {
it("resets to split after closing the dock without replacing the page", async () => {
open();
const frame = await screen.findByLabelText("网页地址");
fireEvent.click(screen.getByRole("button", { name: "加宽浏览器" }));
fireEvent.click(screen.getByRole("button", { name: "加宽工作台" }));
fireEvent.click(screen.getByRole("button", { name: "工作台" }));
fireEvent.click(screen.getByRole("button", { name: "工作台" }));
expect(screen.getByRole("button", { name: "加宽浏览器" }).getAttribute("aria-pressed")).toBe("false");
expect(screen.getByRole("button", { name: "加宽工作台" }).getAttribute("aria-pressed")).toBe("false");
expect(screen.getByLabelText("网页地址")).toBe(frame);
});
it("gives a transient layer first use of Escape", async () => {
const { container, close } = open();
await screen.findByLabelText("网页地址");
fireEvent.click(screen.getByRole("button", { name: "加宽浏览器" }));
fireEvent.click(screen.getByRole("button", { name: "加宽工作台" }));
const dismiss = (e: KeyboardEvent) => e.stopPropagation();
window.addEventListener("keydown", dismiss, true);
try {
@@ -124,9 +124,9 @@ describe("browser wide mode", () => {
const { container, close } = open();
const frame = await screen.findByLabelText("网页地址");
const composer = container.querySelector(".compose");
fireEvent.click(screen.getByRole("button", { name: "全屏浏览器" }));
fireEvent.click(screen.getByRole("button", { name: "全屏工作台" }));
expect(container.querySelector(".pane")?.getAttribute("data-browser-size")).toBe("full");
expect(screen.getByRole("button", { name: "退出全屏" }).getAttribute("aria-pressed")).toBe("true");
expect(screen.getByRole("button", { name: "退出工作台全屏" }).getAttribute("aria-pressed")).toBe("true");
expect(screen.getByLabelText("网页地址")).toBe(frame);
expect(container.querySelector(".compose")).toBe(composer);
fireEvent.keyDown(document.body, { key: "Escape" });
@@ -155,7 +155,7 @@ describe("browser wide mode", () => {
vi.spyOn(port, "subscribe").mockImplementation((onEvent) => { deliver = onEvent; return () => true; });
});
await screen.findByLabelText("网页地址");
fireEvent.click(screen.getByRole("button", { name: "全屏浏览器" }));
fireEvent.click(screen.getByRole("button", { name: "全屏工作台" }));
expect(container.querySelector(".pane")?.getAttribute("data-browser-size")).toBe("full");
waiting = true;
await act(async () => { deliver?.({ kind: "ask_request" } as WireEvent); });
@@ -197,7 +197,8 @@ it.each([
onRegenerate: vi.fn(), canBranch: () => true, onBranch };
render(<SayCard item={{ t: "say", id: "final", text: "answer", done: true }} reply={reply} />);
const button = screen.getByRole("button", { name: label });
expect(button.title).toBe(label);
fireEvent.focus(button);
expect(screen.getByRole("tooltip").textContent).toBe(label);
await userEvent.click(button);
expect(onBranch).toHaveBeenCalledExactlyOnceWith("final");
});
+13 -8
View File
@@ -11,6 +11,7 @@ import { thoughtStartedAt } from "../../state/say";
import { LazyMarkdown } from "../LazyMarkdown";
import { Boundary } from "../Boundary";
import { CopyButton } from "../CopyButton";
import { TooltipButton } from "../TooltipButton";
import { useRevealed } from "../reveal";
import { useShown } from "../shown";
import { ReplyMenu } from "./ReplyMenu";
@@ -67,6 +68,10 @@ export interface Quote {
* individual reply and requires a paired checkpoint. */
export interface ReplyActions {
files?: LocalRefs;
activeFile?: string;
onAddFile?: (reference: string) => void;
openDeliveredFile?: (path: string) => Promise<void>;
revealDeliveredFile?: (path: string) => Promise<void>;
onQuote: (text: string, id: string) => void;
onQuoteFile?: (text: string, source: string) => void;
canRegenerate: (id: string) => boolean;
@@ -185,20 +190,20 @@ export function SayCard({ item, afterAnswer, settled = true, reply, quiet }: { i
<div className="acts">
<CopyButton text={item.text} iconOnly />
{reply && (
<button type="button" data-action="reply.quote" data-target={item.id} title={t("引用到输入框")} aria-label={t("引用到输入框")} onClick={() => reply.onQuote(selectedIn(answer.current) || item.text, item.id)}>
<TooltipButton type="button" data-action="reply.quote" data-target={item.id} title={t("引用到输入框")} aria-label={t("引用到输入框")} onClick={() => reply.onQuote(selectedIn(answer.current) || item.text, item.id)}>
<StudioIcon name="quote" />
</button>
</TooltipButton>
)}
{reply?.canBranch(item.id) && (
<button type="button" data-action="reply.branch" title={t("创建对话分支")} aria-label={t("创建对话分支")} disabled={branching} onClick={() => { setBranching(true); void Promise.resolve(reply.onBranch(item.id)).finally(() => setBranching(false)); }}>
<TooltipButton type="button" data-action="reply.branch" title={t("创建对话分支")} aria-label={t("创建对话分支")} disabled={branching} onClick={() => { setBranching(true); void Promise.resolve(reply.onBranch(item.id)).finally(() => setBranching(false)); }}>
<StudioIcon name={branching ? "clock" : "fork"} />
</button>
</TooltipButton>
)}
{reply?.canRegenerate(item.id) && (
<span className="acts-menu">
<button ref={retry} type="button" data-action="reply.retry" title={t("重新生成")} aria-label={t("重新生成")} aria-expanded={menu === "retry"} onClick={() => setMenu((m) => (m === "retry" ? "" : "retry"))}>
<TooltipButton ref={retry} type="button" data-action="reply.retry" title={t("重新生成")} aria-label={t("重新生成")} aria-expanded={menu === "retry"} onClick={() => setMenu((m) => (m === "retry" ? "" : "retry"))}>
<StudioIcon name="refresh" />
</button>
</TooltipButton>
<ReplyMenu anchor={retry} open={menu === "retry"} onClose={shut}>
<div className="acts-pop-head">
{t("重新生成")}
@@ -218,9 +223,9 @@ export function SayCard({ item, afterAnswer, settled = true, reply, quiet }: { i
)}
{reply && (
<span className="acts-menu">
<button ref={more} type="button" data-action="reply.more" title={t("更多")} aria-label={t("更多")} aria-expanded={menu === "more"} onClick={() => setMenu((m) => (m === "more" ? "" : "more"))}>
<TooltipButton ref={more} type="button" data-action="reply.more" title={t("更多")} aria-label={t("更多")} aria-expanded={menu === "more"} onClick={() => setMenu((m) => (m === "more" ? "" : "more"))}>
<StudioIcon name="more" />
</button>
</TooltipButton>
<ReplyMenu anchor={more} open={menu === "more"} onClose={shut}>
<div className="acts-pop-head">{t("这条回复")}</div>
<button type="button" role="menuitem" data-action="reply.download" onClick={() => { setMenu(""); download(item.text); }}>
@@ -89,12 +89,12 @@ 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(image.getAttribute("src")).toMatch(/^blob:/);
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("图片加载失败");
expect(screen.getByRole("alert").textContent).toContain("图片不可用");
});
it("keeps a conversation file selected when the column already has an agent browser", async () => {
@@ -0,0 +1,53 @@
import { useEffect, useRef, useState } from "react";
import { t } from "../../i18n";
import type { DocumentViewState } from "./viewState";
export default function DelimitedPreview({ text, path, view }: { text: string; path: string; view: DocumentViewState }) {
const [result, setResult] = useState<{ text: string; rows?: string[][]; error?: boolean }>();
const [top, setTop] = useState(view.top ?? 0);
const [left, setLeft] = useState(view.left ?? 0);
const scroll = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scroll.current) { scroll.current.scrollTop = view.top ?? 0; scroll.current.scrollLeft = view.left ?? 0; }
}, [result, view]);
useEffect(() => {
const worker = new Worker(new URL("./delimited.worker.ts", import.meta.url), { type: "module" });
const timer = window.setTimeout(() => { worker.terminate(); setResult({ text, error: true }); }, 10_000);
worker.onmessage = (event: MessageEvent<{ rows?: string[][]; error?: boolean }>) => {
window.clearTimeout(timer);
setResult({ text, ...event.data });
worker.terminate();
};
worker.onerror = () => { window.clearTimeout(timer); setResult({ text, error: true }); worker.terminate(); };
worker.postMessage({ text, delimiter: /\.tsv$/i.test(path) ? "\t" : "," });
return () => { window.clearTimeout(timer); worker.terminate(); };
}, [text, path]);
if (result?.text !== text) return <p role="status">{t("正在读取…")}</p>;
if (result.error || !result.rows) return <p role="alert">{t("表格无法解析或超过 10 万个单元格,请切换源码查看")}</p>;
const rows = result.rows, height = 30;
const start = Math.min(Math.max(0, rows.length - 1), Math.max(0, Math.floor(top / height) - 10));
const visible = rows.slice(start, start + 80);
const columns = rows.reduce((n, row) => Math.max(n, row.length), 0);
const firstColumn = Math.min(Math.max(0, columns - 1), Math.max(0, Math.floor(left / 160) - 2));
const count = Math.min(20, columns - firstColumn);
return <div className="document-table" ref={scroll} data-action="document.scroll" data-target={path} onScroll={(e) => {
view.top = e.currentTarget.scrollTop; view.left = e.currentTarget.scrollLeft;
setTop(view.top); setLeft(view.left);
}}>
<table aria-label={path} aria-rowcount={rows.length} aria-colcount={columns} style={{ width: columns * 160 + 48, tableLayout: "fixed" }}><colgroup>
<col style={{ width: 48 }} />
{firstColumn > 0 && <col style={{ width: firstColumn * 160 }} />}
{Array.from({ length: count }, (_, i) => <col key={i} style={{ width: 160 }} />)}
{columns > firstColumn + count && <col style={{ width: (columns - firstColumn - count) * 160 }} />}
</colgroup><tbody>
<tr aria-hidden="true" style={{ height: start * height }}><td /></tr>
{visible.map((row, i) => <tr key={start + i} aria-rowindex={start + i + 1} style={{ height }}>
<th scope="row">{start + i + 1}</th>
{firstColumn > 0 && <td aria-hidden="true" />}
{Array.from({ length: count }, (_, col) => <td key={col} aria-colindex={firstColumn + col + 1} title={row[firstColumn + col] ?? ""}>{row[firstColumn + col] ?? ""}</td>)}
{columns > firstColumn + count && <td aria-hidden="true" />}
</tr>)}
<tr aria-hidden="true" style={{ height: Math.max(0, rows.length - start - visible.length) * height }}><td /></tr>
</tbody></table>
</div>;
}
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { DeliveryCard } from "./DeliveryCard";
import type { ReplyActions } from "../cards/SayCard";
afterEach(cleanup);
it.each(["line\nbreak.md", "trailing\\", "github:report.md", "notes)"])("does not add an ambiguous file reference for %j", async (path) => {
const user = userEvent.setup(), reply = actions(), add = vi.fn();
reply.onAddFile = add;
render(<DeliveryCard files={[{ path }]} reply={reply} />);
await user.click(screen.getByRole("button", { name: /^更多操作/ }));
const item = screen.getByRole("menuitem", { name: "添加到对话" }) as HTMLButtonElement;
expect(item.disabled).toBe(true);
const copy = screen.getByRole("menuitem", { name: "复制路径" });
await waitFor(() => expect(document.activeElement).toBe(copy));
await user.click(item);
expect(add).not.toHaveBeenCalled();
});
it("opens the structured file identity without interpreting percent escapes or Windows separators", () => {
const open = vi.fn();
const file = { root: "D:\\Library", path: "方案 100%.md", description: "Report" };
const select = vi.fn(() => open);
const reply: ReplyActions = {
files: { file: select, link: () => null, image: () => null },
onQuote: vi.fn(), canRegenerate: () => false, hasLaterTurns: () => false,
onRegenerate: vi.fn(), canBranch: () => false, onBranch: vi.fn(),
};
const { getByRole } = render(<DeliveryCard files={[file]} reply={reply} />);
fireEvent.click(getByRole("button", { name: /^预览.*方案 100%.md/ }));
expect(select).toHaveBeenCalledWith(file);
expect(open).toHaveBeenCalledOnce();
});
const actions = (): ReplyActions => ({
files: { file: () => vi.fn(), link: () => null, image: () => null },
onQuote: vi.fn(), canRegenerate: () => false, hasLaterTurns: () => false,
onRegenerate: vi.fn(), canBranch: () => false, onBranch: vi.fn(),
});
it("keeps auxiliary actions in a menu and adds a file without opening its preview", async () => {
const user = userEvent.setup();
const open = vi.fn(), add = vi.fn(), reply = actions();
reply.files!.file = () => open;
reply.onAddFile = add;
render(<DeliveryCard files={[{ path: "report.pdf", description: "Quarterly report" }]} reply={reply} />);
expect(screen.queryByRole("button", { name: "添加到对话" })).toBeNull();
await user.click(screen.getByRole("button", { name: /更多.*report.pdf/ }));
await user.click(screen.getByRole("menuitem", { name: "添加到对话" }));
expect(add).toHaveBeenCalledWith("@report.pdf");
expect(open).not.toHaveBeenCalled();
expect(screen.queryByRole("menu")).toBeNull();
await screen.findByText("已添加到输入框");
});
it("uses full file identity for the current-view indicator and distinguishes equal basenames", () => {
const reply = { ...actions(), activeFile: "docs/report.md" };
const files = [{ path: "docs/report.md" }, { path: "other/report.md" }];
const { rerender } = render(<DeliveryCard files={files} reply={reply} />);
const buttons = screen.getAllByRole("button", { name: /^预览/ });
expect(buttons[0].getAttribute("aria-current")).toBe("true");
expect(buttons[1].hasAttribute("aria-current")).toBe(false);
expect(screen.getAllByText("查看中")).toHaveLength(1);
expect(screen.getByText("docs")).toBeTruthy();
expect(screen.getByText("other")).toBeTruthy();
rerender(<DeliveryCard files={files} reply={{ ...reply, activeFile: undefined }} />);
expect(screen.queryByText("查看中")).toBeNull();
});
it("supports keyboard menus and restores focus when Escape closes them", async () => {
const user = userEvent.setup();
render(<DeliveryCard files={[{ path: "notes.md" }]} reply={actions()} />);
const more = screen.getByRole("button", { name: /更多.*notes.md/ });
await user.click(more);
const item = screen.getByRole("menuitem", { name: "复制路径" });
await waitFor(() => expect(document.activeElement).toBe(item));
await user.keyboard("{Escape}");
expect(screen.queryByRole("menu")).toBeNull();
expect(document.activeElement).toBe(more);
});
@@ -0,0 +1,91 @@
import { useCallback, useEffect, useId, useRef, useState } from "react";
import type { DeliveredFile } from "../../port/workspace";
import type { ReplyActions } from "../cards/SayCard";
import { copyText } from "../CopyButton";
import { t } from "../../i18n";
import { reason } from "../../i18n/kernel";
import "./delivery.css";
import { StudioIcon } from "../StudioIcon";
import { TooltipButton } from "../TooltipButton";
import { grantedFilePath } from "../grantedfiles";
import { DeliveryMenu, type DeliveryAction } from "./DeliveryMenu";
import { FileTypeIcon } from "./FileTypeIcon";
import { fileReference } from "../fileReference";
export function DeliveryCard({ files, reply }: { files: DeliveredFile[]; reply?: ReplyActions }) {
const counts = new Map<string, number>();
for (const file of files) {
const name = file.path.split("/").at(-1)!;
counts.set(name, (counts.get(name) ?? 0) + 1);
}
return <div className="delivery-files" aria-label={t("交付文件")}>
<div className="delivery-grid" data-single={files.length === 1 ? "" : undefined}>
{files.map((file) => <DeliveryEntry key={JSON.stringify([file.root, file.path])}
file={file} reply={reply} showLocation={(counts.get(file.path.split("/").at(-1)!) ?? 0) > 1} />)}
</div>
</div>;
}
function DeliveryEntry({ file, reply, showLocation }: { file: DeliveredFile; reply?: ReplyActions; showLocation: boolean }) {
const path = file.root ? `${file.root.replace(/[\\/]+$/, "")}/${file.path}` : file.path;
const href = path.split("/").map(encodeURIComponent).join("/");
const open = reply?.files?.file?.(file) ?? reply?.files?.link(href);
const absolute = file.root ? path : reply?.files?.title?.(href) || path;
const reference = fileReference(path);
const name = file.path.split("/").at(-1)!;
const dot = name.lastIndexOf(".");
const stem = dot > 0 ? name.slice(0, dot) : name, extension = dot > 0 ? name.slice(dot) : "";
const current = reply?.activeFile === (file.root ? grantedFilePath(file.root, file.path) : file.path);
const location = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : file.root;
const description = file.description?.trim() || extension.slice(1).toUpperCase() || t("文件");
const [menu, setMenu] = useState(false);
const close = useCallback(() => setMenu(false), []);
const trigger = useRef<HTMLButtonElement>(null);
const [feedback, setFeedback] = useState<{ kind: "busy" | "success" | "error"; text: string }>();
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const live = useRef(true);
const detailID = useId();
useEffect(() => { live.current = true; return () => { live.current = false; clearTimeout(timer.current); }; }, []);
const run = async (action: () => void | Promise<void>, success: string) => {
if (feedback?.kind === "busy") return;
clearTimeout(timer.current);
setFeedback({ kind: "busy", text: t("正在处理…") });
try {
await action();
if (!live.current) return;
setFeedback({ kind: "success", text: success });
timer.current = setTimeout(() => { if (live.current) setFeedback(undefined); }, 3000);
} catch (error) {
if (live.current) setFeedback({ kind: "error", text: reason(error) });
}
};
const actions: DeliveryAction[] = [];
if (reply?.onAddFile) actions.push({ kind: "add", label: t("添加到对话"), icon: "plus", disabled: !reference,
title: reference ? undefined : t("此名称不能表示为 @ 引用"),
run: () => void run(() => reply.onAddFile!(reference), t("已添加到输入框")) });
if (reply?.openDeliveredFile && /\.(md|markdown|txt|csv|tsv|pdf|html?|svg|docx?|xlsx?|pptx?)$/i.test(path))
actions.push({ kind: "open", label: t("用系统应用打开"), icon: "external", run: () => void run(() => reply.openDeliveredFile!(absolute), t("已请求系统应用打开")) });
if (!file.root && reply?.revealDeliveredFile)
actions.push({ kind: "reveal", label: t("在文件夹中显示"), icon: "folder", run: () => void run(() => reply.revealDeliveredFile!(file.path), t("已请求显示文件位置")) });
actions.push({ kind: "copy", label: t("复制路径"), icon: "copy", run: () => void run(() => copyText(absolute), t("已复制路径")) });
return <div className="delivery-file" data-current={current ? "" : undefined}>
<button type="button" className="delivery-open" data-action="delivery.preview" data-target={path} disabled={!open} title={`${absolute}\n${description}`}
aria-label={t("预览 {name}", { name })} aria-describedby={detailID} aria-current={current ? "true" : undefined} onClick={() => open?.()}>
<FileTypeIcon path={file.path} />
<span className="delivery-details">
<span className="delivery-heading">
<strong className="delivery-name"><span>{stem}</span><span className="delivery-extension">{extension}</span></strong>
{current && <span className="delivery-current">{t("查看中")}</span>}
</span>
{showLocation && <small className="delivery-location" title={absolute}>{location || t("项目根目录")}</small>}
<small id={detailID} className="delivery-description">{description}</small>
</span>
</button>
<TooltipButton ref={trigger} className="delivery-more" data-action="delivery.more" data-target={path} title={t("更多操作")} aria-label={t("更多操作:{name}", { name })}
aria-haspopup="menu" aria-expanded={menu} disabled={feedback?.kind === "busy"} onClick={() => setMenu((value) => !value)}>
<span>{t("更多")}</span><StudioIcon name="down" />
</TooltipButton>
{menu && <DeliveryMenu anchor={trigger} label={t("文件操作:{name}", { name })} target={path} actions={actions} onClose={close} />}
{feedback && <div className="delivery-feedback" role={feedback.kind === "error" ? "alert" : "status"} data-kind={feedback.kind}>{feedback.text}</div>}
</div>;
}
@@ -0,0 +1,52 @@
import { useCallback, useRef, type RefObject } from "react";
import { createPortal } from "react-dom";
import { useDismiss } from "../dismiss";
import { beside, pinToViewport, useFollow } from "../place";
import { StudioIcon, type StudioIconName } from "../StudioIcon";
export interface DeliveryAction {
kind: "add" | "open" | "reveal" | "copy";
label: string;
icon: StudioIconName;
disabled?: boolean;
title?: string;
run: () => void;
}
export function DeliveryMenu({ anchor, label, target, actions, onClose }: {
anchor: RefObject<HTMLButtonElement | null>; label: string;
actions: DeliveryAction[]; target: string; onClose: () => void;
}) {
const menu = useRef<HTMLDivElement>(null);
const close = useCallback(() => {
if (menu.current?.contains(document.activeElement)) anchor.current?.focus({ preventScroll: true });
onClose();
}, [anchor, onClose]);
useDismiss(true, anchor, close, menu);
const place = useCallback(() => {
if (!anchor.current || !menu.current) return;
const rect = anchor.current.getBoundingClientRect(), box = menu.current.getBoundingClientRect();
pinToViewport(menu.current, rect.right - box.width, beside(rect, box.height, innerHeight, "below", 6));
}, [anchor]);
useFollow(true, place);
return createPortal(<div className="delivery-menu" role="menu" aria-label={label} ref={menu} data-action="delivery.menu-navigation"
onKeyDown={(event) => {
if (event.key === "Tab") { close(); return; }
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const items = [...event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled)')];
const at = items.indexOf(document.activeElement as HTMLButtonElement);
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1
: (at + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
items[next]?.focus();
}}>
{actions.map((action, index) => {
const add = action.kind === "add", open = action.kind === "open", reveal = action.kind === "reveal";
return <button key={action.label} type="button" role="menuitem" data-target={target}
data-action={add ? "delivery.add-to-chat" : open ? "workbench.open-file" : reveal ? "workbench.reveal" : "workbench.copy-path"}
disabled={action.disabled} title={action.title} autoFocus={index === actions.findIndex((item) => !item.disabled)}
onClick={() => { close(); action.run(); }}>
<StudioIcon name={action.icon} /><span>{action.label}</span>
</button>; })}
</div>, document.body);
}
@@ -0,0 +1,67 @@
import { lazy, Suspense, useEffect, useState } from "react";
import type { AgentPort } from "../../port/port";
import { t } from "../../i18n";
import { useDocument } from "./useDocument";
import { Boundary } from "../Boundary";
import "./document.css";
import type { DocumentViewState } from "./viewState";
const PdfPreview = lazy(() => import("./PdfPreview"));
const DelimitedPreview = lazy(() => import("./DelimitedPreview"));
const StaticHtmlPreview = lazy(() => import("./StaticHtmlPreview"));
function Picture({ data, path, view }: { data: ArrayBuffer; path: string; view: DocumentViewState }) {
const [url, setURL] = useState("");
const [zoom, updateZoom] = useState(view.zoom ?? 1);
const setZoom = (value: number) => { view.zoom = value; updateZoom(value); };
const [failed, setFailed] = useState(false);
useEffect(() => {
const type = /\.svg$/i.test(path) ? "image/svg+xml" : undefined;
const url = URL.createObjectURL(new Blob([data], { type }));
setURL(url); setFailed(false);
return () => URL.revokeObjectURL(url);
}, [data, path]);
return <>
<div className="document-controls">
<button data-action="document.zoom" data-target={path} onClick={() => setZoom(Math.max(0.25, zoom - 0.25))} aria-label={t("缩小")}>−</button>
<button data-action="document.zoom" data-target={path} onClick={() => setZoom(1)}>{Math.round(zoom * 100)}%</button>
<button data-action="document.zoom" data-target={path} onClick={() => setZoom(Math.min(4, zoom + 0.25))} aria-label={t("放大")}>+</button>
</div>
{failed ? <p role="alert">{t("图片不可用")}</p> : <div className="document-picture">
{url && <img src={url} alt={path} style={{ width: `${zoom * 100}%`, maxWidth: "none" }} onError={() => setFailed(true)} />}
</div>}
</>;
}
export default function DocumentPreview({ port, path, shown, refresh, onOpenExternal, view }: {
port: AgentPort; path: string; shown: boolean; refresh: string; onOpenExternal?: () => void;
view: DocumentViewState;
}) {
const { value, reload } = useDocument(port, path, shown, refresh);
const { info, data, error } = value ?? {};
const fallback = <p role="alert">{t("此文件暂不支持预览")}</p>;
let body = <p role="status">{t("正在读取…")}</p>;
if (error) body = <p role="alert">{error}</p>;
else if (info?.kind === "unsupported") body = fallback;
else if (info && info.size > info.limit) body = <p role="alert">{t("文件超过预览大小限制")}</p>;
else if (data && info) {
const text = () => new TextDecoder("utf-8", { fatal: true }).decode(data);
switch (info.kind) {
case "pdf": body = <PdfPreview data={data} view={view} />; break;
case "table": body = <DelimitedPreview path={path} text={text()} view={view} />; break;
case "html": body = <StaticHtmlPreview text={text()} />; break;
case "image": body = <Picture data={data} path={path} view={view} />; break;
default: body = fallback;
}
}
return <div className="document-preview">
<div className="document-controls">
{info && <span>{info.size.toLocaleString()} B</span>}
<button data-action="document.refresh" data-target={path} onClick={reload}>{t("刷新")}</button>
{onOpenExternal && <button data-action="workbench.open-file" data-target={path} onClick={onOpenExternal}>{t("用系统应用打开")}</button>}
</div>
<Boundary fallback={fallback} retryKey={info?.version}>
<Suspense fallback={<p role="status">{t("正在读取…")}</p>}>{body}</Suspense>
</Boundary>
</div>;
}
@@ -0,0 +1,28 @@
import { expect, it } from "vitest";
import { fileAppearance } from "./FileTypeIcon";
it.each([
["方案.MDX", "markdown"], ["D:\\报告\\REPORT.PDF", "pdf"], ["data.csv", "spreadsheet"],
["budget.xlsx", "spreadsheet"], ["合同.docx", "document"], ["slides.key", "presentation"],
["index.html", "html"], ["photo.HEIC", "image"], ["art.svg", "vector"],
["design.psd", "design"], ["song.flac", "audio"], ["demo.mov", "video"],
["backup.tar.gz", "archive"], ["font.woff2", "font"], ["db.sqlite3", "database"],
["data.jsonl", "data"], ["feed.xml", "markup"], ["theme.scss", "stylesheet"],
[".env.production", "config"], ["config.yaml", "config"], ["start.ps1", "script"],
["app.tsx", "code"], ["analysis.py", "code"], ["notebook.ipynb", "notebook"],
["server.log", "text"], ["book.epub", "ebook"], ["app.dmg", "package"],
["module.wasm", "binary"], ["mesh.glb", "model"], ["drawing.dwg", "cad"],
["cert.pem", "certificate"], ["message.eml", "mail"], ["event.ics", "calendar"],
["fix.patch", "diff"], ["flow.mmd", "diagram"], ["data.parquet", "dataset"],
["Dockerfile.dev", "container"], [".gitignore", "git"], ["Makefile", "script"],
["LICENSE", "text"], ["README", "text"], ["foo.unknown", "unknown"],
["report.pdf.exe", "binary"], ["folder.pdf/opaque", "unknown"],
])("classifies %s for display as %s", (path, kind) => {
expect(fileAppearance(path).kind).toBe(kind);
});
it("distinguishes code languages and PDF with short labels", () => {
expect(fileAppearance("main.ts").badge).toBe("TS");
expect(fileAppearance("main.py").badge).toBe("PY");
expect(fileAppearance("report.pdf").badge).toBe("PDF");
});
@@ -0,0 +1,85 @@
import {
BookOpen, Box, Braces, CalendarDays, CodeXml, Container, Database, File,
FileArchive, FileAxis3d, FileChartColumn, FileCode, FileCog, FileDiff,
FileDigit, FileImage, FileKey, FileMusic, FilePenLine, FileScan,
FileSpreadsheet, FileTerminal, FileText, FileType, FileVideoCamera,
GitBranch, Mail, NotebookPen, Package, Palette, PenTool, Presentation,
Workflow, type LucideIcon,
} from "lucide-react";
const groups = [
{ kind: "markdown", icon: FilePenLine, extensions: "md markdown mdx mdown mkd" },
{ kind: "pdf", icon: FileScan, extensions: "pdf" },
{ kind: "spreadsheet", icon: FileSpreadsheet, extensions: "csv tsv xls xlsx xlsm xlsb ods numbers" },
{ kind: "document", icon: FileText, extensions: "doc docx docm dot dotx odt rtf pages" },
{ kind: "presentation", icon: Presentation, extensions: "ppt pptx pptm pps ppsx odp key" },
{ kind: "html", icon: CodeXml, extensions: "html htm xhtml" },
{ kind: "image", icon: FileImage, extensions: "png jpg jpeg gif webp avif bmp ico tif tiff heic heif raw dng" },
{ kind: "vector", icon: PenTool, extensions: "svg eps ai" },
{ kind: "design", icon: Palette, extensions: "psd psb sketch fig xd afdesign afphoto xcf" },
{ kind: "audio", icon: FileMusic, extensions: "mp3 wav flac aac m4a ogg opus aiff wma mid midi" },
{ kind: "video", icon: FileVideoCamera, extensions: "mp4 mov mkv webm avi m4v wmv flv mpeg mpg" },
{ kind: "archive", icon: FileArchive, extensions: "zip 7z rar tar gz tgz bz2 tbz2 xz txz zst cab" },
{ kind: "font", icon: FileType, extensions: "ttf otf woff woff2 eot" },
{ kind: "database", icon: Database, extensions: "sql db sqlite sqlite3 mdb accdb" },
{ kind: "data", icon: Braces, extensions: "json jsonc jsonl ndjson geojson" },
{ kind: "markup", icon: FileCode, extensions: "xml xsd xsl xslt plist rss atom" },
{ kind: "stylesheet", icon: Palette, extensions: "css scss sass less styl" },
{ kind: "config", icon: FileCog, extensions: "yaml yml toml ini conf cfg config properties env editorconfig" },
{ kind: "script", icon: FileTerminal, extensions: "sh bash zsh fish ps1 bat cmd" },
{ kind: "code", icon: FileCode, extensions: "js jsx mjs cjs ts tsx mts cts py pyw go rs java kt kts c h cpp hpp cc cxx cs swift rb php lua r jl dart vue svelte ex exs erl hs scala clj cljs elm sol zig nim pl pm groovy gradle cmake make proto graphql gql" },
{ kind: "notebook", icon: NotebookPen, extensions: "ipynb rmd qmd" },
{ kind: "text", icon: FileText, extensions: "txt text log rst adoc asciidoc tex bib" },
{ kind: "ebook", icon: BookOpen, extensions: "epub mobi azw azw3 fb2" },
{ kind: "package", icon: Package, extensions: "dmg pkg deb rpm apk aab ipa msi msix appx jar war vsix crx" },
{ kind: "binary", icon: FileDigit, extensions: "exe dll so dylib bin wasm o a lib class" },
{ kind: "model", icon: FileAxis3d, extensions: "obj stl fbx glb gltf blend 3ds step stp" },
{ kind: "cad", icon: Box, extensions: "dwg dxf" },
{ kind: "certificate", icon: FileKey, extensions: "pem crt cer p12 pfx pub asc gpg" },
{ kind: "mail", icon: Mail, extensions: "eml msg mbox" },
{ kind: "calendar", icon: CalendarDays, extensions: "ics ical" },
{ kind: "diff", icon: FileDiff, extensions: "diff patch" },
{ kind: "diagram", icon: Workflow, extensions: "drawio mmd mermaid puml plantuml dot" },
{ kind: "dataset", icon: FileChartColumn, extensions: "parquet arrow feather h5 hdf5 mat npy npz" },
] as const;
type FileAppearance = { kind: string; icon: LucideIcon };
const byExtension = new Map<string, FileAppearance>(
groups.flatMap((group) => group.extensions.split(" ").map((extension) => [extension, group] as const)),
);
const byName: Record<string, FileAppearance> = {
dockerfile: { kind: "container", icon: Container },
containerfile: { kind: "container", icon: Container },
makefile: { kind: "script", icon: FileTerminal },
gnumakefile: { kind: "script", icon: FileTerminal },
justfile: { kind: "script", icon: FileTerminal },
".gitignore": { kind: "git", icon: GitBranch },
".gitattributes": { kind: "git", icon: GitBranch },
".gitmodules": { kind: "git", icon: GitBranch },
".npmrc": { kind: "config", icon: FileCog },
".yarnrc": { kind: "config", icon: FileCog },
".bashrc": { kind: "script", icon: FileTerminal },
".zshrc": { kind: "script", icon: FileTerminal },
license: { kind: "text", icon: FileText },
readme: { kind: "text", icon: FileText },
};
export function fileAppearance(path: string): FileAppearance & { badge?: string } {
const name = path.split(/[\\/]/).at(-1)!.toLowerCase();
const extension = name.includes(".") ? name.split(".").at(-1)! : "";
if (Object.hasOwn(byName, name)) return byName[name];
if (name === ".env" || name.startsWith(".env.")) return { kind: "config", icon: FileCog };
if (/^(dockerfile|containerfile)\./.test(name)) return { kind: "container", icon: Container };
const appearance = byExtension.get(extension) ?? { kind: "unknown", icon: File };
const badge = appearance.kind === "markdown" ? "MD"
: ["pdf", "code", "data", "markup", "stylesheet"].includes(appearance.kind) ? extension.toUpperCase() : undefined;
return { ...appearance, badge };
}
export function FileTypeIcon({ path }: { path: string }) {
const { icon: Icon, kind, badge } = fileAppearance(path);
return <span className="delivery-icon" data-file-kind={kind} aria-hidden="true">
<Icon size={22} strokeWidth={1.5} />
{badge && <span className="delivery-icon-badge">{badge}</span>}
</span>;
}
@@ -0,0 +1,25 @@
// @vitest-environment jsdom
import { cleanup, render } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
const pdf = vi.hoisted(() => ({ load: vi.fn(), destroy: vi.fn() }));
vi.mock("pdfjs-dist", () => ({
GlobalWorkerOptions: {}, TextLayer: vi.fn(), getDocument: pdf.load,
}));
import PdfPreview from "./PdfPreview";
afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.clearAllMocks(); });
it("keeps XFA off and releases the loading task when the file changes or closes", () => {
vi.stubGlobal("ResizeObserver", class { observe() {} disconnect() {} });
pdf.load.mockImplementation(() => ({ promise: new Promise(() => {}), destroy: pdf.destroy }));
const data = new ArrayBuffer(16);
const { rerender, unmount } = render(<PdfPreview data={data} view={{}} />);
expect(pdf.load).toHaveBeenCalledWith(expect.objectContaining({ enableXfa: false }));
expect(pdf.load.mock.calls[0][0].data).not.toBe(data);
rerender(<PdfPreview data={new ArrayBuffer(32)} view={{}} />);
expect(pdf.destroy).toHaveBeenCalledTimes(1);
expect(pdf.load).toHaveBeenLastCalledWith(expect.objectContaining({ enableXfa: false }));
unmount();
expect(pdf.destroy).toHaveBeenCalledTimes(2);
});
@@ -0,0 +1,90 @@
import { useEffect, useRef, useState } from "react";
import { getDocument, GlobalWorkerOptions, TextLayer, type PDFDocumentProxy } from "pdfjs-dist";
import workerURL from "pdfjs-dist/build/pdf.worker.min.mjs?url";
import "pdfjs-dist/web/pdf_viewer.css";
import { t } from "../../i18n";
import type { DocumentViewState } from "./viewState";
GlobalWorkerOptions.workerSrc = workerURL;
export default function PdfPreview({ data, view }: { data: ArrayBuffer; view: DocumentViewState }) {
const [doc, setDoc] = useState<PDFDocumentProxy>();
const [page, setPage] = useState(view.page ?? 1);
const [zoom, setZoom] = useState(view.zoom ?? 0);
useEffect(() => { view.page = page; view.zoom = zoom; }, [view, page, zoom]);
const [width, setWidth] = useState(600);
const [error, setError] = useState(false);
const frame = useRef<HTMLDivElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
const text = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!frame.current) return;
const observer = new ResizeObserver(([entry]) => setWidth(Math.max(100, entry.contentRect.width - 24)));
observer.observe(frame.current);
return () => observer.disconnect();
}, []);
useEffect(() => {
let live = true;
setDoc(undefined); setError(false);
const assets = new URL("./pdfjs/", document.baseURI).href;
// PDF.js 6 removed eval and isEvalSupported (mozilla/pdf.js@f6bac014).
// This reader renders only canvas/text; it never creates a scripting manager.
const task = getDocument({
data: data.slice(0),
enableXfa: false,
cMapUrl: assets + "cmaps/", cMapPacked: true,
standardFontDataUrl: assets + "standard_fonts/", wasmUrl: assets + "wasm/",
});
task.onPassword = () => { if (live) setError(true); void task.destroy(); };
void task.promise.then((next) => {
if (live) { setDoc(next); setPage((n) => Math.min(n, next.numPages)); }
}).catch(() => { if (live) setError(true); });
return () => { live = false; void task.destroy(); };
}, [data]);
useEffect(() => {
if (!doc || !canvas.current || !text.current) return;
let live = true;
let cancel = () => {};
let layer: TextLayer | undefined;
const surface = canvas.current, overlay = text.current;
void doc.getPage(page).then(async (pdfPage) => {
if (!live) return;
const base = pdfPage.getViewport({ scale: 1 });
const scale = zoom || width / base.width;
const viewport = pdfPage.getViewport({ scale });
const ratio = Math.min(window.devicePixelRatio || 1, Math.sqrt(16_777_216 / (viewport.width * viewport.height)));
surface.width = Math.max(1, Math.floor(viewport.width * ratio));
surface.height = Math.max(1, Math.floor(viewport.height * ratio));
surface.style.width = `${viewport.width}px`;
surface.style.height = `${viewport.height}px`;
overlay.replaceChildren();
overlay.style.setProperty("--scale-factor", String(scale));
overlay.style.setProperty("--total-scale-factor", String(scale));
overlay.style.width = `${viewport.width}px`;
overlay.style.height = `${viewport.height}px`;
const render = pdfPage.render({ canvas: surface, viewport, transform: [ratio, 0, 0, ratio, 0, 0] });
cancel = () => render.cancel();
layer = new TextLayer({ textContentSource: pdfPage.streamTextContent(), container: overlay, viewport });
await Promise.all([render.promise, layer.render()]);
}).catch(() => { if (live) setError(true); });
return () => { live = false; cancel(); layer?.cancel(); };
}, [doc, page, zoom, width]);
return <div className="document-pdf">
<div className="document-controls">
<button data-action="document.page" disabled={page <= 1 || !doc} onClick={() => setPage((n) => n - 1)}>{t("上一页")}</button>
<span>{page} / {doc?.numPages ?? "—"}</span>
<button data-action="document.page" disabled={!doc || page >= doc.numPages} onClick={() => setPage((n) => n + 1)}>{t("下一页")}</button>
<select data-action="document.zoom" aria-label={t("缩放")} value={zoom} onChange={(e) => setZoom(Number(e.target.value))}>
<option value={0}>{t("适应宽度")}</option>
{[0.5, 1, 1.5, 2].map((n) => <option key={n} value={n}>{n * 100}%</option>)}
</select>
</div>
{error && <p role="alert">{t("PDF 无法显示,文件可能损坏或需要密码")}</p>}
{!doc && !error && <p role="status">{t("正在读取…")}</p>}
<div className="document-pdf-scroll" ref={frame}>
<div className="document-pdf-page" hidden={!doc || error}>
<canvas ref={canvas} /><div ref={text} className="textLayer" />
</div>
</div>
</div>;
}
@@ -0,0 +1,11 @@
import { useMemo } from "react";
import { t } from "../../i18n";
import { staticDocument } from "./staticDocument";
export default function StaticHtmlPreview({ text }: { text: string }) {
const document = useMemo(() => staticDocument(text), [text]);
return <>
<p className="document-notice">{t("静态预览,脚本和外部资源未加载")}</p>
<iframe className="document-html" title={t("HTML 静态预览")} sandbox="" srcDoc={document} />
</>;
}
@@ -0,0 +1,29 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { MockPort } from "../../port/mock";
import { WorkbenchPanel } from "../WorkbenchPanel";
import "../testkit";
afterEach(cleanup);
it("reports actual tab selection and clears it when the canvas is hidden or its file closes", async () => {
const port = new MockPort(), onActiveFile = vi.fn();
const props = { port, tabs: [], manual: false, shown: true, scheme: "light" as const, changes: [],
workspaceRoot: "/project", onSurfaces: vi.fn(), onCloseManual: vi.fn(), onExternal: vi.fn(), onActiveFile };
const view = render(<WorkbenchPanel {...props} fileRequest={{ path: "docs/one.md" }} />);
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("docs/one.md"));
view.rerender(<WorkbenchPanel {...props} fileRequest={{ path: "other/one.md" }} />);
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("other/one.md"));
view.rerender(<WorkbenchPanel {...props} />);
fireEvent.click(screen.getAllByRole("button", { name: "one.md" })[0]);
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("docs/one.md"));
fireEvent.click(screen.getByRole("button", { name: "文件" }));
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith(undefined));
fireEvent.click(screen.getByRole("button", { name: "文件" }));
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("docs/one.md"));
fireEvent.click(screen.getAllByRole("button", { name: "关闭 one.md" })[0]);
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("other/one.md"));
view.rerender(<WorkbenchPanel {...props} shown={false} />);
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith(undefined));
});
@@ -0,0 +1,9 @@
import { parseDelimited } from "./parseDelimited";
self.onmessage = (event: MessageEvent<{ text: string; delimiter: string }>) => {
try {
self.postMessage({ rows: parseDelimited(event.data.text, event.data.delimiter) });
} catch {
self.postMessage({ error: true });
}
};
@@ -0,0 +1,38 @@
import { expect, it } from "vitest";
import type { Item } from "../../state/session";
import { deliveriesByReply } from "./deliveries";
import { fromHistory, initialState, reduce } from "../../state/session";
it("keeps the latest declaration per file within a turn and preserves previous turns", () => {
const tool = (id: string, description: string): Item => ({ t: "tool", id, running: false, children: [], tool: {
name: "present_files", readOnly: true, deliveries: [{ path: "report.md", description }],
} });
const items: Item[] = [
{ t: "user", id: "u1", text: "one" },
tool("t1", "old"), tool("t2", "new"),
{ t: "say", id: "s1", text: "done", done: true },
{ t: "user", id: "u2", text: "two" },
tool("t3", "second"),
{ t: "say", id: "s2", text: "done", done: true },
];
const map = deliveriesByReply(items);
expect(map.get("s1")?.map((f) => f.description)).toEqual(["new"]);
expect(map.get("s2")?.map((f) => f.description)).toEqual(["second"]);
});
it("preserves structured deliveries through live folding and history restoration", () => {
const files = [{ root: "/granted", path: "report 100%.md", description: "Report" }];
const tool = { id: "present", name: "present_files", readOnly: true, output: "done", deliveries: files };
const live = reduce(reduce(initialState, { kind: "tool_dispatch", tool: { id: tool.id, name: tool.name, readOnly: true } }), { kind: "tool_result", tool });
const restored = fromHistory([
{ role: "assistant", content: "", toolCalls: [{ id: tool.id, name: tool.name }] },
{ role: "tool", content: "done", toolCallId: tool.id, toolName: tool.name, deliveries: files },
]);
expect([...deliveriesByReply(live.items).values()]).toEqual([files]);
expect([...deliveriesByReply(restored.items).values()]).toEqual([files]);
const failed = fromHistory([
{ role: "assistant", content: "", toolCalls: [{ id: tool.id, name: tool.name }] },
{ role: "tool", content: "failed", toolCallId: tool.id, toolName: tool.name, toolFailed: true, deliveries: files },
]);
expect(deliveriesByReply(failed.items).size).toBe(0);
});
@@ -0,0 +1,23 @@
import type { Item } from "../../state/session";
import type { DeliveredFile } from "../../port/workspace";
import { opensTurn } from "../blocks";
export function deliveriesByReply(items: Item[]): Map<string, DeliveredFile[]> {
const out = new Map<string, DeliveredFile[]>();
let files = new Map<string, DeliveredFile>();
let anchor = "", call = "";
const flush = () => {
if (files.size && (anchor || call)) out.set(anchor || call, [...files.values()]);
files = new Map(); anchor = ""; call = "";
};
for (const item of items) {
if (opensTurn(item)) flush();
if (item.t === "say" && item.text) anchor = item.id;
if (item.t === "tool" && !item.running && !item.tool.err && item.tool.deliveries?.length) {
call = item.id;
for (const file of item.tool.deliveries) files.set(JSON.stringify([file.root ?? "", file.path]), file);
}
}
flush();
return out;
}
@@ -0,0 +1,43 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { afterEach, expect, it, vi } from "vitest";
import { MockPort } from "../../port/mock";
import { Composer } from "../Composer";
import { DeliveryCard } from "./DeliveryCard";
import "../testkit";
afterEach(cleanup);
function DeliveryComposer({ path, submit }: { path: string; submit: (text: string) => Promise<boolean> }) {
const [inject, setInject] = useState({ n: 0, text: "" });
return <>
<DeliveryCard files={[{ path }]} reply={{
files: { file: () => vi.fn(), link: () => null, image: () => null, title: () => `C:\\Workspace\\${path}` },
onAddFile: (reference) => setInject((was) => ({ n: was.n + 1, text: reference })),
onQuote: vi.fn(), canRegenerate: () => false, hasLaterTurns: () => false,
onRegenerate: vi.fn(), canBranch: () => false, onBranch: vi.fn(),
}} />
<Composer port={new MockPort()} status={null} running={false} inject={inject}
onSubmit={submit} onChanged={vi.fn()} onError={vi.fn()} />
</>;
}
it.each([
["docs/方案 100%.md", "@docs/方案\\ 100%.md"],
[String.raw`before\ space.txt`, String.raw`@before\\ space.txt`],
[String.raw`repeat\\ space.txt`, String.raw`@repeat\\\ space.txt`],
])("adds %j to the real Composer without using the Windows display title as identity", async (path, reference) => {
const user = userEvent.setup(), submit = vi.fn().mockResolvedValue(true);
render(<DeliveryComposer path={path} submit={submit} />);
const box = screen.getByRole("combobox", { name: "任务输入" }) as HTMLTextAreaElement;
await user.click(box);
await user.keyboard("inspect ");
await user.click(screen.getByRole("button", { name: /^更多操作/ }));
await user.click(screen.getByRole("menuitem", { name: "添加到对话" }));
await waitFor(() => expect(box.value).toBe(`inspect ${reference} `));
expect(submit).not.toHaveBeenCalled();
await user.keyboard("please{Enter}");
await waitFor(() => expect(submit).toHaveBeenCalledWith(`inspect ${reference} please`));
});
@@ -0,0 +1,182 @@
.delivery-files {
container-type: inline-size;
min-width: 0;
margin: 12px 0;
}
.delivery-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
@container (min-width: 720px) {
.delivery-grid:not([data-single]) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.delivery-file {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
min-width: 0;
min-height: 72px;
border: 1px solid var(--border);
border-radius: var(--r-md, 8px);
background: var(--raised);
color: var(--text);
}
.delivery-file:hover {
background: var(--float-hi);
}
.delivery-file[data-current] {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
background: color-mix(in srgb, var(--accent) 5%, var(--raised));
}
.delivery-open {
display: flex;
align-items: center;
align-self: stretch;
gap: 10px;
min-width: 0;
padding: 12px;
border: 0;
border-radius: inherit;
background: transparent;
color: inherit;
text-align: start;
font: inherit;
cursor: pointer;
}
.delivery-open:focus-visible, .delivery-more:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -3px;
}
.delivery-icon {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 0 0 32px;
width: 32px;
height: 36px;
color: var(--muted);
}
.delivery-icon-badge {
font: 600 8px/1.2 var(--mono);
letter-spacing: .2px;
}
.delivery-icon svg {
width: 22px;
height: 22px;
}
.delivery-details {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
flex: 1;
}
.delivery-heading, .delivery-name {
display: flex;
align-items: baseline;
min-width: 0;
}
.delivery-heading {
gap: 8px;
}
.delivery-name {
font-size: var(--fs-ui);
font-weight: var(--w-med);
line-height: 1.45;
}
.delivery-name > span:first-child, .delivery-description, .delivery-location {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.delivery-extension {
flex: none;
color: var(--muted);
}
.delivery-description, .delivery-location {
display: block;
font-size: var(--fs-meta);
line-height: 1.5;
color: var(--muted);
}
.delivery-current {
flex: none;
color: var(--accent-ink);
font-size: var(--fs-micro);
white-space: nowrap;
}
.delivery-more {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 60px;
height: 32px;
margin: 0 8px 0 0;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: var(--r-sm);
background: var(--surface);
color: var(--text);
font: var(--w-med) var(--fs-meta) var(--ui);
cursor: pointer;
}
.delivery-more:hover, .delivery-more[aria-expanded="true"] {
background: var(--float-hi);
color: var(--text);
}
.delivery-more svg {
width: 14px;
height: 14px;
}
.delivery-menu {
position: fixed;
z-index: 60;
min-width: 200px;
max-width: calc(100vw / var(--zoom, 1) - 12px);
padding: 5px;
border: 1px solid var(--border);
border-radius: var(--r-md, 8px);
background: var(--float);
color: var(--text);
box-shadow: var(--shadow-float);
}
.delivery-menu button {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border: 0;
border-radius: var(--r-sm);
background: transparent;
color: inherit;
font: var(--fs-ui) var(--ui);
text-align: start;
cursor: pointer;
}
.delivery-menu button:hover:not(:disabled), .delivery-menu button:focus-visible {
background: var(--float-hi);
outline: none;
}
.delivery-menu button:disabled { color: var(--muted); cursor: default; }
.delivery-menu svg {
width: 16px;
height: 16px;
flex: none;
color: var(--muted);
}
.delivery-feedback {
grid-column: 1 / -1;
padding: 0 12px 10px 54px;
font-size: var(--fs-meta);
color: var(--muted);
overflow-wrap: anywhere;
}
.delivery-feedback[data-kind="error"] {
color: var(--err-ink);
}
@@ -0,0 +1,77 @@
.document-preview, .document-pdf {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.document-controls {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
flex-wrap: wrap;
}
.document-controls button, .document-controls select {
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
padding: 4px 8px;
border-radius: 6px;
}
.document-notice, .document-preview > p {
padding: 8px 12px;
}
.document-html {
flex: 1;
width: 100%;
min-height: 240px;
background: white;
border: 0;
}
.document-table, .document-pdf-scroll, .document-picture {
flex: 1;
min-height: 0;
overflow: auto;
}
.document-table table {
border-collapse: collapse;
font-size: 13px;
}
.document-table td, .document-table th {
padding: 0 8px;
border: 1px solid var(--border);
max-width: 400px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 29px;
}
.document-table th {
position: sticky;
left: 0;
background: var(--bg);
}
.document-table tr[aria-hidden] td {
padding: 0;
border: 0;
}
.document-pdf-scroll {
padding: 12px;
}
.document-pdf-page {
position: relative;
width: fit-content;
margin: auto;
}
.document-pdf-page canvas {
display: block;
}
.document-pdf-page .textLayer {
position: absolute;
inset: 0;
}
.document-picture img {
display: block;
}
@@ -0,0 +1,24 @@
// @vitest-environment jsdom
import { expect, it } from "vitest";
import { staticDocument } from "./staticDocument";
import { parseDelimited } from "./parseDelimited";
it("keeps CSV values literal including multiline quoted cells and leading zeros", () => {
expect(parseDelimited('id,value\n001,"hello\nworld"\n002,"=SUM(A1:A2)"', ",")).toEqual([
["id", "value"], ["001", "hello\nworld"], ["002", "=SUM(A1:A2)"],
]);
expect(parseDelimited('a\t"b\tc"\n1\t2', "\t")).toEqual([["a", "b\tc"], ["1", "2"]]);
expect(() => parseDelimited('"unterminated', ",")).toThrow();
expect(() => parseDelimited("a,b,c", ",", 2)).toThrow();
});
it("static HTML cannot execute or load remote resources", () => {
const html = staticDocument('<html><head><meta http-equiv="refresh" content="0;url=https://bad.test"><style>p{color:red}</style></head><body><script>alert(1)</script><p onclick="alert(1)">hello</p><iframe src="https://bad.test"></iframe><form action="https://bad.test"><input></form><img src="https://bad.test/x"><a href="https://bad.test">link</a></body></html>');
const doc = new DOMParser().parseFromString(html, "text/html");
expect(doc.querySelector("script,iframe,form,input,[onclick],[href]")).toBeNull();
expect(doc.querySelector('meta[http-equiv="refresh"]')).toBeNull();
expect(doc.querySelector("img")?.getAttribute("src")).toBeNull();
expect(doc.querySelector("p")?.textContent).toBe("hello");
expect(doc.querySelector("style")?.textContent).toContain("color:red");
expect(doc.querySelector('meta[http-equiv="Content-Security-Policy"]')?.getAttribute("content")).toContain("default-src 'none'");
});
@@ -0,0 +1,25 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { MockPort } from "../../port/mock";
import { useWorkbenchFiles } from "../useWorkbenchFiles";
afterEach(cleanup);
it("warns about disk changes using metadata without reloading an unsaved draft", async () => {
const port = new MockPort();
const read = vi.spyOn(port, "workspaceFile").mockResolvedValue({ path: "notes.md", content: "# Saved", revision: "hash", observedVersion: "disk-1" });
const stat = vi.spyOn(port, "workspacePreviewInfo").mockResolvedValue({
ref: { path: "notes.md" }, kind: "markdown", version: "disk-2", size: 9, limit: 2 << 20, readOnly: false,
});
const paths = ["notes.md"];
const { result } = renderHook(() => useWorkbenchFiles({
port, workspaceRoot: "/fixture", path: "notes.md", openPaths: paths, shown: true, refresh: "", poll: false,
}));
await waitFor(() => expect(result.current.file).toBeTruthy());
act(() => result.current.setDraft("# My unsaved changes"));
await waitFor(() => expect(result.current.error).toContain("未保存的编辑已保留"));
expect(result.current.draft).toBe("# My unsaved changes");
expect(stat).toHaveBeenCalled();
expect(read).toHaveBeenCalledOnce();
});
@@ -0,0 +1,19 @@
import Papa from "papaparse";
export function parseDelimited(text: string, delimiter: string, maxCells = 100_000): string[][] {
const rows: string[][] = [];
let cells = 0;
let failure = "";
Papa.parse<string[]>(text.replace(/^\uFEFF/, ""), {
delimiter, dynamicTyping: false, skipEmptyLines: false,
step(result, parser) {
if (result.errors.length) failure = "invalid";
cells += result.data.length;
if (cells > maxCells) failure = "limit";
if (failure) parser.abort();
else rows.push(result.data);
},
});
if (failure) throw new Error(failure);
return rows;
}
@@ -0,0 +1,18 @@
import type { PreviewInfo } from "../../port/workspace";
type Kind = PreviewInfo["kind"];
export const renderers: ReadonlyArray<{ kind: Kind; extensions: RegExp; source: boolean }> = [
{ kind: "markdown", extensions: /\.(md|markdown|mdx)$/i, source: true },
{ kind: "image", extensions: /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i, source: false },
{ kind: "pdf", extensions: /\.pdf$/i, source: false },
{ kind: "table", extensions: /\.(csv|tsv)$/i, source: true },
{ kind: "html", extensions: /\.html?$/i, source: true },
{ kind: "unsupported", extensions: /\.(docx?|xlsx?|pptx?|zip|gz|7z|rar|exe|dll|bin)$/i, source: false },
];
export function previewKind(path: string): Kind {
return renderers.find((r) => r.extensions.test(path))?.kind ?? "text";
}
export const documentView = (path: string) => !["markdown", "text"].includes(previewKind(path));
export const sourceView = (path: string) => renderers.find((r) => r.extensions.test(path))?.source ?? true;
@@ -0,0 +1,20 @@
import DOMPurify from "dompurify";
export function staticDocument(source: string): string {
const clean = DOMPurify.sanitize(source, {
WHOLE_DOCUMENT: true,
ADD_TAGS: ["style"],
FORBID_TAGS: ["script", "iframe", "object", "embed", "form", "input", "button", "textarea", "select", "link", "base", "meta"],
FORBID_ATTR: ["srcdoc", "srcset", "action", "formaction", "href", "xlink:href", "ping", "target"],
});
const doc = new DOMParser().parseFromString(clean, "text/html");
for (const el of doc.querySelectorAll("[src]")) {
const src = el.getAttribute("src") ?? "";
if (el.tagName !== "IMG" || !/^data:image\/(png|jpeg|gif|webp|avif|bmp|x-icon);base64,/i.test(src)) el.removeAttribute("src");
}
const policy = doc.createElement("meta");
policy.httpEquiv = "Content-Security-Policy";
policy.content = "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'";
doc.head.prepend(policy);
return "<!doctype html>" + doc.documentElement.outerHTML;
}
@@ -0,0 +1,43 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { MockPort } from "../../port/mock";
import { HttpError } from "../../port/port";
import { useWorkbenchFiles } from "../useWorkbenchFiles";
afterEach(() => { cleanup(); vi.useRealTimers(); });
it.each([[404, "workspace.file_missing"], [403, "workspace.preview_denied"]])("surfaces a %s poll failure and removes stale saved text", async (status, code) => {
vi.useFakeTimers();
const port = new MockPort();
const read = vi.spyOn(port, "workspaceFile").mockResolvedValue({ path: "a.md", content: "saved", revision: "r1" });
const paths = ["a.md"];
const { result } = renderHook(() => useWorkbenchFiles({ port, workspaceRoot: "/work", path: "a.md", openPaths: paths, shown: true, refresh: "", poll: true }));
await act(async () => {});
expect(result.current.draft).toBe("saved");
read.mockRejectedValue(new HttpError(status, "unavailable", { code }));
await act(async () => { await vi.advanceTimersByTimeAsync(3000); });
expect(result.current.errorCode).toBe(code);
expect(result.current.file).toBeNull();
expect(result.current.draft).toBe("");
read.mockResolvedValue({ path: "a.md", content: "recreated", revision: "r2" });
await act(async () => { await vi.advanceTimersByTimeAsync(3000); });
expect(result.current.draft).toBe("recreated");
expect(result.current.error).toBe("");
});
it("does not poll metadata for every keystroke and preserves a draft after deletion", async () => {
vi.useFakeTimers();
const port = new MockPort();
vi.spyOn(port, "workspaceFile").mockResolvedValue({ path: "a.md", content: "saved", revision: "r1", observedVersion: "v1" });
const stat = vi.spyOn(port, "workspacePreviewInfo").mockResolvedValue({ ref: { path: "a.md" }, kind: "markdown", version: "v1", size: 5, limit: 2 << 20, readOnly: false });
const paths = ["a.md"];
const { result } = renderHook(() => useWorkbenchFiles({ port, workspaceRoot: "/work", path: "a.md", openPaths: paths, shown: true, refresh: "", poll: true }));
await act(async () => {});
for (const text of ["a", "ab", "abc"]) await act(async () => result.current.setDraft(text));
expect(stat).toHaveBeenCalledOnce();
stat.mockRejectedValue(new HttpError(404, "gone", { code: "workspace.file_missing" }));
await act(async () => { await vi.advanceTimersByTimeAsync(3000); });
expect(result.current.draft).toBe("abc");
expect(result.current.error).toContain("找不到");
});
@@ -0,0 +1,49 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { MockPort } from "../../port/mock";
import { HttpError } from "../../port/port";
import { useDocument } from "./useDocument";
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
it("discards a previous file's late response and aborts on close", async () => {
const port = new MockPort();
let done!: (value: { data: ArrayBuffer; version: string }) => void;
let signal: AbortSignal | undefined;
vi.spyOn(port, "workspacePreviewContent").mockImplementationOnce((_ref, s?: AbortSignal) => {
signal = s;
return new Promise((resolve) => { done = resolve; });
});
const { result, rerender, unmount } = renderHook(({ path }) => useDocument(port, path, true, ""), { initialProps: { path: "one.csv" } });
await waitFor(() => expect(done).toBeDefined());
rerender({ path: "two.csv" });
await waitFor(() => expect(result.current.value?.info.ref.path).toBe("two.csv"));
expect(signal?.aborted).toBe(true);
await act(async () => done({ data: new ArrayBuffer(0), version: "old" }));
expect(result.current.value?.info.ref.path).toBe("two.csv");
unmount();
});
it("removes stale bytes when access is revoked and reload fails", async () => {
const port = new MockPort();
const { result } = renderHook(() => useDocument(port, "report.csv", true, ""));
await waitFor(() => expect(result.current.value?.data).toBeTruthy());
vi.spyOn(port, "workspacePreviewInfo").mockRejectedValue(new HttpError(403, "denied", { code: "workspace.preview_denied" }));
act(() => result.current.reload());
await waitFor(() => expect(result.current.value?.error).toBeTruthy());
expect(result.current.value?.data).toBeUndefined();
});
it("checks metadata on invalidation and only forces unchanged bytes for manual refresh", async () => {
const port = new MockPort();
const content = vi.spyOn(port, "workspacePreviewContent");
const info = vi.spyOn(port, "workspacePreviewInfo");
const { result, rerender } = renderHook(({ refresh }) => useDocument(port, "report.csv", true, refresh), { initialProps: { refresh: "first" } });
await waitFor(() => expect(result.current.value?.data).toBeTruthy());
rerender({ refresh: "tool finished" });
await waitFor(() => expect(info).toHaveBeenCalledTimes(2));
expect(content).toHaveBeenCalledTimes(1);
act(() => result.current.reload());
await waitFor(() => expect(content).toHaveBeenCalledTimes(2));
});
@@ -0,0 +1,55 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AgentPort } from "../../port/port";
import type { PreviewInfo } from "../../port/workspace";
import { parseGrantedFile } from "../grantedfiles";
import { reason } from "../../i18n/kernel";
type Loaded = { info: PreviewInfo; data?: ArrayBuffer; error?: string };
export function useDocument(port: AgentPort, path: string, shown: boolean, refresh: string) {
const held = useMemo(() => parseGrantedFile(path), [path]);
const ref = useMemo(() => ({ path: held?.rel ?? path, ...(held ? { root: held.root } : {}) }), [held, path]);
const [state, setState] = useState<{ ref: typeof ref; port: AgentPort; value: Loaded }>();
const [reload, setReload] = useState(0);
const cache = useRef<Loaded | undefined>(undefined);
const lastReload = useRef(0);
const request = useCallback(() => setReload((n) => n + 1), []);
useEffect(() => {
cache.current = undefined;
setState(undefined);
}, [ref, port]);
useEffect(() => {
if (!shown) return;
const controller = new AbortController();
let busy = false;
const read = async (force: boolean) => {
if (busy || controller.signal.aborted) return;
busy = true;
let observed: PreviewInfo | undefined;
try {
const info = await port.workspacePreviewInfo(ref, controller.signal);
observed = info;
if (controller.signal.aborted) return;
let value: Loaded;
if (info.kind === "unsupported" || info.size > info.limit) value = { info };
else if (!force && cache.current?.data && cache.current.info.version === info.version) value = { ...cache.current, info, error: undefined };
else {
const loaded = await port.workspacePreviewContent(ref, controller.signal);
value = { info: { ...info, version: loaded.version || info.version }, data: loaded.data };
}
if (!controller.signal.aborted) { cache.current = value; setState({ ref, port, value }); }
} catch (e) {
if (!controller.signal.aborted) {
cache.current = undefined;
setState({ ref, port, value: { info: observed ?? { ref, kind: "unsupported", size: 0, limit: 0, version: "", readOnly: true }, error: reason(e) } });
}
} finally { busy = false; }
};
const force = lastReload.current !== reload;
lastReload.current = reload;
void read(force);
const timer = window.setInterval(() => void read(false), 3000);
return () => { controller.abort(); window.clearInterval(timer); };
}, [port, ref, shown, refresh, reload]);
return { value: state?.ref === ref && state.port === port ? state.value : undefined, reload: request };
}
@@ -0,0 +1,12 @@
export interface DocumentViewState {
top?: number;
left?: number;
page?: number;
zoom?: number;
}
export function documentState(states: Map<string, DocumentViewState>, path: string): DocumentViewState {
let state = states.get(path);
if (!state) { state = {}; states.set(path, state); }
return state;
}
@@ -0,0 +1,17 @@
import { expect, it } from "vitest";
import { fileReference } from "./fileReference";
it.each([
["docs/方案 100%.md", "@docs/方案\\ 100%.md"],
[String.raw`literal\name.txt`, String.raw`@literal\name.txt`],
[String.raw`before\ space.txt`, String.raw`@before\\ space.txt`],
[String.raw`repeat\\ space.txt`, String.raw`@repeat\\\ space.txt`],
["before\\\ttab.txt", "@before\\\\\ttab.txt"],
[String.raw`\\server\share\team notes.txt`, String.raw`@\\server\share\team\ notes.txt`],
])("preserves the kernel @-token spelling for %j", (path, expected) => {
expect(fileReference(path)).toBe(expected);
});
it.each(["", "trailing\\", "trailing ", "line\nbreak", "line\rbreak", "line\fbreak", "notes)", "notes.md:10", "server:resource", String.raw`C:\notes.md`])(
"refuses an ambiguous or unrepresentable reference %j", (path) => expect(fileReference(path)).toBe(""),
);
@@ -0,0 +1,13 @@
/** Mirrors control.EscapeRefPath's @-token grammar, not JS or shell escaping.
* Backslashes stay literal; only space/tab consume a preceding backslash.
* Reject spellings whose token boundary, punctuation or MCP classification can
* change the selected file when the person adds prose after the reference. */
export function fileReference(path: string): string {
if (!path || path.includes(":") || path.endsWith("\\") || path.trimEnd() !== path || /[\r\n\f]|[.,;!?)}\]]$/.test(path)) return "";
let token = "@";
for (const character of path) {
if (character === " " || character === "\t") token += "\\";
token += character;
}
return token;
}
@@ -58,6 +58,7 @@ it.each([["before", false], ["before", true], ["after", false], ["after", true]]
const fresh = page({ id: "fresh", target: "tf", url: "https://example.com/fresh", title: "Fresh" });
const { container, rerender } = render(panel(port, { tabs: [agent] }));
await user.click(await screen.findByRole("button", { name: "index.html" }));
await user.click(await screen.findByRole("button", { name: "交互运行" }));
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
await user.click(container.querySelector('[data-action="workbench.files"]')!);
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
@@ -86,6 +87,7 @@ it("keeps a superseded successful user tab owned while a separate preview claim
const userTab = page({ id: "user", target: "tu", url: "https://example.com", title: "User page" });
const { container, rerender } = render(panel(port));
await user.click(await screen.findByRole("button", { name: "index.html" }));
await user.click(await screen.findByRole("button", { name: "交互运行" }));
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
const pending = openPage(port, userTab.url);
await user.click(container.querySelector('[data-action="workbench.files"]')!);
@@ -106,6 +108,7 @@ describe("opening an HTML file from the explorer", () => {
vi.spyOn(port, "workspaceFile").mockReturnValue(new Promise((resolve) => { read = resolve; }));
const { container } = render(panel(port));
await user.click(await screen.findByRole("button", { name: "index.html" }));
await user.click(await screen.findByRole("button", { name: "交互运行" }));
await waitFor(() => expect(port.workspaceFile).toHaveBeenCalled());
if (choice === "Files") await user.click(container.querySelector('[data-action="workbench.files"]')!);
else await user.click(screen.getByRole("button", { name: "新建标签" }));
@@ -129,6 +132,7 @@ describe("opening an HTML file from the explorer", () => {
const fresh = page({ id: "fresh", target: "tf", url: "https://example.com/fresh", title: "Fresh" });
const { container, rerender } = render(panel(port, { tabs: [agent] }));
await user.click(await screen.findByRole("button", { name: "index.html" }));
await user.click(await screen.findByRole("button", { name: "交互运行" }));
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
await user.click(container.querySelector('[data-action="workbench.files"]')!);
const reject = () => act(async () => reply(new Response(JSON.stringify({ code: "browser.open_failed", params: { tab: page().id } }), { status: 400 })));
@@ -151,13 +155,14 @@ describe("opening an HTML file from the explorer", () => {
const { port, open } = setup();
const { rerender } = render(panel(port));
await user.click(await screen.findByRole("button", { name: "index.html" }));
await user.click(await screen.findByRole("button", { name: "交互运行" }));
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
expect(open).toHaveBeenCalledWith(`${ROOT}/index.html`, true);
expect(unseenPageOpenings(port)).toHaveLength(1);
rerender(panel(port, { tabs: [page()] }));
expect(unseenPageOpenings(port)).toHaveLength(0);
await waitFor(() => expect(document.querySelector(".workbench-canvas .bpanel")).toBeTruthy());
expect(screen.getByRole("button", { name: "预览" }).getAttribute("aria-pressed")).toBe("true");
expect(screen.getByRole("button", { name: "交互运行" }).getAttribute("aria-pressed")).toBe("true");
expect(document.querySelector(".cm-content")).toBeNull();
expect(unseenPageOpenings(port)).toHaveLength(0);
});
@@ -170,6 +175,7 @@ describe("opening an HTML file from the explorer", () => {
open.mockReturnValue(new Promise((resolve) => (answer = resolve)));
const { rerender } = render(panel(port));
await user.click(await screen.findByRole("button", { name: "index.html" }));
await user.click(await screen.findByRole("button", { name: "交互运行" }));
await waitFor(() => expect(open).toHaveBeenCalled());
rerender(panel(port, { tabs: [page()] }));
expect(screen.queryByRole("tab", { name: "Landing" })).toBeNull();
@@ -187,6 +193,8 @@ describe("opening an HTML file from the explorer", () => {
render(panel(port));
(await screen.findByRole("button", { name: "about.htm" })).focus();
await user.keyboard("{Enter}");
screen.getByRole("button", { name: "交互运行" }).focus();
await user.keyboard("{Enter}");
await waitFor(() => expect(open).toHaveBeenCalledWith(`${ROOT}/about.htm`, true));
});
@@ -195,11 +203,12 @@ describe("opening an HTML file from the explorer", () => {
const { port, open } = setup();
const { rerender } = render(panel(port));
await user.click(await screen.findByRole("button", { name: "index.html" }));
await user.click(await screen.findByRole("button", { name: "交互运行" }));
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
rerender(panel(port, { tabs: [page()] }));
await user.click(await screen.findByRole("button", { name: "编辑" }));
await waitFor(() => expect(document.querySelector(".cm-content")).toBeTruthy());
await user.click(screen.getByRole("button", { name: "预览" }));
await user.click(screen.getByRole("button", { name: "交互运行" }));
await user.click(screen.getAllByRole("button", { name: "index.html" }).find((b) => b.classList.contains("workbench-tree-row"))!);
expect(open).toHaveBeenCalledTimes(1);
expect(document.querySelector(".workbench-canvas .bpanel")).toBeTruthy();
@@ -211,6 +220,7 @@ describe("opening an HTML file from the explorer", () => {
const { port, open, control } = setup({ revision: () => revision });
const { rerender } = render(panel(port));
await user.click(await screen.findByRole("button", { name: "index.html" }));
await user.click(await screen.findByRole("button", { name: "交互运行" }));
await waitFor(() => expect(open).toHaveBeenCalled());
rerender(panel(port, { tabs: [page()], wrote: 0 }));
await waitFor(() => expect(document.querySelector(".bpanel")).toBeTruthy());
@@ -229,6 +239,7 @@ describe("opening an HTML file from the explorer", () => {
render(panel(port, { tabs: [page()] }));
expect(screen.getByRole("tab", { name: "Landing" })).toBeTruthy();
await user.click(await screen.findByRole("button", { name: "index.html" }));
await user.click(await screen.findByRole("button", { name: "交互运行" }));
await waitFor(() => expect(screen.queryByRole("tab", { name: "Landing" })).toBeNull());
expect(open).not.toHaveBeenCalled();
expect(document.querySelector(".workbench-canvas .bpanel")).toBeTruthy();
@@ -240,6 +251,7 @@ describe("opening an HTML file from the explorer", () => {
vi.spyOn(port, "workspaces").mockResolvedValue({ current: "D:\\proj\\site\\", canSwitch: true, canIsolate: true, recents: [] });
const { rerender } = render(panel(port));
await user.click(await screen.findByRole("button", { name: "index.html" }));
await user.click(await screen.findByRole("button", { name: "交互运行" }));
await waitFor(() => expect(open).toHaveBeenCalledWith("D:\\proj\\site/index.html", true));
open.mockClear();
rerender(panel(port, { tabs: [page({ url: "file:///D:/proj/site/index.html" })] }));
@@ -260,6 +272,7 @@ describe("opening an HTML file from the explorer", () => {
refusedLikeTheKernel(port, "browser.open_failed", "browser.url_refused: outside the workspace");
render(panel(port));
await user.click(await screen.findByRole("button", { name: "index.html" }));
await user.click(await screen.findByRole("button", { name: "交互运行" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("打不开这个网页:browser.url_refused: outside the workspace");
await waitFor(() => expect(document.querySelector(".cm-content")).toBeTruthy());
@@ -272,6 +285,7 @@ describe("opening an HTML file from the explorer", () => {
refusedLikeTheKernel(port, "browser.network_path", "a network path");
render(panel(port));
await user.click(await screen.findByRole("button", { name: "index.html" }));
await user.click(await screen.findByRole("button", { name: "交互运行" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("网络上另一台机器");
expect(alert.textContent).not.toContain("{");
@@ -300,22 +314,24 @@ describe("opening an HTML file from the explorer", () => {
expect(open).not.toHaveBeenCalled();
});
it("opens an HTML file in the editor where the window draws no pages", async () => {
it("opens static HTML where the window draws no browser pages", async () => {
const user = userEvent.setup();
const { port, open } = setup({ views: false });
render(panel(port));
await user.click(await screen.findByRole("button", { name: "index.html" }));
await waitFor(() => expect(document.querySelector(".cm-content")).toBeTruthy());
expect(screen.queryByRole("button", { name: "预览" })).toBeNull();
await screen.findByTitle("HTML 静态预览");
expect(screen.queryByRole("button", { name: "交互运行" })).toBeNull();
expect(screen.getByRole("button", { name: "预览" })).toBeTruthy();
expect(open).not.toHaveBeenCalled();
});
it("opens an HTML file in the editor when the workspace is on another machine", async () => {
it("opens static HTML when the workspace is on another machine", async () => {
const user = userEvent.setup();
const { port, open } = setup();
render(panel(port, { remote: true }));
await user.click(await screen.findByRole("button", { name: "index.html" }));
await waitFor(() => expect(document.querySelector(".cm-content")).toBeTruthy());
await screen.findByTitle("HTML 静态预览");
expect(screen.queryByRole("button", { name: "交互运行" })).toBeNull();
expect(open).not.toHaveBeenCalled();
});
-6
View File
@@ -149,12 +149,6 @@ function basename(path: string): string {
return parts[parts.length - 1] || path;
}
// refToken is the same token the @ completion produces, so a dropped file and a
// completed one leave the composer identical.
export function refToken(rel: string): string {
return "@" + rel;
}
// A paste long enough to bury the composer becomes a chip instead. Lines and
// characters both count, whichever comes first: eight hundred lines of narrow
// log is under the character cap and has already eaten the window.
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, within } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Packages } from "./Packages";
@@ -64,8 +64,9 @@ describe("installed package invocation copy", () => {
button.focus();
await user.keyboard("{Enter}");
expect(copy).toHaveBeenCalledExactlyOnceWith(command);
expect(button.getAttribute("title")).toBe(t("已复制"));
expect(button.querySelector('[aria-live="polite"]')?.textContent).toBe(t("已复制"));
fireEvent.focus(button);
expect(screen.getByRole("tooltip").textContent).toBe(t("已复制"));
expect(details.open).toBe(true);
expect(toggle).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
@@ -101,11 +102,12 @@ describe("installed package invocation copy", () => {
copy.mockRejectedValueOnce(new Error("clipboard denied"));
const button = row.getByRole("button", { name: label(entries[0][1]) });
await user.click(button);
expect(button.getAttribute("title")).toBe(t("复制失败,请重试"));
expect(button.querySelector('[aria-live="polite"]')?.textContent).toBe(t("复制失败,请重试"));
fireEvent.focus(button);
expect(screen.getByRole("tooltip").textContent).toBe(t("复制失败,请重试"));
expect(row.getByRole<HTMLButtonElement>("switch", { name: t("关闭") + " review-kit" }).disabled).toBe(false);
await user.click(button);
expect(button.getAttribute("title")).toBe(t("已复制"));
expect(button.querySelector('[aria-live="polite"]')?.textContent).toBe(t("已复制"));
expect(copy).toHaveBeenCalledTimes(2);
expect(details.open).toBe(true);
});
@@ -123,10 +125,10 @@ describe("installed package invocation copy", () => {
const current = row.getByRole("button", { name: label(command) });
await act(async () => outcome === "success" ? finish() : fail(new Error("previous clipboard denied")));
expect(current.getAttribute("data-state")).toBe("idle");
expect(current.getAttribute("title")).toBe(label(command));
expect(current.querySelector('[aria-live="polite"]')?.textContent).toBe(label(command));
await user.click(current);
expect(copy).toHaveBeenLastCalledWith(command);
expect(current.getAttribute("title")).toBe(t("已复制"));
expect(current.querySelector('[aria-live="polite"]')?.textContent).toBe(t("已复制"));
});
it("keeps copy feedback local while package management remains available", async () => {
@@ -160,6 +162,6 @@ describe("installed package invocation copy", () => {
expect(props.onChanged).not.toHaveBeenCalled();
await user.click(current);
expect(copy).toHaveBeenCalledTimes(2);
expect(current.getAttribute("title")).toBe(t("已复制"));
expect(current.querySelector('[aria-live="polite"]')?.textContent).toBe(t("已复制"));
});
});
+1
View File
@@ -44,6 +44,7 @@ export interface Primitive {
}
export const PRIMITIVES: Primitive[] = [
{ file: "ui/TooltipButton.tsx", callbacks: { onClick: "click", onPointerDown: "pointerdown" } },
{ file: "ui/Switch.tsx", callbacks: { onClick: "click" } },
{ file: "ui/Menu.tsx", callbacks: { onPick: "click" } },
];
@@ -37,7 +37,7 @@ it.each(["zh", "en"])("copies the canonical namespaced invocation with keyboard
await user.keyboard("{Enter}");
expect(write).toHaveBeenCalledWith("/review-kit:review");
expect(await screen.findByText(t("已复制"))).toBeTruthy();
expect(copy().getAttribute("title")).toBe(t("已复制"));
expect(copy().querySelector('[aria-live="polite"]')?.textContent).toBe(t("已复制"));
expect(setSkillEnabled).not.toHaveBeenCalled();
expect(props.onDone).not.toHaveBeenCalled();
expect(props.onFailed).not.toHaveBeenCalled();
@@ -97,7 +97,7 @@ it("does not carry a previous invocation's pending copy feedback into refreshed
view.rerender(<SkillRow {...props} sk={{ ...props.sk, slashName: "review-kit:audit" }} />);
await act(async () => finish());
expect(screen.queryByText(t("已复制"))).toBeNull();
expect(copy("/review-kit:audit").getAttribute("title")).toBe(t("复制调用命令 {command}", { command: "/review-kit:audit" }));
expect(copy("/review-kit:audit").querySelector('[aria-live="polite"]')?.textContent).toBe(t("复制调用命令 {command}", { command: "/review-kit:audit" }));
await user.click(copy("/review-kit:audit"));
expect(write).toHaveBeenLastCalledWith("/review-kit:audit");
});
+22
View File
@@ -0,0 +1,22 @@
.icon-tooltip {
position: fixed;
z-index: 70;
display: flex;
align-items: center;
gap: 8px;
max-width: min(340px, calc(100vw / var(--zoom, 1) - 12px));
padding: 6px 9px;
border: 1px solid var(--border);
border-radius: var(--r-sm);
background: var(--float);
color: var(--text);
box-shadow: var(--shadow-float);
font: var(--w-med) var(--fs-ui) var(--ui);
overflow-wrap: anywhere;
pointer-events: none;
}
.icon-tooltip kbd {
color: var(--muted);
font: inherit;
white-space: nowrap;
}
@@ -3,7 +3,7 @@ import type { AgentPort } from "../port/port";
import { host } from "../port/host";
import type { LocalRefs } from "./Markdown";
import { GRANTED, conversationFile, isLocalPathShape } from "./filelinks";
import { GRANTED_FILE, grantedFileKey } from "./grantedfiles";
import { GRANTED_FILE, grantedFileKey, grantedFilePath } from "./grantedfiles";
import { WorkbenchFileMenu, absoluteWorkspacePath, type FileMenu } from "./WorkbenchFileMenu";
export function useConversationFiles(port: AgentPort, root: string, remote: boolean, shown: boolean, reveal: () => void, fail: (e: unknown) => void) {
@@ -65,6 +65,7 @@ export function useConversationFiles(port: AgentPort, root: string, remote: bool
// A path in text may become a link when it is in the workspace or in a granted folder.
const linkable = (href: string) => path(href) ?? (isLocalPathShape(href) ? grantedFileKey(granted, href) : null);
return {
file: (ref) => () => ask(ref.root ? grantedFilePath(ref.root, ref.path) : ref.path),
path,
linkable,
title: (href) => { const file = path(href); return file ? absoluteWorkspacePath(root, file) : ""; },
@@ -5,12 +5,14 @@ import { locate } from "../state/gittree";
import { WORKSPACE_FILE_MISSING, reason } from "../i18n/kernel";
import { HttpError } from "../port/port";
import { parseGrantedFile } from "./grantedfiles";
import { t } from "../i18n";
type Entry = {
file: WorkspaceFile | null; draft: string; diff: string; error: string; missing: boolean;
errorCode: string;
pending: "" | "read" | "save"; issued: number;
};
const blank = (): Entry => ({ file: null, draft: "", diff: "", error: "", missing: false, pending: "", issued: 0 });
const blank = (): Entry => ({ file: null, draft: "", diff: "", error: "", errorCode: "", missing: false, pending: "", issued: 0 });
const dirty = (entry: Entry) => !!entry.file && entry.draft !== entry.file.content;
export function useWorkbenchFiles({ port, workspaceRoot, repos = [], path, openPaths, shown, refresh, poll }: {
@@ -59,12 +61,19 @@ export function useWorkbenchFiles({ port, workspaceRoot, repos = [], path, openP
entry.file = file;
entry.draft = file.content;
entry.error = "";
entry.errorCode = "";
entry.missing = false;
if (!quiet) entry.diff = patch?.diff ?? "";
} catch (e) {
if (valid() && !quiet) {
const code = e instanceof HttpError ? e.reason?.code ?? "" : "";
const unavailable = [WORKSPACE_FILE_MISSING, "workspace.root_not_granted", "workspace.preview_denied", "workspace.file_unreadable"].includes(code);
if (valid() && !dirty(entry) && (!quiet || unavailable)) {
changed = true;
entry.error = reason(e);
entry.errorCode = code;
entry.missing = e instanceof HttpError && e.reason?.code === WORKSPACE_FILE_MISSING;
entry.file = null;
entry.draft = "";
}
} finally {
if (valid()) { entry.pending = ""; if (changed) notify(); }
@@ -82,6 +91,32 @@ export function useWorkbenchFiles({ port, workspaceRoot, repos = [], path, openP
const timer = window.setInterval(() => { cancel = read(true) ?? cancel; }, 3000);
return () => { cancel?.(); window.clearInterval(timer); };
}, [read, poll, shown]);
const isDirty = dirty(entry), observedVersion = entry.file?.observedVersion, saving = entry.pending === "save";
useEffect(() => {
if (!shown || !isDirty || !observedVersion || saving) return;
const controller = new AbortController();
let busy = false;
const inspect = async () => {
if (busy) return;
busy = true;
try {
const held = parseGrantedFile(path);
const info = await port.workspacePreviewInfo({ path: held?.rel ?? path, root: held?.root }, controller.signal);
if (!controller.signal.aborted && info.version !== entry.file?.observedVersion) {
const error = t("磁盘内容已变化,未保存的编辑已保留");
if (entry.error !== error) { entry.error = error; notify(); }
}
} catch (e) {
if (!controller.signal.aborted) {
const error = reason(e);
if (entry.error !== error) { entry.error = error; notify(); }
}
} finally { busy = false; }
};
void inspect();
const timer = window.setInterval(() => void inspect(), 3000);
return () => { controller.abort(); window.clearInterval(timer); };
}, [port, entry, path, shown, refresh, isDirty, observedVersion, saving, notify]);
const setDraft = (draft: string) => {
if (current.current !== entries || !entry.file || entry.file.readOnly) return;
@@ -34,7 +34,7 @@ describe("the controls below a message", () => {
const copy = screen.getByRole("button", { name: "复制" });
await user.click(copy);
expect(write).toHaveBeenCalledExactlyOnceWith(text);
expect(copy.getAttribute("title")).toBe("已复制");
expect(copy.querySelector('[aria-live="polite"]')?.textContent).toBe("已复制");
write.mockRestore();
});
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { App } from "./App";
@@ -53,7 +53,10 @@ describe("the window bar's view switch", () => {
it("names its buttons by label and shortcut, not by icon alone", async () => {
render(<App hub={readyHub()} />);
const button = await screen.findByRole("button", { name: "工作台" });
expect(button.getAttribute("title")).toMatch(/工作台\s+(Ctrl 3|⌘3)/);
fireEvent.focus(button);
const tip = document.querySelector(".icon-tooltip");
expect(tip?.textContent).toContain("工作台");
expect(tip?.querySelector("kbd")?.textContent).toMatch(/Ctrl 3|⌘3/);
expect(button.getAttribute("aria-keyshortcuts")).toBeTruthy();
});
});
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { MockPort } from "../port/mock";
import { WorkbenchPanel } from "./WorkbenchPanel";
@@ -40,7 +40,7 @@ it("does not open a preview page for an HTML file that cannot be read", async ()
expect(open).not.toHaveBeenCalled();
});
it("still previews an HTML file the editor cannot read, such as one over the size limit", async () => {
it("allows explicit interactive opening when static HTML exceeds its limit", async () => {
const port = new MockPort();
vi.spyOn(port, "workspaceFiles").mockResolvedValue({ files: ["big.html"], directories: [] });
vi.spyOn(port, "workspaceFile").mockRejectedValue(new HttpError(400, "unreadable", { code: "workspace.file_unreadable" }));
@@ -51,5 +51,21 @@ it("still previews an HTML file the editor cannot read, such as one over the siz
const open = vi.spyOn(port, "browserOpen").mockResolvedValue({ id: "p", target: "t", url: "file:///one/big.html", title: "", active: false });
const view = render(<WorkbenchPanel {...props(port)} />);
view.rerender(<WorkbenchPanel {...props(port)} fileRequest={{ path: "big.html" }} />);
fireEvent.click(await screen.findByRole("button", { name: "交互运行" }));
await waitFor(() => expect(open).toHaveBeenCalled());
});
it("shows metadata and a typed error for an unknown binary instead of an editor", async () => {
const port = new MockPort();
vi.spyOn(port, "workspaceFiles").mockResolvedValue({ files: ["opaque.data"], directories: [] });
vi.spyOn(port, "workspaceFile").mockRejectedValue(new HttpError(400, "unreadable", { code: "workspace.file_unreadable" }));
vi.spyOn(port, "workspacePreviewInfo").mockResolvedValue({
ref: { path: "opaque.data" }, kind: "text", size: 128, limit: 2 << 20, version: "v1", readOnly: false,
});
vi.spyOn(port, "workspacePreviewContent").mockRejectedValue(new HttpError(415, "binary", { code: "workspace.preview_not_text" }));
const view = render(<WorkbenchPanel {...props(port)} />);
view.rerender(<WorkbenchPanel {...props(port)} fileRequest={{ path: "opaque.data" }} />);
await waitFor(() => expect(document.querySelector(".document-preview [role=alert]")?.textContent).toContain("文本"));
expect(screen.getByText("128 B")).toBeTruthy();
expect(document.querySelector(".cm-content")).toBeNull();
});
@@ -23,6 +23,7 @@ it.each(['event-first', 'reply-first'])('preserves newer Files when preview work
const open = vi.spyOn(port,'browserOpen').mockReturnValue(new Promise((_,reject)=>{fail=reject;}));
const {rerender} = render(<WorkbenchPanel {...props} port={port} tabs={[agent]} />);
fireEvent.click(await screen.findByRole('button',{name:'index.html'}));
fireEvent.click(await screen.findByRole('button',{name:'交互运行'}));
await waitFor(()=>expect(workspaces).toHaveBeenCalled());
expect(open).not.toHaveBeenCalled();
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
@@ -57,6 +58,7 @@ it('does not start a preview after its file read finishes behind a newer Files s
const open = vi.spyOn(port, 'browserOpen').mockResolvedValue(page);
render(<WorkbenchPanel {...props} port={port} tabs={[agent]} />);
fireEvent.click(await screen.findByRole('button',{name:'index.html'}));
fireEvent.click(await screen.findByRole('button',{name:'交互运行'}));
await waitFor(()=>expect(reading).toHaveBeenCalled());
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
await act(async()=>read({path:'index.html',content:'hello',revision:'r1'}));
@@ -76,6 +78,7 @@ it('follows a genuine agent tab while an older preview is still preparing its wo
const open = vi.spyOn(port, 'browserOpen').mockResolvedValue(page);
const {rerender} = render(<WorkbenchPanel {...props} port={port} tabs={[agent]} />);
fireEvent.click(await screen.findByRole('button',{name:'index.html'}));
fireEvent.click(await screen.findByRole('button',{name:'交互运行'}));
await waitFor(()=>expect(lookup).toHaveBeenCalled());
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
const fresh = {...agent,id:'fresh',target:'tf',title:'Fresh',active:false};
@@ -0,0 +1,62 @@
type TreeRow = {
kind: "folder" | "file";
path: string;
name: string;
depth: number;
};
/** Folders first at every level, with the tree still reading as a tree. Sorting
* flat paths as text interleaves `bin/` with `boot.test.exe`, because text is
* the only thing it compares. Segment by segment, whichever row is a directory
* where the two paths part wins there, and its children stay under it. */
function before(a: TreeRow, b: TreeRow): number {
const x = a.path.split("/"), y = b.path.split("/");
for (let i = 0; i < Math.min(x.length, y.length); i++) {
if (x[i] === y[i]) continue;
const dirs = [i < x.length - 1 || a.kind === "folder", i < y.length - 1 || b.kind === "folder"];
return dirs[0] === dirs[1] ? x[i].localeCompare(y[i]) : dirs[0] ? -1 : 1;
}
return x.length - y.length;
}
export function treeRows(
files: string[],
directories: string[],
query: string,
closed: Set<string>,
): TreeRow[] {
const matches = query
? files.filter((path) =>
path.toLocaleLowerCase().includes(query.toLocaleLowerCase()),
)
: files;
const folders = new Set<string>(directories);
for (const path of matches)
for (let i = 1, bits = path.split("/"); i < bits.length; i++)
folders.add(bits.slice(0, i).join("/"));
const hidden = (path: string) =>
[...closed].some(
(folder) => path === folder || path.startsWith(`${folder}/`),
);
const rows: TreeRow[] = [...folders]
.filter(
(path) => ![...closed].some((parent) => path.startsWith(`${parent}/`)),
)
.map((path) => ({
kind: "folder",
path,
name: path.split("/").at(-1)!,
depth: path.split("/").length - 1,
}));
rows.push(
...matches
.filter((path) => !hidden(path.split("/").slice(0, -1).join("/")))
.map((path) => ({
kind: "file" as const,
path,
name: path.split("/").at(-1)!,
depth: path.split("/").length - 1,
})),
);
return rows.sort(before);
}
+33
View File
@@ -0,0 +1,33 @@
import { readFileSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { createRequire } from "node:module";
import type { Plugin } from "vite";
export function pdfAssets(): Plugin {
const require = createRequire(import.meta.url);
const root = dirname(require.resolve("pdfjs-dist/package.json"));
const files = new Map<string, string>();
for (const dir of ["cmaps", "standard_fonts", "wasm"]) {
for (const name of readdirSync(join(root, dir))) files.set(`pdfjs/${dir}/${name}`, join(root, dir, name));
}
files.set("pdfjs/LICENSE", join(root, "LICENSE"));
files.set("licenses/papaparse.txt", join(dirname(require.resolve("papaparse")), "LICENSE"));
const purify = dirname(dirname(require.resolve("dompurify")));
files.set("licenses/dompurify.txt", join(purify, "LICENSE"));
files.set("licenses/dompurify-mpl.txt", join(purify, "LICENSE-MPL"));
files.set("licenses/lucide-react.txt", join(dirname(require.resolve("lucide-react/package.json")), "LICENSE"));
return {
name: "pdf-local-assets",
generateBundle() {
for (const [fileName, path] of files) this.emitFile({ type: "asset", fileName, source: readFileSync(path) });
},
configureServer(server) {
server.middlewares.use((req, res, next) => {
const path = files.get((req.url ?? "").split("?")[0].replace(/^\//, ""));
if (!path) return next();
res.setHeader("Content-Type", path.endsWith(".wasm") ? "application/wasm" : "application/octet-stream");
res.end(readFileSync(path));
});
},
};
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import { pdfAssets } from "./tools/pdf-assets.ts";
interface ProxyEvents {
on(event: "proxyReq", cb: (req: { setHeader(k: string, v: string): void }) => void): void;
@@ -36,7 +37,7 @@ export default defineConfig(({ mode }) => {
const serve = loadEnv(mode, ".", "REASONIX").REASONIX_SERVE;
return {
base: "./",
plugins: [react()],
plugins: [react(), pdfAssets()],
// Stylesheets are stubbed for tests by default, `?raw` included, so a
// guard that reads app.css to check a rule gets an empty string and passes
// on nothing. Processing it is what makes that guard able to fail.
+93
View File
@@ -0,0 +1,93 @@
---
owner: @SivanCola
backup: @esengine
status: active
reviewed: 2026-10-21
---
# File delivery and preview / 文件交付与预览
`present_files` declares one to four existing files. The host validates every
file before returning a versioned result; failure publishes no delivery cards.
The declaration identifies deliverables, not verified completion.
`present_files` 每次声明一到四个现有文件。宿主全部校验成功后才产生交付卡片;
声明交付文件不等于验证内容正确,也不替代任务完成判定。
## Reading / 阅读
The model-facing tool is **off by default**. Opt in in the user configuration
(`~/.reasonix/config.toml`, or `config.toml` under `REASONIX_HOME`), then reopen
the session. A project cannot enable or disable it. `tools.disabled` and an
explicit `tools.enabled` allowlist still apply.
While off, it is absent from
both the tool registry and provider requests; file previews and existing cards
remain available.
Enabling it adds its fixed schema to the next session's tool
prefix, which may cause a first-request cache miss, never mid-session churn.
模型工具**默认关闭**。在用户配置(`~/.reasonix/config.toml`,或
`REASONIX_HOME` 下的 `config.toml`)中启用后重新打开会话;项目配置不能修改此选择。
`tools.disabled` 和显式的 `tools.enabled` 白名单仍然有效。
关闭时工具不会进入注册表或模型请求;文件预览、消息文件链接和历史交付卡片仍然可用。
启用会改变下一会话的工具前缀,
可能使首个请求缓存未命中,但不会在会话运行中改变工具定义。
```toml
[tools]
present_files = true
```
| Format / 格式 | View / 展示 | Limit / 限额 |
| --- | --- | --- |
| Markdown, MDX | Rendered Markdown; JSX is not executed / Markdown 正文,不执行 JSX | 2 MiB |
| Text, code / 文本、代码 | Existing source editor / 原有源码编辑器 | 2 MiB |
| CSV, TSV | Worker parsing, virtual rows and columns / Worker 解析、虚拟表格 | 2 MiB, 100,000 cells / 单元格 |
| HTML | Sanitized, sandboxed static iframe / 清理后的静态沙箱预览 | 2 MiB |
| PDF | Local PDF.js worker, page controls, zoom, selectable text / 本地阅读器、分页、缩放、文字选择 | 32 MiB |
| Images / 图片 | Image element with zoom / 图片缩放 | 10 MiB |
| Office, archives, binaries / Office、压缩包、二进制 | Information and available actions / 文件信息与可用操作 | No inline preview / 无内联预览 |
The workspace tree, message links and cards share workbench tabs. Full references
include the authorized root and relative path; display names do not identify files.
Open records read the source file's current content, without version snapshots.
文件树、消息链接和卡片复用工作台标签。文件身份包含授权根目录和相对路径,
不能以显示名称去重。历史交付记录读取源文件当前内容,不保存版本快照。
Cards use two columns at a container width of 720 CSS pixels, otherwise one.
The More menu contains copy, attachment and supported native actions. The current
marker follows the actual visible workbench file. Type icons do not imply support.
卡片容器达到 720 CSS 像素时显示两列,否则显示一列。“更多”菜单提供复制路径、
添加到对话及宿主支持的原生操作。“查看中”跟随实际可见文件;图标不代表预览能力。
## Boundaries / 边界
- Controller preview APIs use the shared bounded, root-confined file service.
内容采用原始字节读取,不通过 JSON/base64 传输 PDF。
- Root confinement remains enforced on the opened descriptor. Missing, denied,
non-regular, oversized and non-text files have typed errors.
文件缺失、拒绝访问、非普通文件、超限和非文本均有独立错误码。
- HTML scripts, submission, redirects and external resources are blocked.
本地桌面可显式选择已有的“交互运行”;远程页面不能调用本机系统打开。
- Only host-confirmed successful presenter results project to cards. Live and
restored projections use the original tool history, including full raw results.
普通工具输出相同 JSON 不会产生交付卡片,失败和拒绝调用也不会产生成功卡片。
- Visible binary previews inspect metadata every three seconds and reload bytes
only on change. Obsolete requests are aborted or discarded.
可见二进制预览约每三秒检查元数据,变化后才读取正文;旧请求会取消或丢弃。
- Saved text disappears after a typed missing/denied response. Dirty drafts stay
intact and show the observation failure; saving still checks revisions.
文件删除或撤权后清除旧的已保存正文,未保存草稿仍保留,并继续使用保存冲突检查。
New `deliveries` and `observedVersion` fields are optional. Old histories remain
readable; older clients keep the ordinary tool result. Tool schemas stay stable
within a run, while adding the capability changes the upgrade-time catalog.
新增字段均可缺省;旧会话仍可读取,旧客户端仍显示普通工具结果。
运行期间工具定义不变;升级时新增能力会改变工具目录,可能造成一次缓存失效。
+2 -1
View File
@@ -90,7 +90,8 @@ Checked against `studio` at `d779d89bc` on 2026-10-08.
| Worktree merge dialog | Unverified | Worktree handling exists in `ui/Workspaces.tsx`; the merge flow was not checked |
| Scheduled tasks (heartbeat) | Missing | #11384 |
| Keyboard shortcuts cheat sheet | Unverified | Key handling is in `ui/keys.ts`, `ui/windowkeys.ts`; no cheat sheet found by name; no issue yet |
| CSV and media preview in the workspace panel | Unverified | `ui/WorkbenchPanel.tsx` and `ui/CodeEditor.tsx` exist; file kinds not checked |
| CSV and media preview in the workspace panel | Have differently | `ui/document`: CSV/TSV, PDF and still images render in the workbench; audio/video remain unsupported. CSV/TSV、PDF 和图片可直接阅读,音视频仍不支持内联预览。 |
| File delivery cards / 文件交付卡片 | Have | `present_files` and `ui/document/DeliveryCard.tsx`: validated file references reopen in the shared workbench; auxiliary actions live in More. 已验证文件引用复用工作台标签,辅助操作位于“更多”。 |
| IM gateway (WeChat, Feishu, QQ) | Removed on purpose | Commit `f352dfbec` "remove the IM bot gateway"; `docs/MIGRATING.md` keeps the `[bot]` section untouched; #11952 asks for it back |
## Rows added by Studio
+25
View File
@@ -9,6 +9,31 @@ reviewed: 2026-09-29
This document records the provider-visible contract for Reasonix compile-time built-in tools. It is generated from the same canonical schema path used by the runtime registry.
`present_files` declares up to four existing files in the workspace or authorized folders.
Its versioned result remains in the canonical tool history; live and restored cards use
the host-confirmed capability identity and the complete result, even when model context
is shortened.
Opening a card reads the current source file, not an immutable snapshot.
It does not certify task completion and is not admitted by the Observe posture.
`present_files` 每次声明最多四个工作区或已授权目录中的现有文件。有版本的结果保存在原始工具历史中;
实时与恢复后的卡片使用宿主确认的能力身份和完整结果,不依赖模型上下文是否被压缩。
点击卡片读取源文件当前内容,不保存版本快照,不构成任务完成证明,也不进入 Observe 模式。
This tool is conditional: user-only `tools.present_files = true` enables it on
session open. Off by default, it contributes nothing to the registry or provider
request. The unconditional builtin budget remains 7,185 tokens.
Boot effect tests
verify absence while off, the exact enabled schema, denied reads, direct and
capability-proxy calls, and stable schemas within a turn. Viewers remain available.
该工具按条件注册:仅用户配置 `tools.present_files = true` 在打开会话时启用。
默认关闭,不进入注册表或模型请求;无条件内置工具预算保持 7,185 tokens。
Boot 效果测试覆盖关闭时缺席、启用时精确的 schema、读取拒绝、直接调用、能力代理及轮内稳定性。
文件预览不受此开关影响。
| Tool | Read-only | Description |
| --- | --- | --- |
| `bash` | false | Execute a command in the shell and return combined stdout/stderr. Use for builds, tests, git, package managers, etc. To read or modify a file use read_file / edit_file / write_file — never Get-Content, cat, sed, head/tail or a shell redirect. read_file returns up to 2000 lines per call, each prefixed with its line number, and those numbers are what edit_file targets; paging a file through the shell instead costs one round trip per chunk and yields no line anchors. |
+2 -2
View File
@@ -171,8 +171,8 @@ func applyUnifiedProviderToolSurface(reg *tool.Registry, goalTurnsUnreachable bo
}
}
// advise, best_of_n, run_script, the peer tools and read_mcp_resource are
// registered only when config asks, at boot.
for _, name := range []string{advisor.Name, bestof.Name, script.Name, "list_peers", "send_peer_message", "dispatch_to_project", "view_image", plugin.ResourceReadToolName} {
// registered only when config asks, at boot; present_files follows the same rule.
for _, name := range []string{advisor.Name, bestof.Name, script.Name, "list_peers", "send_peer_message", "dispatch_to_project", "view_image", "present_files", plugin.ResourceReadToolName} {
if _, ok := reg.Get(name); ok {
allow = append(allow, name)
}
+1
View File
@@ -372,6 +372,7 @@ func (b *builder) wireTools() error {
// The full inventory registers for use_capability; the provider-visible surface narrows later.
addBuiltins(t.reg, cfg.Tools.Enabled, env.writeRoots, env.bash, env.bashTimeout, env.search, b.stderr, root, b.proxy, env.forbidReadRoots, env.readRoots, env.readPaths, env.sessionGuard, env.managedConfig, opts.FileOverlay, opts.TerminalRunner, env.sessionTemp, b.fileWriteReceipt)
addViewImage(t.reg, cfg, b.model.entry, env, root)
addPresentFiles(t.reg, cfg, env, root)
bindFileViews(t.reg, cfg.Tools.ChangedFilesProtected())
t.peerLink, t.peerTools = peerTools(t.reg, cfg)
t.settingLink = addSettingTool(t.reg, cfg)
@@ -0,0 +1,155 @@
package boot
import (
"bytes"
"context"
"encoding/json"
"path/filepath"
"reflect"
"testing"
"time"
"reasonix/internal/contract/event"
"reasonix/internal/contract/eventwire"
"reasonix/internal/contract/provider"
"reasonix/internal/runtime/agent/testutil"
"reasonix/internal/session/control"
"reasonix/internal/tools/builtin"
)
func TestEffectPresentFilesSurvivesTheRealToolPipeline(t *testing.T) {
for _, proxy := range []bool{false, true} {
name := "direct"
if proxy {
name = "capability"
}
for _, mode := range []string{"off", "on", "forbidden", "disabled", "excluded"} {
t.Run(name+"/"+mode, func(t *testing.T) { presentFilesEffect(t, proxy, mode) })
}
}
}
func presentFilesEffect(t *testing.T, proxy bool, mode string) {
t.Helper()
home := filepath.Join(isolateConfigHome(t), ".reasonix")
t.Setenv("REASONIX_HOME", home)
settings := "[tools]\npresent_files = true\n"
switch mode {
case "off":
settings = ""
case "forbidden":
settings += "[sandbox]\nforbid_read = [\"report.md\"]\n"
case "disabled":
settings += "disabled = [\"present_files\"]\n"
case "excluded":
settings += "enabled = [\"read_file\"]\n"
}
writeFile(t, home, "config.toml", settings)
enabled := mode == "on" || mode == "forbidden"
dir := robustTempDir(t)
t.Chdir(dir)
registerBootTokenProfileTestProvider()
call := provider.ToolCall{ID: "present", Name: "present_files", Arguments: `{"files":[{"path":"report.md","description":"Report"}]}`}
if proxy {
call.Name = "use_capability"
call.Arguments = `{"action":"call","capability_id":"tool:present_files","arguments":{"files":[{"path":"report.md","description":"Report"}]}}`
}
prov := testutil.NewMock("present-files",
testutil.Turn{ToolCalls: []provider.ToolCall{call}},
testutil.Turn{Text: "Done."},
)
setBootTokenProfileTestProvider(t, prov)
writeFile(t, dir, "reasonix.toml", "default_model = \"test-model\"\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n")
writeFile(t, dir, "report.md", "# Report")
approveWorkspace(t, dir)
events := make(chan event.Event, 1000)
ctrl, err := Build(context.Background(), Options{WorkspaceRoot: dir, Sink: event.FuncSink(func(e event.Event) { events <- e })})
if err != nil {
t.Fatal(err)
}
t.Cleanup(ctrl.Close)
want := builtin.Workspace{}.PresentFiles()
count := 0
for _, entry := range ctrl.AllToolContractEntries() {
if entry.Name == want.Name() {
count++
}
}
if (count == 1) != enabled || count > 1 {
t.Fatalf("registry contribution = %d, enabled = %v", count, enabled)
}
if err := ctrl.NewSession(); err != nil {
t.Fatal(err)
}
ctrl.SetToolApprovalMode(control.ToolApprovalYolo)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := ctrl.Run(ctx, "Present the existing report.md file."); err != nil {
t.Fatal(err)
}
found := false
for len(events) > 0 {
e := <-events
if e.Kind == event.ToolResult && e.Tool.ID == "present" {
wire := eventwire.ToWire(e)
if wire.Tool == nil {
t.Fatal("tool result missing wire payload")
}
if mode != "on" {
if e.Tool.Err == "" || len(wire.Tool.Deliveries) != 0 {
t.Fatalf("unavailable/forbidden call produced delivery: %+v", e.Tool)
}
} else if len(wire.Tool.Deliveries) != 1 {
t.Fatalf("delivery did not reach UI: %+v, output %q", e.Tool, e.Tool.Output)
}
found = true
}
}
if !found {
t.Fatal("present tool result missing")
}
found = false
for _, message := range ctrl.History() {
for _, call := range message.ToolCalls {
if call.ID == "present" && call.CapabilityID == "tool:present_files" {
found = true
}
}
}
if mode == "on" && !found {
t.Fatal("host-confirmed identity was not persisted")
}
requests := prov.Requests()
if len(requests) < 2 || !reflect.DeepEqual(requests[0].Tools, requests[1].Tools) {
t.Fatal("delivery changed the tool schema within the turn")
}
if !enabled {
first, err := json.Marshal(requests[0])
if err != nil {
t.Fatal(err)
}
if bytes.Contains(first, []byte(want.Name())) {
t.Fatal("disabled presenter leaked into the first provider request")
}
}
count = 0
for _, schema := range requests[0].Tools {
if schema.Name != want.Name() {
continue
}
count++
var gotSchema, wantSchema any
if err := json.Unmarshal(schema.Parameters, &gotSchema); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(provider.CanonicalizeSchema(want.Schema()), &wantSchema); err != nil {
t.Fatal(err)
}
if schema.Description != want.Description() || !reflect.DeepEqual(gotSchema, wantSchema) {
t.Fatalf("unexpected provider contribution: %+v", schema)
}
}
if (count == 1) != enabled || count > 1 {
t.Fatalf("provider contribution = %d, enabled = %v", count, enabled)
}
}
@@ -0,0 +1,19 @@
package boot
import (
"slices"
"reasonix/internal/contract/config"
"reasonix/internal/contract/tool"
"reasonix/internal/tools/builtin"
)
// addPresentFiles contributes no tool or provider schema until the user opts in.
func addPresentFiles(reg *tool.Registry, cfg *config.Config, env toolEnvironment, root string) {
if !cfg.Tools.PresentFiles || len(cfg.Tools.Enabled) > 0 && !slices.Contains(cfg.Tools.Enabled, "present_files") {
return
}
reg.Add(builtin.Workspace{
Dir: root, WriteRoots: env.writeRoots, ReadRoots: env.readRoots, ForbidReadRoots: env.forbidReadRoots,
}.PresentFiles())
}
+234
View File
@@ -0,0 +1,234 @@
// Package workspacefile provides bounded, root-confined document reads.
package workspacefile
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"unicode/utf8"
)
const (
TextLimit = 2 << 20
ImageLimit = 10 << 20
PDFLimit = 32 << 20
)
var (
ErrDenied = errors.New("file is outside authorized roots")
ErrMissing = errors.New("file does not exist")
ErrNotFile = errors.New("not a regular file")
ErrTooLarge = errors.New("file exceeds preview limit")
ErrNotText = errors.New("file is not UTF-8 text")
ErrUnsupported = errors.New("file format has no preview")
ErrChanged = errors.New("file changed during preview read")
)
type Ref struct {
Path string `json:"path"`
Root string `json:"root,omitempty"`
}
type Info struct {
Ref Ref `json:"ref"`
Kind string `json:"kind"`
Size int64 `json:"size"`
Version string `json:"version"`
Limit int64 `json:"limit"`
ReadOnly bool `json:"readOnly"`
}
// Service receives roots already authorized by its host.
type Service struct {
Primary string
Extra []string
}
func (s Service) Resolve(path string) (Ref, error) {
if !filepath.IsAbs(path) {
if !filepath.IsLocal(path) {
return Ref{}, ErrDenied
}
return Ref{Path: filepath.ToSlash(filepath.Clean(path))}, nil
}
for _, root := range append([]string{s.Primary}, s.Extra...) {
rel, err := filepath.Rel(root, path)
if err == nil && filepath.IsLocal(rel) {
ref := Ref{Path: filepath.ToSlash(rel)}
if filepath.Clean(root) != filepath.Clean(s.Primary) {
ref.Root = root
}
return ref, nil
}
}
return Ref{}, ErrDenied
}
func (s Service) open(ref Ref) (*os.File, error) {
path := filepath.FromSlash(ref.Path)
if !filepath.IsLocal(path) || path == "." {
return nil, ErrDenied
}
base := s.Primary
if ref.Root != "" && filepath.Clean(ref.Root) != filepath.Clean(base) {
found := false
for _, extra := range s.Extra {
if filepath.Clean(extra) == filepath.Clean(ref.Root) {
base, found = extra, true
break
}
}
if !found {
return nil, ErrDenied
}
}
root, err := os.OpenRoot(base)
if err != nil {
return nil, classify(err)
}
defer root.Close()
resolvedBase, err := filepath.EvalSymlinks(base)
if err != nil {
return nil, classify(err)
}
target, err := filepath.EvalSymlinks(filepath.Join(base, path))
if err != nil {
return nil, classify(err)
}
relative, err := filepath.Rel(resolvedBase, target)
if err != nil || !filepath.IsLocal(relative) {
return nil, ErrDenied
}
// Stat before opening excludes FIFOs and devices, which may block on open.
info, err := root.Stat(path)
if err != nil {
return nil, classify(err)
}
if !info.Mode().IsRegular() {
return nil, ErrNotFile
}
file, err := root.OpenFile(path, os.O_RDONLY|nonblock, 0)
if err != nil {
return nil, classify(err)
}
info, err = file.Stat()
if err != nil || !info.Mode().IsRegular() {
file.Close()
if err != nil {
return nil, classify(err)
}
return nil, ErrNotFile
}
return file, nil
}
func classify(err error) error {
if errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("%w: %w", ErrMissing, err)
}
if errors.Is(err, fs.ErrPermission) {
return fmt.Errorf("%w: %w", ErrDenied, err)
}
return err
}
func metadata(ref Ref, info fs.FileInfo) Info {
kind, limit := Format(ref.Path)
return Info{Ref: ref, Kind: kind, Size: info.Size(), Limit: limit, ReadOnly: ref.Root != "",
Version: strconv.FormatInt(info.ModTime().UnixNano(), 10) + ":" + strconv.FormatInt(info.Size(), 10)}
}
func (s Service) Info(ctx context.Context, ref Ref) (Info, error) {
if err := ctx.Err(); err != nil {
return Info{}, err
}
f, err := s.open(ref)
if err != nil {
return Info{}, err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return Info{}, err
}
return metadata(ref, info), nil
}
func (s Service) Content(ctx context.Context, ref Ref) ([]byte, Info, error) {
return s.content(ctx, ref, false)
}
func (s Service) Text(ctx context.Context, ref Ref) ([]byte, Info, error) {
return s.content(ctx, ref, true)
}
func (s Service) content(ctx context.Context, ref Ref, textOnly bool) ([]byte, Info, error) {
if err := ctx.Err(); err != nil {
return nil, Info{}, err
}
f, err := s.open(ref)
if err != nil {
return nil, Info{}, err
}
defer f.Close()
before, err := f.Stat()
if err != nil {
return nil, Info{}, err
}
info := metadata(ref, before)
if textOnly {
info.Kind, info.Limit = "text", TextLimit
}
if info.Kind == "unsupported" {
return nil, info, ErrUnsupported
}
if info.Size > info.Limit {
return nil, info, ErrTooLarge
}
data, err := io.ReadAll(io.LimitReader(f, info.Limit+1))
if err != nil {
return nil, info, err
}
if err := ctx.Err(); err != nil {
return nil, info, err
}
if int64(len(data)) > info.Limit {
return nil, info, ErrTooLarge
}
after, err := f.Stat()
if err != nil {
return nil, info, err
}
if metadata(ref, after).Version != info.Version {
return nil, info, ErrChanged
}
if info.Kind != "pdf" && info.Kind != "image" && (!utf8.Valid(data) || strings.IndexByte(string(data), 0) >= 0) {
return nil, info, ErrNotText
}
return data, info, nil
}
func Format(path string) (string, int64) {
switch strings.ToLower(filepath.Ext(path)) {
case ".md", ".markdown", ".mdx":
return "markdown", TextLimit
case ".html", ".htm":
return "html", TextLimit
case ".csv", ".tsv":
return "table", TextLimit
case ".pdf":
return "pdf", PDFLimit
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".bmp", ".ico", ".svg":
return "image", ImageLimit
case ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".zip", ".gz", ".7z", ".rar", ".exe", ".dll", ".bin":
return "unsupported", 0
default:
return "text", TextLimit
}
}
+89
View File
@@ -0,0 +1,89 @@
package workspacefile
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
)
func TestPreviewReadsBytesAndRefusesEscape(t *testing.T) {
root := t.TempDir()
data := []byte("%PDF-1.7\n\x00binary")
if err := os.WriteFile(filepath.Join(root, "报告.pdf"), data, 0o600); err != nil {
t.Fatal(err)
}
s := Service{Primary: root}
info, err := s.Info(context.Background(), Ref{Path: "报告.pdf"})
if err != nil || info.Kind != "pdf" || info.Size != int64(len(data)) {
t.Fatalf("info = %+v, %v", info, err)
}
got, _, err := s.Content(context.Background(), info.Ref)
if err != nil || string(got) != string(data) {
t.Fatalf("read = %q, %v", got, err)
}
for _, ref := range []Ref{{Path: "../secret"}, {Root: t.TempDir(), Path: "x"}} {
if _, err := s.Info(context.Background(), ref); !errors.Is(err, ErrDenied) {
t.Fatalf("escape = %v", err)
}
}
outside := t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(root, "link")); err == nil {
if _, err := s.Info(context.Background(), Ref{Path: "link/secret.txt"}); err == nil {
t.Fatal("symlink escaped root")
}
}
}
func TestPreviewLimitsAndUnknownBinary(t *testing.T) {
root := t.TempDir()
s := Service{Primary: root}
for _, tc := range []struct {
name string
data []byte
want error
}{
{"big.txt", make([]byte, TextLimit+1), ErrTooLarge},
{"unknown.bin", []byte{0, 1, 2}, ErrUnsupported},
{"bad.txt", []byte{0xff, 0xfe}, ErrNotText},
} {
if err := os.WriteFile(filepath.Join(root, tc.name), tc.data, 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := s.Content(context.Background(), Ref{Path: tc.name}); !errors.Is(err, tc.want) {
t.Errorf("%s: %v, want %v", tc.name, err, tc.want)
}
}
if _, err := s.Info(context.Background(), Ref{Path: "absent"}); !errors.Is(err, ErrMissing) {
t.Fatal(err)
}
}
func TestGrantedRootAndCancellation(t *testing.T) {
extra := t.TempDir()
if err := os.WriteFile(filepath.Join(extra, "notes.md"), []byte("# Notes"), 0o600); err != nil {
t.Fatal(err)
}
s := Service{Primary: t.TempDir(), Extra: []string{extra}}
ref, err := s.Resolve(filepath.Join(extra, "notes.md"))
if err != nil || ref.Root != extra || ref.Path != "notes.md" {
t.Fatalf("resolve = %+v, %v", ref, err)
}
info, err := s.Info(t.Context(), ref)
if err != nil || !info.ReadOnly {
t.Fatalf("grant = %+v, %v", info, err)
}
s.Extra = nil
if _, err := s.Info(t.Context(), ref); !errors.Is(err, ErrDenied) {
t.Fatalf("revoked grant = %v", err)
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
if _, _, err := s.Content(ctx, ref); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled read = %v", err)
}
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package workspacefile
import "syscall"
const nonblock = syscall.O_NONBLOCK
@@ -0,0 +1,3 @@
package workspacefile
const nonblock = 0
+2
View File
@@ -1216,6 +1216,8 @@ type ToolsConfig struct {
SettingTool *bool `toml:"setting_tool"`
// MCPResources offers a tool that reads a connected server's resource; user file only, off by default.
MCPResources bool `toml:"mcp_resources"`
// PresentFiles offers file-delivery cards to the model; user file only, off by default.
PresentFiles bool `toml:"present_files"`
WebAccessSettings
// VideoNativeMaxBytes lowers the size read_video sends natively below the endpoint's limit; 0 keeps it. User file only.
VideoNativeMaxBytes int64 `toml:"video_native_max_bytes"`
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more