Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/webui/webapp/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
writeSessionToUrl,
type SessionRestoreOutcome,
} from "@/lib/url-restore";
import { openFileInWeb } from "@/lib/open-file";

/**
* The application root.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -283,7 +296,7 @@ function App() {
/>
) : null
}
panel={panel ? <RightPanel kind={panel} onClose={() => setPanel(null)} t={t} /> : null}
panel={panel ? <RightPanel kind={panel} onClose={() => setPanel(null)} t={t} locale={locale} /> : null}
onOpenPanel={openPanel}
onOpenSettings={openSettings}
alertCount={alertCount}
Expand All @@ -294,6 +307,7 @@ function App() {
t={t}
locale={locale}
sessionId={state.mcodeSessionId ?? null}
onOpenFile={onOpenFile}
/>
<Composer t={t} onAddProvider={openProviderAdd} />
</>
Expand Down Expand Up @@ -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 (
Expand All @@ -377,6 +393,7 @@ function ScrollRestoredChat({
if (!sessionId) return;
writeScrollPosition(sessionId, top);
}}
onOpenFile={onOpenFile}
/>
);
}
39 changes: 33 additions & 6 deletions packages/webui/webapp/components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);
// Decode, then fold each run of thinking/tool blocks into one activity group so
Expand Down Expand Up @@ -229,6 +249,7 @@ export function Chat({ t, locale, initialScrollTop, onScrollPersist, sessionKey
blocks={unit.blocks}
summary={unit.summary}
t={t}
onOpenFile={onOpenFile}
/>
) : (
<Block key={originalIndex} block={unit.block} t={t} />
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -899,7 +922,7 @@ function ActivityGroup({
block.role === "thinking" ? (
<ThinkingRow key={index} block={block} t={t} />
) : (
<ToolCard key={index} block={block} t={t} />
<ToolCard key={index} block={block} t={t} onOpenFile={onOpenFile} />
),
)}
</div>
Expand Down Expand Up @@ -1003,7 +1026,7 @@ const CATEGORY_GLYPH: Record<SummaryIconType, string> = {
* 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 ?? [];
Expand Down Expand Up @@ -1111,13 +1134,17 @@ function ToolCard({ block, t }: { block: TranscriptBlock; t: (key: MessageKey) =
{paths.length > 0 ? (
<div className="mb-1 flex flex-wrap gap-1">
{paths.map((path) => (
<span
<button
key={path}
type="button"
title={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"
data-testid="tool-card-path"
data-path={path}
onClick={() => 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}
</span>
</button>
))}
</div>
) : null}
Expand Down
82 changes: 82 additions & 0 deletions packages/webui/webapp/components/file-preview-pane.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);

useEffect(() => subscribeOpenFile(setPath), []);

return (
<div
className="mt-2 flex min-h-0 flex-col gap-2 border-t border-border_light pt-3"
data-testid="file-preview-pane"
>
{path ? (
<>
<div className="flex min-w-0 items-center justify-between gap-2">
<span
className="min-w-0 truncate text-caption-small-strong text-text_default_tertiary"
data-testid="file-preview-pane-active"
title={path}
>
{path}
</span>
<button
type="button"
onClick={closeOpenFile}
aria-label={t("files.preview.close")}
title={t("files.preview.close")}
data-testid="file-preview-pane-close"
className="flex size-6 flex-none items-center justify-center rounded-[6px] text-icon_default_tertiary transition-colors hover:bg-bg_interaction_tertiary_hover hover:text-icon_default_primary"
>
<Icon name="close" size={12} />
</button>
</div>
<div className="min-h-[120px] flex-1 overflow-hidden">
<FilePreview path={path} t={t} locale={locale} />
</div>
</>
) : (
<p
className="px-1.5 py-2 text-caption-small-strong text-text_default_tertiary"
data-testid="file-preview-pane-empty"
>
{t("files.preview.empty")}
</p>
)}
</div>
);
}
56 changes: 39 additions & 17 deletions packages/webui/webapp/components/panels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -54,13 +56,15 @@ export function RightPanel({
kind,
onClose,
t,
locale,
}: {
kind: PanelKind;
/** Used by the search panel for its own Esc/blanket/close affordance. The
* container does not render its own close button (upstream's Drawer has
* no title row, see 反编译 eK in 36705 chunk). */
onClose: () => void;
t: (key: MessageKey) => string;
locale: Locale;
}) {
return (
<aside
Expand All @@ -87,7 +91,7 @@ export function RightPanel({
one-line change — but do not read them as ported surfaces.
*/}
{kind === "workspace" ? <WorkspacePanel t={t} /> : null}
{kind === "files" ? <FilesPanel t={t} /> : null}
{kind === "files" ? <FilesPanel t={t} locale={locale} /> : null}
{kind === "alerts" ? <AlertsPanel t={t} /> : null}
{kind === "search" ? <SearchPanel onClose={onClose} t={t} /> : null}
{kind === "progress" ? <ProgressPanel t={t} /> : null}
Expand Down Expand Up @@ -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 ?? "";

Expand Down Expand Up @@ -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)}
/>
);
Expand Down Expand Up @@ -1019,6 +1024,14 @@ function FilesPanel({ t }: { t: (key: MessageKey) => string }) {
</p>
) : null}
</div>

{/* 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`). */}
<FilePreviewPane t={t} locale={locale} />
</div>
);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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. */}
<span className="w-4 flex-shrink-0" />
<span
className={[
"flex size-4 flex-shrink-0 items-center justify-center",
fileTypeColor(entry.name),
].join(" ")}
aria-hidden
>
<Icon name="file" size={13} />
</span>
<span
<button
type="button"
onClick={onOpen}
aria-label={t("files.tree.fileAria").replace("{name}", entry.name)}
title={entry.path}
className="min-w-0 flex-1 truncate text-sm text-text_default_primary"
data-testid="files-tree-file-open"
className="flex min-w-0 flex-1 items-center gap-1.5 truncate rounded text-left text-text_default_primary hover:text-text_default_primary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border_accent"
>
{entry.name}
</span>
<span
className={[
"flex size-4 flex-shrink-0 items-center justify-center",
fileTypeColor(entry.name),
].join(" ")}
aria-hidden
>
<Icon name="file" size={13} />
</span>
<span className="min-w-0 truncate text-sm">{entry.name}</span>
</button>
<span className="flex-shrink-0 text-caption-small-strong text-text_default_tertiary">
{entry.size > 0 ? formatSize(entry.size) : ""}
</span>
Expand Down
6 changes: 6 additions & 0 deletions packages/webui/webapp/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -618,6 +621,9 @@ const zh: Record<MessageKey, string> = {
"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": "局域网共享",
Expand Down
Loading
Loading