feat(ui-workspace): fold sidebar sessions with a monotonic watermark

Each Workspace shows a contiguous prefix of its Sessions up to a watermark.
The watermark rises to cover running, waiting, unread-completion, and
current Sessions within the first 50 rows, and to a search reveal target at
any row. It never falls on its own; Show more raises it past 50, and Show
less or folding (including an ancestor) resets it.
This commit is contained in:
turtle1999 committed 2026-10-12 20:36:18 +08:00
1 parent 3a7e896206
commit 7df3c41ec9
5 files changed
+239 -95

No files matched your search

@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-workspace/use-this-package:
en: b164014ee565be16
zh: 6a741647932b93e6
en: d51507f87484f818
zh: e2600daa21e35f36
/deepseek-ai-dsh-client-ui-workspace/use-this-package/reordering-and-view-options:
en: 8673afc1d72ad077
zh: 9fbb83a4029c5d10
+1 -1
View File
@@ -27,7 +27,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 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.
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 its first non-blank Sessions in list order up to a per-Workspace watermark, initially five. The watermark rises to cover the last Session within the first 50 that is running, has a running child, awaits the user, has an unread completion, or is open in the main view; a Session opened from search raises it to that Session at any position. The watermark never falls on its own, so a Session stays visible after its run or notification ends. Each **Show more** click raises it by five, without the 50-row limit, and reveals the rest when five or fewer remain; after the final batch, **Show less** resets it. Folding a Workspace resets its watermark and those of its nested Workspaces; a reset watermark still covers the qualifying Sessions. The selected blank **New Session** is an extra row outside the watermark until its first prompt. Watermarks are not persisted; they reset when the grouped tree unmounts, including reloads, flat grouping, and search results.
History rows without a stored title use the localized unnamed label (未命名 / Untitled), rather than a directory name. The current blank row remains New Session; other blank rows remain hidden. Rename drafts use the stored title, or start empty when unnamed; unnamed rows do not offer title copying.
+1 -1
View File
@@ -27,7 +27,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
用侧边栏浏览 Workspace 及其 Session、重排它们并新建会话;在 Session Intent 主视觉区用选择器为新会话选择 Workspace。打开的 Workspace 默认显示五条空闲的非空白 Session。正在运行的 Session(包括有子会话正在运行的父会话)始终按原顺序显示,不占用这五条配额;当前选中的空白**新会话**在首条提示词落地前也作为额外行。每次点击**展开其余**最多再显示五条空闲 Session;全部显示后,**收起**恢复初始行数,但仍显示正在运行的 Session。关闭再打开 Workspace 也会恢复该折叠投影。
用侧边栏浏览 Workspace 及其 Session、重排它们并新建会话;在 Session Intent 主视觉区用选择器为新会话选择 Workspace。打开的 Workspace 按列表顺序显示前面的非空白 Session,直到该 Workspace 自己的水位线,水位线初始为五。水位线会升高,以覆盖前 50 条中最后一条正在运行、有子会话正在运行、等待用户、有未读完成提醒或在主视图中打开的 Session;从搜索打开的 Session 会把水位线升高到该 Session,无论其位置。水位线不会自行回落,因此 Session 在运行或通知结束后仍保持可见。每次点击**展开其余**将水位线升高五条,不受 50 行限制;剩余五条或更少时显示全部;最后一批显示后,**收起**重置水位线。折叠 Workspace 会重置其水位线及其嵌套 Workspace 的水位线;重置后的水位线仍覆盖符合条件的 Session。当前选中的空白**新会话**在首条提示词落地前是水位线之外的额外行。水位线不持久化;分组树卸载时会重置,包括重新加载、切换到平铺分组和显示搜索结果时。
未保存标题的历史会话显示本地化名称「未命名 / Untitled」,不使用目录名兜底。当前空会话仍显示「新会话」,其他空会话仍隐藏。重命名草稿使用已保存标题,无标题时留空;无标题行不提供标题复制。
@@ -51,24 +51,60 @@ 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
/** Idle Session rows visible per Workspace before the local overflow control. */
const COLLAPSED_SESSION_LIMIT = 5
/** Non-blank Session rows visible per Workspace while no Session raises its watermark. */
const SESSION_WATERMARK_BASE = 5
/** Highest watermark that attention-bearing Sessions raise automatically; Show more and search reveals may exceed it. */
const SESSION_WATERMARK_AUTO_MAX = 50
/** Keep provisional and running rows outside the idle-session quota, including parents with running children. */
function collapsedSessionRows(sessions: readonly SessionNode[], limit = COLLAPSED_SESSION_LIMIT): {
/** Show the blank row plus the first `watermark` non-blank rows, in list order. */
function watermarkRows(sessions: readonly SessionNode[], watermark: number): {
rows: readonly SessionNode[]
hiddenCount: number
} {
let idleCount = 0
let count = 0
const rows = sessions.filter((session) => {
if (session.blank || session.running || session.runningSubagentCount > 0) return true
if (idleCount >= limit) return false
idleCount += 1
return true
if (session.blank) return true
count += 1
return count <= watermark
})
return { rows, hiddenCount: sessions.length - rows.length }
}
/**
* Smallest watermark that covers every non-blank row within the automatic maximum that is running, has a running
* child, awaits the user, carries an unread completion, or is current, and the reveal target at any position.
*/
function requiredWatermark(
sessions: readonly SessionNode[],
current: SessionId | undefined,
revealSessionId: SessionId | undefined,
): number {
let index = 0
let required = 0
for (const session of sessions) {
if (session.blank) continue
index += 1
const attention = index <= SESSION_WATERMARK_AUTO_MAX && (
session.running || session.runningSubagentCount > 0 || session.pendingInteraction !== undefined
|| session.completed || session.id === current
)
if (attention || session.id === revealSessionId) required = index
}
return required
}
/** Raise each listed group's watermark to its requirement; returns `marks` itself when nothing rises. */
function raiseWatermarks(
marks: Readonly<Record<string, number>>,
required: ReadonlyMap<string, number>,
): Readonly<Record<string, number>> {
let raised = marks
for (const [key, watermark] of required) {
if (watermark > (raised[key] ?? SESSION_WATERMARK_BASE)) raised = { ...raised, [key]: watermark }
}
return raised
}
/** Keep controlled input and RPC payload inside the session.search wire contract. */
function sanitizeSearchQuery(value: string): string {
const withoutNul = value.replaceAll('\0', '')
@@ -277,7 +313,7 @@ function EmptySessions({ rowState, onLeaveArchivedOnly, t }: Pick<SessionTreePro
)
}
/** The scrolling session tree; unmounting drops the sessions subscription and local row limits. */
/** The scrolling session tree; unmounting drops the sessions subscription and the session watermarks. */
function SessionTree({
list, useSessionStatus, startSession, open, workspaces, ungroupedSessionIds,
rowState, onLeaveArchivedOnly,
@@ -297,7 +333,10 @@ function SessionTree({
const revealGroup = revealSessionId === undefined || !workspaceReady
? undefined
: owningGroupKey(workspaces, revealSessionId)
const [sessionLimits, setSessionLimits] = useState<Readonly<Record<string, number>>>({})
// Per-group raised watermarks; an absent key means the base watermark.
const [storedWatermarks, setStoredWatermarks] = useState<Readonly<Record<string, number>>>({})
// Advances on user-driven watermark changes so AnimatedRows settles them; automatic raises animate.
const [watermarkMotionKey, setWatermarkMotionKey] = useState(0)
// Transient drag marker state; the selected mode owns the resulting order.
const [drag, setDrag] = useState<DragState | null>(null)
const sessionDropCommitted = useRef(false)
@@ -347,13 +386,35 @@ function SessionTree({
}
}
}, [groupExpansion, parents, revealGroup, setGroupExpanded])
useEffect(() => {
if (revealSessionId === undefined || revealGroup === undefined) return
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
setSessionLimits(limits => limits[revealGroup] === Infinity ? limits : { ...limits, [revealGroup]: Infinity })
}, [groups, revealGroup, revealSessionId])
const baselineWatermark = (group: GroupNode): number => Math.max(SESSION_WATERMARK_BASE, requiredWatermark(
group.sessions,
current,
group.key === revealGroup ? revealSessionId : undefined,
))
const foldedKeys = new Set(groups.filter(group => !group.expanded).map(group => group.key))
const underFoldedAncestor = (key: string): boolean => {
for (let owner = parents.get(key); owner !== undefined; owner = parents.get(owner)) {
if (foldedKeys.has(owner)) return true
}
return false
}
// A group hidden by a folded ancestor keeps its own expansion and rows but must not raise its watermark.
const requiredWatermarks = new Map(groups
.filter(group => !underFoldedAncestor(group.key))
.map(group => [group.key, baselineWatermark(group)]))
const sessionWatermarks = raiseWatermarks(storedWatermarks, requiredWatermarks)
// Raising during render keeps the watermark monotonic without committing a lower frame first.
if (sessionWatermarks !== storedWatermarks) setStoredWatermarks(marks => raiseWatermarks(marks, requiredWatermarks))
const dropWatermarks = (drop: (key: string) => boolean): void => {
setStoredWatermarks(marks => Object.fromEntries(Object.entries(marks).filter(([key]) => !drop(key))))
setWatermarkMotionKey(key => key + 1)
}
const withinGroup = (key: string, root: string): boolean => {
for (let owner: string | undefined = key; owner !== undefined; owner = parents.get(owner)) {
if (owner === root) return true
}
return false
}
const now = Date.now()
const commitSessionDrag = (activeDrag: DragState, over: NonNullable<DragState['over']>): void => {
if (sessionDropCommitted.current) return
@@ -366,7 +427,7 @@ function SessionTree({
? ungroupedSessionIds
: workspaces.find(workspace => workspace.workspaceId === activeDrag.accountKey)?.sessionIds
if (accountSessionIds === undefined) return
const renderedSessions = collapsedSessionRows(group.sessions, sessionLimits[group.key]).rows
const renderedSessions = watermarkRows(group.sessions, sessionWatermarks[group.key] ?? SESSION_WATERMARK_BASE).rows
const nextOrder = sessionDragOrder(accountSessionIds, renderedSessions, activeDrag, over)
if (nextOrder !== undefined) setSessionOrder(activeDrag.accountKey, nextOrder)
}
@@ -416,8 +477,9 @@ function SessionTree({
const workspaceId = group.workspaceId
const children = childrenByParent.get(group.key) ?? []
const compatibleDrag = workspaceDrag !== null && parents.get(workspaceDrag.workspaceId) === parents.get(group.key)
const collapsed = collapsedSessionRows(group.sessions)
const visible = collapsedSessionRows(group.sessions, sessionLimits[group.key])
const watermark = sessionWatermarks[group.key] ?? SESSION_WATERMARK_BASE
const collapsed = watermarkRows(group.sessions, baselineWatermark(group))
const visible = watermarkRows(group.sessions, watermark)
const sessionsExpanded = visible.hiddenCount === 0
rowKeys.push(`workspace:${group.key}`)
const childRows = group.expanded ? children.map(child => renderGroup(child, depth + 1)) : []
@@ -500,9 +562,7 @@ function SessionTree({
home={home}
t={t}
onToggle={() => {
if (group.expanded) {
setSessionLimits(limits => ({ ...limits, [group.key]: COLLAPSED_SESSION_LIMIT }))
}
if (group.expanded) dropWatermarks(key => withinGroup(key, group.key))
setGroupExpanded(group.key, !group.expanded)
}}
onCreate={() => {
@@ -585,14 +645,15 @@ function SessionTree({
data-row-key={`overflow:${group.key}`}
aria-expanded={sessionsExpanded}
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,
if (sessionsExpanded) {
dropWatermarks(key => key === group.key)
return
}
setStoredWatermarks(marks => ({
...marks,
[group.key]: visible.hiddenCount <= SESSION_WATERMARK_BASE ? Infinity : watermark + SESSION_WATERMARK_BASE,
}))
setWatermarkMotionKey(key => key + 1)
}}
>
{sessionsExpanded
@@ -613,7 +674,7 @@ function SessionTree({
label={t('section.sessions')}
rowKeys={rowKeys}
ready={list.phase === 'ready' && workspaceReady && !nativeDragActive}
resetKey={JSON.stringify([animationResetKey, sessionLimits])}
resetKey={JSON.stringify([animationResetKey, watermarkMotionKey])}
>
{groups.length === 0 && (
<EmptySessions rowState={rowState} onLeaveArchivedOnly={onLeaveArchivedOnly} t={t} />
@@ -7,7 +7,9 @@ import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-sess
import type {
WorkspaceId, WorkspaceSnapshot, WorkspaceView,
} from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type {
SessionPendingInteraction, SessionStatus, SessionStatusSnapshot,
} from '@deepseek-ai/dsh-client-ui-session/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
@@ -770,63 +772,80 @@ 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', () => {
it('raises the watermark to the last attention row and keeps it after the attention 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))])),
})
const status = (id: string, value: Partial<SessionStatus>) =>
hook<SessionStatusSnapshot>(new Map([[sid(id), {
running: false, pendingInteraction: undefined, completionUnread: false, ...value,
}]]))
const expectVisible = (count: number) => {
expect(screen.getAllByRole('treeitem').map(row => within(row).getByText(/^(alpha|session-\d+)$/u).textContent))
.toEqual(['alpha', ...items.slice(0, count).map(item => item.displayTitle)])
}
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()
expectVisible(5)
rerender(b, { useSessionStatus: status('session-9', { running: true }) })
expectVisible(9)
expect(screen.getByRole('button', { name: '展开其余 3 个会话' })).toBeTruthy()
rerender(b, { useSessionStatus: status('session-9', {}) })
expectVisible(9)
fireEvent.click(screen.getByText('alpha'))
fireEvent.click(screen.getByText('alpha'))
expectVisible(5)
rerender(b, { useSessionStatus: status('session-7', {
pendingInteraction: { key: 'question:1', kind: 'plan-review', sessionId: sid('session-7') } as SessionPendingInteraction,
}) })
expectVisible(7)
rerender(b, { useSessionStatus: status('session-8', { completionUnread: true }) })
expectVisible(8)
fireEvent.click(screen.getByRole('button', { name: '展开其余 4 个会话' }))
expectVisible(12)
fireEvent.click(screen.getByRole('button', { name: '收起' }))
expectVisible(8)
})
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))
it('stops automatic raises at 50 rows while Show more continues past them', () => {
const items = Array.from({ length: 60 }, (_, index) => summary(`session-${index + 1}`, 60 - index))
const b = mount({
useSessions: hook(sessionState(items)),
useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])),
})
fireEvent.click(screen.getByText('alpha'))
const running = (...ids: string[]) => hook<SessionStatusSnapshot>(new Map(ids.map(id => [sid(id), {
running: true, pendingInteraction: undefined, completionUnread: false,
}])))
rerender(b, {
useSessions: hook(sessionState(items, { main: sid('session-53') })),
useSessionStatus: hook<SessionStatusSnapshot>(new Map([
[sid('session-55'), { running: true, pendingInteraction: undefined, completionUnread: false }],
[sid('session-56'), { running: false, pendingInteraction: undefined, completionUnread: true }],
[sid('session-57'), {
running: false, completionUnread: false,
pendingInteraction: { key: 'question:1', kind: 'question', sessionId: sid('session-57') } as SessionPendingInteraction,
}],
])),
})
expect(screen.queryByText('session-6')).toBeNull()
for (const id of ['session-53', 'session-55', 'session-56', 'session-57']) expect(screen.queryByText(id)).toBeNull()
rerender(b, { useSessionStatus: running('session-50', 'session-55') })
expect(screen.getByText('session-50')).toBeTruthy()
expect(screen.queryByText('session-51')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '展开其余 10 个会话' }))
expect(screen.getByText('session-55')).toBeTruthy()
expect(screen.queryByText('session-56')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '展开其余 5 个会话' }))
expect(screen.getByText('session-60')).toBeTruthy()
})
it('covers the current session and a parent with a running child', () => {
const items = Array.from({ length: 9 }, (_, index) => summary(`session-${index + 1}`, 9 - index))
const child = summary('child', 0, { origin: 'subagent', running: true })
mount({
const b = mount({
useSessions: hook(sessionState([...items, child], {
projectionsBySession: {
[sid('session-7')]: {
@@ -839,8 +858,11 @@ describe('WorkspaceBrowser', () => {
})
fireEvent.click(screen.getByText('alpha'))
expect(screen.getByText('session-7')).toBeTruthy()
expect(screen.queryByText('session-6')).toBeNull()
expect(screen.queryByText('session-8')).toBeNull()
expect(screen.queryByText('child')).toBeNull()
rerender(b, { useSessions: hook(sessionState(items, { main: sid('session-8') })) })
expect(screen.getByText('session-8')).toBeTruthy()
expect(screen.queryByText('session-9')).toBeNull()
expect(screen.getByRole('button', { name: '展开其余 1 个会话' })).toBeTruthy()
})
@@ -1548,7 +1570,7 @@ 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', () => {
it('raises the watermark to a search result beyond the initial five rows', () => {
const items = Array.from({ length: 17 }, (_, index) => summary(`session-${index + 1}`, 17 - index))
const b = mount({
useSessions: hook(sessionState(items)),
@@ -1560,16 +1582,28 @@ describe('WorkspaceBrowser', () => {
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(screen.getByText('session-11')).toBeTruthy()
expect(screen.queryByText('session-12')).toBeNull()
expect(scrollIntoView.mock.instances.at(-1)).toBe(screen.getByText('session-11').closest('[role="treeitem"]'))
fireEvent.click(screen.getByRole('button', { name: '收起' }))
fireEvent.click(screen.getByRole('button', { name: '展开其余 6 个会话' }))
expect(screen.getByText('session-16')).toBeTruthy()
fireEvent.click(screen.getByText('alpha'))
fireEvent.click(screen.getByText('alpha'))
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()
expect(screen.getByRole('button', { name: '展开其余 12 个会话' })).toBeTruthy()
})
it('reveals a search result beyond the automatic 50-row limit', () => {
const items = Array.from({ length: 60 }, (_, index) => summary(`session-${index + 1}`, 60 - index))
mount({
useSessions: hook(sessionState(items)),
useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])),
})
fireEvent.change(screen.getByPlaceholderText('搜索会话名称'), { target: { value: 'session-58' } })
fireEvent.click(screen.getByRole('treeitem'))
expect(screen.getByText('session-58')).toBeTruthy()
expect(screen.queryByText('session-59')).toBeNull()
expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy()
})
it('keeps the bounded group projection when the revealed result is already within it', () => {
@@ -2418,6 +2452,55 @@ describe('Workspace tree grouping', () => {
expect(within(section('Projects')).getByText('Child')).toBeTruthy()
})
it('folding a parent Workspace resets descendant watermarks', () => {
const rootItems = Array.from({ length: 7 }, (_, index) => summary(`root-${index + 1}`, 30 - index))
const childItems = Array.from({ length: 7 }, (_, index) => summary(`child-${index + 1}`, 20 - index))
mount({
useSessions: hook(sessionState([...rootItems, ...childItems])),
useWorkspaces: hook(workspaceState([
{ ...root, sessionIds: rootItems.map(item => item.id) },
{ ...child, sessionIds: childItems.map(item => item.id) },
])),
})
fireEvent.click(screen.getByText('Child'))
fireEvent.click(within(section('Child')).getByRole('button', { name: '展开其余 2 个会话' }))
fireEvent.click(within(section('Projects')).getAllByRole('button', { name: '展开其余 2 个会话' })[0]!)
expect(screen.getByText('child-7')).toBeTruthy()
expect(screen.getByText('root-7')).toBeTruthy()
fireEvent.click(screen.getByText('Child'))
fireEvent.click(screen.getByText('Child'))
expect(screen.queryByText('child-6')).toBeNull()
expect(screen.getByText('root-7')).toBeTruthy()
fireEvent.click(within(section('Child')).getByRole('button', { name: '展开其余 2 个会话' }))
fireEvent.click(screen.getByText('Projects'))
fireEvent.click(screen.getByText('Projects'))
expect(screen.queryByText('root-6')).toBeNull()
expect(screen.queryByText('child-6')).toBeNull()
})
it('does not raise a nested watermark while a folded ancestor hides it', () => {
const rootItems = [summary('root-1', 30)]
const childItems = Array.from({ length: 8 }, (_, index) => summary(`child-${index + 1}`, 20 - index))
const b = mount({
useSessions: hook(sessionState([...rootItems, ...childItems])),
useWorkspaces: hook(workspaceState([
{ ...root, sessionIds: rootItems.map(item => item.id) },
{ ...child, sessionIds: childItems.map(item => item.id) },
])),
})
fireEvent.click(screen.getByText('Child'))
fireEvent.click(screen.getByText('Projects'))
const status = (running: boolean) => hook<SessionStatusSnapshot>(new Map([[sid('child-8'), {
running, pendingInteraction: undefined, completionUnread: false,
}]]))
rerender(b, { useSessionStatus: status(true) })
rerender(b, { useSessionStatus: status(false) })
fireEvent.click(screen.getByText('Projects'))
expect(screen.getByText('child-5')).toBeTruthy()
expect(screen.queryByText('child-6')).toBeNull()
expect(screen.queryByText('child-8')).toBeNull()
})
it('nests newly registered ancestors and children while retaining each Workspace session', () => {
const b = mount({
useSessions: hook(sessionState([summary('root-session', 1), summary('team-session', 2), summary('child-session', 3)])),