Преглед изворни кода

fix(web): address Workspace tree review feedback

Turtle пре 2 недеља
родитељ
комит
5ec3368285

+ 3 - 3
.agents/notes/implemented/feature/2026-09-15-sidebar-parent-folders.i18n.yaml → .agents/notes/implemented/feature/2026-09-15-sidebar-workspace-hierarchy.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # 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: 236d0be6cbe12538c76608af62b9448d313f7739
-2026-09-15-sidebar-parent-folders.zh.md: 3e243326dab6502061f2d3d2c5df0fbf88cfd699
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-sidebar-workspace-hierarchy.md
+2026-09-15-sidebar-workspace-hierarchy.md: 82e63684ec1d8c5d86e062b125aff6e1144ef8fa
+2026-09-15-sidebar-workspace-hierarchy.zh.md: 07f9ff89b6e33fd4d46b5e386b4240ad12c177c5

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

@@ -2,7 +2,7 @@
 
 Status: implemented
 
-English | [中文](2026-09-15-sidebar-parent-folders.zh.md)
+English | [中文](2026-09-15-sidebar-workspace-hierarchy.zh.md)
 
 ## Problem
 
@@ -12,7 +12,7 @@ A flat Workspace list makes related projects hard to browse when many directorie
 
 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.
+Adding a directory registers a normal Workspace and opens its Session directly. Parent Workspaces retain their own Sessions and standard row actions, with child Workspaces preceding their own Sessions. 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. Explicit collapse also hides the current Session, while ancestor folder icons identify its containing subtree. All rows have equal-width fills and hit targets, with content indentation per level. Workspace drag stays among siblings; descendant drop targets delegate to the nearest compatible ancestor, and unchanged sibling positions do not write Host order. Search navigation expands every ancestor.
 
 ## Alternatives considered
 
@@ -26,6 +26,6 @@ Adding a directory registers a normal Workspace and opens its Session directly.
 
 ## Consequences
 
-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.
+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 registered path spelling after separator normalization determines nesting.
 
-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.
+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 through descendant targets, parent-subtree moves, collapsed-current hints, and equal-width rows.

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

@@ -2,7 +2,7 @@
 
 Status: implemented
 
-[English](2026-09-15-sidebar-parent-folders.md) | 中文
+[English](2026-09-15-sidebar-workspace-hierarchy.md) | 中文
 
 ## 问题
 
@@ -12,7 +12,7 @@ Status: implemented
 
 侧栏默认将 Workspace 显示为同级分组。在**视图选项 → 分组方式**中选择**按工作区树**后,侧栏从已注册的 Workspace 路径派生递归层级。所选模式保存在现有的浏览器本地视图存储中。每个 Workspace 位于最近的严格祖先下;相同路径不会成为自己的父级。比较遵循目录分隔符和 Host 的大小写拼写,不解析符号链接别名。同级项目保留 Host 顺序。
 
-添加目录会直接注册普通 Workspace 并打开其 Session。父 Workspace 保留自己的 Session 和标准行操作。现有的浏览器本地展开状态同时控制子 Workspace 和父级自己的 Session 行;没有显式偏好时,祖先默认展开。所有行的高亮与点击区域等宽,内容按层级缩进。Workspace 拖拽限制在同级之间,搜索导航会展开全部祖先。
+添加目录会直接注册普通 Workspace 并打开其 Session。父 Workspace 保留自己的 Session 和标准行操作,子 Workspace 位于父级自己的 Session 之前。现有的浏览器本地展开状态同时控制子 Workspace 和父级自己的 Session 行;没有显式偏好时,祖先默认展开。显式折叠也会隐藏当前 Session,但祖先文件夹图标会标识它所在的子树。所有行的高亮与点击区域等宽,内容按层级缩进。Workspace 拖拽限制在同级之间;后代落点交给最近的兼容祖先,同级位置未变时不写入 Host 顺序,搜索导航会展开全部祖先。
 
 ## 考虑过的替代方案
 
