diff --git a/apps/web/tests/expected/workspace-new-session-folding/first-batch.expected.md b/apps/web/tests/expected/workspace-new-session-folding/first-batch.expected.md new file mode 100644 index 0000000000..6009227833 --- /dev/null +++ b/apps/web/tests/expected/workspace-new-session-folding/first-batch.expected.md @@ -0,0 +1,18 @@ +- tree "Sessions": + - treeitem "{{workspace}}" [expanded] + - treeitem "New Session" [selected] + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} Session actions for {{workspace}} Archive session Pin session": + - text: {{workspace}} + - button "Session actions for {{workspace}}" + - button "Archive session" + - button "Pin session" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - button "Show 6 more sessions" diff --git a/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md b/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md index 8896ca5c5c..8778baac12 100644 --- a/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md +++ b/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md @@ -9,4 +9,4 @@ - treeitem "{{workspace}} 1min" - treeitem "{{workspace}} 1min" - treeitem "{{workspace}} 1min" - - button "Show 1 more sessions" + - button "Show 11 more sessions" diff --git a/apps/web/tests/workspace-new-session-folding.e2e.ts b/apps/web/tests/workspace-new-session-folding.e2e.ts index 21bdd58871..e23e0e9a80 100644 --- a/apps/web/tests/workspace-new-session-folding.e2e.ts +++ b/apps/web/tests/workspace-new-session-folding.e2e.ts @@ -22,7 +22,7 @@ const EXPECTED_DIR = fileURLToPath(new URL('./expected/workspace-new-session-fol const SIDEBAR_EXPECTED = join(EXPECTED_DIR, 'sidebar.expected.md') const SEED = fileURLToPath(new URL('../../../snapshots/web/message-feedback-protocol/session.v3.jsonl', import.meta.url)) const MODE = webSnapshotMode() -const EXISTING_SESSION_COUNT = 6 +const EXISTING_SESSION_COUNT = 16 describe('web e2e: blank New Session folding quota', () => { let scaffold: WebScaffold @@ -72,7 +72,7 @@ describe('web e2e: blank New Session folding quota', () => { await expect.poll(() => sidebar.getByRole('treeitem').count(), { timeout: 15_000 }).toBe(7) expect(await sidebar.getByText('New Session', { exact: true }).count()).toBe(1) expect(await sidebar.getByText(basename(scaffold.workspaceCwd), { exact: true }).count()).toBe(6) - const showMore = sidebar.getByRole('button', { name: 'Show 1 more sessions' }) + const showMore = sidebar.getByRole('button', { name: 'Show 11 more sessions' }) await showMore.waitFor({ timeout: 15_000 }) await compareOrRefreshGolden( SIDEBAR_EXPECTED, @@ -81,11 +81,20 @@ describe('web e2e: blank New Session folding quota', () => { ) await showMore.click() - await expect.poll(() => sidebar.getByRole('treeitem').count(), { timeout: 10_000 }).toBe(8) - expect(await sidebar.getByText(basename(scaffold.workspaceCwd), { exact: true }).count()).toBe(7) + await expect.poll(() => sidebar.getByRole('treeitem').count(), { timeout: 10_000 }).toBe(12) + expect(await sidebar.getByText(basename(scaffold.workspaceCwd), { exact: true }).count()).toBe(11) + await compareOrRefreshGolden( + join(EXPECTED_DIR, 'first-batch.expected.md'), + await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd), + MODE, + ) + await sidebar.getByRole('button', { name: 'Show 6 more sessions' }).click() + await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(17) + await sidebar.getByRole('button', { name: 'Show 1 more sessions' }).click() + await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(18) await sidebar.getByRole('button', { name: 'Show less' }).click() await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(7) - await assertFixtureInventory(EXPECTED_DIR, ['sidebar.expected.md']) + await assertFixtureInventory(EXPECTED_DIR, ['sidebar.expected.md', 'first-batch.expected.md']) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index d6fe6aa37c..2b13e4542f 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: 4280a8888fc4db9bc2360d5c8ad9c4e7ff62c53a -README.zh.md: 6b41555c1b4dbaf2c62776040ac9cd29c0562635 +README.md: 7d9ed7d8410998a41cec47fd6d4acd916bdf5de9 +README.zh.md: 3595d48d1cd101dc6a892aedca8f9b35d0497ad7 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 4280a8888f..7d9ed7d841 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -25,7 +25,7 @@ This package lets users browse grouped or flat Session lists, choose a Workspace ## Use this package -Use the sidebar to browse Workspaces and their Sessions, reorder them, and start new ones; use the picker in the Session Intent hero to choose a Workspace for a new session. An open Workspace shows five non-blank Sessions by default and keeps the selected blank **New Session** as one provisional extra row until its first prompt. **Show more** reveals the hidden remainder; closing and reopening the Workspace restores this folded projection. +Use the sidebar to browse Workspaces and their Sessions, reorder them, and start new ones; use the picker in the Session Intent hero to choose a Workspace for a new session. An open Workspace shows five idle, non-blank Sessions by default. Running Sessions, including parents with running children, remain visible in their ordered positions without using that quota; the selected blank **New Session** is also an extra row until its first prompt. Each **Show more** click reveals up to five more idle Sessions; after the final batch, **Show less** restores the initial rows while keeping running Sessions visible. Closing and reopening the Workspace also restores this folded projection. ### Reordering and view options diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 6b41555c1b..3595d48d1c 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -用侧边栏浏览 Workspace 及其 Session、重排它们并新建会话;在 Session Intent 主视觉区用选择器为新会话选择 Workspace。打开的 Workspace 默认显示五条非空白 Session,并在首条提示词落地前把当前选中的空白**新会话**作为一条临时额外行。**展开其余**会显示隐藏条目;关闭再打开 Workspace 会恢复该折叠投影。 +用侧边栏浏览 Workspace 及其 Session、重排它们并新建会话;在 Session Intent 主视觉区用选择器为新会话选择 Workspace。打开的 Workspace 默认显示五条空闲的非空白 Session。正在运行的 Session(包括有子会话正在运行的父会话)始终按原顺序显示,不占用这五条配额;当前选中的空白**新会话**在首条提示词落地前也作为额外行。每次点击**展开其余**最多再显示五条空闲 Session;全部显示后,**收起**恢复初始行数,但仍显示正在运行的 Session。关闭再打开 Workspace 也会恢复该折叠投影。 ### 重排序与视图选项 diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 7d5fc2e79e..a421d6bb95 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -50,19 +50,19 @@ const EXPAND_SLIDE_MS = 300 const SEARCH_DEBOUNCE_MS = 250 /** `session.search` wire bound, measured in JavaScript UTF-16 code units. */ const SEARCH_QUERY_MAX_CODE_UNITS = 500 -/** Session rows visible per Workspace before the local overflow control. */ +/** Idle Session rows visible per Workspace before the local overflow control. */ const COLLAPSED_SESSION_LIMIT = 5 -/** Fold one Workspace without charging its provisional New Session against the ordinary-row limit. */ -function collapsedSessionRows(sessions: readonly SessionNode[]): { +/** Keep provisional and running rows outside the idle-session quota, including parents with running children. */ +function collapsedSessionRows(sessions: readonly SessionNode[], limit = COLLAPSED_SESSION_LIMIT): { rows: readonly SessionNode[] hiddenCount: number } { - let ordinaryCount = 0 + let idleCount = 0 const rows = sessions.filter((session) => { - if (session.blank) return true - if (ordinaryCount >= COLLAPSED_SESSION_LIMIT) return false - ordinaryCount += 1 + if (session.blank || session.running || session.runningSubagentCount > 0) return true + if (idleCount >= limit) return false + idleCount += 1 return true }) return { rows, hiddenCount: sessions.length - rows.length } @@ -79,11 +79,6 @@ function sanitizeSearchQuery(value: string): string { return withoutNul.slice(0, end) } -/** Immutable membership toggle for the local expand-all array. */ -function toggled(list: readonly string[], key: string): string[] { - return list.includes(key) ? list.filter(k => k !== key) : [...list, key] -} - /** * Accept the native drag at document level while a row drag is active: row * hover still owns the insertion marker, and releasing outside the list must @@ -258,7 +253,7 @@ type SessionTreeProps = Pick< onSessionRevealed: (sessionId: SessionId) => void } -/** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */ +/** The scrolling session tree; unmounting drops the sessions subscription and local row limits. */ function SessionTree({ list, useSessionStatus, startSession, open, workspaces, ungroupedSessionIds, rowState, @@ -278,7 +273,7 @@ function SessionTree({ const revealGroup = revealSessionId === undefined || !workspaceReady ? undefined : owningGroupKey(workspaces, revealSessionId) - const [expandedSessionGroups, setExpandedSessionGroups] = useState([]) + const [sessionLimits, setSessionLimits] = useState>>({}) // Transient drag marker state; the selected mode owns the resulting order. const [drag, setDrag] = useState(null) const sessionDropCommitted = useRef(false) @@ -333,7 +328,7 @@ function SessionTree({ const group = groups.find(candidate => candidate.key === revealGroup) if (group === undefined || !group.expanded || !group.sessions.some(row => row.id === revealSessionId)) return if (collapsedSessionRows(group.sessions).rows.some(row => row.id === revealSessionId)) return - setExpandedSessionGroups(keys => keys.includes(revealGroup) ? keys : [...keys, revealGroup]) + setSessionLimits(limits => limits[revealGroup] === Infinity ? limits : { ...limits, [revealGroup]: Infinity }) }, [groups, revealGroup, revealSessionId]) const now = Date.now() const commitSessionDrag = (activeDrag: DragState, over: NonNullable): void => { @@ -347,8 +342,7 @@ function SessionTree({ ? ungroupedSessionIds : workspaces.find(workspace => workspace.workspaceId === activeDrag.accountKey)?.sessionIds if (accountSessionIds === undefined) return - const sessionsExpanded = expandedSessionGroups.includes(group.key) - const renderedSessions = sessionsExpanded ? group.sessions : collapsedSessionRows(group.sessions).rows + const renderedSessions = collapsedSessionRows(group.sessions, sessionLimits[group.key]).rows const nextOrder = sessionDragOrder(accountSessionIds, renderedSessions, activeDrag, over) if (nextOrder !== undefined) setSessionOrder(activeDrag.accountKey, nextOrder) } @@ -395,10 +389,11 @@ function SessionTree({ const children = childrenByParent.get(group.key) ?? [] const compatibleDrag = workspaceDrag !== null && parents.get(workspaceDrag.workspaceId) === parents.get(group.key) const collapsed = collapsedSessionRows(group.sessions) - const sessionsExpanded = expandedSessionGroups.includes(group.key) + const visible = collapsedSessionRows(group.sessions, sessionLimits[group.key]) + const sessionsExpanded = visible.hiddenCount === 0 rowKeys.push(`workspace:${group.key}`) const childRows = group.expanded ? children.map(child => renderGroup(child, depth + 1)) : [] - const sessions = sessionsExpanded ? group.sessions : collapsed.rows + const sessions = visible.rows for (const node of sessions) rowKeys.push(`session:${node.id}`) if (collapsed.hiddenCount > 0) rowKeys.push(`overflow:${group.key}`) const workspaceMarker = workspaceId !== undefined && workspaceDrag?.over?.id === workspaceId @@ -477,7 +472,7 @@ function SessionTree({ t={t} onToggle={() => { if (group.expanded) { - setExpandedSessionGroups(keys => keys.filter(key => key !== group.key)) + setSessionLimits(limits => ({ ...limits, [group.key]: COLLAPSED_SESSION_LIMIT })) } setGroupExpanded(group.key, !group.expanded) }} @@ -560,11 +555,20 @@ function SessionTree({ className={css.sessionOverflowButton} data-row-key={`overflow:${group.key}`} aria-expanded={sessionsExpanded} - onClick={() => { setExpandedSessionGroups(keys => toggled(keys, group.key)) }} + onClick={() => { + setSessionLimits(limits => ({ + ...limits, + [group.key]: sessionsExpanded + ? COLLAPSED_SESSION_LIMIT + : visible.hiddenCount <= COLLAPSED_SESSION_LIMIT + ? Infinity + : (limits[group.key] ?? COLLAPSED_SESSION_LIMIT) + COLLAPSED_SESSION_LIMIT, + })) + }} > {sessionsExpanded ? t('sessions.collapse') - : t('sessions.expand', { n: collapsed.hiddenCount })} + : t('sessions.expand', { n: visible.hiddenCount })} )} @@ -580,7 +584,7 @@ function SessionTree({ label={t('section.sessions')} rowKeys={rowKeys} ready={list.phase === 'ready' && workspaceReady && !nativeDragActive} - resetKey={JSON.stringify([animationResetKey, expandedSessionGroups])} + resetKey={JSON.stringify([animationResetKey, sessionLimits])} > {groups.length === 0 && (
{t('empty.none')}
diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 21b6510a07..4d7110b1a6 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -700,6 +700,155 @@ describe('WorkspaceBrowser', () => { expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy() }) + it('keeps running sessions outside every batch quota and preserves their order after collapse', () => { + const idle = Array.from({ length: 12 }, (_, index) => summary(`idle-${index + 1}`, 12 - index)) + const first = summary('running-first', 14, { running: true }) + const last = summary('running-last', 0, { running: true }) + const blank = summary('blank', 15, { blank: true }) + const items = [blank, first, ...idle, last] + mount({ + useSessions: hook(sessionState(items, { main: blank.id })), + useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])), + }) + const expectRows = (count: number) => { + expect(screen.getAllByRole('treeitem').map(row => + within(row).getByText(/^(alpha|新会话|running-(first|last)|idle-\d+)$/u).textContent)).toEqual([ + 'alpha', '新会话', 'running-first', ...idle.slice(0, count).map(item => item.displayTitle), 'running-last', + ]) + } + expectRows(5) + fireEvent.click(screen.getByRole('button', { name: '展开其余 7 个会话' })) + expectRows(10) + fireEvent.click(screen.getByRole('button', { name: '展开其余 2 个会话' })) + expectRows(12) + fireEvent.click(screen.getByRole('button', { name: '收起' })) + expectRows(5) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByText('alpha')) + expectRows(5) + }) + + it('reveals a hidden session when live running status starts and folds it when the run ends', () => { + const items = Array.from({ length: 12 }, (_, index) => summary(`session-${index + 1}`, 12 - index)) + const b = mount({ + useSessions: hook(sessionState(items)), + useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])), + }) + fireEvent.click(screen.getByText('alpha')) + expect(screen.queryByText('session-12')).toBeNull() + rerender(b, { + useSessionStatus: hook(new Map([[sid('session-12'), { + running: true, pendingInteraction: undefined, completionUnread: false, + }]])), + }) + expect(screen.getByText('session-12')).toBeTruthy() + expect(screen.queryByText('session-6')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 6 个会话' })).toBeTruthy() + rerender(b, { + useSessionStatus: hook(new Map([[sid('session-12'), { + running: false, pendingInteraction: undefined, completionUnread: true, + }]])), + }) + expect(screen.queryByText('session-12')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 7 个会话' })).toBeTruthy() + }) + + it('keeps a parent with a running child visible outside the idle-session quota', () => { + const items = Array.from({ length: 7 }, (_, index) => summary(`session-${index + 1}`, 7 - index)) + const child = summary('child', 0, { origin: 'subagent', running: true }) + mount({ + useSessions: hook(sessionState([...items, child], { + projectionsBySession: { + [sid('session-7')]: { + state: 'ready', error: null, + values: { subagentCatalog: [{ id: child.id, mode: 'continuable', label: 'child', createdAt: 1 }] }, + }, + }, + })), + useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])), + }) + fireEvent.click(screen.getByText('alpha')) + expect(screen.getByText('session-7')).toBeTruthy() + expect(screen.queryByText('session-6')).toBeNull() + expect(screen.queryByText('child')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 1 个会话' })).toBeTruthy() + }) + + it.each([false, true])('expands 17 ordinary sessions five at a time and resets after the final partial batch (blank: %s)', (withBlank) => { + const ordinary = Array.from({ length: 17 }, (_, index) => summary(`session-${index + 1}`, 17 - index)) + const blank = summary('blank', 18, { blank: true }) + const items = withBlank ? [blank, ...ordinary] : ordinary + mount({ + useSessions: hook(sessionState(items, withBlank ? { main: blank.id } : {})), + useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])), + }) + if (!withBlank) fireEvent.click(screen.getByText('alpha')) + const expectVisible = (count: number) => { + for (const [index, item] of ordinary.entries()) { + expect(screen.queryByText(item.displayTitle) !== null).toBe(index < count) + } + expect(screen.getAllByRole('treeitem')).toHaveLength(count + 1 + Number(withBlank)) + expect(screen.queryByText('新会话') !== null).toBe(withBlank) + } + expectVisible(5) + const labels: string[] = [] + for (const [remaining, visible] of [[12, 10], [7, 15], [2, 17]] as const) { + const overflow = screen.getByRole('button', { name: `展开其余 ${remaining} 个会话` }) + labels.push(overflow.textContent ?? '') + expect(screen.queryByRole('button', { name: '收起' })).toBeNull() + fireEvent.click(overflow) + expectVisible(visible) + } + expect(screen.queryByRole('button', { name: /展开其余/ })).toBeNull() + const collapse = screen.getByRole('button', { name: '收起' }) + labels.push(collapse.textContent ?? '') + expect(labels).toMatchInlineSnapshot(` + [ + "展开其余 12 个会话", + "展开其余 7 个会话", + "展开其余 2 个会话", + "收起", + ] + `) + fireEvent.click(collapse) + expectVisible(5) + fireEvent.click(screen.getByRole('button', { name: '展开其余 12 个会话' })) + expectVisible(10) + expect(screen.getByRole('button', { name: '展开其余 7 个会话' })).toBeTruthy() + }) + + it('keeps each Workspace batch limit independent when another Workspace expands or collapses', () => { + const alpha = Array.from({ length: 17 }, (_, index) => summary(`alpha-${index + 1}`, 17 - index)) + const beta = Array.from({ length: 11 }, (_, index) => summary(`beta-${index + 1}`, 11 - index)) + mount({ + useSessions: hook(sessionState([...alpha, ...beta])), + useWorkspaces: hook(workspaceState([ + workspace('alpha', alpha.map(item => item.id)), + workspace('beta', beta.map(item => item.id)), + ])), + }) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByText('beta')) + fireEvent.click(screen.getByRole('button', { name: '展开其余 12 个会话' })) + expect(screen.getByText('alpha-10')).toBeTruthy() + expect(screen.queryByText('alpha-11')).toBeNull() + expect(screen.queryByText('beta-6')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '展开其余 6 个会话' })) + expect(screen.getByText('beta-10')).toBeTruthy() + expect(screen.queryByText('beta-11')).toBeNull() + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByText('alpha')) + expect(screen.queryByText('alpha-6')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 12 个会话' })).toBeTruthy() + expect(screen.getByText('beta-10')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '展开其余 1 个会话' })) + expect(screen.getByText('beta-11')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '收起' })) + expect(screen.queryByText('beta-6')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 6 个会话' })).toBeTruthy() + expect(screen.getByRole('button', { name: '展开其余 12 个会话' })).toBeTruthy() + }) + it('keeps the blank New Session outside the five-row folding quota', () => { const ordinary = Array.from({ length: 6 }, (_, index) => summary(`session-${index + 1}`, 6 - index)) const blank = summary('blank', 7, { blank: true }) @@ -756,6 +905,36 @@ describe('WorkspaceBrowser', () => { expect(screen.queryByText('session-6')).toBeNull() }) + it('reorders a newly exposed batch row while preserving the blank and hidden tail', () => { + const ordinary = Array.from({ length: 12 }, (_, index) => summary(`session-${index + 1}`, 12 - index)) + const blank = summary('blank', 13, { blank: true }) + const b = mount({ + useSessions: hook(sessionState([blank, ...ordinary], { main: blank.id })), + useWorkspaces: hook(workspaceState([workspace('alpha', [blank.id, ...ordinary.map(item => item.id)])])), + }) + fireEvent.click(screen.getByRole('button', { name: '展开其余 7 个会话' })) + const source = screen.getByText('session-10').closest('[role="treeitem"]') as HTMLElement + const target = screen.getByText('session-6').closest('[role="treeitem"]') as HTMLElement + target.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, + x: 0, y: 100, toJSON: () => ({}), + }) + fireEvent.dragStart(source, { dataTransfer: dragData() }) + fireDrag(target, 'drop', 105) + + expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual([ + 'blank', 'session-1', 'session-2', 'session-3', 'session-4', 'session-5', + 'session-10', 'session-6', 'session-7', 'session-8', 'session-9', 'session-11', 'session-12', + ]) + expect(screen.getAllByRole('treeitem').slice(1).map(row => row.querySelector('[class*="title"]')?.textContent)).toEqual([ + '新会话', 'session-1', 'session-2', 'session-3', 'session-4', 'session-5', + 'session-10', 'session-6', 'session-7', 'session-8', 'session-9', + ]) + expect(screen.queryByText('session-11')).toBeNull() + expect(screen.queryByText('session-12')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy() + }) + it('discards manual positions on recency selection and switches to Manual on drag', async () => { const initial = sessionState([summary('one', 3), summary('two', 2)]) const b = mount({ @@ -1299,6 +1478,30 @@ describe('WorkspaceBrowser', () => { expect(b.store.getSnapshot().groupExpansion).not.toHaveProperty('stale') }) + it('reveals every session when a search result is beyond the initial five-row quota', () => { + const items = Array.from({ length: 17 }, (_, index) => summary(`session-${index + 1}`, 17 - index)) + const b = mount({ + useSessions: hook(sessionState(items)), + useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])), + }) + const input = screen.getByPlaceholderText('搜索会话名称') + fireEvent.change(input, { target: { value: 'session-11' } }) + fireEvent.click(screen.getByRole('treeitem')) + + expect(b.props.open).toHaveBeenCalledWith(sid('session-11')) + expect(input.value).toBe('') + for (const item of items) expect(screen.getByText(item.displayTitle)).toBeTruthy() + expect(screen.queryByRole('button', { name: /展开其余/ })).toBeNull() + expect(scrollIntoView.mock.instances.at(-1)).toBe(screen.getByText('session-11').closest('[role="treeitem"]')) + fireEvent.click(screen.getByRole('button', { name: '收起' })) + expect(screen.queryByText('session-6')).toBeNull() + expect(screen.queryByText('session-11')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '展开其余 12 个会话' })) + expect(screen.getByText('session-10')).toBeTruthy() + expect(screen.queryByText('session-11')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 7 个会话' })).toBeTruthy() + }) + it('keeps the bounded group projection when the revealed result is already within it', () => { const sessions = sessionState([ summary('target', 6, { displayTitle: 'Needle session' }),