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({