@@ -26,6 +26,6 @@ Status: implemented
 
 ## 结果
 
-用户可以折叠相关项目,而不改变 Session 归属。删除祖先会保留子工作区注册;独立的 [Workspace 删除语义](2026-07-27-workspace-registration-deletion.zh.md)仍决定被删除 Workspace 自身 Session 的处理,该决策继续有效。浏览器本地折叠偏好不跨浏览器同步,嵌套取决于规范路径拼写。
+用户可以折叠相关项目,而不改变 Session 归属。删除祖先会保留子工作区注册;独立的 [Workspace 删除语义](2026-07-27-workspace-registration-deletion.zh.md)仍决定被删除 Workspace 自身 Session 的处理,该决策继续有效。浏览器本地折叠偏好不跨浏览器同步,嵌套取决于分隔符归一化后的注册路径拼写。
 
-纯路径测试覆盖严格祖先关系、路径段边界、POSIX 反斜杠和 Windows 分隔符。组件与浏览器场景覆盖默认同级分组、树形模式选择与恢复、直接添加、后续注册、父级独立 Session、折叠恢复、搜索展开、同级排序及整行等宽。
+纯路径测试覆盖严格祖先关系、路径段边界、POSIX 反斜杠和 Windows 分隔符。组件与浏览器场景覆盖默认同级分组、树形模式选择与恢复、直接添加、后续注册、父级独立 Session、折叠恢复、搜索展开、通过后代落点进行同级排序、父级子树移动、折叠当前会话提示及整行等宽。

+ 22 - 0
apps/web/tests/workspace-management.e2e.ts

@@ -705,7 +705,29 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo
       page, '[aria-label="Workspace actions for folder-group"] >> xpath=ancestor::*[contains(@class, "groupSection")][1]',
       scaffold.workspaceCwd,
     ), MODE)
+    const sourceWorkspace = scaffold.ctx.workspaceRegistry.list().find(workspace => workspace.title === 'xx')!
+    await sourceWorkspace.setTitle('drag-source')
+    const dragSource = page.getByRole('treeitem').filter({ has: page.getByText('drag-source', { exact: true }) })
+    await dragSource.waitFor()
+    await dragSource.dragTo(parent, { targetPosition: { x: 10, y: 3 } })
+    await expect.poll(() => {
+      const ordered = scaffold.ctx.workspaceRegistry.list()
+      return ordered.findIndex(workspace => workspace.id === sourceWorkspace.id)
+        < ordered.findIndex(workspace => workspace.id === parentWorkspace.id)
+    }, { timeout: 10_000 }).toBe(true)
+    const lastChild = section.getByRole('treeitem', { name: 'project-one', exact: true })
+    const targetBounds = (await lastChild.boundingBox())!
+    const sectionBounds = (await section.boundingBox())!
+    expect(targetBounds.y + targetBounds.height / 2).toBeGreaterThan(sectionBounds.y + sectionBounds.height / 2)
+    await dragSource.dragTo(lastChild)
+    await expect.poll(() => {
+      const ordered = scaffold.ctx.workspaceRegistry.list()
+      return ordered.findIndex(workspace => workspace.id === sourceWorkspace.id)
+        > ordered.findIndex(workspace => workspace.id === parentWorkspace.id)
+    }, { timeout: 10_000 }).toBe(true)
+    await section.getByText('project-two', { exact: true }).waitFor()
     await parent.click()
+    expect(await parent.locator('[class*="folderActive"]').count()).toBe(1)
     expect(await section.getByText('project-two', { exact: true }).count()).toBe(0)
     const warningStart = tripwire.warnings.length
     await page.reload({ waitUntil: 'load' })

+ 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: 609489b0de68410875edbc7f93f038daeca36661
-README.zh.md: 81e0c7ddbab1f6b02678dd13c59cf2284468f4f1
+README.md: 42a5603d80ce3ec2cd85b94848d8e006bf038b8e
+README.zh.md: 6b04fea847d7f28bbf07bf031fc53212e2301405

