diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index ed87dba3eb..687f287046 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 116cebbbd7..d97b93b13a 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -27,7 +27,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 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. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 7be85089f9..d8d8717933 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" ## 使用本包 -用侧边栏浏览 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」,不使用目录名兜底。当前空会话仍显示「新会话」,其他空会话仍隐藏。重命名草稿使用已保存标题,无标题时留空;无标题行不提供标题复制。 diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 72d1fb79db..3434d8af51 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -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>, + required: ReadonlyMap, +): Readonly> { + 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>>({}) + // Per-group raised watermarks; an absent key means the base watermark. + const [storedWatermarks, setStoredWatermarks] = useState>>({}) + // 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(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): 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 && ( 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 40b9a97a6d..362cd1604d 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -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) => + hook(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(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() + 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(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(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(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)])),