Files
qwen-code/docs/design/web-shell-navigation-rail.md
f2e069061a feat(web-shell): add adaptive navigation rail and unified Live settings (#12943)
* feat(web-shell): add adaptive navigation rail and unified Live settings

* style(web-shell): add space below the Home action section

* Revert "style(web-shell): add space below the Home action section"

This reverts commit a2e1c76223.

* fix(web-shell): Preserve navigation and Live drafts across refreshes

* fix(web-shell): keep rail navigation state, drag widths, and Live settings drafts consistent

Address the Critical findings from the latest review rounds:

- Build the standalone shell sidebar config from the sidebar's exported
  defaults so the Agents entry can no longer silently drop out.
- Gate rail navigation's persisted uncollapse behind restoresColumn so
  opening a full page (Settings, Plugins, ...) no longer rewrites the
  user's collapse preference; only Home/Channels/Live restore the column.
- Reset the sidebar section when a panel closes and stop leaking 'split'
  as a sidebar page, so a wide split keeps the home column and a working
  collapse control.
- Clamp drag widths to the container-aware cap the CSS already renders,
  floor the visual drag at the layout minimum, and restore the persisted
  width from state instead of a mid-animation/container-capped rect.
- Cap the compact drawer's min-width at the container width.
- Keep the Live voice trigger reachable from the Live page header when
  the sidebar's slot is hidden.
- Settle Live settings draft fields (and their conflict baselines) that a
  refresh converges onto, so a later edit is not blocked by a stale
  baseline.

* fix(web-shell): keep rail panel sections, status dots, and Live drafts consistent

Address review findings on the navigation-rail change: retain the
Channels/Live sidebar section when its panel closes (an explicit Home
choice sticks on the first click), load secondary-workspace attention
status whenever the Home column is hidden (idling the poll when nothing
runs), ignore zero-displacement presses on the resize handle, settle
padded Live settings drafts on trimmed refresh convergence, disable the
shortcut capture until status loads, key the empty-chat welcome chrome
off the shell container signal, and document /live in the pre-auth path
lists.

* fix(web-shell): close rail-era gaps in shortcut, dock, and status handling

Address the round-5 review findings on the navigation-rail change. The
Cmd/Ctrl+B handler resolved its target with e.target, which shadow-DOM
retargeting replaces with the portal host, so the editable-target
exemption never fired for portaled dialogs; it now reads
composedPath()[0] like its siblings. The same handler toggled the raw
sidebarCollapsed state, silently inverting the persisted preference in
the split-view fold band where the sidebar renders folded regardless;
the shortcut, the sidebar prop, and the environment-panel dock budget
now share one effective-collapsed expression. The rail's 56px is
excluded from the dock budget so the environment panel keeps docking at
1440px windows instead of being force-closed on load, with the e2e step
re-pinned at 1440 and the consequence recorded in the sidebar docs. The
secondary-workspace status queries now stay active while the Live
section column is open (the rail Home button is the only status surface
there), and the rail branch resets the project search when Live unmounts
its autoFocus field so a Home round-trip cannot steal focus. The Live
Voice card gates key removal and the required marker on the saved key
state, so a model edit leaving a route can no longer offer removal of a
key that was never stored.

* fix(web-shell): stop silent collapse-preference and drag-width rewrites

Address the round-5 review findings on the navigation-rail change, plus
the residual direction of the round-4 fold-band fix:

- The Cmd/Ctrl+B toggle computed from the effective folded state is a
  constant expansion in the split-view fold band; it now rewrites the
  unchanged preference there instead of flipping a stored `true` to
  `false` on a keypress that changes nothing on screen.
- The rail's column restore fires `onCollapsedChange(false)` only when
  the sidebar is actually collapsed, so a drawer tap on Channels/Live no
  longer silently clears the desktop preference from the forced-open
  drawer.
- The Channels rail entry restores the column only when the Channels
  column exists (`channelNavigationEnabled`), matching openNavigation's
  rule for full-page entries.
- The App's rail-Home callback drops its redundant preference write; the
  sidebar's openNavigation already owns column restoration.
- A drag released at or past the container cap while the cap already
  bound the start width no longer persists the capped width over a wider
  stored preference, and releasing at the drag start restores the
  pre-drag state width as well.
- The Live settings settle effect now runs only on a genuine status
  change, so typing a value that briefly trims onto the saved one no
  longer loses the just-typed characters.

Each behaviour is pinned by a unit case that goes red without its fix.

* fix(web-shell): settle Live drafts only on genuinely changed values

Follow-up to the round-5 settle fix: the daemon SDK returns a fresh
status object per fetch, so keying the settle effect on object identity
still let a 1s install poll or focus refresh with unchanged data
converge a padded draft the user was typing. Compare the settle-relevant
saved values against the previous status instead: only a refresh that
actually moved a saved value settles, while draft-only re-renders and
unchanged polls leave the in-progress text alone. Pinned by `does not
settle a padded draft when a poll returns unchanged values`, red against
the identity comparison and green with value comparison; the convergence
cases stay green.

* fix(web-shell): keep capped drags window-scoped and drop detached rail hints

Address the two round-6 Critical findings:

- The container-cap guard added last round only covered a release past the
  cap. While the cap binds, the rendered edge cannot follow the pointer, so
  the rect-based delta is container-scoped in both directions: a 2px
  leftward jitter stored 298 over a 420 preference, and a later host
  widening rendered the narrow value. Map the pointer delta onto the state
  width whenever the cap bound at drag start, so persistence stays
  window-scoped (418), while the past-cap branch keeps priority and an
  uncapped drag still persists what the user saw.
- navigationHint held a live rail button and was only cleared by DOM
  handlers on the nav. Removed elements fire neither pointerout nor blur,
  so an unmounted rail - or just an unmounted hinted button, when its
  feature gate flips - left the tooltip Popover open against a detached
  node, painting the previous icon label near the viewport origin when the
  rail returned. Clear the hint when the rail is gone or its anchor is
  detached; the check runs every render because a dependency list cannot
  observe a child unmounting while both deps stay unchanged.

Both are pinned by cases that go red without their fix: a leftward jitter
stores 418 (298 without the mapping, with the two neighbouring width cases
still green), and a rail tooltip does not survive a rail -> single -> rail
round trip.

* fix(web-shell): re-baseline hidden-surface and failed-save trackers

Address the two round-7 Critical findings:

- The secondary-workspace completion tracker only re-baselined while the
  status surface was hidden, leaving a frozen running snapshot across a
  visible Home period. Comparing the next refetch against it repainted a
  completion the user watched happen as an unread one. The visible path now
  nulls the per-source baseline, so the first hidden cycle after a visible
  period is baseline-only: only completions actually observed while hidden
  produce a dot. The always-hidden sibling path (and its existing test in
  WebShellSidebar.collapse-persist.test.tsx) is untouched.
- A save rejected after the daemon persisted some of its writes (its
  settings writes land before setEnabled, which rolls back only `enabled`)
  was reported as settings.liveSetup.conflict: the stale apiKey baseline
  compared against the refreshed keyConfigured/storedKey flipped by the
  card's own request. The rejected save now queues a re-baseline of the
  request keys and refreshes, and conflict with those keys is suspended
  until the refreshed status lands and re-anchors them (state, not a ref,
  so the re-baseline provokes the render that clears it).

Both pinned by cases that go red without the fix: no completed badge after
a plugins -> home -> plugins round trip while the sibling always-hidden
case stays green, and no conflict plus a retryable Save after a partially
landed save.

* fix(web-shell): stop certifying unfreshed secondary workspace status

Address the round-8 Critical (body-level, independently confirmed by
qqqys): the secondary catalog page is loaded and polled only while the
Home column is hidden, but the rail Home button folded its retained
snapshots into the indicator unconditionally. An approval resolved
elsewhere kept painting the amber dot and the "Waiting for approval"
accessible name until the user hid the Home column again — two render
sites for one stale certification, and a regression over the pre-PR
surface, which could only render the dot from a live, fetchable path.

build the rail indicator from the primary page plus only those secondary
sessions whose queries are currently armed. While the Home column is
visible the secondary contribution is dropped entirely; hiding the Home
column re-arms the queries and the still-waiting state comes back. On
live-state daemons the secondary catalog path is unloaded regardless, so
nothing live is hidden by this gate. Pinned by a plugins -> home ->
plugins round trip asserting both the dot attribute and the accessible
name, red without the gate while the always-hidden attention case stays
green.

* fix(web-shell): gate the completion tracker baseline on page freshness

Address round-9 Critical R9-1: nulling the secondary baseline on the
visible path closes only half the leak. On re-entering the hidden
surface the tracker installed the store's retained, never-refetched
catalog page as the new baseline (the guard rejected only loading and
error, never a stale page), so the poll arriving seconds later was
diffed against a snapshot taken before the visible window and a
completion the user watched was repainted as unread — exactly what the
visible-path null was supposed to prevent.

Stamp the moment the surface becomes hidden and refuse to install or
compare any secondary snapshot that is not provably fresh
(`(snapshot.updatedAt ?? 0) < hiddenSince`, an unstamped page counting
as not-fresh). The first after-hidden poll then installs the baseline
from truly fresh data. The strengthened round-trip case drives retained
running -> stale re-entry -> fresh idle; it is red without the gate
(the completion badge and the Finished accessible name both appear) and
green with it, while the always-hidden completion case stays green.

* fix(web-shell): clamp every collapse entrance and clear More on rail teardown

Address round-9 Criticals R9-2 and R9-3 (body-level):

- The split-view fold-band clamp lived only at the Cmd+B entrance; the
  rail's Collapse/Expand button reached handleSidebarCollapsedChange
  unclamped, so a click that visibly does nothing still overwrote the
  stored preference and a stored `true` could never be re-written inside
  the band. The clamp now lives once in the shared writer: in the fold
  band it re-stores the current raw preference regardless of which value
  was requested. (`sidebarCollapsed` and `splitFoldedSidebar` reach the
  stable []-callback through refs — the former would close over the
  mount-time value and the latter is declared later, TDZ.) The Cmd+B
  site shrinks back to passing the toggle intent. Pinned by a third arm
  in the fold-band App test that clicks the collapse toggle at 1100px
  and finds the stored `true` untouched — red with the clamp removed.
- The More reset effect did not watch layout, so a rail -> single ->
  rail round trip left moreOpen=true against an unmounted owner and the
  rail returned with a phantom open menu (content in the document,
  focus inside, never reopened). `layout` joins the dependency list and
  a round-trip case in WebShellSidebar.collapse-persist.test.tsx goes
  red without it.

* fix(web-shell): let exit-split navigation restore the column under the fold clamp

Address maintainer P2 inline: after the shared-writer clamp landed, a
Home click from the split-view fold band was still intercepted — the
rail's restoresColumn `onCollapsedChange(false)` wrote the folded raw
value back (correct for taps that stay in split view), and `onOpenHome`
then closed split view with the raw preference unchanged, leaving the
Home column folded until a second click.

Split the intent at the shared writer: `exitFoldBand` marks entrances
that reorganize the layout away from split view, bypassing preservation,
and the rail Home handler uses it when the fold band was the overriding
state at click time. Taps that stay in the fold (Cmd/Ctrl+B, the rail
Collapse/Expand button) keep their preference protection untouched, and
the write still never happens inside the mobile drawer, where
splitFoldedSidebar is forced false by construction — so the drawer case
is unaffected (drawer protection is preserved).

Pinned by a fourth arm in `does not invert the stored collapse
preference in the split-view fold band`: stored `true` at 1100px split
view, click `[data-testid="open-home"]` once, and the column is expanded
with the stored key `false`; dropping the bypass inverts that arm while
the three protected arms stay green.

* fix(web-shell): cover Channels and Live in the exit-split fold exception

Address maintainer P2 follow-up: the exitFoldBand carve-out applied only
to Home, so Channels and Live — whose `openPanel` call exits split view
through the web-shell page set (`setMainView('chat')`) the same way —
still had their restoresColumn write clamped when the fold band owned
the sidebar, leaving the section column folded until a second rail tap.

Extent the same bypass to those two entrances, with each entry's own
column-existing gate: Channels applies it only when channelSidebarEnabled
would actually produce a column (mirroring the rail's
channelNavigationEnabled rule, which still leaves the full-page channels
variant folded), and the Live handler inherits the liveSidebarEnabled
guard it's already defined under.

Pinned by two parallel cases beside the fold-band keyboard/click arms:
stored `true` at 1100px split view → one rail tap on `[data-testid=
"open-channels"]` (with `session_source_metadata`) and one on
`[data-testid="open-live"]` (with showLive) — each expands the column and
writes `false`; dropping both bypasses turns both cases red while the
protected Home arm stays green.

---------

Co-authored-by: 钉萁 <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
2026-10-05 08:13:30 +00:00

15 KiB
Raw Permalink Blame History

Web Shell navigation rail

English | 简体中文

Problem and scope

The current sidebar mixes global navigation with project conversations and turns into a hover-operated session popover when collapsed. Introduce a persistent desktop icon rail and a collapsible Home column, retaining explicit embedded-host controls and backend behavior, with the requested Home-only default for unconfigured hosts. No user avatar, global recent catalog, new navigation framework, or daemon API changes.

Rail layout and behavior

Home opens the existing brand, New task, pinned sessions and project sections. Hosts without a dedicated Live entry retain their enabled Live group here. Enabled primary navigation entries remain directly accessible in the rail. Its bottom contains only More and the collapse/expand control. In Home-only mode the collapse/expand control stays at the secondary column’s bottom right. When the primary rail exists, it alone owns this control; all secondary columns omit it. More groups settings, theme and other enabled footer actions; version text also lives in More; host custom content retains a wide area in Home. Rail icons use a consistent 17px size and 1.8px outline stroke, with Home using House. Selection preserves the same icon shape and changes only foreground and background colors. Idle icons use neutral gray; hover and selection use the stronger foreground and a subtle rounded background without a hard button border. Home, primary navigation, More and collapse/expand use a shared black, white-text, arrowless Popover without entrance or exit animations on hover and keyboard focus instead of native titles, preserving accessible labels and the scoped portal root. The Home-only fallback Live group uses the Project heading color and an adjacent chevron. Channel and Live column titles share Home’s brand typography: 18px, weight 500, line height 20px. Their bottom borders share Home’s 12px horizontal inset instead of spanning the column. The Project heading chevron and actions appear on hover or keyboard focus, staying visible on touch devices. Workspace and Live session groups retain indentation without vertical tree guides. Live retains its existing capability and showLive gates; this change does not add a new voice-start policy.

With a primary rail, collapse hides the secondary column completely and leaves only the 56px rail. Its expand button or Home click restores the column; hovering does not reopen it. Home-only mode retains main’s existing 56px folded sidebar with brand, New task, hover-session surface and bottom expand control. Clicking Home expands it and returns to the existing chat without creating a session. Except for Channels and Live described below, clicking another navigation icon opens its existing full-width functional page immediately to the right of the rail, replacing the Home column and chat presentation. Forms are not squeezed into a project-width column. In-place MCP, Skills, Agents and Extensions command panels keep Home visible and selected. Browser history returning to chat restores Home (or Live for a Live session URL). Scheduled Tasks and Goals omit their Back buttons when the rail is available; embedded single-column, compact drawer, or hidden-sidebar hosts keep them. Their page headers use the same 46px minimum height, 10px vertical padding and 14px title as Channel settings. Keyboard collapse first moves focus from the secondary column being hidden to the rail expand control. Collapse/expand and Cmd/Ctrl+B change only sidebar visibility, preserving the active page, URL, form drafts and active calls. Explicit page navigation may still unmount page forms, as it already does. Theme and local-file controls remain actions rather than pages.

Unified Channels section

In rail mode, when source metadata and the existing source switch are enabled and the Channels entry is allowed, Channels opens a 300px session column and the full settings page together. Its header shows only Channels above a bottom border; search remains in the Project toolbar. Clicking the primary Channels icon always reopens the existing full main-area management page while retaining the channel list. The main-page title is Settings. This dedicated rail settings page has no Back button while the rail is visible; compact drawers, single-column and legacy entry points retain it. Opening a channel session returns to chat and retains the Channels selection. Home selects ordinary tasks. Hide the old Tasks/Channels tabs only when their replacement is reachable; single-column hosts and older daemons retain their existing behavior.

Reuse the existing source-filtered session catalogs, WorkspaceSection session rendering and shared row actions. Retain workspace folders and expansion state as Home, together with workspace-qualified loading, search, channel-type grouping, rename, export, pin where supported, delete, archive/restore, errors and capability/permission checks. The channel archive also groups sessions by workspace. Channel settings omits its introductory sentence and repeated workspace summary. Its toolbar places the workspace selector on the left without a visible label (the accessible name remains), followed immediately by a badge labeled “N configured channels” (using the actual count) for the selected workspace; refresh stays on the right. The unconfigured empty state has a border. No configuration counts are added to sidebar folders. Channel settings keeps its existing workspace selection behavior; matching channel names in different workspaces remain separate. Workspace management stays on Home: hide manage/add-workspace controls, workspace row actions and actionable workspace hover details in Channels. Search and session actions remain available. No daemon, Channel-route or generic navigation configuration change. A local Home/Channels/Live section choice survives returning from settings or opening a conversation; the public Channels route continues to mean channel management.

Dedicated Live section

In rail layout, showLive and the existing primary-nav whitelist control the new live entry. Live opens its 300px session column and the existing Qwen Live settings card in the main area, with the same 46px header and Back rules as Channels. Both right-side page titles are Settings. The existing Live card uses a single column capped at 768px; labels and inputs align, with one Save button below the form and a red asterisk immediately after the API key label when required (with aria-required), instead of a Required badge. The enabled switch, endpoint, key replacement/removal, model, voice and native shortcut are staged locally. Save submits only changed, supported fields in one existing POST /live/setup request. Native enable confirmation appears at Save; cancellation and request failure retain drafts. Editing a field back to its saved value removes that draft field. If a refresh changes a field underneath an unsaved edit, saving is blocked with an explicit conflict notice; the user can discard the draft and load the latest observed settings before editing again. This client-side check covers observed changes, not atomic concurrent writes or secret replacements with unchanged metadata. Staged key removal remains visible and undoable even after re-enabling. Background status refresh does not disable an already loaded form or its Save button when there is no conflict; initial loading and mutations still block controls. Provider-route models do not submit inactive endpoint/key replacement drafts. Switching to a custom model submits endpoint only if explicitly edited; an untouched endpoint preserves the stored independent configuration, with a preservation hint instead of implying a reset to defaults. Host install/retry and launch remain immediate actions. Selecting a Live conversation opens its existing detail; clicking Live again returns to settings. The column directly lists daemon-owned Live workspace sessions and their existing actions without a collapsible Conversations heading, without ordinary project management or a new archive feature. A settled empty Live list shows a muted inbox icon above the existing no-sessions label, horizontally centered; loading and error behavior stays unchanged. Channels uses MessagesSquare; Live uses AudioLines, both at 17px with a 1.8px stroke.

Reuse the settings card, setup hook, and voice dialog without adding a welcome page or new voice capabilities. Move the existing composer voice trigger into the Live column through a portal; the controller stays mounted so navigation neither starts capture nor interrupts a call. When the secondary column is hidden or the narrow drawer closes, the trigger returns to the composer. Only explicitly activating the voice action uses the current start/connect flow. Existing support and composer action restrictions still apply. When this dedicated entry is available, global Settings omits the duplicate card; single-column, hidden-sidebar, showLive: false, and whitelist-excluded hosts keep the original Settings → Experimental card and conversation placement. Unsupported older daemons retain their session list and show an unavailable-settings notice. Add /live to the existing route parser, history handling and daemon public-document path list rather than creating a new navigation system. Only exact page GET/HEAD document requests serve public HTML; Live API requests retain authentication in both cold and warm daemons.

Validate route/reload/history, settings relocation and fallback, session selection, collapse/expand, narrow-host Back, no navigation-triggered Live mutation or media capture, and retained voice controls across trigger relocation. Existing voice/settings unit tests cover the unchanged capabilities; mock-browser screenshots show layout only.

Compatibility and implementation

Breaking change: omitting sidebar now displays Home instead of hiding the sidebar; omitted item lists now expose only New task/collapse instead of all built-ins. Existing hosts must pass sidebar: false to stay hidden, or explicitly populate primaryNav.items and footer.items to retain their menus. Compact branding is now visible by default; pass branding.hideWhenCompact: true to hide it.

Use one automatic layout instead of a public sidebar.layout option. Home always exists. When another configured and available primary entry or footer action exists, show the rail; otherwise show only the 300px secondary column. Availability checks include project capabilities, Live opt-in, managed-provider availability and workflow support. Split availability and automatic folding use the shell container width (1024px); room for the split sidebar uses a 1200px container threshold. Unconfigured hosts, sidebar: true, and empty options default to Home/New task with only the collapse footer control. sidebar: false or enabled: false still hides the sidebar. Standalone explicitly configures its existing full primary and footer entries. Hosts opt into other entries through the existing primaryNav.items and footer.items; omitting their item lists no longer enables every built-in feature. A Home-only sidebar collapses to the existing 56px strip and hover-session popover; with multiple entries collapse leaves only the primary rail, whose expand button or Home click restores Home. All secondary columns omit their own collapse button in this layout.

Keep sidebar.enabled/defaultCollapsed, primaryNav.items/render, footer.items/render, branding, showLive, lockWorkspaceCwd, and host drawer APIs. sidebar:false hides both navigation columns. Host render callbacks retain wide slots in Home. Functional-page selection comes from App's existing activePanel/mainView. A local Home/Channels/Live selection controls the session source without adding a router. Browser history and page callbacks remain authoritative. With URL navigation, only explicit host item lists restrict existing page routes; Home-only menu defaults do not reject Settings, Plugins or Goals URLs. The new Live route still requires an available Live entry.

The stored sidebar width describes the combined rail and Home column; default Home to 300px plus a 56px rail (356px combined), and Home-only presentation to 300px. Previously saved total widths remain authoritative, clamped to an expanded minimum of 220px Home-only or 276px including the 56px rail. Do not unconditionally add 56px to historical values. Resizing and responsive behavior must respect the embedded container, not just window width. When the timeline is hidden in a desktop chat viewport of at least 760px, a separate conversation-search trigger remains available at the top right; compact mobile views retain their existing behavior. On narrow containers Home keeps its logo and brand name by default, including when a desktop collapse preference is stored; explicit branding:false or branding.hideWhenCompact:true still hides it. Use the existing drawer interaction, with both columns inside the drawer; Escape, focus, portals and host containment remain intact. Short rails scroll when primary entries need more room. The More popover reuses existing footer actions and host filters; update polling, the local-files bridge and the desktop relay remain mounted when it closes. The upstream desktop relay remains available in More with its existing authorization and connection lifecycle. Preserve scoped CSS, semantic colors, public variables, React 18/19 and portal-root integration.

Affected areas: WebShellSidebar and its CSS, App layout/wiring, translations, README, focused unit and browser tests. Reuse the existing hover-session surface only in Home-only mode; rail layout hides the secondary column and expands only on click.

Risks and validation

Main risks are custom host content, constrained widths, page-route selection, collapse persistence, split view, keyboard focus, and Live capability gating. Test default/disabled/custom/locked sidebars, mouse hover versus click, draft retention, browser history, mobile and embedded narrow containers, short-height overflow, dark/light themes, and session operations. Browser screenshots using a mock daemon establish UI behavior only, not real Git/backend operations. Run build, typecheck, focused tests, bundle, browser tests and two clean self-audit passes.

Acceptance and open questions

Home-only collapse retains its existing 56px folded sidebar and bottom expand control. Rail mode has one collapse/expand control at the primary rail’s bottom and leaves only that 56px rail after collapse. Home or Expand restores the conversation tree; function icons open their corresponding pages. All enabled entries remain reachable directly or through More, and Home never creates a session. Existing host configuration remains valid, and Live remains opt-in for embedded hosts. No open product questions; no PR or publishing is included in this implementation request.