Przeglądaj źródła

feat(web): make Workspace tree an explicit grouping option

Turtle 1 tydzień temu
rodzic
commit
b432223364

+ 2 - 2
.agents/notes/implemented/feature/2026-09-15-sidebar-parent-folders.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-sidebar-parent-folders.md
-2026-09-15-sidebar-parent-folders.md: 2898f670bbf8b2eb8b9077fb2c3098e6fde64467
-2026-09-15-sidebar-parent-folders.zh.md: 5b633af8a18f56302374c51eb53fc7b820ab9e5f
+2026-09-15-sidebar-parent-folders.md: 236d0be6cbe12538c76608af62b9448d313f7739
+2026-09-15-sidebar-parent-folders.zh.md: 3e243326dab6502061f2d3d2c5df0fbf88cfd699

+ 4 - 2
.agents/notes/implemented/feature/2026-09-15-sidebar-parent-folders.md

@@ -10,12 +10,14 @@ A flat Workspace list makes related projects hard to browse when many directorie
 
 ## Decision
 
-The sidebar derives a recursive hierarchy from registered Workspace paths. Each Workspace appears under its nearest strict ancestor; path equality never creates a self-parent. Comparison respects directory separators and Host case spelling, without resolving symlink aliases. Siblings retain their Host order.
+The sidebar defaults to sibling Workspace sections. Selecting **Workspace Tree** in **View options → Group by** derives a recursive hierarchy from registered Workspace paths. The selected mode persists in the existing browser-local viewing store. Each Workspace appears under its nearest strict ancestor; path equality never creates a self-parent. Comparison respects directory separators and Host case spelling, without resolving symlink aliases. Siblings retain their Host order.
 
 Adding a directory registers a normal Workspace and opens its Session directly. Parent Workspaces retain their own Sessions and standard row actions. The existing browser-local expansion state controls both child Workspaces and the parent's Session rows; ancestors default to expanded when no explicit preference exists. All rows have equal-width fills and hit targets, with content indentation per level. Workspace drag stays among siblings, and search navigation expands every ancestor.
 
 ## Alternatives considered
 
+**Tree grouping by default:** this hides projects under ancestors even when users want to browse every Workspace directly. An explicit viewing mode preserves the default layout without adding a confirmation to directory adoption.
+
 **Recursive Workspace membership:** this changes the working-directory invariant and makes a Session eligible for multiple accounts. Display nesting needs neither change.
 
 **Separate parent-folder records and an add-time choice:** this duplicates directories already represented by Workspaces and adds a confirmation to ordinary addition. Deriving hierarchy from the registry preserves one add flow and lets every directory own Sessions.
@@ -26,4 +28,4 @@ Adding a directory registers a normal Workspace and opens its Session directly.
 
 Users can collapse related projects without changing Session ownership. Deleting an ancestor preserves child registrations; the independent [Workspace deletion semantics](2026-07-27-workspace-registration-deletion.md) still govern the deleted Workspace's own Sessions. That decision remains active. Browser-local collapse preferences do not synchronize, and canonical path spelling determines nesting.
 
-Pure path tests cover strict ancestry, segment boundaries, POSIX backslashes, and Windows separators. Component and browser scenarios exercise direct addition, later registrations, independent parent Sessions, collapse restoration, search revelation, sibling sorting, and equal-width rows.
+Pure path tests cover strict ancestry, segment boundaries, POSIX backslashes, and Windows separators. Component and browser scenarios exercise default sibling grouping, tree-mode selection and restoration, direct addition, later registrations, independent parent Sessions, collapse restoration, search revelation, sibling sorting, and equal-width rows.

+ 4 - 2
.agents/notes/implemented/feature/2026-09-15-sidebar-parent-folders.zh.md

@@ -10,12 +10,14 @@ Status: implemented
 
 ## 决策
 
-侧栏从已注册的 Workspace 路径派生递归层级。每个 Workspace 位于最近的严格祖先下;相同路径不会成为自己的父级。比较遵循目录分隔符和 Host 的大小写拼写,不解析符号链接别名。同级项目保留 Host 顺序。
+侧栏默认将 Workspace 显示为同级分组。在**视图选项 → 分组方式**中选择**按工作区树**后,侧栏从已注册的 Workspace 路径派生递归层级。所选模式保存在现有的浏览器本地视图存储中。每个 Workspace 位于最近的严格祖先下;相同路径不会成为自己的父级。比较遵循目录分隔符和 Host 的大小写拼写,不解析符号链接别名。同级项目保留 Host 顺序。
 
 添加目录会直接注册普通 Workspace 并打开其 Session。父 Workspace 保留自己的 Session 和标准行操作。现有的浏览器本地展开状态同时控制子 Workspace 和父级自己的 Session 行;没有显式偏好时,祖先默认展开。所有行的高亮与点击区域等宽,内容按层级缩进。Workspace 拖拽限制在同级之间,搜索导航会展开全部祖先。
 
 ## 考虑过的替代方案
 