+ 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. **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.
+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. Child Workspaces appear before the parent's own Sessions. Ancestors start expanded unless a saved collapsed state exists. A saved collapse also hides the current Session; ancestor folder icons stay highlighted when a descendant Workspace contains it. Row fills and hit targets span the same width at every level; only the contents indent. Workspace dragging reorders siblings; dropping on a descendant targets the nearest compatible ancestor, so an expanded parent can be moved past without collapsing it. 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 仅重排同级项目。选择搜索结果会展开全部祖先。分组方式和展开状态保存在当前浏览器中;切换模式会保留各 Workspace 的展开偏好,单列表视图保持平铺。
+选择**添加工作区**并选取目录,即可注册工作区并打开 Session。**视图选项 → 分组方式**默认为**按工作区**,将工作区作为同级分组显示。选择**按工作区树**后,每个 Workspace 会位于最近的已注册祖先之下,之后添加的 Workspace 也会自动归入。每个 Workspace 保留自己的 Session 和行操作,子 Workspace 显示在父级自己的 Session 之前。祖先默认展开,已有的折叠偏好除外。保存的折叠状态也会隐藏当前 Session;如果后代 Workspace 包含当前 Session,祖先文件夹图标仍保持高亮。各层级的高亮和点击区域保持整行同宽,仅内容缩进。拖拽 Workspace 仅重排同级项目;落在后代行上时,由最近的兼容祖先接收,因此无需先折叠父级就能将其他工作区拖到其后。选择搜索结果会展开全部祖先。分组方式和展开状态保存在当前浏览器中;切换模式会保留各 Workspace 的展开偏好,单列表视图保持平铺。
 
 层级仅使用已注册的规范路径,不扫描项目,也不解析符号链接别名。嵌套不会改变 Session 的工作目录、日志或 Workspace 归属。删除父 Workspace 后,子 Workspace 仍保持注册,并归入下一个已注册祖先;没有祖先时显示在根层级。
 

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

@@ -240,15 +240,12 @@
 
 .projectRow:hover .rowActions,
 .sessionRow:hover .rowActions,
-.projectRow:focus-within .rowActions,
-.sessionRow:focus-within .rowActions,
 .projectRow.menuOpen .rowActions,
 .sessionRow.menuOpen .rowActions {
   display: inline-flex;
 }
 
 .sessionRow:hover .time,
-.sessionRow:focus-within .time,
 .sessionRow.menuOpen .time {
   display: none;
 }

+ 4 - 2
packages/client/ui-workspace/src/client/rows/Rows.tsx

@@ -102,6 +102,7 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
  * Workspace shows its hover card (the ungrouped bucket has none).
  * `containsCurrent` arrives on the node (derivation fact, no renderer scan).
  * @param props.group - derived group node.
+ * @param props.containsCurrentDescendant - highlight an ancestor even when its subtree is collapsed.
  * @param props.onToggle - expand/collapse the group.
  * @param props.onCreate - start a frontend Session inside this Workspace.
  * @param props.drag - optional workspace-row drag wiring.
@@ -109,8 +110,9 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
  * @param props.t - the browser root's locale seat.
  * @returns the row element.
  */
