Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f53df3e
fix(web-shell): surface cross-workspace sessions in split view & over…
wenshao Jul 11, 2026
23cc397
fix(web-shell): repair stale composerTagIcons import that broke the b…
wenshao Jul 12, 2026
c7e36b4
fix(web-shell): address review feedback for cross-workspace sessions
wenshao Jul 12, 2026
c58e667
feat(web-shell): show each split pane's workspace in its composer pla…
wenshao Jul 12, 2026
30f44d8
Revert "feat(web-shell): show each split pane's workspace in its comp…
wenshao Jul 12, 2026
e258178
Merge remote-tracking branch 'origin/main' into fix/web-shell-split-o…
wenshao Jul 12, 2026
ea32215
feat(web-shell): label each split pane's workspace in its composer to…
wenshao Jul 12, 2026
aadea86
feat(web-shell): show the workspace chip in the main composer too
wenshao Jul 12, 2026
bd3d928
fix(web-shell): keep a session when a shrink closes the split view
wenshao Jul 12, 2026
4c2f2fd
Merge remote-tracking branch 'origin/main' into fix/web-shell-split-o…
wenshao Jul 12, 2026
4cc5fb4
fix(web-shell): restore the split view when the screen grows back
wenshao Jul 12, 2026
4a04476
fix(web-shell): keep the chat's git branch when folding the split on …
wenshao Jul 12, 2026
5a21824
feat(web-shell): auto-collapse the sidebar in a narrow split view
wenshao Jul 12, 2026
3fecb6c
test(web-shell): cover the cross-workspace fetch race, quiet split-pi…
wenshao Jul 12, 2026
52f7fbe
test(web-shell): use a valid DaemonMode and assert the pane workspace…
wenshao Jul 12, 2026
3f7b67a
perf(web-shell): memoize pane toolbar actions; cover chip tooltip fal…
wenshao Jul 12, 2026
37f12f3
merge: resolve conflict with origin/main in ChatEditor imports
qwen-code-dev-bot Jul 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
69 changes: 69 additions & 0 deletions packages/web-shell/client/components/SessionOverviewPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, DaemonSessionSummary[]>;
// 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<typeof vi.fn> };

const sessionsReload = vi.fn(async () => sessionsState.sessions);
const statusReload = vi.fn(async () => statusState.report);
Expand All @@ -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(
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<void> {
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() ?? '',
Expand Down Expand Up @@ -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(
[
Expand Down Expand Up @@ -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', () => {
Expand Down
56 changes: 51 additions & 5 deletions packages/web-shell/client/components/SessionOverviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<SessionCardStatus, number> = {
Expand All @@ -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]),
Expand All @@ -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) => {
Expand Down Expand Up @@ -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;
Expand All @@ -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(() => {
Comment thread
wenshao marked this conversation as resolved.
Resolved
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.
Expand All @@ -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) => {
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -392,6 +425,19 @@ function SessionOverviewPanelInner({
>
{t(`sessionsOverview.status.${card.status}`)}
</span>
{multiWorkspace && (
<span
className={cx(
styles.workspaceBadge,
card.isNonPrimary && styles.workspaceBadgeOther,
)}
title={card.workspaceCwd}
>
{card.isNonPrimary
? workspaceBasename(card.workspaceCwd)
: t('sidebar.workspacePrimary')}
</span>
)}
{card.model && (
<span className={styles.metaItem} title={card.model}>
{card.model}
Expand Down
24 changes: 23 additions & 1 deletion packages/web-shell/client/components/SplitView.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@
}

.pickerItem {
display: block;
display: flex;
align-items: baseline;
gap: 8px;
width: 100%;
text-align: left;
padding: 7px 10px;
Expand All @@ -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 {
Expand Down
Loading
Loading