From e1159a4685ce6515cd2702e01978ca06d6c4faeb Mon Sep 17 00:00:00 2001
From: SivanCola <32437197+SivanCola@users.noreply.github.com>
Date: Wed, 21 Oct 2026 03:32:25 +0800
Subject: [PATCH] Add structured file deliveries and workbench document
previews
Problem: AI-created documents could not be handed off as validated, durable
file cards, and binary files could not be read through the text editor route.
Root cause: delivery identity lived only in ordinary tool output, while file
reading, view selection and auxiliary actions lacked a shared preview path.
Fix: add a bounded root-confined reader, Controller and raw-byte preview APIs,
and host-confirmed present_files results projected through live and stored
history. Add lazy PDF, worker CSV and sandboxed HTML viewers, responsive file
cards, type icons, current-file state and accessible shared tooltips. Preserve
dirty drafts while surfacing deletion/revocation and avoid metadata reads per
keystroke. Keep the current workbench request-consumption contract intact.
Verification: root and desktop Go tests, go vet, make lint/check, frontend
typecheck, 498 related tests and production build. Browser checks cover PDF
navigation, CSV literals, menus, request consumption and responsive themes.
The builtin schema inventory grows by 143 measured tokens; boot effect tests
verify stable schemas within a turn for direct and capability-proxy calls.
---
.gitattributes | 1 +
.../electron/test/document_preview_live.js | 81 ++++++
desktop/frontend-next/package.json | 5 +
desktop/frontend-next/perf/delivery-data.ts | 23 ++
.../frontend-next/perf/document-fixtures.cjs | 24 ++
.../frontend-next/perf/document-preview.mjs | 100 ++++++++
desktop/frontend-next/perf/harness.tsx | 17 +-
desktop/frontend-next/perf/media/delivery.pdf | 43 ++++
desktop/frontend-next/pnpm-lock.yaml | 216 +++++++++++++++-
desktop/frontend-next/src/i18n/en.ts | 2 +
desktop/frontend-next/src/i18n/en_document.ts | 44 ++++
desktop/frontend-next/src/i18n/kernel.ts | 7 +
desktop/frontend-next/src/port/mock.ts | 2 -
.../frontend-next/src/port/mock_workspace.ts | 11 +
desktop/frontend-next/src/port/port.ts | 6 +-
desktop/frontend-next/src/port/session.ts | 1 +
desktop/frontend-next/src/port/sse.ts | 14 ++
desktop/frontend-next/src/port/wire.ts | 1 +
desktop/frontend-next/src/port/workspace.ts | 24 ++
desktop/frontend-next/src/state/session.ts | 3 +-
.../src/ui/BrowserFullToggle.tsx | 9 +-
.../src/ui/BrowserWideToggle.tsx | 9 +-
desktop/frontend-next/src/ui/Chrome.tsx | 15 +-
.../frontend-next/src/ui/CopyButton.test.tsx | 8 +-
desktop/frontend-next/src/ui/CopyButton.tsx | 6 +-
desktop/frontend-next/src/ui/Markdown.tsx | 1 +
desktop/frontend-next/src/ui/Pane.tsx | 15 +-
desktop/frontend-next/src/ui/PhonePop.tsx | 5 +-
.../src/ui/TooltipButton.test.tsx | 80 ++++++
.../frontend-next/src/ui/TooltipButton.tsx | 73 ++++++
desktop/frontend-next/src/ui/Transcript.tsx | 11 +-
desktop/frontend-next/src/ui/ViewSwitch.tsx | 13 +-
.../frontend-next/src/ui/WorkbenchFileBar.tsx | 17 +-
.../frontend-next/src/ui/WorkbenchPanel.tsx | 121 ++++-----
.../src/ui/browser-wide.interaction.test.tsx | 18 +-
.../src/ui/cards/SayCard.interaction.test.tsx | 3 +-
.../frontend-next/src/ui/cards/SayCard.tsx | 21 +-
.../conversation-files.interaction.test.tsx | 4 +-
.../src/ui/document/DelimitedPreview.tsx | 53 ++++
.../src/ui/document/DeliveryCard.test.tsx | 70 ++++++
.../src/ui/document/DeliveryCard.tsx | 87 +++++++
.../src/ui/document/DeliveryMenu.tsx | 45 ++++
.../src/ui/document/DocumentPreview.tsx | 67 +++++
.../src/ui/document/FileTypeIcon.test.ts | 28 +++
.../src/ui/document/FileTypeIcon.tsx | 85 +++++++
.../src/ui/document/PdfPreview.tsx | 87 +++++++
.../src/ui/document/StaticHtmlPreview.tsx | 11 +
.../document/active-file.interaction.test.tsx | 29 +++
.../src/ui/document/delimited.worker.ts | 9 +
.../src/ui/document/deliveries.test.ts | 38 +++
.../src/ui/document/deliveries.ts | 23 ++
.../src/ui/document/delivery.css | 181 ++++++++++++++
.../src/ui/document/document.css | 77 ++++++
.../src/ui/document/document.test.ts | 24 ++
.../ui/document/draft-observation.test.tsx | 25 ++
.../src/ui/document/parseDelimited.ts | 19 ++
.../frontend-next/src/ui/document/registry.ts | 18 ++
.../src/ui/document/staticDocument.ts | 20 ++
.../src/ui/document/text-refresh.test.tsx | 43 ++++
.../src/ui/document/useDocument.test.tsx | 49 ++++
.../src/ui/document/useDocument.ts | 55 ++++
.../src/ui/document/viewState.ts | 12 +
.../src/ui/html-preview.interaction.test.tsx | 30 ++-
desktop/frontend-next/src/ui/tooltip.css | 22 ++
.../src/ui/useConversationFiles.tsx | 3 +-
.../frontend-next/src/ui/useWorkbenchFiles.ts | 39 ++-
.../src/ui/view-switch.interaction.test.tsx | 7 +-
...orkbench-missing-file.interaction.test.tsx | 20 +-
...ench-opening-lifetime.interaction.test.tsx | 3 +
.../frontend-next/src/ui/workbench_tree.ts | 62 +++++
desktop/frontend-next/tools/pdf-assets.ts | 33 +++
desktop/frontend-next/vite.config.ts | 3 +-
docs/FILE_PREVIEW.md | 67 +++++
docs/STUDIO_PARITY.md | 3 +-
docs/TOOL_CONTRACT.md | 20 ++
docs/assets/file-delivery/dark.jpg | Bin 0 -> 67665 bytes
docs/assets/file-delivery/light.jpg | Bin 0 -> 70322 bytes
docs/assets/file-delivery/phone.jpg | Bin 0 -> 35230 bytes
.../boot/effect_present_files_test.go | 89 +++++++
internal/base/workspacefile/file.go | 234 ++++++++++++++++++
internal/base/workspacefile/file_test.go | 89 +++++++
internal/base/workspacefile/open_unix.go | 7 +
internal/base/workspacefile/open_windows.go | 3 +
internal/contract/delivery/delivery.go | 41 +++
internal/contract/delivery/delivery_test.go | 25 ++
internal/contract/event/event.go | 8 +-
internal/contract/eventwire/wire.go | 17 +-
internal/contract/tool/tool.go | 5 +
internal/frontend/serve/history.go | 16 +-
internal/frontend/serve/open_document.go | 6 +-
internal/frontend/serve/open_document_test.go | 2 +-
internal/frontend/serve/routes.go | 1 +
internal/frontend/serve/workspace_files.go | 47 ++--
.../frontend/serve/workspace_files_test.go | 11 +-
internal/frontend/serve/workspace_image.go | 29 +--
internal/frontend/serve/workspace_preview.go | 65 +++++
.../frontend/serve/workspace_preview_test.go | 91 +++++++
internal/runtime/agent/execute_batch.go | 6 +
internal/runtime/agent/execute_one.go | 19 +-
internal/runtime/agent/shell_subject.go | 22 ++
internal/session/control/port.go | 3 +
internal/session/control/workspace_preview.go | 28 +++
internal/tools/builtin/present_files.go | 104 ++++++++
internal/tools/builtin/present_files_test.go | 63 +++++
internal/tools/builtin/schema_budget.json | 2 +-
internal/tools/builtin/workspace.go | 1 +
tools/repolint/layers.go | 2 +
tools/repolint/wireparity.go | 4 +
108 files changed, 3309 insertions(+), 252 deletions(-)
create mode 100644 desktop/electron/test/document_preview_live.js
create mode 100644 desktop/frontend-next/perf/delivery-data.ts
create mode 100644 desktop/frontend-next/perf/document-fixtures.cjs
create mode 100644 desktop/frontend-next/perf/document-preview.mjs
create mode 100644 desktop/frontend-next/perf/media/delivery.pdf
create mode 100644 desktop/frontend-next/src/i18n/en_document.ts
create mode 100644 desktop/frontend-next/src/ui/TooltipButton.test.tsx
create mode 100644 desktop/frontend-next/src/ui/TooltipButton.tsx
create mode 100644 desktop/frontend-next/src/ui/document/DelimitedPreview.tsx
create mode 100644 desktop/frontend-next/src/ui/document/DeliveryCard.test.tsx
create mode 100644 desktop/frontend-next/src/ui/document/DeliveryCard.tsx
create mode 100644 desktop/frontend-next/src/ui/document/DeliveryMenu.tsx
create mode 100644 desktop/frontend-next/src/ui/document/DocumentPreview.tsx
create mode 100644 desktop/frontend-next/src/ui/document/FileTypeIcon.test.ts
create mode 100644 desktop/frontend-next/src/ui/document/FileTypeIcon.tsx
create mode 100644 desktop/frontend-next/src/ui/document/PdfPreview.tsx
create mode 100644 desktop/frontend-next/src/ui/document/StaticHtmlPreview.tsx
create mode 100644 desktop/frontend-next/src/ui/document/active-file.interaction.test.tsx
create mode 100644 desktop/frontend-next/src/ui/document/delimited.worker.ts
create mode 100644 desktop/frontend-next/src/ui/document/deliveries.test.ts
create mode 100644 desktop/frontend-next/src/ui/document/deliveries.ts
create mode 100644 desktop/frontend-next/src/ui/document/delivery.css
create mode 100644 desktop/frontend-next/src/ui/document/document.css
create mode 100644 desktop/frontend-next/src/ui/document/document.test.ts
create mode 100644 desktop/frontend-next/src/ui/document/draft-observation.test.tsx
create mode 100644 desktop/frontend-next/src/ui/document/parseDelimited.ts
create mode 100644 desktop/frontend-next/src/ui/document/registry.ts
create mode 100644 desktop/frontend-next/src/ui/document/staticDocument.ts
create mode 100644 desktop/frontend-next/src/ui/document/text-refresh.test.tsx
create mode 100644 desktop/frontend-next/src/ui/document/useDocument.test.tsx
create mode 100644 desktop/frontend-next/src/ui/document/useDocument.ts
create mode 100644 desktop/frontend-next/src/ui/document/viewState.ts
create mode 100644 desktop/frontend-next/src/ui/tooltip.css
create mode 100644 desktop/frontend-next/src/ui/workbench_tree.ts
create mode 100644 desktop/frontend-next/tools/pdf-assets.ts
create mode 100644 docs/FILE_PREVIEW.md
create mode 100644 docs/assets/file-delivery/dark.jpg
create mode 100644 docs/assets/file-delivery/light.jpg
create mode 100644 docs/assets/file-delivery/phone.jpg
create mode 100644 internal/assembly/boot/effect_present_files_test.go
create mode 100644 internal/base/workspacefile/file.go
create mode 100644 internal/base/workspacefile/file_test.go
create mode 100644 internal/base/workspacefile/open_unix.go
create mode 100644 internal/base/workspacefile/open_windows.go
create mode 100644 internal/contract/delivery/delivery.go
create mode 100644 internal/contract/delivery/delivery_test.go
create mode 100644 internal/frontend/serve/workspace_preview.go
create mode 100644 internal/frontend/serve/workspace_preview_test.go
create mode 100644 internal/runtime/agent/shell_subject.go
create mode 100644 internal/session/control/workspace_preview.go
create mode 100644 internal/tools/builtin/present_files.go
create mode 100644 internal/tools/builtin/present_files_test.go
diff --git a/.gitattributes b/.gitattributes
index bdb3376b27..9ec1c1bb7f 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -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
diff --git a/desktop/electron/test/document_preview_live.js b/desktop/electron/test/document_preview_live.js
new file mode 100644
index 0000000000..db092734cf
--- /dev/null
+++ b/desktop/electron/test/document_preview_live.js
@@ -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"), '
Static document
');
+ 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"'));
+ 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);
+});
diff --git a/desktop/frontend-next/package.json b/desktop/frontend-next/package.json
index 518b3d0220..21fa7866eb 100644
--- a/desktop/frontend-next/package.json
+++ b/desktop/frontend-next/package.json
@@ -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",
diff --git a/desktop/frontend-next/perf/delivery-data.ts b/desktop/frontend-next/perf/delivery-data.ts
new file mode 100644
index 0000000000..7e2d6b8017
--- /dev/null
+++ b/desktop/frontend-next/perf/delivery-data.ts
@@ -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 = {
+ "方案.md": "# 文件交付与预览体验\n\n点击聊天中的交付卡片,可在右侧打开对应文件。\n\n| 示例文件 | 体验内容 |\n| --- | --- |\n| 方案.md | Markdown 正文与表格 |\n| 数据.csv | 表格与源码 |\n| 报告.html | 静态 HTML |\n| 示例.pdf | 分页与文字选择 |",
+ "数据.csv": '编号,内容\n001,"第一行\n第二行"\n002,=SUM(A1:A2)',
+ "报告.html": "交付文件可以直接阅读
文件交付预览示例。
",
+ "示例.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: "四份文件已准备好。点击卡片在右侧预览,其他操作可从卡片的更多菜单进入。" },
+ ];
+}
diff --git a/desktop/frontend-next/perf/document-fixtures.cjs b/desktop/frontend-next/perf/document-fixtures.cjs
new file mode 100644
index 0000000000..f6e626194f
--- /dev/null
+++ b/desktop/frontend-next/perf/document-fixtures.cjs
@@ -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 };
diff --git a/desktop/frontend-next/perf/document-preview.mjs b/desktop/frontend-next/perf/document-preview.mjs
new file mode 100644
index 0000000000..2eeefdf8b7
--- /dev/null
+++ b/desktop/frontend-next/perf/document-preview.mjs
@@ -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": 'Static report