-export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, home, t }: {
+export function ProjectRowItem({ group, containsCurrentDescendant = false, onToggle, onCreate, actions, drag, home, t }: {
   group: GroupNode
+  containsCurrentDescendant?: boolean
   onToggle: () => void
   onCreate: () => void
   /** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */
@@ -124,7 +126,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, home,
   const row = group
   // The ungrouped bucket has no workspace title: its label is dictionary copy.
   const label = row.workspaceId === undefined ? t('group.ungrouped') : row.label
-  const active = group.expanded && group.containsCurrent
+  const active = containsCurrentDescendant || (group.expanded && group.containsCurrent)
   const [menuOpen, setMenuOpen] = useState(false)
   const workspaceMenuItems = [
     { id: 'rename', label: t('rename'), icon: <IconEditOutline16 /> },

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

@@ -509,7 +509,3 @@
     transition: none;
   }
 }
-
-.parentChildren {
-  min-width: 0;
-}

+ 34 - 11
packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx

@@ -161,7 +161,7 @@ interface WorkspaceDragState {
   over: { id: WorkspaceId; half: 'before' | 'after' } | null
 }
 
-/** Resolve an insertion side from the full rendered workspace group. */
+/** Resolve an insertion side across the Workspace header, descendants, and Sessions. */
 function workspaceGroupHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' {
   const rect = e.currentTarget.getBoundingClientRect()
   return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
@@ -247,6 +247,13 @@ function SessionTree({
       return [workspace.workspaceId, path === undefined ? undefined : keysByPath.get(path)]
     }))
   }, [nestWorkspaces, workspaces])
