mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-17 04:34:46 +00:00
* fix(web-shell): keep split-view session list fresh and preserve panes across view switches The in-window split view's "add pane" picker read a stale session snapshot — `useSessions` only fetches on mount — so sessions created after entering the split never appeared. And switching away from the split and back cleared the panes, because the live pane set lived in local state that died on unmount while the seed it re-mounted from was never updated (and the no-arg "Open Split View" button reset it to empty). - Reload the picker list when it opens and when the parent's session-list reload token changes, so it never offers a removed session or misses a new one. - Mirror the live pane set up to the app via onPanesChange so it survives SplitView unmounting; restore it (instead of reseeding empty) when the split is reopened without an explicit selection. * test(web-shell): cover split-view refresh/restore per review; coalesce token reloads Addresses review feedback on #6418: - SplitView: skip a token-driven reload while one is already in flight, so a burst of session-list changes (bulk create/delete) doesn't fire a redundant concurrent round-trip per bump (matches the sidebar's poll guard). - SplitView test: the freshness test now proves the picker re-renders with the refreshed list — a session appearing only after reload shows up — not just that reload() was called. - App test: cover the openSplitView preserve/restore path end-to-end — a reported pane set survives leaving the split and is restored on reopen. * fix(web-shell): reload split picker on every token bump (drop in-flight guard) The in-flight guard added in the previous commit could drop a session-list reload token that arrives while a reload is still running: the effect has already run for that token value, and clearing the in-flight flag in `finally` doesn't re-run it, so the picker could stay stale after burst create/delete/ rename activity — and the split has no polling fallback to recover. Reload on every distinct token bump instead. `useDaemonResource` serializes responses via its sequence counter (last write wins), so overlapping reloads are correct, and the token is bumped only on discrete session-change events — an occasional redundant fetch is far cheaper than a lost refresh. * test(web-shell): cover openSplitView explicit-selection branch (dedupe + cap) Per review: the restore branch of openSplitView was covered but the explicit-selection branch (dedupe + MAX_SPLIT_PANES cap, replacing any prior set) was only exercised, not asserted. Add a `?split=` URL test with duplicate and over-cap ids that asserts the split seeds exactly the deduped, capped selection.
352 lines
13 KiB
TypeScript
352 lines
13 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import * as React from 'react';
|
|
import { act } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import { I18nProvider } from '../i18n';
|
|
|
|
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
let connectionState: any;
|
|
let sessionsState: any[];
|
|
// 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<typeof vi.fn>;
|
|
|
|
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
|
|
DaemonSessionProvider: (props: any) => (
|
|
<div data-session={props.sessionId} data-clientid={props.clientId}>
|
|
{props.children}
|
|
</div>
|
|
),
|
|
useConnection: () => connectionState,
|
|
// 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.
|
|
useSessions: () => {
|
|
const [sessions, setSessions] = React.useState<any[]>(() => sessionsState);
|
|
const reload = React.useCallback(async () => {
|
|
reloadMock();
|
|
setSessions([...sessionsState]);
|
|
return sessionsState;
|
|
}, []);
|
|
return { sessions, reload };
|
|
},
|
|
}));
|
|
|
|
vi.mock('./ChatPane', () => ({
|
|
ChatPane: (props: any) => {
|
|
// Let a test force a render crash to exercise the per-pane ErrorBoundary.
|
|
if (props.title === 'BOOM') throw new Error('pane exploded');
|
|
return (
|
|
<div data-testid="chat-pane" data-current={props.isCurrent ? 'yes' : 'no'}>
|
|
<span data-testid="pane-title">{props.title}</span>
|
|
{props.onClose && (
|
|
<button data-testid="pane-close" onClick={props.onClose}>
|
|
x
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
}));
|
|
|
|
const { SplitView } = await import('./SplitView');
|
|
|
|
let root: Root | null = null;
|
|
let container: HTMLDivElement | null = null;
|
|
|
|
beforeEach(() => {
|
|
connectionState = {
|
|
sessionId: 's3',
|
|
capabilities: { features: [] },
|
|
workspaceCwd: '/w',
|
|
};
|
|
sessionsState = [
|
|
{ sessionId: 's1', workspaceCwd: '/w', displayName: 'One' },
|
|
{ sessionId: 's2', workspaceCwd: '/w', displayName: 'Two' },
|
|
{ sessionId: 's3', workspaceCwd: '/w', displayName: 'Three' },
|
|
{ sessionId: 's4', workspaceCwd: '/w', displayName: 'Four' },
|
|
];
|
|
reloadMock = vi.fn();
|
|
});
|
|
|
|
afterEach(() => {
|
|
act(() => root?.unmount());
|
|
container?.remove();
|
|
root = null;
|
|
container = null;
|
|
});
|
|
|
|
function render(props: Record<string, unknown> = {}): void {
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
act(() =>
|
|
root!.render(
|
|
<I18nProvider language="en">
|
|
<SplitView onExit={() => {}} {...props} />
|
|
</I18nProvider>,
|
|
),
|
|
);
|
|
}
|
|
|
|
function panes(): HTMLElement[] {
|
|
return Array.from(container!.querySelectorAll('[data-testid="chat-pane"]'));
|
|
}
|
|
function titles(): string[] {
|
|
return Array.from(container!.querySelectorAll('[data-testid="pane-title"]')).map(
|
|
(el) => el.textContent ?? '',
|
|
);
|
|
}
|
|
function pickerOptions(): string[] {
|
|
return Array.from(
|
|
container!.querySelectorAll('[role="option"] button'),
|
|
).map((el) => (el.textContent ?? '').trim());
|
|
}
|
|
function openPicker(): void {
|
|
const addButton = container!.querySelector(
|
|
'button[aria-haspopup="listbox"]',
|
|
) as HTMLButtonElement;
|
|
act(() =>
|
|
addButton.dispatchEvent(new MouseEvent('click', { bubbles: true })),
|
|
);
|
|
}
|
|
|
|
describe('SplitView', () => {
|
|
it('renders one pane per initial session, each under its own provider', () => {
|
|
render({ initialSessionIds: ['s1', 's2'] });
|
|
expect(panes()).toHaveLength(2);
|
|
expect(titles()).toEqual(['One', 'Two']);
|
|
const providers = container!.querySelectorAll('[data-session]');
|
|
expect(providers[0].getAttribute('data-session')).toBe('s1');
|
|
// Panes use a distinct client id (with a per-mount nonce) so they don't
|
|
// collide with the main view — or with another tab's panes for the session.
|
|
const clientId = providers[0].getAttribute('data-clientid') ?? '';
|
|
expect(clientId).toMatch(/^split-pane:.+:s1$/);
|
|
// Both panes share this instance's nonce.
|
|
const s2ClientId = providers[1].getAttribute('data-clientid') ?? '';
|
|
const nonce = clientId.slice('split-pane:'.length, -':s1'.length);
|
|
expect(s2ClientId).toBe(`split-pane:${nonce}:s2`);
|
|
});
|
|
|
|
it('seeds with the current session when no initial sessions are given', () => {
|
|
render({ initialSessionIds: [] });
|
|
expect(titles()).toEqual(['Three']);
|
|
expect(panes()[0].getAttribute('data-current')).toBe('yes');
|
|
});
|
|
|
|
it('dedupes initial sessions', () => {
|
|
render({ initialSessionIds: ['s1', 's1', 's2'] });
|
|
expect(titles()).toEqual(['One', 'Two']);
|
|
});
|
|
|
|
it('adds a pane from the picker', () => {
|
|
render({ initialSessionIds: ['s1'] });
|
|
expect(panes()).toHaveLength(1);
|
|
const addButton = container!.querySelector(
|
|
'button[aria-haspopup="listbox"]',
|
|
) as HTMLButtonElement;
|
|
act(() =>
|
|
addButton.dispatchEvent(new MouseEvent('click', { bubbles: true })),
|
|
);
|
|
// Picker lists sessions not already shown (s2, s3, s4).
|
|
const options = container!.querySelectorAll('[role="option"] button');
|
|
expect(options).toHaveLength(3);
|
|
act(() =>
|
|
options[0].dispatchEvent(new MouseEvent('click', { bubbles: true })),
|
|
);
|
|
expect(panes()).toHaveLength(2);
|
|
});
|
|
|
|
it('closes the picker on Escape', () => {
|
|
render({ initialSessionIds: ['s1'] });
|
|
const addButton = container!.querySelector(
|
|
'button[aria-haspopup="listbox"]',
|
|
) as HTMLButtonElement;
|
|
act(() =>
|
|
addButton.dispatchEvent(new MouseEvent('click', { bubbles: true })),
|
|
);
|
|
expect(addButton.getAttribute('aria-expanded')).toBe('true');
|
|
act(() =>
|
|
document.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
|
),
|
|
);
|
|
expect(addButton.getAttribute('aria-expanded')).toBe('false');
|
|
expect(container!.querySelector('[role="listbox"]')).toBeNull();
|
|
});
|
|
|
|
it('closes the picker on a click outside it', () => {
|
|
render({ initialSessionIds: ['s1'] });
|
|
const addButton = container!.querySelector(
|
|
'button[aria-haspopup="listbox"]',
|
|
) as HTMLButtonElement;
|
|
act(() =>
|
|
addButton.dispatchEvent(new MouseEvent('click', { bubbles: true })),
|
|
);
|
|
expect(container!.querySelector('[role="listbox"]')).not.toBeNull();
|
|
// A mousedown anywhere outside the add-wrap dismisses it…
|
|
act(() =>
|
|
document.body.dispatchEvent(
|
|
new MouseEvent('mousedown', { bubbles: true }),
|
|
),
|
|
);
|
|
expect(addButton.getAttribute('aria-expanded')).toBe('false');
|
|
expect(container!.querySelector('[role="listbox"]')).toBeNull();
|
|
});
|
|
|
|
it('keeps the picker open on a click inside it', () => {
|
|
render({ initialSessionIds: ['s1'] });
|
|
const addButton = container!.querySelector(
|
|
'button[aria-haspopup="listbox"]',
|
|
) as HTMLButtonElement;
|
|
act(() =>
|
|
addButton.dispatchEvent(new MouseEvent('click', { bubbles: true })),
|
|
);
|
|
const listbox = container!.querySelector('[role="listbox"]') as HTMLElement;
|
|
// A mousedown on the picker itself must not dismiss it (the click that
|
|
// selects an option would otherwise be swallowed).
|
|
act(() =>
|
|
listbox.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })),
|
|
);
|
|
expect(addButton.getAttribute('aria-expanded')).toBe('true');
|
|
expect(container!.querySelector('[role="listbox"]')).not.toBeNull();
|
|
});
|
|
|
|
it('removes a pane via its close button', () => {
|
|
render({ initialSessionIds: ['s1', 's2'] });
|
|
const closes = container!.querySelectorAll('[data-testid="pane-close"]');
|
|
act(() =>
|
|
closes[0].dispatchEvent(new MouseEvent('click', { bubbles: true })),
|
|
);
|
|
expect(titles()).toEqual(['Two']);
|
|
});
|
|
|
|
it('auto-exits to the overview when the last pane is closed', () => {
|
|
const onExit = vi.fn();
|
|
render({ initialSessionIds: ['s1'], onExit });
|
|
expect(onExit).not.toHaveBeenCalled();
|
|
const close = container!.querySelector('[data-testid="pane-close"]');
|
|
act(() =>
|
|
close!.dispatchEvent(new MouseEvent('click', { bubbles: true })),
|
|
);
|
|
expect(onExit).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('exits via the back button', () => {
|
|
const onExit = vi.fn();
|
|
render({ initialSessionIds: ['s1'], onExit });
|
|
// The back button is the first toolbar button (aria-label from common.back).
|
|
const back = container!.querySelector('header button') as HTMLButtonElement;
|
|
act(() => back.dispatchEvent(new MouseEvent('click', { bubbles: true })));
|
|
expect(onExit).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('caps the number of panes at MAX_PANES (6)', () => {
|
|
sessionsState = Array.from({ length: 8 }, (_, i) => ({
|
|
sessionId: `x${i}`,
|
|
workspaceCwd: '/w',
|
|
displayName: `Pane ${i}`,
|
|
}));
|
|
render({ initialSessionIds: sessionsState.map((s) => s.sessionId) });
|
|
// Eight requested, but only six live panes mount.
|
|
expect(panes()).toHaveLength(6);
|
|
});
|
|
|
|
it('isolates a crashing pane so the rest of the split survives', () => {
|
|
sessionsState = [
|
|
{ sessionId: 's1', workspaceCwd: '/w', displayName: 'BOOM' },
|
|
{ sessionId: 's2', workspaceCwd: '/w', displayName: 'Two' },
|
|
];
|
|
render({ initialSessionIds: ['s1', 's2'] });
|
|
// The crashing pane shows its error fallback; the healthy pane still renders.
|
|
expect(container!.textContent).toContain('This session pane hit an error');
|
|
expect(panes()).toHaveLength(1);
|
|
expect(titles()).toEqual(['Two']);
|
|
});
|
|
|
|
it('reloads the session list when the picker opens (never a stale list)', () => {
|
|
render({ initialSessionIds: ['s1'] });
|
|
// `useSessions` only fetches on mount; nothing reloads until the user acts.
|
|
expect(reloadMock).not.toHaveBeenCalled();
|
|
const addButton = container!.querySelector(
|
|
'button[aria-haspopup="listbox"]',
|
|
) as HTMLButtonElement;
|
|
act(() =>
|
|
addButton.dispatchEvent(new MouseEvent('click', { bubbles: true })),
|
|
);
|
|
expect(reloadMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('renders the refreshed session list on reopen — not the entry snapshot', () => {
|
|
render({ initialSessionIds: ['s1'] });
|
|
// First open: the picker offers the sessions present at entry.
|
|
openPicker();
|
|
expect(pickerOptions()).toEqual(['Two', 'Three', 'Four']);
|
|
// A session is created elsewhere after the split was entered…
|
|
sessionsState = [
|
|
...sessionsState,
|
|
{ sessionId: 's5', workspaceCwd: '/w', displayName: 'Five' },
|
|
];
|
|
// …reopening the picker reloads and the new session now appears. Without the
|
|
// reload-on-open the list would be frozen at the entry snapshot (no 'Five').
|
|
act(() =>
|
|
document.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
|
),
|
|
);
|
|
openPicker();
|
|
expect(pickerOptions()).toEqual(['Two', 'Three', 'Four', 'Five']);
|
|
});
|
|
|
|
it('reloads the picker list when the parent bumps the reload token', () => {
|
|
render({ initialSessionIds: ['s1'], sessionListReloadToken: 0 });
|
|
// The initial token is not a change, so it does not trigger a reload.
|
|
expect(reloadMock).not.toHaveBeenCalled();
|
|
act(() =>
|
|
root!.render(
|
|
<I18nProvider language="en">
|
|
<SplitView
|
|
onExit={() => {}}
|
|
initialSessionIds={['s1']}
|
|
sessionListReloadToken={1}
|
|
/>
|
|
</I18nProvider>,
|
|
),
|
|
);
|
|
expect(reloadMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('mirrors the live pane set up to the parent as panes change', () => {
|
|
const onPanesChange = vi.fn();
|
|
render({ initialSessionIds: ['s1'], onPanesChange });
|
|
// Reported on mount so the parent's seed reflects the actual panes…
|
|
expect(onPanesChange).toHaveBeenLastCalledWith(['s1']);
|
|
// …and after every add (so switching away and back restores it).
|
|
const addButton = container!.querySelector(
|
|
'button[aria-haspopup="listbox"]',
|
|
) as HTMLButtonElement;
|
|
act(() =>
|
|
addButton.dispatchEvent(new MouseEvent('click', { bubbles: true })),
|
|
);
|
|
const options = container!.querySelectorAll('[role="option"] button');
|
|
act(() =>
|
|
options[0].dispatchEvent(new MouseEvent('click', { bubbles: true })),
|
|
);
|
|
expect(onPanesChange).toHaveBeenLastCalledWith(['s1', 's2']);
|
|
// …and after every remove.
|
|
const close = container!.querySelector('[data-testid="pane-close"]');
|
|
act(() =>
|
|
close!.dispatchEvent(new MouseEvent('click', { bubbles: true })),
|
|
);
|
|
expect(onPanesChange).toHaveBeenLastCalledWith(['s2']);
|
|
});
|
|
});
|