Preview stays in the workbench.
',
+ };
+ 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();
+}
diff --git a/desktop/frontend-next/perf/harness.tsx b/desktop/frontend-next/perf/harness.tsx
index cccc809a5b..0b23ef0266 100644
--- a/desktop/frontend-next/perf/harness.tsx
+++ b/desktop/frontend-next/perf/harness.tsx
@@ -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 {
- 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) => 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)[method] = () => Promise.reject(new Error(message));
};
diff --git a/desktop/frontend-next/perf/media/delivery.pdf b/desktop/frontend-next/perf/media/delivery.pdf
new file mode 100644
index 0000000000..ed7ea8992d
--- /dev/null
+++ b/desktop/frontend-next/perf/media/delivery.pdf
@@ -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
diff --git a/desktop/frontend-next/pnpm-lock.yaml b/desktop/frontend-next/pnpm-lock.yaml
index 1d5d9bf993..b984088c7c 100644
--- a/desktop/frontend-next/pnpm-lock.yaml
+++ b/desktop/frontend-next/pnpm-lock.yaml
@@ -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/react@19.3.0)(react@19.3.1)
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/dom@10.4.1)
+ '@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/react@19.3.0)
'@vitejs/plugin-react':
specifier: ^6.1.2
- version: 6.1.2(vite@8.3.4(esbuild@0.28.2))
+ version: 6.1.2(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2))
jsdom:
specifier: ^30.1.3
version: 30.1.3(@noble/hashes@2.4.0)
@@ -158,10 +173,10 @@ importers:
version: 7.0.2
vite:
specifier: ^8.3.4
- version: 8.3.4(esbuild@0.28.2)
+ version: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)
vitest:
specifier: ^5.0.4
- version: 5.0.4(jsdom@30.1.3(@noble/hashes@2.4.0))(vite@8.3.4(esbuild@0.28.2))
+ version: 5.0.4(@types/node@26.6.5)(jsdom@30.1.3(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2))
packages:
@@ -625,6 +640,81 @@ packages:
'@marijn/find-cluster-break@1.0.4':
resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
+ '@napi-rs/canvas-android-arm64@1.0.12':
+ resolution: {integrity: sha512-Z8hg9BD7ABaZNhdM8O96IrfBaYLM7KzO/YyKA8eU9BDdc5fZJRTWyOhSAyOv59/ari6bOYa+p96h7zN6wbJRDA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [android]
+
+ '@napi-rs/canvas-darwin-arm64@1.0.12':
+ resolution: {integrity: sha512-dRtZCi/u1IOZyRjEDCA73PqgWakf5689K2jfv4uPRYGMJvEqtV+Eq7MIQkBJLmdfUPe7AktSiBZRml2pggnjRQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@napi-rs/canvas-darwin-x64@1.0.12':
+ resolution: {integrity: sha512-YwvMgK5NBXwPY1AjZGXfPCG3hp/OFGM0Kw+JZHCOR/FYaeSOftaZRyxaDEGkJQSLEUZZIMNJXKCWgZYSzgWZEA==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@napi-rs/canvas-linux-arm-gnueabihf@1.0.12':
+ resolution: {integrity: sha512-LlOsZy8qh+qJq1euEvDZqa9muACS1fL1Ag38c7J+axAnA8NeXazMtqmUyKb4NVyft6DqbU/MesEj3C0bAFnv4Q==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@napi-rs/canvas-linux-arm64-gnu@1.0.12':
+ resolution: {integrity: sha512-nvuqhi85GzaFl/6sid4qf7wwjHuGU6gfIuDyywUb/fB+kp6Fp3h8GyIAKss/QPil3zVO2r8g6O9ah+IOUoBrIw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@napi-rs/canvas-linux-arm64-musl@1.0.12':
+ resolution: {integrity: sha512-Bf6+9PoEr9K06P/oyMab7zZOpW90KimbIuNsy/vL0VhZZKsb0B0rCC78cp0jXhK6PIM7v8t6uX1sn7isOZ8KEw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@napi-rs/canvas-linux-riscv64-gnu@1.0.12':
+ resolution: {integrity: sha512-j7VcZduLSRx60nv/wbdyH+fyB5QriWGuO8GrvMDlFlt7GNUKAw5lHAHpcuuQW3QwLOe+3P+3qryzyT3sW5pCqQ==}
+ engines: {node: '>= 10'}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@napi-rs/canvas-linux-x64-gnu@1.0.12':
+ resolution: {integrity: sha512-twDb8ZNgoCF8EG6ka+Lk4etU+ea9lv6NiV5ptIf+thz3LgWuHBbt85c+YgL1s05VsHe4WYWK0f+GhR45IuqARg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@napi-rs/canvas-linux-x64-musl@1.0.12':
+ resolution: {integrity: sha512-mtZbBeNCdGA0MYQxivPmMOp5JMH80LaJLQeEMg7Nne517fvgGb+DWTJTmoE/qUW8YJ4nkiP2vQyr1XVGrH/Ktg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@napi-rs/canvas-win32-arm64-msvc@1.0.12':
+ resolution: {integrity: sha512-HgGRuBUkFL8U99ogwUnTz3SmwUu7WKjmj0eOVWRqVZTPPyfWGEIMr+ycb8oDoNZcKM9vSiF1rpy3r+UIqqaKqw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@napi-rs/canvas-win32-x64-msvc@1.0.12':
+ resolution: {integrity: sha512-SEEfblIAnYeS4UkeFPp9jw6ZWuHxaTqumUuksMgnMXmPLW8Ofb21uu7cujhw+lk4E9V3aWgGAE9f8g+CdoAU0Q==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@napi-rs/canvas@1.0.12':
+ resolution: {integrity: sha512-PSwhRdvizfuiSDlhE4Ld2jl/JqkrbSFFyZO0ycamE6mBpf1H0uUF3Tm8yQaVDeAwCNNDzxjRfrN22KmWLTDwcw==}
+ engines: {node: '>= 10'}
+
'@noble/curves@2.4.0':
resolution: {integrity: sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==}
engines: {node: '>= 20.19.0'}
@@ -840,6 +930,12 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+ '@types/node@26.6.5':
+ resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==}
+
+ '@types/papaparse@5.5.2':
+ resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==}
+
'@types/react-dom@19.3.0':
resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
peerDependencies:
@@ -1204,6 +1300,9 @@ packages:
dompurify@3.4.11:
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
+ dompurify@3.4.17:
+ resolution: {integrity: sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==}
+
earcut@3.2.4:
resolution: {integrity: sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==}
@@ -1514,6 +1613,15 @@ packages:
resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==}
engines: {node: 20 || >=22}
+ lucide-react@1.60.0:
+ 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
+
lz-string@1.5.0:
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'}
+ papaparse@5.7.0:
+ resolution: {integrity: sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==}
+
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
@@ -1704,6 +1815,10 @@ packages:
resolution: {integrity: sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==}
hasBin: true
+ pdfjs-dist@6.4.299:
+ resolution: {integrity: sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==}
+ engines: {node: '>=22.13.0 || >=24'}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -2021,6 +2136,9 @@ packages:
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
+ undici-types@8.9.0:
+ resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
+
undici@8.12.1:
resolution: {integrity: sha512-usz3OVQBeh1LxNn0DzU0voLXMgn5CU/WUdpBsY6Ti5afNao+vTkmn4H/NxoXolc10cOqe2+YIG0Hspq2rsl64w==}
engines: {node: '>=22.19.0'}
@@ -2740,6 +2858,54 @@ snapshots:
'@marijn/find-cluster-break@1.0.4': {}
+ '@napi-rs/canvas-android-arm64@1.0.12':
+ optional: true
+
+ '@napi-rs/canvas-darwin-arm64@1.0.12':
+ optional: true
+
+ '@napi-rs/canvas-darwin-x64@1.0.12':
+ optional: true
+
+ '@napi-rs/canvas-linux-arm-gnueabihf@1.0.12':
+ optional: true
+
+ '@napi-rs/canvas-linux-arm64-gnu@1.0.12':
+ optional: true
+
+ '@napi-rs/canvas-linux-arm64-musl@1.0.12':
+ optional: true
+
+ '@napi-rs/canvas-linux-riscv64-gnu@1.0.12':
+ optional: true
+
+ '@napi-rs/canvas-linux-x64-gnu@1.0.12':
+ optional: true
+
+ '@napi-rs/canvas-linux-x64-musl@1.0.12':
+ optional: true
+
+ '@napi-rs/canvas-win32-arm64-msvc@1.0.12':
+ optional: true
+
+ '@napi-rs/canvas-win32-x64-msvc@1.0.12':
+ optional: true
+
+ '@napi-rs/canvas@1.0.12':
+ 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/curves@2.4.0':
dependencies:
'@noble/hashes': 2.4.0
@@ -2895,6 +3061,14 @@ snapshots:
'@types/ms@2.1.0': {}
+ '@types/node@26.6.5':
+ dependencies:
+ undici-types: 8.9.0
+
+ '@types/papaparse@5.5.2':
+ dependencies:
+ '@types/node': 26.6.5
+
'@types/react-dom@19.3.0(@types/react@19.3.0)':
dependencies:
'@types/react': 19.3.0
@@ -2986,19 +3160,19 @@ snapshots:
optionalDependencies:
maplibre-gl: 6.14.0
- '@vitejs/plugin-react@6.1.2(vite@8.3.4(esbuild@0.28.2))':
+ '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2))':
dependencies:
'@rolldown/pluginutils': 1.0.1
- vite: 8.3.4(esbuild@0.28.2)
+ vite: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)
- '@vitest/mocker@5.0.4(vite@8.3.4(esbuild@0.28.2))':
+ '@vitest/mocker@5.0.4(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2))':
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(esbuild@0.28.2)
+ vite: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)
'@vitest/spy@5.0.4': {}
@@ -3145,6 +3319,10 @@ snapshots:
optionalDependencies:
'@types/trusted-types': 2.0.7
+ dompurify@3.4.17:
+ optionalDependencies:
+ '@types/trusted-types': 2.0.7
+
earcut@3.2.4: {}
entities@4.5.0: {}
@@ -3504,6 +3682,12 @@ snapshots:
lru-cache@11.5.3: {}
+ lucide-react@1.60.0(@types/react@19.3.0)(react@19.3.1):
+ dependencies:
+ react: 19.3.1
+ optionalDependencies:
+ '@types/react': 19.3.0
+
lz-string@1.5.0: {}
magic-string@1.4.3:
@@ -3922,6 +4106,8 @@ snapshots:
obug@3.0.0: {}
+ papaparse@5.7.0: {}
+
parse-entities@4.0.2:
dependencies:
'@types/unist': 2.0.11
@@ -3944,6 +4130,10 @@ snapshots:
dependencies:
resolve-protobuf-schema: 2.1.0
+ pdfjs-dist@6.4.299:
+ optionalDependencies:
+ '@napi-rs/canvas': 1.0.12
+
picocolors@1.1.1: {}
picomatch@4.0.7: {}
@@ -4323,6 +4513,8 @@ snapshots:
uc.micro@2.1.0: {}
+ undici-types@8.9.0: {}
+
undici@8.12.1: {}
unified@11.0.5:
@@ -4428,7 +4620,7 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
- vite@8.3.4(esbuild@0.28.2):
+ vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2):
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
- vitest@5.0.4(jsdom@30.1.3(@noble/hashes@2.4.0))(vite@8.3.4(esbuild@0.28.2)):
+ vitest@5.0.4(@types/node@26.6.5)(jsdom@30.1.3(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)):
dependencies:
'@types/chai': 5.2.3
- '@vitest/mocker': 5.0.4(vite@8.3.4(esbuild@0.28.2))
+ '@vitest/mocker': 5.0.4(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2))
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(esbuild@0.28.2)
+ vite: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)
why-is-node-running: 3.2.1
optionalDependencies:
+ '@types/node': 26.6.5
jsdom: 30.1.3(@noble/hashes@2.4.0)
transitivePeerDependencies:
- msw
diff --git a/desktop/frontend-next/src/i18n/en.ts b/desktop/frontend-next/src/i18n/en.ts
index a23d0a0322..f3bcc115ba 100644
--- a/desktop/frontend-next/src/i18n/en.ts
+++ b/desktop/frontend-next/src/i18n/en.ts
@@ -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 = {
...EN_COMPOSITION,
+ ...EN_DOCUMENT,
"此检查点不包含文件快照": "This checkpoint contains no file snapshots",
...EN_SURFACES,
"重命名聊天": "Rename chat",
diff --git a/desktop/frontend-next/src/i18n/en_document.ts b/desktop/frontend-next/src/i18n/en_document.ts
new file mode 100644
index 0000000000..28efee5d19
--- /dev/null
+++ b/desktop/frontend-next/src/i18n/en_document.ts
@@ -0,0 +1,44 @@
+export const EN_DOCUMENT: Record = {
+ "预览 {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.",
+};
diff --git a/desktop/frontend-next/src/i18n/kernel.ts b/desktop/frontend-next/src/i18n/kernel.ts
index b25cb56250..4dc9e4633a 100644
--- a/desktop/frontend-next/src/i18n/kernel.ts
+++ b/desktop/frontend-next/src/i18n/kernel.ts
@@ -35,6 +35,13 @@ const SAID: Record = {
"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": "界面描述不符合约定",
diff --git a/desktop/frontend-next/src/port/mock.ts b/desktop/frontend-next/src/port/mock.ts
index 9dbc3fd010..49ac843665 100644
--- a/desktop/frontend-next/src/port/mock.ts
+++ b/desktop/frontend-next/src/port/mock.ts
@@ -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 { 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 {
diff --git a/desktop/frontend-next/src/port/mock_workspace.ts b/desktop/frontend-next/src/port/mock_workspace.ts
index 5e3aac14ff..05f20b5b81 100644
--- a/desktop/frontend-next/src/port/mock_workspace.ts
+++ b/desktop/frontend-next/src/port/mock_workspace.ts
@@ -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 { return { path, content: `// ${path}\n`, revision: "fixture" }; }
+ async workspacePreviewInfo(ref: import("./workspace").FileRef): Promise {
+ 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() {}
diff --git a/desktop/frontend-next/src/port/port.ts b/desktop/frontend-next/src/port/port.ts
index b58397cb02..7db370eb3a 100644
--- a/desktop/frontend-next/src/port/port.ts
+++ b/desktop/frontend-next/src/port/port.ts
@@ -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;
saveProviderKey(apiKey: string): Promise;
// The models that answer the job asked about; "chat" when none is named.
diff --git a/desktop/frontend-next/src/port/session.ts b/desktop/frontend-next/src/port/session.ts
index d40e21ec2b..f7ead1e59e 100644
--- a/desktop/frontend-next/src/port/session.ts
+++ b/desktop/frontend-next/src/port/session.ts
@@ -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.
diff --git a/desktop/frontend-next/src/port/sse.ts b/desktop/frontend-next/src/port/sse.ts
index 09208c9a2b..24b7c09731 100644
--- a/desktop/frontend-next/src/port/sse.ts
+++ b/desktop/frontend-next/src/port/sse.ts
@@ -408,6 +408,20 @@ export class SsePort extends SseWorkspace implements AgentPort {
return this.get(`/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)}` : ""}`;
}
diff --git a/desktop/frontend-next/src/port/wire.ts b/desktop/frontend-next/src/port/wire.ts
index 96ab66e6cd..250cef0257 100644
--- a/desktop/frontend-next/src/port/wire.ts
+++ b/desktop/frontend-next/src/port/wire.ts
@@ -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;
diff --git a/desktop/frontend-next/src/port/workspace.ts b/desktop/frontend-next/src/port/workspace.ts
index 5569b9d236..b82397f9d5 100644
--- a/desktop/frontend-next/src/port/workspace.ts
+++ b/desktop/frontend-next/src/port/workspace.ts
@@ -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;
+ workspacePreviewContent(ref: FileRef, signal?: AbortSignal): Promise<{ data: ArrayBuffer; version: string }>;
+}
+
export interface WorkspaceEntry {
path: string;
name: string;
diff --git a/desktop/frontend-next/src/state/session.ts b/desktop/frontend-next/src/state/session.ts
index 6cd2867650..422912fe94 100644
--- a/desktop/frontend-next/src/state/session.ts
+++ b/desktop/frontend-next/src/state/session.ts
@@ -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 } : {}),
}),
diff --git a/desktop/frontend-next/src/ui/BrowserFullToggle.tsx b/desktop/frontend-next/src/ui/BrowserFullToggle.tsx
index 1cd8887ec2..be2a33bffb 100644
--- a/desktop/frontend-next/src/ui/BrowserFullToggle.tsx
+++ b/desktop/frontend-next/src/ui/BrowserFullToggle.tsx
@@ -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 (
-
+
);
}
diff --git a/desktop/frontend-next/src/ui/BrowserWideToggle.tsx b/desktop/frontend-next/src/ui/BrowserWideToggle.tsx
index 723293ee23..4ff55a883e 100644
--- a/desktop/frontend-next/src/ui/BrowserWideToggle.tsx
+++ b/desktop/frontend-next/src/ui/BrowserWideToggle.tsx
@@ -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 (
-
+
);
}
diff --git a/desktop/frontend-next/src/ui/Chrome.tsx b/desktop/frontend-next/src/ui/Chrome.tsx
index 7ae0a735ec..49208363ba 100644
--- a/desktop/frontend-next/src/ui/Chrome.tsx
+++ b/desktop/frontend-next/src/ui/Chrome.tsx
@@ -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,
{hub && onError &&
}
{port && (
-
+
)}
-
-
+
{port &&
}
diff --git a/desktop/frontend-next/src/ui/CopyButton.test.tsx b/desktop/frontend-next/src/ui/CopyButton.test.tsx
index 6c0ae14e61..8a292e1501 100644
--- a/desktop/frontend-next/src/ui/CopyButton.test.tsx
+++ b/desktop/frontend-next/src/ui/CopyButton.test.tsx
@@ -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(x := 1);
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();
- expect(screen.getByRole("button").getAttribute("title")).toBe(t("复制回复"));
+ fireEvent.focus(screen.getByRole("button"));
+ expect(screen.getByRole("tooltip").textContent).toBe(t("复制回复"));
});
});
diff --git a/desktop/frontend-next/src/ui/CopyButton.tsx b/desktop/frontend-next/src/ui/CopyButton.tsx
index 467f104a26..08e55ac2ff 100644
--- a/desktop/frontend-next/src/ui/CopyButton.tsx
+++ b/desktop/frontend-next/src/ui/CopyButton.tsx
@@ -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 (
-
+
);
}
diff --git a/desktop/frontend-next/src/ui/Markdown.tsx b/desktop/frontend-next/src/ui/Markdown.tsx
index 9cd1912803..0c5ef2274b 100644
--- a/desktop/frontend-next/src/ui/Markdown.tsx
+++ b/desktop/frontend-next/src/ui/Markdown.tsx
@@ -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. */
diff --git a/desktop/frontend-next/src/ui/Pane.tsx b/desktop/frontend-next/src/ui/Pane.tsx
index fbe188be6e..8b73e78133 100644
--- a/desktop/frontend-next/src/ui/Pane.tsx
+++ b/desktop/frontend-next/src/ui/Pane.tsx
@@ -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: (path: string) => setInserted((was) => ({ n: was.n + 1, text: "@" + path.replace(/[ \t]/g, "\\$&") })),
+ 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}
diff --git a/desktop/frontend-next/src/ui/PhonePop.tsx b/desktop/frontend-next/src/ui/PhonePop.tsx
index cb3d5e92cb..001d09faaa 100644
--- a/desktop/frontend-next/src/ui/PhonePop.tsx
+++ b/desktop/frontend-next/src/ui/PhonePop.tsx
@@ -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 (
-
{online > 0 &&
{online}}
-
+
{open &&
}
{(cloudNote || localNote) &&
{cloudNote || localNote}
}
diff --git a/desktop/frontend-next/src/ui/TooltipButton.test.tsx b/desktop/frontend-next/src/ui/TooltipButton.test.tsx
new file mode 100644
index 0000000000..aee79f843a
--- /dev/null
+++ b/desktop/frontend-next/src/ui/TooltipButton.test.tsx
@@ -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(Copy);
+ 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();
+ const clicked = vi.fn();
+ const view = render(Wide);
+ const button = screen.getByRole("button");
+ expect(ref.current).toBe(button);
+ fireEvent.focus(button);
+ expect(screen.getByRole("tooltip").textContent).toContain("⌘Shift+B");
+ view.rerender(Wide);
+ 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(Files);
+ 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(<>QuoteMore>);
+ 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();
+ const button = screen.getByRole("button", { name: "工作台" });
+ fireEvent.focus(button);
+ expect(screen.getByRole("tooltip").textContent).toContain("打开工作台");
+ expect(screen.getByRole("tooltip").textContent).toContain("3");
+ view.rerender();
+ expect(screen.getByRole("button", { name: "工作台" })).toBe(button);
+ expect(screen.getByRole("tooltip").textContent).toContain("关闭工作台");
+ fireEvent.click(button);
+ expect(onView).toHaveBeenCalledWith("flow");
+});
diff --git a/desktop/frontend-next/src/ui/TooltipButton.tsx b/desktop/frontend-next/src/ui/TooltipButton.tsx
new file mode 100644
index 0000000000..142899d1b8
--- /dev/null
+++ b/desktop/frontend-next/src/ui/TooltipButton.tsx
@@ -0,0 +1,73 @@
+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";
+
+type Props = ComponentPropsWithRef<"button"> & { shortcut?: string };
+let dismissActive: (() => void) | undefined;
+
+export function TooltipButton({
+ title, shortcut, ref, children, onPointerEnter, onPointerLeave, onPointerDown,
+ onFocus, onBlur, onClick, "aria-describedby": describedBy, ...props
+}: Props) {
+ const anchor = useRef(null);
+ const tip = useRef(null);
+ const timer = useRef | 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();
+ };
+ addEventListener("keydown", key, true);
+ addEventListener("blur", close);
+ return () => { removeEventListener("keydown", key, true); 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 <>
+
+ {open && title && !props.disabled && createPortal(
+
+ {title}{shortcut && {shortcut}}
+
, document.body,
+ )}
+ >;
+}
diff --git a/desktop/frontend-next/src/ui/Transcript.tsx b/desktop/frontend-next/src/ui/Transcript.tsx
index 00ac7055af..6c037f927c 100644
--- a/desktop/frontend-next/src/ui/Transcript.tsx
+++ b/desktop/frontend-next/src/ui/Transcript.tsx
@@ -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) ? : null;
// Which runs of work are open, held here so re-hosting one under its sentence
// does not close it.
const [opened, setOpened] = useState>({});
@@ -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 ? : undefined}
+ afterAnswer={row.activity || deliveries.has(row.item.id) ? <>{delivery(row.item.id)}{row.activity && }> : undefined}
/>
) : (
-
+
+ {row.activity.map((item) => {delivery(item.id)})}
+
+
);
});
diff --git a/desktop/frontend-next/src/ui/ViewSwitch.tsx b/desktop/frontend-next/src/ui/ViewSwitch.tsx
index f6f05dd011..478752f413 100644
--- a/desktop/frontend-next/src/ui/ViewSwitch.tsx
+++ b/desktop/frontend-next/src/ui/ViewSwitch.tsx
@@ -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 && (
-
+
)}
-
+
>
);
}
diff --git a/desktop/frontend-next/src/ui/WorkbenchFileBar.tsx b/desktop/frontend-next/src/ui/WorkbenchFileBar.tsx
index 56788c45b0..c0c3742667 100644
--- a/desktop/frontend-next/src/ui/WorkbenchFileBar.tsx
+++ b/desktop/frontend-next/src/ui/WorkbenchFileBar.tsx
@@ -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) => (