+**默认采用树形分组:**这会把项目隐藏在祖先之下,即使用户希望直接浏览全部 Workspace。显式视图模式保留默认布局,也不为目录添加流程增加确认。
+
 **递归 Workspace 成员关系:**这会改变工作目录不变量,并让一个 Session 同时符合多个记账。展示嵌套不需要这两项改变。
 
 **独立父目录记录与添加时选择:**这会重复表示已有 Workspace 目录,并为普通添加增加确认。根据注册表派生层级可保留单一添加流程,并让每个目录都能拥有 Session。
@@ -26,4 +28,4 @@ Status: implemented
 
 用户可以折叠相关项目,而不改变 Session 归属。删除祖先会保留子工作区注册;独立的 [Workspace 删除语义](2026-07-27-workspace-registration-deletion.zh.md)仍决定被删除 Workspace 自身 Session 的处理,该决策继续有效。浏览器本地折叠偏好不跨浏览器同步,嵌套取决于规范路径拼写。
 
-纯路径测试覆盖严格祖先关系、路径段边界、POSIX 反斜杠和 Windows 分隔符。组件与浏览器场景覆盖直接添加、后续注册、父级独立 Session、折叠恢复、搜索展开、同级排序及整行等宽。
+纯路径测试覆盖严格祖先关系、路径段边界、POSIX 反斜杠和 Windows 分隔符。组件与浏览器场景覆盖默认同级分组、树形模式选择与恢复、直接添加、后续注册、父级独立 Session、折叠恢复、搜索展开、同级排序及整行等宽。

+ 13 - 0
apps/web/tests/expected/workspace-management/grouping-options.expected.md

@@ -0,0 +1,13 @@
+- menu:
+  - text: Group by
+  - menuitem "WorkSpace":
+    - text: WorkSpace
+    - img
+  - menuitem "Workspace Tree"
+  - menuitem "In one list"
+  - separator
+  - text: Order by
+  - menuitem "Manual"
+  - menuitem "Last updated":
+    - text: Last updated
+    - img

+ 1 - 1
apps/web/tests/schedule-after.e2e.ts

@@ -699,7 +699,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
     expect(await flatRow.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count()).toBe(1)
 
     await page.getByRole('button', { name: 'View options' }).click()
-    await page.getByRole('menuitem', { name: 'WorkSpace' }).click()
+    await page.getByRole('menuitem', { name: 'WorkSpace', exact: true }).click()
     await catalogRow.waitFor({ timeout: 15_000 })
     expect(await catalogRow.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count()).toBe(1)
 

+ 17 - 4
apps/web/tests/workspace-management.e2e.ts

@@ -4,7 +4,7 @@
 // typed draft, same-basename directory adoption, the rename round
 // trip over the real wire (workspace.rename RPC + durable registry), the
 // duplicate-name pre-check, the
-// flat "In one list" view with its persisted group-by preference, the session
+// flat "In one list" and opt-in Workspace tree views with persisted grouping, the session
 // hover card and row action menu, and the session archive round trip (row
 // menu → workspace.archiveSession RPC → durable global set → row hidden
 // across reload). Zero model calls: workspace.create/rename/archiveSession
@@ -37,7 +37,7 @@ const SEED_ID = 'workspace-management-web-e2e'
 const POINTER_TRANSIT_MS = 300
 const POINTER_HOLD_MS = 600
 
-describe('web e2e: workspace management (create / rename / flat view / hover affordances)', () => {
+describe('web e2e: workspace management (create / rename / grouping / hover affordances)', () => {
   let scaffold: WebScaffold
   let browser: Browser
   let page: Page
@@ -430,7 +430,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     acknowledgeReloadConnectionLoss(tripwire, warningStart)
     await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 15_000 }).toBe(0)
     await page.getByRole('button', { name: 'View options' }).click()
-    await page.getByRole('menuitem', { name: 'WorkSpace' }).click()
+    await page.getByRole('menuitem', { name: 'WorkSpace', exact: true }).click()
     await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
     expect(tripwire.pageErrors).toEqual([])
   }, 90_000)
