Selaa lähdekoodia

refactor(web): derive workspace hierarchy automatically from paths

Turtle 5 päivää sitten
vanhempi
sitoutus
db2e03b89d

+ 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;
 # 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:
 # 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
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-sidebar-parent-folders.md
-2026-09-15-sidebar-parent-folders.md: 15d6f56106ff0c5df956aee07847f9438561ca6f
-2026-09-15-sidebar-parent-folders.zh.md: 732007523b6690e53efb76cf42c8966c8bca644e
+2026-09-15-sidebar-parent-folders.md: 2898f670bbf8b2eb8b9077fb2c3098e6fde64467
+2026-09-15-sidebar-parent-folders.zh.md: 5b633af8a18f56302374c51eb53fc7b820ab9e5f

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

@@ -1,4 +1,4 @@
-# Agent Note: Sidebar Parent Folders
+# Agent Note: Sidebar Workspace Hierarchy
 
 
 Status: implemented
 Status: implemented
 
 
@@ -10,20 +10,20 @@ A flat Workspace list makes related projects hard to browse when many directorie
 
 
 ## Decision
 ## Decision
 
 
-Parent folders are browser-local viewing state in the existing Workspace store. Each Workspace appears under the most specific selected ancestor or equal path; unassigned Workspaces retain their root positions relative to each other. Selected parents retain addition order, including empty parents. Path comparison respects directory separators and uses Host case spelling. It does not resolve symlink aliases.
+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 existing Add workspace flow offers parent grouping after directory selection, without creating a Workspace or Session. Parent rows reuse the Workspace folder affordance and ellipsis menu. Row fills and hit targets span the same width at every level; indentation applies only to row contents. Project rows retain their current actions and per-account Session ordering. Workspace dragging is restricted to siblings in the same parent. Search-result navigation expands the parent before revealing the Session. Missing parent-folder state in older browser preferences means no grouping; removing a parent never changes Host state.
+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
 ## Alternatives considered
 
 
-**Recursive Workspace membership:** this changes the working-directory invariant and makes a Session eligible for multiple accounts. Display grouping needs neither change.
+**Recursive Workspace membership:** this changes the working-directory invariant and makes a Session eligible for multiple accounts. Display nesting needs neither change.
 
 
-**A Host-persisted folder hierarchy:** this would synchronize organization across browsers but adds records and API operations for a viewing preference. Browser-local persistence matches the requested scope.
+**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.
 
 
-**Automatic directory discovery:** this could show projects without a registered Workspace, but adds filesystem listing, refresh, and adoption behavior. The grouping operation consumes the current Workspace list only.
+**Automatic directory discovery:** this could show unregistered projects, but requires filesystem listing, refresh, and adoption behavior. The sidebar consumes the current Workspace list only.
 
 
 ## Consequences
 ## Consequences
 
 
-Users can collapse a family of projects without changing Session ownership. Removing a group preserves the independent [Workspace deletion semantics](2026-07-27-workspace-registration-deletion.md). That decision remains active and is not superseded. Groups do not synchronize across browsers, selected parent folders remain one level deep, and symlink aliases require selecting the canonical ancestor.
+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 overlapping roots, segment boundaries, POSIX backslashes, and Windows separators. Component tests cover preference restoration, removal, search revelation, and sibling sorting. The Workspace-management browser scenario exercises the real picker and checks that grouping preserves Host registrations and Agent count.
+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.

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

@@ -1,4 +1,4 @@
-# Agent Note: 侧边栏父目录分组
+# Agent Note: 侧栏工作区层级
 
 
 Status: implemented
 Status: implemented
 
 
@@ -6,24 +6,24 @@ Status: implemented
 
 
 ## 问题
 ## 问题
 
 
-多个项目共享父目录时,平铺的 Workspace 列表不便浏览相关项目。如果将父目录视为所有后代 Session 的所有者,就会违背 Workspace 成员 Session 的规范工作目录必须等于注册路径的要求。
+多目共享同一父目录时,平铺的 Workspace 列表不便浏览相关项目。若让父目录拥有所有后代 Session,则会违背 Workspace 成员 Session 的规范工作目录必须等于注册路径这一要求。
 
 
 ## 决策
 ## 决策
 
 
-父目录作为浏览器本地视图状态保存在现有 Workspace store 中。每个 Workspace 归入最具体的已选祖先目录或相同路径;未归组的 Workspace 在根层保留彼此的相对顺序。已选父目录按添加顺序显示,包括空父目录。路径比较遵循目录分隔符并使用 Host 的大小写拼写,不解析符号链接别名。
+侧栏从已注册的 Workspace 路径派生递归层级。每个 Workspace 位于最近的严格祖先下;相同路径不会成为自己的父级。比较遵循目录分隔符和 Host 的大小写拼写,不解析符号链接别名。同级项目保留 Host 顺序。
 
 
-现有添加工作区流程在选择目录后提供父目录分组选项,不创建 Workspace 或 Session。父目录行复用 Workspace 文件夹样式和省略号菜单。各层级的行背景与点击区域保持同宽,缩进仅作用于行内容。项目行保留现有操作与各账户的 Session 排序。Workspace 拖拽仅限同一父目录下的项目。搜索结果导航先展开父目录,再显示 Session。旧浏览器偏好缺少父目录状态时表示不分组;移除父目录不会改变 Host 状态
+添加目录会直接注册普通 Workspace 并打开其 Session。父 Workspace 保留自己的 Session 和标准行操作。现有的浏览器本地展开状态同时控制子 Workspace 和父级自己的 Session 行;没有显式偏好时,祖先默认展开。所有行的高亮与点击区域等宽,内容按层级缩进。Workspace 拖拽限制在同级之间,搜索导航会展开全部祖先
 
 
 ## 考虑过的替代方案
 ## 考虑过的替代方案
 
 
-**递归 Workspace 归属:**这会改变工作目录不变量,并使一个 Session 符合多个账户的归属条件。展示分组不需要这些变化
+**递归 Workspace 成员关系:**这会改变工作目录不变量,并让一个 Session 同时符合多个记账。展示嵌套不需要这两项改变
 
 
-**Host 持久化的目录层级:**它可以在浏览器间同步组织方式,但会为视图偏好增加记录和 API 操作。浏览器本地持久化符合本次范围
+**独立父目录记录与添加时选择:**这会重复表示已有 Workspace 目录,并为普通添加增加确认。根据注册表派生层级可保留单一添加流程,并让每个目录都能拥有 Session
 
 
-**自动发现目录:**它可以显示尚未注册 Workspace 的项目,但需要文件系统列表、刷新与注册行为。分组操作只消费当前 Workspace 列表。
+**自动发现目录:**这能展示尚未注册的项目,但需要文件系统列举、刷新和采用行为。侧栏仅使用当前 Workspace 列表。
 
 
-## 影响
+## 结果
 
 
-用户可以折叠一组项目,而不改变 Session 归属。移除分组保留独立的 [Workspace 删除语义](2026-07-27-workspace-registration-deletion.zh.md)。该决策继续有效,未被取代。分组不在浏览器间同步,已选父目录保持单层,符号链接别名需要选择规范祖先目录
+用户可以折叠相关项目,而不改变 Session 归属。删除祖先会保留子工作区注册;独立的 [Workspace 删除语义](2026-07-27-workspace-registration-deletion.zh.md)仍决定被删除 Workspace 自身 Session 的处理,该决策继续有效。浏览器本地折叠偏好不跨浏览器同步,嵌套取决于规范路径拼写
 
 
-纯路径测试覆盖重叠根目录、路径段边界、POSIX 反斜杠与 Windows 分隔符。组件测试覆盖偏好恢复、移除、搜索展开与同级排序。Workspace 管理浏览器场景使用真实选择器,并检查分组保留 Host 注册记录和 Agent 数量
+纯路径测试覆盖严格祖先关系、路径段边界、POSIX 反斜杠和 Windows 分隔符。组件与浏览器场景覆盖直接添加、后续注册、父级独立 Session、折叠恢复、搜索展开、同级排序及整行等宽

+ 9 - 19
apps/web/tests/expected/workspace-management/parent-folders.expected.md

@@ -1,21 +1,11 @@
-- dialog "Add workspace":
-  - heading "Add workspace" [level=2]
-  - button "Close":
+- treeitem "folder-group" [expanded]:
+  - img
+  - text: folder-group
+- group:
+  - treeitem "project-two" [expanded]:
     - img
     - img
-  - text: {{cwd}}/folder-group
-  - checkbox "Group by child workspaces" [checked]
-  - text: Group by child workspaces Show added workspaces in this directory and their sessions. project-two project-one
-  - button "Cancel"
-  - button "Add"
-- treeitem "{{cwd}}/folder-group" [expanded]:
-  - button "{{cwd}}/folder-group" [expanded]:
+    - text: project-two
+  - treeitem "New Session" [selected]
+  - treeitem "project-one" [expanded]:
     - img
     - img
-    - text: {{cwd}}/folder-group
-  - group:
-    - treeitem "project-two" [expanded]:
-      - img
-      - text: project-two
-    - treeitem "New Session" [selected]
-    - treeitem "project-one" [expanded]:
-      - img
-      - text: project-one
+    - text: project-one

+ 32 - 45
apps/web/tests/workspace-management.e2e.ts

@@ -14,7 +14,6 @@
 import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
 import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { fileURLToPath } from 'node:url'
 import { join, sep } from 'node:path'
 import { join, sep } from 'node:path'
