From 5a7cb9414929699fed71aef5fe8c67c8c0f329ae Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Mon, 12 Oct 2026 22:28:05 +0800 Subject: [PATCH] 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. --- .../watermark-kept.expected.md | 16 ++++++++ .../workspace-new-session-folding.e2e.ts | 39 ++++++++++++++++++- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../src/client/rows/WorkspaceBrowser.tsx | 11 +++--- .../tests/workspace-browser.client.spec.tsx | 23 +++++++++++ 7 files changed, 85 insertions(+), 12 deletions(-) create mode 100644 apps/web/tests/expected/workspace-new-session-folding/watermark-kept.expected.md diff --git a/apps/web/tests/expected/workspace-new-session-folding/watermark-kept.expected.md b/apps/web/tests/expected/workspace-new-session-folding/watermark-kept.expected.md new file mode 100644 index 0000000000..153f797a70 --- /dev/null +++ b/apps/web/tests/expected/workspace-new-session-folding/watermark-kept.expected.md @@ -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" diff --git a/apps/web/tests/workspace-new-session-folding.e2e.ts b/apps/web/tests/workspace-new-session-folding.e2e.ts index 176af733b0..4a24a46e59 100644 --- a/apps/web/tests/workspace-new-session-folding.e2e.ts +++ b/apps/web/tests/workspace-new-session-folding.e2e.ts @@ -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([]) }) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 687f287046..75a22081cd 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: d51507f87484f818 - zh: e2600daa21e35f36 + en: d042b4b1dd7e7f56 + zh: c12fa070ef5a02a4 /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 d97b93b13a..6cccb558f9 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 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. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index d8d8717933..09a05fe996 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,直到该 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」,不使用目录名兜底。当前空会话仍显示「新会话」,其他空会话仍隐藏。重命名草稿使用已保存标题,无标题时留空;无标题行不提供标题复制。 diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 3434d8af51..22cce48839 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -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}`) 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 362cd1604d..544ddd5140 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -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(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({