From 8e95cdcc32f9aaee748a59cea2e32a0a3b08e751 Mon Sep 17 00:00:00 2001 From: s39-dev Date: Tue, 29 Sep 2026 06:00:49 +0800 Subject: [PATCH 1/3] feat(webui): align the sidebar with the reference (webui-parity 47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 1 (ticket must-dos): - S1: selected session/subagent rows paint with the selected token while hover keeps the hover token, restoring the lost "which session am I in" signal (distinguishable in dark; the light tokens are equal upstream) - N2: nav rows get a real active signal — pure rule in lib/sidebar-nav.ts, driven by the tree-column surface (page passes activeNavSurface) and the engine's active session id; the collapsed icon rail lights up too - S2: the projects section header is a plain 28px div — the focusable button with no onClick is gone - S5: project/directory/subagent disclosures animate through the reference's grid-template-rows 0fr->1fr motion (.webui-expandable-motion, 180ms + 140ms opacity); children stay mounted, inert + aria-hidden while closed; reduced-motion drops the transition but keeps the state - S3/S4: empty state gets the reference card face; the error state is a role="alert" that names the live failure reason (sidebar.loadError) - C3: the rail toggle's aria-label flips 展开导航栏/收起导航栏 per state - C1/C5: rail width 52 -> 64px with the reference's 180ms cubic-bezier; collapsed background goes transparent - C6: the toolbar column gains pl-[142px] while collapsed so the session title keeps its horizontal position Layer 2 (structural alignment): - S8: session and subagent rows are deep links over the app's own ?session= grammar (lib/url-restore.ts#sessionHref) — plain clicks route through switchSession as before; modified clicks open a restorable URL. The reference's #session= fragment grammar is deliberately NOT copied: the restore pipeline parses the query string and preserves fragments - C2: the collapse toggle moves outside the sidebar onto a pointer-events-none overlay (32px button, floats right of the rail when collapsed) - C4 (kept): the collapsed form stays the icon rail — the reference's emptied strip hides every affordance and its own rail-mode user-menu branch never executes there - S6 (verified, not added): "click selects a project" has no backend contract to drive it Reverse-parity kept: drag-resize 240-400, <980px auto-collapse, collapsed persistence (same webui:ui-state key, no new storage). GET /api/session-tree stays read-only. Docs: bilingual sidebar sections and anchor-table rows in webui.md and webui.zh-CN.md. Tests: webapp/test/sidebar-nav-active.test.ts (27 cases: pure-function decisions + static-source wiring tripwires). --- docs/webui.md | 98 ++++++++ docs/webui.zh-CN.md | 31 +++ packages/webui/webapp/app/globals.css | 46 +++- packages/webui/webapp/app/page.tsx | 14 ++ .../webui/webapp/components/session-tree.tsx | 224 +++++++++++++----- packages/webui/webapp/components/shell.tsx | 142 +++++++---- packages/webui/webapp/lib/i18n.ts | 13 +- packages/webui/webapp/lib/sidebar-nav.ts | 39 +++ packages/webui/webapp/lib/url-restore.ts | 15 ++ .../webapp/test/sidebar-nav-active.test.ts | 208 ++++++++++++++++ 10 files changed, 710 insertions(+), 120 deletions(-) create mode 100644 packages/webui/webapp/lib/sidebar-nav.ts create mode 100644 packages/webui/webapp/test/sidebar-nav-active.test.ts diff --git a/docs/webui.md b/docs/webui.md index 8fdf8e653..ac8276563 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -292,6 +292,101 @@ writes into `cs.chat`; switched-away writes via `workspace` and `workspaceFallback` so post-mortems can answer "why did the file tree jump". +## Sidebar (webui-parity ticket 47) + +The sidebar was aligned with the reference client across three areas: +collapse behaviour, nav rows, and the session list. This section is the +contract; `docs/webui.zh-CN.md`'s sidebar section is the user-facing twin. + +### Collapse contract + +| Constant | Value | Where | +| --- | --- | --- | +| `SIDEBAR_RAIL` | `64` (px) | `components/shell.tsx` — raised from 52 to match the reference rail | +| `SIDEBAR_MIN` / `SIDEBAR_MAX` / `SIDEBAR_DEFAULT` | `240` / `400` / `240` (px) | drag-resize clamp, unchanged | +| `SIDEBAR_AUTO_COLLAPSE_PX` | `980` (px) | `innerWidth < 980` collapses; never auto-expands | +| Width transition | `180ms cubic-bezier(.2,.7,.2,1)` | on the outer wrapper, matching the reference's `.webui-rail` | +| Collapsed background | `bg-transparent` | expanded keeps `bg-bg_default_scrim` | +| Collapsed toolbar compensation | `pl-[142px]` | wraps the toolbar slot in `AppShell`, the reference's own constant | + +The collapse toggle is a `size-8` button on a `pointer-events-none` overlay +**outside** the sidebar (`data-testid="sidebar-collapse-toggle"`, expanded at +`left: 8px`, collapsed at `SIDEBAR_RAIL + 8`), so it stays reachable in both +states instead of living inside the clipping card. Its `aria-label` flips +with state — `sidebar.expand` / `sidebar.collapse`, "Expand navigation bar" / +"Collapse navigation bar" (展开导航栏 / 收起导航栏) — and `aria-expanded` +carries the machine-readable half. + +The collapsed form is an **icon rail**, not the reference's emptied strip: +rows and the avatar menu stay reachable. This is a deliberate keep — the +reference's own collapsed shell hides everything and its rail-mode +`UserMenu` branch never executes there. + +Persistence: the collapsed flag round-trips through the existing +`webui:ui-state` payload (`sidebarCollapsed`, see `lib/persist.ts` +`readShellCollapsedFromPersistedState` / `writePersistedShellCollapsed`). +**No new storage key was added**, so the persistence-keys section is +unchanged. + +### Nav-row activation + +Activation is a pure function, `lib/sidebar-nav.ts#isSidebarNavActive`, so +the regression suite drives the decision rather than rendered classes: + +- `sidebar.search` lights when the tree column's active tab is the `search` + surface; +- `sidebar.plugins` lights for the `plugins` surface (the legacy + `panel === "plugins"` mirror agrees with `openSurfaceTab`); +- `topbar.newSession` lights only with **no session selected and no sibling + surface active** — the reference's home-mode rule for its 新建任务 row. + +The page computes the tree-surface signal (`activeNavSurface`, passed +through `AppShell`) because the tab strip state lives in `page.tsx`; the +session-id half comes from the store inside the shell. The active token is +`bg_interaction_tertiary_hover` held permanently — the reference's own +nav-active treatment, distinct from the session-row rule below. + +定时 / 网站 / 远程 are **not rendered**: the repo's standing decision is +that a control without a contract behind it is omitted, not shipped inert. +They remain a product decision (see HANDOVER's 18094 fusion list). + +### Session-list contract + +- Selected rows (`sidebar-session-row`, `sidebar-subagent-row`) paint with + `bg-bg_interaction_tertiary_selected`; hover uses + `bg_interaction_tertiary_hover`. The two tokens differ — sharing one made + the open session indistinguishable from any hovered row. +- Session and subagent rows are `` deep links over + the **`?session=` query grammar** (`lib/url-restore.ts#sessionHref`), not + the reference's `#session=` fragment: the restore pipeline (cold load, + popstate, replaceState sync) parses the query string, and + `writeSessionToUrl` preserves fragments, so a fragment href would linger + beside the query parameter. Plain left clicks are intercepted into the + same `switchSession` call as before; modified clicks (middle / cmd / + ctrl / shift) fall through to the browser, and the URL they open is one + the cold-load path already restores. +- Disclosures (project → directories → subagents) mount through + `session-tree.tsx#Expandable`, the reference's `.webui-expandable-motion` + (grid-template-rows `0fr→1fr`, 180ms, plus a 140ms opacity crossfade); + `globals.css` carries the rule under the same class name with a + `prefers-reduced-motion` branch that drops the transition but keeps the + open/closed state. Children stay mounted while collapsed (wrapped in + `inert` + `aria-hidden`). +- The reveal mechanism stays **per-directory, 6 at a time** + (`SESSION_VISIBLE_LIMIT`); no global Load-more was introduced. +- `GET /api/session-tree` and `GET /api/sessions` are **read-only in this + ticket** — no request parameter or response field changed. + +### What this ticket does not do + +The reference has a right-click context menu (pin / archive / fork / delete) +and a localStorage pin+archive overlay; both need server contracts that do +not exist yet (no pin, archive or copy endpoints) and are deferred as a +batch. The Agent Team badge, the `workspaceDir` secondary line and the +「最近任务」 section exist in reference files but are not rendered by the +reference's own shell, so they are not implemented here either. A later +agent must not mistake any of these for "implemented but broken". + ## Context window (what the picker shows, and what a pick does today) The model picker's detail area (below the provider list) mounts a context-window radio group only for the active model, and only when its `/api/models` entry carries at least two `contextWindowOptions`. A model without the field — or with a single option, which would be a no-op choice — renders no control and no placeholder. Today that is exactly `MiniMax-M3` and `MiniMax-M3.1-Flash-Preview` (`[512000, 1000000]`); every other catalogue entry stays field-free. Each option label is a compact token count (`512K`, `1M`), and an option the engine hints as `higher_usage` (`contextWindowOptionHints`) carries a "higher usage" tag. @@ -308,7 +403,10 @@ below cites the component file and one `data-testid` per surface. | Surface | Component | Anchor `data-testid` | | --- | --- | --- | | Sidebar (rail) | `components/shell.tsx` | `sidebar-scroll-viewport` | +| Sidebar collapse toggle (overlay outside the rail, ticket 47) | `components/shell.tsx` | `sidebar-collapse-toggle` | | Sidebar session tree | `components/session-tree.tsx` | `sidebar-session-row` | +| Session-tree section header (plain text, ticket 47) | `components/session-tree.tsx#SectionHeader` | `sidebar-section-header` | +| Session-tree error state (`role="alert"`, ticket 47) | `components/session-tree.tsx` | `sidebar-tree-error` | | Sidebar user menu (settings / check-in / usage / sign-out) | `components/shell.tsx#SidebarFooter` | `sidebar-user-menu` | | Sidebar inbox (alerts flyout) | `components/inbox.tsx` | `inbox-flyout` | | Toolbar (top bar with model selector) | `components/toolbar.tsx` | `toolbar-session-status` | diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index ded535404..9262689f9 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -235,6 +235,34 @@ exec 回合的代价——以下都是当前真实存在的行为,选择权限 - 响应负载多了 `session.workspace` 和 `session.workspaceFallback`;尾部 `pushStateFor(cid)` 推 SSE 时原样带上新的 `cs.workspace.dir`,`FilesPanel` 通过 `useSessionContext()` 订阅自然重新渲染,前端**不**需要为了这次修复改动任何接线。 - `routes/sessions.js#_eventsAppend("session.switch", …)` 写入 `workspace` 和 `workspaceFallback`,事后追查"为什么文件树跳了"时可以从审计链里直接定位。 +## 左侧边栏(webui-parity ticket 47) + +左侧边栏对照参照实现做了三块对齐:折叠行为、导航项、会话列表。用户看到的变化与不变化如下。 + +### 折叠行为 + +- **折叠宽度 64px**(原 52px),折叠/展开是 180ms 的宽度过渡。折叠后侧栏背景转为透明,只剩一条图标轨——**不是**参照里折叠后整条清空的空竖条;图标轨可用性更高(新建/搜索/插件与头像菜单都还在),是保留的现有能力。 +- **折叠开关移到了侧栏之外**:一个浮在界面上层的 32px 按钮,展开时贴侧栏左上角,折叠后浮在图标轨右侧。它的读屏标签随状态在「展开导航栏 / 收起导航栏」之间切换(中英两语都切)。 +- **折叠时主内容有左补偿**:会话顶栏的标题行在折叠时保持大约原来的水平位置(`pl-[142px]`),不会因为侧栏收窄而向左跳一截。 +- 三项既有能力**保留未动**:拖拽调宽(240–400px)、视口窄于 980px 时自动折叠、折叠偏好持久化(沿用既有的 `webui:ui-state` 键,未新增键)。 + +### 导航项 + +侧栏导航是「新建会话 / 搜索 / 插件」三项。参照里另有「定时 / 网站 / 远程」三项,它们在参照中是不可点击的占位(inert);本仓库的既定决策是不渲染没有落点的占位控件,这三项**不出现**在界面上,属于产品拍板项(见 HANDOVER 融合清单)。当前所在界面对应的导航项有**激活态高亮**:树列正在显示「搜索」或「插件」表面时对应行高亮;没有会话选中(首页)且没有别的表面占屏时,「新建会话」高亮。 + +### 会话列表 + +- **选中态与悬停态可区分**:选中行用 `bg_interaction_tertiary_selected`,悬停用 `bg_interaction_tertiary_hover`,两个设计令牌不同——此前两者同色,「我在哪个会话」的信号是丢失的。 +- 「项目」小节头是 28px 高的纯文本行(不可交互);此前它渲染了一个没有点击行为的按钮,读屏用户会聚焦到一个点不动的控件,已移除。 +- 空态是带圆角与背景的卡片面;错误态带 `role="alert"` 并把失败原因(网络错误 / HTTP 状态等)直接透出,而不是只有一句通用文案。 +- 项目/目录/子代理的展开收起有 180ms 高度 + 140ms 透明度过渡;系统开启「减弱动态效果」时过渡关闭,内容照常显示。 +- 会话行与子代理行是 `?session=` 的深链(``):普通左键行为不变;中键 / Ctrl+点击在新标签打开的 URL,冷加载恢复机制(「重开不乱」)本来就会认。 +- **揭示机制保持每目录 6 条**(「更多 (N)」逐目录展开),没有引入参照的全局 Load more——每目录揭示更省界面空间且已上线。 + +### 参照有、本轮未实现(如实说明) + +参照实现的右键上下文菜单(置顶 / 归档 / 复制为新会话 / 删除)、置顶与归档的本地 overlay 排序、Agent Team 徽标与工作区目录副行、「最近任务」小节——**本轮均未实现**。前两项依赖服务端契约(置顶、归档、复制均无端点),需要与后续工单统一排期;后两项在参照自己的壳里也没有渲染点。后续 agent 不应把它们误判为「已实现但坏了」。 + ## 上下文窗口(模型选择器里能调什么、切了之后发生什么) 模型选择器面板底部有一个详情区:当前模型声明了至少两个上下文窗口档位时,显示一排「上下文窗口」单选按钮;没声明或只有一个档位的模型不渲染任何控件,也不留空白。当前只有 `MiniMax-M3` 和 `MiniMax-M3.1-Flash-Preview` 有档位(512K / 1M),其余模型一概没有。档位标签是紧凑的 token 数(512K、1M);引擎标注为「更高用量」的档位(1M)会带「用量较高」提示,如实告知成本差异。 @@ -253,7 +281,10 @@ exec 回合的代价——以下都是当前真实存在的行为,选择权限 | 表面 | 组件 | 锚点 `data-testid` | | --- | --- | --- | | 侧栏(rail) | `components/shell.tsx` | `sidebar-scroll-viewport` | +| 侧栏折叠开关(侧栏外浮层,工单 47) | `components/shell.tsx` | `sidebar-collapse-toggle` | | 侧栏会话树 | `components/session-tree.tsx` | `sidebar-session-row` | +| 会话树项目小节头(纯文本,工单 47) | `components/session-tree.tsx#SectionHeader` | `sidebar-section-header` | +| 会话树错误态(`role="alert"`,工单 47) | `components/session-tree.tsx` | `sidebar-tree-error` | | 侧栏用户菜单(设置 / 每日签到 / 用量 / 退出登录) | `components/shell.tsx#SidebarFooter` | `sidebar-user-menu` | | 侧栏 inbox(告警浮层) | `components/inbox.tsx` | `inbox-flyout` | | 顶栏(带模型选择器) | `components/toolbar.tsx` | `toolbar-session-status` | diff --git a/packages/webui/webapp/app/globals.css b/packages/webui/webapp/app/globals.css index 3e447de2c..002890838 100644 --- a/packages/webui/webapp/app/globals.css +++ b/packages/webui/webapp/app/globals.css @@ -186,6 +186,42 @@ animation: webui-settings-content-in 180ms ease both; } +/* Sidebar list surfaces (webui-parity 47). + * + * Both rules copy the reference shell's own classes (`shell.css` + * `.webui-empty-state` / `.webui-expandable-motion`) so a future + * side-by-side comparison asserts against the same names: + * + * - `.webui-empty-state` gives the empty list a card face (radius, grouped + * background, padding) instead of bare floating text. + * - `.webui-expandable-motion` animates a disclosure with + * `grid-template-rows 0fr -> 1fr`, which interpolates without measuring + * row heights, plus a 140ms opacity crossfade. The `is-open` class is the + * only switch; children must keep `min-height: 0; overflow: hidden` or + * the 0fr track cannot collapse them. */ +.webui-empty-state { + border-radius: var(--radius_8); + background-color: var(--bg_grouped_secondary); + padding: var(--spacing_8) var(--spacing_12); + line-height: var(--line_height_20); +} +.webui-expandable-motion { + display: grid; + grid-template-rows: 0fr; + opacity: 0; + transition: + grid-template-rows 180ms cubic-bezier(0.2, 0.7, 0.2, 1), + opacity 140ms ease-out; +} +.webui-expandable-motion > * { + min-height: 0; + overflow: hidden; +} +.webui-expandable-motion.is-open { + grid-template-rows: 1fr; + opacity: 1; +} + /* Respect prefers-reduced-motion. * * Every transition / animation added by ticket 07 (the model-selector @@ -200,7 +236,12 @@ * catch-all's duration hack is ever retuned. The dots' rule carries * !important because official-utilities.css (imported after this file * in app/layout.tsx) re-declares the dots' animation-name at equal - * specificity — see the inline note below. */ + * specificity — see the inline note below. + * + * webui-parity 47: the sidebar disclosure gets `transition: none` rather + * than relying on the 0.001ms catch-all — the content must still show + * (the 0fr/1fr track and opacity are state, not motion), it just must + * not slide. */ @media (prefers-reduced-motion: reduce) { .mavis-skeleton-bar { animation: none; @@ -219,6 +260,9 @@ .thinking-live-dot { animation: none; } + .webui-expandable-motion { + transition: none; + } *, *::before, *::after { diff --git a/packages/webui/webapp/app/page.tsx b/packages/webui/webapp/app/page.tsx index 3af115309..39ee32710 100644 --- a/packages/webui/webapp/app/page.tsx +++ b/packages/webui/webapp/app/page.tsx @@ -502,6 +502,19 @@ function App() { return null; }, [tabState.previewActiveId, tabState.treeActiveId, tabState.tabs]); + // webui-parity 47 (N2) — which sidebar nav row reads as active. The tab + // strip is owned by this page, so the shell cannot derive the signal + // itself: the tree column's active tab being search / plugins lights the + // matching sidebar row, and `panel === "plugins"` keeps the legacy mirror + // (set by openPanel) in agreement with openSurfaceTab. + const activeNavSurface: "search" | "plugins" | null = useMemo(() => { + const treeTab = tabState.tabs.find((tab) => tab.id === tabState.treeActiveId); + if (treeTab && (treeTab.kind === "search" || treeTab.kind === "plugins")) { + return treeTab.kind; + } + return panel === "plugins" ? "plugins" : null; + }, [tabState.tabs, tabState.treeActiveId, panel]); + // ============================================================ // Reveal-in-tree handler. Defined BEFORE the early return for // `!state` because hooks must be called in the same order on @@ -671,6 +684,7 @@ function App() { panel={null} onOpenPanel={openPanel} onOpenSurfaceTab={openSurfaceTab} + activeNavSurface={activeNavSurface} onOpenSettings={openSettings} onOpenUsage={openUsage} alertCount={alertCount} diff --git a/packages/webui/webapp/components/session-tree.tsx b/packages/webui/webapp/components/session-tree.tsx index a2acb7147..9ad51eca4 100644 --- a/packages/webui/webapp/components/session-tree.tsx +++ b/packages/webui/webapp/components/session-tree.tsx @@ -7,6 +7,7 @@ import * as api from "@/lib/api"; import { reportActionError, runAction } from "@/lib/action-errors"; import { useSessionContext } from "@/lib/store"; import type { MessageKey } from "@/lib/i18n"; +import { sessionHref } from "@/lib/url-restore"; import { Icon } from "./icons"; import { ProjectRowSwitchAction } from "./workspace-picker"; @@ -35,6 +36,14 @@ import { ProjectRowSwitchAction } from "./workspace-picker"; * the pin is omitted. * - Upstream's per-row actions are pin / rename / delete. Rename, delete and * export all have endpoints and are rendered; pinning has none, so it is not. + * + * webui-parity 47 aligned the interaction layer with the reference: selected + * rows paint with the `tertiary_selected` token (hover keeps `tertiary_hover`), + * the section header is a plain div, disclosures animate through `Expandable` + * (the reference's `.webui-expandable-motion`), the empty and error states + * carry the reference's card face and `role="alert"` respectively, and + * session/subagent rows are `` deep links over the app's own `?session=` + * grammar (`lib/url-restore.ts#sessionHref`). */ // How many sessions a directory shows before the rest go behind `更多`. Upstream @@ -44,6 +53,35 @@ const SESSION_VISIBLE_LIMIT = 6; /** Upstream's `placeholder` treatment for a session with no title yet. */ const UNTITLED: MessageKey = "sidebar.untitled"; +/** + * The disclosure wrapper (webui-parity 47, S5). + * + * The reference animates every sidebar disclosure with its + * `.webui-expandable-motion` rule: `grid-template-rows 0fr → 1fr` (height + * interpolates without measuring any row) plus a 140ms opacity crossfade; + * `globals.css` carries the rule verbatim under the same class name, with a + * `prefers-reduced-motion` branch that drops the transition but keeps the + * open/closed state. + * + * The children stay MOUNTED while collapsed — that is what keeps the session + * tree's loaded state and (in the shell) scroll position intact through a + * collapse cycle — and the wrapper is `inert` + `aria-hidden` while closed so + * the hidden rows are neither focusable nor announced. + */ +function Expandable({ open, children }: { open: boolean; children: React.ReactNode }) { + return ( +
{ + element?.toggleAttribute("inert", !open); + }} + > +
{children}
+
+ ); +} + export function SessionTree({ t }: { t: (key: MessageKey) => string }) { const store = useSessionContext(); const state = store.state; @@ -162,12 +200,24 @@ export function SessionTree({ t }: { t: (key: MessageKey) => string }) { style={{ scrollbarGutter: "stable" }} > {error ? ( -

- {t("error.session")} + /* webui-parity 47 (S4): `role="alert"` so screen readers announce the + failure, and the live reason follows the localized prefix — a bare + generic string left the user unable to tell a dead engine from a + 500. */ +

+ {t("sidebar.loadError")} + {error}

) : null} {!error && projects.length === 0 ? ( -

+ /* webui-parity 47 (S3): the empty state gets the reference's card + face (`.webui-empty-state`: radius, grouped background, padding) + instead of bare floating text. */ +

{t("sidebar.empty")}

) : null} @@ -205,21 +255,20 @@ export function SessionTree({ t }: { t: (key: MessageKey) => string }) { /** * Upstream's `group/section` header row. * - * The chevron is `opacity-0` until the header is hovered — upstream shows the - * disclose affordance only on hover, keeping the resting list quiet. + * webui-parity 47 (S2): a plain `div`, matching the reference's section + * header (`.webui-rail-section-header`: `h-7` / `px-2` / tertiary label, no + * arrow, not interactive). The previous markup rendered a ` - {actions} +
+ {label}
); } @@ -364,24 +413,24 @@ function ProjectNode({ - {open - ? project.directories.map((directory) => ( - - )) - : null} + + {project.directories.map((directory) => ( + + ))} + ); } @@ -514,13 +563,16 @@ function DirectoryNode({ )} {/* A headerless (single-directory) project is governed by the project's - own disclosure, so its sessions are shown whenever the project is - open, one indent in from the project row. */} - {headerless || open - ? headerless - ?
{rows}
- : rows - : null} + own disclosure (the Expandable that wraps every directory), so its + sessions render unconditionally and are hidden by that wrapper, one + indent in from the project row. A multi-directory project gates its + session rows on the directory's own disclosure, animated the same + way. */} + {headerless ? ( +
{rows}
+ ) : ( + {rows} + )} ); } @@ -630,22 +682,24 @@ function SessionNode({ } }, [draft, session.id, session.title, onChanged, t]); - // Row geometry. `pl-2` lives on the OUTER container, not on the button, so - // that the disclosure — now a sibling of the button rather than a child of + // Row geometry. `pl-2` lives on the OUTER container, not on the row, so + // that the disclosure — now a sibling of the row rather than a child of // it — keeps the same left inset it had while nested. + // webui-parity 47 (S1): the selected row uses `tertiary_selected`, not the + // hover fill. The two sharing a token made "which session am I in" + // invisible — hovering any row painted it exactly like the active one. const rowSurface = [ "w-full flex items-center gap-2 pl-2 pr-0.5 h-[30px] transition-colors rounded-lg", active - ? "bg-bg_interaction_tertiary_hover text-text_default_primary" + ? "bg-bg_interaction_tertiary_selected text-text_default_primary" : "text-text_default_primary hover:bg-bg_interaction_tertiary_hover", ].join(" "); - // A real `
+ )} @@ -781,8 +858,9 @@ function SessionNode({ )} - {open && hasChildren - ? session.children.map((child) => ( + {hasChildren ? ( + + {session.children.map((child) => ( - )) - : null} + ))} + + ) : null} ); } @@ -881,17 +960,32 @@ function SubagentRow({ void runAction(t("sidebar.openSession"), api.switchSession(session.id)).then(onChanged); }, [session.id, onChanged, t]); + // webui-parity 47 (S1 + S8): same selected token and same `` deep-link + // treatment as the parent session row — see SessionNode's row markup. return ( - + ); } diff --git a/packages/webui/webapp/components/shell.tsx b/packages/webui/webapp/components/shell.tsx index a8c02b775..3333d6ff5 100644 --- a/packages/webui/webapp/components/shell.tsx +++ b/packages/webui/webapp/components/shell.tsx @@ -11,6 +11,7 @@ import { Icon } from "./icons"; import { InboxFlyout } from "./inbox"; import { SessionTree } from "./session-tree"; import { runAction } from "@/lib/action-errors"; +import { isSidebarNavActive, type SidebarNavSurface } from "@/lib/sidebar-nav"; import { readShellCollapsedFromPersistedState, writePersistedShellCollapsed, @@ -35,12 +36,13 @@ import type { PanelKind } from "./panels"; */ // Desktop parity : 240px default, drag-clamped to -// 240–400, and a 52px icon rail when collapsed — the sidebar never becomes a -// zero-width column. +// 240–400, and a 64px icon rail when collapsed — the sidebar never becomes a +// zero-width column. webui-parity 47 (C1) raised the rail from 52px to the +// reference's 64px so the collapsed icon column breathes the same way. const SIDEBAR_MIN = 240; const SIDEBAR_MAX = 400; const SIDEBAR_DEFAULT = 240; -const SIDEBAR_RAIL = 52; +const SIDEBAR_RAIL = 64; /** Below this width the sidebar collapses itself, as the desktop client does. */ const SIDEBAR_AUTO_COLLAPSE_PX = 980; @@ -82,9 +84,17 @@ interface ShellProps { * conversation column and the disclaimer appears below it. */ hasConversation?: boolean; + /** + * webui-parity 47 (N2) — which tree-column surface the sidebar should mark + * as active. The tab strip lives in the page (page.tsx owns `tabState`), + * so the page computes "the tree column's active tab is search/plugins" + * and hands it down; the nav row cannot read it anywhere else. Null when + * no sidebar-owned surface is the active one. + */ + activeNavSurface?: "search" | "plugins" | null; } -export function AppShell({ t, children, toolbar, panel, onOpenPanel, onOpenSurfaceTab, onOpenSettings, onOpenUsage, alertCount = 0, hasConversation = false }: ShellProps) { +export function AppShell({ t, children, toolbar, panel, onOpenPanel, onOpenSurfaceTab, onOpenSettings, onOpenUsage, alertCount = 0, hasConversation = false, activeNavSurface = null }: ShellProps) { // The sidebar is collapsible from the button in its own top strip. The state // lives here rather than in `Sidebar` because the expand affordance has to be // rendered by the content column once the sidebar is clipped away. @@ -132,13 +142,19 @@ export function AppShell({ t, children, toolbar, panel, onOpenPanel, onOpenSurfa onOpenAlerts={toggleInbox} collapsed={collapsed} onToggleCollapsed={toggleCollapsed} + activeNavSurface={activeNavSurface} />
- {toolbar} - {/* Collapsed: the sidebar's own toggle is clipped, so the expand - affordance floats over the content at the same top offset. */} + {/* webui-parity 47 (C6): while the rail is collapsed the toolbar's + title row would otherwise jump ~180px left, because the flex row + reclaims the sidebar's width. The reference compensates with + `pl-[142px]` on its session title row (64px rail + 142px ≈ the + expanded title position), so the same constant is applied here + rather than inventing one. The padding wraps the toolbar slot, + not the content, matching what the reference compensates. */} +
{toolbar}
{/* The AI-content disclaimer lives in the conversation column, not @@ -208,6 +224,7 @@ function Sidebar({ alertCount = 0, collapsed, onToggleCollapsed, + activeNavSurface, }: { t: (key: MessageKey) => string; onOpenPanel?: (kind: PanelKind) => void; @@ -219,11 +236,25 @@ function Sidebar({ alertCount?: number; collapsed: boolean; onToggleCollapsed: () => void; + /** See the `activeNavSurface` prop on `AppShell` (webui-parity 47 N2). */ + activeNavSurface?: "search" | "plugins" | null; }) { const { state } = useSessionContext(); + const sessionId = state?.mcodeSessionId ?? null; const [width, setWidth] = useState(SIDEBAR_DEFAULT); const dragging = useRef(false); + // webui-parity 47 (N2) — the nav rows' active wiring. The reference marks + // each rail row with `data-webui-nav-active` from a real `active` prop; the + // same signal here drives both the full row and the collapsed icon rail. + // The rule itself lives in `lib/sidebar-nav.ts` (pure, unit-driven); the + // inputs are the engine's active session id and the page-owned surface + // signal handed down through `activeNavSurface`. + const isNavActive = useCallback( + (key: MessageKey): boolean => isSidebarNavActive(key, sessionId, activeNavSurface ?? null), + [activeNavSurface, sessionId], + ); + const onBell = useCallback(() => { onOpenAlerts(); }, [onOpenAlerts]); @@ -271,22 +302,27 @@ function Sidebar({ return ( <> - {/* Collapsing animates the outer width to the 52px rail rather than to zero, - and the transcript keeps its state: the session tree stays mounted (just - hidden) so its loaded rows and scroll position survive a collapse cycle. */} + {/* Collapsing animates the outer width to the 64px rail rather than to + zero, and the transcript keeps its state: the session tree stays + mounted (just hidden) so its loaded rows and scroll position survive + a collapse cycle. webui-parity 47 (C1): 180ms on the reference's own + cubic-bezier(.2,.7,.2,1), replacing the previous 200ms ease-out. */}
{/* Upstream's sidebar card. Class order and the inline geometry come from its live DOM (`data-testid="sidebar-base-card"`): the card itself owns the `border-right: 0.6px` hairline and a width/transform/opacity transition, which is why the border is inline here rather than a - utility. */} + utility. webui-parity 47 (C5): the card goes transparent while + collapsed — the rail is a chromeless strip, and the reference's + own `webui-rail` swaps `bg-bg_default_scrim` for `bg-transparent` + on the same branch. */}
- {/* Upstream's window-drag strip. `[-webkit-app-region:drag]` is inert in - a browser, so it is omitted; the strip now carries the sidebar - collapse toggle in place of upstream's traffic-light gap. The - `pl-2` matches the nav-row padding below (also `px-2`), so the - toggle's left edge sits at the same x as the new-session button - beneath it. The SVG is forced to `block` (see IconButton's - `[&>svg]:block`) so it no longer drifts against the inline - baseline — without it the icon sat visibly off-centre inside the - 28x28 button. */} + {/* Upstream's window-drag strip, kept as a spacer. webui-parity 47 + (C2) moved the collapse toggle OUT of the sidebar onto a + floating overlay (rendered after the separator below), so the + strip no longer carries a control — it only holds the nav rows' + vertical offset, which stays 38px so the rows do not shift. */}
-
- - - -
+
{/* Nav rows. Upstream's section is `px-2 pt-2 pb-5 space-y-px`. */}