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:
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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
Reference in new issue
Block a user