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' }),