* feat(web-shell): add split pane header action slot with overflow
Let hosts render per-session actions in each split pane header, collapsing them into a … menu when the pane is too narrow.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(web-shell): add pane header actions PR screenshots
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web-shell): tighten pane header overflow measurement
Drop the per-render children effect dependency that rebuilt ResizeObserver during streaming, and reserve workspace-tag width when computing available header space.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web-shell): address pane header overflow review blockers
Mount host actions in only one tree, and wrap overflow entries as DropdownMenuItems so Radix selection and keyboard navigation work.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web-shell): keep pane header actions alive across overflow
Flatten Fragment host actions before building the overflow menu, and keep the same host instances mounted when collapsing so stateful actions are not reset.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web-shell): address pane header overflow review suggestions (#7808)
* fix(web-shell): proxy overflow clicks via action slots
Wrap host pane actions in stable slots so the overflow menu can activate interactive descendants without requiring opaque custom components to forward internal data attributes.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web-shell): address overflow menu review suggestions (#7808)
* fix(web-shell): harden pane header overflow actions (#7808)
Restore the 8px gap between the built-in maximize/close controls, ignore
aria-hidden glyphs when labelling overflow items, omit non-interactive
children from the overflow menu, and document the popover constraint on
renderHeaderActions. Refreshes the design doc to match the mount-once
implementation.
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com>
* refactor(web-shell): drop redundant primary-workspace label
The workspace selector in the composer already marks the default target
with its own checkmark, so appending "· Primary" to the primary entry's
name carried no extra information. Remove that tag everywhere it showed:
- composer selector: trigger, tooltip, and dropdown list
- sidebar workspace header badge (also lets the name show untruncated)
- session overview / split-view picker badges — the primary now shows
its folder basename, consistent with the other workspaces
- scheduled-tasks dialog workspace labels
Delete the now-unused i18n keys (sidebar.workspacePrimary,
scheduledTasks.workspacePrimaryTag; en + zh) and update the two tests
that asserted the old tag.
* refactor(web-shell): reuse workspaceBasename + cover primary-badge removal
Address /review suggestions on the primary-workspace-label cleanup:
- ScheduledTasksDialog's local workspaceLabel() is now functionally identical to the shared workspaceBasename() util (both return the cwd's last path segment), so reuse the util and delete the duplicate.
- Add a WebShellSidebar test asserting the primary workspace header no longer renders a "Primary" badge, so a regression re-adding it fails.
* test(web-shell): assert SplitView primary picker item has no "Primary" tag
Covers the fourth /review suggestion (terminal-only): the multi-workspace picker test now asserts primary-workspace sessions render their basename, not the removed "Primary" label.
* test(web-shell): assert scheduled-tasks picker option text drops (primary)
Covers the re-review suggestion: the workspace <select> picker options were checked for count and value but not visible text, so a regression re-adding a "(primary)" suffix to the primary option would pass undetected. Assert the option labels are the bare basenames.
---------
Co-authored-by: wenshao <wenshao@example.com>
* feat(web-shell): maximize a single split pane
Add a per-pane maximize/restore toggle to the split view. Clicking it makes
one pane fill the whole split and hides the others; the hidden panes stay
mounted so their sessions keep streaming (a purely visual solo). Restore via
the header button or Escape — Escape defers to the composer, the add-session
picker, and open dialogs so it never steals their key.
The toggle only appears with 2+ panes, adding a session exits maximize to
reveal the new pane, and the maximize is dropped whenever its pane leaves the
set or the split shrinks to a single pane.
* refactor(web-shell): use lucide icons for the maximize toggle; cover switch + picker-Escape
Address review on #6951:
- Swap the hand-written Maximize2/Minimize2 SVG paths for the named lucide-react
components, per the web-shell icon convention (README) and matching DialogShell.
- Add tests for moving maximize between panes (guards the toggle's switch branch)
and for Escape closing the add-session picker without un-maximizing (guards the
pickerOpen deferral).
---------
Co-authored-by: wenshao <wenshao@example.com>
* fix(web-shell): surface cross-workspace sessions in split view & overview
The split-view "add session" picker and the Session Overview only listed the
primary workspace's sessions, so on a multi-workspace daemon
(`qwen serve --workspace A --workspace B …`) sessions in non-primary
workspaces could not be picked into a split pane or triaged from the overview
— even though the sidebar already lists them and the load path already drives
them.
Both surfaces now merge the primary workspace's sessions with the live
sessions of every other trusted workspace, label each session by its
workspace, and — in the split view — attach each pane under its session's own
workspace so a non-primary session no longer 409s against the primary cwd. On
a single-workspace daemon the behavior is unchanged.
- add useOtherWorkspaceSessions: fans out listWorkspaceSessions over the
non-primary trusted workspaces in capabilities.workspaces (Promise.allSettled,
tolerant of one failing), and empty on a single-workspace daemon
- SplitView: merge the lists, tag picker items by workspace, and pass each
pane's workspaceCwd to its DaemonSessionProvider
- SessionOverviewPanel: merge the lists and add a per-card workspace badge
- add utils/workspace helpers, plus unit tests across all four surfaces
* fix(web-shell): repair stale composerTagIcons import that broke the build
ScheduledTasksDialog imported getComposerTagIconUrl from `../composerTagIcons`,
a module deleted when the helper was consolidated into `utils/composerTag.ts`.
The stale path failed `vite build` (and the web-shell browser-regression e2e).
Point it at the current location.
* fix(web-shell): address review feedback for cross-workspace sessions
- useOtherWorkspaceSessions: return the shared EMPTY sentinel when every
workspace responds empty (skip a no-op re-render), guard the load effect
against a stale in-flight fetch overwriting a newer one, and keep the
single-workspace path fully synchronous (no fetch, no post-render setState)
- fetch non-primary workspaces at the primary list's page size so a busy
workspace is not truncated at the daemon's smaller default
- split picker: tag primary sessions with the same localized "primary" label
the Session Overview uses, so both surfaces read consistently
- tests: cover reload(), the poll re-query, empty-primary merge, and the
primary badge render; quieten the act() warnings
* feat(web-shell): show each split pane's workspace in its composer placeholder
In a multi-workspace split, once a session becomes a pane there was nothing
indicating which workspace it belongs to. Name the pane's own workspace in the
composer placeholder ("Message this session in <workspace>…"), so it's clear
which workspace a message is going to before you send it. Single-workspace
daemons are unchanged.
* Revert "feat(web-shell): show each split pane's workspace in its composer placeholder"
This reverts commit c58e667127.
* feat(web-shell): label each split pane's workspace in its composer toolbar
On a multi-workspace daemon, split-view panes can hold sessions from
different workspaces, but nothing showed which workspace a pane's message
would go to. Add a compact, non-interactive workspace chip to the pane
composer toolbar (next to where the git-branch chip sits), mirroring
GitBranchIndicator.
The chip renders only on a multi-workspace daemon, is fed each pane's
workspace explicitly by the split view (which knows it per session), and
keeps its label visible as panes narrow — only tightening and truncating
rather than collapsing to an icon — since it is the pane's identity.
* feat(web-shell): show the workspace chip in the main composer too
Extend the per-pane workspace label to the main (single-session) chat
composer: on a multi-workspace daemon the composer toolbar now names the
current session's workspace, so it is always clear which workspace a
message targets — not only in split view.
Place the workspace chip before the git-branch chip. Reuses the existing
WorkspaceIndicator and `workspace` toolbar action, fed from the active
connection's capabilities and workspace cwd.
* fix(web-shell): keep a session when a shrink closes the split view
When the viewport shrinks below the large-screen breakpoint the split
view auto-closes and folds back to the single chat. If that chat had no
session of its own — the common case when the split was opened straight
from the Session Overview or a `?split=a,b` link — the user was stranded
on an empty "new chat". Fall back to the split's first pane instead.
Best-effort and gated to the uncontrolled (standalone) split: a load
failure (e.g. a non-primary-workspace session the single connection
cannot own) simply leaves the empty chat, i.e. the previous behavior.
* fix(web-shell): restore the split view when the screen grows back
A shrink below the large-screen breakpoint folds the split view down to
the single chat (its entry points are hidden on small screens). That fold
used to be permanent — widening the window back left the user on a single
chat with their panes gone. Fold it away only temporarily instead:
remember that a shrink folded it, and restore the same split once the
screen grows back past the breakpoint, so a transient resize is lossless.
Standalone/uncontrolled split only; a controlled host still owns its own
split lifecycle. While folded, the narrow chat still falls back to the
split's first pane so it isn't an empty new chat.
* fix(web-shell): keep the chat's git branch when folding the split on shrink
Folding the split on a shrink no longer switches the single chat's
session. The previous shrink-time loadSession(firstPane) re-pointed the
main connection at the split's first pane; when that pane lived in a
different (e.g. git-less) workspace it wiped the chat's git branch — and
more broadly changed the session/URL the user drops back to. Fold the
split away without touching the chat's connection, so its session, git
branch and URL are exactly what they were once the screen grows back.
* feat(web-shell): auto-collapse the sidebar in a narrow split view
In split view the session sidebar competes with the panes for width. Below
1200px it now auto-collapses to its icon rail so the panes get the room,
and expands again once the window grows back — the session list and the
"New chat" label no longer eat space a narrow split needs. A wide split
(>= 1200px) keeps the full sidebar and the user's own collapse preference;
nothing changes outside split view.
* test(web-shell): cover the cross-workspace fetch race, quiet split-picker act() warnings
Address review suggestions on the cross-workspace session listing:
- useOtherWorkspaceSessions: add a test that a stale in-flight fetch is
discarded by the `cancelled` guard when the target workspace set changes
mid-flight (a workspace registered/unregistered while a list is loading).
- SplitView: flush after opening the picker so the reload()/reloadOther()
the picker-open effect fires no longer leak act() warnings in the two
cross-workspace tests.
* test(web-shell): use a valid DaemonMode and assert the pane workspace prop
Two review suggestions on the cross-workspace tests:
- workspace.test.ts: the caps() mock used `mode: 'workspace'`, not a valid
DaemonMode (`'http-bridge' | 'native'`) — use `'native'`.
- SplitView.test.tsx: the ChatPane mock now captures `workspaceCwd`, and the
cross-workspace attach test asserts the pane receives it (for the composer
chip) — so dropping that prop pass-through would now fail the test rather
than only the provider's `data-workspace`.
* perf(web-shell): memoize pane toolbar actions; cover chip tooltip fallback & deep-link remount
Address review suggestions on the composer workspace chip:
- ChatPane: memoize `paneToolbarActions` so its array identity is stable —
ChatEditor is `React.memo`, and a fresh `[...]` each render defeated it,
re-rendering the composer on every pane render.
- ChatEditor.test: cover the `workspaceTitle ?? workspaceName` tooltip
fallback (chip rendered with a name but no explicit title).
- SplitView.test: cover the deep-link remount path — a pane mounted before
the other-workspace fan-out resolves remounts under its own workspace once
`workspaceCwdById` populates (pane key `b1:` → `b1:/wsB`).
---------
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
* fix(web-shell): remove meaningless "current" badge from split-view panes
In the split view every pane is an equal, independently interactive session (its own DaemonSessionProvider, SSE, transcript, and approvals), so tagging one pane as the workspace's "current" session carried no operational meaning. It only leaked a single-view concept into a peer-of-equals layout and reliably prompted "what is this?" questions from users. Panes are already identified by their titles.
Drop the isCurrent badge and its border highlight from ChatPane, and stop passing isCurrent from SplitView. The sidebar and session overview keep their "current" indicators, which are legitimate "you are here" navigation. currentSessionId is retained only to seed the initial pane.
* fix(web-shell): clear the split-view composer on send, not at turn end
A split-view pane kept the just-sent text sitting in its composer until the whole turn finished. handleSubmit committed the draft on the sendPrompt promise resolving, but that promise resolves via waitForAcceptedPromptCompletion (turn end), not at admission. Switch to the onAdmitted hook so the composer clears the moment the daemon accepts the prompt, matching the main view. A prompt rejected before admission still preserves the draft and surfaces the error.
* fix(web-shell): pass commitAccepted directly as onAdmitted; cover admit-then-fail
Review follow-up:
- commitAccepted is already `() => void`, so pass it directly as the onAdmitted option instead of wrapping it in a redundant `() => commitAccepted?.()` closure.
- Add a test for the turn failing after admission: the draft stays cleared (no second commit) and the error is still surfaced to onError.
* fix(web-shell): keep split-view session list fresh and preserve panes across view switches
The in-window split view's "add pane" picker read a stale session snapshot —
`useSessions` only fetches on mount — so sessions created after entering the
split never appeared. And switching away from the split and back cleared the
panes, because the live pane set lived in local state that died on unmount while
the seed it re-mounted from was never updated (and the no-arg "Open Split View"
button reset it to empty).
- Reload the picker list when it opens and when the parent's session-list reload
token changes, so it never offers a removed session or misses a new one.
- Mirror the live pane set up to the app via onPanesChange so it survives
SplitView unmounting; restore it (instead of reseeding empty) when the split is
reopened without an explicit selection.
* test(web-shell): cover split-view refresh/restore per review; coalesce token reloads
Addresses review feedback on #6418:
- SplitView: skip a token-driven reload while one is already in flight, so a
burst of session-list changes (bulk create/delete) doesn't fire a redundant
concurrent round-trip per bump (matches the sidebar's poll guard).
- SplitView test: the freshness test now proves the picker re-renders with the
refreshed list — a session appearing only after reload shows up — not just
that reload() was called.
- App test: cover the openSplitView preserve/restore path end-to-end — a reported
pane set survives leaving the split and is restored on reopen.
* fix(web-shell): reload split picker on every token bump (drop in-flight guard)
The in-flight guard added in the previous commit could drop a session-list
reload token that arrives while a reload is still running: the effect has
already run for that token value, and clearing the in-flight flag in `finally`
doesn't re-run it, so the picker could stay stale after burst create/delete/
rename activity — and the split has no polling fallback to recover.
Reload on every distinct token bump instead. `useDaemonResource` serializes
responses via its sequence counter (last write wins), so overlapping reloads are
correct, and the token is bumped only on discrete session-change events — an
occasional redundant fetch is far cheaper than a lost refresh.
* test(web-shell): cover openSplitView explicit-selection branch (dedupe + cap)
Per review: the restore branch of openSplitView was covered but the
explicit-selection branch (dedupe + MAX_SPLIT_PANES cap, replacing any prior
set) was only exercised, not asserted. Add a `?split=` URL test with duplicate
and over-cap ids that asserts the split seeds exactly the deduped, capped
selection.
* feat(web-shell): add Session Overview panel and in-window split view
Add a large-screen "Session Overview" mission-control panel and an
in-window split view so users can monitor and drive multiple daemon
sessions at once.
- SessionOverviewPanel: ranked live cards (needs-approval -> running ->
idle) merging the workspace session list with the detail=full status
report. Multi-select opens the selected sessions as a split view in
the current tab ("Open in split") or in a new browser tab ("Open in
new tab", via a ?split=a,b URL).
- SplitView + ChatPane: one DaemonWorkspaceProvider hosting N
DaemonSessionProvider panes, each a self-contained interactive chat
(transcript, composer, streaming, tool/ask approvals). Browser focus
scopes the keyboard per pane, so panes never contend over approvals.
- Sidebar entry points gated to large screens; the split view's Back
returns to the Session Overview.
* refactor(web-shell): address review feedback on the session overview / split view
- SessionOverviewPanel: prune the selection Set when a session leaves the list
(so a reappearing session isn't silently reselected) and make select-all use
the intersection rather than prev.size.
- Extract isAskUserPermission into a shared util so App.tsx and ChatPane.tsx no
longer keep verbatim copies that can drift.
- SplitView: dismiss the "add session" picker on Escape or a click outside it.
- Tests: MAX_PANES cap, popup-blocked path, checkbox-selects-without-navigating,
stale-selection pruning, and a direct test for the extracted util.
* fix(web-shell): address /review findings on the split view
- ToolApproval: add a `keyboardActive` prop; split panes pass false so global
Enter/Escape/digit shortcuts can't confirm the wrong session's approval, and
the outer session's approval overlay is no longer rendered behind the split
(where it would keep its global shortcuts while hidden).
- ChatPane: defer the composer commit until sendPrompt resolves, so a rejected
prompt (transcript loading / disconnected / turn active) preserves the draft
instead of silently dropping it.
- SplitView: include a per-mount nonce in each pane's clientId so two tabs
opening the same split don't share a client id — which suppressOwnUserEcho
would treat as a self-echo and drop from the transcript.
- SessionOverviewPanel: cap the split selection to MAX_SPLIT_PANES before
building the ?split= URL or opening the in-window split, with a hint when more
are selected; also dismiss the split picker on Escape / click-outside.
- Tests covering each.
* fix(web-shell): address second /review round on the split view
- SplitView: wrap each pane in its own ErrorBoundary, so a render crash in one
pane (malformed block, unexpected tool shape) shows an inline fallback with a
close action instead of white-screening the whole split.
- splitUrl / overview: carry the daemon token into the new-tab split URL's
fragment. The current tab has already stripped the token from its URL, so a
token-auth (`serve --open`) deployment would otherwise open the split tab
unauthenticated. The token rides the hash (never sent to the server / logs).
- Tests: per-pane error isolation, token-in-fragment (and none without a token),
and the overview polling effects (interval fires, document.hidden skips, and
the in-flight guard prevents overlapping polls).
* fix(web-shell): hide the outer chat under the split and share app-level contexts
- App: hide (display:none) + aria-hide the outer chat subtree whenever
mainView !== 'chat', not only when a panel is open. Previously the outer
chat/composer/toolbar stayed reachable by keyboard/AT behind the full-page
split (it was only covered visually). State is preserved (node stays mounted).
- App: wrap SplitView in the app-level WebShellCustomizationProvider and
CompactModeContext so split panes render markdown / tool-headers / thinking
the same way the single-session chat does. Todo contexts stay chat-only —
they belong to the outer session, not the panes.
* refactor(web-shell): address review suggestions — coverage, dedup, split UX
- ToolApproval: add a dedicated test on the real component that the global
keyboard shortcut is armed by default and NOT armed when keyboardActive=false
(the cross-pane approval safety mechanism).
- SplitView: auto-exit to the Session Overview when the last pane is closed
(guarded so an initial empty seed doesn't bounce straight back out).
- ChatPane: add tests for the cancel action, the empty/whitespace submit guard,
and error routing to the onError prop.
- Extract the shared session-list page size + organization feature flag into
constants/sessions.ts, used by the overview, split view, and sidebar, so the
values can't drift between the three.
* fix(web-shell): surface outer approval + failed refresh in overview/split
- Split view: when the outer (main) session is waiting on an approval
that's hidden behind the split, show a non-blocking notice banner with
a "Go to it" button that returns to the chat where the approval lives.
- Auto-close the split (like the overview panel) when the viewport shrinks
below the large-screen breakpoint, so users aren't stranded.
- Session Overview: surface a failed refresh inline (keeping the last-good
cards) instead of silently swallowing it once cards are on screen.
- Tests: status-report poll cadence, picker dismiss (Escape / outside /
inside click), inline refresh-failure banner.
* fix(web-shell): sever window.opener on split tab; tighten hidden-chat test
- openSelectedInNewTab now clears win.opener (the split tab carries a
daemon token in its URL fragment) to prevent reverse tabnabbing, matching
the existing bug-report window.open path.
- Strengthen the split-view App test so a missing outer-chat subtree fails
instead of passing vacuously through an optional chain.
* fix(web-shell): split-view focus/stability/robustness follow-ups
- Refocus the composer after a shrink-driven split close so keyboard users
aren't dropped onto <body> (skips when an approval or panel takes over).
- Stabilize SplitView onExit via useCallback so its last-pane-close effect
doesn't re-fire on every App re-render.
- ChatPane: surface a per-pane connection-loss banner instead of silently
showing stale messages when a pane's daemon connection drops.
- ChatPane: anchor the streaming timer to the active turn's start (last user
message timestamp) so a pane opened mid-turn shows real elapsed time.
- Tests: split auto-close on shrink, outer-approval split notice + return-to-
chat, connection banner, and streaming-timer anchoring.