fix(web): batch session expansion and keep running sessions visible (#4715)

* fix(web): expand workspace sessions in bounded batches

* fix(web): keep running sessions outside overflow quota

---------

Co-authored-by: Turtle <[email protected]>
This commit is contained in:
Turtle 2099andTurtle authored and GitHub committed 2026-09-20 10:38:56 +00:00
1 parent 068cf03441
commit e8f3d02da8
8 files changed
+267 -33

No files matched your search

@@ -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"
@@ -9,4 +9,4 @@
- treeitem "{{workspace}} 1min"
- treeitem "{{workspace}} 1min"
- treeitem "{{workspace}} 1min"
- button "Show 1 more sessions"
- button "Show 11 more sessions"
@@ -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([])
})
@@ -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
+1 -1
View File
@@ -25,7 +25,7 @@ This package lets users browse grouped or flat Session lists, choose a Workspace
<a id="use-this-package"></a>
## 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
+1 -1
View File
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
用侧边栏浏览 Workspace 及其 Session、重排它们并新建会话;在 Session Intent 主视觉区用选择器为新会话选择 Workspace。打开的 Workspace 默认显示五条非空白 Session,并在首条提示词落地前把当前选中的空白**新会话**作为一条临时额外行。**展开其余**会显示隐藏条目;关闭再打开 Workspace 会恢复该折叠投影。
用侧边栏浏览 Workspace 及其 Session、重排它们并新建会话;在 Session Intent 主视觉区用选择器为新会话选择 Workspace。打开的 Workspace 默认显示五条空闲的非空白 Session。正在运行的 Session(包括有子会话正在运行的父会话)始终按原顺序显示,不占用这五条配额;当前选中的空白**新会话**在首条提示词落地前也作为额外行。每次点击**展开其余**最多再显示五条空闲 Session;全部显示后,**收起**恢复初始行数,但仍显示正在运行的 Session。关闭再打开 Workspace 也会恢复该折叠投影。
### 重排序与视图选项
@@ -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<string[]>([])
const [sessionLimits, setSessionLimits] = useState<Readonly<Record<string, number>>>({})
// Transient drag marker state; the selected mode owns the resulting order.
const [drag, setDrag] = useState<DragState | null>(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<DragState['over']>): 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 })}
</button>
)}
</div>
@@ -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 && (
<div className={css.empty} data-row-key="empty">{t('empty.none')}</div>
@@ -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<SessionStatusSnapshot>(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<SessionStatusSnapshot>(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<HTMLInputElement>('搜索会话名称')
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' }),