-import { homedir } from 'node:os'
 import type { Browser, Locator, Page } from 'playwright'
 import type { Browser, Locator, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
@@ -77,10 +76,6 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     // Creating selects the new folder in the listing; Open adopts it.
     // Creating selects the new folder in the listing; Open adopts it.
     await dialog.getByRole('button', { name: 'Open', exact: true }).click()
     await dialog.getByRole('button', { name: 'Open', exact: true }).click()
     await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
     await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
-    const confirmation = page.getByRole('dialog', { name: 'Add workspace', exact: true })
-    await confirmation.getByRole('checkbox', { name: 'Group by child workspaces' }).uncheck()
-    await confirmation.getByRole('button', { name: 'Add', exact: true }).click()
-    await confirmation.waitFor({ state: 'hidden' })
     await expect.poll(
     await expect.poll(
       () => scaffold.ctx.workspaceRegistry.resolveByPath(join(parent, name)),
       () => scaffold.ctx.workspaceRegistry.resolveByPath(join(parent, name)),
       { timeout: 10_000 },
       { timeout: 10_000 },
@@ -101,10 +96,6 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     const dialog = await browseTo(path)
     const dialog = await browseTo(path)
     await dialog.getByRole('button', { name: 'Open', exact: true }).click()
     await dialog.getByRole('button', { name: 'Open', exact: true }).click()
     await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
     await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
-    const confirmation = page.getByRole('dialog', { name: 'Add workspace', exact: true })
-    await confirmation.getByRole('checkbox', { name: 'Group by child workspaces' }).uncheck()
-    await confirmation.getByRole('button', { name: 'Add', exact: true }).click()
-    await confirmation.waitFor({ state: 'hidden' })
     await expect.poll(
     await expect.poll(
       () => scaffold.ctx.workspaceRegistry.resolveByPath(path),
       () => scaffold.ctx.workspaceRegistry.resolveByPath(path),
       { timeout: 10_000 },
       { timeout: 10_000 },
@@ -671,62 +662,58 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
   }, 90_000)
   }, 90_000)
 
 
-  it('groups existing Workspaces by a picked parent without creating a parent Workspace', async () => {
+  it('automatically nests Workspaces and preserves the parent Workspace session', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-parent-folders'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-parent-folders'))
     const parentPath = join(scaffold.workspaceCwd, 'folder-group')
     const parentPath = join(scaffold.workspaceCwd, 'folder-group')
-    const parentName = parentPath.startsWith(homedir() + sep) ? `~${parentPath.slice(homedir().length)}` : parentPath
     await mkdir(parentPath)
     await mkdir(parentPath)
     await addNewFolderWorkspace(parentPath, 'project-one')
     await addNewFolderWorkspace(parentPath, 'project-one')
-    await addNewFolderWorkspace(parentPath, 'project-two')
-    const workspaceIds = scaffold.ctx.workspaceRegistry.list().map(workspace => workspace.id)
+    const childWorkspace = (await scaffold.ctx.workspaceRegistry.resolveByPath(join(parentPath, 'project-one')))!
+    const childSessionIds = [...childWorkspace.sessionIds]
+    const workspaceCount = scaffold.ctx.workspaceRegistry.list().length
     const agentCount = scaffold.ctx.agents.list().length
     const agentCount = scaffold.ctx.agents.list().length
-    await page.getByRole('button', { name: 'Add workspace', exact: true }).click()
-    const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
-    await dialog.getByRole('button', { name: 'Edit path' }).click()
-    await dialog.locator('input[aria-label="Edit path"]').fill(parentPath)
-    await page.keyboard.press('Enter')
-    await dialog.locator('input[aria-label="Edit path"]').waitFor({ state: 'detached' })
-    await dialog.getByRole('button', { name: 'Open', exact: true }).click()
-    await dialog.waitFor({ state: 'hidden' })
-    const confirmation = page.getByRole('dialog', { name: 'Add workspace', exact: true })
-    expect(await confirmation.getByRole('checkbox', { name: 'Group by child workspaces' }).isChecked()).toBe(true)
-    const confirmationAria = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
-    await confirmation.getByRole('button', { name: 'Add', exact: true }).click()
-    await confirmation.waitFor({ state: 'hidden' })
-    const parent = page.getByRole('treeitem', { name: parentPath, exact: true })
-    await parent.getByText('project-two', { exact: true }).waitFor()
-    expect(await parent.getByText('project-one', { exact: true }).count()).toBe(1)
-    expect(scaffold.ctx.workspaceRegistry.list().map(workspace => workspace.id)).toEqual(workspaceIds)
-    expect(scaffold.ctx.agents.list()).toHaveLength(agentCount)
+    await adoptDirectory(parentPath, { waitForAgent: true })
+    expect(await page.getByRole('dialog', { name: 'Add workspace', exact: true }).count()).toBe(0)
+    expect(scaffold.ctx.workspaceRegistry.list()).toHaveLength(workspaceCount + 1)
+    expect(scaffold.ctx.agents.list()).toHaveLength(agentCount + 1)
+    expect([...childWorkspace.sessionIds]).toEqual(childSessionIds)
+    const parentWorkspace = (await scaffold.ctx.workspaceRegistry.resolveByPath(parentPath))!
+    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 section.getByText('project-one', { exact: true }).waitFor()
+    await addNewFolderWorkspace(parentPath, 'project-two')
+    const project = section.getByRole('treeitem', { name: 'project-two', exact: true })
+    const session = section.locator('[aria-selected="true"]')
+    await session.waitFor()
     const parentBounds = (await parent.boundingBox())!
     const parentBounds = (await parent.boundingBox())!
-    const project = parent.getByRole('treeitem', { name: 'project-two', exact: true })
-    const session = parent.locator('[aria-selected="true"]')
     for (const row of [project, session]) {
     for (const row of [project, session]) {
       const bounds = (await row.boundingBox())!
       const bounds = (await row.boundingBox())!
       expect(bounds.x).toBeCloseTo(parentBounds.x, 0)
       expect(bounds.x).toBeCloseTo(parentBounds.x, 0)
       expect(bounds.width).toBeCloseTo(parentBounds.width, 0)
       expect(bounds.width).toBeCloseTo(parentBounds.width, 0)
     }
     }
-    const parentLabel = (await parent.getByRole('button', { name: parentName, exact: true }).locator('span').last().boundingBox())!
+    const parentLabel = (await parent.getByText('folder-group', { exact: true }).boundingBox())!
     const projectLabel = (await project.getByText('project-two', { exact: true }).boundingBox())!
     const projectLabel = (await project.getByText('project-two', { exact: true }).boundingBox())!
     expect(projectLabel.x - parentLabel.x).toBeCloseTo(12, 0)
     expect(projectLabel.x - parentLabel.x).toBeCloseTo(12, 0)
     const expected = fileURLToPath(new URL('./expected/workspace-management/parent-folders.expected.md', import.meta.url))
     const expected = fileURLToPath(new URL('./expected/workspace-management/parent-folders.expected.md', import.meta.url))
-    await compareOrRefreshGolden(expected, confirmationAria + '\n' + await captureStableAria(
-      page, '[role="treeitem"][aria-label]', scaffold.workspaceCwd,
-      { replacements: [[parentName, '{{cwd}}/folder-group']] },
+    await compareOrRefreshGolden(expected, await captureStableAria(
+      page, '[aria-label="Workspace actions for folder-group"] >> xpath=ancestor::*[contains(@class, "groupSection")][1]',
+      scaffold.workspaceCwd,
     ), MODE)
     ), MODE)
-    await parent.getByRole('button', { name: parentName, exact: true }).click()
-    expect(await parent.getByText('project-two', { exact: true }).count()).toBe(0)
+    await parent.click()
+    expect(await section.getByText('project-two', { exact: true }).count()).toBe(0)
     const warningStart = tripwire.warnings.length
     const warningStart = tripwire.warnings.length
     await page.reload({ waitUntil: 'load' })
     await page.reload({ waitUntil: 'load' })
     await parent.waitFor()
     await parent.waitFor()
     acknowledgeReloadConnectionLoss(tripwire, warningStart)
     acknowledgeReloadConnectionLoss(tripwire, warningStart)
     expect(await parent.getAttribute('aria-expanded')).toBe('false')
     expect(await parent.getAttribute('aria-expanded')).toBe('false')
-    await parent.hover()
-    await parent.getByRole('button', { name: `Workspace actions for ${parentName}` }).click()
-    await page.getByRole('menuitem', { name: 'Remove group', exact: true }).click()
-    await parent.waitFor({ state: 'detached' })
-    await page.getByRole('tree').getByText('project-two', { exact: true }).waitFor()
-    expect(scaffold.ctx.workspaceRegistry.list().map(workspace => workspace.id)).toEqual(workspaceIds)
+    await parent.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,
+    ), { timeout: 10_000 }).toBe('folder-group')
+    expect(parentWorkspace.sessionIds).toHaveLength(1)
+    expect([...childWorkspace.sessionIds]).toEqual(childSessionIds)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
   })
   })
 
 

+ 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;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
 #   pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
-README.md: 965b1895f739120d4ff05c29f3ce9d5cce04d1ec
-README.zh.md: 066772902715b25ba5b1133242ea333d4292b9c4
+README.md: a39258648301c446f340c7d20a2c41d226856958
+README.zh.md: a9523c3543f9762bcaf014e2cf5208183372167c

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