+  const currentAncestors = useMemo(() => {
+    const keys = new Set<string>()
+    for (let key = currentGroup === undefined ? undefined : parents.get(currentGroup); key !== undefined; key = parents.get(key)) {
+      keys.add(key)
+    }
+    return keys
+  }, [currentGroup, parents])
   const expandedGroups = useMemo(() => {
     const ancestorKeys = new Set<string | undefined>(parents.values())
     return [...workspaces.map(workspace => workspace.workspaceId), UNGROUPED_KEY]
@@ -261,7 +268,9 @@ function SessionTree({
   )
   useEffect(() => {
     for (let key = revealGroup; key !== undefined; key = parents.get(key)) {
-      if (groupExpansion[key] !== true) setGroupExpanded(key, true)
+      if (groupExpansion[key] === false || (key === revealGroup && groupExpansion[key] !== true)) {
+        setGroupExpanded(key, true)
+      }
     }
   }, [groupExpansion, parents, revealGroup, setGroupExpanded])
   useEffect(() => {
@@ -334,22 +343,33 @@ function SessionTree({
     if (rowIndex === -1) return
     const anchor = over.half === 'before' ? over.id : siblings[rowIndex + 1]?.workspaceId
     if (anchor === activeDrag.workspaceId) return
-    const sourceIndex = workspaces.findIndex(workspace => workspace.workspaceId === activeDrag.workspaceId)
+    const sourceIndex = siblings.findIndex(workspace => workspace.workspaceId === activeDrag.workspaceId)
     const anchorIndex = anchor === undefined
-      ? workspaces.length
-      : workspaces.findIndex(workspace => workspace.workspaceId === anchor)
+      ? siblings.length
+      : siblings.findIndex(workspace => workspace.workspaceId === anchor)
     if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
     insertWorkspaceBefore(activeDrag.workspaceId, anchor).catch((reason: unknown) => {
       console.warn('workspace reorder rejected:', reason)
     })
   }
-  const rootGroups = groups.filter(group => parents.get(group.key) === undefined)
+  const childrenByParent = useMemo(() => {
+    const children = new Map<string | undefined, GroupNode[]>()
+    for (const group of groups) {
+      const parent = parents.get(group.key)
+      const siblings = children.get(parent)
+      if (siblings === undefined) children.set(parent, [group])
+      else siblings.push(group)
+    }
+    return children
+  }, [groups, parents])
+  const rootGroups = childrenByParent.get(undefined) ?? []
   const workspaceDropAtListStart = rootGroups[0]?.workspaceId !== undefined
     && workspaceDrag?.over?.id === rootGroups[0].workspaceId
     && workspaceDrag.over.half === 'before'
 
   const renderGroup = (group: GroupNode, depth: number): ReactNode => {
     const workspaceId = group.workspaceId
+    const children = childrenByParent.get(group.key) ?? []
     const compatibleDrag = workspaceDrag !== null && parents.get(workspaceDrag.workspaceId) === parents.get(group.key)
     const collapsed = collapsedSessionRows(group.sessions)
     const sessionsExpanded = expandedSessionGroups.includes(group.key)
@@ -383,7 +403,7 @@ function SessionTree({
         commitWorkspaceDrag(workspaceDrag, { id: workspaceId, half })
       }
     return (
-    // Group section: header row + expanded top-level session rows. The
+    // Group section: header, descendant Workspaces, and own Session rows. The
     // inter-group breathing room is the section's own margin
     // (WorkspaceBrowser.module.css).
       <div
@@ -398,10 +418,11 @@ function SessionTree({
           ? undefined
           : (e) => {
             e.preventDefault()
+            if (hoverWorkspace === undefined && parents.get(group.key) !== undefined) return
             e.stopPropagation()
             if (hoverWorkspace === undefined) {
               e.dataTransfer.dropEffect = 'none'
-              setWorkspaceDrag({ ...workspaceDrag, over: null })
+              if (workspaceDrag.over !== null) setWorkspaceDrag({ ...workspaceDrag, over: null })
             } else {
               e.dataTransfer.dropEffect = 'move'
               hoverWorkspace(workspaceGroupHalf(e))
@@ -411,6 +432,7 @@ function SessionTree({
           ? undefined
           : (e) => {
             e.preventDefault()
+            if (dropWorkspace === undefined && parents.get(group.key) !== undefined) return
             e.stopPropagation()
             if (dropWorkspace === undefined) {
               workspaceDropCommitted.current = true
@@ -422,6 +444,7 @@ function SessionTree({
       >
         <ProjectRowItem
           group={group}
+          containsCurrentDescendant={currentAncestors.has(group.key)}
           home={home}
           t={t}
           onToggle={() => {
@@ -450,9 +473,9 @@ 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))}
+        {group.expanded && children.length > 0 && (
+          <div role="group">
+            {children.map(child => renderGroup(child, depth + 1))}
           </div>
         )}
         {(sessionsExpanded

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

@@ -508,12 +508,11 @@ function folderPath(path: string): string {
 /**
  * Find the nearest registered ancestor, excluding the Workspace directory itself.
  * Paths use Host spelling; matching is case-sensitive, like Workspace identity.
- * @param path - Workspace directory; absent for Ungrouped.
+ * @param path - Workspace directory.
  * @param parents - registered Workspace directory paths.
  * @returns the owning parent path, or undefined when no parent contains the Workspace.
  */
-export function owningParentFolder(path: string | undefined, parents: readonly string[]): string | undefined {
-  if (path === undefined) return undefined
+export function owningParentFolder(path: string, parents: readonly string[]): string | undefined {
   const child = folderPath(path)
   let owner: string | undefined
   let length = -1

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

@@ -587,7 +587,6 @@ describe('workspaceLabel', () => {
   })
 })
 
-
 describe('parent folder membership', () => {
   it.each([
     ['/git/app', ['/git'], '/git'],
@@ -596,7 +595,6 @@ describe('parent folder membership', () => {
     ['/git/team/app', ['/git', '/git/team'], '/git/team'],
     ['/git/team/app', ['/git/team', '/git'], '/git/team'],
     ['/git/app', ['/'], '/'],
-    [undefined, ['/git'], undefined],
     ['/git/app', [], undefined],
     [String.raw`C:\git\app`, ['C:/git/'], 'C:/git/'],
     [String.raw`\\server\share\app`, [String.raw`\\server\share`], String.raw`\\server\share`],

+ 68 - 0
packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx

@@ -1742,6 +1742,74 @@ describe('Workspace tree grouping', () => {
     expect(screen.getByText('Child')).toBeTruthy()
   })
 
+  it('drops after an expanded parent through its last descendant', () => {
+    const outside = { ...workspace('outside', []), path: '/outside' }
+    const tail = { ...workspace('tail', []), path: '/tail' }
+    const b = mount({ useWorkspaces: hook(workspaceState([outside, root, team, child, tail])) })
+    section('Projects').getBoundingClientRect = () => ({
+      top: 0, bottom: 200, left: 0, right: 200, width: 200, height: 200,
+      x: 0, y: 0, toJSON: () => ({}),
+    })
+    const source = screen.getByText('outside').closest('[role="treeitem"]')!
+    fireEvent.dragStart(source, { dataTransfer: dragData() })
+    fireDrag(section('Child'), 'dragOver', 190)
+    fireDrag(section('Child'), 'drop', 190)
+    fireEvent.dragEnd(source)
+    expect(b.props.insertWorkspaceBefore).toHaveBeenCalledExactlyOnceWith(wid('outside'), wid('tail'))
+  })
+
+  it('avoids a Host reorder when only descendants separate adjacent siblings', () => {
+    const outside = { ...workspace('outside', []), path: '/outside' }
+    const b = mount({ useWorkspaces: hook(workspaceState([root, child, outside])) })
+    section('outside').getBoundingClientRect = () => ({
+      top: 0, bottom: 200, left: 0, right: 200, width: 200, height: 200,
+      x: 0, y: 0, toJSON: () => ({}),
+    })
+    const source = screen.getByText('Projects').closest('[role="treeitem"]')!
+    fireEvent.dragStart(source, { dataTransfer: dragData() })
+    fireDrag(section('outside'), 'drop', 10)
+    fireEvent.dragEnd(source)
+    expect(b.props.insertWorkspaceBefore).not.toHaveBeenCalled()
+  })
+
+  it('moves a parent with its descendants while preserving their order', () => {
+    const outside = { ...workspace('outside', []), path: '/outside' }
+    const other = workspace('other', [])
+    const b = mount({ useWorkspaces: hook(workspaceState([root, team, child, other, outside])) })
+    const source = screen.getByText('Projects').closest('[role="treeitem"]')!
+    fireEvent.dragStart(source, { dataTransfer: dragData() })
+    fireDrag(section('outside'), 'drop', 100)
+    fireEvent.dragEnd(source)
+    expect(b.props.insertWorkspaceBefore).toHaveBeenCalledExactlyOnceWith(root.workspaceId, undefined)
+    rerender(b, { useWorkspaces: hook(workspaceState([team, child, other, outside, root])) })
+    expect(screen.getAllByRole('treeitem').map(row => row.textContent)).toEqual([
+      'outside', 'Projects', 'Team', 'Child', 'other',
+    ])
+    expect(within(section('Team')).getByText('Child')).toBeTruthy()
+  })
+
+  it('keeps a saved ancestor collapse and highlights the current descendant', () => {
+    const b = mount({ useWorkspaces: hook(workspaceState([root, team, child])) })
+    fireEvent.click(screen.getByText('Projects'))
+    rerender(b, {
+      useSessions: hook(sessionState([summary('child-session', 1)], { current: sid('child-session') })),
+    })
+    expect(screen.queryByText('Child')).toBeNull()
+    expect(b.store.getSnapshot().groupExpansion.root).toBe(false)
+    expect(section('Projects').querySelector('[class*="folderActive"]')).not.toBeNull()
+  })
+
+  it('reveals a search hit without persisting default-expanded ancestors', () => {
+    const b = mount({
+      useWorkspaces: hook(workspaceState([root, team, child])),
+      useSessions: hook(sessionState([summary('child-session', 1)])),
+    })
+    fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'child-session' } })
+    fireEvent.click(screen.getByRole('treeitem'))
+    expect(b.store.getSnapshot().groupExpansion).toEqual({ child: true })
+    expect(screen.getByText('child-session')).toBeTruthy()
+  })
+
   it('keeps Workspace drag within its parent and uses the next displayed sibling as anchor', () => {
     const b = mount({ useWorkspaces: hook(workspaceState([
       workspace('alpha', []), { ...workspace('outside', []), path: '/elsewhere/outside' },