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