@@ -662,7 +662,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     expect(tripwire.pageErrors).toEqual([])
   }, 90_000)
 
-  it('automatically nests Workspaces and preserves the parent Workspace session', async () => {
+  it('opts into Workspace tree grouping and preserves the parent Workspace session', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-parent-folders'))
     const parentPath = join(scaffold.workspaceCwd, 'folder-group')
     await mkdir(parentPath)
@@ -680,6 +680,12 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     expect(parentWorkspace.sessionIds).toHaveLength(1)
     const parent = page.getByRole('treeitem').filter({ has: page.getByText('folder-group', { exact: true }) })
     const section = parent.locator('xpath=ancestor::*[contains(@class, "groupSection")][1]')
+    await page.getByRole('tree', { name: 'Sessions', exact: true }).getByText('project-one', { exact: true }).waitFor()
+    expect(await section.getByText('project-one', { exact: true }).count()).toBe(0)
+    await page.getByRole('button', { name: 'View options', exact: true }).click()
+    const optionsExpected = fileURLToPath(new URL('./expected/workspace-management/grouping-options.expected.md', import.meta.url))
+    await compareOrRefreshGolden(optionsExpected, await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), MODE)
+    await page.getByRole('menuitem', { name: 'Workspace Tree', exact: true }).click()
     await section.getByText('project-one', { exact: true }).waitFor()
     await addNewFolderWorkspace(parentPath, 'project-two')
     const project = section.getByRole('treeitem', { name: 'project-two', exact: true })
@@ -708,6 +714,13 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     expect(await parent.getAttribute('aria-expanded')).toBe('false')
     await parent.click()
     await section.getByText('project-two', { exact: true }).waitFor()
+    await page.getByRole('button', { name: 'View options', exact: true }).click()
+    await page.getByRole('menuitem', { name: 'WorkSpace', exact: true }).click()
+    await page.getByRole('tree', { name: 'Sessions', exact: true }).getByText('project-two', { exact: true }).waitFor()
+    expect(await section.getByText('project-two', { exact: true }).count()).toBe(0)
+    await page.getByRole('button', { name: 'View options', exact: true }).click()
+    await page.getByRole('menuitem', { name: 'Workspace Tree', exact: true }).click()
+    await section.getByText('project-two', { exact: true }).waitFor()
     await clickHoverAction(parent, 'New session in folder-group')
     await expect.poll(() => section.locator('[aria-selected="true"]').evaluate(row =>
       row.closest('[class*="groupSection"]')?.querySelector('[role="treeitem"]')?.textContent,

+ 2 - 2
packages/client/ui-workspace/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
-README.md: a39258648301c446f340c7d20a2c41d226856958
-README.zh.md: a9523c3543f9762bcaf014e2cf5208183372167c
+README.md: 609489b0de68410875edbc7f93f038daeca36661
+README.zh.md: 81e0c7ddbab1f6b02678dd13c59cf2284468f4f1

+ 1 - 1
packages/client/ui-workspace/README.md

@@ -33,7 +33,7 @@ Use the sidebar to browse Workspaces and their Sessions, reorder them, and start
 
 ### Workspace hierarchy
 
-Choose **Add workspace** and select a directory to register it and open a Session. The sidebar automatically nests each Workspace under its nearest registered ancestor, including Workspaces added later. Each Workspace keeps its own Sessions and row actions. Ancestors start expanded unless a saved collapsed state exists. Row fills and hit targets span the same width at every level; only the contents indent. Workspace dragging reorders siblings. Search-result navigation expands every ancestor. Expansion is saved in the current browser; the single-list view stays flat.
+Choose **Add workspace** and select a directory to register it and open a Session. **View options → Group by** defaults to **WorkSpace**, which lists Workspaces as sibling sections. Select **Workspace Tree** to nest each Workspace under its nearest registered ancestor, including Workspaces added later. Each Workspace keeps its own Sessions and row actions. Ancestors start expanded unless a saved collapsed state exists. Row fills and hit targets span the same width at every level; only the contents indent. Workspace dragging reorders siblings. Search-result navigation expands every ancestor. Grouping and expansion are saved in the current browser; switching modes preserves each Workspace's expansion preference, and the single-list view stays flat.
 
 Hierarchy uses registered canonical paths only. It does not scan for projects or resolve symlink aliases. Nesting does not change Session working directories, logs, or Workspace membership. Deleting a parent Workspace leaves its child Workspaces registered and places them under their next registered ancestor, or at the root.
 

+ 1 - 1
packages/client/ui-workspace/README.zh.md

@@ -33,7 +33,7 @@ kind: "package-reference"
 
 ### 工作区层级
 
-选择**添加工作区**并选取目录,即可注册工作区并打开 Session。侧栏会自动将每个 Workspace 放到最近的已注册祖先之下,之后添加的 Workspace 也会自动归入。每个 Workspace 保留自己的 Session 和行操作。祖先默认展开,已有的折叠偏好除外。各层级的高亮和点击区域保持整行同宽,仅内容缩进。拖拽 Workspace 仅重排同级项目。选择搜索结果会展开全部祖先。展开状态保存在当前浏览器中;单列表视图保持平铺。
+选择**添加工作区**并选取目录,即可注册工作区并打开 Session。**视图选项 → 分组方式**默认为**按工作区**,将工作区作为同级分组显示。选择**按工作区树**后,每个 Workspace 会位于最近的已注册祖先之下,之后添加的 Workspace 也会自动归入。每个 Workspace 保留自己的 Session 和行操作。祖先默认展开,已有的折叠偏好除外。各层级的高亮和点击区域保持整行同宽,仅内容缩进。拖拽 Workspace 仅重排同级项目。选择搜索结果会展开全部祖先。分组方式和展开状态保存在当前浏览器中;切换模式会保留各 Workspace 的展开偏好,单列表视图保持平铺。
 
 层级仅使用已注册的规范路径,不扫描项目,也不解析符号链接别名。嵌套不会改变 Session 的工作目录、日志或 Workspace 归属。删除父 Workspace 后,子 Workspace 仍保持注册,并归入下一个已注册祖先;没有祖先时显示在根层级。
 

+ 2 - 0
packages/client/ui-workspace/src/client/locales.ts

@@ -13,6 +13,7 @@ export const zh = {
   'viewOptions.label': '视图选项',
   'groupBy.label': '分组方式',
   'groupBy.workspace': '按工作区',
+  'groupBy.workspaceTree': '按工作区树',
   'groupBy.flat': '单列表',
   'orderBy.label': '排序方式',
   'orderBy.manual': '手动排序',
@@ -83,6 +84,7 @@ export const en = {
   'viewOptions.label': 'View options',
   'groupBy.label': 'Group by',
   'groupBy.workspace': 'WorkSpace',
+  'groupBy.workspaceTree': 'Workspace Tree',
   'groupBy.flat': 'In one list',
   'orderBy.label': 'Order by',
   'orderBy.manual': 'Manual',

+ 13 - 8
packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx

@@ -27,7 +27,7 @@ import {
   pinCurrentBlank, reconcileManualOrder, UNGROUPED_KEY, visibleSessionIds,
 } from '../tree.ts'
 import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './Rows.tsx'
-import { FLAT_SESSION_ORDER_KEY } from '../stores.ts'
+import { FLAT_SESSION_ORDER_KEY, type SessionGroupBy } from '../stores.ts'
 import { WorkspacePickFlow } from '../WorkspacePicker.tsx'
 import css from './WorkspaceBrowser.module.css'
 
@@ -98,9 +98,9 @@ function useNativeDragAcceptance(active: boolean): void {
 
 /** Grouping and ordering menu; own open state so it resets with the wide chrome. */
 function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
-  groupBy: 'workspace' | 'flat'
+  groupBy: SessionGroupBy
   orderBy: SessionOrderBy
-  onGroupPick: (mode: 'workspace' | 'flat') => void
+  onGroupPick: (mode: SessionGroupBy) => void
   onOrderPick: (mode: SessionOrderBy) => void
   t: WorkspaceBrowserProps['t']
 }) {
@@ -112,6 +112,7 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
       items={[
         { type: 'label' as const, id: 'group-by', text: t('groupBy.label') },
         { id: 'workspace', label: t('groupBy.workspace') },
+        { id: 'workspace-tree', label: t('groupBy.workspaceTree') },
         { id: 'flat', label: t('groupBy.flat') },
         { type: 'separator' as const, id: 'order-by-separator' },
         { type: 'label' as const, id: 'order-by', text: t('orderBy.label') },
@@ -120,7 +121,7 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
       ]}
       selectedIds={[groupBy, orderBy]}
       onSelect={(id) => {
-        if (id === 'workspace' || id === 'flat') onGroupPick(id)
+        if (id === 'workspace' || id === 'workspace-tree' || id === 'flat') onGroupPick(id)
         else if (id === 'manual' || id === 'updated') onOrderPick(id)
         setOpen(false)
       }}
@@ -181,9 +182,11 @@ type SessionTreeProps = Pick<
   ungroupedSessionIds: readonly SessionId[]
   /** Whether the current Workspace stream has a complete Host baseline. */
   workspaceReady: boolean
-  /** Explicit persisted zero-or-five-session state by Workspace group. */
+  /** Nest Workspaces under their nearest registered ancestors. */
+  nestWorkspaces: boolean
+  /** Explicit persisted group expansion, including descendants in tree mode. */
   groupExpansion: Readonly<Record<string, boolean>>
-  /** Persist one Workspace group's zero-or-five-session state. */
+  /** Persist one Workspace group's expansion. */
   setGroupExpanded: (key: string, expanded: boolean) => void
   /** Save a drag order and select Manual. */
   setSessionOrder: (accountKey: string, order: readonly string[]) => void
@@ -210,7 +213,7 @@ function SessionTree({
   workspaceReady, usePanelInfo,
   onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
   insertWorkspaceBefore,
-  groupExpansion, setGroupExpanded,
+  nestWorkspaces, groupExpansion, setGroupExpanded,
   setSessionOrder, home, t,
   revealSessionId, onSessionRevealed,
 }: SessionTreeProps) {
@@ -236,13 +239,14 @@ function SessionTree({
     setGroupExpanded(currentGroup, true)
   }, [current, currentGroup, setGroupExpanded, groupExpansion])
   const parents = useMemo(() => {
+    if (!nestWorkspaces) return new Map<string, WorkspaceId | undefined>()
     const keysByPath = new Map(workspaces.map(workspace => [workspace.path, workspace.workspaceId]))
     const paths = [...keysByPath.keys()]
     return new Map<string, WorkspaceId | undefined>(workspaces.map((workspace) => {
       const path = owningParentFolder(workspace.path, paths)
       return [workspace.workspaceId, path === undefined ? undefined : keysByPath.get(path)]
     }))
-  }, [workspaces])
+  }, [nestWorkspaces, workspaces])
   const expandedGroups = useMemo(() => {
     const ancestorKeys = new Set<string | undefined>(parents.values())
     return [...workspaces.map(workspace => workspace.workspaceId), UNGROUPED_KEY]
@@ -1247,6 +1251,7 @@ export function WorkspaceBrowser({
                 workspaces={orderedWorkspaces}
                 ungroupedSessionIds={orderedUngroupedSessionIds}
                 workspaceReady={workspaceReady}
+                nestWorkspaces={groupBy === 'workspace-tree'}
                 groupExpansion={groupExpansion}
                 setGroupExpanded={actions.setGroupExpanded}
                 setSessionOrder={saveSessionOrder}

+ 3 - 3
packages/client/ui-workspace/src/client/stores.ts

@@ -10,8 +10,8 @@ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-sto
 /** Browser-local order account for the hierarchy-free flat Session list. */
 export const FLAT_SESSION_ORDER_KEY = '__flat_session_order__'
 
-/** Session-list grouping mode: workspace sections or one flat recency list. */
-export type SessionGroupBy = 'workspace' | 'flat'
+/** Session-list grouping mode: sibling Workspace sections, a Workspace tree, or one flat list. */
+export type SessionGroupBy = 'workspace' | 'workspace-tree' | 'flat'
 /** Session order: saved manual positions or current recency. */
 export type SessionOrderBy = 'manual' | 'updated'
 
@@ -19,7 +19,7 @@ export type SessionOrderBy = 'manual' | 'updated'
 type WorkspaceViewState = {
   groupBy: SessionGroupBy
   orderBy: SessionOrderBy
-  /** Explicit zero-or-five-session state keyed by Workspace group identity. */
+  /** Explicit group expansion keyed by Workspace identity, including descendants in tree mode. */
   groupExpansion: Record<string, boolean>
   /** Saved manual order per Workspace group plus the browser-local flat-list account. */
   sessionOrderByAccount: Record<string, string[]>

+ 43 - 3
packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx

@@ -389,7 +389,7 @@ describe('WorkspaceBrowser', () => {
     expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label
     expect(screen.getByRole('separator')).toBeTruthy()
     expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
-      '按工作区', '单列表', '手动排序', '最近更新',
+      '按工作区', '按工作区树', '单列表', '手动排序', '最近更新',
     ])
     expect(screen.getByRole('menuitem', { name: '按工作区' }).querySelector('svg')).toBeTruthy()
     expect(screen.getByRole('menuitem', { name: '手动排序' }).querySelector('svg')).toBeTruthy()
@@ -415,6 +415,42 @@ describe('WorkspaceBrowser', () => {
     expect(b.store.getSnapshot().groupBy).toBe('workspace')
   })
 
+  it('keeps Workspaces as siblings by default and restores the selected tree grouping', () => {
+    const workspaces = hook(workspaceState([
+      { ...workspace('root', [], 'Projects'), path: '/projects' },
+      workspace('child', ['child-session'], 'Child'),
+    ]))
+    const sessions = hook(sessionState([summary('child-session', 1)]))
+    const b = mount({ useWorkspaces: workspaces, useSessions: sessions })
+    const parentSection = () => screen.getByText('Projects').closest<HTMLElement>('[class*="groupSection"]')!
+    const choose = (name: string) => {
+      fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
+      fireEvent.click(screen.getByRole('menuitem', { name }))
+    }
+    expect(b.store.getSnapshot().groupBy).toBe('workspace')
+    expect(screen.getByText('Child')).toBeTruthy()
+    expect(within(parentSection()).queryByText('Child')).toBeNull()
+    choose('按工作区树')
+    expect(b.store.getSnapshot().groupBy).toBe('workspace-tree')
+    expect(within(parentSection()).getByText('Child')).toBeTruthy()
+    fireEvent.click(screen.getByText('Projects'))
+    expect(screen.queryByText('Child')).toBeNull()
+    choose('按工作区')
+    expect(screen.getByText('Child')).toBeTruthy()
+    expect(within(parentSection()).queryByText('Child')).toBeNull()
+    choose('按工作区树')
+    expect(screen.queryByText('Child')).toBeNull()
+    b.view.unmount()
+    const restored = mount({ useWorkspaces: workspaces, useSessions: sessions })
+    expect(restored.store.getSnapshot().groupBy).toBe('workspace-tree')
+    expect(screen.queryByText('Child')).toBeNull()
+    fireEvent.click(screen.getByText('Projects'))
+    expect(within(parentSection()).getByText('Child')).toBeTruthy()
+    choose('单列表')
+    expect(screen.queryByText('Projects')).toBeNull()
+    expect(screen.getByText('child-session')).toBeTruthy()
+  })
+
   it('persists flat-list drag order locally and applies Last updated within that account', async () => {
     const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
     const workspaces = workspaceState([
@@ -877,6 +913,7 @@ describe('WorkspaceBrowser', () => {
   })
 
   it('opens a Host content hit, exits search, and reveals its hidden grouped row', async () => {
+    createWorkspaceViewStore().create().actions.setGroupBy('workspace-tree')
     createWorkspaceViewStore().create().actions.setGroupExpanded('root', false)
     vi.useFakeTimers()
     try {
@@ -1643,7 +1680,10 @@ describe('WorkspaceBrowser', () => {
 })
 
 
-describe('automatic Workspace hierarchy', () => {
+describe('Workspace tree grouping', () => {
+  beforeEach(() => {
+    createWorkspaceViewStore().create().actions.setGroupBy('workspace-tree')
+  })
   const root = { ...workspace('root', ['root-session'], 'Projects'), path: '/projects' }
   const team = workspace('team', ['team-session'], 'Team')
   const child = { ...workspace('child', ['child-session'], 'Child'), path: '/projects/team/child' }
@@ -1698,7 +1738,7 @@ describe('automatic Workspace hierarchy', () => {
     expect(screen.getByText('Child')).toBeTruthy()
     act(() => { restored.store.actions.setGroupBy('flat') })
     expect(screen.queryByText('Projects')).toBeNull()
-    act(() => { restored.store.actions.setGroupBy('workspace') })
+    act(() => { restored.store.actions.setGroupBy('workspace-tree') })
     expect(screen.getByText('Child')).toBeTruthy()
   })