@@ -31,11 +31,11 @@ Use the sidebar to browse Workspaces and their Sessions, reorder them, and start
 
 
 **Last updated** orders ordinary Sessions by their latest user prompt or steer time, newest first, in both grouped and flat views. **Manual** freezes the current displayed order and holds positions when activity changes; newly discovered ordinary Sessions append to the end, newest first when several arrive together. Returning to Last updated discards every manual position, and entering Manual again freezes the then-current recency order. The browser defaults to Last updated and remembers the selected mode across reloads. Dragging an ordinary Session applies the move locally and selects Manual. The selected blank **New Session** is always pinned first and cannot be dragged; after its first prompt it becomes an ordinary draggable row, retaining its first position in Manual or following its current timestamp in Last updated. In a collapsed group, drag boundaries follow rendered rows and place the source before intervening hidden rows, so a drag cannot hide its source. Session display orders for real Workspaces, Ungrouped, and the flat list are browser-local; Workspace group drag order remains Host-durable.
 **Last updated** orders ordinary Sessions by their latest user prompt or steer time, newest first, in both grouped and flat views. **Manual** freezes the current displayed order and holds positions when activity changes; newly discovered ordinary Sessions append to the end, newest first when several arrive together. Returning to Last updated discards every manual position, and entering Manual again freezes the then-current recency order. The browser defaults to Last updated and remembers the selected mode across reloads. Dragging an ordinary Session applies the move locally and selects Manual. The selected blank **New Session** is always pinned first and cannot be dragged; after its first prompt it becomes an ordinary draggable row, retaining its first position in Manual or following its current timestamp in Last updated. In a collapsed group, drag boundaries follow rendered rows and place the source before intervening hidden rows, so a drag cannot hide its source. Session display orders for real Workspaces, Ungrouped, and the flat list are browser-local; Workspace group drag order remains Host-durable.
 
 
-### Parent folders
+### Workspace hierarchy
 
 
-Choose **Add workspace**, select a directory, then confirm **Group by child workspaces** to group already registered Workspaces by path. The option starts checked when the directory contains registered Workspaces. Clear it to add the directory as an ordinary Workspace. Parent folders appear in addition order; overlapping parents assign each Workspace to its most specific selected parent, including a Workspace at that exact path. Rows share the same full-width hover and selection area; only their contents are indented. Projects keep their existing Session actions, and Workspace dragging reorders siblings within the same parent. Empty parents stay visible with an explanation. Parent paths and their independent collapse states persist in this browser; older preferences start without parent folders. Use the parent row’s **… → Remove group** menu to remove only its grouping, and selecting a search result expands its parent. The flat view ignores parent folders.
+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.
 
 
-Parent grouping does not scan for unregistered directories or resolve symlink aliases; choose the ancestor of the canonical path shown on the Workspace hover card. It does not change a Session's working directory, log, or Workspace membership.
+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.
 
 
 ### Search
 ### Search
 
 

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

@@ -31,11 +31,11 @@ kind: "package-reference"
 
 
 **最近更新**在分组和单列表视图中都按普通 Session 的最近一次用户提示词或 steering(中途引导)时间降序排列。**手动排序**会冻结当前显示顺序,并在活动变化时保持已有位置;新发现的普通 Session 追加到末尾,多条同时到达时按最近更新时间降序排列。返回最近更新会丢弃所有手动位置;再次进入手动排序时,则冻结当时的最近更新顺序。浏览器默认采用最近更新,并在重新加载后记住所选模式。拖拽普通 Session 会在浏览器本地应用移动并选中手动排序。当前选中的空白**新会话**始终固定在首位且无法拖拽;首条提示词落地后,它成为可拖拽的普通行,在手动排序中保留首位,在最近更新中按当前时间戳排列。折叠分组的拖拽边界按渲染行确定,并把来源行放在中间隐藏行之前,因此拖拽不会隐藏来源行。真实 Workspace、Ungrouped 与单列表的 Session 显示顺序都保留在浏览器本地;Workspace 分组的拖拽顺序仍由 Host 持久化。
 **最近更新**在分组和单列表视图中都按普通 Session 的最近一次用户提示词或 steering(中途引导)时间降序排列。**手动排序**会冻结当前显示顺序,并在活动变化时保持已有位置;新发现的普通 Session 追加到末尾,多条同时到达时按最近更新时间降序排列。返回最近更新会丢弃所有手动位置;再次进入手动排序时,则冻结当时的最近更新顺序。浏览器默认采用最近更新,并在重新加载后记住所选模式。拖拽普通 Session 会在浏览器本地应用移动并选中手动排序。当前选中的空白**新会话**始终固定在首位且无法拖拽;首条提示词落地后,它成为可拖拽的普通行,在手动排序中保留首位,在最近更新中按当前时间戳排列。折叠分组的拖拽边界按渲染行确定,并把来源行放在中间隐藏行之前,因此拖拽不会隐藏来源行。真实 Workspace、Ungrouped 与单列表的 Session 显示顺序都保留在浏览器本地;Workspace 分组的拖拽顺序仍由 Host 持久化。
 
 
-### 父目录分组
+### 工作区层级
 
 
-选择**添加工作区**并选取目录,确认**按子工作区分组**,即可按路径归组已注册的 Workspace。目录包含已注册 Workspace 时,该选项默认勾选;取消勾选则将目录添加为普通 Workspace。父目录按添加顺序显示;父目录重叠时,每个 Workspace 归入最具体的已选父目录,也包括路径恰好等于父目录的 Workspace。各层级的悬停和选中区域保持整行同宽,仅行内容缩进。项目保留现有 Session 操作,拖拽 Workspace 仅重排同一父目录下的项目。空父目录保持可见并显示说明。父目录路径和独立折叠状态保存在当前浏览器中;旧版偏好默认没有父目录分组。通过父目录行的 **… → 移除分组**菜单仅移除展示分组,选择搜索结果会展开其父目录。单列表视图不使用父目录分组
+选择**添加工作区**并选取目录,即可注册工作区并打开 Session。侧栏会自动将每个 Workspace 放到最近的已注册祖先之下,之后添加的 Workspace 也会自动归入。每个 Workspace 保留自己的 Session 和行操作。祖先默认展开,已有的折叠偏好除外。各层级的高亮和点击区域保持整行同宽,仅内容缩进。拖拽 Workspace 仅重排同级项目。选择搜索结果会展开全部祖先。展开状态保存在当前浏览器中;单列表视图保持平铺
 
 
-父目录分组不会扫描未注册的目录,也不会解析符号链接别名;请选择 Workspace 悬浮卡片所示规范路径的祖先目录。分组不会改变 Session 的工作目录、日志或 Workspace 归属。
+层级仅使用已注册的规范路径,不扫描项目,也不解析符号链接别名。嵌套不会改变 Session 的工作目录、日志或 Workspace 归属。删除父 Workspace 后,子 Workspace 仍保持注册,并归入下一个已注册祖先;没有祖先时显示在根层级。
 
 
 ### 搜索
 ### 搜索
 
 

+ 0 - 34
packages/client/ui-workspace/src/client/WorkspacePicker.module.css

@@ -18,37 +18,3 @@
 .menuStatus {
 .menuStatus {
   color: var(--dsw-alias-label-secondary);
   color: var(--dsw-alias-label-secondary);
 }
 }
-
-.pickedDirectory {
-  overflow-wrap: anywhere;
-  font-size: 14px;
-  line-height: 22px;
-}
-
-.groupChoice {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  margin-top: 16px;
-  font-size: 14px;
-  line-height: 22px;
-  cursor: pointer;
-}
-
-.groupChoice input {
-  accent-color: var(--dsw-alias-state-business-primary);
-}
-
-.groupHelp,
-.groupPreview {
-  margin: 8px 0 0 24px;
-  font-size: 12px;
-  line-height: 20px;
-  color: var(--dsw-alias-label-secondary);
-  overflow-wrap: anywhere;
-}
-
-.groupPreview {
-  padding-left: 12px;
-  border-left: 1px solid var(--dsw-alias-border-l4);
-}

+ 1 - 52
packages/client/ui-workspace/src/client/WorkspacePicker.tsx

@@ -18,7 +18,6 @@ import type {
 } from '@deepseek-ai/dsh-api-workspace-controller/client'
 } from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts'
 import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts'
-import { owningParentFolder } from './tree.ts'
 import css from './WorkspacePicker.module.css'
 import css from './WorkspacePicker.module.css'
 
 
 const ADD_WORKSPACE = '::add-workspace'
 const ADD_WORKSPACE = '::add-workspace'
