* 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 a2e1c7622336cceb174faf1814b057a727f6a589.
* 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: 钉萁 <dingqi.jww@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Shaojin Wen <szujobs@gmail.com>
16 KiB
WebShell Sidebar — Customization Guide
The WebShellSidebar is the session list and navigation panel rendered inside
the web-shell App component. This document maps each visual area to its
current customization capability and identifies areas with no external injection
point.
Enabling the sidebar
The sidebar defaults to Home only: a 300px session column with a bottom collapse control. Omitting sidebar, passing true, or passing {} uses this default. Pass sidebar={false} or { enabled: false } to hide it.
import { WebShellWithProviders } from '@qwen-code/web-shell';
<WebShellWithProviders
baseUrl="http://localhost:4170"
sidebar={true} // Home-only default
// or with fine-grained options:
// sidebar={{ enabled: true, defaultCollapsed: false, ... }}
/>;
Layout overview
Home-only hosts show branding, New task, projects, archived sessions and the collapse control in one column. Configuring another available primary entry or footer action adds a 56px icon rail beside the default 300px secondary column. Primary entries live in the rail; footer actions and version live in More. Custom renderers remain in the wide Home column. There is no public layout option.
Breaking change: previously an omitted sidebar hid the sidebar, and omitted navigation/footer item lists exposed all built-ins when the sidebar was enabled. Hosts must now pass sidebar={false} to retain the former hidden default, or explicitly list the entries they need. Standalone Web Shell explicitly configures its full menu. branding.hideWhenCompact now defaults to false; set it to true to preserve a hidden brand in the compact drawer.
Customizable areas
① Branding — branding
interface WebShellSidebarBranding {
render?: () => ReactNode; // replace the entire branding row
hideWhenCompact?: boolean; // hide branding in the compact drawer (default: false)
}
| Value | Effect |
|---|---|
undefined (default) |
Resolved brand: brand prop → daemon GET /brand → built-in Qwen logo + "Qwen Code" text |
false |
Branding row hidden entirely |
{ render: () => <MyHeader /> } |
Full replacement with custom content |
{ hideWhenCompact: false } |
Keep branding visible in the compact drawer (the default) |
The default row is data-driven, not fixed: a daemon that serves a ui.brand
configuration renames the text and swaps the mark, and an embedding host can
override both with the shell component's brand prop (onBrandResolved reports
the outcome for the host's own chrome). branding.render stays the
highest-precedence override — it wins over the prop and the daemon-resolved
value, exactly as before.
sidebar={{
branding: {
render: () => (
<div style={{ display: 'flex', gap: 8 }}>
<img src="/my-logo.svg" alt="" width={24} />
<span>My App</span>
</div>
),
},
}}
② Primary Navigation — primaryNav
type WebShellSidebarPrimaryNavItem =
| 'newTask' // ✏️ New Task button
| 'plugins' // 🧩 Plugins button
| 'channels' // Channel sessions and settings
| 'live' // Live sessions and settings; requires showLive
| 'workflows' // Requires workflow support
| 'managed' // Requires a managed-agent provider
| 'scheduledTasks' // 📅 Scheduled Tasks button
| 'goals'; // 🎯 Goals button
interface WebShellSidebarPrimaryNavOptions {
items?: readonly WebShellSidebarPrimaryNavItem[]; // which built-in buttons to show (default: ['newTask'])
render?: () => ReactNode; // additional custom content after built-in buttons
}
The primary navigation area contains built-in buttons controlled by items:
- Only New task is shown when
itemsis not specified; Home always exists - Only the listed buttons are shown when
itemsis provided - Custom content can be added via
render()after the built-in buttons
| Value | Effect |
|---|---|
undefined (default) |
Home with New task |
{ items: ['plugins'] } |
Home and Plugins; no New task button |
{ items: ['plugins', 'scheduledTasks'] } |
Home + Plugins + Scheduled Tasks |
{ items: [], render: () => ... } |
Home with custom content; no built-in primary buttons |
sidebar={{
primaryNav: {
items: ['plugins', 'scheduledTasks'], // hide newTask and goals
render: () => (
<button onClick={() => console.log('custom action')}>
🔗 Data Sync
</button>
),
},
}}
④ Footer — footer
type WebShellSidebarFooterItem =
| 'settings' // ⚙ Settings panel
| 'update' // Update action when supported
| 'localFiles' // Local-files bridge when available
| 'desktopRelay' // Use this computer through the existing desktop relay
| 'workspacesOverview' // Workspace management when unlocked
| 'version' // version label (e.g. "v0.19.10")
| 'theme' // ☀/🌙 light/dark toggle
| 'sessionsOverview' // ▦ session overview panel
| 'splitView' // ◧ split view (shell containers at least 1024px wide)
| 'daemonStatus' // 📊 daemon status panel
| 'collapse'; // ◁/▷ collapse/expand toggle
interface WebShellSidebarFooterOptions {
items?: readonly WebShellSidebarFooterItem[]; // which built-in items to show (default: ['collapse'])
render?: () => ReactNode; // custom content in the Home footer
}
| Value | Effect |
|---|---|
undefined (default) |
Only collapse/expand |
false |
Footer hidden; the mobile drawer keeps only its close control |
{ items: ['settings', 'theme', 'collapse'] } |
Only listed items shown; the mobile drawer always keeps its close control |
In rail layout, footer actions and version appear in More; only the collapse/expand control stays at the rail bottom. Home-only mode retains the existing compact footer behavior.
sidebar={{
footer: { items: ['theme', 'collapse'] }, // minimal footer
}}
Custom content via render() appears in the wide Home footer:
sidebar={{
footer: {
items: ['collapse'],
render: () => (
<button onClick={() => openHelpCenter()}>
❓ Help
</button>
),
},
}}
Note: 'scheduledTasks' and 'goals' have been moved to the primary
navigation area (②) and require explicit configuration in embedded hosts. They are controlled by primaryNav.items instead of
footer.items.
Other top-level options
interface WebShellSidebarOptions {
enabled?: boolean; // show/hide sidebar (default: true, including when sidebar is omitted)
defaultCollapsed?: boolean; // initial collapsed state (persisted in localStorage)
showCompactToggle?: boolean; // show the collapse button in the chat area (default: true)
showSessionSourceSwitch?: boolean; // show the Tasks/Channels switch (default: true)
showLive?: boolean; // show daemon-owned Live conversations (default: false)
branding?: false | WebShellSidebarBranding;
primaryNav?: WebShellSidebarPrimaryNavOptions;
hideProjectHeader?: boolean; // hide "Projects" header row (default: false = shown)
sessionActions?: WebShellSidebarSessionActionsOptions;
footer?: false | WebShellSidebarFooterOptions;
}
Session source switch — showSessionSourceSwitch
Set showSessionSourceSwitch to false when an embedded host should show only
ordinary task sessions:
sidebar={{
showSessionSourceSwitch: false,
}}
This removes the Tasks/Channels switch and fixes every active, archived, primary,
and secondary session query to sourceType: "default". Omitting the option keeps
channel-session access. When a dedicated Channels rail entry is available, it replaces the source tabs; otherwise the tabs remain.
Live conversations — showLive
Live conversations are hidden from embedded hosts by default. Opt in when the host should expose daemon-owned Live conversations:
Previous releases displayed this group without an explicit option, so hosts
that rely on it must set showLive: true when upgrading.
sidebar={{
showLive: true,
primaryNav: { items: ['newTask', 'live'] },
}}
Without the live primary entry, showLive: true retains the existing Live group in Home and settings under Experimental. With the entry, Live opens its own session column and settings page.
③ Project Header — hideProjectHeader
Controls visibility of the "Projects" header row (the row with the collapse
toggle, search icon, and add workspace button). Defaults to false (shown).
sidebar={{
hideProjectHeader: true, // hide the "项目 ▼ [🔍] [+]" row
}}
When hidden, the session list entries and archived sessions are still shown — the header row with its action buttons and the session search bar are removed.
Session Row Actions — sessionActions
type WebShellSidebarSessionActionItem =
| 'details' // 📝 Details (dropdown sub-menu)
| 'rename' // ✏️ Rename (dropdown menu)
| 'group' // 📁 Group/Move to folder (dropdown menu)
| 'export' // 📤 Export chat history (dropdown menu)
| 'delete' // 🗑 Delete session (dropdown menu)
| 'pin' // 📌 Pin/Unpin (inline button)
| 'archive'; // 📦 Archive (dropdown menu)
/** Subset with working inline (hover-button) handlers. */
type WebShellSidebarSessionInlineActionItem =
| 'pin'
| 'rename'
| 'export'
| 'delete';
interface WebShellSidebarSessionActionsOptions {
items?: readonly WebShellSidebarSessionActionItem[]; // which actions to show (default: all)
inlineItems?: readonly WebShellSidebarSessionInlineActionItem[]; // which items appear as inline buttons (default: ['pin'])
}
Controls which action buttons appear on session rows:
items: Master control for all actions (both inline and dropdown). If an item is not initems, it's hidden everywhere.inlineItems: Controls which items appear as inline buttons (on hover). Defaults to['pin']. Only items with working inline handlers can be used:'pin','rename','export','delete'.'details','group', and'archive'are dropdown-only.
Visibility priority: Both items AND the item's built-in condition AND inlineItems must all pass for the inline button to show. For example, delete as inline requires items to include 'delete' AND inlineItems to include 'delete'.
| Value | Effect |
|---|---|
undefined (default) |
All actions shown, pin as inline |
{ inlineItems: ['pin', 'delete'] } |
Pin + delete as inline buttons |
{ inlineItems: [] } |
No inline buttons at all |
{ inlineItems: ['rename', 'export'] } |
Rename + export as inline buttons |
The dropdown trigger (⋮) is automatically hidden when no dropdown items
are enabled. Inline buttons are only shown when both
their capability condition and items include them. Archive is disabled on
the current session and on any session with a running turn, because the daemon
closes the live session when it archives.
sidebar={{
sessionActions: {
items: ['details', 'rename', 'export', 'delete', 'pin'], // which actions to show (master control)
inlineItems: ['pin', 'delete'], // pin + delete as inline buttons
},
}}
Non-customizable areas
Projects / Workspaces (inside session list)
When the session list is visible, the following sub-areas are rendered but not individually customizable:
| Aspect | Detail |
|---|---|
| Data source | useSessions() hook → daemon API (/sessions endpoint) |
| Session list sorting | By creation time, descending |
| Session row rendering | Internal renderSessionRow useCallback — not injectable |
| Search / filter | Built-in search bar with client-side text matching |
| Session groups | SessionGroupSection component with 6 preset colors + custom hex |
| Workspace sections | WorkspaceSection per daemon workspace, not replaceable |
| Add workspace dialog | Built-in AddWorkspaceDialog |
⑤ Resize handle
- Drag handle on the right edge for resizing sidebar width
- Width is persisted in localStorage as the total sidebar width. Defaults are 300px Home-only and 356px with the rail. Restored widths are clamped to at least 220px Home-only or 276px with the rail; old values are not blindly increased by 56px. Dragging below the collapse threshold still folds the sidebar.
- The environment panel's dock breakpoint ignores the rail's 56px (the message area yields them), so the panel keeps docking at the same window widths as a Home-only sidebar — e.g. a 1440px window.
- Not configurable
Runtime behavior props
These WebShellProps affect sidebar behavior indirectly:
| Prop | Effect |
|---|---|
onNewSession |
Override the new-session handler |
onLoadSession |
Override session loading logic |
onSessionIdChange |
React to session switches |
splitSessionIds |
Control split-view sessions externally |
theme / onThemeChange |
Control / observe theme |
language / onLanguageChange |
Control / observe UI language |
Collapsed and mobile states
| State | Behavior |
|---|---|
| Expanded | Full sidebar with text labels |
| Collapsed | Home-only: existing 56px strip and hover sessions; with rail: only the 56px primary rail |
| Mobile | Drawer uses 70% of its container, within width limits, with backdrop and a close control at the owning column bottom |
Collapse state is persisted in localStorage under the key
qwen-code-web-shell-sidebar-collapsed.
The sidebar, its compact drawer, and the empty-chat welcome chrome follow the shell container width (compact at 760px); chat message content keeps viewport-based breakpoints. Split availability uses 1024px and split-sidebar room uses 1200px. Keyboard collapse moves focus out of the hidden secondary column to the rail control.
The resized desktop width is restored only in expanded layouts. Opening or closing the mobile drawer does not overwrite that width or the persisted desktop collapse preference.
Source locations
| Component | File |
|---|---|
| WebShellSidebar | packages/web-shell/client/components/sidebar/WebShellSidebar.tsx |
| SessionGroupSection | packages/web-shell/client/components/sidebar/SessionGroupSection.tsx |
| WorkspaceSection | packages/web-shell/client/components/sidebar/WorkspaceSection.tsx |
| Sidebar styles | packages/web-shell/client/components/sidebar/WebShellSidebar.module.css |
| App integration | packages/web-shell/client/App.tsx (search WebShellSidebar) |
| Entry point (dev) | packages/web-shell/client/main.tsx (explicit primary and footer item lists) |