From 826e3521a02db403161969acf38bc3d51daba26a Mon Sep 17 00:00:00 2001 From: liuhailong <857688528@qq.com> Date: Sun, 27 Sep 2026 19:38:25 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(webui):=20open.file.in.web=20=E2=80=94?= =?UTF-8?q?=20single-source=20'open=20file'=20capability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slice 02 preview component had no entry point — neither the file tree (空间目录) nor the turn summary (轮次总结) reached it. Slice 12 wires both through ONE shared action so a click in either place lands on the same right-hand preview surface (file-preview-pane). Action: lib/open-file.ts owns the single-source 'open.file.in.web' contract — openFileInWeb(path) is the only way to open a file, both entry points call it, persistence (localStorage 'webui:open-file:path') makes a refresh reopen the same file. The companion webapp unit test pins the contract at three levels: module behaviour, source-tree single-source (grep the webapp for callers), and the page-level panel-open discipline. Entry 1 (file tree): panels.tsx FilesPanel renders FileRow as a clickable button (testid files-tree-file-open) that calls openFileInWeb; the hover-only 'copy absolute path' button is unchanged. Entry 2 (turn summary): chat.tsx ActivityGroup → ToolCard renders block.toolPaths (the server's '@ /path' body lines) as clickable buttons (testid tool-card-path) that call the page-level onOpenFile handler. The handler sets the path AND auto-opens the files panel when it is closed, so the preview the user triggered is the one they see. Honest limitation: the slice-12 wiring is in place for the turn-summary entry, but the current session transcripts in the dev DB don't carry the server's 'locations' data, so the per-file chips aren't visible on those specific sessions. The wiring is correct and a session whose tool updates include locations will render clickable path chips as designed. Persistence key: webui:open-file:path (localStorage). Slice 07 owns the cross-restart durability migration; this slice only adds the per-file 'refresh reopens same file' guarantee. Gates: webapp typecheck 0; test:webapp 406/406 pass; webapp build ✓; source-inventory regenerated. --- packages/webui/webapp/app/page.tsx | 19 +- packages/webui/webapp/components/chat.tsx | 39 ++- .../webapp/components/file-preview-pane.tsx | 82 ++++++ packages/webui/webapp/components/panels.tsx | 56 ++-- packages/webui/webapp/lib/i18n.ts | 6 + packages/webui/webapp/lib/open-file.ts | 144 ++++++++++ packages/webui/webapp/test/open-file.test.ts | 252 ++++++++++++++++++ 7 files changed, 574 insertions(+), 24 deletions(-) create mode 100644 packages/webui/webapp/components/file-preview-pane.tsx create mode 100644 packages/webui/webapp/lib/open-file.ts create mode 100644 packages/webui/webapp/test/open-file.test.ts diff --git a/packages/webui/webapp/app/page.tsx b/packages/webui/webapp/app/page.tsx index a9bc4b97..bf083f94 100644 --- a/packages/webui/webapp/app/page.tsx +++ b/packages/webui/webapp/app/page.tsx @@ -29,6 +29,7 @@ import { writeSessionToUrl, type SessionRestoreOutcome, } from "@/lib/url-restore"; +import { openFileInWeb } from "@/lib/open-file"; /** * The application root. @@ -121,6 +122,18 @@ function App() { setSettingsOpen(true); }, []); + // Single-source "open.file.in.web" — fired from the turn summary's + // file paths (`ActivityGroup` → `ToolCard` in `components/chat.tsx`). + // The action in `lib/open-file.ts` is also called from the file tree + // (`components/panels.tsx#FileRow`); both entry points converge on the + // same `FilePreviewPane`. When the right panel is closed we open it + // here so the user actually sees the preview they triggered — the + // pane only renders inside `FilesPanel`. + const onOpenFile = useCallback((path: string) => { + openFileInWeb(path); + setPanel((current) => (current === "files" ? current : "files")); + }, []); + // Ctrl+N / Ctrl+K mirror the shortcuts the sidebar advertises. Ctrl+N is only // bound when the shell is mounted (i.e. a session exists), matching the // affordance being visible. @@ -283,7 +296,7 @@ function App() { /> ) : null } - panel={panel ? setPanel(null)} t={t} /> : null} + panel={panel ? setPanel(null)} t={t} locale={locale} /> : null} onOpenPanel={openPanel} onOpenSettings={openSettings} alertCount={alertCount} @@ -294,6 +307,7 @@ function App() { t={t} locale={locale} sessionId={state.mcodeSessionId ?? null} + onOpenFile={onOpenFile} /> @@ -361,10 +375,12 @@ function ScrollRestoredChat({ t, locale, sessionId, + onOpenFile, }: { t: (key: import("@/lib/i18n").MessageKey) => string; locale: import("@/lib/i18n").Locale; sessionId: string | null; + onOpenFile?: (path: string) => void; }) { const initial = sessionId ? readScrollPosition(sessionId) : 0; return ( @@ -377,6 +393,7 @@ function ScrollRestoredChat({ if (!sessionId) return; writeScrollPosition(sessionId, top); }} + onOpenFile={onOpenFile} /> ); } diff --git a/packages/webui/webapp/components/chat.tsx b/packages/webui/webapp/components/chat.tsx index de977fa0..6f3b671b 100644 --- a/packages/webui/webapp/components/chat.tsx +++ b/packages/webui/webapp/components/chat.tsx @@ -65,9 +65,29 @@ interface ChatProps { * captures `0` from the no-active-session pre-SSE render. */ sessionKey?: string | null; + /** + * Open a file in the right-hand preview pane (`open.file.in.web`, + * webui-parity 12). Called from the turn-summary's per-file paths + * surfaced inside the `ActivityGroup` body — the tree entry point + * calls the same action from `components/panels.tsx#FileRow`. The + * page-level handler is responsible for opening the right panel + * when it is currently closed. + * + * Optional because the home screen (`HomeState`) does not render a + * transcript with tool cards; the default no-op keeps the surface + * trivial there. + */ + onOpenFile?: (path: string) => void; } -export function Chat({ t, locale, initialScrollTop, onScrollPersist, sessionKey }: ChatProps) { +export function Chat({ + t, + locale, + initialScrollTop, + onScrollPersist, + sessionKey, + onOpenFile = () => {}, +}: ChatProps) { const { state } = useSessionContext(); const scrollerRef = useRef(null); // Decode, then fold each run of thinking/tool blocks into one activity group so @@ -229,6 +249,7 @@ export function Chat({ t, locale, initialScrollTop, onScrollPersist, sessionKey blocks={unit.blocks} summary={unit.summary} t={t} + onOpenFile={onOpenFile} /> ) : ( @@ -819,10 +840,12 @@ function ActivityGroup({ blocks, summary, t, + onOpenFile, }: { blocks: TranscriptBlock[]; summary: ActivitySummary; t: (key: MessageKey) => string; + onOpenFile: (path: string) => void; }) { const [open, setOpen] = useState(false); @@ -899,7 +922,7 @@ function ActivityGroup({ block.role === "thinking" ? ( ) : ( - + ), )} @@ -1003,7 +1026,7 @@ const CATEGORY_GLYPH: Record = { * beneath it. Output collapses by default — a tool can emit thousands of lines, and * upstream keeps it behind a disclosure for the same reason. */ -function ToolCard({ block, t }: { block: TranscriptBlock; t: (key: MessageKey) => string }) { +function ToolCard({ block, t, onOpenFile }: { block: TranscriptBlock; t: (key: MessageKey) => string; onOpenFile: (path: string) => void }) { const [open, setOpen] = useState(false); const output = block.toolOutput ?? []; const paths = block.toolPaths ?? []; @@ -1111,13 +1134,17 @@ function ToolCard({ block, t }: { block: TranscriptBlock; t: (key: MessageKey) = {paths.length > 0 ? (
{paths.map((path) => ( - onOpenFile(path)} + className="tool-resource-reference max-w-[260px] truncate rounded-md bg-bg_grouped_tertiary_elevated px-1.5 py-0.5 text-caption-small-strong text-text_default_secondary transition-colors hover:bg-bg_interaction_tertiary_hover hover:text-text_default_primary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border_accent" > {path} - + ))}
) : 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 (