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)])),