From f53df3ef2a954952b4c1ef8757aed208df58b061 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 12 Jul 2026 04:00:09 +0800 Subject: [PATCH 01/14] fix(web-shell): surface cross-workspace sessions in split view & overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../SessionOverviewPanel.module.css | 24 +++ .../components/SessionOverviewPanel.test.tsx | 69 +++++++ .../components/SessionOverviewPanel.tsx | 56 +++++- .../client/components/SplitView.module.css | 24 ++- .../client/components/SplitView.test.tsx | 106 ++++++++++- .../web-shell/client/components/SplitView.tsx | 173 ++++++++++++------ .../hooks/useOtherWorkspaceSessions.test.tsx | 131 +++++++++++++ .../client/hooks/useOtherWorkspaceSessions.ts | 94 ++++++++++ .../web-shell/client/utils/workspace.test.ts | 94 ++++++++++ packages/web-shell/client/utils/workspace.ts | 64 +++++++ 10 files changed, 777 insertions(+), 58 deletions(-) create mode 100644 packages/web-shell/client/hooks/useOtherWorkspaceSessions.test.tsx create mode 100644 packages/web-shell/client/hooks/useOtherWorkspaceSessions.ts create mode 100644 packages/web-shell/client/utils/workspace.test.ts create mode 100644 packages/web-shell/client/utils/workspace.ts diff --git a/packages/web-shell/client/components/SessionOverviewPanel.module.css b/packages/web-shell/client/components/SessionOverviewPanel.module.css index 5e74473919d..e786c45ef18 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.module.css +++ b/packages/web-shell/client/components/SessionOverviewPanel.module.css @@ -196,6 +196,30 @@ background: color-mix(in srgb, var(--muted-foreground) 12%, transparent); } +/* Which workspace a session lives in — only shown on a multi-workspace daemon. + The primary reads muted; a non-primary workspace gets an accent so + cross-workspace sessions stand out in the grid. */ +.workspaceBadge { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 1px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + color: var(--muted-foreground); + background: color-mix(in srgb, var(--muted-foreground) 12%, transparent); +} + +.workspaceBadgeOther { + color: var(--primary); + background: color-mix(in srgb, var(--primary) 14%, transparent); +} + .empty { padding: 24px 12px; text-align: center; diff --git a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx index 4ea149e19e1..7c98fa73043 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx @@ -30,6 +30,11 @@ let sessionsState: { let statusState: { report?: { full?: { sessions: DaemonStatusReportSession[] } }; }; +// Live sessions the mock daemon returns per non-primary workspace cwd. +let otherWorkspaceSessions: Record; +// Stable client object (per test) so the other-workspace hook's load callback +// keeps a stable identity and its effect doesn't loop. +let workspaceClient: { listWorkspaceSessions: ReturnType }; const sessionsReload = vi.fn(async () => sessionsState.sessions); const statusReload = vi.fn(async () => statusState.report); @@ -38,6 +43,10 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ useConnection: () => connectionState, useSessions: () => ({ ...sessionsState, reload: sessionsReload }), useStatusReport: () => ({ ...statusState, reload: statusReload }), + useWorkspace: () => ({ + client: workspaceClient, + capabilities: connectionState.capabilities, + }), })); const { SessionOverviewPanel, deriveSessionCards } = await import( @@ -89,6 +98,12 @@ beforeEach(() => { }; sessionsState = { sessions: [], loading: false }; statusState = { report: { full: { sessions: [] } } }; + otherWorkspaceSessions = {}; + workspaceClient = { + listWorkspaceSessions: vi.fn( + async (cwd: string) => otherWorkspaceSessions[cwd] ?? [], + ), + }; sessionsReload.mockClear(); statusReload.mockClear(); onOpenSession = vi.fn(); @@ -127,6 +142,14 @@ function rerender(props: { onOpenSplit?: (ids: string[]) => void } = {}): void { ); } +// Flush the other-workspace hook's async fan-out (Promise.allSettled + setState). +async function flushAsync(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + function cardLabels(): string[] { return Array.from(container!.querySelectorAll('ul li')).map( (li) => li.querySelectorAll('button')[0]?.textContent?.trim() ?? '', @@ -178,6 +201,22 @@ describe('deriveSessionCards', () => { expect(cards[0].status).toBe('idle'); }); + it('flags sessions in a non-primary workspace against the primary cwd', () => { + const cards = deriveSessionCards( + [ + session('a', { workspaceCwd: '/w' }), + session('b', { workspaceCwd: '/wsB' }), + ], + [], + undefined, + '/w', + ); + const byId = new Map(cards.map((c) => [c.sessionId, c])); + expect(byId.get('a')?.isNonPrimary).toBe(false); + expect(byId.get('b')?.isNonPrimary).toBe(true); + expect(byId.get('b')?.workspaceCwd).toBe('/wsB'); + }); + it('labels with displayName, falling back to a short id, and flags current', () => { const cards = deriveSessionCards( [ @@ -398,6 +437,36 @@ describe('SessionOverviewPanel', () => { ); expect(onOpenSplit.mock.calls[0][0]).toHaveLength(6); }); + + it('lists an other-workspace session as a card with a workspace badge', async () => { + connectionState.capabilities = { + features: [], + workspaceCwd: '/w', + workspaces: [ + { id: 'w0', cwd: '/w', primary: true, trusted: true }, + { id: 'w1', cwd: '/wsB', primary: false, trusted: true }, + ], + }; + sessionsState.sessions = [session('s-run', { displayName: 'Alpha' })]; + otherWorkspaceSessions['/wsB'] = [ + session('b1', { workspaceCwd: '/wsB', displayName: 'Beta' }), + ]; + render(); + await flushAsync(); // let the other-workspace fan-out resolve + // The non-primary session shows up as its own card… + expect(cardLabels()).toContain('Beta'); + // …tagged with its workspace basename (the primary card reads "primary"). + expect(container!.textContent).toContain('wsB'); + }); + + it('does not query other workspaces on a single-workspace daemon', async () => { + sessionsState.sessions = [session('s-run', { displayName: 'Alpha' })]; + render(); + await flushAsync(); + expect(workspaceClient.listWorkspaceSessions).not.toHaveBeenCalled(); + // No workspace badge on a single-workspace daemon. + expect(container!.textContent).not.toContain('wsB'); + }); }); describe('SessionOverviewPanel polling', () => { diff --git a/packages/web-shell/client/components/SessionOverviewPanel.tsx b/packages/web-shell/client/components/SessionOverviewPanel.tsx index 74a16a46db9..17ec3830da5 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.tsx @@ -18,6 +18,13 @@ import type { import { useI18n } from '../i18n'; import { formatRelativeTime } from '../utils/formatRelativeTime'; import { buildSplitUrl, MAX_SPLIT_PANES } from '../utils/splitUrl'; +import { + hasMultipleWorkspaces, + isNonPrimaryWorkspaceSession, + mergeSessionsById, + workspaceBasename, +} from '../utils/workspace'; +import { useOtherWorkspaceSessions } from '../hooks/useOtherWorkspaceSessions'; import { getDaemonToken } from '../config/daemon'; import { SESSION_LIST_PAGE_SIZE, @@ -49,6 +56,10 @@ export interface SessionCard { updatedAt?: string; color?: DaemonSessionGroupColor | null; isCurrent: boolean; + /** The workspace the session lives in. */ + workspaceCwd: string; + /** True when the session belongs to a non-primary workspace. */ + isNonPrimary: boolean; } const STATUS_PRIORITY: Record = { @@ -70,6 +81,7 @@ export function deriveSessionCards( sessions: DaemonSessionSummary[], statusSessions: DaemonStatusReportSession[], currentSessionId: string | undefined, + primaryCwd?: string, ): SessionCard[] { const statusById = new Map( statusSessions.map((session) => [session.sessionId, session]), @@ -87,6 +99,11 @@ export function deriveSessionCards( updatedAt: session.updatedAt || session.createdAt, color: session.color, isCurrent: session.sessionId === currentSessionId, + workspaceCwd: session.workspaceCwd, + isNonPrimary: isNonPrimaryWorkspaceSession( + session.workspaceCwd, + primaryCwd, + ), }; }); cards.sort((a, b) => { @@ -154,6 +171,16 @@ function SessionOverviewPanelInner({ ? { view: 'organized' as const, group: 'all' } : {}), }); + // Fold in the live sessions of the daemon's other workspaces (empty on a + // single-workspace daemon), so the overview is mission control for every + // workspace, not just the primary one. + const { sessions: otherSessions, reload: reloadOther } = + useOtherWorkspaceSessions(); + const mergedSessions = useMemo( + () => mergeSessionsById(sessions, otherSessions), + [sessions, otherSessions], + ); + const multiWorkspace = hasMultipleWorkspaces(connection.capabilities); const status = useStatusReport({ autoLoad: true, detail: 'full' }); const statusReload = status.reload; const statusReport = status.report; @@ -168,12 +195,12 @@ function SessionOverviewPanelInner({ const timer = window.setInterval(() => { if (document.hidden || listInFlight.current) return; listInFlight.current = true; - void reload().finally(() => { + void Promise.all([reload(), reloadOther()]).finally(() => { listInFlight.current = false; }); }, LIST_POLL_MS); return () => window.clearInterval(timer); - }, [reload]); + }, [reload, reloadOther]); // Poll the richer status report less often — it is the only source of // per-session "needs approval" and current-model, but costs more to build. @@ -189,14 +216,19 @@ function SessionOverviewPanelInner({ return () => window.clearInterval(timer); }, [statusReload]); + // The primary workspace cwd (not `connection.workspaceCwd`, which follows the + // currently-loaded session and can itself be non-primary) — so cards are + // tagged against the real primary. + const primaryCwd = connection.capabilities?.workspaceCwd; const cards = useMemo( () => deriveSessionCards( - sessions, + mergedSessions, statusReport?.full?.sessions ?? [], currentSessionId, + primaryCwd, ), - [sessions, statusReport, currentSessionId], + [mergedSessions, statusReport, currentSessionId, primaryCwd], ); const toggleSelected = useCallback((sessionId: string) => { @@ -267,8 +299,9 @@ function SessionOverviewPanelInner({ const refresh = useCallback(() => { void reload(); + void reloadOther(); void statusReload(); - }, [reload, statusReload]); + }, [reload, reloadOther, statusReload]); if (cards.length === 0) { return ( @@ -392,6 +425,19 @@ function SessionOverviewPanelInner({ > {t(`sessionsOverview.status.${card.status}`)} + {multiWorkspace && ( + + {card.isNonPrimary + ? workspaceBasename(card.workspaceCwd) + : t('sidebar.workspacePrimary')} + + )} {card.model && ( {card.model} diff --git a/packages/web-shell/client/components/SplitView.module.css b/packages/web-shell/client/components/SplitView.module.css index 633b684b568..913971d7eb9 100644 --- a/packages/web-shell/client/components/SplitView.module.css +++ b/packages/web-shell/client/components/SplitView.module.css @@ -85,7 +85,9 @@ } .pickerItem { - display: block; + display: flex; + align-items: baseline; + gap: 8px; width: 100%; text-align: left; padding: 7px 10px; @@ -95,9 +97,29 @@ color: var(--foreground); font-size: 13px; cursor: pointer; +} + +.pickerItemLabel { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Which workspace a session lives in — only shown on a multi-workspace daemon, + so the single-workspace picker is unchanged. */ +.pickerItemWorkspace { + flex: 0 0 auto; + max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: 11px; + color: var( + --muted-foreground, + color-mix(in srgb, var(--foreground) 55%, transparent) + ); } .pickerItem:hover { diff --git a/packages/web-shell/client/components/SplitView.test.tsx b/packages/web-shell/client/components/SplitView.test.tsx index c3dfeeef8a8..a0250ed00a1 100644 --- a/packages/web-shell/client/components/SplitView.test.tsx +++ b/packages/web-shell/client/components/SplitView.test.tsx @@ -16,17 +16,34 @@ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); /* eslint-disable @typescript-eslint/no-explicit-any */ let connectionState: any; let sessionsState: any[]; +// Live sessions the mock daemon returns per non-primary workspace cwd, keyed by +// cwd — drives `useOtherWorkspaceSessions` (via the mocked `useWorkspace`). +let otherWorkspaceSessions: Record; +// Stable client object (assigned once per test) so the other-workspace hook's +// load callback keeps a stable identity and its effect doesn't loop. +let workspaceClient: { listWorkspaceSessions: ReturnType }; // Stable across renders (assigned once per test) so SplitView's reload effects, // which depend on `reload`'s identity, don't re-fire on every render. let reloadMock: ReturnType; vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ DaemonSessionProvider: (props: any) => ( -
+
{props.children}
), useConnection: () => connectionState, + // `client` is a stable object; `capabilities` mirrors the connection so a test + // that sets `capabilities.workspaces` drives both the picker labels and the + // other-workspace fan-out from one place. + useWorkspace: () => ({ + client: workspaceClient, + capabilities: connectionState.capabilities, + }), // Stateful, like the real hook: reload() re-renders with the CURRENT module // store. This lets a test prove the picker renders sessions that appeared // only after the reload — not merely that reload() was called. @@ -75,9 +92,23 @@ beforeEach(() => { { sessionId: 's3', workspaceCwd: '/w', displayName: 'Three' }, { sessionId: 's4', workspaceCwd: '/w', displayName: 'Four' }, ]; + otherWorkspaceSessions = {}; + workspaceClient = { + listWorkspaceSessions: vi.fn( + async (cwd: string) => otherWorkspaceSessions[cwd] ?? [], + ), + }; reloadMock = vi.fn(); }); +// Flush the other-workspace hook's async fan-out (Promise.allSettled + setState). +async function flushAsync(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + afterEach(() => { act(() => root?.unmount()); container?.remove(); @@ -405,4 +436,77 @@ describe('SplitView', () => { act(() => close!.dispatchEvent(new MouseEvent('click', { bubbles: true }))); expect(onPanesChange).toHaveBeenLastCalledWith(['s1']); }); + + const MULTI_WORKSPACE_CAPS = { + features: [] as string[], + workspaces: [ + { id: 'w0', cwd: '/w', primary: true, trusted: true }, + { id: 'w1', cwd: '/wsB', primary: false, trusted: true }, + ], + }; + + it('offers other trusted workspaces’ sessions in the picker, tagged by workspace', async () => { + connectionState.capabilities = MULTI_WORKSPACE_CAPS; + otherWorkspaceSessions['/wsB'] = [ + { sessionId: 'b1', workspaceCwd: '/wsB', displayName: 'Beta' }, + ]; + render({ sessionIds: ['s1'] }); + await flushAsync(); // let the other-workspace fan-out resolve + openPicker(); + const options = pickerOptions(); + // Primary sessions are still listed… + expect(options.some((o) => o.includes('Two'))).toBe(true); + // …plus the non-primary session, tagged with its workspace basename. + expect(options.some((o) => o.includes('Beta') && o.includes('wsB'))).toBe( + true, + ); + }); + + it('attaches an added other-workspace pane under its own workspace cwd', async () => { + connectionState.capabilities = MULTI_WORKSPACE_CAPS; + otherWorkspaceSessions['/wsB'] = [ + { sessionId: 'b1', workspaceCwd: '/wsB', displayName: 'Beta' }, + ]; + // Uncontrolled (no `sessionIds`) so the picker mounts panes locally; the + // seed is the current session s3. + render(); + await flushAsync(); + openPicker(); + const betaButton = Array.from( + container!.querySelectorAll('[role="option"] button'), + ).find((el) => + (el.textContent ?? '').includes('Beta'), + ) as HTMLButtonElement; + act(() => + betaButton.dispatchEvent(new MouseEvent('click', { bubbles: true })), + ); + const providerFor = (id: string) => + Array.from(container!.querySelectorAll('[data-session]')).find( + (el) => el.getAttribute('data-session') === id, + ); + // The new pane binds the session's own workspace, so the daemon routes the + // attach to /wsB instead of 409ing it against the primary cwd. + expect(providerFor('b1')?.getAttribute('data-workspace')).toBe('/wsB'); + // The primary seed pane still binds the primary cwd. + expect(providerFor('s3')?.getAttribute('data-workspace')).toBe('/w'); + }); + + it('never fans out to other workspaces on a single-workspace daemon', async () => { + // Default capabilities carry no `workspaces`, so the other-workspace hook + // must not touch the daemon and the picker stays untagged. + render({ sessionIds: ['s1'] }); + await flushAsync(); + expect(workspaceClient.listWorkspaceSessions).not.toHaveBeenCalled(); + openPicker(); + expect(pickerOptions()).toEqual(['Two', 'Three', 'Four']); + }); + + it('does not pin a workspace on panes for a single-workspace daemon', () => { + // The pane provider must get no `workspaceCwd` prop (it falls back to the + // provider's primary cwd) so a deep-linked pane never re-attaches when the + // session list resolves — today's behavior, unchanged. + render({ sessionIds: ['s1'] }); + const provider = container!.querySelector('[data-session="s1"]'); + expect(provider?.getAttribute('data-workspace')).toBeNull(); + }); }); diff --git a/packages/web-shell/client/components/SplitView.tsx b/packages/web-shell/client/components/SplitView.tsx index 43020c104fe..6d1464f4224 100644 --- a/packages/web-shell/client/components/SplitView.tsx +++ b/packages/web-shell/client/components/SplitView.tsx @@ -24,6 +24,12 @@ import { SESSION_LIST_PAGE_SIZE, SESSION_ORGANIZATION_FEATURE, } from '../constants/sessions'; +import { useOtherWorkspaceSessions } from '../hooks/useOtherWorkspaceSessions'; +import { + hasMultipleWorkspaces, + mergeSessionsById, + workspaceBasename, +} from '../utils/workspace'; import styles from './SplitView.module.css'; const MAX_PANES = MAX_SPLIT_PANES; @@ -88,6 +94,16 @@ export function SplitView({ ? { view: 'organized' as const, group: 'all' } : {}), }); + // Live sessions from the daemon's other workspaces, so the picker can offer — + // and a pane can attach to — sessions that aren't in the primary workspace. + // Empty (a no-op) on a single-workspace daemon. + const { sessions: otherSessions, reload: reloadOther } = + useOtherWorkspaceSessions(); + const allSessions = useMemo( + () => mergeSessionsById(sessions, otherSessions), + [sessions, otherSessions], + ); + const multiWorkspace = hasMultipleWorkspaces(connection.capabilities); const sessionIdsControlled = sessionIds !== undefined; const normalizedSessionIds = useMemo( () => @@ -148,8 +164,11 @@ export function SplitView({ // mount, so without this the picker would offer whatever was current when the // split was first entered, missing sessions created since. useEffect(() => { - if (pickerOpen) void reload(); - }, [pickerOpen, reload]); + if (pickerOpen) { + void reload(); + void reloadOther(); + } + }, [pickerOpen, reload, reloadOther]); // Also refresh when the parent signals the list changed elsewhere (a session // created / deleted / renamed in the sidebar or another tab), so an open @@ -171,19 +190,40 @@ export function SplitView({ ) { prevReloadTokenRef.current = sessionListReloadToken; void reload(); + void reloadOther(); } - }, [sessionListReloadToken, reload]); + }, [sessionListReloadToken, reload, reloadOther]); const titleById = useMemo(() => { const map = new Map(); - for (const session of sessions) { + for (const session of allSessions) { map.set( session.sessionId, session.displayName?.trim() || session.sessionId.slice(0, 8), ); } return map; - }, [sessions]); + }, [allSessions]); + + // The workspace each session lives in, so a pane attaches under its owning + // workspace (a non-primary session 409s if loaded with the primary cwd). The + // seed pane is the current session, whose workspace the connection already + // knows before the lists finish loading — cover it so it attaches correctly + // on first paint. + const workspaceCwdById = useMemo(() => { + const map = new Map(); + for (const session of allSessions) { + map.set(session.sessionId, session.workspaceCwd); + } + if ( + currentSessionId && + connection.workspaceCwd && + !map.has(currentSessionId) + ) { + map.set(currentSessionId, connection.workspaceCwd); + } + return map; + }, [allSessions, currentSessionId, connection.workspaceCwd]); const addPane = useCallback( (sessionId: string) => { @@ -240,8 +280,8 @@ export function SplitView({ ); const available = useMemo( - () => sessions.filter((session) => !paneIds.includes(session.sessionId)), - [sessions, paneIds], + () => allSessions.filter((session) => !paneIds.includes(session.sessionId)), + [allSessions, paneIds], ); const canAdd = paneIds.length < MAX_PANES && available.length > 0; @@ -290,8 +330,18 @@ export function SplitView({ className={styles.pickerItem} onClick={() => addPane(session.sessionId)} > - {titleById.get(session.sessionId) ?? - session.sessionId.slice(0, 8)} + + {titleById.get(session.sessionId) ?? + session.sessionId.slice(0, 8)} + + {multiWorkspace && ( + + {workspaceBasename(session.workspaceCwd)} + + )} ))} @@ -304,51 +354,72 @@ export function SplitView({ {paneIds.length === 0 ? (
{t('splitView.empty')}
) : ( - paneIds.map((sessionId) => ( -
- {/* Contain a render crash to its own pane — a malformed block in + paneIds.map((sessionId) => { + const paneWorkspaceCwd = workspaceCwdById.get(sessionId); + return ( +
+ {/* Contain a render crash to its own pane — a malformed block in one session must not white-screen the whole split. */} - ( -
-
- {titleById.get(sessionId) ?? sessionId.slice(0, 8)} + ( +
+
+ {titleById.get(sessionId) ?? sessionId.slice(0, 8)} +
+
+ {t('splitView.paneError')}: {error.message} +
+
-
- {t('splitView.paneError')}: {error.message} -
- -
- )} - > - - removePane(sessionId)} - onError={onError} - onRightPanelOpen={onRightPanelOpen} - onPaneArtifactsChange={onPaneArtifactsChange} - messageTurnOutputs={messageTurnOutputs} - /> - - -
- )) + + removePane(sessionId)} + onError={onError} + onRightPanelOpen={onRightPanelOpen} + onPaneArtifactsChange={onPaneArtifactsChange} + messageTurnOutputs={messageTurnOutputs} + /> + +
+
+ ); + }) )}
diff --git a/packages/web-shell/client/hooks/useOtherWorkspaceSessions.test.tsx b/packages/web-shell/client/hooks/useOtherWorkspaceSessions.test.tsx new file mode 100644 index 00000000000..505f2319b1e --- /dev/null +++ b/packages/web-shell/client/hooks/useOtherWorkspaceSessions.test.tsx @@ -0,0 +1,131 @@ +// @vitest-environment jsdom +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import type { + DaemonSessionSummary, + DaemonWorkspaceCapability, +} from '@qwen-code/sdk/daemon'; +import { SESSION_LIST_PAGE_SIZE } from '../constants/sessions'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +/* eslint-disable @typescript-eslint/no-explicit-any */ +let capabilities: any; +let listWorkspaceSessions: ReturnType; +// Stable client object (per test) — the real `useWorkspace().client` is a +// memoized `DaemonClient`, and the hook depends on its identity, so an unstable +// mock would re-fire the load effect on every render (infinite loop). +let client: { listWorkspaceSessions: ReturnType }; + +vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ + useWorkspace: () => ({ client, capabilities }), +})); + +const { useOtherWorkspaceSessions } = await import( + './useOtherWorkspaceSessions' +); + +let root: Root | null = null; +let container: HTMLDivElement | null = null; +let latest: ReturnType; + +function Harness() { + latest = useOtherWorkspaceSessions(); + return null; +} + +function render(): void { + container = document.createElement('div'); + root = createRoot(container); + act(() => root!.render()); +} + +// Flush the hook's async fan-out (Promise.allSettled + setState). +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function ws( + cwd: string, + primary: boolean, + trusted: boolean, +): DaemonWorkspaceCapability { + return { id: cwd, cwd, primary, trusted }; +} + +function session(id: string, cwd: string): DaemonSessionSummary { + return { sessionId: id, workspaceCwd: cwd }; +} + +beforeEach(() => { + capabilities = {}; + listWorkspaceSessions = vi.fn(async () => []); + client = { listWorkspaceSessions }; +}); + +afterEach(() => { + act(() => root?.unmount()); + root = null; + container = null; +}); + +describe('useOtherWorkspaceSessions', () => { + it('returns [] and never queries the daemon without a workspaces list', async () => { + render(); + await flush(); + expect(latest.sessions).toEqual([]); + expect(listWorkspaceSessions).not.toHaveBeenCalled(); + }); + + it('lists only non-primary, trusted workspaces (live/active)', async () => { + capabilities = { + workspaces: [ + ws('/w', true, true), // primary → skipped + ws('/b', false, true), // listed + ws('/c', false, false), // untrusted → skipped + ], + }; + listWorkspaceSessions.mockImplementation(async (cwd: string) => + cwd === '/b' ? [session('b1', '/b')] : [], + ); + render(); + await flush(); + expect(listWorkspaceSessions).toHaveBeenCalledTimes(1); + expect(listWorkspaceSessions).toHaveBeenCalledWith('/b', { + pageSize: SESSION_LIST_PAGE_SIZE, + archiveState: 'active', + }); + expect(latest.sessions.map((s) => s.sessionId)).toEqual(['b1']); + }); + + it('merges workspaces and keeps the ones that respond when another fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + capabilities = { + workspaces: [ + ws('/w', true, true), + ws('/b', false, true), + ws('/c', false, true), + ], + }; + listWorkspaceSessions.mockImplementation(async (cwd: string) => { + if (cwd === '/b') return [session('b1', '/b')]; + throw new Error('workspace /c is unreachable'); + }); + render(); + await flush(); + // /b's session survives even though /c rejected. + expect(latest.sessions.map((s) => s.sessionId)).toEqual(['b1']); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); +}); diff --git a/packages/web-shell/client/hooks/useOtherWorkspaceSessions.ts b/packages/web-shell/client/hooks/useOtherWorkspaceSessions.ts new file mode 100644 index 00000000000..ae3e7e6c7ae --- /dev/null +++ b/packages/web-shell/client/hooks/useOtherWorkspaceSessions.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useEffect, useState } from 'react'; +import { useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; +import type { DaemonSessionSummary } from '@qwen-code/sdk/daemon'; +import { SESSION_LIST_PAGE_SIZE } from '../constants/sessions'; + +export interface OtherWorkspaceSessionsResult { + /** + * Live sessions from every non-primary, trusted registered workspace, merged + * into one flat list. Each summary already carries its own `workspaceCwd`. + */ + sessions: DaemonSessionSummary[]; + /** Re-fetch every target workspace. Stable identity (safe in effect deps). */ + reload: () => Promise; +} + +const EMPTY: DaemonSessionSummary[] = []; + +/** + * Collect the *live* sessions of the daemon's other workspaces so the split + * view and session overview can list and open sessions that are not in the + * primary workspace. The primary workspace's own sessions still come from + * `useSessions()`; callers merge the two (see `mergeSessionsById`). + * + * Scope & guarantees: + * - Targets only `capabilities.workspaces` entries that are non-primary **and** + * trusted — an untrusted workspace can't be listed (the daemon 403s it) and + * the primary is already covered by `useSessions`. + * - Non-primary workspaces are live-only on the daemon (Phase 2a), so this asks + * for `archiveState: 'active'` and never for the organized/persisted view. + * - Fans out with `Promise.allSettled`: one workspace failing (e.g. transiently + * unreachable) drops only its own rows, never the others'. + * - Returns an empty, stable list on a single-workspace daemon (no + * `capabilities.workspaces`, or only the primary), so merging it is a no-op + * and the single-workspace UI is byte-identical. + */ +export function useOtherWorkspaceSessions(): OtherWorkspaceSessionsResult { + const workspace = useWorkspace(); + const client = workspace.client; + + // A newline-joined key of the non-primary trusted cwds, so `load` (and the + // effect that runs it) only change identity when the actual target set does — + // not on every capabilities re-render. + const targetsKey = (workspace.capabilities?.workspaces ?? []) + .filter((w) => !w.primary && w.trusted) + .map((w) => w.cwd) + .join('\n'); + + const [sessions, setSessions] = useState(EMPTY); + + const load = useCallback(async () => { + const cwds = targetsKey ? targetsKey.split('\n') : []; + if (cwds.length === 0) { + setSessions((prev) => (prev.length === 0 ? prev : EMPTY)); + return; + } + const settled = await Promise.allSettled( + cwds.map((cwd) => + // Match the primary list's page size (both callers fetch the primary + // with SESSION_LIST_PAGE_SIZE); the daemon's default is far smaller, so + // without this a busy non-primary workspace would silently truncate. + client.listWorkspaceSessions(cwd, { + pageSize: SESSION_LIST_PAGE_SIZE, + archiveState: 'active', + }), + ), + ); + const merged: DaemonSessionSummary[] = []; + settled.forEach((result, index) => { + if (result.status === 'fulfilled') { + merged.push(...result.value); + } else { + // Surface connectivity failures without blanking the workspaces that + // did respond — mirrors the sidebar's per-section poll. + console.warn( + `[useOtherWorkspaceSessions] failed to list sessions for ${cwds[index]}:`, + result.reason, + ); + } + }); + setSessions(merged); + }, [client, targetsKey]); + + useEffect(() => { + void load(); + }, [load]); + + return { sessions, reload: load }; +} diff --git a/packages/web-shell/client/utils/workspace.test.ts b/packages/web-shell/client/utils/workspace.test.ts new file mode 100644 index 00000000000..f8b57152c2f --- /dev/null +++ b/packages/web-shell/client/utils/workspace.test.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import type { + DaemonCapabilities, + DaemonSessionSummary, + DaemonWorkspaceCapability, +} from '@qwen-code/sdk/daemon'; +import { + hasMultipleWorkspaces, + isNonPrimaryWorkspaceSession, + mergeSessionsById, + workspaceBasename, +} from './workspace'; + +function caps(workspaces?: DaemonWorkspaceCapability[]): DaemonCapabilities { + return { + v: 1, + mode: 'workspace', + features: [], + modelServices: [], + ...(workspaces ? { workspaces } : {}), + } as unknown as DaemonCapabilities; +} + +function ws(cwd: string): DaemonWorkspaceCapability { + return { id: cwd, cwd, primary: false, trusted: true }; +} + +function session(id: string, cwd: string): DaemonSessionSummary { + return { sessionId: id, workspaceCwd: cwd }; +} + +describe('workspaceBasename', () => { + it('returns the last path segment', () => { + expect(workspaceBasename('/home/me/projects/api')).toBe('api'); + expect(workspaceBasename('/home/me/projects/api/')).toBe('api'); + expect(workspaceBasename('C:\\Users\\me\\web')).toBe('web'); + }); + + it('falls back to the whole string when there are no segments', () => { + expect(workspaceBasename('/')).toBe('/'); + expect(workspaceBasename('')).toBe(''); + }); +}); + +describe('hasMultipleWorkspaces', () => { + it('is false without a workspaces list or with a single entry', () => { + expect(hasMultipleWorkspaces(undefined)).toBe(false); + expect(hasMultipleWorkspaces(caps())).toBe(false); + expect(hasMultipleWorkspaces(caps([ws('/w')]))).toBe(false); + }); + + it('is true with more than one workspace', () => { + expect(hasMultipleWorkspaces(caps([ws('/w'), ws('/b')]))).toBe(true); + }); +}); + +describe('isNonPrimaryWorkspaceSession', () => { + it('is true only when both cwds are known and differ', () => { + expect(isNonPrimaryWorkspaceSession('/b', '/w')).toBe(true); + expect(isNonPrimaryWorkspaceSession('/w', '/w')).toBe(false); + expect(isNonPrimaryWorkspaceSession(undefined, '/w')).toBe(false); + expect(isNonPrimaryWorkspaceSession('/b', undefined)).toBe(false); + }); +}); + +describe('mergeSessionsById', () => { + it('returns the primary list unchanged (same ref) when there are no others', () => { + const primary = [session('a', '/w')]; + expect(mergeSessionsById(primary, [])).toBe(primary); + }); + + it('appends other-workspace sessions', () => { + const merged = mergeSessionsById( + [session('a', '/w')], + [session('b', '/b')], + ); + expect(merged.map((s) => s.sessionId)).toEqual(['a', 'b']); + }); + + it('keeps the primary entry on an id collision', () => { + const merged = mergeSessionsById( + [session('a', '/w')], + [session('a', '/b')], + ); + expect(merged).toHaveLength(1); + expect(merged[0].workspaceCwd).toBe('/w'); + }); +}); diff --git a/packages/web-shell/client/utils/workspace.ts b/packages/web-shell/client/utils/workspace.ts new file mode 100644 index 00000000000..f6f9f98dded --- /dev/null +++ b/packages/web-shell/client/utils/workspace.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + DaemonCapabilities, + DaemonSessionSummary, +} from '@qwen-code/sdk/daemon'; + +/** + * Last path segment of an absolute workspace cwd, for a compact per-workspace + * label (e.g. `/home/me/projects/api` → `api`). Falls back to the full path when + * it has no segments. Mirrors the sidebar's `WorkspaceSection` naming so the + * split view / overview label a workspace the same way its sidebar section does. + */ +export function workspaceBasename(cwd: string): string { + const parts = cwd.split(/[\\/]+/).filter(Boolean); + return parts.at(-1) ?? cwd; +} + +/** + * True when the daemon advertises more than one registered workspace — i.e. the + * multi-workspace session surfaces (per-workspace labels/tags) should show. + * A single-workspace daemon omits `workspaces` (or lists just the primary), so + * every workspace-scoped affordance stays hidden and the UI is unchanged. + */ +export function hasMultipleWorkspaces( + capabilities: DaemonCapabilities | undefined, +): boolean { + return (capabilities?.workspaces?.length ?? 0) > 1; +} + +/** + * Whether a session belongs to a workspace other than the primary one. Both cwds + * are daemon-canonicalized, so a raw string compare is correct. Returns false + * when either cwd is unknown (treat as primary) so single-workspace never tags. + */ +export function isNonPrimaryWorkspaceSession( + workspaceCwd: string | undefined, + primaryCwd: string | undefined, +): boolean { + return !!workspaceCwd && !!primaryCwd && workspaceCwd !== primaryCwd; +} + +/** + * Merge the primary workspace's sessions with the sessions collected from other + * workspaces into one list, keyed by `sessionId` (primary wins on the unlikely + * id collision). Returns the primary list unchanged (same reference) when there + * are no other-workspace sessions, so the single-workspace path is a no-op. + */ +export function mergeSessionsById( + primary: DaemonSessionSummary[], + others: DaemonSessionSummary[], +): DaemonSessionSummary[] { + if (others.length === 0) return primary; + const byId = new Map(); + for (const session of primary) byId.set(session.sessionId, session); + for (const session of others) { + if (!byId.has(session.sessionId)) byId.set(session.sessionId, session); + } + return [...byId.values()]; +} From 23cc397652b0901afd5440db71040bb5cb02205f Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 12 Jul 2026 08:02:57 +0800 Subject: [PATCH 02/14] 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. --- .../client/components/dialogs/ScheduledTasksDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx b/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx index f59dcf7ed9d..feb4adcb837 100644 --- a/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx @@ -23,7 +23,7 @@ import type { DaemonWorkspaceSkillStatus, } from '@qwen-code/sdk/daemon'; import { useI18n } from '../../i18n'; -import { getComposerTagIconUrl } from '../composerTagIcons'; +import { getComposerTagIconUrl } from '../../utils/composerTag'; import { cssUrlValue } from '../../utils/cssUrlVar'; import { DialogShell } from './DialogShell'; import { From c7e36b45c9109eac2a840c2b9befcab11347d315 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 12 Jul 2026 08:02:58 +0800 Subject: [PATCH 03/14] 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 --- .../components/SessionOverviewPanel.test.tsx | 34 ++++++++++++++- .../client/components/SplitView.test.tsx | 22 +++++++++- .../web-shell/client/components/SplitView.tsx | 12 +++++- .../hooks/useOtherWorkspaceSessions.test.tsx | 21 ++++++++- .../client/hooks/useOtherWorkspaceSessions.ts | 43 +++++++++++++++---- .../web-shell/client/utils/workspace.test.ts | 7 +++ 6 files changed, 125 insertions(+), 14 deletions(-) diff --git a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx index 7c98fa73043..89839a58f78 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx @@ -142,11 +142,13 @@ function rerender(props: { onOpenSplit?: (ids: string[]) => void } = {}): void { ); } -// Flush the other-workspace hook's async fan-out (Promise.allSettled + setState). +// Flush the other-workspace hook's async fan-out (Promise.allSettled + the +// effect's `.then` setState). Three ticks so the state update lands in `act`. async function flushAsync(): Promise { await act(async () => { await Promise.resolve(); await Promise.resolve(); + await Promise.resolve(); }); } @@ -455,8 +457,10 @@ describe('SessionOverviewPanel', () => { await flushAsync(); // let the other-workspace fan-out resolve // The non-primary session shows up as its own card… expect(cardLabels()).toContain('Beta'); - // …tagged with its workspace basename (the primary card reads "primary"). + // …tagged with its workspace basename… expect(container!.textContent).toContain('wsB'); + // …while the primary card carries the localized "primary" badge. + expect(container!.textContent).toContain('primary'); }); it('does not query other workspaces on a single-workspace daemon', async () => { @@ -537,4 +541,30 @@ describe('SessionOverviewPanel polling', () => { vi.useRealTimers(); } }); + + it('re-queries other workspaces on each list poll (multi-workspace)', async () => { + connectionState.capabilities = { + features: [], + workspaceCwd: '/w', + workspaces: [ + { id: 'w0', cwd: '/w', primary: true, trusted: true }, + { id: 'w1', cwd: '/wsB', primary: false, trusted: true }, + ], + }; + sessionsState.sessions = [session('a')]; + otherWorkspaceSessions['/wsB'] = [session('b1', { workspaceCwd: '/wsB' })]; + vi.useFakeTimers(); + try { + render(); + await vi.advanceTimersByTimeAsync(10); // settle the initial fan-out + workspaceClient.listWorkspaceSessions.mockClear(); + await vi.advanceTimersByTimeAsync(3100); // one list-poll tick + expect(workspaceClient.listWorkspaceSessions).toHaveBeenCalledWith( + '/wsB', + expect.objectContaining({ archiveState: 'active' }), + ); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/packages/web-shell/client/components/SplitView.test.tsx b/packages/web-shell/client/components/SplitView.test.tsx index a0250ed00a1..28ffa72d4b2 100644 --- a/packages/web-shell/client/components/SplitView.test.tsx +++ b/packages/web-shell/client/components/SplitView.test.tsx @@ -101,11 +101,13 @@ beforeEach(() => { reloadMock = vi.fn(); }); -// Flush the other-workspace hook's async fan-out (Promise.allSettled + setState). +// Flush the other-workspace hook's async fan-out (Promise.allSettled + the +// effect's `.then` setState). Three ticks so the state update lands in `act`. async function flushAsync(): Promise { await act(async () => { await Promise.resolve(); await Promise.resolve(); + await Promise.resolve(); }); } @@ -439,6 +441,7 @@ describe('SplitView', () => { const MULTI_WORKSPACE_CAPS = { features: [] as string[], + workspaceCwd: '/w', workspaces: [ { id: 'w0', cwd: '/w', primary: true, trusted: true }, { id: 'w1', cwd: '/wsB', primary: false, trusted: true }, @@ -509,4 +512,21 @@ describe('SplitView', () => { const provider = container!.querySelector('[data-session="s1"]'); expect(provider?.getAttribute('data-workspace')).toBeNull(); }); + + it('re-queries other workspaces when the picker opens', async () => { + connectionState.capabilities = MULTI_WORKSPACE_CAPS; + otherWorkspaceSessions['/wsB'] = [ + { sessionId: 'b1', workspaceCwd: '/wsB', displayName: 'Beta' }, + ]; + render({ sessionIds: ['s1'] }); + await flushAsync(); + const before = workspaceClient.listWorkspaceSessions.mock.calls.length; + openPicker(); + await flushAsync(); + // Opening the picker reloads the other-workspace list so it never offers a + // stale set (mirrors the primary `reload()` on picker open). + expect( + workspaceClient.listWorkspaceSessions.mock.calls.length, + ).toBeGreaterThan(before); + }); }); diff --git a/packages/web-shell/client/components/SplitView.tsx b/packages/web-shell/client/components/SplitView.tsx index 6d1464f4224..b7bd4214780 100644 --- a/packages/web-shell/client/components/SplitView.tsx +++ b/packages/web-shell/client/components/SplitView.tsx @@ -27,6 +27,7 @@ import { import { useOtherWorkspaceSessions } from '../hooks/useOtherWorkspaceSessions'; import { hasMultipleWorkspaces, + isNonPrimaryWorkspaceSession, mergeSessionsById, workspaceBasename, } from '../utils/workspace'; @@ -104,6 +105,10 @@ export function SplitView({ [sessions, otherSessions], ); const multiWorkspace = hasMultipleWorkspaces(connection.capabilities); + // The primary workspace cwd, for labeling picker items the same way the + // Session Overview labels its cards (primary → the localized tag, others → + // the workspace basename). + const primaryCwd = connection.capabilities?.workspaceCwd; const sessionIdsControlled = sessionIds !== undefined; const normalizedSessionIds = useMemo( () => @@ -339,7 +344,12 @@ export function SplitView({ className={styles.pickerItemWorkspace} title={session.workspaceCwd} > - {workspaceBasename(session.workspaceCwd)} + {isNonPrimaryWorkspaceSession( + session.workspaceCwd, + primaryCwd, + ) + ? workspaceBasename(session.workspaceCwd) + : t('sidebar.workspacePrimary')}
)} diff --git a/packages/web-shell/client/hooks/useOtherWorkspaceSessions.test.tsx b/packages/web-shell/client/hooks/useOtherWorkspaceSessions.test.tsx index 505f2319b1e..8802e2655a1 100644 --- a/packages/web-shell/client/hooks/useOtherWorkspaceSessions.test.tsx +++ b/packages/web-shell/client/hooks/useOtherWorkspaceSessions.test.tsx @@ -47,11 +47,13 @@ function render(): void { act(() => root!.render()); } -// Flush the hook's async fan-out (Promise.allSettled + setState). +// Flush the hook's async fan-out (Promise.allSettled + the effect's `.then` +// setState). Three ticks so the React state update lands inside `act`. async function flush(): Promise { await act(async () => { await Promise.resolve(); await Promise.resolve(); + await Promise.resolve(); }); } @@ -128,4 +130,21 @@ describe('useOtherWorkspaceSessions', () => { expect(warn).toHaveBeenCalled(); warn.mockRestore(); }); + + it('re-fetches the target workspaces when reload() is called', async () => { + capabilities = { + workspaces: [ws('/w', true, true), ws('/b', false, true)], + }; + listWorkspaceSessions.mockResolvedValue([session('b1', '/b')]); + render(); + await flush(); + expect(listWorkspaceSessions).toHaveBeenCalledTimes(1); + // The callers drive refresh via reload() (poll tick / picker open) — it must + // fetch again. + await act(async () => { + await latest.reload(); + }); + expect(listWorkspaceSessions).toHaveBeenCalledTimes(2); + expect(latest.sessions.map((s) => s.sessionId)).toEqual(['b1']); + }); }); diff --git a/packages/web-shell/client/hooks/useOtherWorkspaceSessions.ts b/packages/web-shell/client/hooks/useOtherWorkspaceSessions.ts index ae3e7e6c7ae..d6ec622e41a 100644 --- a/packages/web-shell/client/hooks/useOtherWorkspaceSessions.ts +++ b/packages/web-shell/client/hooks/useOtherWorkspaceSessions.ts @@ -53,12 +53,14 @@ export function useOtherWorkspaceSessions(): OtherWorkspaceSessionsResult { const [sessions, setSessions] = useState(EMPTY); - const load = useCallback(async () => { + // Fetch + merge the target workspaces' live sessions. Returns the stable + // EMPTY sentinel when there is nothing to fetch or every list came back + // empty, so `setSessions` is a reference-equal no-op re-render in that case. + const fetchSessions = useCallback(async (): Promise< + DaemonSessionSummary[] + > => { const cwds = targetsKey ? targetsKey.split('\n') : []; - if (cwds.length === 0) { - setSessions((prev) => (prev.length === 0 ? prev : EMPTY)); - return; - } + if (cwds.length === 0) return EMPTY; const settled = await Promise.allSettled( cwds.map((cwd) => // Match the primary list's page size (both callers fetch the primary @@ -83,12 +85,35 @@ export function useOtherWorkspaceSessions(): OtherWorkspaceSessionsResult { ); } }); - setSessions(merged); + return merged.length === 0 ? EMPTY : merged; }, [client, targetsKey]); + 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]); + + // Load on mount and whenever the target set changes. With no other + // workspaces (the single-workspace daemon) this stays fully synchronous — no + // fetch, no post-render `setState` — so the common path never even touches + // the daemon. The `cancelled` guard stops a slow in-flight fetch from + // overwriting a newer one when the target set changes mid-flight (e.g. a + // workspace is registered / unregistered). useEffect(() => { - void load(); - }, [load]); + if (!targetsKey) { + setSessions((prev) => (prev.length === 0 ? prev : EMPTY)); + return; + } + let cancelled = false; + void fetchSessions().then((result) => { + if (!cancelled) setSessions(result); + }); + return () => { + cancelled = true; + }; + }, [targetsKey, fetchSessions]); - return { sessions, reload: load }; + return { sessions, reload }; } diff --git a/packages/web-shell/client/utils/workspace.test.ts b/packages/web-shell/client/utils/workspace.test.ts index f8b57152c2f..b6afb4a2855 100644 --- a/packages/web-shell/client/utils/workspace.test.ts +++ b/packages/web-shell/client/utils/workspace.test.ts @@ -83,6 +83,13 @@ describe('mergeSessionsById', () => { expect(merged.map((s) => s.sessionId)).toEqual(['a', 'b']); }); + it('returns only other-workspace sessions when the primary list is empty', () => { + // The primary workspace may have no live sessions while a non-primary one + // does — the early same-ref return is skipped and every other is inserted. + const merged = mergeSessionsById([], [session('b', '/b')]); + expect(merged.map((s) => s.sessionId)).toEqual(['b']); + }); + it('keeps the primary entry on an id collision', () => { const merged = mergeSessionsById( [session('a', '/w')], From c58e667127b29282e0ffc9604ea1098235068c02 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 12 Jul 2026 11:45:48 +0800 Subject: [PATCH 04/14] feat(web-shell): show each split pane's workspace in its composer placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 …"), so it's clear which workspace a message is going to before you send it. Single-workspace daemons are unchanged. --- .../client/components/ChatPane.test.tsx | 22 +++++++++++++++++++ .../web-shell/client/components/ChatPane.tsx | 13 ++++++++++- packages/web-shell/client/i18n.tsx | 4 ++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx index 70fdb1ba754..314d19bd43e 100644 --- a/packages/web-shell/client/components/ChatPane.test.tsx +++ b/packages/web-shell/client/components/ChatPane.test.tsx @@ -282,6 +282,28 @@ describe('ChatPane', () => { expect(container!.textContent).toContain('Refactor core'); }); + it('uses the plain composer placeholder on a single-workspace daemon', () => { + render({ title: 'Refactor core' }); + expect(latestChatEditorProps.placeholderText).toBe('Message this session…'); + }); + + it('names the pane workspace in the composer placeholder on a multi-workspace daemon', () => { + // This pane's session lives in the non-primary "api" workspace. + connectionState.workspaceCwd = '/work/api'; + connectionState.capabilities = { + features: [], + workspaceCwd: '/work/web-shell', + workspaces: [ + { id: 'w0', cwd: '/work/web-shell', primary: true, trusted: true }, + { id: 'w1', cwd: '/work/api', primary: false, trusted: true }, + ], + }; + render({ title: 'Add pagination' }); + expect(latestChatEditorProps.placeholderText).toBe( + 'Message this session in api…', + ); + }); + it('reports loaded pane artifacts to the outer panel owner', async () => { const onPaneArtifactsChange = vi.fn(); connectionState.capabilities = { features: ['session_artifacts'] }; diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index 4beb976b0da..ec134ed5a8e 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -31,6 +31,7 @@ import { isAskUserPermission } from '../utils/askUserPermission'; import { isDaemonApprovalMode } from '../utils/sessionPreparation'; import { isVisibleComposerModel } from '../utils/composerModels'; import { getModelDisplayName } from '../utils/modelDisplay'; +import { hasMultipleWorkspaces, workspaceBasename } from '../utils/workspace'; import { getLocalCommands, localizeBuiltinDescriptions, @@ -360,6 +361,16 @@ export function ChatPane({ const headerLabel = title || connection.displayName || connection.sessionId?.slice(0, 8) || ''; + // On a multi-workspace daemon, name the pane's workspace in the composer + // placeholder so it's clear which workspace a message is going to. Single + // workspace: unchanged. + const composerPlaceholder = + hasMultipleWorkspaces(connection.capabilities) && connection.workspaceCwd + ? t('splitView.composerPlaceholderWorkspace', { + workspace: workspaceBasename(connection.workspaceCwd), + }) + : t('splitView.composerPlaceholder'); + return (
diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index b4df133c521..85cd9c7111f 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -1724,6 +1724,8 @@ const EN: Messages = { 'splitView.goToApproval': 'Go to it', 'splitView.empty': 'No sessions in the split. Add one to get started.', 'splitView.composerPlaceholder': 'Message this session…', + 'splitView.composerPlaceholderWorkspace': (v) => + `Message this session in ${v?.workspace ?? ''}…`, 'settings.title': 'Settings', 'settings.loading': 'Loading settings...', 'settings.empty': 'No settings available.', @@ -3421,6 +3423,8 @@ const ZH: Messages = { 'splitView.goToApproval': '前往处理', 'splitView.empty': '分屏中还没有会话,添加一个开始。', 'splitView.composerPlaceholder': '给这个会话发消息…', + 'splitView.composerPlaceholderWorkspace': (v) => + `给 ${v?.workspace ?? ''} 的这个会话发消息…`, 'settings.title': '设置', 'settings.loading': '正在加载设置...', 'settings.empty': '暂无可用设置。', From 30f44d819136aaafb97147aafcc8ffc8f460a65d Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 12 Jul 2026 13:00:21 +0800 Subject: [PATCH 05/14] Revert "feat(web-shell): show each split pane's workspace in its composer placeholder" This reverts commit c58e667127b29282e0ffc9604ea1098235068c02. --- .../client/components/ChatPane.test.tsx | 22 ------------------- .../web-shell/client/components/ChatPane.tsx | 13 +---------- packages/web-shell/client/i18n.tsx | 4 ---- 3 files changed, 1 insertion(+), 38 deletions(-) diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx index 314d19bd43e..70fdb1ba754 100644 --- a/packages/web-shell/client/components/ChatPane.test.tsx +++ b/packages/web-shell/client/components/ChatPane.test.tsx @@ -282,28 +282,6 @@ describe('ChatPane', () => { expect(container!.textContent).toContain('Refactor core'); }); - it('uses the plain composer placeholder on a single-workspace daemon', () => { - render({ title: 'Refactor core' }); - expect(latestChatEditorProps.placeholderText).toBe('Message this session…'); - }); - - it('names the pane workspace in the composer placeholder on a multi-workspace daemon', () => { - // This pane's session lives in the non-primary "api" workspace. - connectionState.workspaceCwd = '/work/api'; - connectionState.capabilities = { - features: [], - workspaceCwd: '/work/web-shell', - workspaces: [ - { id: 'w0', cwd: '/work/web-shell', primary: true, trusted: true }, - { id: 'w1', cwd: '/work/api', primary: false, trusted: true }, - ], - }; - render({ title: 'Add pagination' }); - expect(latestChatEditorProps.placeholderText).toBe( - 'Message this session in api…', - ); - }); - it('reports loaded pane artifacts to the outer panel owner', async () => { const onPaneArtifactsChange = vi.fn(); connectionState.capabilities = { features: ['session_artifacts'] }; diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index ec134ed5a8e..4beb976b0da 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -31,7 +31,6 @@ import { isAskUserPermission } from '../utils/askUserPermission'; import { isDaemonApprovalMode } from '../utils/sessionPreparation'; import { isVisibleComposerModel } from '../utils/composerModels'; import { getModelDisplayName } from '../utils/modelDisplay'; -import { hasMultipleWorkspaces, workspaceBasename } from '../utils/workspace'; import { getLocalCommands, localizeBuiltinDescriptions, @@ -361,16 +360,6 @@ export function ChatPane({ const headerLabel = title || connection.displayName || connection.sessionId?.slice(0, 8) || ''; - // On a multi-workspace daemon, name the pane's workspace in the composer - // placeholder so it's clear which workspace a message is going to. Single - // workspace: unchanged. - const composerPlaceholder = - hasMultipleWorkspaces(connection.capabilities) && connection.workspaceCwd - ? t('splitView.composerPlaceholderWorkspace', { - workspace: workspaceBasename(connection.workspaceCwd), - }) - : t('splitView.composerPlaceholder'); - return (
diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 85cd9c7111f..b4df133c521 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -1724,8 +1724,6 @@ const EN: Messages = { 'splitView.goToApproval': 'Go to it', 'splitView.empty': 'No sessions in the split. Add one to get started.', 'splitView.composerPlaceholder': 'Message this session…', - 'splitView.composerPlaceholderWorkspace': (v) => - `Message this session in ${v?.workspace ?? ''}…`, 'settings.title': 'Settings', 'settings.loading': 'Loading settings...', 'settings.empty': 'No settings available.', @@ -3423,8 +3421,6 @@ const ZH: Messages = { 'splitView.goToApproval': '前往处理', 'splitView.empty': '分屏中还没有会话,添加一个开始。', 'splitView.composerPlaceholder': '给这个会话发消息…', - 'splitView.composerPlaceholderWorkspace': (v) => - `给 ${v?.workspace ?? ''} 的这个会话发消息…`, 'settings.title': '设置', 'settings.loading': '正在加载设置...', 'settings.empty': '暂无可用设置。', From ea32215d8accf49d8de49e5dc7a411a8cd137e4e Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 12 Jul 2026 14:09:37 +0800 Subject: [PATCH 06/14] feat(web-shell): label each split pane's workspace in its composer toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../client/components/ChatEditor.module.css | 45 ++++++++++++++++ .../client/components/ChatEditor.test.tsx | 32 +++++++++++ .../client/components/ChatEditor.tsx | 20 ++++++- .../client/components/ChatPane.test.tsx | 25 +++++++++ .../web-shell/client/components/ChatPane.tsx | 29 +++++++++- .../web-shell/client/components/SplitView.tsx | 1 + .../client/components/WorkspaceIndicator.tsx | 53 +++++++++++++++++++ packages/web-shell/client/i18n.tsx | 2 + 8 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 packages/web-shell/client/components/WorkspaceIndicator.tsx diff --git a/packages/web-shell/client/components/ChatEditor.module.css b/packages/web-shell/client/components/ChatEditor.module.css index 1652fc4c02b..30598d3d272 100644 --- a/packages/web-shell/client/components/ChatEditor.module.css +++ b/packages/web-shell/client/components/ChatEditor.module.css @@ -934,6 +934,42 @@ white-space: nowrap; } +/* Which workspace a split-view pane's session lives in — parallel to the git + branch chip, shown only on a multi-workspace daemon. */ +.workspaceChip { + display: inline-flex; + min-width: 0; + max-width: 160px; + height: 28px; + padding: 0 8px; + align-items: center; + gap: 5px; + border-radius: 6px; + color: var(--agent-gray-500); + font-family: var(--font-sans, system-ui, sans-serif); + font-size: 13px; + line-height: 1; +} + +.workspaceChipIcon { + display: inline-flex; + width: 16px; + height: 16px; + flex: 0 0 16px; +} + +.workspaceChipIcon svg { + width: 16px; + height: 16px; +} + +.workspaceChipText { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .toolbarRight { flex-shrink: 0; margin-right: -5px; @@ -955,6 +991,15 @@ .toolbarLeft .toolBtnText { display: none; } + + /* Keep the workspace name legible in a narrow split pane — it is the pane's + identity, so unlike the action buttons it never collapses to an icon; + it only tightens and truncates (the full cwd stays in the tooltip). */ + .toolbarLeft .workspaceChip { + max-width: 108px; + padding: 0 6px; + gap: 4px; + } } .dropdownWrapper { diff --git a/packages/web-shell/client/components/ChatEditor.test.tsx b/packages/web-shell/client/components/ChatEditor.test.tsx index 0c465b86ec6..a722419cf33 100644 --- a/packages/web-shell/client/components/ChatEditor.test.tsx +++ b/packages/web-shell/client/components/ChatEditor.test.tsx @@ -107,6 +107,8 @@ afterEach(() => { function renderChatEditor(props: { gitBranch?: string; + workspaceName?: string; + workspaceTitle?: string; visibleToolbarActions?: readonly ComposerToolbarAction[]; }) { const container = document.createElement('div'); @@ -160,3 +162,33 @@ describe('ChatEditor git branch toolbar integration', () => { ).toBeNull(); }); }); + +describe('ChatEditor workspace toolbar integration', () => { + it('shows the workspace indicator when the workspace action is visible', () => { + const container = renderChatEditor({ + workspaceName: 'api', + workspaceTitle: '/work/api', + visibleToolbarActions: ['workspace'], + }); + const chip = container.querySelector('[aria-label="Workspace: api"]'); + expect(chip).not.toBeNull(); + expect(chip?.getAttribute('title')).toBe('/work/api'); + expect( + container.querySelector('[data-web-shell-workspace]'), + ).not.toBeNull(); + }); + + it('hides the workspace indicator without a name or visible action', () => { + expect( + renderChatEditor({ + visibleToolbarActions: ['workspace'], + }).querySelector('[aria-label^="Workspace:"]'), + ).toBeNull(); + expect( + renderChatEditor({ + workspaceName: 'api', + visibleToolbarActions: [], + }).querySelector('[aria-label^="Workspace:"]'), + ).toBeNull(); + }); +}); diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index 70dbf7ecc78..23e464c9516 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -43,6 +43,7 @@ import { planSlashSectionRows } from '../utils/slashSectionPlan'; import { getModelDisplayName } from '../utils/modelDisplay'; import { VoiceButton } from '../voice/VoiceButton'; import { GitBranchIndicator } from './GitBranchIndicator'; +import { WorkspaceIndicator } from './WorkspaceIndicator'; import styles from './ChatEditor.module.css'; export type ComposerToolbarAction = @@ -52,7 +53,8 @@ export type ComposerToolbarAction = | 'commands' | 'files' | 'widthMode' - | 'voice'; + | 'voice' + | 'workspace'; const ACTIVE_TOOLBAR_ACTIONS = [ 'approvalMode', @@ -60,6 +62,7 @@ const ACTIVE_TOOLBAR_ACTIONS = [ 'model', 'widthMode', 'voice', + 'workspace', ] as const satisfies readonly ComposerToolbarAction[]; const ACTIVE_TOOLBAR_ACTION_SET = new Set( ACTIVE_TOOLBAR_ACTIONS, @@ -90,6 +93,10 @@ interface ChatEditorProps { currentMode?: string; currentModel?: string; gitBranch?: string; + /** Workspace name shown in the pane composer's `workspace` toolbar chip. */ + workspaceName?: string; + /** Full workspace cwd, used as the chip's tooltip. */ + workspaceTitle?: string; chatWidthMode?: '1000' | 'wide'; showChatWidthToggle?: boolean; chatWidthToggleMin?: number; @@ -918,6 +925,8 @@ export const ChatEditor = memo( currentMode = 'default', currentModel = '', gitBranch, + workspaceName, + workspaceTitle, chatWidthMode = '1000', showChatWidthToggle = true, chatWidthToggleMin, @@ -1558,6 +1567,15 @@ export const ChatEditor = memo( ariaLabel={t('git.currentBranch', { branch: gitBranch })} /> )} + {workspaceName && showToolbarAction('workspace') && ( + + )} {showToolbarAction('approvalMode') && (
({ }), usePromptStatus: () => 'idle', useWorkspaceActions: () => ({}), + useWorkspace: () => ({ capabilities: connectionState.capabilities }), useWorkspaceEventSignals: () => ({ artifactsVersion: 0 }), })); @@ -282,6 +283,30 @@ describe('ChatPane', () => { expect(container!.textContent).toContain('Refactor core'); }); + it('adds no workspace toolbar chip on a single-workspace daemon', () => { + render({ title: 'Refactor core', workspaceCwd: '/w' }); + expect(latestChatEditorProps.visibleToolbarActions).not.toContain( + 'workspace', + ); + expect(latestChatEditorProps.workspaceName).toBeUndefined(); + }); + + it('shows the pane workspace as a toolbar chip on a multi-workspace daemon', () => { + connectionState.capabilities = { + features: [], + workspaceCwd: '/work/web-shell', + workspaces: [ + { id: 'w0', cwd: '/work/web-shell', primary: true, trusted: true }, + { id: 'w1', cwd: '/work/api', primary: false, trusted: true }, + ], + }; + // The split view hands each pane its own workspace explicitly. + render({ title: 'Add pagination', workspaceCwd: '/work/api' }); + expect(latestChatEditorProps.visibleToolbarActions).toContain('workspace'); + expect(latestChatEditorProps.workspaceName).toBe('api'); + expect(latestChatEditorProps.workspaceTitle).toBe('/work/api'); + }); + it('reports loaded pane artifacts to the outer panel owner', async () => { const onPaneArtifactsChange = vi.fn(); connectionState.capabilities = { features: ['session_artifacts'] }; diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index 4beb976b0da..0d86c3f4aa6 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -12,6 +12,7 @@ import { useStreamingState, useTranscriptBlocks, useTranscriptStore, + useWorkspace, useWorkspaceActions, type DaemonWorkspaceActions, } from '@qwen-code/webui/daemon-react-sdk'; @@ -31,6 +32,7 @@ import { isAskUserPermission } from '../utils/askUserPermission'; import { isDaemonApprovalMode } from '../utils/sessionPreparation'; import { isVisibleComposerModel } from '../utils/composerModels'; import { getModelDisplayName } from '../utils/modelDisplay'; +import { hasMultipleWorkspaces, workspaceBasename } from '../utils/workspace'; import { getLocalCommands, localizeBuiltinDescriptions, @@ -68,6 +70,12 @@ const PANE_TOOLBAR_ACTIONS: readonly ComposerToolbarAction[] = [ export interface ChatPaneProps { /** Header label; falls back to the session's own display name / id. */ title?: string; + /** + * The workspace this pane's session lives in. Passed explicitly by the split + * view (which knows it per session) and shown as a composer-toolbar chip on a + * multi-workspace daemon; falls back to the connection's own workspace. + */ + workspaceCwd?: string; onClose?: () => void; onError?: (error: unknown, fallback: string) => void; onRightPanelOpen?: (request: TurnOutputOpenRequest) => void; @@ -88,6 +96,7 @@ export interface ChatPaneProps { */ export function ChatPane({ title, + workspaceCwd, onClose, onError, onRightPanelOpen, @@ -98,6 +107,7 @@ export function ChatPane({ const connection = useConnection(); const actions = useActions(); const workspaceActions = useWorkspaceActions(); + const workspace = useWorkspace(); const messages = useMessages(t); const blocks = useTranscriptBlocks(); const store = useTranscriptStore(); @@ -360,6 +370,17 @@ export function ChatPane({ const headerLabel = title || connection.displayName || connection.sessionId?.slice(0, 8) || ''; + // On a multi-workspace daemon, surface this pane's workspace as a composer- + // toolbar chip (next to where the git-branch chip sits), so it's clear which + // workspace a message goes to. Multi-workspace-ness comes from the shared + // workspace provider (the pane's own session connection may not carry it). + const paneWorkspaceCwd = workspaceCwd ?? connection.workspaceCwd; + const showWorkspaceChip = + hasMultipleWorkspaces(workspace.capabilities) && !!paneWorkspaceCwd; + const paneToolbarActions = showWorkspaceChip + ? [...PANE_TOOLBAR_ACTIONS, 'workspace' as const] + : PANE_TOOLBAR_ACTIONS; + return (
removePane(sessionId)} onError={onError} onRightPanelOpen={onRightPanelOpen} diff --git a/packages/web-shell/client/components/WorkspaceIndicator.tsx b/packages/web-shell/client/components/WorkspaceIndicator.tsx new file mode 100644 index 00000000000..4019d6ac3d3 --- /dev/null +++ b/packages/web-shell/client/components/WorkspaceIndicator.tsx @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import styles from './ChatEditor.module.css'; + +function WorkspaceFolderIcon() { + return ( + + ); +} + +/** + * A compact, non-interactive chip naming the workspace a split-view pane's + * session belongs to. Mirrors {@link GitBranchIndicator}; both sit in the + * composer toolbar. Shown only on a multi-workspace daemon (the pane composer + * opts into the `workspace` toolbar action) so it's clear which workspace a + * message goes to. Unlike the toolbar action buttons, the name stays visible as + * the pane narrows — it's the pane's identity — and only tightens and ellipsizes + * (the full cwd stays in the tooltip). + */ +export function WorkspaceIndicator({ + name, + title, + ariaLabel, +}: { + name: string; + title: string; + ariaLabel: string; +}) { + return ( + + + + + {name} + + ); +} diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 25cb7ff9581..3f0ee0a63c1 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -17,6 +17,7 @@ type Messages = Record; const EN: Messages = { 'git.currentBranch': (v) => `Current Git branch: ${v?.branch ?? ''}`, + 'workspace.paneLabel': (v) => `Workspace: ${v?.name ?? ''}`, 'about.auth': 'Auth', 'about.baseUrl': 'Base URL', 'about.fastModel': 'Fast Model', @@ -1764,6 +1765,7 @@ const EN: Messages = { const ZH: Messages = { ...EN, 'git.currentBranch': (v) => `当前 Git 分支:${v?.branch ?? ''}`, + 'workspace.paneLabel': (v) => `工作区:${v?.name ?? ''}`, // Tool display names (chat-stream badge labels). Keyed by `toolName.`; // a wire name with no entry here falls back to the English display name via // `localizeToolDisplayName`. Proper tool names / acronyms stay in English From aadea86695478d9aa0f8cc3ff24e4fb32960aeba Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 12 Jul 2026 14:39:15 +0800 Subject: [PATCH 07/14] feat(web-shell): show the workspace chip in the main composer too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/web-shell/client/App.tsx | 8 ++++++++ .../client/components/ChatEditor.test.tsx | 17 +++++++++++++++++ .../web-shell/client/components/ChatEditor.tsx | 12 ++++++------ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 1c57f643630..b18f3dce7f3 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -126,6 +126,7 @@ import { } from './utils/copyCommand'; import { isEditableTarget } from './utils/dom'; import { getModelDisplayName } from './utils/modelDisplay'; +import { hasMultipleWorkspaces, workspaceBasename } from './utils/workspace'; import { isVisibleComposerModel } from './utils/composerModels'; import { filterModelSwitchMessages } from './utils/modelSwitchMessages'; import { decideEscapeIntent } from './utils/escapeIntent'; @@ -5933,6 +5934,13 @@ export function App({ currentMode={currentMode} currentModel={currentModel} gitBranch={connection.gitBranch} + workspaceName={ + hasMultipleWorkspaces(connection.capabilities) && + connection.workspaceCwd + ? workspaceBasename(connection.workspaceCwd) + : undefined + } + workspaceTitle={connection.workspaceCwd || undefined} chatWidthMode={chatWidthMode} showChatWidthToggle={!isChatEmptyState} chatWidthToggleMin={chatWidthToggleMin} diff --git a/packages/web-shell/client/components/ChatEditor.test.tsx b/packages/web-shell/client/components/ChatEditor.test.tsx index a722419cf33..ddb660e43da 100644 --- a/packages/web-shell/client/components/ChatEditor.test.tsx +++ b/packages/web-shell/client/components/ChatEditor.test.tsx @@ -191,4 +191,21 @@ describe('ChatEditor workspace toolbar integration', () => { }).querySelector('[aria-label^="Workspace:"]'), ).toBeNull(); }); + + it('renders the workspace chip before the git branch chip', () => { + const container = renderChatEditor({ + gitBranch: 'main', + workspaceName: 'api', + workspaceTitle: '/work/api', + visibleToolbarActions: ['workspace', 'gitBranch'], + }); + const ws = container.querySelector('[data-web-shell-workspace]'); + const git = container.querySelector('[data-web-shell-git-branch]'); + expect(ws).not.toBeNull(); + expect(git).not.toBeNull(); + // The workspace chip must precede the git-branch chip in document order. + expect( + ws!.compareDocumentPosition(git!) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); }); diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index 23e464c9516..acbabde2e87 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -1561,12 +1561,6 @@ export const ChatEditor = memo(
)}
- {gitBranch && showToolbarAction('gitBranch') && ( - - )} {workspaceName && showToolbarAction('workspace') && ( )} + {gitBranch && showToolbarAction('gitBranch') && ( + + )} {showToolbarAction('approvalMode') && (
Date: Sun, 12 Jul 2026 14:57:37 +0800 Subject: [PATCH 08/14] fix(web-shell): keep a session when a shrink closes the split view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/web-shell/client/App.test.tsx | 48 ++++++++++++++++++++++++++ packages/web-shell/client/App.tsx | 27 ++++++++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index af16d5d63e0..b9679e4ee93 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -1895,6 +1895,54 @@ describe('App session callbacks', () => { ).toBeNull(); }); + it('lands on the first pane, not an empty new chat, when a shrink closes a URL-driven split', async () => { + let large = true; + let changeHandler: ((event: { matches: boolean }) => void) | undefined; + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + get matches() { + return query.includes('min-width') ? large : false; + }, + media: query, + addEventListener: ( + _type: string, + cb: (event: { matches: boolean }) => void, + ) => { + if (query.includes('min-width')) changeHandler = cb; + }, + removeEventListener: vi.fn(), + })), + }); + // The single chat has no session of its own — the split was entered from a + // `?split=` deep link — so a naive close would strand on an empty new chat. + mockConnection.sessionId = undefined; + window.history.replaceState(null, '', '/?split=s1,s2'); + + try { + const { container } = renderApp(); + await flush(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + + await act(async () => { + large = false; + changeHandler?.({ matches: false }); + await Promise.resolve(); + }); + + // The split folds back to chat and re-attaches to the first pane's + // session instead of stranding the user on an empty new chat. + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); + expect(mockSessionActions.loadSession).toHaveBeenCalledWith('s1'); + } finally { + window.history.replaceState(null, '', '/'); + } + }); + it('auto-closes the Session Overview when the screen shrinks below the breakpoint', async () => { // Drive isLargeScreen through a controllable media query: open the panel on // a large screen, then flip below the breakpoint and confirm it closes. diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index b18f3dce7f3..fa6c256b573 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -1922,6 +1922,10 @@ export function App({ ); // Sessions to seed the split view with (e.g. the selection from the overview). const [splitSessionIds, setSplitSessionIds] = useState([]); + // Latest pane list, readable from the shrink-close effect without making it a + // dependency (it changes on every pane add/remove). + const splitSessionIdsRef = useRef(splitSessionIds); + splitSessionIdsRef.current = splitSessionIds; const [showExtensionsDialog, setShowExtensionsDialog] = useState(false); const [mcpDialogMessage, setMcpDialogMessage] = useState(null); @@ -2076,8 +2080,29 @@ export function App({ notifyControlledSplitClose(); setMainView('chat'); focusComposerAfterSplitCloseRef.current = true; + // If the chat we fall back to has no session of its own — the common case + // when the split was entered straight from the Session Overview or a + // `?split=a,b` link — land on the split's first pane instead of stranding + // the user on an empty "new chat". Best-effort: a load failure (e.g. a + // non-primary-workspace session the single connection can't own) just + // leaves the empty chat, i.e. the previous behavior. + const firstPane = splitSessionIdsRef.current[0]; + if ( + firstPane && + !currentSessionIdRef.current && + !externalSplitControlled + ) { + void sessionActions.loadSession(firstPane).catch(() => undefined); + } } - }, [isLargeScreen, activePanel, mainView, notifyControlledSplitClose]); + }, [ + isLargeScreen, + activePanel, + mainView, + notifyControlledSplitClose, + sessionActions, + externalSplitControlled, + ]); // Land focus on the composer after a shrink-driven split close so keyboard // users aren't dropped onto — but not when the chat now shows an // approval overlay (it owns the keyboard) or a panel (its Back self-focuses). From 4cc5fb4fca8f9bbf1b37db5a9d9646240cbbaabe Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 12 Jul 2026 15:29:00 +0800 Subject: [PATCH 09/14] fix(web-shell): restore the split view when the screen grows back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/web-shell/client/App.test.tsx | 53 ++++++++++++++++++++++++++ packages/web-shell/client/App.tsx | 49 +++++++++++++++++------- 2 files changed, 88 insertions(+), 14 deletions(-) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index b9679e4ee93..0a710b44601 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -1943,6 +1943,59 @@ describe('App session callbacks', () => { } }); + it('restores the split view when the screen grows back after a shrink', async () => { + let large = true; + let changeHandler: ((event: { matches: boolean }) => void) | undefined; + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + get matches() { + return query.includes('min-width') ? large : false; + }, + media: query, + addEventListener: ( + _type: string, + cb: (event: { matches: boolean }) => void, + ) => { + if (query.includes('min-width')) changeHandler = cb; + }, + removeEventListener: vi.fn(), + })), + }); + window.history.replaceState(null, '', '/?split=s1,s2'); + + try { + const { container } = renderApp(); + await flush(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + + // Shrinking below the breakpoint folds the split away... + await act(async () => { + large = false; + changeHandler?.({ matches: false }); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); + + // ...and growing back past it restores the same split (a transient resize + // is lossless, not a permanent drop of the panes). + await act(async () => { + large = true; + changeHandler?.({ matches: true }); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + } finally { + window.history.replaceState(null, '', '/'); + } + }); + it('auto-closes the Session Overview when the screen shrinks below the breakpoint', async () => { // Drive isLargeScreen through a controllable media query: open the panel on // a large screen, then flip below the breakpoint and confirm it closes. diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index fa6c256b573..803d2a8d125 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -2065,27 +2065,48 @@ export function App({ openSplitView(ids); } }, [externalSplitControlled, openSplitView]); - // If the viewport shrinks below the large-screen breakpoint, close the Session - // Overview panel and the split view — both are large-screen-only surfaces - // whose entry points are hidden on small screens, so leaving them up would - // strand the user in a view they can no longer re-enter. - // When a shrink closes the split, its panes unmount and take keyboard focus - // with them; flag the composer to be refocused once the chat is shown again. + // If the viewport shrinks below the large-screen breakpoint, fold away the + // Session Overview panel and the split view — both are large-screen-only + // surfaces whose entry points are hidden on small screens. The split is only + // folded, not discarded: growing back past the breakpoint restores it, so a + // transient resize is lossless. When a shrink folds the split, its panes + // unmount and take keyboard focus with them; flag the composer to be refocused + // once the chat is shown again. const focusComposerAfterSplitCloseRef = useRef(false); + // True while the split view is only *temporarily* folded away because the + // window is narrower than the large-screen breakpoint. Growing back past the + // breakpoint restores it, so a transient resize doesn't drop the user's panes. + const splitFoldedByShrinkRef = useRef(false); useEffect(() => { - if (!isLargeScreen && activePanel === 'sessions') { + if (isLargeScreen) { + // Grew back above the breakpoint: restore a split that a shrink folded + // away. Standalone/uncontrolled only — a controlled host owns its split + // lifecycle and re-opens it itself. + if (splitFoldedByShrinkRef.current) { + splitFoldedByShrinkRef.current = false; + if (!externalSplitControlled && splitSessionIdsRef.current.length > 0) { + setMainView((prev) => (prev === 'chat' ? 'split' : prev)); + } + } + return; + } + if (activePanel === 'sessions') { setActivePanel(null); } - if (!isLargeScreen && mainView === 'split') { + if (mainView === 'split') { notifyControlledSplitClose(); setMainView('chat'); focusComposerAfterSplitCloseRef.current = true; - // If the chat we fall back to has no session of its own — the common case - // when the split was entered straight from the Session Overview or a - // `?split=a,b` link — land on the split's first pane instead of stranding - // the user on an empty "new chat". Best-effort: a load failure (e.g. a - // non-primary-workspace session the single connection can't own) just - // leaves the empty chat, i.e. the previous behavior. + // Remember to restore the split once the screen grows back, so a transient + // shrink is lossless rather than permanently dropping the panes. + if (!externalSplitControlled) { + splitFoldedByShrinkRef.current = true; + } + // Meanwhile give the folded-down chat a session instead of stranding the + // user on an empty new chat (the common case when the split came from the + // Session Overview or a `?split=a,b` link). Best-effort; a load failure + // (e.g. a non-primary-workspace session the single connection can't own) + // just leaves the empty chat. const firstPane = splitSessionIdsRef.current[0]; if ( firstPane && From 4a04476a2f3f8f10a78168222dc9699ec23e4d89 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 12 Jul 2026 15:57:29 +0800 Subject: [PATCH 10/14] fix(web-shell): keep the chat's git branch when folding the split on shrink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/web-shell/client/App.test.tsx | 13 ++++++------- packages/web-shell/client/App.tsx | 20 ++++---------------- 2 files changed, 10 insertions(+), 23 deletions(-) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 0a710b44601..731267cf0f3 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -1895,7 +1895,7 @@ describe('App session callbacks', () => { ).toBeNull(); }); - it('lands on the first pane, not an empty new chat, when a shrink closes a URL-driven split', async () => { + it('folds the split without switching the chat session on shrink', async () => { let large = true; let changeHandler: ((event: { matches: boolean }) => void) | undefined; Object.defineProperty(window, 'matchMedia', { @@ -1914,9 +1914,7 @@ describe('App session callbacks', () => { removeEventListener: vi.fn(), })), }); - // The single chat has no session of its own — the split was entered from a - // `?split=` deep link — so a naive close would strand on an empty new chat. - mockConnection.sessionId = undefined; + mockConnection.sessionId = 'session-1'; window.history.replaceState(null, '', '/?split=s1,s2'); try { @@ -1932,12 +1930,13 @@ describe('App session callbacks', () => { await Promise.resolve(); }); - // The split folds back to chat and re-attaches to the first pane's - // session instead of stranding the user on an empty new chat. + // The split folds back to chat, but folding must leave the chat's own + // connection untouched — switching sessions here would drop its session / + // git-branch / URL context and break the lossless restore on regrow. expect( container.querySelector('[data-testid="split-view-page"]'), ).toBeNull(); - expect(mockSessionActions.loadSession).toHaveBeenCalledWith('s1'); + expect(mockSessionActions.loadSession).not.toHaveBeenCalled(); } finally { window.history.replaceState(null, '', '/'); } diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 803d2a8d125..2aa0d191a59 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -2097,31 +2097,19 @@ export function App({ notifyControlledSplitClose(); setMainView('chat'); focusComposerAfterSplitCloseRef.current = true; - // Remember to restore the split once the screen grows back, so a transient - // shrink is lossless rather than permanently dropping the panes. + // Fold, don't discard: remember to restore the same split once the screen + // grows back, so a transient shrink is lossless. The chat's own connection + // (its session, git branch, URL, …) is left untouched — restoring the + // split, or dropping back to that chat, is exactly what it was before. if (!externalSplitControlled) { splitFoldedByShrinkRef.current = true; } - // Meanwhile give the folded-down chat a session instead of stranding the - // user on an empty new chat (the common case when the split came from the - // Session Overview or a `?split=a,b` link). Best-effort; a load failure - // (e.g. a non-primary-workspace session the single connection can't own) - // just leaves the empty chat. - const firstPane = splitSessionIdsRef.current[0]; - if ( - firstPane && - !currentSessionIdRef.current && - !externalSplitControlled - ) { - void sessionActions.loadSession(firstPane).catch(() => undefined); - } } }, [ isLargeScreen, activePanel, mainView, notifyControlledSplitClose, - sessionActions, externalSplitControlled, ]); // Land focus on the composer after a shrink-driven split close so keyboard From 5a2182412ee0944c5a7a05d771ca15e3cf711925 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 12 Jul 2026 16:37:46 +0800 Subject: [PATCH 11/14] feat(web-shell): auto-collapse the sidebar in a narrow split view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/web-shell/client/App.test.tsx | 59 +++++++++++++++++++++++--- packages/web-shell/client/App.tsx | 11 ++++- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 731267cf0f3..4358a094bf9 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -337,6 +337,7 @@ vi.mock('./components/sidebar/WebShellSidebar', async () => { return { WebShellSidebar: (props: { sessionListReloadToken?: number; + collapsed?: boolean; onOpenDaemonStatus?: () => void; onOpenSessions?: () => void; onOpenSplitView?: () => void; @@ -346,7 +347,10 @@ vi.mock('./components/sidebar/WebShellSidebar', async () => { // exercise those activePanel branches (neither has a slash command). return React.createElement( 'div', - { 'data-testid': 'sidebar' }, + { + 'data-testid': 'sidebar', + 'data-collapsed': String(Boolean(props.collapsed)), + }, React.createElement( 'button', { @@ -1822,7 +1826,7 @@ describe('App session callbacks', () => { _type: string, cb: (event: { matches: boolean }) => void, ) => { - if (query.includes('min-width')) changeHandler = cb; + if (query.includes('1024')) changeHandler = cb; }, removeEventListener: vi.fn(), })), @@ -1866,7 +1870,7 @@ describe('App session callbacks', () => { _type: string, cb: (event: { matches: boolean }) => void, ) => { - if (query.includes('min-width')) changeHandler = cb; + if (query.includes('1024')) changeHandler = cb; }, removeEventListener: vi.fn(), })), @@ -1909,7 +1913,7 @@ describe('App session callbacks', () => { _type: string, cb: (event: { matches: boolean }) => void, ) => { - if (query.includes('min-width')) changeHandler = cb; + if (query.includes('1024')) changeHandler = cb; }, removeEventListener: vi.fn(), })), @@ -1956,7 +1960,7 @@ describe('App session callbacks', () => { _type: string, cb: (event: { matches: boolean }) => void, ) => { - if (query.includes('min-width')) changeHandler = cb; + if (query.includes('1024')) changeHandler = cb; }, removeEventListener: vi.fn(), })), @@ -1995,6 +1999,49 @@ describe('App session callbacks', () => { } }); + it('auto-collapses the sidebar in a narrow split and expands it when wide', async () => { + let wide = false; + let changeHandler: ((event: { matches: boolean }) => void) | undefined; + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + get matches() { + // Keep the large-screen (>=1024) query true so the split renders; + // the >=1200 "sidebar has room" query is the one under test. + if (query.includes('1200')) return wide; + return query.includes('min-width'); + }, + media: query, + addEventListener: ( + _type: string, + cb: (event: { matches: boolean }) => void, + ) => { + if (query.includes('1200')) changeHandler = cb; + }, + removeEventListener: vi.fn(), + })), + }); + window.history.replaceState(null, '', '/?split=s1,s2'); + + try { + const { container } = renderApp(); + await flush(); + const sidebar = () => container.querySelector('[data-testid="sidebar"]'); + // Narrow split (< 1200px): the sidebar collapses to free room for panes. + expect(sidebar()?.getAttribute('data-collapsed')).toBe('true'); + + // Grow past 1200px: the sidebar expands again. + await act(async () => { + wide = true; + changeHandler?.({ matches: true }); + await Promise.resolve(); + }); + expect(sidebar()?.getAttribute('data-collapsed')).toBe('false'); + } finally { + window.history.replaceState(null, '', '/'); + } + }); + it('auto-closes the Session Overview when the screen shrinks below the breakpoint', async () => { // Drive isLargeScreen through a controllable media query: open the panel on // a large screen, then flip below the breakpoint and confirm it closes. @@ -2011,7 +2058,7 @@ describe('App session callbacks', () => { _type: string, cb: (event: { matches: boolean }) => void, ) => { - if (query.includes('min-width')) changeHandler = cb; + if (query.includes('1024')) changeHandler = cb; }, removeEventListener: vi.fn(), })), diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 2aa0d191a59..cec32af92d1 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -981,6 +981,11 @@ export function App({ // once) is only offered on large screens; below that there is no room for it // to be useful. const isLargeScreen = useIsLargeScreen(); + // In split view the session sidebar competes with the panes for width. Below + // this width it auto-collapses to its icon rail so the panes get the room, and + // expands again once the window grows back. A wide split keeps the full + // sidebar (and the user's own collapse preference). + const splitSidebarHasRoom = useIsLargeScreen('(min-width: 1200px)'); useEffect(() => { const mql = window.matchMedia('(max-width: 760px)'); @@ -5401,7 +5406,11 @@ export function App({ aria-hidden="true" /> { closeMobileDrawer(); From 3fecb6c8f8ea72236309ee2a345f97bf165807f9 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 12 Jul 2026 16:56:12 +0800 Subject: [PATCH 12/14] 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. --- .../client/components/SplitView.test.tsx | 2 + .../hooks/useOtherWorkspaceSessions.test.tsx | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/web-shell/client/components/SplitView.test.tsx b/packages/web-shell/client/components/SplitView.test.tsx index 28ffa72d4b2..e988b30c4d1 100644 --- a/packages/web-shell/client/components/SplitView.test.tsx +++ b/packages/web-shell/client/components/SplitView.test.tsx @@ -456,6 +456,7 @@ describe('SplitView', () => { render({ sessionIds: ['s1'] }); await flushAsync(); // let the other-workspace fan-out resolve openPicker(); + await flushAsync(); // opening the picker re-fires reload()/reloadOther() const options = pickerOptions(); // Primary sessions are still listed… expect(options.some((o) => o.includes('Two'))).toBe(true); @@ -475,6 +476,7 @@ describe('SplitView', () => { render(); await flushAsync(); openPicker(); + await flushAsync(); // opening the picker re-fires reload()/reloadOther() const betaButton = Array.from( container!.querySelectorAll('[role="option"] button'), ).find((el) => diff --git a/packages/web-shell/client/hooks/useOtherWorkspaceSessions.test.tsx b/packages/web-shell/client/hooks/useOtherWorkspaceSessions.test.tsx index 8802e2655a1..609ed6604a3 100644 --- a/packages/web-shell/client/hooks/useOtherWorkspaceSessions.test.tsx +++ b/packages/web-shell/client/hooks/useOtherWorkspaceSessions.test.tsx @@ -147,4 +147,45 @@ describe('useOtherWorkspaceSessions', () => { expect(listWorkspaceSessions).toHaveBeenCalledTimes(2); expect(latest.sessions.map((s) => s.sessionId)).toEqual(['b1']); }); + + it('discards a stale in-flight fetch when the target set changes', async () => { + // /b resolves slowly; after the target set switches to /c (a workspace is + // (un)registered mid-flight), the stale /b result must not overwrite /c's. + let resolveB: (v: DaemonSessionSummary[]) => void = () => {}; + const bPending = new Promise((r) => { + resolveB = r; + }); + listWorkspaceSessions.mockImplementation(async (cwd: string) => { + if (cwd === '/b') return bPending; + if (cwd === '/c') return [session('c1', '/c')]; + return []; + }); + + capabilities = { + workspaces: [ws('/w', true, true), ws('/b', false, true)], + }; + render(); + // Do not flush — /b's fetch is still in flight (bPending is unresolved). + + // Switch the target set to /c before /b resolves. + capabilities = { + workspaces: [ws('/w', true, true), ws('/c', false, true)], + }; + await act(async () => { + root!.render(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(latest.sessions.map((s) => s.sessionId)).toEqual(['c1']); + + // Now resolve the stale /b fetch — its `cancelled` guard must drop it, so + // the list stays on /c's result rather than reverting to /b's. + await act(async () => { + resolveB([session('b1', '/b')]); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(latest.sessions.map((s) => s.sessionId)).toEqual(['c1']); + }); }); From 52f7fbeb2a54fa19f9113fc3ad9b01edf4893fc1 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 12 Jul 2026 18:32:28 +0800 Subject: [PATCH 13/14] test(web-shell): use a valid DaemonMode and assert the pane workspace prop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- packages/web-shell/client/components/SplitView.test.tsx | 9 ++++++++- packages/web-shell/client/utils/workspace.test.ts | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/web-shell/client/components/SplitView.test.tsx b/packages/web-shell/client/components/SplitView.test.tsx index e988b30c4d1..d45baac1580 100644 --- a/packages/web-shell/client/components/SplitView.test.tsx +++ b/packages/web-shell/client/components/SplitView.test.tsx @@ -63,7 +63,7 @@ vi.mock('./ChatPane', () => ({ // Let a test force a render crash to exercise the per-pane ErrorBoundary. if (props.title === 'BOOM') throw new Error('pane exploded'); return ( -
+
{props.title} {props.onClose && (