fix(ui-workspace): address watermark review

- Compute each group's baseline watermark once per render.
- Skip the row-motion reset when folding drops no raised watermark.
- Document that the watermark is a row count that a reorder can outrun, and
  that any unmount of the grouped tree resets it; pin both in tests.
- Cover raise-to-current, keep-after-leaving, and fold reset in the keyless
  Web folding scenario.
This commit is contained in:
turtle1999 committed 2026-10-12 22:28:05 +08:00
1 parent 7df3c41ec9
commit 5a7cb94149
7 files changed
+85 -12

No files matched your search

@@ -0,0 +1,16 @@
- tree "Sessions":
- treeitem "{{workspace}} Workspace actions for {{workspace}} New session in {{workspace}}" [expanded]:
- text: {{workspace}}
- button "Workspace actions for {{workspace}}"
- button "New session in {{workspace}}"
- treeitem "New Session" [selected]
- treeitem "Untitled 1min"
- treeitem "Untitled 1min"
- treeitem "Untitled 1min"
- treeitem "Untitled 1min"
- treeitem "Untitled 1min"
- treeitem "Untitled 1min"
- treeitem "Untitled 1min"
- treeitem "Untitled 1min"
- treeitem "Untitled 1min"
- button "Show 7 more sessions"
@@ -1,4 +1,4 @@
/** Blank New Session folding through the shipped Web composition. */
/** Blank New Session folding and the session watermark through the shipped Web composition. */
import { readFile } from 'node:fs/promises'
import { basename, join } from 'node:path'
@@ -107,7 +107,42 @@ describe('web e2e: blank New Session folding quota', () => {
await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(18)
await sidebar.getByRole('button', { name: 'Show less' }).click()
await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(7)
await assertFixtureInventory(EXPECTED_DIR, ['sidebar.expected.md', 'first-batch.expected.md'])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it('raises the watermark to the open session, keeps it after leaving, and resets it on fold', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-workspace-session-watermark'))
const sidebar = page.getByRole('tree', { name: 'Sessions' })
const workspaceTitle = basename(scaffold.workspaceCwd)
const sessions = () => sidebar.getByText('Untitled', { exact: true }).count()
await sidebar.getByRole('button', { name: 'Show 11 more sessions' }).click()
await expect.poll(sessions).toBe(10)
await sidebar.getByText('Untitled', { exact: true }).nth(8).click()
await sidebar.getByRole('button', { name: 'Show 6 more sessions' }).click()
await sidebar.getByRole('button', { name: 'Show 1 more sessions' }).click()
await expect.poll(sessions).toBe(16)
await sidebar.getByRole('button', { name: 'Show less' }).click()
await expect.poll(sessions).toBe(9)
await sidebar.getByRole('button', { name: 'Show 7 more sessions' }).waitFor()
const workspaceRow = sidebar.getByRole('treeitem').first()
await workspaceRow.hover()
await page.getByRole('button', { name: `New session in ${workspaceTitle}` }).click()
await sidebar.getByText('New Session', { exact: true }).waitFor()
expect(await sessions()).toBe(9)
await workspaceRow.hover()
await compareOrRefreshGolden(
join(EXPECTED_DIR, 'watermark-kept.expected.md'),
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),
MODE,
)
await workspaceRow.click()
await workspaceRow.click()
await expect.poll(sessions).toBe(5)
await sidebar.getByRole('button', { name: 'Show 11 more sessions' }).waitFor()
await assertFixtureInventory(EXPECTED_DIR, ['sidebar.expected.md', 'first-batch.expected.md', 'watermark-kept.expected.md'])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-workspace/use-this-package:
en: d51507f87484f818
zh: e2600daa21e35f36
en: d042b4b1dd7e7f56
zh: c12fa070ef5a02a4
/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 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.
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 is a row count that never falls on its own, so the rows stay visible after a run or notification ends; a reorder that moves a Session past the watermark hides it. 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; any unmount of the grouped tree resets them.
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,直到该 Workspace 自己的水位线,水位线初始为五。水位线会升高,以覆盖前 50 条中最后一条正在运行、有子会话正在运行、等待用户、有未读完成提醒或在主视图中打开的 Session;从搜索打开的 Session 会把水位线升高到该 Session,无论其位置。水位线不会自行回落,因此 Session 在运行或通知结束后仍保持可见。每次点击**展开其余**将水位线升高五条,不受 50 行限制;剩余五条或更少时显示全部;最后一批显示后,**收起**重置水位线。折叠 Workspace 会重置其水位线及其嵌套 Workspace 的水位线;重置后的水位线仍覆盖符合条件的 Session。当前选中的空白**新会话**在首条提示词落地前是水位线之外的额外行。水位线不持久化;分组树卸载时会重置,包括重新加载、切换到平铺分组和显示搜索结果时。
用侧边栏浏览 Workspace 及其 Session、重排它们并新建会话;在 Session Intent 主视觉区用选择器为新会话选择 Workspace。打开的 Workspace 按列表顺序显示前面的非空白 Session,直到该 Workspace 自己的水位线,水位线初始为五。水位线会升高,以覆盖前 50 条中最后一条正在运行、有子会话正在运行、等待用户、有未读完成提醒或在主视图中打开的 Session;从搜索打开的 Session 会把水位线升高到该 Session,无论其位置。水位线是一个行数,不会自行回落,因此运行或通知结束后这些行仍保持可见;重排若把某个 Session 移到水位线之后,该 Session 会被隐藏。每次点击**展开其余**将水位线升高五条,不受 50 行限制;剩余五条或更少时显示全部;最后一批显示后,**收起**重置水位线。折叠 Workspace 会重置其水位线及其嵌套 Workspace 的水位线;重置后的水位线仍覆盖符合条件的 Session。当前选中的空白**新会话**在首条提示词落地前是水位线之外的额外行。水位线不持久化;分组树的任何卸载都会重置水位线。
未保存标题的历史会话显示本地化名称「未命名 / Untitled」,不使用目录名兜底。当前空会话仍显示「新会话」,其他空会话仍隐藏。重命名草稿使用已保存标题,无标题时留空;无标题行不提供标题复制。
@@ -386,11 +386,11 @@ function SessionTree({
}
}
}, [groupExpansion, parents, revealGroup, setGroupExpanded])
const baselineWatermark = (group: GroupNode): number => Math.max(SESSION_WATERMARK_BASE, requiredWatermark(
const baselines = new Map(groups.map(group => [group.key, 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)) {
@@ -399,13 +399,12 @@ function SessionTree({
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 requiredWatermarks = new Map([...baselines].filter(([key]) => !underFoldedAncestor(key)))
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 => {
if (!Object.keys(storedWatermarks).some(drop)) return
setStoredWatermarks(marks => Object.fromEntries(Object.entries(marks).filter(([key]) => !drop(key))))
setWatermarkMotionKey(key => key + 1)
}
@@ -478,7 +477,7 @@ function SessionTree({
const children = childrenByParent.get(group.key) ?? []
const compatibleDrag = workspaceDrag !== null && parents.get(workspaceDrag.workspaceId) === parents.get(group.key)
const watermark = sessionWatermarks[group.key] ?? SESSION_WATERMARK_BASE
const collapsed = watermarkRows(group.sessions, baselineWatermark(group))
const collapsed = watermarkRows(group.sessions, baselines.get(group.key) ?? SESSION_WATERMARK_BASE)
const visible = watermarkRows(group.sessions, watermark)
const sessionsExpanded = visible.hiddenCount === 0
rowKeys.push(`workspace:${group.key}`)
@@ -809,6 +809,29 @@ describe('WorkspaceBrowser', () => {
expectVisible(8)
})
it('keeps the raised row count, not the Session, when a new Session pushes it down', () => {
const items = Array.from({ length: 12 }, (_, index) => summary(`session-${index + 1}`, 20 - 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 = (value: boolean) => hook<SessionStatusSnapshot>(new Map([[sid('session-9'), {
running: value, pendingInteraction: undefined, completionUnread: false,
}]]))
rerender(b, { useSessionStatus: running(true) })
rerender(b, { useSessionStatus: running(false) })
expect(screen.getByText('session-9')).toBeTruthy()
const newest = summary('newest', 30)
rerender(b, {
useSessions: hook(sessionState([newest, ...items])),
useWorkspaces: hook(workspaceState([workspace('alpha', [newest.id, ...items.map(item => item.id)])])),
})
expect(screen.getByText('session-8')).toBeTruthy()
expect(screen.queryByText('session-9')).toBeNull()
expect(screen.getByRole('button', { name: '展开其余 4 个会话' })).toBeTruthy()
})
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({