@@ -39,8 +38,6 @@ export interface WorkspacePickFlowProps {
   useDirectoryFlow: SnapshotSelectorHook<boolean>
   useDirectoryFlow: SnapshotSelectorHook<boolean>
   /** Render this surface's directory-flow hole with the owner conversation (the entry's narrowed renderSlot). */
   /** Render this surface's directory-flow hole with the owner conversation (the entry's narrowed renderSlot). */
   renderDirectoryFlow: (owner: DirectoryFlowOwnerProps) => ReactNode
   renderDirectoryFlow: (owner: DirectoryFlowOwnerProps) => ReactNode
-  /** Offer parent grouping after directory selection; confirmation calls this without creating a Workspace. */
-  onPickParentFolder?: ((path: string) => void) | undefined
   /** A real Workspace was picked or created. */
   /** A real Workspace was picked or created. */
   onPick: (workspaceId: WorkspaceId) => void
   onPick: (workspaceId: WorkspaceId) => void
   /** Close the popover (outside click / Escape / post-pick). */
   /** Close the popover (outside click / Escape / post-pick). */
@@ -67,7 +64,6 @@ export function WorkspacePickFlow({
   useDirectoryFlow,
   useDirectoryFlow,
   renderDirectoryFlow,
   renderDirectoryFlow,
   onPick,
   onPick,
-  onPickParentFolder,
   onClose,
   onClose,
   addOnly = false,
   addOnly = false,
   side = 'bottom',
   side = 'bottom',
@@ -83,16 +79,11 @@ export function WorkspacePickFlow({
   const [modalError, setModalError] = useState<string | null>(null)
   const [modalError, setModalError] = useState<string | null>(null)
   const [flowOpen, setFlowOpen] = useState(false)
   const [flowOpen, setFlowOpen] = useState(false)
   const [pickingFolder, setPickingFolder] = useState(false)
   const [pickingFolder, setPickingFolder] = useState(false)
-  const [pickedDirectory, setPickedDirectory] = useState<string | null>(null)
-  const [groupChildren, setGroupChildren] = useState(false)
   // One picking interaction at a time: while the flow is open (native chooser
   // One picking interaction at a time: while the flow is open (native chooser
   // pending, browse dialog up) or its pick is being adopted, every other
   // pending, browse dialog up) or its pick is being adopted, every other
   // menu action stays disabled — a late outcome must not race a concurrent
   // menu action stays disabled — a late outcome must not race a concurrent
   // selection or adoption.
   // selection or adoption.
-  const flowBusy = flowOpen || pickingFolder || pickedDirectory !== null
-  const childWorkspaces = pickedDirectory === null ? [] : workspaces.filter(
-    workspace => owningParentFolder(workspace.path, [pickedDirectory]) !== undefined,
-  )
+  const flowBusy = flowOpen || pickingFolder
 
 
   // The occupied hole gates the picking affordance: with no composed flow the
   // The occupied hole gates the picking affordance: with no composed flow the
   // entry simply is not there (the seam's documented no-flow default). The
   // entry simply is not there (the seam's documented no-flow default). The
@@ -144,7 +135,6 @@ export function WorkspacePickFlow({
 
 
   const openDirectoryFlow = useCallback((): void => {
   const openDirectoryFlow = useCallback((): void => {
     onClose()
     onClose()
-    setPickedDirectory(null)
     setErrorOpen(false)
     setErrorOpen(false)
     setModalError(null)
     setModalError(null)
     setFlowOpen(true)
     setFlowOpen(true)
@@ -171,12 +161,6 @@ export function WorkspacePickFlow({
     open: flowOpen,
     open: flowOpen,
     busy: pickingFolder,
     busy: pickingFolder,
     onPicked: (path) => {
     onPicked: (path) => {
-      if (onPickParentFolder !== undefined) {
-        setFlowOpen(false)
-        setPickedDirectory(path)
-        setGroupChildren(workspaces.some(workspace => owningParentFolder(workspace.path, [path]) !== undefined))
-        return
-      }
       setPickingFolder(true)
       setPickingFolder(true)
       void adoptDirectory(path).finally(() => { setPickingFolder(false) })
       void adoptDirectory(path).finally(() => { setPickingFolder(false) })
     },
     },
@@ -212,41 +196,6 @@ export function WorkspacePickFlow({
       />
       />
       {open && !addIsTheOnlyEntry && !menuIsEmpty && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">{t('picker.loading')}</div>}
       {open && !addIsTheOnlyEntry && !menuIsEmpty && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">{t('picker.loading')}</div>}
       {renderDirectoryFlow(flowOwner)}
       {renderDirectoryFlow(flowOwner)}
-      <Modal
-        open={pickedDirectory !== null}
-        onClose={() => { setPickedDirectory(null) }}
-        closeLabel={t('close')}
-        title={t('workspace.add')}
-        footer={(
-          <>
-            <Button variant="outline" className={css.modalAction} onClick={() => { setPickedDirectory(null) }}>{t('cancel')}</Button>
-            <Button variant="primary" className={css.modalAction} onClick={() => {
-              if (pickedDirectory === null) return
-              const path = pickedDirectory
-              setPickedDirectory(null)
-              if (groupChildren && onPickParentFolder !== undefined) onPickParentFolder(path)
-              else {
-                setPickingFolder(true)
-                void adoptDirectory(path).finally(() => { setPickingFolder(false) })
-              }
-            }}>{t('picker.add')}</Button>
-          </>
-        )}
-      >
-        <div className={css.pickedDirectory}>{pickedDirectory}</div>
-        <label className={css.groupChoice}>
-          <input type="checkbox" checked={groupChildren} onChange={(event) => { setGroupChildren(event.target.checked) }} />
-          {t('parentFolder.group')}
-        </label>
-        <div className={css.groupHelp}>{t('parentFolder.help')}</div>
-        {groupChildren && (
-          <div className={css.groupPreview}>
-            {childWorkspaces.length === 0 ? t('parentFolder.empty') : childWorkspaces.map(workspace => (
-              <div key={workspace.workspaceId}>{workspace.title}</div>
-            ))}
-          </div>
-        )}
-      </Modal>
       <Modal
       <Modal
         open={errorOpen}
         open={errorOpen}
         onClose={closeModal}
         onClose={closeModal}

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

@@ -7,11 +7,6 @@
 /** Simplified Chinese dictionary (the key-set source of truth). */
 /** Simplified Chinese dictionary (the key-set source of truth). */
 export const zh = {
 export const zh = {
   'group.ungrouped': '未分组',
   'group.ungrouped': '未分组',
-  'parentFolder.group': '按子工作区分组',
-  'parentFolder.help': '显示此目录下已添加的工作区及其会话。',
-  'parentFolder.remove': '移除分组',
-  'picker.add': '添加',
-  'parentFolder.empty': '此目录下没有已添加的工作区',
   'session.new': '新会话',
   'session.new': '新会话',
   'section.workspaces': '工作区',
   'section.workspaces': '工作区',
   'section.sessions': '会话',
   'section.sessions': '会话',
@@ -82,11 +77,6 @@ export type WorkspaceKey = keyof typeof zh
 /** English dictionary, checked complete against the zh key set. */
 /** English dictionary, checked complete against the zh key set. */
 export const en = {
 export const en = {
   'group.ungrouped': 'Ungrouped',
   'group.ungrouped': 'Ungrouped',
-  'parentFolder.group': 'Group by child workspaces',
-  'parentFolder.help': 'Show added workspaces in this directory and their sessions.',
-  'parentFolder.remove': 'Remove group',
-  'picker.add': 'Add',
-  'parentFolder.empty': 'No added workspaces in this folder',
   'session.new': 'New Session',
   'session.new': 'New Session',
   'section.workspaces': 'Workspaces',
   'section.workspaces': 'Workspaces',
   'section.sessions': 'Sessions',
   'section.sessions': 'Sessions',

+ 0 - 22
packages/client/ui-workspace/src/client/rows/Rows.module.css

@@ -370,25 +370,3 @@
     transition: none;
     transition: none;
   }
   }
 }
 }
-
-.parentToggle {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-  flex: 1;
-  min-width: 0;
-  height: 100%;
-  padding: 0;
-  border: 0;
-  background: transparent;
-  color: inherit;
-  font: inherit;
-  text-align: left;
-  cursor: pointer;
-}
-
-.parentToggle:focus-visible {
-  outline: 2px solid var(--dsw-alias-label-primary);
-  outline-offset: -2px;
-  border-radius: 4px;
-}

+ 0 - 48
packages/client/ui-workspace/src/client/rows/Rows.tsx

@@ -512,51 +512,3 @@ export function SessionNodeItem({
     />
     />
   )
   )
 }
 }
-
-/**
- * Browser-local parent directory control; removal only changes the sidebar view.
- * @param props - selected path, expansion, host home, locale, and view actions.
- * @returns a folder row using the Workspace row styling.
- */
-export function ParentFolderRow({ path, expanded, home, onToggle, onRemove, t }: {
-  path: string
-  expanded: boolean
-  home: string | undefined
-  onToggle: () => void
-  onRemove: () => void
-  t: RowTranslate
-}) {
-  const label = abbreviateHomePath(path, home)
-  const [menuOpen, setMenuOpen] = useState(false)
-  return (
-    <div className={clsx(css.projectRow, menuOpen && css.menuOpen)}>
-      <button type="button" className={css.parentToggle} aria-expanded={expanded} onClick={onToggle} title={path}>
-        <span className={clsx(css.slot, css.folder)}>
-          {expanded ? <IconFolderOpen16 /> : <IconFolderClose16 />}
-        </span>
-        <span className={clsx(css.slot, css.chevron)}>
-          <IconTriangleRightFill14 className={clsx(css.arrow, expanded && css.arrowOpen)} />
-        </span>
-        <span className={css.title}>{label}</span>
-      </button>
-      <span className={css.rowActions}>
-        <Menu
-          open={menuOpen}
-          onClose={() => { setMenuOpen(false) }}
-          items={[{ id: 'remove', label: t('parentFolder.remove') }]}
-          onSelect={() => { setMenuOpen(false); onRemove() }}
-          portal
-          anchor={(
-            <button
-              type="button" className={css.iconButton}
-              aria-label={t('actions.workspace.aria', { name: label })}
-              onClick={() => { setMenuOpen(value => !value) }}
-            >
-              <IconEllipsisOutline16 />
-            </button>
-          )}
-        />
-      </span>
-    </div>
-  )
-}

+ 0 - 1
packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css

@@ -511,6 +511,5 @@
 }
 }
 
 
 .parentChildren {
 .parentChildren {
-  --dsh-workspace-indent: 12px;
   min-width: 0;
   min-width: 0;
 }
 }

+ 39 - 59
packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx

@@ -9,7 +9,7 @@
  * menu in between; the flow and its error dialog live in WorkspacePicker
  * menu in between; the flow and its error dialog live in WorkspacePicker
  * (same package — direct composition, no slot between them).
  * (same package — direct composition, no slot between them).
  */
  */
-import { useEffect, useMemo, useRef, useState } from 'react'
+import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
 import clsx from 'clsx'
 import clsx from 'clsx'
 import {
 import {
   Button, IconCloseFill14, IconPersonalizationOutline16,
   Button, IconCloseFill14, IconPersonalizationOutline16,
@@ -26,7 +26,7 @@ import {
   deriveFlat, deriveGroups, deriveSearchResults, orderByRecency, owningGroupKey, owningParentFolder,
   deriveFlat, deriveGroups, deriveSearchResults, orderByRecency, owningGroupKey, owningParentFolder,
   pinCurrentBlank, reconcileManualOrder, UNGROUPED_KEY, visibleSessionIds,
   pinCurrentBlank, reconcileManualOrder, UNGROUPED_KEY, visibleSessionIds,
 } from '../tree.ts'
 } from '../tree.ts'
-import { ParentFolderRow, ProjectRowItem, SearchResultItem, SessionNodeItem } from './Rows.tsx'
+import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './Rows.tsx'
 import { FLAT_SESSION_ORDER_KEY } from '../stores.ts'
 import { FLAT_SESSION_ORDER_KEY } from '../stores.ts'
 import { WorkspacePickFlow } from '../WorkspacePicker.tsx'
 import { WorkspacePickFlow } from '../WorkspacePicker.tsx'
 import css from './WorkspaceBrowser.module.css'
 import css from './WorkspaceBrowser.module.css'
@@ -42,7 +42,6 @@ const SEARCH_DEBOUNCE_MS = 250
 const SEARCH_QUERY_MAX_CODE_UNITS = 500
 const SEARCH_QUERY_MAX_CODE_UNITS = 500
 /** Session rows visible per Workspace before the local overflow control. */
 /** Session rows visible per Workspace before the local overflow control. */
 const COLLAPSED_SESSION_LIMIT = 5
 const COLLAPSED_SESSION_LIMIT = 5
-const NO_PARENT_FOLDERS: Readonly<Record<string, boolean>> = {}
 
 
 /** Fold one Workspace without charging its provisional New Session against the ordinary-row limit. */
 /** Fold one Workspace without charging its provisional New Session against the ordinary-row limit. */
 function collapsedSessionRows(sessions: readonly SessionNode[]): {
 function collapsedSessionRows(sessions: readonly SessionNode[]): {
@@ -172,10 +171,6 @@ type SessionTreeProps = Pick<
   'useSessionPendingInteraction' | 'startSession' | 'open' | 'forkSession'
   'useSessionPendingInteraction' | 'startSession' | 'open' | 'forkSession'
   | 'insertWorkspaceBefore' | 't' | 'usePanelInfo'
   | 'insertWorkspaceBefore' | 't' | 'usePanelInfo'
 > & {
 > & {
-  /** Browser-local parent paths and expansion, independent of Workspace membership. */
-  parentFolders: Readonly<Record<string, boolean>>
-  setParentFolder: (path: string, expanded: boolean) => void
-  removeParentFolder: (path: string) => void
   /** Always-mounted Session list snapshot. */
   /** Always-mounted Session list snapshot. */
   list: SessionListState
   list: SessionListState
   /** Host account home for POSIX hover-path abbreviation. */
   /** Host account home for POSIX hover-path abbreviation. */
@@ -211,7 +206,7 @@ type SessionTreeProps = Pick<
 /** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */
 /** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */
 function SessionTree({
 function SessionTree({
   list, useSessionPendingInteraction, startSession, open, forkSession, workspaces, ungroupedSessionIds,
   list, useSessionPendingInteraction, startSession, open, forkSession, workspaces, ungroupedSessionIds,
-  archivedSessionIds, parentFolders, setParentFolder, removeParentFolder,
+  archivedSessionIds,
   workspaceReady, usePanelInfo,
   workspaceReady, usePanelInfo,
   onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
   onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
   insertWorkspaceBefore,
   insertWorkspaceBefore,
@@ -240,10 +235,19 @@ function SessionTree({
     if (current === undefined || currentGroup === undefined || Object.hasOwn(groupExpansion, currentGroup)) return
     if (current === undefined || currentGroup === undefined || Object.hasOwn(groupExpansion, currentGroup)) return
     setGroupExpanded(currentGroup, true)
     setGroupExpanded(currentGroup, true)
   }, [current, currentGroup, setGroupExpanded, groupExpansion])
   }, [current, currentGroup, setGroupExpanded, groupExpansion])
-  const expandedGroups = useMemo(
-    () => Object.entries(groupExpansion).filter(([, expanded]) => expanded).map(([key]) => key),
-    [groupExpansion],
-  )
+  const parents = useMemo(() => {
+    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])
+  const expandedGroups = useMemo(() => {
+    const ancestorKeys = new Set<string | undefined>(parents.values())
+    return [...workspaces.map(workspace => workspace.workspaceId), UNGROUPED_KEY]
+      .filter(key => groupExpansion[key] ?? ancestorKeys.has(key))
+  }, [groupExpansion, parents, workspaces])
   const groups = useMemo(
   const groups = useMemo(
     () => deriveGroups(list, workspaces, archivedSessionIds, pendingInteractions, {
     () => deriveGroups(list, workspaces, archivedSessionIds, pendingInteractions, {
       expandedGroups,
       expandedGroups,
@@ -252,9 +256,10 @@ function SessionTree({
     [list, workspaces, archivedSessionIds, pendingInteractions, expandedGroups, ungroupedSessionIds],
     [list, workspaces, archivedSessionIds, pendingInteractions, expandedGroups, ungroupedSessionIds],
   )
   )
   useEffect(() => {
   useEffect(() => {
-    if (revealGroup === undefined || groupExpansion[revealGroup] === true) return
-    setGroupExpanded(revealGroup, true)
-  }, [groupExpansion, revealGroup, setGroupExpanded])
+    for (let key = revealGroup; key !== undefined; key = parents.get(key)) {
+      if (groupExpansion[key] !== true) setGroupExpanded(key, true)
+    }
+  }, [groupExpansion, parents, revealGroup, setGroupExpanded])
   useEffect(() => {
   useEffect(() => {
     if (revealSessionId === undefined || revealGroup === undefined) return
     if (revealSessionId === undefined || revealGroup === undefined) return
     const group = groups.find(candidate => candidate.key === revealGroup)
     const group = groups.find(candidate => candidate.key === revealGroup)
@@ -262,12 +267,6 @@ function SessionTree({
     if (collapsedSessionRows(group.sessions).rows.some(row => row.id === revealSessionId)) return
     if (collapsedSessionRows(group.sessions).rows.some(row => row.id === revealSessionId)) return
     setExpandedSessionGroups(keys => keys.includes(revealGroup) ? keys : [...keys, revealGroup])
     setExpandedSessionGroups(keys => keys.includes(revealGroup) ? keys : [...keys, revealGroup])
   }, [groups, revealGroup, revealSessionId])
   }, [groups, revealGroup, revealSessionId])
-  const parentPaths = useMemo(() => Object.keys(parentFolders), [parentFolders])
-  const parentOf = (group: GroupNode): string | undefined => owningParentFolder(group.cwd, parentPaths)
-  const revealParent = owningParentFolder(groups.find(group => group.key === revealGroup)?.cwd, parentPaths)
-  useEffect(() => {
-    if (revealParent !== undefined && parentFolders[revealParent] !== true) setParentFolder(revealParent, true)
-  }, [revealParent, parentFolders, setParentFolder])
   const now = Date.now()
   const now = Date.now()
   const commitSessionDrag = (activeDrag: DragState, over: NonNullable<DragState['over']>): void => {
   const commitSessionDrag = (activeDrag: DragState, over: NonNullable<DragState['over']>): void => {
     if (sessionDropCommitted.current) return
     if (sessionDropCommitted.current) return
@@ -325,9 +324,8 @@ function SessionTree({
     if (workspaceDropCommitted.current) return
     if (workspaceDropCommitted.current) return
     workspaceDropCommitted.current = true
     workspaceDropCommitted.current = true
     setWorkspaceDrag(null)
     setWorkspaceDrag(null)
-    const source = workspaces.find(workspace => workspace.workspaceId === activeDrag.workspaceId)
-    const owner = owningParentFolder(source?.path, parentPaths)
-    const siblings = workspaces.filter(workspace => owningParentFolder(workspace.path, parentPaths) === owner)
+    const owner = parents.get(activeDrag.workspaceId)
+    const siblings = workspaces.filter(workspace => parents.get(workspace.workspaceId) === owner)
     const rowIndex = siblings.findIndex(workspace => workspace.workspaceId === over.id)
     const rowIndex = siblings.findIndex(workspace => workspace.workspaceId === over.id)
     if (rowIndex === -1) return
     if (rowIndex === -1) return
     const anchor = over.half === 'before' ? over.id : siblings[rowIndex + 1]?.workspaceId
     const anchor = over.half === 'before' ? over.id : siblings[rowIndex + 1]?.workspaceId
@@ -341,17 +339,14 @@ function SessionTree({
       console.warn('workspace reorder rejected:', reason)
       console.warn('workspace reorder rejected:', reason)
     })
     })
   }
   }
-  const workspaceDropAtListStart = parentPaths.length === 0 && groups[0]?.workspaceId !== undefined
-    && workspaceDrag?.over?.id === groups[0].workspaceId
+  const rootGroups = groups.filter(group => parents.get(group.key) === undefined)
+  const workspaceDropAtListStart = rootGroups[0]?.workspaceId !== undefined
+    && workspaceDrag?.over?.id === rootGroups[0].workspaceId
     && workspaceDrag.over.half === 'before'
     && workspaceDrag.over.half === 'before'
 
 
-  const draggedWorkspace = workspaceDrag === null
-    ? undefined
-    : workspaces.find(workspace => workspace.workspaceId === workspaceDrag.workspaceId)
-  const draggedParent = owningParentFolder(draggedWorkspace?.path, parentPaths)
-  const renderGroup = (group: GroupNode) => {
+  const renderGroup = (group: GroupNode, depth: number): ReactNode => {
     const workspaceId = group.workspaceId
     const workspaceId = group.workspaceId
-    const compatibleDrag = workspaceDrag !== null && draggedParent === parentOf(group)
+    const compatibleDrag = workspaceDrag !== null && parents.get(workspaceDrag.workspaceId) === parents.get(group.key)
     const collapsed = collapsedSessionRows(group.sessions)
     const collapsed = collapsedSessionRows(group.sessions)
     const sessionsExpanded = expandedSessionGroups.includes(group.key)
     const sessionsExpanded = expandedSessionGroups.includes(group.key)
     const workspaceMarker = workspaceId !== undefined && workspaceDrag?.over?.id === workspaceId
     const workspaceMarker = workspaceId !== undefined && workspaceDrag?.over?.id === workspaceId
@@ -389,6 +384,7 @@ function SessionTree({
     // (WorkspaceBrowser.module.css).
     // (WorkspaceBrowser.module.css).
       <div
       <div
         key={group.key}
         key={group.key}
+        style={{ '--dsh-workspace-indent': `${depth * 12}px` } as CSSProperties}
         className={clsx(
         className={clsx(
           css.groupSection,
           css.groupSection,
           workspaceMarker === 'before' && css.workspaceDropBefore,
           workspaceMarker === 'before' && css.workspaceDropBefore,
@@ -398,6 +394,7 @@ function SessionTree({
           ? undefined
           ? undefined
           : (e) => {
           : (e) => {
             e.preventDefault()
             e.preventDefault()
+            e.stopPropagation()
             if (hoverWorkspace === undefined) {
             if (hoverWorkspace === undefined) {
               e.dataTransfer.dropEffect = 'none'
               e.dataTransfer.dropEffect = 'none'
               setWorkspaceDrag({ ...workspaceDrag, over: null })
               setWorkspaceDrag({ ...workspaceDrag, over: null })
@@ -410,6 +407,7 @@ function SessionTree({
           ? undefined
           ? undefined
           : (e) => {
           : (e) => {
             e.preventDefault()
             e.preventDefault()
+            e.stopPropagation()
             if (dropWorkspace === undefined) {
             if (dropWorkspace === undefined) {
               workspaceDropCommitted.current = true
               workspaceDropCommitted.current = true
               setWorkspaceDrag(null)
               setWorkspaceDrag(null)
@@ -448,6 +446,11 @@ function SessionTree({
               },
               },
             }}
             }}
         />
         />
+        {group.expanded && groups.some(child => parents.get(child.key) === group.key) && (
+          <div role="group" className={css.parentChildren}>
+            {groups.filter(child => parents.get(child.key) === group.key).map(child => renderGroup(child, depth + 1))}
+          </div>
+        )}
         {(sessionsExpanded
         {(sessionsExpanded
           ? group.sessions
           ? group.sessions
           : collapsed.rows
           : collapsed.rows
@@ -522,25 +525,10 @@ function SessionTree({
         role="tree"
         role="tree"
         aria-label={t('section.sessions')}
         aria-label={t('section.sessions')}
       >
       >
-        {groups.length === 0 && parentPaths.length === 0 && (
+        {groups.length === 0 && (
           <div className={css.empty}>{t('empty.none')}</div>
           <div className={css.empty}>{t('empty.none')}</div>
         )}
         )}
-        {parentPaths.map(path => (
-          <div key={path} role="treeitem" aria-label={path} aria-expanded={parentFolders[path]}>
-            <ParentFolderRow
-              path={path} expanded={parentFolders[path] === true} home={home} t={t}
-              onToggle={() => { setParentFolder(path, parentFolders[path] !== true) }}
-              onRemove={() => { removeParentFolder(path) }}
-            />
-            {parentFolders[path] === true && (
-              <div role="group" className={css.parentChildren}>
-                {groups.filter(group => parentOf(group) === path).map(renderGroup)}
-                {!groups.some(group => parentOf(group) === path) && <div className={css.empty}>{t('parentFolder.empty')}</div>}
-              </div>
-            )}
-          </div>
-        ))}
-        {groups.filter(group => parentOf(group) === undefined).map(renderGroup)}
+        {rootGroups.map(group => renderGroup(group, 0))}
       </div>
       </div>
       <span className={css.fade} />
       <span className={css.fade} />
     </div>
     </div>
@@ -776,7 +764,6 @@ export function WorkspaceBrowser({
   const groupBy = useStore(s => s.groupBy)
   const groupBy = useStore(s => s.groupBy)
   const orderBy = useStore(s => s.orderBy)
   const orderBy = useStore(s => s.orderBy)
   const groupExpansion = useStore(s => s.groupExpansion)
   const groupExpansion = useStore(s => s.groupExpansion)
-  const parentFolders = useStore(s => s.parentFolders ?? NO_PARENT_FOLDERS)
   const sessionOrderByAccount = useStore(s => s.sessionOrderByAccount)
   const sessionOrderByAccount = useStore(s => s.sessionOrderByAccount)
   const workspaceReady = workspacePhase === 'ready' && workspaceStreamState !== 'loading'
   const workspaceReady = workspacePhase === 'ready' && workspaceStreamState !== 'loading'
   const currentBlank = list.current !== undefined && list.byId[list.current]?.blank === true
   const currentBlank = list.current !== undefined && list.byId[list.current]?.blank === true
@@ -822,7 +809,7 @@ export function WorkspaceBrowser({
     )
     )
   }, [currentBlank, flatMemberIds, list.byId, orderBy, sessionOrderByAccount])
   }, [currentBlank, flatMemberIds, list.byId, orderBy, sessionOrderByAccount])
   const activeSessionOrders = useMemo<Readonly<Record<string, readonly string[]>>>(() => Object.fromEntries([
   const activeSessionOrders = useMemo<Readonly<Record<string, readonly string[]>>>(() => Object.fromEntries([
-    ...orderedWorkspaces.map(workspace => [workspace.workspaceId as string, workspace.sessionIds] as const),
+    ...orderedWorkspaces.map(workspace => [workspace.workspaceId, workspace.sessionIds] as const),
     [UNGROUPED_KEY, orderedUngroupedSessionIds] as const,
     [UNGROUPED_KEY, orderedUngroupedSessionIds] as const,
     [FLAT_SESSION_ORDER_KEY, orderedFlatSessionIds] as const,
     [FLAT_SESSION_ORDER_KEY, orderedFlatSessionIds] as const,
   ]), [orderedFlatSessionIds, orderedUngroupedSessionIds, orderedWorkspaces])
   ]), [orderedFlatSessionIds, orderedUngroupedSessionIds, orderedWorkspaces])
@@ -831,7 +818,7 @@ export function WorkspaceBrowser({
     actions.retainAccountKeys([
     actions.retainAccountKeys([
       UNGROUPED_KEY,
       UNGROUPED_KEY,
       FLAT_SESSION_ORDER_KEY,
       FLAT_SESSION_ORDER_KEY,
-      ...workspaces.map(workspace => workspace.workspaceId as string),
+      ...workspaces.map(workspace => workspace.workspaceId),
     ])
     ])
   }, [actions.retainAccountKeys, workspacePhase, workspaces])
   }, [actions.retainAccountKeys, workspacePhase, workspaces])
   useEffect(() => {
   useEffect(() => {
@@ -1189,10 +1176,6 @@ export function WorkspaceBrowser({
           useDirectoryFlow={useDirectoryFlow}
           useDirectoryFlow={useDirectoryFlow}
           renderDirectoryFlow={owner => renderSlot('sidebar.workspaces.directoryFlow', owner)}
           renderDirectoryFlow={owner => renderSlot('sidebar.workspaces.directoryFlow', owner)}
           addOnly
           addOnly
-          onPickParentFolder={(path) => {
-            actions.setParentFolder(path, true)
-            actions.setGroupBy('workspace')
-          }}
           side="right"
           side="right"
           onPick={(workspaceId) => {
           onPick={(workspaceId) => {
             setWsPickerOpen(false)
             setWsPickerOpen(false)
@@ -1264,9 +1247,6 @@ export function WorkspaceBrowser({
                 workspaces={orderedWorkspaces}
                 workspaces={orderedWorkspaces}
                 ungroupedSessionIds={orderedUngroupedSessionIds}
                 ungroupedSessionIds={orderedUngroupedSessionIds}
                 workspaceReady={workspaceReady}
                 workspaceReady={workspaceReady}
-                parentFolders={parentFolders}
-                setParentFolder={actions.setParentFolder}
-                removeParentFolder={actions.removeParentFolder}
                 groupExpansion={groupExpansion}
                 groupExpansion={groupExpansion}
                 setGroupExpanded={actions.setGroupExpanded}
                 setGroupExpanded={actions.setGroupExpanded}
                 setSessionOrder={saveSessionOrder}
                 setSessionOrder={saveSessionOrder}

+ 0 - 6
packages/client/ui-workspace/src/client/stores.ts

@@ -18,8 +18,6 @@ export type SessionOrderBy = 'manual' | 'updated'
 /** Workspace browser viewing state persisted across surface remounts and reloads. */
 /** Workspace browser viewing state persisted across surface remounts and reloads. */
 type WorkspaceViewState = {
 type WorkspaceViewState = {
   groupBy: SessionGroupBy
   groupBy: SessionGroupBy
-  /** Selected parent paths and expansion; absent in older browser preferences. */
-  parentFolders?: Record<string, boolean>
   orderBy: SessionOrderBy
   orderBy: SessionOrderBy
   /** Explicit zero-or-five-session state keyed by Workspace group identity. */
   /** Explicit zero-or-five-session state keyed by Workspace group identity. */
   groupExpansion: Record<string, boolean>
   groupExpansion: Record<string, boolean>
@@ -32,8 +30,6 @@ type WorkspaceViewState = {
  * return type); drift fails assignability at the defineStore call.
  * return type); drift fails assignability at the defineStore call.
  */
  */
 type WorkspaceViewActions = {
 type WorkspaceViewActions = {
-  setParentFolder: (draft: WorkspaceViewState, path: string, expanded: boolean) => void
-  removeParentFolder: (draft: WorkspaceViewState, path: string) => void
   setGroupBy: (draft: WorkspaceViewState, mode: SessionGroupBy) => void
   setGroupBy: (draft: WorkspaceViewState, mode: SessionGroupBy) => void
   setOrderBy: (
   setOrderBy: (
     draft: WorkspaceViewState,
     draft: WorkspaceViewState,
@@ -75,8 +71,6 @@ export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState
     }),
     }),
     persist: 'dsh.workspace.view.v5',
     persist: 'dsh.workspace.view.v5',
     actions: {
     actions: {
-      setParentFolder: (d, path, expanded) => { (d.parentFolders ??= {})[path] = expanded },
-      removeParentFolder: (d, path) => { delete d.parentFolders?.[path] },
       setGroupBy: (d, mode: SessionGroupBy) => { d.groupBy = mode },
       setGroupBy: (d, mode: SessionGroupBy) => { d.groupBy = mode },
       setOrderBy: (d, mode: SessionOrderBy, initialOrders) => {
       setOrderBy: (d, mode: SessionOrderBy, initialOrders) => {
         if (mode === d.orderBy) return
         if (mode === d.orderBy) return

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

@@ -506,10 +506,10 @@ function folderPath(path: string): string {
 }
 }
 
 
 /**
 /**
- * Find the most specific selected parent, including a Workspace at that directory.
+ * Find the nearest registered ancestor, excluding the Workspace directory itself.
  * Paths use Host spelling; matching is case-sensitive, like Workspace identity.
  * Paths use Host spelling; matching is case-sensitive, like Workspace identity.
  * @param path - Workspace directory; absent for Ungrouped.
  * @param path - Workspace directory; absent for Ungrouped.
- * @param parents - selected absolute directory paths.
+ * @param parents - registered Workspace directory paths.
  * @returns the owning parent path, or undefined when no parent contains the Workspace.
  * @returns the owning parent path, or undefined when no parent contains the Workspace.
  */
  */
 export function owningParentFolder(path: string | undefined, parents: readonly string[]): string | undefined {
 export function owningParentFolder(path: string | undefined, parents: readonly string[]): string | undefined {
@@ -519,7 +519,7 @@ export function owningParentFolder(path: string | undefined, parents: readonly s
   let length = -1
   let length = -1
   for (const parent of parents) {
   for (const parent of parents) {
     const root = folderPath(parent)
     const root = folderPath(parent)
-    if (root.length > length && (child === root || child.startsWith(`${root}/`))) {
+    if (root.length > length && child !== root && child.startsWith(`${root}/`)) {
       owner = parent
       owner = parent
       length = root.length
       length = root.length
     }
     }

+ 2 - 2
packages/client/ui-workspace/tests/tree.client.spec.ts

@@ -591,7 +591,7 @@ describe('workspaceLabel', () => {
 describe('parent folder membership', () => {
 describe('parent folder membership', () => {
   it.each([
   it.each([
     ['/git/app', ['/git'], '/git'],
     ['/git/app', ['/git'], '/git'],
-    ['/git', ['/git/'], '/git/'],
+    ['/git', ['/git/'], undefined],
     ['/git-other/app', ['/git'], undefined],
     ['/git-other/app', ['/git'], undefined],
     ['/git/team/app', ['/git', '/git/team'], '/git/team'],
     ['/git/team/app', ['/git', '/git/team'], '/git/team'],
     ['/git/team/app', ['/git/team', '/git'], '/git/team'],
     ['/git/team/app', ['/git/team', '/git'], '/git/team'],
@@ -602,7 +602,7 @@ describe('parent folder membership', () => {
     [String.raw`\\server\share\app`, [String.raw`\\server\share`], String.raw`\\server\share`],
     [String.raw`\\server\share\app`, [String.raw`\\server\share`], String.raw`\\server\share`],
     [String.raw`/git/a\b`, ['/git/a'], undefined],
     [String.raw`/git/a\b`, ['/git/a'], undefined],
     ['/Git/app', ['/git'], undefined],
     ['/Git/app', ['/git'], undefined],
-  ])('groups %s under its most specific selected parent', (path, parents, expected) => {
+  ])('groups %s under its nearest registered ancestor', (path, parents, expected) => {
     expect(owningParentFolder(path, parents)).toBe(expected)
     expect(owningParentFolder(path, parents)).toBe(expected)
   })
   })
 })
 })

+ 50 - 78
packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx

@@ -387,7 +387,7 @@ describe('WorkspaceBrowser', () => {
 
 
     fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
     fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
     expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label
     expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label
-    expect(screen.getAllByRole('separator')).toHaveLength(1)
+    expect(screen.getByRole('separator')).toBeTruthy()
     expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
     expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
       '按工作区', '单列表', '手动排序', '最近更新',
       '按工作区', '单列表', '手动排序', '最近更新',
     ])
     ])
@@ -877,7 +877,7 @@ describe('WorkspaceBrowser', () => {
   })
   })
 
 
   it('opens a Host content hit, exits search, and reveals its hidden grouped row', async () => {
   it('opens a Host content hit, exits search, and reveals its hidden grouped row', async () => {
-    createWorkspaceViewStore().create().actions.setParentFolder('/projects', false)
+    createWorkspaceViewStore().create().actions.setGroupExpanded('root', false)
     vi.useFakeTimers()
     vi.useFakeTimers()
     try {
     try {
       const open = vi.fn()
       const open = vi.fn()
@@ -895,6 +895,7 @@ describe('WorkspaceBrowser', () => {
           summary('body-hit', 1, { displayTitle: 'Research notes' }),
           summary('body-hit', 1, { displayTitle: 'Research notes' }),
         ])),
         ])),
         useWorkspaces: hook(workspaceState([
         useWorkspaces: hook(workspaceState([
+          { ...workspace('root', []), path: '/projects' },
           workspace('research', [
           workspace('research', [
             'newest-1', 'newest-2', 'newest-3', 'newest-4', 'newest-5', 'body-hit',
             'newest-1', 'newest-2', 'newest-3', 'newest-4', 'newest-5', 'body-hit',
           ], 'Research Workspace'),
           ], 'Research Workspace'),
@@ -919,7 +920,7 @@ describe('WorkspaceBrowser', () => {
       expect(input.value).toBe('')
       expect(input.value).toBe('')
       expect(screen.queryByRole('tree', { name: '搜索结果' })).toBeNull()
       expect(screen.queryByRole('tree', { name: '搜索结果' })).toBeNull()
       expect(screen.getByRole('tree', { name: '会话' })).toBeTruthy()
       expect(screen.getByRole('tree', { name: '会话' })).toBeTruthy()
-      expect(b.store.getSnapshot().groupExpansion).toEqual({ research: true })
+      expect(b.store.getSnapshot().groupExpansion).toEqual({ root: true, research: true })
       const targetRow = screen.getByText('Research notes').closest('[role="treeitem"]')
       const targetRow = screen.getByText('Research notes').closest('[role="treeitem"]')
       expect(targetRow).toBeTruthy()
       expect(targetRow).toBeTruthy()
       expect(screen.getByRole('button', { name: '收起' })).toBeTruthy()
       expect(screen.getByRole('button', { name: '收起' })).toBeTruthy()
@@ -1642,99 +1643,69 @@ describe('WorkspaceBrowser', () => {
 })
 })
 
 
 
 
-describe('parent folders', () => {
-  it('cancels a picked path and can add it as a normal Workspace instead of a group', async () => {
+describe('automatic Workspace hierarchy', () => {
+  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' }
+  const section = (title: string) => screen.getByText(title).closest<HTMLElement>('[class*="groupSection"]')!
+
+  it('adopts a parent directory and opens its Session without confirmation', async () => {
     const b = mount({
     const b = mount({
-      useWorkspaces: hook(workspaceState([workspace('alpha', [])])),
-      createWorkspace: vi.fn(async () => workspace('projects', [])),
+      useWorkspaces: hook(workspaceState([child])),
+      createWorkspace: vi.fn(async () => root),
       renderSlot: ((_name: string, owner: DirectoryFlowOwnerProps) => owner.open
       renderSlot: ((_name: string, owner: DirectoryFlowOwnerProps) => owner.open
         ? <button onClick={() => { owner.onPicked('/projects') }}>Pick directory</button> : null) as WorkspaceBrowserProps['renderSlot'],
         ? <button onClick={() => { owner.onPicked('/projects') }}>Pick directory</button> : null) as WorkspaceBrowserProps['renderSlot'],
     })
     })
     fireEvent.click(screen.getByRole('button', { name: '添加工作区' }))
     fireEvent.click(screen.getByRole('button', { name: '添加工作区' }))
     fireEvent.click(screen.getByRole('button', { name: 'Pick directory' }))
     fireEvent.click(screen.getByRole('button', { name: 'Pick directory' }))
-    fireEvent.click(screen.getByRole('button', { name: '取消' }))
-    expect(b.props.createWorkspace).not.toHaveBeenCalled()
-    expect(b.props.startSession).not.toHaveBeenCalled()
-    expect(b.store.getSnapshot().parentFolders).toBeUndefined()
-    fireEvent.click(screen.getByRole('button', { name: '添加工作区' }))
-    fireEvent.click(screen.getByRole('button', { name: 'Pick directory' }))
-    fireEvent.click(screen.getByRole('checkbox', { name: '按子工作区分组' }))
-    fireEvent.click(screen.getByRole('button', { name: '添加' }))
-    await waitFor(() => { expect(b.props.startSession).toHaveBeenCalledWith(wid('projects')) })
+    await waitFor(() => { expect(b.props.startSession).toHaveBeenCalledWith(wid('root')) })
     expect(b.props.createWorkspace).toHaveBeenCalledWith({ path: '/projects' })
     expect(b.props.createWorkspace).toHaveBeenCalledWith({ path: '/projects' })
-    expect(b.store.getSnapshot().parentFolders).toBeUndefined()
+    expect(screen.queryByRole('dialog')).toBeNull()
+    rerender(b, { useWorkspaces: hook(workspaceState([child, root])) })
+    expect(within(section('Projects')).getByText('Child')).toBeTruthy()
   })
   })
 
 
-  it('defaults an empty directory to Workspace adoption but allows an empty parent group', () => {
+  it('nests newly registered ancestors and children while retaining each Workspace session', () => {
     const b = mount({
     const b = mount({
-      renderSlot: ((_name: string, owner: DirectoryFlowOwnerProps) => owner.open
-        ? <button onClick={() => { owner.onPicked('/empty') }}>Pick directory</button> : null) as WorkspaceBrowserProps['renderSlot'],
-    })
-    fireEvent.click(screen.getByRole('button', { name: '添加工作区' }))
-    fireEvent.click(screen.getByRole('button', { name: 'Pick directory' }))
-    const choice = screen.getByRole('checkbox', { name: '按子工作区分组' }) as HTMLInputElement
-    expect(choice.checked).toBe(false)
-    fireEvent.click(choice)
-    fireEvent.click(screen.getByRole('button', { name: '添加' }))
-    expect(screen.getByRole('treeitem', { name: '/empty' })).toBeTruthy()
-    expect(b.props.createWorkspace).not.toHaveBeenCalled()
+      useSessions: hook(sessionState([summary('root-session', 1), summary('team-session', 2), summary('child-session', 3)])),
+      useWorkspaces: hook(workspaceState([child, root])),
+    })
+    expect(within(section('Projects')).getByText('Child')).toBeTruthy()
+    rerender(b, { useWorkspaces: hook(workspaceState([child, team, root])) })
+    expect(within(section('Projects')).getByText('Team')).toBeTruthy()
+    expect(within(section('Team')).getByText('Child')).toBeTruthy()
+    expect(within(section('Team')).queryByText('root-session')).toBeNull()
+    expect(within(section('Projects')).getByText('root-session')).toBeTruthy()
+    expect(within(section('Team')).getByText('team-session')).toBeTruthy()
+    expect(screen.getAllByText('Child')).toHaveLength(1)
+    fireEvent.click(screen.getByText('Child'))
+    expect(within(section('Child')).getByText('child-session')).toBeTruthy()
+    fireEvent.click(screen.getByRole('button', { name: '在“Projects”中新建会话' }))
+    expect(b.props.startSession).toHaveBeenCalledWith(root.workspaceId)
+    rerender(b, { useWorkspaces: hook(workspaceState([child, root])) })
+    expect(screen.queryByText('Team')).toBeNull()
+    expect(within(section('Projects')).getByText('Child')).toBeTruthy()
   })
   })
 
 
-  it('adds a parent through the picker without creating a Workspace or Session, and restores it after reload', () => {
-    const b = mount({
-      useWorkspaces: hook(workspaceState([workspace('alpha', ['first'])])),
-      useSessions: hook(sessionState([summary('first', 1)])),
-      renderSlot: ((_name: string, owner: DirectoryFlowOwnerProps) => owner.open
-        ? <button onClick={() => { owner.onPicked('/projects') }}>Pick parent</button> : null) as WorkspaceBrowserProps['renderSlot'],
-    })
-    fireEvent.click(screen.getByRole('button', { name: '添加工作区' }))
-    fireEvent.click(screen.getByRole('button', { name: 'Pick parent' }))
-    expect(screen.getByRole('checkbox', { name: '按子工作区分组' }).getAttribute('checked')).not.toBeNull()
-    expect(b.props.createWorkspace).not.toHaveBeenCalled()
-    fireEvent.click(screen.getByRole('button', { name: '添加' }))
-    expect(b.props.createWorkspace).not.toHaveBeenCalled()
-    expect(b.props.startSession).not.toHaveBeenCalled()
-    const parent = screen.getByRole('treeitem', { name: '/projects' })
-    expect(within(parent).getByText('alpha')).toBeTruthy()
-    fireEvent.click(within(parent).getByRole('button', { name: '/projects' }))
-    expect(screen.queryByText('alpha')).toBeNull()
+  it('restores collapsed ancestors and keeps the flat view independent', () => {
+    const b = mount({ useWorkspaces: hook(workspaceState([root, team, child])) })
+    fireEvent.click(screen.getByText('Projects'))
+    expect(screen.queryByText('Team')).toBeNull()
     b.view.unmount()
     b.view.unmount()
-    const restored = mount({ useWorkspaces: b.props.useWorkspaces, useSessions: b.props.useSessions })
-    expect(screen.getByRole('treeitem', { name: '/projects' }).getAttribute('aria-expanded')).toBe('false')
-    fireEvent.click(screen.getByRole('button', { name: '工作区“/projects”的操作' }))
-    fireEvent.click(screen.getByRole('menuitem', { name: '移除分组' }))
-    expect(screen.queryByRole('treeitem', { name: '/projects' })).toBeNull()
-    expect(screen.getByText('alpha')).toBeTruthy()
-    expect(restored.props.deleteWorkspace).not.toHaveBeenCalled()
-  })
-
-  it('assigns overlapping parents once, retains empty folders, and ignores them in flat view', () => {
-    const preferences = createWorkspaceViewStore().create()
-    preferences.actions.setParentFolder('/projects', true)
-    preferences.actions.setParentFolder('/projects/team', true)
-    preferences.actions.setParentFolder('/empty', true)
-    const b = mount({ useWorkspaces: hook(workspaceState([
-      workspace('alpha', []), { ...workspace('beta', []), path: '/projects/team/beta' },
-    ])) })
-    expect(within(screen.getByRole('treeitem', { name: '/projects' })).queryByText('beta')).toBeNull()
-    expect(within(screen.getByRole('treeitem', { name: '/projects/team' })).getByText('beta')).toBeTruthy()
-    expect(screen.getAllByText('beta')).toHaveLength(1)
-    expect(within(screen.getByRole('treeitem', { name: '/empty' })).getByText('此目录下没有已添加的工作区')).toBeTruthy()
-    act(() => { b.store.actions.setGroupBy('flat') })
-    expect(screen.queryByRole('treeitem', { name: '/projects' })).toBeNull()
-    act(() => { b.store.actions.setGroupBy('workspace') })
-    expect(screen.getByRole('treeitem', { name: '/projects' })).toBeTruthy()
-    act(() => { b.store.actions.removeParentFolder('/projects/team') })
-    expect(within(screen.getByRole('treeitem', { name: '/projects' })).getByText('beta')).toBeTruthy()
+    const restored = mount({ useWorkspaces: b.props.useWorkspaces })
+    expect(screen.queryByText('Child')).toBeNull()
+    fireEvent.click(screen.getByText('Projects'))
+    expect(screen.getByText('Child')).toBeTruthy()
+    act(() => { restored.store.actions.setGroupBy('flat') })
+    expect(screen.queryByText('Projects')).toBeNull()
+    act(() => { restored.store.actions.setGroupBy('workspace') })
+    expect(screen.getByText('Child')).toBeTruthy()
   })
   })
 
 
   it('keeps Workspace drag within its parent and uses the next displayed sibling as anchor', () => {
   it('keeps Workspace drag within its parent and uses the next displayed sibling as anchor', () => {
-    const preferences = createWorkspaceViewStore().create()
-    preferences.actions.setParentFolder('/projects', true)
-    preferences.actions.setParentFolder('/elsewhere', true)
     const b = mount({ useWorkspaces: hook(workspaceState([
     const b = mount({ useWorkspaces: hook(workspaceState([
       workspace('alpha', []), { ...workspace('outside', []), path: '/elsewhere/outside' },
       workspace('alpha', []), { ...workspace('outside', []), path: '/elsewhere/outside' },
-      workspace('beta', []), workspace('gamma', []),
+      workspace('beta', []), workspace('gamma', []), root,
     ])) })
     ])) })
     const alpha = screen.getByText('alpha').closest('[role="treeitem"]') as HTMLElement
     const alpha = screen.getByText('alpha').closest('[role="treeitem"]') as HTMLElement
     const beta = screen.getByText('beta').closest('[role="treeitem"]') as HTMLElement
     const beta = screen.getByText('beta').closest('[role="treeitem"]') as HTMLElement
@@ -1752,6 +1723,7 @@ describe('parent folders', () => {
     fireEvent.dragStart(alpha, { dataTransfer: dragData() })
     fireEvent.dragStart(alpha, { dataTransfer: dragData() })
     fireDrag(beta.parentElement as HTMLElement, 'drop', 100)
     fireDrag(beta.parentElement as HTMLElement, 'drop', 100)
     fireEvent.dragEnd(alpha)
     fireEvent.dragEnd(alpha)
+    expect(b.props.insertWorkspaceBefore).toHaveBeenCalledOnce()
     expect(b.props.insertWorkspaceBefore).toHaveBeenCalledWith(wid('alpha'), wid('gamma'))
     expect(b.props.insertWorkspaceBefore).toHaveBeenCalledWith(wid('alpha'), wid('gamma'))
   })
   })
 })
 })