qwen-code/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx

1319 lines
44 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
const {
mockConnection,
mockUseSessions,
mockActive,
mockArchived,
renameSessionSpy,
mockExportSession,
mockWorkspaceActions,
} = vi.hoisted(() => {
const makeStore = () => ({
sessions: [] as MockSession[],
loading: false,
error: null as unknown,
reload: vi.fn(),
deleteSession: vi.fn().mockResolvedValue(true),
archiveSession: vi.fn().mockResolvedValue(true),
unarchiveSession: vi.fn().mockResolvedValue(true),
});
const mockActive = makeStore();
const mockArchived = makeStore();
const mockExportSession = vi.fn();
const mockUseSessions = vi.fn(
(options?: { archiveState?: 'active' | 'archived' }) =>
options?.archiveState === 'archived'
? mockArchived
: { ...mockActive, exportSession: mockExportSession },
);
return {
mockConnection: {
status: 'connected',
sessionId: null as string | null,
workspaceCwd: '/tmp/project',
capabilities: { qwenCodeVersion: '1.2.3', features: [] as string[] } as
| { qwenCodeVersion?: string; features?: string[] }
| undefined,
},
mockUseSessions,
mockActive,
mockArchived,
renameSessionSpy: vi.fn(),
mockExportSession,
mockWorkspaceActions: {
listSessionGroups: vi.fn().mockResolvedValue({
groups: [],
colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'],
}),
createSessionGroup: vi.fn(),
updateSessionGroup: vi.fn(),
deleteSessionGroup: vi.fn(),
updateSessionOrganization: vi.fn(),
},
};
});
type MockSession = {
sessionId: string;
workspaceCwd: string;
displayName?: string;
createdAt?: string;
updatedAt?: string;
clientCount?: number;
hasActivePrompt?: boolean;
isArchived?: boolean;
isPinned?: boolean;
groupId?: string | null;
color?: 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | null;
};
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
useConnection: () => mockConnection,
useActions: () => ({ renameSession: renameSessionSpy }),
useWorkspaceActions: () => mockWorkspaceActions,
useSessions: (options?: { archiveState?: 'active' | 'archived' }) =>
mockUseSessions(options),
}));
function makeSession(
sessionId: string,
over: Partial<MockSession> = {},
): MockSession {
return {
sessionId,
workspaceCwd: '/tmp/project',
displayName: `Session ${sessionId}`,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
clientCount: 0,
hasActivePrompt: false,
...over,
};
}
const { I18nProvider } = await import('../../i18n');
const { WebShellSidebar } = await import('./WebShellSidebar');
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
const noop = () => {};
const SIDEBAR_WIDTH_STORAGE_KEY = 'qwen-code-web-shell-sidebar-width';
function setStoredSidebarWidth(width: number): void {
window.localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(width));
}
function pointerEvent(type: string, clientX: number): MouseEvent {
return new MouseEvent(type, { bubbles: true, clientX });
}
function renderSidebar(
collapsed: boolean,
overrides: Partial<{
onOpenSettings: () => void;
onOpenDaemonStatus: () => void;
onOpenScheduledTasks: () => void;
onOpenSessions: () => void;
canOpenSessionsOverview: boolean;
onOpenSplitView: () => void;
canOpenSplitView: boolean;
onCollapsedChange: (collapsed: boolean) => void;
onNewSession: () => Promise<boolean> | boolean;
onLoadSession: (sessionId: string) => Promise<void> | void;
onError: (error: unknown, message: string) => void;
sessionListReloadToken: number;
}> = {},
): { container: HTMLElement; rerender: (props: typeof overrides) => void } {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const doRender = (props: typeof overrides) => {
act(() => {
root.render(
<I18nProvider language="en">
<WebShellSidebar
collapsed={collapsed}
onCollapsedChange={noop}
onOpenSettings={noop}
onOpenDaemonStatus={noop}
onOpenScheduledTasks={noop}
onOpenSessions={noop}
onOpenSplitView={noop}
onNewSession={() => false}
onLoadSession={noop}
onError={noop}
{...props}
/>
</I18nProvider>,
);
});
};
doRender(overrides);
mounted.push({ root, container });
return { container, rerender: doRender };
}
beforeEach(() => {
mockUseSessions.mockClear();
window.localStorage.clear();
mockConnection.sessionId = null;
mockConnection.capabilities = { qwenCodeVersion: '1.2.3', features: [] };
for (const store of [mockActive, mockArchived]) {
store.sessions = [];
store.loading = false;
store.error = null;
store.reload.mockReset();
store.deleteSession.mockReset();
store.archiveSession.mockReset();
store.unarchiveSession.mockReset();
store.deleteSession.mockResolvedValue(true);
store.archiveSession.mockResolvedValue(true);
store.unarchiveSession.mockResolvedValue(true);
}
renameSessionSpy.mockClear();
mockExportSession.mockReset();
mockExportSession.mockResolvedValue({
content: '<html>export</html>',
filename: 'session.html',
mimeType: 'text/html',
format: 'html',
});
mockWorkspaceActions.listSessionGroups.mockReset();
mockWorkspaceActions.listSessionGroups.mockResolvedValue({
groups: [],
colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'],
});
mockWorkspaceActions.createSessionGroup.mockReset();
mockWorkspaceActions.updateSessionGroup.mockReset();
mockWorkspaceActions.deleteSessionGroup.mockReset();
mockWorkspaceActions.updateSessionOrganization.mockReset();
});
afterEach(() => {
for (const { root, container } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
}
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('WebShellSidebar — version footer', () => {
it('shows the settings label and qwen-code version at full footer width', () => {
setStoredSidebarWidth(360);
const { container } = renderSidebar(false, {
canOpenSessionsOverview: true,
canOpenSplitView: true,
});
const settingsButton = container.querySelector<HTMLButtonElement>(
'[aria-label="Settings"]',
);
const badge = container.querySelector('[title="Qwen Code v1.2.3"]');
expect(settingsButton).not.toBeNull();
expect(settingsButton?.textContent).toContain('Settings');
expect(badge).not.toBeNull();
expect(badge?.textContent).toBe('v1.2.3');
});
it('shows the qwen-code version in the footer when expanded', () => {
const { container } = renderSidebar(false);
const badge = container.querySelector('[title="Qwen Code v1.2.3"]');
expect(badge).not.toBeNull();
expect(badge?.textContent).toBe('v1.2.3');
});
it('hides the settings label first while keeping the settings button accessible', () => {
setStoredSidebarWidth(260);
const { container } = renderSidebar(false, {
canOpenSessionsOverview: true,
canOpenSplitView: true,
});
const settingsButton = container.querySelector<HTMLButtonElement>(
'[aria-label="Settings"]',
);
const badge = container.querySelector('[title="Qwen Code v1.2.3"]');
expect(settingsButton).not.toBeNull();
expect(settingsButton?.title).toBe('Settings');
expect(settingsButton?.textContent).not.toContain('Settings');
expect(settingsButton?.querySelector('svg')).not.toBeNull();
expect(badge).not.toBeNull();
});
it('hides the version at tight footer width', () => {
setStoredSidebarWidth(220);
const { container } = renderSidebar(false, {
canOpenSessionsOverview: true,
canOpenSplitView: true,
});
const settingsButton = container.querySelector<HTMLButtonElement>(
'[aria-label="Settings"]',
);
expect(settingsButton).not.toBeNull();
expect(settingsButton?.textContent).not.toContain('Settings');
expect(container.querySelector('[title="Qwen Code v1.2.3"]')).toBeNull();
expect(container.textContent ?? '').not.toContain('v1.2.3');
});
it('renders a non-semver fallback (e.g. "unknown") without a bogus "v" prefix', () => {
mockConnection.capabilities = { qwenCodeVersion: 'unknown' };
const { container } = renderSidebar(false);
const badge = container.querySelector('[title="Qwen Code unknown"]');
expect(badge).not.toBeNull();
expect(badge?.textContent).toBe('unknown');
expect(container.textContent ?? '').not.toContain('vunknown');
});
it('hides the version when the sidebar is collapsed', () => {
const { container } = renderSidebar(true);
expect(container.querySelector('[title="Qwen Code v1.2.3"]')).toBeNull();
expect(container.textContent ?? '').not.toContain('v1.2.3');
});
it('renders no version badge when the daemon reports none', () => {
mockConnection.capabilities = undefined;
const { container } = renderSidebar(false);
expect(container.textContent ?? '').not.toMatch(/v\d/);
});
});
describe('WebShellSidebar — brand logo', () => {
it('renders the Qwen brand mark beside the new-chat button when expanded', () => {
const { container } = renderSidebar(false);
// Filled brand mark (shared with the favicon), not a stroked nav icon.
const mark = container.querySelector('svg path[fill="#6D44E8"]');
expect(mark).not.toBeNull();
// The mark and the new-chat button share the same top row.
const topRow = mark!.closest('div');
expect(topRow?.querySelector('[aria-label="New chat"]')).not.toBeNull();
});
it('hides the brand mark when collapsed (only the new-chat button remains)', () => {
const { container } = renderSidebar(true);
expect(container.querySelector('svg path[fill="#6D44E8"]')).toBeNull();
expect(container.querySelector('[aria-label="New chat"]')).not.toBeNull();
});
});
describe('WebShellSidebar — daemon status entry', () => {
it('invokes onOpenDaemonStatus when the footer button is clicked', () => {
const onOpenDaemonStatus = vi.fn();
const { container } = renderSidebar(false, { onOpenDaemonStatus });
const button = container.querySelector<HTMLButtonElement>(
'[aria-label="Daemon Status"]',
);
expect(button).not.toBeNull();
act(() => {
button!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(onOpenDaemonStatus).toHaveBeenCalledTimes(1);
});
it('still exposes the daemon status button when collapsed', () => {
const onOpenDaemonStatus = vi.fn();
const { container } = renderSidebar(true, { onOpenDaemonStatus });
const button = container.querySelector<HTMLButtonElement>(
'[aria-label="Daemon Status"]',
);
expect(button).not.toBeNull();
act(() => {
button!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(onOpenDaemonStatus).toHaveBeenCalledTimes(1);
});
});
describe('WebShellSidebar — session overview entry', () => {
it('offers the entry point only on large screens', () => {
const small = renderSidebar(false, { canOpenSessionsOverview: false });
expect(
small.container.querySelector('[aria-label="Session Overview"]'),
).toBeNull();
const large = renderSidebar(false, { canOpenSessionsOverview: true });
expect(
large.container.querySelector('[aria-label="Session Overview"]'),
).not.toBeNull();
});
it('invokes onOpenSessions when the footer button is clicked', () => {
const onOpenSessions = vi.fn();
const { container } = renderSidebar(false, {
canOpenSessionsOverview: true,
onOpenSessions,
});
const button = container.querySelector<HTMLButtonElement>(
'[aria-label="Session Overview"]',
);
expect(button).not.toBeNull();
act(() => {
button!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(onOpenSessions).toHaveBeenCalledTimes(1);
});
});
describe('WebShellSidebar — split view entry', () => {
it('offers the split view entry only on large screens', () => {
const small = renderSidebar(false, { canOpenSplitView: false });
expect(
small.container.querySelector('[aria-label="Split View"]'),
).toBeNull();
const large = renderSidebar(false, { canOpenSplitView: true });
expect(
large.container.querySelector('[aria-label="Split View"]'),
).not.toBeNull();
});
it('invokes onOpenSplitView when the footer button is clicked', () => {
const onOpenSplitView = vi.fn();
const { container } = renderSidebar(false, {
canOpenSplitView: true,
onOpenSplitView,
});
const button = container.querySelector<HTMLButtonElement>(
'[aria-label="Split View"]',
);
expect(button).not.toBeNull();
act(() => {
button!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(onOpenSplitView).toHaveBeenCalledTimes(1);
});
});
describe('WebShellSidebar — resize behavior', () => {
it('persists normal drag widths without collapsing', () => {
setStoredSidebarWidth(260);
const onCollapsedChange = vi.fn();
const { container } = renderSidebar(false, { onCollapsedChange });
const handle = container.querySelector<HTMLElement>('[role="separator"]');
expect(handle).not.toBeNull();
act(() => {
handle!.dispatchEvent(pointerEvent('pointerdown', 260));
window.dispatchEvent(pointerEvent('pointermove', 230));
window.dispatchEvent(pointerEvent('pointerup', 230));
});
expect(onCollapsedChange).not.toHaveBeenCalled();
expect(window.localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY)).toBe('230');
});
it('collapses when dragged past the compact threshold and restores the expanded width', () => {
setStoredSidebarWidth(260);
const onCollapsedChange = vi.fn();
const { container } = renderSidebar(false, { onCollapsedChange });
const handle = container.querySelector<HTMLElement>('[role="separator"]');
expect(handle).not.toBeNull();
act(() => {
handle!.dispatchEvent(pointerEvent('pointerdown', 260));
window.dispatchEvent(pointerEvent('pointermove', 130));
});
expect(onCollapsedChange).toHaveBeenCalledWith(true);
expect(onCollapsedChange).toHaveBeenCalledTimes(1);
expect(window.localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY)).toBe('260');
});
});
function click(el: Element | null): void {
expect(el).not.toBeNull();
act(() => {
el!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
}
// Clicks that kick off an async action settle trailing state updates in
// `.finally()`; flush those microtasks inside act().
async function clickAsync(el: Element | null): Promise<void> {
expect(el).not.toBeNull();
await act(async () => {
el!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
}
describe('WebShellSidebar — session organization', () => {
it('uses organized sessions only when the daemon advertises the capability', () => {
renderSidebar(false);
expect(mockUseSessions).toHaveBeenCalledWith({
autoLoad: true,
pageSize: 1000,
archiveState: 'active',
});
for (const { root, container } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
}
mockConnection.capabilities = {
qwenCodeVersion: '1.2.3',
features: ['session_organization'],
};
mockWorkspaceActions.listSessionGroups.mockReturnValue(
new Promise(() => undefined),
);
const { container } = renderSidebar(false);
expect(mockUseSessions).toHaveBeenCalledWith({
autoLoad: true,
pageSize: 1000,
archiveState: 'active',
view: 'organized',
group: 'all',
});
expect(container.querySelector('[aria-label="Session group"]')).toBeNull();
});
it('creates session groups from an in-app dialog form', async () => {
mockConnection.capabilities = {
qwenCodeVersion: '1.2.3',
features: ['session_organization'],
};
mockWorkspaceActions.createSessionGroup.mockResolvedValue({
id: 'group-1',
name: 'Backend',
color: 'green',
order: 0,
createdAt: '2026-07-04T00:00:00.000Z',
updatedAt: '2026-07-04T00:00:00.000Z',
});
mockActive.sessions = [
makeSession('550e8400-e29b-41d4-a716-446655440000', {
displayName: 'Review plan',
createdAt: '2026-07-04T00:00:00.000Z',
updatedAt: '2026-07-04T00:00:00.000Z',
}),
];
const promptSpy = vi.spyOn(window, 'prompt');
renderSidebar(false);
await act(async () => {
await Promise.resolve();
});
const organizeButton = document.body.querySelector<HTMLButtonElement>(
'[aria-label="Group"]',
);
expect(organizeButton).not.toBeNull();
act(() => {
organizeButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
const createButton = Array.from(
document.body.querySelectorAll<HTMLButtonElement>('button'),
).find((button) => button.textContent?.includes('Create group'));
expect(createButton).not.toBeNull();
act(() => {
createButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
const nameInput = document.body.querySelector<HTMLInputElement>(
'input[maxlength="64"]',
);
expect(nameInput).not.toBeNull();
const setInputValue = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)?.set;
act(() => {
setInputValue?.call(nameInput, 'Backend');
nameInput!.dispatchEvent(new Event('input', { bubbles: true }));
});
const colorSelect = Array.from(
document.body.querySelectorAll<HTMLSelectElement>('select'),
).find((select) => select.value === 'red');
expect(colorSelect).toBeDefined();
const setSelectValue = Object.getOwnPropertyDescriptor(
HTMLSelectElement.prototype,
'value',
)?.set;
act(() => {
setSelectValue?.call(colorSelect, 'green');
colorSelect!.dispatchEvent(new Event('change', { bubbles: true }));
});
const saveButton = Array.from(
document.body.querySelectorAll<HTMLButtonElement>('button'),
).find((button) => button.textContent === 'save');
expect(saveButton).not.toBeNull();
await act(async () => {
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(promptSpy).not.toHaveBeenCalled();
expect(mockWorkspaceActions.createSessionGroup).toHaveBeenCalledWith({
name: 'Backend',
color: 'green',
});
// Creating a group for a session assigns it and clears any color tag —
// color and named group are mutually exclusive in the UI.
expect(mockWorkspaceActions.updateSessionOrganization).toHaveBeenCalledWith(
'550e8400-e29b-41d4-a716-446655440000',
{ groupId: 'group-1', color: null },
);
promptSpy.mockRestore();
});
it('uses a themed group menu and assigns the selected group', async () => {
mockConnection.capabilities = {
qwenCodeVersion: '1.2.3',
features: ['session_organization'],
};
mockWorkspaceActions.listSessionGroups.mockResolvedValue({
groups: [
{
id: 'group-1',
name: 'Backend',
color: 'green',
order: 0,
createdAt: '2026-07-04T00:00:00.000Z',
updatedAt: '2026-07-04T00:00:00.000Z',
},
],
colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'],
});
mockWorkspaceActions.updateSessionOrganization.mockResolvedValue({
sessionId: '550e8400-e29b-41d4-a716-446655440000',
groupId: 'group-1',
isPinned: false,
updatedAt: '2026-07-04T00:00:00.000Z',
});
mockActive.sessions = [
makeSession('550e8400-e29b-41d4-a716-446655440000', {
displayName: 'Review plan',
createdAt: '2026-07-04T00:00:00.000Z',
updatedAt: '2026-07-04T00:00:00.000Z',
}),
];
renderSidebar(false);
await act(async () => {
await Promise.resolve();
});
const organizeButton = document.body.querySelector<HTMLButtonElement>(
'[aria-label="Group"]',
);
expect(organizeButton).not.toBeNull();
act(() => {
organizeButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
const menu = document.body.querySelector<HTMLElement>(
'[role="menu"][aria-label="Group"]',
);
expect(menu).not.toBeNull();
expect(menu!.querySelector('select')).toBeNull();
const selectedOption = menu!.querySelector<HTMLElement>(
'[role="menuitemradio"][aria-checked="true"]',
);
expect(selectedOption?.textContent).toContain('Ungrouped');
await act(async () => {
await new Promise((resolve) => window.requestAnimationFrame(resolve));
});
expect(document.activeElement).toBe(selectedOption);
act(() => {
menu!.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowDown',
bubbles: true,
}),
);
});
// One ArrowDown from "Ungrouped" now lands on the first color quick-pick.
expect(document.activeElement?.textContent).toContain('Red');
const groupOption = Array.from(
menu!.querySelectorAll<HTMLButtonElement>('button'),
).find((button) => button.textContent?.includes('Backend'));
expect(groupOption).not.toBeNull();
await act(async () => {
groupOption!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
// Assigning a named group clears any color tag (single-choice).
expect(mockWorkspaceActions.updateSessionOrganization).toHaveBeenCalledWith(
'550e8400-e29b-41d4-a716-446655440000',
{ groupId: 'group-1', color: null },
);
});
it('offers the six color quick-picks and assigns the chosen color', async () => {
mockConnection.capabilities = {
qwenCodeVersion: '1.2.3',
features: ['session_organization'],
};
mockWorkspaceActions.updateSessionOrganization.mockResolvedValue({
sessionId: '550e8400-e29b-41d4-a716-446655440000',
groupId: null,
color: 'red',
isPinned: false,
updatedAt: '2026-07-04T00:00:00.000Z',
});
mockActive.sessions = [
makeSession('550e8400-e29b-41d4-a716-446655440000', {
displayName: 'Review plan',
}),
];
renderSidebar(false);
await act(async () => {
await Promise.resolve();
});
const organizeButton = document.body.querySelector<HTMLButtonElement>(
'[aria-label="Group"]',
);
expect(organizeButton).not.toBeNull();
act(() => {
organizeButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
const menu = document.body.querySelector<HTMLElement>(
'[role="menu"][aria-label="Group"]',
);
expect(menu).not.toBeNull();
const radioLabels = Array.from(
menu!.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]'),
).map((button) => button.textContent ?? '');
// Ungrouped + all six colors are offered as single-choice radios.
for (const name of [
'Ungrouped',
'Red',
'Orange',
'Yellow',
'Green',
'Blue',
'Purple',
]) {
expect(radioLabels.some((label) => label.includes(name))).toBe(true);
}
const redOption = Array.from(
menu!.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]'),
).find((button) => button.textContent?.includes('Red'));
expect(redOption).not.toBeNull();
await act(async () => {
redOption!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
// Picking a color clears any named-group assignment (single-choice).
expect(mockWorkspaceActions.updateSessionOrganization).toHaveBeenCalledWith(
'550e8400-e29b-41d4-a716-446655440000',
{ color: 'red', groupId: null },
);
});
it('groups sessions into color sections ahead of the recent bucket', async () => {
mockConnection.capabilities = {
qwenCodeVersion: '1.2.3',
features: ['session_organization'],
};
mockActive.sessions = [
makeSession('session-red', { displayName: 'Red work', color: 'red' }),
makeSession('session-plain', { displayName: 'Loose end', color: null }),
];
const { container } = renderSidebar(false);
await act(async () => {
await Promise.resolve();
});
// A "Red" color section exists and holds the tagged session.
const redSection = container.querySelector<HTMLElement>(
'section[aria-label="Red"]',
);
expect(redSection).not.toBeNull();
expect(redSection!.textContent).toContain('Red work');
// The untagged session falls through to the Recent bucket.
expect(container.textContent).toContain('Recent');
expect(container.textContent).toContain('Loose end');
});
it('renders organized sessions as collapsible group sections', async () => {
mockConnection.capabilities = {
qwenCodeVersion: '1.2.3',
features: ['session_organization'],
};
mockWorkspaceActions.listSessionGroups.mockResolvedValue({
groups: [
{
id: 'group-1',
name: 'Backend',
color: 'green',
order: 0,
createdAt: '2026-07-04T00:00:00.000Z',
updatedAt: '2026-07-04T00:00:00.000Z',
},
],
colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'],
});
mockActive.sessions = [
makeSession('session-a', {
displayName: 'API review',
groupId: 'group-1',
}),
makeSession('session-b', {
displayName: 'Release notes',
groupId: null,
}),
];
const { container } = renderSidebar(false);
await act(async () => {
await Promise.resolve();
});
const backendHeader = Array.from(
container.querySelectorAll<HTMLButtonElement>('button'),
).find(
(button) =>
button.textContent?.includes('Backend') &&
button.textContent.includes('1'),
);
expect(backendHeader).not.toBeNull();
expect(container.textContent).toContain('Recent');
expect(container.textContent).toContain('API review');
expect(container.textContent).toContain('Release notes');
act(() => {
backendHeader!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(container.textContent).not.toContain('API review');
expect(container.textContent).toContain('Release notes');
});
it('reloads sessions after deleting a group', async () => {
mockConnection.capabilities = {
qwenCodeVersion: '1.2.3',
features: ['session_organization'],
};
mockWorkspaceActions.listSessionGroups.mockResolvedValue({
groups: [
{
id: 'group-1',
name: 'Backend',
color: 'green',
order: 0,
createdAt: '2026-07-04T00:00:00.000Z',
updatedAt: '2026-07-04T00:00:00.000Z',
},
],
colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'],
});
mockWorkspaceActions.deleteSessionGroup.mockResolvedValue(true);
mockActive.sessions = [
makeSession('session-a', {
displayName: 'API review',
groupId: 'group-1',
}),
];
const { container } = renderSidebar(false);
await act(async () => {
await Promise.resolve();
});
mockWorkspaceActions.listSessionGroups.mockClear();
const deleteGroupButton = container.querySelector<HTMLButtonElement>(
'[aria-label="Delete group"]',
);
expect(deleteGroupButton).not.toBeNull();
act(() => {
deleteGroupButton!.dispatchEvent(
new MouseEvent('click', { bubbles: true }),
);
});
const confirmDeleteButton = Array.from(
document.body.querySelectorAll<HTMLButtonElement>('button'),
).find((button) => button.textContent?.trim() === 'Delete group');
expect(confirmDeleteButton).toBeDefined();
await act(async () => {
confirmDeleteButton!.dispatchEvent(
new MouseEvent('click', { bubbles: true }),
);
await Promise.resolve();
});
expect(mockWorkspaceActions.deleteSessionGroup).toHaveBeenCalledWith(
'group-1',
);
expect(mockActive.reload).toHaveBeenCalledTimes(1);
expect(mockWorkspaceActions.listSessionGroups).toHaveBeenCalledTimes(1);
});
it('toggles pin state from the session action button', async () => {
mockConnection.capabilities = {
qwenCodeVersion: '1.2.3',
features: ['session_organization'],
};
mockWorkspaceActions.updateSessionOrganization.mockResolvedValue({
sessionId: '550e8400-e29b-41d4-a716-446655440000',
groupId: null,
isPinned: true,
pinnedAt: '2026-07-04T00:00:00.000Z',
updatedAt: '2026-07-04T00:00:00.000Z',
});
mockActive.sessions = [
makeSession('550e8400-e29b-41d4-a716-446655440000', {
displayName: 'Review plan',
createdAt: '2026-07-04T00:00:00.000Z',
updatedAt: '2026-07-04T00:00:00.000Z',
}),
];
const { container } = renderSidebar(false);
await act(async () => {
await Promise.resolve();
});
const pinButton =
container.querySelector<HTMLButtonElement>('[aria-label="Pin"]');
expect(pinButton).not.toBeNull();
await act(async () => {
pinButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(mockWorkspaceActions.updateSessionOrganization).toHaveBeenCalledWith(
'550e8400-e29b-41d4-a716-446655440000',
{ isPinned: true },
);
expect(mockActive.reload).toHaveBeenCalledTimes(1);
});
it('does not drop organization actions for another session while one is busy', async () => {
mockConnection.capabilities = {
qwenCodeVersion: '1.2.3',
features: ['session_organization'],
};
let resolveFirst: ((value: unknown) => void) | undefined;
mockWorkspaceActions.updateSessionOrganization.mockImplementation(
(sessionId: string) => {
if (sessionId === 'session-a') {
return new Promise((resolve) => {
resolveFirst = resolve;
});
}
return Promise.resolve({
sessionId,
groupId: null,
isPinned: true,
pinnedAt: '2026-07-04T00:00:00.000Z',
updatedAt: '2026-07-04T00:00:00.000Z',
});
},
);
mockActive.sessions = [makeSession('session-a'), makeSession('session-b')];
const { container } = renderSidebar(false);
await act(async () => {
await Promise.resolve();
});
const pinButtons = Array.from(
container.querySelectorAll<HTMLButtonElement>('[aria-label="Pin"]'),
);
expect(pinButtons).toHaveLength(2);
await act(async () => {
pinButtons[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(
mockWorkspaceActions.updateSessionOrganization,
).toHaveBeenCalledTimes(1);
await act(async () => {
pinButtons[1]!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(
mockWorkspaceActions.updateSessionOrganization,
).toHaveBeenCalledTimes(2);
expect(
mockWorkspaceActions.updateSessionOrganization,
).toHaveBeenLastCalledWith('session-b', { isPinned: true });
await act(async () => {
resolveFirst?.({
sessionId: 'session-a',
groupId: null,
isPinned: true,
pinnedAt: '2026-07-04T00:00:00.000Z',
updatedAt: '2026-07-04T00:00:00.000Z',
});
await Promise.resolve();
});
});
it('keeps new session available while a session organization update is busy', async () => {
mockConnection.capabilities = {
qwenCodeVersion: '1.2.3',
features: ['session_organization'],
};
let resolveUpdate: ((value: unknown) => void) | undefined;
mockWorkspaceActions.updateSessionOrganization.mockReturnValueOnce(
new Promise((resolve) => {
resolveUpdate = resolve;
}),
);
mockActive.sessions = [makeSession('session-a')];
const onNewSession = vi.fn();
const { container } = renderSidebar(false, { onNewSession });
await act(async () => {
await Promise.resolve();
});
const pinButton =
container.querySelector<HTMLButtonElement>('[aria-label="Pin"]');
await act(async () => {
pinButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
const newSessionButton = container.querySelector<HTMLButtonElement>(
'[aria-label="New chat"]',
);
expect(newSessionButton).not.toBeNull();
expect(newSessionButton!.disabled).toBe(false);
act(() => {
newSessionButton!.click();
});
expect(onNewSession).toHaveBeenCalledTimes(1);
await act(async () => {
resolveUpdate?.({
sessionId: 'session-a',
groupId: null,
isPinned: true,
pinnedAt: '2026-07-04T00:00:00.000Z',
updatedAt: '2026-07-04T00:00:00.000Z',
});
await Promise.resolve();
});
expect(newSessionButton!.disabled).toBe(false);
});
it('does not report organization failure when post-mutation reload fails', async () => {
mockConnection.capabilities = {
qwenCodeVersion: '1.2.3',
features: ['session_organization'],
};
mockWorkspaceActions.updateSessionOrganization.mockResolvedValueOnce({
sessionId: 'session-a',
groupId: null,
isPinned: true,
pinnedAt: '2026-07-04T00:00:00.000Z',
updatedAt: '2026-07-04T00:00:00.000Z',
});
mockActive.reload.mockRejectedValueOnce(new Error('reload failed'));
mockActive.sessions = [makeSession('session-a')];
const onError = vi.fn();
const { container } = renderSidebar(false, { onError });
await act(async () => {
await Promise.resolve();
});
const pinButton =
container.querySelector<HTMLButtonElement>('[aria-label="Pin"]');
await act(async () => {
pinButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(mockWorkspaceActions.updateSessionOrganization).toHaveBeenCalledWith(
'session-a',
{ isPinned: true },
);
expect(mockActive.reload).toHaveBeenCalledTimes(1);
expect(onError).not.toHaveBeenCalled();
});
});
describe('WebShellSidebar — session export', () => {
it('hides export action when daemon does not advertise session_export', () => {
mockActive.sessions = [makeSession('session-1')];
const { container } = renderSidebar(false);
expect(
container.querySelector('[aria-label="Export conversation record"]'),
).toBeNull();
});
it('downloads an HTML export when export action is clicked', async () => {
mockConnection.capabilities = {
qwenCodeVersion: '1.2.3',
features: ['session_export'],
};
mockActive.sessions = [makeSession('session-1')];
const createObjectURL = vi.fn(() => 'blob:session-export');
const revokeObjectURL = vi.fn();
vi.stubGlobal('URL', {
...URL,
createObjectURL,
revokeObjectURL,
});
const clickSpy = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {});
const { container } = renderSidebar(false);
const button = container.querySelector<HTMLButtonElement>(
'[aria-label="Export conversation record"]',
);
expect(button).not.toBeNull();
await act(async () => {
button!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await new Promise((r) => setTimeout(r, 0));
});
expect(mockExportSession).toHaveBeenCalledWith('session-1', 'html');
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).toHaveBeenCalledWith('blob:session-export');
});
it('does not block switching sessions while an export is running', async () => {
mockConnection.capabilities = {
qwenCodeVersion: '1.2.3',
features: ['session_export'],
};
mockActive.sessions = [makeSession('session-1'), makeSession('session-2')];
let resolveExport:
| ((value: Awaited<ReturnType<typeof mockExportSession>>) => void)
| undefined;
mockExportSession.mockReturnValueOnce(
new Promise((resolve) => {
resolveExport = resolve;
}),
);
const createObjectURL = vi.fn(() => 'blob:session-export');
vi.stubGlobal('URL', {
...URL,
createObjectURL,
revokeObjectURL: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
const onLoadSession = vi.fn();
const { container } = renderSidebar(false, { onLoadSession });
const exportButton = container.querySelector<HTMLButtonElement>(
'[aria-label="Export conversation record"]',
);
await act(async () => {
exportButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
const secondSessionRow = Array.from(
container.querySelectorAll<HTMLElement>('[role="button"]'),
).find((el) => el.textContent?.includes('Session session-2'));
click(secondSessionRow ?? null);
expect(onLoadSession).toHaveBeenCalledWith('session-2');
await act(async () => {
resolveExport?.({
content: '<html>export</html>',
filename: 'session.html',
mimeType: 'text/html',
format: 'html',
});
await Promise.resolve();
});
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
});
it('reports export failures through onError', async () => {
mockConnection.capabilities = {
qwenCodeVersion: '1.2.3',
features: ['session_export'],
};
mockActive.sessions = [makeSession('session-1')];
const error = new Error('download failed');
mockExportSession.mockRejectedValueOnce(error);
const onError = vi.fn();
const { container } = renderSidebar(false, { onError });
const button = container.querySelector<HTMLButtonElement>(
'[aria-label="Export conversation record"]',
);
expect(button).not.toBeNull();
await act(async () => {
button!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await new Promise((r) => setTimeout(r, 0));
});
expect(onError).toHaveBeenCalledWith(error, 'Failed to export session');
});
});
describe('WebShellSidebar — archive actions', () => {
it('archives an active session from the quick action button', async () => {
mockActive.sessions = [makeSession('aaaaaaaa')];
const { container } = renderSidebar(false);
const archiveBtn = container.querySelector<HTMLButtonElement>(
'[aria-label="Archive"]',
);
expect(archiveBtn).not.toBeNull();
expect(archiveBtn!.disabled).toBe(false);
await clickAsync(archiveBtn);
expect(mockActive.archiveSession).toHaveBeenCalledWith('aaaaaaaa');
expect(mockArchived.unarchiveSession).not.toHaveBeenCalled();
});
it('disables archiving the current session', () => {
mockActive.sessions = [makeSession('current1')];
mockConnection.sessionId = 'current1';
const { container } = renderSidebar(false);
const archiveBtn = container.querySelector<HTMLButtonElement>(
'[aria-label="Archive"]',
);
expect(archiveBtn).not.toBeNull();
expect(archiveBtn!.disabled).toBe(true);
click(archiveBtn);
expect(mockActive.archiveSession).not.toHaveBeenCalled();
});
it('opens the overflow menu with rename, archive, and delete', () => {
mockActive.sessions = [makeSession('aaaaaaaa')];
const { container } = renderSidebar(false);
click(container.querySelector('[aria-label="More actions"]'));
const menu = container.querySelector('[role="menu"]');
expect(menu).not.toBeNull();
const labels = Array.from(menu!.querySelectorAll('[role="menuitem"]')).map(
(el) => el.textContent,
);
expect(labels).toEqual(['Rename', 'Archive', 'Delete']);
});
it('archives a non-current session from the overflow menu', async () => {
mockActive.sessions = [makeSession('aaaaaaaa')];
const { container } = renderSidebar(false);
click(container.querySelector('[aria-label="More actions"]'));
const archiveItem = Array.from(
container.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'),
).find((el) => el.textContent === 'Archive');
await clickAsync(archiveItem ?? null);
expect(mockActive.archiveSession).toHaveBeenCalledWith('aaaaaaaa');
});
it('reveals archived sessions on demand and restores them', async () => {
mockArchived.sessions = [makeSession('bbbbbbbb', { isArchived: true })];
const { container } = renderSidebar(false);
// Collapsed by default: the archived rows (and their Restore button) are
// not rendered until the section is expanded.
expect(container.querySelector('[aria-label="Restore"]')).toBeNull();
const header = Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes('Archived'),
);
click(header ?? null);
const restoreBtn = container.querySelector<HTMLButtonElement>(
'[aria-label="Restore"]',
);
expect(restoreBtn).not.toBeNull();
await clickAsync(restoreBtn);
expect(mockArchived.unarchiveSession).toHaveBeenCalledWith('bbbbbbbb');
});
});
describe('WebShellSidebar — sessionListReloadToken effect', () => {
it('calls reload when token changes', async () => {
mockActive.reload.mockResolvedValue(undefined);
const { rerender } = renderSidebar(false, {
sessionListReloadToken: 0,
});
expect(mockActive.reload).not.toHaveBeenCalled();
rerender({ sessionListReloadToken: 1 });
await act(async () => {
await Promise.resolve();
});
expect(mockActive.reload).toHaveBeenCalledTimes(1);
});
it('does not call reload when token is undefined', async () => {
mockActive.reload.mockResolvedValue(undefined);
const { rerender } = renderSidebar(false);
rerender({});
await act(async () => {
await Promise.resolve();
});
expect(mockActive.reload).not.toHaveBeenCalled();
});
it('does not call reload when token is unchanged', async () => {
mockActive.reload.mockResolvedValue(undefined);
const { rerender } = renderSidebar(false, {
sessionListReloadToken: 1,
});
mockActive.reload.mockClear();
rerender({ sessionListReloadToken: 1 });
await act(async () => {
await Promise.resolve();
});
expect(mockActive.reload).not.toHaveBeenCalled();
});
it('skips reload when document is hidden', async () => {
mockActive.reload.mockResolvedValue(undefined);
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => true,
});
const { rerender } = renderSidebar(false, {
sessionListReloadToken: 0,
});
rerender({ sessionListReloadToken: 1 });
await act(async () => {
await Promise.resolve();
});
expect(mockActive.reload).not.toHaveBeenCalled();
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => false,
});
});
it('skips reload when a poll is already in flight', async () => {
let resolveFirstPoll: (() => void) | undefined;
mockActive.reload.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveFirstPoll = resolve;
}),
);
const { rerender } = renderSidebar(false, {
sessionListReloadToken: 0,
});
rerender({ sessionListReloadToken: 1 });
await act(async () => {
await Promise.resolve();
});
expect(mockActive.reload).toHaveBeenCalledTimes(1);
mockActive.reload.mockResolvedValue(undefined);
rerender({ sessionListReloadToken: 2 });
await act(async () => {
await Promise.resolve();
});
expect(mockActive.reload).toHaveBeenCalledTimes(1);
await act(async () => {
resolveFirstPoll?.();
await Promise.resolve();
});
rerender({ sessionListReloadToken: 3 });
await act(async () => {
await Promise.resolve();
});
expect(mockActive.reload).toHaveBeenCalledTimes(2);
});
});