feat(web-shell): flesh out the multi-workspace split view (cross-workspace sessions, workspace labels, responsive layout) - #6746
Conversation
…view 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
|
Thanks for the PR! (re-run on latest commits — assessment unchanged) Template looks good ✓ Problem: observed gap, not theoretical. On a multi-workspace daemon, split view and session overview only show primary-workspace sessions while the sidebar (#6705) and daemon (#6737) already support cross-workspace sessions. The 409 Direction: aligned. This completes the multi-workspace UX already partially shipped — sidebar lists them, daemon supports them, but two remaining surfaces were left behind. Natural catch-up work. Size: not applicable — all 19 files are in Approach: the scope feels right. Four focused pieces — cross-workspace session listing, workspace labels in the composer, lossless split fold/restore on resize, sidebar auto-collapse in narrow splits. The Moving on to code review. 🔍 中文说明感谢贡献!(re-run 更新——评估不变) 模板完整 ✓ 问题:已观测到的差距,非理论性。多 workspace daemon 下,分屏和会话总览只显示主 workspace 的会话,而侧边栏(#6705)和 daemon(#6737)已支持跨 workspace 会话。409 方向:对齐。多 workspace 体验已部分上线,本 PR 补全剩余两个界面。 规模:不适用——全部 19 个文件在 方案:范围合理。四个聚焦部分——跨 workspace 会话列表、输入框 workspace 标签、无损折叠/恢复、窄分屏侧边栏自动折叠。hook 被两个消费者共用, 进入代码审查 🔍 — Qwen Code · qwen3.7-max |
Code ReviewIndependent proposal before reading the diff: a hook that fans out No critical blockers found. Reuse check: Implementation highlights: The TestingUnit tests (182 tests across 7 files)Coverage includes: single-workspace no-op, multi-workspace fan-out, TypeScript type checkVite buildBuild passes cleanly. Real-scenario testingNot applicable — this is a web-shell React UI feature requiring a running multi-workspace daemon and browser to drive. Tmux-based CLI testing cannot exercise React components rendered in a browser. The PR provides Playwright screenshots as evidence. 中文说明代码审查阅读 diff 前独立方案与 PR 实现几乎完全一致——自然、最小方案。未发现关键阻断问题。 复用检查: 浏览器端无 Node 实现亮点: hook 的 测试全部 182 个测试通过(7 个文件)。TypeScript 类型检查通过。Vite 构建通过。 真实场景测试不适用——web-shell React UI 功能需要浏览器,tmux 无法驱动。 — Qwen Code · qwen3.7-max |
|
Clean, focused feature-completion work. The multi-workspace daemon plumbing already landed in the sidebar (#6705) and daemon (#6737); this PR catches up the two remaining surfaces — split view picker and session overview — that were still blind to non-primary workspace sessions. My independent proposal matched the PR's approach: a hook fanning out Since the last review: What works well: the single-workspace daemon is a strict no-op (stable empty reference, no daemon call, no UI change). 182 unit tests pass (5 more than the last review), TypeScript type check is clean, and Approving. ✅ 中文说明干净、专注的功能补全。多 workspace daemon 底层能力已在侧边栏(#6705)和 daemon(#6737)上线;本 PR 补上了最后两个还对非主 workspace 会话视而不见的界面。 我独立的方案与 PR 方法一致。实现是自然的、最小的路径。 上次审阅后: 亮点:单 workspace daemon 下严格零操作; 182 个单元测试通过(比上次多 5 个),TypeScript 类型检查通过, 通过。✅ — Qwen Code · qwen3.7-max |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
doudouOUC
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
…uild 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.
- 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
|
Thanks for the thorough review! Addressed across two follow-up commits. Build fix (this was reding CI, pre-existing on Code suggestions:
Test coverage:
Not done, non-blocking (happy to add if you'd like): a dedicated pane-remount coverage test, and factoring the duplicated 中文说明感谢细致的审阅!已在两个后续 commit 中处理:
|
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
…ceholder
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.
…oser placeholder" This reverts commit c58e667.
…verview-cross-workspace
…olbar 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.
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.
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.
…verview-cross-workspace # Conflicts: # packages/web-shell/client/components/ChatEditor.module.css
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.
…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.
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.
…cker 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.
|
Thanks for the review — went through every inline suggestion. Status: Already addressed in the branch (the earlier "address review feedback" commit / current code):
Handled just now (
The multi-workspace pane binding (the |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
… 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`.
wenshao
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
ytahdn
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
| return merged.length === 0 ? EMPTY : merged; | ||
| }, [client, targetsKey]); | ||
|
|
||
| const reload = useCallback(async () => { |
There was a problem hiding this comment.
[Suggestion] reload() lacks the stale-response guard that useEffect (below) implements via cancelled. When targetsKey changes mid-flight (a workspace is registered/unregistered), an in-progress reload() — using the old targetsKey — can resolve after the effect's new fetch, overwriting fresh data with stale results.
| const reload = useCallback(async () => { | |
| const seqRef = useRef(0); | |
| const reload = useCallback(async () => { | |
| if (!targetsKey) return; | |
| const seq = ++seqRef.current; | |
| const result = await fetchSessions(); | |
| if (seq === seqRef.current) setSessions(result); | |
| }, [targetsKey, fetchSessions]); |
— qwen3.7-max via Qwen Code /review
…lback & 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`).
ytahdn
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅
— qwen3.7-max via Qwen Code /review
|
@qwen-code /triage |
doudouOUC
left a comment
There was a problem hiding this comment.
整体质量很高、边界克制到位:单 workspace 严格 no-op(targetsKey 为空不请求、mergeSessionsById 按引用返回、每个徽标都以 hasMultipleWorkspaces 为门),测试扎实,CI 全绿。已按最新 head 3f7b67a 逐行核对,基本可以合并——只在 useOtherWorkspaceSessions.ts 的 reload() 留了一条非阻断 inline suggestion(见代码行内评论)。
🟢 Nits(非阻断,供参考)
- 非主 workspace 持续不可达时,每个轮询 tick 都会
console.warn,长时间挂着会刷屏;可考虑去重/节流。 - 多 workspace 深链窗格的 key 从
id:→id:/cwd会 remount 一次(已被测试覆盖,仅提示取舍)。 - PR 描述提到的
ScheduledTasksDialogimport 修复已随 merge 落到 main,不在本 PR 净 diff 内(纯描述层面)。
已核实通过:DaemonSessionProvider 的 workspaceCwd prop、useWorkspace 形状、sidebar.workspacePrimary 的 EN/ZH 双语 key、composer 里 workspace chip 排在 git 分支之前、WorkspaceIndicator 忠实镜像 GitBranchIndicator、primaryCwd 取 capabilities.workspaceCwd(而非跟随当前会话的 connection.workspaceCwd)。核心 triage gate 不适用(改动全在 packages/web-shell/)。
English
Overall a high-quality, well-scoped PR: the single-workspace path is a strict no-op (targetsKey empty → no fetch, mergeSessionsById returns the primary list by reference, every chip is gated behind hasMultipleWorkspaces), tests are solid, CI is green. Reviewed line-by-line against head 3f7b67a. Ready to merge — I left one non-blocking inline suggestion on reload() in useOtherWorkspaceSessions.ts (see the inline comment).
🟢 Nits (non-blocking)
- A permanently-unreachable non-primary workspace logs a
console.warnon every poll tick — noisy over time; consider dedupe/throttle. - A deep-linked multi-workspace pane remounts once as its key flips
id:→id:/cwd(covered by a test; just flagging the trade-off). - The
ScheduledTasksDialogimport fix mentioned in the description already landed on main via merge and isn't in this PR's net diff (description-only).
Verified OK: DaemonSessionProvider's workspaceCwd prop, the useWorkspace shape, the sidebar.workspacePrimary EN/ZH keys, the workspace chip ordering before the git branch, WorkspaceIndicator faithfully mirroring GitBranchIndicator, and primaryCwd reading capabilities.workspaceCwd (not the session-following connection.workspaceCwd). The core triage gate doesn't apply (all changes are in packages/web-shell/).
| const reload = useCallback(async () => { | ||
| // Nothing to reload on a single-workspace daemon — return synchronously so | ||
| // callers polling `reloadOther()` don't trigger a no-op async state update. | ||
| if (!targetsKey) return; | ||
| setSessions(await fetchSessions()); | ||
| }, [targetsKey, fetchSessions]); |
There was a problem hiding this comment.
[Suggestion] reload() 缺少 useEffect 里那套 stale-response 守卫(cancelled)。当 reloadOther()(轮询 Promise.all([reload(), reloadOther()]) 或 picker 打开触发)在飞行中、且此刻 targetsKey 变化(workspace 注册/注销)时,持旧 targetsKey 的 reload() 可能在 effect 的新请求之后返回,把新数据覆盖回旧数据。触发窄且下个轮询周期自愈,故非阻断;但与 @ytahdn 之前在此处的意见一致,当前 head 仍未处理。
可选修复(序号守卫)——在 hook 顶部加 const reloadSeqRef = useRef(0);,然后:
const reload = useCallback(async () => {
if (!targetsKey) return;
const seq = ++reloadSeqRef.current;
const next = await fetchSessions();
if (seq === reloadSeqRef.current) setSessions(next);
}, [targetsKey, fetchSessions]);(若要与 effect 也彻底“最新者胜”,可让二者共用同一序号源。)或者在 PR 里显式记为可接受风险,以关闭该 thread。
English
[Suggestion] reload() lacks the stale-response guard the useEffect below has (cancelled). While a reloadOther() is in flight (poll Promise.all([reload(), reloadOther()]) or picker-open) and targetsKey changes (a workspace is registered/unregistered), a reload() bound to the old targetsKey can resolve after the effect's fresh fetch and overwrite the new data with stale results. The trigger is narrow and it self-heals on the next poll, so it's non-blocking — but it matches @ytahdn's earlier note here and is still unaddressed on the current head.
Optional fix (a sequence guard) — add const reloadSeqRef = useRef(0); at the top of the hook, then:
const reload = useCallback(async () => {
if (!targetsKey) return;
const seq = ++reloadSeqRef.current;
const next = await fetchSessions();
if (seq === reloadSeqRef.current) setSessions(next);
}, [targetsKey, fetchSessions]);(For a true "latest wins" across the effect too, share one sequence source.) Or explicitly record it as accepted risk to close the thread.
There was a problem hiding this comment.
收尾确认(新 head 37f12f3):自我这条评论以来,PR 自身净改动逐字未变;唯一变化是一次 main merge —— ChatEditor.tsx 的 import 冲突已正确解决(WorkspaceIndicator / GitBranchIndicator 两个 import 都在,workspace chip 仍渲染在 git 分支之前),CI 健康(ubuntu Test 通过)。
本条 reload() race 仍未处理,但与 @ytahdn 的判断一致,属非阻断(触发窄、下个轮询周期自愈)。因此这条不再阻塞——已 approve。建议后续要么加 reloadSeqRef 序号守卫,要么在注释里显式记为"已知的自愈风险",把这个 thread 关掉。
English
Closing note (new head 37f12f3): since this comment, the PR's own net changes are byte-for-byte unchanged; the only delta is one main merge — the ChatEditor.tsx import conflict was resolved correctly (both WorkspaceIndicator / GitBranchIndicator imports present, the workspace chip still renders before the git branch) and CI is healthy (ubuntu Test passing).
This reload() race is still open, but — agreeing with @ytahdn — it's non-blocking (narrow trigger, self-heals on the next poll). So it isn't a blocker; approving. Follow-up: either add a reloadSeqRef sequence guard, or record it in a comment as a known self-healing risk to close this thread.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
|
@qwen-code /resolve |
Keep both the WorkspaceIndicator import (from the PR branch) and the toolbarDropdown utility imports (from origin/main) since both are used in the file.
|
Qwen Code resolved the merge conflicts and pushed the branch update. Merge conflict resolution summary — PR #6746ConflictFile: The conflict was in the import block at the top of the file:
ResolutionKept both imports. Each side introduced an independent, non-overlapping import that is actively used elsewhere in the file:
Both source modules ( Other auto-merged files (no conflicts)Git cleanly auto-merged changes in |
37f12f3
|
@qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
doudouOUC
left a comment
There was a problem hiding this comment.
Approving. 已按最新 head 37f12f3 逐行复核。
- 单 workspace 严格 no-op(
targetsKey为空不请求、mergeSessionsById按引用返回、每个徽标以hasMultipleWorkspaces为门),跨 workspace fan-out /Promise.allSettled容错 / 按 workspace 挂载均有测试覆盖,CI 健康。 - 本轮的
mainmerge 未改变 PR 自身净改动;ChatEditor.tsx的 import 冲突解决正确(chip 顺序、门控完好)。 - 唯一遗留:
useOtherWorkspaceSessions.ts的reload()stale-response race —— 非阻断(与 @ytahdn 一致,见该行 thread),建议后续处理或显式记录即可。
English
Approving. Reviewed line-by-line against head 37f12f3.
- Single-workspace path is a strict no-op (
targetsKeyempty → no fetch,mergeSessionsByIdreturns the primary list by reference, every chip gated behindhasMultipleWorkspaces); cross-workspace fan-out,Promise.allSettledtolerance, and workspace-aware pane attachment are all tested; CI is healthy. - This round's
mainmerge left the PR's own net changes unchanged; theChatEditor.tsximport conflict was resolved correctly (chip order and gating intact). - Sole leftover: the
reload()stale-response race inuseOtherWorkspaceSessions.ts— non-blocking (agreeing with @ytahdn; see the inline thread), fine to address or explicitly record as a follow-up.
…e split (QwenLM#7000) When the viewport shrinks below the large-screen breakpoint the split view folds back to the single chat. If that chat had no session of its own — the common case when the split was entered from the Session Overview or a `?split=a,b` link — the user was stranded on an empty "new chat" instead of the split's first (leftmost) pane. This restores a fallback that was tried and then superseded during QwenLM#6746: the fold-time re-point to the first pane was dropped because it wiped the git branch and changed the session+URL of a chat that already had a session. Re-add it but guarded on the *empty* chat (`!currentSessionId`) — an empty chat has no git branch or session to preserve — so it fixes the empty-new-chat case without regressing the branch-preservation case. Best-effort and standalone-split-only, as before. Covered by two tests: lands on the first pane when the outer chat is empty, and keeps the existing session (no re-point) when it isn't. Co-authored-by: wenshao <wenshao@example.com>
What this PR does
Rounds out the web-shell's multi-workspace Split View and Session Overview, and makes the split view hold up as the window resizes. Four related pieces:
Cross-workspace session listing. The Split View "add session" picker and the Session Overview now list sessions from every trusted workspace, not just the primary. Each session is tagged with its workspace (the primary reads
primary, others read the workspace's name), and each split pane attaches under its own session's workspace so a non-primary session no longer fails with409 session_workspace_conflict. A newuseOtherWorkspaceSessionshook fans outlistWorkspaceSessionsacross the non-primary, trusted entries incapabilities.workspaceswithPromise.allSettled(one workspace failing drops only its own rows) and returns a stable empty list on a single-workspace daemon — so this piece is a strict no-op there.Workspace labels in the composer. On a multi-workspace daemon, each split pane's composer toolbar — and the single chat's composer — shows a compact chip naming the workspace its session lives in. It mirrors the existing git-branch chip and sits just before it, so it's always clear which workspace a message targets. In a narrow pane the label truncates rather than collapsing to a bare icon, since it is the pane's identity.
Lossless split fold/restore on resize. The split view is a large-screen-only surface (≥ 1024px). Shrinking below that now folds it down to the single chat instead of discarding it, and growing back past the breakpoint restores the same split — a transient resize is lossless. Folding no longer touches the chat's own connection, so its session, git branch and URL are exactly what they were once the split comes back.
Sidebar auto-collapse in a narrow split. Below 1200px the split view's session sidebar auto-collapses to its icon rail so the panes get the width (the "New chat" label and session list stop eating space a narrow split needs); it expands again when the window grows back. A wide split keeps the full sidebar and the user's own collapse preference.
This PR also repairs a pre-existing stale import (
ScheduledTasksDialogimportedgetComposerTagIconUrlfrom a deleted../composerTagIconsmodule) that was failingvite build.Why it's needed
On a multi-workspace daemon (
qwen serve --workspace A --workspace B …, where the first is the primary), the split picker and overview only ever showed the primary workspace's sessions, and nothing indicated which workspace a pane — or the main chat — targeted. Separately, the split view dropped the user's panes on any window shrink and kept a full-width sidebar even when the panes needed the room. This completes the multi-workspace UX (the sidebar #6705 and the daemon #6737 already handle their side) and makes the split view behave sensibly under resize.Reviewer Test Plan
How to verify
The cross-workspace listing is covered by unit tests (mocked daemon) for the single-workspace no-op, cross-workspace fan-out,
Promise.allSettledtolerance, workspace-aware pane attachment, badge rendering, and the utilities:The composer labels and the resize behavior add tests in:
client/components/ChatEditor.test.tsx— theworkspacetoolbar chip renders with its label/tooltip, is hidden without a name or action, and is ordered before the git-branch chip.client/components/ChatPane.test.tsx— the pane chip appears only on a multi-workspace daemon.client/App.test.tsx— a shrink folds the split without switching the chat's session (preserving its git branch / URL); growing back restores the same split; and the sidebar auto-collapses in a narrow split and expands when wide.Manually: start a daemon on two workspaces (first is primary), e.g.
qwen serve --workspace /path/primary --workspace /path/other, create a session in each, then open the web-shell.primary/ the other workspace's name); adding the non-primary one attaches without a 409.Evidence
Captured by driving the real app against a mock multi-workspace daemon (Playwright). Daemon: a primary
web-shellworkspace and a non-primaryapiworkspace.Cross-workspace listing — the Split View picker and the Overview list sessions from every workspace, each tagged by workspace:
Workspace labels — the single chat's composer names its workspace, placed before the git-branch chip:
Each split pane names its own workspace too (
web-shell/api), staying legible as panes narrow:Sidebar auto-collapse — a wide split keeps the full sidebar; a narrow split (< 1200px) collapses it to the icon rail so the panes get the room:
The lossless fold/restore is a resize behavior (hard to convey in a still) and is covered by the
App.test.tsxtests above and a real-browser Playwright check.Tested on
Environment (optional)
npm run dev(Vite) + Playwright against the web-shell mock daemon; Node 22.Risk & Scope
mergeSessionsByIdreturns the primary list by reference, every new chip is gated behindhasMultipleWorkspaces), by folding the split without mutating the chat's connection, and by computing the sidebar collapse purely from width — all covered by tests.packages/web-shell/.Linked Issues
None. Follow-up to #6705 (multi-workspace sidebar) and #6737 (non-primary session load / model / approval).
中文说明
这个 PR 做了什么
完善 Web Shell 多 workspace 的分屏与会话总览,并让分屏在窗口缩放时表现得当。四个相关部分:
跨 workspace 会话列表。 分屏"添加会话"选择器和会话总览现在列出所有受信任 workspace 的会话,而不只是主 workspace。每个会话按 workspace 打标签(主显示
primary,其它显示 workspace 名),并且每个分屏窗格按其会话自身的 workspace 挂载,非主会话不再返回409 session_workspace_conflict。新增的useOtherWorkspaceSessionshook 用Promise.allSettled对capabilities.workspaces中非主且受信任的项逐个调用listWorkspaceSessions(单个失败只丢它自己的结果),并在单 workspace daemon 下返回稳定的空列表——因此这部分在单 workspace 时是严格零变化。输入框里的 workspace 标签。 在多 workspace daemon 下,每个分屏窗格的输入框工具栏——以及单聊天的输入框——都会显示一个标明其会话所属 workspace 的小徽标。它仿照已有的 git 分支徽标,位于其之前,因此始终清楚消息会发往哪个 workspace。窗格变窄时标签只截断,不塌缩成纯图标(它是窗格的身份)。
分屏在缩放时的无损折叠/恢复。 分屏是仅大屏(≥ 1024px)功能。拉窄到断点以下现在是把分屏折叠成单聊天(而非丢弃),拉宽回到断点以上则恢复同一个分屏——瞬时缩放无损。折叠时不再改动单聊天自身的 connection,所以分屏回来后它的会话、git 分支、URL 与之前完全一致。
窄分屏时侧边栏自动折叠。 窗口 < 1200px 时,分屏的 session 侧边栏自动折叠成图标条,把宽度让给窗格("新对话"文字与会话列表不再占用窄分屏需要的空间);拉宽后自动展开。宽分屏保留完整侧边栏与用户自己的折叠偏好。
本 PR 同时修复了一个既有的失效 import(
ScheduledTasksDialog从已删除的../composerTagIcons引入getComposerTagIconUrl),它此前会导致vite build失败。为什么需要
在多 workspace 的 daemon(
qwen serve --workspace A --workspace B …,第一个为主)下,分屏选择器和总览只显示主 workspace 的会话,也无从得知某个窗格或主聊天指向哪个 workspace。另外,分屏在任何一次拉窄时都会丢掉用户的窗格,而且在窗格需要空间时仍保留整条侧边栏。本改动补齐了多 workspace 体验(侧边栏 #6705、daemon #6737 已处理各自部分),并让分屏在缩放下表现合理。审阅测试计划
如何验证
跨 workspace 列表由单元测试(mock daemon)覆盖:单 workspace 零变化、跨 workspace 并发拉取、
Promise.allSettled容错、按 workspace 挂载窗格、徽标渲染与工具函数:输入框标签与缩放行为的测试:
client/components/ChatEditor.test.tsx——workspace工具栏徽标带标签/提示地渲染、无名称或未开启时隐藏、且排在 git 分支徽标之前。client/components/ChatPane.test.tsx——窗格徽标仅在多 workspace daemon 下出现。client/App.test.tsx——拉窄折叠分屏时不切换单聊天会话(保住其 git 分支 / URL);拉宽恢复同一分屏;窄分屏侧边栏自动折叠、拉宽展开。手动验证:用两个 workspace 启动 daemon(第一个为主),在每个各建一个会话,打开 Web Shell。分屏 → 添加会话会列出两个 workspace 的会话(标注
primary/ 另一个 workspace 名),加非主会话无 409;会话总览两个 workspace 的卡片都在,各带徽标;每个分屏窗格与单聊天输入框都显示 workspace 徽标(在 git 分支之前);把窗口拉窄到 ~1024px 以下分屏折叠成单聊天(会话/分支不变),拉宽分屏恢复;~1024–1200px 之间侧边栏折叠成图标条。单 workspace daemon 下无任何变化。证据
截图取自 Playwright 驱动真实 App 对接 mock 多 workspace daemon(主
web-shell+ 非主api)。截图见上方英文部分:跨 workspace 列表(分屏选择器 / 总览)、输入框 workspace 标签(单聊天在 git 分支前 + 分屏窗格)、侧边栏在宽/窄分屏下的展开 vs 折叠。无损折叠/恢复是缩放行为(静态图难以呈现),由上述App.test.tsx测试与真实浏览器 Playwright 校验覆盖。测试平台
本地在 🍏 macOS 上验证;🪟 Windows / 🐧 Linux 由 CI 覆盖。
环境
npm run dev(Vite)+ Playwright,针对 Web Shell mock daemon;Node 22。风险与范围
mergeSessionsById按引用返回主列表、所有新徽标以hasMultipleWorkspaces为条件)、折叠分屏时不改动单聊天 connection、侧边栏折叠纯按宽度计算——均有测试覆盖。packages/web-shell/。关联 Issue
无。是 #6705(多 workspace 侧边栏)和 #6737(非主会话加载 / 模型 / 审批)的后续。