diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx new file mode 100644 index 00000000000..1a9cd76bb22 --- /dev/null +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx @@ -0,0 +1,437 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import type { DaemonSessionSummary } from '@qwen-code/sdk/daemon'; + +const { connection, workspace, workspaceActions, active, pinned, archived } = + vi.hoisted(() => { + const makeSessions = () => { + const state = { + sessions: [] as DaemonSessionSummary[], + loading: false, + error: null as Error | null, + // Mirror useDaemonSessions: data is undefined until the first list + // settles. Unit tests treat the mock as already settled. + data: [] as DaemonSessionSummary[] | undefined, + reload: vi.fn().mockResolvedValue(undefined), + deleteSession: vi.fn().mockResolvedValue(true), + archiveSession: vi.fn().mockResolvedValue(true), + unarchiveSession: vi.fn().mockResolvedValue(true), + exportSession: vi.fn(), + }; + state.data = state.sessions; + return state; + }; + return { + connection: { + status: 'connected', + sessionId: null as string | null, + workspaceCwd: '/tmp/project', + capabilities: undefined as + | { + qwenCodeVersion: string; + features: string[]; + } + | undefined, + }, + workspace: { + capabilities: undefined as + | { + qwenCodeVersion: string; + features: string[]; + } + | undefined, + client: { + workspaceByCwd: vi.fn(() => ({ + listWorkspaceSessions: vi.fn().mockResolvedValue([]), + listSessionGroups: vi.fn().mockResolvedValue({ + groups: [], + colorOptions: [ + 'red', + 'orange', + 'yellow', + 'green', + 'blue', + 'purple', + ], + }), + })), + }, + refreshCapabilities: vi.fn(), + }, + workspaceActions: { + addWorkspace: vi.fn(), + removeWorkspace: vi.fn(), + listSessionGroups: vi.fn().mockResolvedValue({ + groups: [], + colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'], + }), + createSessionGroup: vi.fn(), + updateSessionGroup: vi.fn(), + deleteSessionGroup: vi.fn(), + updateSessionOrganization: vi.fn(), + }, + active: makeSessions(), + pinned: makeSessions(), + archived: makeSessions(), + }; + }); + +vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ + useConnection: () => connection, + useActions: () => ({ renameSession: vi.fn() }), + useWorkspace: () => workspace, + useWorkspaceActions: () => workspaceActions, + useSessions: (options?: { archiveState?: string; group?: string }) => { + if (options?.archiveState === 'archived') return archived; + if (options?.group === 'pinned') return pinned; + return active; + }, +})); + +const { I18nProvider } = await import('../../i18n'); +const { WebShellSidebar } = await import('./WebShellSidebar'); +const { COLLAPSED_SESSION_SECTIONS_STORAGE_KEY } = await import( + './collapsedSessionSections' +); + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +if (!globalThis.PointerEvent) { + globalThis.PointerEvent = MouseEvent as typeof PointerEvent; +} +if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false; +} +if (!Element.prototype.setPointerCapture) { + Element.prototype.setPointerCapture = () => {}; +} +if (!Element.prototype.releasePointerCapture) { + Element.prototype.releasePointerCapture = () => {}; +} +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; +} + +function makeSession( + sessionId: string, + over: Partial = {}, +): DaemonSessionSummary { + 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, + isArchived: false, + isPinned: false, + groupId: null, + color: null, + ...over, + } as DaemonSessionSummary; +} + +const organizationCapabilities = { + qwenCodeVersion: '1.2.3', + features: ['session_organization'], +}; + +const namedGroup = { + id: 'group-1', + name: 'Backend', + color: 'green' as const, + order: 0, + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', +}; + +let root: Root; +let container: HTMLDivElement; + +function renderSidebar() { + act(() => { + root.render( + + {}} + onOpenSettings={() => {}} + onOpenDaemonStatus={() => {}} + onOpenScheduledTasks={() => {}} + onOpenSessions={() => {}} + onOpenSplitView={() => {}} + onNewSession={() => false} + onLoadSession={() => {}} + onError={() => {}} + /> + , + ); + }); +} + +async function flushSidebar() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function groupHeader(label: string): HTMLButtonElement { + const section = container.querySelector( + `section[aria-label="${label}"]`, + ); + expect(section).not.toBeNull(); + const header = section!.querySelector( + 'button[aria-expanded]', + ); + expect(header).not.toBeNull(); + return header!; +} + +function click(element: HTMLElement): void { + element.dispatchEvent(new MouseEvent('click', { bubbles: true })); +} + +beforeEach(() => { + window.localStorage.clear(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + connection.sessionId = null; + connection.workspaceCwd = '/tmp/project'; + connection.capabilities = organizationCapabilities; + workspace.capabilities = organizationCapabilities; + workspaceActions.listSessionGroups.mockReset(); + workspaceActions.listSessionGroups.mockResolvedValue({ + groups: [namedGroup], + colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'], + }); + active.sessions = [ + makeSession('session-a', { + displayName: 'API review', + groupId: 'group-1', + }), + makeSession('session-b', { + displayName: 'Release notes', + groupId: null, + }), + ]; + active.data = active.sessions; + pinned.sessions = []; + pinned.data = pinned.sessions; + archived.sessions = []; + archived.data = archived.sessions; +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + window.localStorage.clear(); + vi.restoreAllMocks(); +}); + +describe('WebShellSidebar collapsed session group persistence', () => { + it('writes collapsed section ids with the qwen-code-web-shell-* key', async () => { + renderSidebar(); + await flushSidebar(); + + const backend = container.querySelector( + 'section[aria-label="Backend"]', + ); + expect(backend?.textContent).toContain('API review'); + act(() => click(groupHeader('Backend'))); + await flushSidebar(); + + expect(groupHeader('Backend').getAttribute('aria-expanded')).toBe('false'); + expect(backend?.textContent).not.toContain('API review'); + expect( + window.localStorage.getItem(COLLAPSED_SESSION_SECTIONS_STORAGE_KEY), + ).toBe(JSON.stringify(['group:group-1'])); + }); + + it('keeps a collapsed named group collapsed across remount', async () => { + window.localStorage.setItem( + COLLAPSED_SESSION_SECTIONS_STORAGE_KEY, + JSON.stringify(['group:group-1']), + ); + + renderSidebar(); + await flushSidebar(); + + expect(groupHeader('Backend').getAttribute('aria-expanded')).toBe('false'); + const backend = container.querySelector( + 'section[aria-label="Backend"]', + ); + expect(backend?.textContent).not.toContain('API review'); + expect(container.textContent).toContain('Release notes'); + }); + + it('keeps an expanded group expanded across remount after clearing collapse', async () => { + window.localStorage.setItem( + COLLAPSED_SESSION_SECTIONS_STORAGE_KEY, + JSON.stringify(['group:group-1']), + ); + + renderSidebar(); + await flushSidebar(); + act(() => click(groupHeader('Backend'))); + await flushSidebar(); + expect(container.textContent).toContain('API review'); + expect( + window.localStorage.getItem(COLLAPSED_SESSION_SECTIONS_STORAGE_KEY), + ).toBe(JSON.stringify([])); + + act(() => root.unmount()); + container.remove(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + renderSidebar(); + await flushSidebar(); + + expect(groupHeader('Backend').getAttribute('aria-expanded')).toBe('true'); + expect(container.textContent).toContain('API review'); + }); + + it('tolerates corrupt localStorage data', async () => { + window.localStorage.setItem( + COLLAPSED_SESSION_SECTIONS_STORAGE_KEY, + 'not valid json', + ); + + renderSidebar(); + await flushSidebar(); + + expect(groupHeader('Backend').getAttribute('aria-expanded')).toBe('true'); + expect(container.textContent).toContain('API review'); + }); + + it('ignores non-array localStorage payloads', async () => { + window.localStorage.setItem( + COLLAPSED_SESSION_SECTIONS_STORAGE_KEY, + JSON.stringify({ group: 'group-1' }), + ); + + renderSidebar(); + await flushSidebar(); + + expect(groupHeader('Backend').getAttribute('aria-expanded')).toBe('true'); + }); + + it('does not crash when localStorage.setItem throws', async () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('quota exceeded'); + }); + + renderSidebar(); + await flushSidebar(); + act(() => click(groupHeader('Backend'))); + await flushSidebar(); + + expect(groupHeader('Backend').getAttribute('aria-expanded')).toBe('false'); + }); + + it('auto-collapses a brand-new section that appears mid-session', async () => { + renderSidebar(); + await flushSidebar(); + expect(groupHeader('Backend').getAttribute('aria-expanded')).toBe('true'); + expect(container.querySelector('section[aria-label="Red"]')).toBeNull(); + + // Keep the same React root so the first-catalog latch stays flipped. + // Color sections are derived from the session list, so tagging a session + // mid-session invents a new `color:red` section id. + active.sessions = [ + makeSession('session-a', { + displayName: 'API review', + groupId: 'group-1', + }), + makeSession('session-b', { + displayName: 'Release notes', + groupId: null, + color: 'red', + }), + ]; + active.data = active.sessions; + renderSidebar(); + await flushSidebar(); + + const redHeader = groupHeader('Red'); + expect(redHeader.getAttribute('aria-expanded')).toBe('false'); + expect( + container.querySelector('section[aria-label="Red"]')?.textContent, + ).not.toContain('Release notes'); + expect(groupHeader('Backend').getAttribute('aria-expanded')).toBe('true'); + }); + + it('restores multiple section kinds and keeps sibling ids when one is removed', async () => { + active.sessions = [ + makeSession('session-a', { + displayName: 'API review', + groupId: 'group-1', + }), + makeSession('session-b', { + displayName: 'Release notes', + groupId: null, + }), + makeSession('session-c', { + displayName: 'Hotfix', + groupId: null, + color: 'red', + }), + ]; + active.data = active.sessions; + window.localStorage.setItem( + COLLAPSED_SESSION_SECTIONS_STORAGE_KEY, + JSON.stringify(['color:red', 'group:group-1', 'recent']), + ); + + renderSidebar(); + await flushSidebar(); + + expect(groupHeader('Backend').getAttribute('aria-expanded')).toBe('false'); + expect(groupHeader('Ungrouped').getAttribute('aria-expanded')).toBe( + 'false', + ); + expect(groupHeader('Red').getAttribute('aria-expanded')).toBe('false'); + + act(() => click(groupHeader('Backend'))); + await flushSidebar(); + + expect(groupHeader('Backend').getAttribute('aria-expanded')).toBe('true'); + expect(groupHeader('Ungrouped').getAttribute('aria-expanded')).toBe( + 'false', + ); + expect(groupHeader('Red').getAttribute('aria-expanded')).toBe('false'); + expect( + JSON.parse( + window.localStorage.getItem(COLLAPSED_SESSION_SECTIONS_STORAGE_KEY) ?? + '[]', + ), + ).toEqual(['color:red', 'recent']); + }); + + it('does not clobber workspace-scoped collapse ids when primary toggles', async () => { + window.localStorage.setItem( + COLLAPSED_SESSION_SECTIONS_STORAGE_KEY, + JSON.stringify([ + 'group:group-1', + 'ws:other|group:g2', + 'ws:other|ungrouped', + ]), + ); + + renderSidebar(); + await flushSidebar(); + act(() => click(groupHeader('Backend'))); + await flushSidebar(); + + expect( + JSON.parse( + window.localStorage.getItem(COLLAPSED_SESSION_SECTIONS_STORAGE_KEY) ?? + '[]', + ), + ).toEqual(['ws:other|group:g2', 'ws:other|ungrouped']); + }); +}); diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index 6c594a2730b..d58bcaeda88 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -80,6 +80,11 @@ import { DialogShell } from '../dialogs/DialogShell'; import { AddWorkspaceDialog } from '../dialogs/AddWorkspaceDialog'; import { WorkspaceSection } from './WorkspaceSection'; import { SessionGroupSection } from './SessionGroupSection'; +import { + isPrimaryCollapsedSectionId, + readCollapsedSessionSectionIds, + replaceOwnedCollapsedSessionSectionIds, +} from './collapsedSessionSections'; import { SESSION_LIST_PAGE_SIZE, SESSION_ORGANIZATION_FEATURE, @@ -441,6 +446,7 @@ export function WebShellSidebar({ sessions, loading, error, + data: sessionsPage, reload, deleteSession, exportSession, @@ -454,6 +460,16 @@ export function WebShellSidebar({ ? { view: 'organized' as const, group: 'all' } : {}), }); + // useDaemonResource starts with loading=false before autoLoad runs, so + // !loading is not “settled”. Treat the first data as the ready signal (empty + // lists are still defined data) so the initial-catalog latch waits. Errors + // must NOT settle it: a latch consumed against a failed request would treat + // every section from the eventual successful reload as brand-new, + // auto-collapsing and persisting over the user's restored expansions. + const sessionsCatalogReady = + !organizationEnabled || + !includePrimaryWorkspaceSessions || + sessionsPage !== undefined; const { sessions: primaryPinnedSessions, reload: reloadPinnedSessions } = useSessions({ autoLoad: organizationEnabled, @@ -524,8 +540,32 @@ export function WebShellSidebar({ } | null>(null); const [collapsedSessionSectionIds, setCollapsedSessionSectionIds] = useState< Set - >(() => new Set()); + >( + () => + new Set( + Array.from(readCollapsedSessionSectionIds()).filter( + isPrimaryCollapsedSectionId, + ), + ), + ); const knownSessionSectionIdsRef = useRef>(new Set()); + // Dedicated first-sync latch. Cleared only after both groups catalog and + // sessions list have settled (including empty responses). Do not infer this + // from knownSessionSectionIdsRef.size — seeding that set early would make the + // first real sync look mid-session and auto-collapse restored expansions. + const awaitingInitialSessionCatalogRef = useRef(true); + const [groupsCatalogReady, setGroupsCatalogReady] = + useState(!organizationEnabled); + // organizationEnabled can flip true mid-session (capabilities can land after + // the flat sessions request settles). Close the gate during that same render: + // deferring to the reload effect would let the auto-collapse effect consume + // the first-sync latch against the stale pre-organized catalog first. + const [prevOrganizationEnabled, setPrevOrganizationEnabled] = + useState(organizationEnabled); + if (prevOrganizationEnabled !== organizationEnabled) { + setPrevOrganizationEnabled(organizationEnabled); + setGroupsCatalogReady(!organizationEnabled); + } const [sidebarWidth, setSidebarWidth] = useState(readSidebarWidth); const [projectExpanded, setProjectExpanded] = useState(false); const [projectsExpanded, setProjectsExpanded] = useState(true); @@ -837,6 +877,7 @@ export function WebShellSidebar({ if (!organizationEnabled) { setGroups([]); setColorOptions([]); + setGroupsCatalogReady(true); return; } try { @@ -844,6 +885,10 @@ export function WebShellSidebar({ setGroups(catalog.groups); setMenuGroups(catalog.groups); setColorOptions(catalog.colorOptions); + // Empty catalogs still settle the latch — sessions/groups hydrate on + // independent requests, so readiness cannot wait for a non-empty list. + // Failures must not settle it (see sessionsCatalogReady above). + setGroupsCatalogReady(true); } catch (err) { onError(err, t('sidebar.groupsLoadFailed')); } @@ -853,8 +898,10 @@ export function WebShellSidebar({ if (!organizationEnabled) { setGroups([]); setColorOptions([]); + setGroupsCatalogReady(true); return; } + setGroupsCatalogReady(false); void reloadGroups(); }, [organizationEnabled, reloadGroups]); @@ -1933,17 +1980,43 @@ export function WebShellSidebar({ }, [filteredSessions, groups, organizationEnabled, searchQuery, t]); useEffect(() => { + if (!organizationEnabled) return; + // Wait for both independent catalog sources. Flipping the latch on the + // first non-empty derived sections would treat later initial recent/color + // ids as brand-new and auto-collapse them; leaving the latch set when the + // first ready catalog is empty would leave the first real section expanded. + if (!groupsCatalogReady || !sessionsCatalogReady) return; + const unseenIds = sessionSections .map((section) => section.id) .filter((id) => !knownSessionSectionIdsRef.current.has(id)); + const isInitialCatalog = awaitingInitialSessionCatalogRef.current; + if (isInitialCatalog) { + awaitingInitialSessionCatalogRef.current = false; + for (const id of unseenIds) knownSessionSectionIdsRef.current.add(id); + return; + } if (unseenIds.length === 0) return; for (const id of unseenIds) knownSessionSectionIdsRef.current.add(id); + // Brand-new sections that appear mid-session still start collapsed. setCollapsedSessionSectionIds((current) => { const next = new Set(current); for (const id of unseenIds) next.add(id); return next; }); - }, [sessionSections]); + }, [ + groupsCatalogReady, + organizationEnabled, + sessionSections, + sessionsCatalogReady, + ]); + + useEffect(() => { + replaceOwnedCollapsedSessionSectionIds( + collapsedSessionSectionIds, + isPrimaryCollapsedSectionId, + ); + }, [collapsedSessionSectionIds]); const toggleSessionSection = useCallback((sectionId: string) => { setCollapsedSessionSectionIds((current) => { diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx index 96a96799715..f35c5ae1878 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx @@ -13,6 +13,10 @@ import type { } from '@qwen-code/sdk/daemon'; import { FolderClosedIcon, FolderOpenIcon } from 'lucide-react'; import { SESSION_LIST_PAGE_SIZE } from '../../constants/sessions'; +import { + readWorkspaceCollapsedGroupIds, + writeWorkspaceCollapsedGroupIds, +} from './collapsedSessionSections'; import { SessionGroupSection } from './SessionGroupSection'; import styles from './WorkspaceSection.module.css'; @@ -109,8 +113,8 @@ export function WorkspaceSection({ const [groups, setGroups] = useState([]); const [loadError, setLoadError] = useState(false); const [internalExpanded, setInternalExpanded] = useState(false); - const [collapsedGroupIds, setCollapsedGroupIds] = useState>( - () => new Set(), + const [collapsedGroupIds, setCollapsedGroupIds] = useState>(() => + readWorkspaceCollapsedGroupIds(workspace.id), ); const [actionsVisible, setActionsVisible] = useState(false); const expanded = controlledExpanded ?? internalExpanded; @@ -122,6 +126,12 @@ export function WorkspaceSection({ if (controlledExpanded === undefined) setInternalExpanded(false); }, [controlledExpanded, workspace.id]); + // The render site keys this component by workspace id, so an id change + // always remounts and the lazy useState initializer re-reads storage. + useEffect(() => { + writeWorkspaceCollapsedGroupIds(workspace.id, collapsedGroupIds); + }, [collapsedGroupIds, workspace.id]); + useEffect(() => { if (controlledExpanded === undefined && autoExpandKey) { setInternalExpanded(true); diff --git a/packages/web-shell/client/components/sidebar/collapsedSessionSections.test.ts b/packages/web-shell/client/components/sidebar/collapsedSessionSections.test.ts new file mode 100644 index 00000000000..ecf970d2cd8 --- /dev/null +++ b/packages/web-shell/client/components/sidebar/collapsedSessionSections.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest'; +import { + COLLAPSED_SESSION_SECTIONS_STORAGE_KEY, + readWorkspaceCollapsedGroupIds, + replaceOwnedCollapsedSessionSectionIds, + writeWorkspaceCollapsedGroupIds, +} from './collapsedSessionSections'; + +afterEach(() => { + window.localStorage.clear(); +}); + +describe('collapsedSessionSections helpers', () => { + it('merges owned ids without removing other owners', () => { + window.localStorage.setItem( + COLLAPSED_SESSION_SECTIONS_STORAGE_KEY, + JSON.stringify([ + 'group:primary', + 'recent', + 'ws:alpha|group:g1', + 'ws:beta|ungrouped', + ]), + ); + + replaceOwnedCollapsedSessionSectionIds( + new Set(['group:primary', 'color:red']), + (id) => !id.startsWith('ws:'), + ); + + expect( + JSON.parse( + window.localStorage.getItem(COLLAPSED_SESSION_SECTIONS_STORAGE_KEY) ?? + '[]', + ), + ).toEqual([ + 'color:red', + 'group:primary', + 'ws:alpha|group:g1', + 'ws:beta|ungrouped', + ]); + }); + + it('round-trips workspace-local group ids through namespaced storage', () => { + writeWorkspaceCollapsedGroupIds('ws-1', new Set(['group-a', 'ungrouped'])); + writeWorkspaceCollapsedGroupIds('ws-2', new Set(['group-b'])); + + expect(Array.from(readWorkspaceCollapsedGroupIds('ws-1')).sort()).toEqual([ + 'group-a', + 'ungrouped', + ]); + expect(Array.from(readWorkspaceCollapsedGroupIds('ws-2')).sort()).toEqual([ + 'group-b', + ]); + expect( + JSON.parse( + window.localStorage.getItem(COLLAPSED_SESSION_SECTIONS_STORAGE_KEY) ?? + '[]', + ), + ).toEqual([ + 'ws:ws-1|group:group-a', + 'ws:ws-1|ungrouped', + 'ws:ws-2|group:group-b', + ]); + }); +}); diff --git a/packages/web-shell/client/components/sidebar/collapsedSessionSections.ts b/packages/web-shell/client/components/sidebar/collapsedSessionSections.ts new file mode 100644 index 00000000000..33c2c744db5 --- /dev/null +++ b/packages/web-shell/client/components/sidebar/collapsedSessionSections.ts @@ -0,0 +1,117 @@ +/** + * Shared localStorage helpers for Web Shell session-organization collapse + * state. Primary sidebar and per-workspace sections both write into one app + * key so preferences survive reload without competing overwrites. + * + * Id conventions: + * - Primary catalog: `group:`, `recent`, `color:` + * - Workspace-scoped: `ws:|group:`, `ws:|ungrouped` + */ + +export const COLLAPSED_SESSION_SECTIONS_STORAGE_KEY = + 'qwen-code-web-shell-collapsed-session-groups'; + +const WORKSPACE_SECTION_PREFIX = 'ws:'; + +export function isPrimaryCollapsedSectionId(id: string): boolean { + return !id.startsWith(WORKSPACE_SECTION_PREFIX); +} + +export function workspaceGroupSectionId( + workspaceId: string, + groupId: string, +): string { + return `${WORKSPACE_SECTION_PREFIX}${workspaceId}|group:${groupId}`; +} + +export function workspaceUngroupedSectionId(workspaceId: string): string { + return `${WORKSPACE_SECTION_PREFIX}${workspaceId}|ungrouped`; +} + +export function isWorkspaceCollapsedSectionId( + workspaceId: string, + id: string, +): boolean { + return id.startsWith(`${WORKSPACE_SECTION_PREFIX}${workspaceId}|`); +} + +export function readCollapsedSessionSectionIds(): Set { + if (typeof window === 'undefined') return new Set(); + try { + const raw = window.localStorage.getItem( + COLLAPSED_SESSION_SECTIONS_STORAGE_KEY, + ); + if (!raw) return new Set(); + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set( + parsed.filter( + (item): item is string => + typeof item === 'string' && item.trim().length > 0, + ), + ); + } catch { + return new Set(); + } +} + +export function writeCollapsedSessionSectionIds( + ids: ReadonlySet, +): void { + try { + window.localStorage.setItem( + COLLAPSED_SESSION_SECTIONS_STORAGE_KEY, + JSON.stringify(Array.from(ids).sort()), + ); + } catch { + // localStorage can be unavailable in private or embedded contexts. + } +} + +/** + * Replace one owner's subset of collapsed ids while preserving other owners + * (primary vs per-workspace), so parallel writers do not clobber each other. + */ +export function replaceOwnedCollapsedSessionSectionIds( + ownedIds: ReadonlySet, + isOwned: (id: string) => boolean, +): void { + const stored = readCollapsedSessionSectionIds(); + const next = new Set(Array.from(stored).filter((id) => !isOwned(id))); + for (const id of ownedIds) next.add(id); + writeCollapsedSessionSectionIds(next); +} + +export function readWorkspaceCollapsedGroupIds( + workspaceId: string, +): Set { + const stored = readCollapsedSessionSectionIds(); + const local = new Set(); + const groupPrefix = `${WORKSPACE_SECTION_PREFIX}${workspaceId}|group:`; + const ungroupedId = workspaceUngroupedSectionId(workspaceId); + for (const id of stored) { + if (id === ungroupedId) { + local.add('ungrouped'); + } else if (id.startsWith(groupPrefix)) { + local.add(id.slice(groupPrefix.length)); + } + } + return local; +} + +export function writeWorkspaceCollapsedGroupIds( + workspaceId: string, + localIds: ReadonlySet, +): void { + const owned = new Set(); + for (const id of localIds) { + owned.add( + id === 'ungrouped' + ? workspaceUngroupedSectionId(workspaceId) + : workspaceGroupSectionId(workspaceId, id), + ); + } + replaceOwnedCollapsedSessionSectionIds(owned, (id) => + isWorkspaceCollapsedSectionId(workspaceId, id), + ); +} diff --git a/packages/web-shell/client/e2e/utils/mockDaemon.ts b/packages/web-shell/client/e2e/utils/mockDaemon.ts index 822ff44d179..a4677adf734 100644 --- a/packages/web-shell/client/e2e/utils/mockDaemon.ts +++ b/packages/web-shell/client/e2e/utils/mockDaemon.ts @@ -6,6 +6,8 @@ import { type DaemonEvent, type DaemonRestoredSession, type DaemonSession, + type DaemonSessionGroup, + type DaemonSessionGroupCatalog, type DaemonSessionState, type DaemonSessionSummary, type DaemonWorkspaceExtensionsStatus, @@ -41,6 +43,7 @@ export interface WebShellDaemonScenario { skills: DaemonWorkspaceSkillsStatus; settings: DaemonWorkspaceSettingsStatus; sessions: DaemonSessionSummary[]; + sessionGroups: DaemonSessionGroup[]; events: DaemonEvent[]; state: DaemonSessionState; } @@ -59,7 +62,13 @@ export interface MockDaemonController { type ScenarioOverrides = Partial< Omit< WebShellDaemonScenario, - 'capabilities' | 'providers' | 'skills' | 'settings' | 'sessions' | 'state' + | 'capabilities' + | 'providers' + | 'skills' + | 'settings' + | 'sessions' + | 'sessionGroups' + | 'state' > > & { capabilities?: Partial; @@ -67,6 +76,7 @@ type ScenarioOverrides = Partial< skills?: Partial; settings?: Partial; sessions?: DaemonSessionSummary[]; + sessionGroups?: DaemonSessionGroup[]; state?: Partial; }; @@ -241,6 +251,7 @@ export function createWebShellDaemonScenario( skills, settings, sessions, + sessionGroups: overrides.sessionGroups ?? [], events: overrides.events ?? [], state, }; @@ -285,7 +296,14 @@ export async function installMockDaemon( return; } - await handleDaemonRoute(route, method, path, scenario, body); + await handleDaemonRoute( + route, + method, + path, + scenario, + body, + url.searchParams, + ); }); return { @@ -429,6 +447,7 @@ function isDaemonPath(path: string): boolean { /^\/workspace\/mcp\/[^/]+\/tools\/?$/.test(path) || /^\/workspace\/mcp\/[^/]+\/resources\/?$/.test(path) || /^\/workspace\/.+\/sessions\/?$/.test(path) || + /^\/workspace\/.+\/session-groups\/?$/.test(path) || path === '/session' || /^\/permission\/[^/]+\/?$/.test(path) || /^\/session\/[^/]+\/pending-prompts(?:\/[^/]+)?\/?$/.test(path) || @@ -466,6 +485,9 @@ function isDaemonRoute(method: string, path: string): boolean { if (method === 'GET' && /^\/workspace\/.+\/sessions\/?$/.test(path)) { return true; } + if (method === 'GET' && /^\/workspace\/.+\/session-groups\/?$/.test(path)) { + return true; + } if (method === 'POST' && path === '/session') return true; if (method === 'POST' && /^\/permission\/[^/]+\/?$/.test(path)) return true; if ( @@ -497,6 +519,7 @@ async function handleDaemonRoute( path: string, scenario: WebShellDaemonScenario, body: unknown, + searchParams: URLSearchParams = new URLSearchParams(), ): Promise { if (method === 'GET' && path === '/health') { await json(route, { ok: true, healthy: true }); @@ -561,7 +584,23 @@ async function handleDaemonRoute( return; } if (method === 'GET' && /^\/workspace\/.+\/sessions\/?$/.test(path)) { - await json(route, { sessions: scenario.sessions }); + // Mirror production query modes: `group=pinned` is the pinned bucket; + // `group=all` (and missing group) returns the full active list. The UI + // excludes pinned rows from organized sections via `excludePinned`. + const group = searchParams.get('group'); + const sessions = + group === 'pinned' + ? scenario.sessions.filter((session) => Boolean(session.isPinned)) + : scenario.sessions; + await json(route, { sessions }); + return; + } + if (method === 'GET' && /^\/workspace\/.+\/session-groups\/?$/.test(path)) { + const catalog: DaemonSessionGroupCatalog = { + groups: scenario.sessionGroups, + colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'], + }; + await json(route, catalog); return; } if (method === 'POST' && path === '/session') { diff --git a/packages/web-shell/client/e2e/web-shell.collapsed-groups-persist.spec.ts b/packages/web-shell/client/e2e/web-shell.collapsed-groups-persist.spec.ts new file mode 100644 index 00000000000..fba126eac27 --- /dev/null +++ b/packages/web-shell/client/e2e/web-shell.collapsed-groups-persist.spec.ts @@ -0,0 +1,165 @@ +import { expect, test, type Page, type TestInfo } from '@playwright/test'; +import { COLLAPSED_SESSION_SECTIONS_STORAGE_KEY } from '../components/sidebar/collapsedSessionSections'; +import { + createWebShellDaemonScenario, + installMockDaemon, + replayCompleteEvent, + type MockDaemonController, + type WebShellDaemonScenario, +} from './utils/mockDaemon'; + +test('persists collapsed session groups across reload @smoke', async ({ + page, +}, testInfo) => { + const scenario = createOrganizedScenario(); + const daemon = await installScenario(page, scenario, testInfo); + + await gotoSession(page, scenario, daemon); + + const backendSection = page.locator('section[aria-label="Backend"]'); + const backendHeader = backendSection.getByRole('button', { + name: /^Backend/, + }); + await expect(backendHeader).toHaveAttribute('aria-expanded', 'true'); + await expect(backendSection).toContainText('API review'); + + await backendHeader.click(); + await expect(backendHeader).toHaveAttribute('aria-expanded', 'false'); + await expect(backendSection).not.toContainText('API review'); + await expect + .poll(async () => + page.evaluate( + (key) => window.localStorage.getItem(key), + COLLAPSED_SESSION_SECTIONS_STORAGE_KEY, + ), + ) + .toBe(JSON.stringify(['group:group-backend'])); + + await page.reload(); + await expect(page.locator('[data-web-shell-root]')).toBeVisible(); + await completeReplay( + page, + daemon, + scenario.sessionId, + scenario.events.length, + ); + + const backendAfterReload = page.locator('section[aria-label="Backend"]'); + const backendHeaderAfterReload = backendAfterReload.getByRole('button', { + name: /^Backend/, + }); + await expect(backendHeaderAfterReload).toHaveAttribute( + 'aria-expanded', + 'false', + ); + await expect(backendAfterReload).not.toContainText('API review'); + await expect(page.locator('section[aria-label="Ungrouped"]')).toContainText( + 'Release notes', + ); +}); + +function createOrganizedScenario(): WebShellDaemonScenario { + const workspaceCwd = '/tmp/qwen-web-shell-e2e'; + const sessionId = 'web-shell-e2e-session'; + return createWebShellDaemonScenario({ + workspaceCwd, + sessionId, + capabilities: { + features: [ + 'session_events', + 'permission_vote', + 'session_permission_vote', + 'session_scope_override', + 'workspace_settings', + 'workspace_voice', + 'session_organization', + ], + }, + sessionGroups: [ + { + id: 'group-backend', + name: 'Backend', + color: 'green', + order: 0, + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }, + ], + sessions: [ + { + sessionId, + workspaceCwd, + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:00.000Z', + displayName: 'E2E Harness Session', + clientCount: 1, + hasActivePrompt: false, + groupId: null, + color: null, + }, + { + sessionId: 'session-api-review', + workspaceCwd, + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:00.000Z', + displayName: 'API review', + clientCount: 0, + hasActivePrompt: false, + groupId: 'group-backend', + color: null, + }, + { + sessionId: 'session-release-notes', + workspaceCwd, + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:00.000Z', + displayName: 'Release notes', + clientCount: 0, + hasActivePrompt: false, + groupId: null, + color: null, + }, + ], + }); +} + +async function installScenario( + page: Page, + scenario: WebShellDaemonScenario, + testInfo: TestInfo, +): Promise { + return installMockDaemon(page, scenario, { + baseURL: String(testInfo.project.use.baseURL), + }); +} + +async function gotoSession( + page: Page, + scenario: WebShellDaemonScenario, + daemon: MockDaemonController, +): Promise { + await page.goto(`/session/${encodeURIComponent(scenario.sessionId)}`); + await expect(page.locator('[data-web-shell-root]')).toBeVisible(); + await completeReplay( + page, + daemon, + scenario.sessionId, + scenario.events.length, + ); +} + +async function completeReplay( + page: Page, + daemon: MockDaemonController, + sessionId?: string, + replayedCount = 0, +): Promise { + const connection = await daemon.sse.waitForConnection(sessionId); + await daemon.sendEvent( + replayCompleteEvent({ + sessionId: connection.sessionId, + replayedCount, + }), + ); + await expect(page.getByText('Loading...')).toHaveCount(0); +}