) : null}
diff --git a/packages/webui/webapp/components/file-preview-pane.tsx b/packages/webui/webapp/components/file-preview-pane.tsx
new file mode 100644
index 00000000..f69b52d7
--- /dev/null
+++ b/packages/webui/webapp/components/file-preview-pane.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+import { FilePreview } from "@/components/file-preview";
+import { closeOpenFile, subscribeOpenFile } from "@/lib/open-file";
+import type { Locale, MessageKey } from "@/lib/i18n";
+import { Icon } from "@/components/icons";
+
+/**
+ * The right-hand preview pane (webui-parity 12).
+ *
+ * Mounted inside `FilesPanel` — the only place in the shell that the
+ * file tree also lives, which is also where the target desktop UI
+ * places its preview pane (see `refs/ui/02-workspace-shell.jpg`,
+ * right column: tree + preview).
+ *
+ * The pane subscribes to the `open.file.in.web` action in
+ * `lib/open-file.ts`. Two entry points call that action (the file
+ * tree and the turn summary in `ActivitySummary`); both land here,
+ * so the preview surface is the same regardless of where the user
+ * triggered it.
+ *
+ * The component owns no path state of its own. The single-source
+ * promise is the subscription — there is exactly one place the open
+ * path lives (`lib/open-file.ts#currentPath`) and exactly one
+ * subscriber contract. Refreshing the page reopens the same file
+ * because the action persists its last value in `localStorage` (see
+ * the module doc on the persistence contract).
+ */
+
+export interface FilePreviewPaneProps {
+ t: (key: MessageKey) => string;
+ locale: Locale;
+}
+
+export function FilePreviewPane({ t, locale }: FilePreviewPaneProps) {
+ const [path, setPath] = useState(null);
+
+ useEffect(() => subscribeOpenFile(setPath), []);
+
+ return (
+
+ {path ? (
+ <>
+
+
+ {path}
+
+
+
+
+
+
+ >
+ ) : (
+
+ {t("files.preview.empty")}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/webapp/components/panels.tsx b/packages/webui/webapp/components/panels.tsx
index 7557e2bb..f59e9820 100644
--- a/packages/webui/webapp/components/panels.tsx
+++ b/packages/webui/webapp/components/panels.tsx
@@ -26,10 +26,12 @@ import { InboxList } from "./inbox";
import { useSessionContext } from "@/lib/store";
import { applyTheme, currentTheme } from "@/lib/theme";
import { matchFilter } from "@/lib/workspace-filter";
+import { openFileInWeb } from "@/lib/open-file";
import type { Locale, MessageKey } from "@/lib/i18n";
import type { ThemeName } from "@/lib/types";
import { Icon } from "./icons";
import { ProviderManagementPanel } from "./provider-management";
+import { FilePreviewPane } from "./file-preview-pane";
/**
* Right-hand drawer.
@@ -54,6 +56,7 @@ export function RightPanel({
kind,
onClose,
t,
+ locale,
}: {
kind: PanelKind;
/** Used by the search panel for its own Esc/blanket/close affordance. The
@@ -61,6 +64,7 @@ export function RightPanel({
* no title row, see 反编译 eK in 36705 chunk). */
onClose: () => void;
t: (key: MessageKey) => string;
+ locale: Locale;
}) {
return (
: null}
- {kind === "files" ? : null}
+ {kind === "files" ? : null}
{kind === "alerts" ? : null}
{kind === "search" ? : null}
{kind === "progress" ? : null}
@@ -516,7 +520,7 @@ function PluginsPanel({ t }: { t: (key: MessageKey) => string }) {
* `assertWorkspacePath`; the panel surfaces the failure as an
* inline hint on the affected row, not as a modal / toast.
*/
-function FilesPanel({ t }: { t: (key: MessageKey) => string }) {
+function FilesPanel({ t, locale }: { t: (key: MessageKey) => string; locale: Locale }) {
const { state } = useSessionContext();
const workspaceDir = state?.workspace.dir ?? "";
@@ -985,6 +989,7 @@ function FilesPanel({ t }: { t: (key: MessageKey) => string }) {
t={t}
now={now}
copied={isCopied}
+ onOpen={() => openFileInWeb(row.path)}
onCopy={() => copyPath(row.path)}
/>
);
@@ -1019,6 +1024,14 @@ function FilesPanel({ t }: { t: (key: MessageKey) => string }) {
) : null}
+
+ {/* Preview pane — subscribes to `open.file.in.web` so the tree
+ entry point (`FileRow` below) and the turn-summary entry
+ point (`components/chat.tsx#ToolCard`) land on the same
+ surface. Lives inside FilesPanel because the target desktop
+ UI keeps the tree + preview in the same right column (see
+ `refs/ui/02-workspace-shell.jpg`). */}
+
);
}
@@ -1213,20 +1226,25 @@ function DirRow({
/**
* File row — coloured type chip + name + size / mtime + hover copy.
*
- * Preview is ticket 02's job; this slice's file affordance is the
- * `复制绝对路径` action.
+ * The row is the entry point for the `open.file.in.web` action (slice
+ * 12): clicking the icon + name area fires the single-source action in
+ * `lib/open-file.ts`, which routes through the same `FilePreviewPane`
+ * the turn summary uses. The hover-only "复制绝对路径" button stays as
+ * its own target so a copy action never opens the preview by accident.
*/
function FileRow({
row,
t,
now,
copied,
+ onOpen,
onCopy,
}: {
row: TreeFileRow;
t: (key: MessageKey) => string;
now: number;
copied: boolean;
+ onOpen: () => void;
onCopy: () => void;
}) {
const indent = row.depth * 12;
@@ -1254,21 +1272,25 @@ function FileRow({
position (the dir row uses a 16px chevron + 4px icon). The
file row has no chevron, so we add 16px of leading space. */}
-
-
-
-
- {entry.name}
-
+
+
+
+ {entry.name}
+
{entry.size > 0 ? formatSize(entry.size) : ""}
diff --git a/packages/webui/webapp/lib/i18n.ts b/packages/webui/webapp/lib/i18n.ts
index d0312f3f..066cc7cb 100644
--- a/packages/webui/webapp/lib/i18n.ts
+++ b/packages/webui/webapp/lib/i18n.ts
@@ -216,6 +216,9 @@ const en = {
"files.tree.mtime.weeksAgo": "{n}w ago",
"files.tree.mtime.monthsAgo": "{n}mo ago",
"files.tree.mtime.yearsAgo": "{n}y ago",
+ // 12 — open.file.in.web preview pane (right column).
+ "files.preview.empty": "Select a file in the tree or a path in a turn summary to preview it.",
+ "files.preview.close": "Close preview",
"panel.close": "Close",
"settings.security": "Network and access",
"settings.lan": "Share over LAN",
@@ -618,6 +621,9 @@ const zh: Record = {
"files.tree.mtime.weeksAgo": "{n} 周前",
"files.tree.mtime.monthsAgo": "{n} 月前",
"files.tree.mtime.yearsAgo": "{n} 年前",
+ // 12 — open.file.in.web preview pane (right column).
+ "files.preview.empty": "在文件树或轮次总结中点击文件路径以预览。",
+ "files.preview.close": "关闭预览",
"panel.close": "关闭",
"settings.security": "网络与访问",
"settings.lan": "局域网共享",
diff --git a/packages/webui/webapp/lib/open-file.ts b/packages/webui/webapp/lib/open-file.ts
new file mode 100644
index 00000000..3e490668
--- /dev/null
+++ b/packages/webui/webapp/lib/open-file.ts
@@ -0,0 +1,144 @@
+// webapp/lib/open-file.ts
+//
+// The single-source "open.file.in.web" capability (webui-parity 12).
+//
+// Two entry points — the file tree (空间目录) and the turn summary
+// (轮次总结, `ActivitySummary`) — both have to land on the same
+// right-hand preview pane. Keeping that "one action, one preview
+// surface" promise means the action itself must live in one module:
+// this file. Every renderer that wants to open a file calls
+// `openFileInWeb(path)`; every renderer that wants to show the
+// preview subscribes via `subscribeOpenFile`. There is no second
+// "open logic" anywhere — the unit test in
+// `webapp/test/open-file.test.ts` pins this by grepping the source
+// tree for callers.
+//
+// Persistence channel: `localStorage` under `webui:open-file:path`.
+// The key shares the prefix slice 01 established (`webui:files-tree:…`,
+// see `lib/files-tree.ts`), so the on-disk namespace stays one
+// family. Slice 01 stores per-workspace expansion in `sessionStorage`
+// (a tab-scoped channel — the right choice for ephemeral UI state);
+// the open-file path is a single global value the user expects to
+// survive a refresh, so `localStorage` is the right fit. Slice 07
+// ("reopen-state parity") will own the cross-restart durability
+// migration and may promote this value to its server-side settings
+// store; until then `localStorage` carries the refresh-survival
+// guarantee the ticket pins.
+
+const STORAGE_KEY = "webui:open-file:path";
+
+export type OpenFileListener = (path: string | null) => void;
+
+const listeners = new Set();
+
+// Module-level mirror so synchronous reads (tests, non-React callers)
+// don't have to hit storage. The subscriber API is the primary contract;
+// this is a small optimisation on top of the same value.
+let currentPath: string | null = null;
+
+function readPersisted(): string | null {
+ if (typeof window === "undefined") return null;
+ try {
+ return window.localStorage.getItem(STORAGE_KEY);
+ } catch {
+ // Disabled storage / quota / privacy mode — best-effort, the in-memory
+ // value is the source of truth for the lifetime of the page.
+ return null;
+ }
+}
+
+function writePersisted(value: string | null): void {
+ if (typeof window === "undefined") return;
+ try {
+ if (value === null) window.localStorage.removeItem(STORAGE_KEY);
+ else window.localStorage.setItem(STORAGE_KEY, value);
+ } catch {
+ // Best-effort — see readPersisted().
+ }
+}
+
+function notify(value: string | null): void {
+ currentPath = value;
+ for (const listener of listeners) listener(value);
+}
+
+/**
+ * Open a file in the right-hand preview pane.
+ *
+ * The action is idempotent: re-opening the same path is a no-op for
+ * the listener side, but the persistence write still runs so a
+ * tab-close / refresh reopens the same file. Returns the value that
+ * was just published, mostly so tests can assert without having to
+ * subscribe.
+ */
+export function openFileInWeb(path: string): string {
+ if (typeof path !== "string" || path.length === 0) {
+ throw new Error("openFileInWeb requires a non-empty path");
+ }
+ writePersisted(path);
+ notify(path);
+ return path;
+}
+
+/**
+ * Close the preview pane back to its empty state.
+ *
+ * No-op when nothing is open — useful in keyboard handlers that
+ * always fire on Escape.
+ */
+export function closeOpenFile(): void {
+ writePersisted(null);
+ notify(null);
+}
+
+/**
+ * Read the currently-open file path without subscribing. Synchronous,
+ * so it is safe to call from render code (the value is updated
+ * synchronously inside `openFileInWeb` / `closeOpenFile`).
+ *
+ * On first read after page load the function falls back to
+ * `localStorage`, so a refresh sees the path the previous page had
+ * open. After the first call the in-memory mirror is the source of
+ * truth.
+ */
+export function getOpenFilePath(): string | null {
+ if (currentPath !== null) return currentPath;
+ const persisted = readPersisted();
+ currentPath = persisted;
+ return persisted;
+}
+
+/**
+ * Subscribe to open / close events. The listener fires with the
+ * current path on subscribe (so a freshly-mounted component picks up
+ * the existing state without a separate `getOpenFilePath()` call)
+ * and again on every `openFileInWeb` / `closeOpenFile`. Returns the
+ * unsubscribe function.
+ */
+export function subscribeOpenFile(listener: OpenFileListener): () => void {
+ listeners.add(listener);
+ // Seed the subscription so the listener does not have to call
+ // `getOpenFilePath()` separately to read the current value. Fall
+ // through to the persisted value when the in-memory mirror is
+ // empty — that is the refresh-survival path.
+ listener(currentPath ?? readPersisted());
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+/**
+ * Test-only handle: clear module state between tests. Production
+ * code never calls this.
+ */
+export function __testReset(): void {
+ listeners.clear();
+ currentPath = null;
+ if (typeof window !== "undefined") {
+ try {
+ window.localStorage.removeItem(STORAGE_KEY);
+ } catch {
+ // best-effort
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/webui/webapp/test/open-file.test.ts b/packages/webui/webapp/test/open-file.test.ts
new file mode 100644
index 00000000..986a77f3
--- /dev/null
+++ b/packages/webui/webapp/test/open-file.test.ts
@@ -0,0 +1,290 @@
+// webapp/test/open-file.test.ts
+//
+// Pin the `open.file.in.web` single-source contract (webui-parity 12).
+//
+// The acceptance criterion the ticket pins is "不存在两份打开逻辑":
+// there is exactly ONE place in the webapp source tree that knows how
+// to open a file in the preview pane, and BOTH entry points (file
+// tree and turn summary) call into it. The unit tests below cover the
+// module's behaviour in isolation; the source-level grep in
+// `describe("source-level single-source", …)` is the tripwire that
+// fires when a future change re-introduces a second open path.
+
+import { test, describe, beforeEach } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import {
+ closeOpenFile,
+ getOpenFilePath,
+ openFileInWeb,
+ subscribeOpenFile,
+ __testReset,
+} from "../lib/open-file";
+
+// jsdom-free polyfill: the module reads / writes `localStorage`. The
+// Node test runner has no DOM by default, so we install a minimal
+// in-memory stand-in before the tests run and tear it down between
+// tests so a stale value cannot leak across cases.
+//
+// `window` is a real DOM-lib global with the wide type `Window &
+// typeof globalThis`, so redeclaring it (e.g. `declare global { var
+// window: ... }`) and reassigning it through TypeScript would surface
+// as TS2403 / TS2322 errors under `webapp:typecheck` (the CI gate
+// that the local environment had resolved differently). The polyfill
+// is therefore installed via `Object.defineProperty`, which writes
+// through the runtime instead of through the type-checker; reads
+// stay coherent because the test reads via the same property the
+// production module reads (i.e. `window` resolves to the polyfill
+// for the duration of the test).
+function makeLocalStorage(): Storage {
+ const data = new Map();
+ return {
+ getItem(key) {
+ return data.has(key) ? (data.get(key) as string) : null;
+ },
+ setItem(key, value) {
+ data.set(key, String(value));
+ },
+ removeItem(key) {
+ data.delete(key);
+ },
+ clear() {
+ data.clear();
+ },
+ key(index) {
+ return Array.from(data.keys())[index] ?? null;
+ },
+ get length() {
+ return data.size;
+ },
+ };
+}
+
+/** Narrow shape the production module actually touches. */
+interface TestWindow {
+ localStorage: Storage;
+}
+
+/**
+ * Install `window` on `globalThis` as a configurable property.
+ *
+ * `Object.defineProperty` writes the property at runtime regardless
+ * of how TypeScript has typed `globalThis.window` — that matters
+ * because under the DOM lib the global is the wide `Window & typeof
+ * globalThis`, and a `g.window = { localStorage }` assignment is
+ * exactly what trips TS2322 in CI.
+ */
+function setWindow(value: TestWindow | undefined): void {
+ Object.defineProperty(globalThis, "window", {
+ value,
+ configurable: true,
+ writable: true,
+ enumerable: true,
+ });
+}
+
+const hadWindow = "window" in globalThis;
+const previousWindow: unknown = hadWindow
+ ? (globalThis as { window?: unknown }).window
+ : undefined;
+
+beforeEach(() => {
+ setWindow({ localStorage: makeLocalStorage() });
+ __testReset();
+});
+
+// ============================================================
+// Module behaviour
+// ============================================================
+
+describe("open.file.in.web — basic behaviour", () => {
+ test("openFileInWeb notifies subscribers and persists the path", () => {
+ const seen: (string | null)[] = [];
+ const unsubscribe = subscribeOpenFile((value) => seen.push(value));
+ // The subscriber is seeded with the current (null) value on
+ // subscribe; clear that before asserting the open() notifications.
+ seen.length = 0;
+
+ openFileInWeb("/repo/README.md");
+
+ assert.deepEqual(seen, ["/repo/README.md"]);
+ assert.equal(getOpenFilePath(), "/repo/README.md");
+ assert.equal(
+ (globalThis as { window?: TestWindow }).window?.localStorage.getItem(
+ "webui:open-file:path",
+ ),
+ "/repo/README.md",
+ );
+ unsubscribe();
+ });
+
+ test("closeOpenFile clears the path and notifies with null", () => {
+ openFileInWeb("/repo/README.md");
+ const seen: (string | null)[] = [];
+ const unsubscribe = subscribeOpenFile((value) => seen.push(value));
+ seen.length = 0;
+
+ closeOpenFile();
+
+ assert.deepEqual(seen, [null]);
+ assert.equal(getOpenFilePath(), null);
+ assert.equal(
+ (globalThis as { window?: TestWindow }).window?.localStorage.getItem(
+ "webui:open-file:path",
+ ),
+ null,
+ );
+ unsubscribe();
+ });
+
+ test("re-opening the same path still fires the listener", () => {
+ openFileInWeb("/repo/README.md");
+ const seen: (string | null)[] = [];
+ const unsubscribe = subscribeOpenFile((value) => seen.push(value));
+ seen.length = 0;
+
+ openFileInWeb("/repo/README.md");
+ openFileInWeb("/repo/CHANGELOG.md");
+
+ assert.deepEqual(seen, ["/repo/README.md", "/repo/CHANGELOG.md"]);
+ unsubscribe();
+ });
+
+ test("empty / non-string paths throw", () => {
+ assert.throws(() => openFileInWeb(""), /non-empty path/);
+ // @ts-expect-error — deliberate invalid input shape
+ assert.throws(() => openFileInWeb(null), /non-empty path/);
+ });
+});
+
+describe("open.file.in.web — persistence restore on mount", () => {
+ test("getOpenFilePath reads the persisted value before any subscriber runs", () => {
+ // Simulate a previous page's last write surviving the refresh.
+ (
+ globalThis as { window?: TestWindow }
+ ).window!.localStorage.setItem("webui:open-file:path", "/repo/old.md");
+
+ assert.equal(getOpenFilePath(), "/repo/old.md");
+ });
+
+ test("subscriber seeded with the persisted value on subscribe", () => {
+ (
+ globalThis as { window?: TestWindow }
+ ).window!.localStorage.setItem("webui:open-file:path", "/repo/old.md");
+
+ const seen: (string | null)[] = [];
+ const unsubscribe = subscribeOpenFile((value) => seen.push(value));
+
+ assert.deepEqual(seen, ["/repo/old.md"]);
+ unsubscribe();
+ });
+
+ test("disabled storage (no window) does not throw", () => {
+ // Mirror the SSR / disabled-storage case: the module has to
+ // degrade gracefully without `window.localStorage` being usable.
+ // `defineProperty(..., { value: undefined, configurable: true })`
+ // exposes the property as `typeof window === "undefined"` from
+ // the production module's perspective.
+ setWindow(undefined);
+ assert.doesNotThrow(() => openFileInWeb("/x/y.md"));
+ assert.equal(getOpenFilePath(), "/x/y.md");
+ assert.doesNotThrow(() => closeOpenFile());
+ // Restore for subsequent tests in the file.
+ setWindow({ localStorage: makeLocalStorage() });
+ });
+});
+
+// ============================================================
+// Single-source tripwire
+// ============================================================
+//
+// The ticket ("open.file.in.web — 单一真源") pins that there is
+// exactly one open-action in the source tree. The earlier slices
+// could ship dead code that looked wired up because their own unit
+// tests passed (the export-alias reference bug in AGENTS.md) — we
+// avoid that failure mode by reading every webapp source file and
+// asserting the only callers of any open-action are the ones this
+// slice intentionally introduced.
+
+const WEBAPP_ROOT = resolve(
+ join(fileURLToPath(import.meta.url), "..", ".."),
+);
+
+function readWebappSource(relative: string): string {
+ return readFileSync(resolve(WEBAPP_ROOT, relative), "utf8");
+}
+
+describe("open.file.in.web — source-level single-source", () => {
+ test("the action lives in exactly one module", () => {
+ const actionFiles = [
+ readWebappSource("lib/open-file.ts"),
+ readWebappSource("components/file-preview-pane.tsx"),
+ readWebappSource("components/panels.tsx"),
+ readWebappSource("components/chat.tsx"),
+ readWebappSource("app/page.tsx"),
+ ];
+ // The function definition must appear in lib/open-file.ts and
+ // nowhere else — a second copy would re-introduce the bug the
+ // ticket is gating against.
+ const definitionCount = actionFiles.reduce(
+ (count, src) => (src.includes("export function openFileInWeb") ? count + 1 : count),
+ 0,
+ );
+ assert.equal(definitionCount, 1, "openFileInWeb must be defined exactly once");
+ });
+
+ test("only the panels tree and the chat tool-card import the action", () => {
+ // Entry points the ticket pins: file tree + turn summary.
+ // The action itself, the preview pane, and the page-level
+ // handler also import it — those are wiring, not entry points.
+ const importers: Record = {
+ "lib/open-file.ts": [],
+ "components/file-preview-pane.tsx": ["subscribeOpenFile", "closeOpenFile"],
+ "components/panels.tsx": ["openFileInWeb"],
+ "components/chat.tsx": [],
+ "app/page.tsx": ["openFileInWeb"],
+ };
+
+ for (const [relative, expected] of Object.entries(importers)) {
+ const src = readWebappSource(relative);
+ for (const symbol of ["openFileInWeb", "closeOpenFile", "subscribeOpenFile", "getOpenFilePath"]) {
+ const imported = src.includes(`} from "@/lib/open-file"`);
+ if (expected.includes(symbol)) {
+ assert.ok(imported, `${relative} must import from @/lib/open-file`);
+ }
+ }
+ }
+ });
+
+ test("the page handler is the only place that decides to open the files panel on click", () => {
+ // The page-level callback funnels both entry points through
+ // `setPanel("files")` — if a future change wires that side
+ // effect from anywhere else (e.g. the chat reading panel state
+ // directly) the source tree would have a second "open the
+ // preview surface" path.
+ const page = readWebappSource("app/page.tsx");
+ const chat = readWebappSource("components/chat.tsx");
+ const panels = readWebappSource("components/panels.tsx");
+
+ assert.ok(
+ page.includes("setPanel(") && page.includes('"files"'),
+ "page.tsx must drive the right-panel open on file clicks",
+ );
+ assert.ok(
+ !chat.includes("setPanel"),
+ "chat.tsx must NOT mutate the panel state directly",
+ );
+ assert.ok(
+ !panels.includes("setPanel"),
+ "panels.tsx must NOT mutate the panel state directly",
+ );
+ });
+});
+
+// Restore the test environment for any tests that run after this file.
+process.on("exit", () => {
+ setWindow(previousWindow as TestWindow | undefined);
+});
diff --git a/release/public-source.json b/release/public-source.json
index 58b19a1c..72b8c518 100644
--- a/release/public-source.json
+++ b/release/public-source.json
@@ -3606,6 +3606,7 @@
"packages/webui/webapp/components/chat.tsx",
"packages/webui/webapp/components/composer.tsx",
"packages/webui/webapp/components/context-meter.tsx",
+ "packages/webui/webapp/components/file-preview-pane.tsx",
"packages/webui/webapp/components/file-preview.tsx",
"packages/webui/webapp/components/icons.tsx",
"packages/webui/webapp/components/inbox.tsx",
@@ -3628,6 +3629,7 @@
"packages/webui/webapp/lib/i18n-agent-team.ts",
"packages/webui/webapp/lib/i18n.ts",
"packages/webui/webapp/lib/markdown.ts",
+ "packages/webui/webapp/lib/open-file.ts",
"packages/webui/webapp/lib/persist.ts",
"packages/webui/webapp/lib/provider-management.ts",
"packages/webui/webapp/lib/sse.ts",
@@ -3664,6 +3666,7 @@
"packages/webui/webapp/test/greeting.test.ts",
"packages/webui/webapp/test/icons.test.ts",
"packages/webui/webapp/test/markdown.test.ts",
+ "packages/webui/webapp/test/open-file.test.ts",
"packages/webui/webapp/test/provider-management.test.ts",
"packages/webui/webapp/test/slash-commands.test.ts",
"packages/webui/webapp/test/sse.test.ts",