Browse Source

refactor(web): unify workspace grouping controls and row highlights

Turtle 5 days ago
parent
commit
e706da7424

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-sidebar-parent-folders.md
-2026-09-15-sidebar-parent-folders.md: 6927be9c39e061207d1b006139bc5bb504bba3ac
-2026-09-15-sidebar-parent-folders.zh.md: 560c9a8daea88f7d63f0833a0b4c04c61c558818
+2026-09-15-sidebar-parent-folders.md: 15d6f56106ff0c5df956aee07847f9438561ca6f
+2026-09-15-sidebar-parent-folders.zh.md: 732007523b6690e53efb76cf42c8966c8bca644e

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

@@ -12,7 +12,7 @@ A flat Workspace list makes related projects hard to browse when many directorie
 
 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 directory picker reports a parent path without creating a Workspace or Session. 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.
+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.
 
 ## Alternatives considered
 

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

@@ -12,7 +12,7 @@ Status: implemented
 
 父目录作为浏览器本地视图状态保存在现有 Workspace store 中。每个 Workspace 归入最具体的已选祖先目录或相同路径;未归组的 Workspace 在根层保留彼此的相对顺序。已选父目录按添加顺序显示,包括空父目录。路径比较遵循目录分隔符并使用 Host 的大小写拼写,不解析符号链接别名。
 
-目录选择器返回父目录路径,不创建 Workspace 或 Session。项目行保留现有操作与各账户的 Session 排序。Workspace 拖拽仅限同一父目录下的项目。搜索结果导航先展开父目录,再显示 Session。旧浏览器偏好缺少父目录状态时表示不分组;移除父目录不会改变 Host 状态。
+现有添加工作区流程在选择目录后提供父目录分组选项,不创建 Workspace 或 Session。父目录行复用 Workspace 文件夹样式和省略号菜单。各层级的行背景与点击区域保持同宽,缩进仅作用于行内容。项目行保留现有操作与各账户的 Session 排序。Workspace 拖拽仅限同一父目录下的项目。搜索结果导航先展开父目录,再显示 Session。旧浏览器偏好缺少父目录状态时表示不分组;移除父目录不会改变 Host 状态。
 
 ## 考虑过的替代方案
 

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

@@ -1,3 +1,12 @@
+- dialog "Add workspace":
+  - heading "Add workspace" [level=2]
+  - button "Close":
+    - 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]:
     - img

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

@@ -14,6 +14,7 @@
 import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { join, sep } from 'node:path'
+import { homedir } from 'node:os'
 import type { Browser, Locator, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
@@ -76,6 +77,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     // Creating selects the new folder in the listing; Open adopts it.
     await dialog.getByRole('button', { name: 'Open', exact: true }).click()
     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(
       () => scaffold.ctx.workspaceRegistry.resolveByPath(join(parent, name)),
       { timeout: 10_000 },
@@ -96,6 +101,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     const dialog = await browseTo(path)
     await dialog.getByRole('button', { name: 'Open', exact: true }).click()
     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(
       () => scaffold.ctx.workspaceRegistry.resolveByPath(path),
       { timeout: 10_000 },
@@ -665,13 +674,13 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
   it('groups existing Workspaces by a picked parent without creating a parent Workspace', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-parent-folders'))
     const parentPath = join(scaffold.workspaceCwd, 'folder-group')
+    const parentName = parentPath.startsWith(homedir() + sep) ? `~${parentPath.slice(homedir().length)}` : parentPath
     await mkdir(parentPath)
     await addNewFolderWorkspace(parentPath, 'project-one')
     await addNewFolderWorkspace(parentPath, 'project-two')
     const workspaceIds = scaffold.ctx.workspaceRegistry.list().map(workspace => workspace.id)
     const agentCount = scaffold.ctx.agents.list().length
-    await page.getByRole('button', { name: 'View options' }).click()
-    await page.getByRole('menuitem', { name: 'Add parent folder…' }).click()
+    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)
@@ -679,16 +688,33 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     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)
+    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]) {
+      const bounds = (await row.boundingBox())!
+      expect(bounds.x).toBeCloseTo(parentBounds.x, 0)
+      expect(bounds.width).toBeCloseTo(parentBounds.width, 0)
+    }
+    const parentLabel = (await parent.getByRole('button', { name: parentName, exact: true }).locator('span').last().boundingBox())!
+    const projectLabel = (await project.getByText('project-two', { exact: true }).boundingBox())!
+    expect(projectLabel.x - parentLabel.x).toBeCloseTo(12, 0)
     const expected = fileURLToPath(new URL('./expected/workspace-management/parent-folders.expected.md', import.meta.url))
-    await compareOrRefreshGolden(expected, await captureStableAria(
+    await compareOrRefreshGolden(expected, confirmationAria + '\n' + await captureStableAria(
       page, '[role="treeitem"][aria-label]', scaffold.workspaceCwd,
+      { replacements: [[parentName, '{{cwd}}/folder-group']] },
     ), MODE)
-    await parent.getByRole('button', { name: parentPath, exact: true }).click()
+    await parent.getByRole('button', { name: parentName, exact: true }).click()
     expect(await parent.getByText('project-two', { exact: true }).count()).toBe(0)
     const warningStart = tripwire.warnings.length
     await page.reload({ waitUntil: 'load' })
@@ -696,7 +722,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     acknowledgeReloadConnectionLoss(tripwire, warningStart)
     expect(await parent.getAttribute('aria-expanded')).toBe('false')
     await parent.hover()
-    await parent.getByRole('button', { name: `Remove parent folder “${parentPath}”` }).click()
+    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)

+ 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: 8b79c1cb09aece51f6fe94c9f9a1319281f9ad3a
-README.zh.md: 435a57f36a8cad06dfcc69ee8b21472b3ad4d650
+README.md: 965b1895f739120d4ff05c29f3ce9d5cce04d1ec
+README.zh.md: 066772902715b25ba5b1133242ea333d4292b9c4

+ 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
 
 ### Parent folders
 
-Choose **View options → Add parent folder…** to group already registered Workspaces by their directory paths. Parent folders appear in addition order; overlapping parents assign each Workspace to its most specific selected parent, including a Workspace at that exact path. Projects keep their existing rows and 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. Removing a parent only changes grouping, and selecting a search result expands its parent. The flat view ignores parent folders.
+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.
 
 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.
 

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

@@ -33,7 +33,7 @@ kind: "package-reference"
 
 ### 父目录分组
 
-选择**视图选项 → 添加父目录分组…**,按目录路径归组已注册的 Workspace。父目录按添加顺序显示;父目录重叠时,每个 Workspace 归入最具体的已选父目录,也包括路径恰好等于父目录的 Workspace。项目保留现有行样式与 Session 操作,拖拽 Workspace 仅重排同一父目录下的项目。空父目录保持可见并显示说明。父目录路径和独立折叠状态保存在当前浏览器中;旧版偏好默认没有父目录分组。移除父目录只改变展示分组,选择搜索结果会展开其父目录。单列表视图不使用父目录分组。
+选择**添加工作区**并选取目录,确认**按子工作区分组**,即可按路径归组已注册的 Workspace。目录包含已注册 Workspace 时,该选项默认勾选;取消勾选则将目录添加为普通 Workspace。父目录按添加顺序显示;父目录重叠时,每个 Workspace 归入最具体的已选父目录,也包括路径恰好等于父目录的 Workspace。各层级的悬停和选中区域保持整行同宽,仅行内容缩进。项目保留现有 Session 操作,拖拽 Workspace 仅重排同一父目录下的项目。空父目录保持可见并显示说明。父目录路径和独立折叠状态保存在当前浏览器中;旧版偏好默认没有父目录分组。通过父目录行的 **… → 移除分组**菜单仅移除展示分组,选择搜索结果会展开其父目录。单列表视图不使用父目录分组。
 
 父目录分组不会扫描未注册的目录,也不会解析符号链接别名;请选择 Workspace 悬浮卡片所示规范路径的祖先目录。分组不会改变 Session 的工作目录、日志或 Workspace 归属。
 

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

@@ -18,3 +18,37 @@
 .menuStatus {
   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);
+}

+ 46 - 3
packages/client/ui-workspace/src/client/WorkspacePicker.tsx

@@ -18,6 +18,7 @@ import type {
 } from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts'
+import { owningParentFolder } from './tree.ts'
 import css from './WorkspacePicker.module.css'
 
 const ADD_WORKSPACE = '::add-workspace'
@@ -38,7 +39,7 @@ export interface WorkspacePickFlowProps {
   useDirectoryFlow: SnapshotSelectorHook<boolean>
   /** Render this surface's directory-flow hole with the owner conversation (the entry's narrowed renderSlot). */
   renderDirectoryFlow: (owner: DirectoryFlowOwnerProps) => ReactNode
-  /** Handle a picked parent directory without registering or opening a Workspace. */
+  /** 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. */
   onPick: (workspaceId: WorkspaceId) => void
@@ -82,11 +83,16 @@ export function WorkspacePickFlow({
   const [modalError, setModalError] = useState<string | null>(null)
   const [flowOpen, setFlowOpen] = 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
   // pending, browse dialog up) or its pick is being adopted, every other
   // menu action stays disabled — a late outcome must not race a concurrent
   // selection or adoption.
-  const flowBusy = flowOpen || pickingFolder
+  const flowBusy = flowOpen || pickingFolder || pickedDirectory !== null
+  const childWorkspaces = pickedDirectory === null ? [] : workspaces.filter(
+    workspace => owningParentFolder(workspace.path, [pickedDirectory]) !== undefined,
+  )
 
   // 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
@@ -138,6 +144,7 @@ export function WorkspacePickFlow({
 
   const openDirectoryFlow = useCallback((): void => {
     onClose()
+    setPickedDirectory(null)
     setErrorOpen(false)
     setModalError(null)
     setFlowOpen(true)
@@ -166,7 +173,8 @@ export function WorkspacePickFlow({
     onPicked: (path) => {
       if (onPickParentFolder !== undefined) {
         setFlowOpen(false)
-        onPickParentFolder(path)
+        setPickedDirectory(path)
+        setGroupChildren(workspaces.some(workspace => owningParentFolder(workspace.path, [path]) !== undefined))
         return
       }
       setPickingFolder(true)
@@ -204,6 +212,41 @@ export function WorkspacePickFlow({
       />
       {open && !addIsTheOnlyEntry && !menuIsEmpty && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">{t('picker.loading')}</div>}
       {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
         open={errorOpen}
         onClose={closeModal}

+ 8 - 4
packages/client/ui-workspace/src/client/locales.ts

@@ -7,8 +7,10 @@
 /** Simplified Chinese dictionary (the key-set source of truth). */
 export const zh = {
   'group.ungrouped': '未分组',
-  'parentFolder.add': '添加父目录分组…',
-  'parentFolder.remove': '移除父目录分组“{name}”',
+  'parentFolder.group': '按子工作区分组',
+  'parentFolder.help': '显示此目录下已添加的工作区及其会话。',
+  'parentFolder.remove': '移除分组',
+  'picker.add': '添加',
   'parentFolder.empty': '此目录下没有已添加的工作区',
   'session.new': '新会话',
   'section.workspaces': '工作区',
@@ -80,8 +82,10 @@ export type WorkspaceKey = keyof typeof zh
 /** English dictionary, checked complete against the zh key set. */
 export const en = {
   'group.ungrouped': 'Ungrouped',
-  'parentFolder.add': 'Add parent folder…',
-  'parentFolder.remove': 'Remove parent folder “{name}”',
+  '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',
   'section.workspaces': 'Workspaces',

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

@@ -5,6 +5,7 @@
   gap: 6px;
   border-radius: 8px;
   padding: 0 8px;
+  padding-inline-start: calc(8px + var(--dsh-workspace-indent, 0px));
   cursor: pointer;
   user-select: none;
   color: var(--dsw-alias-label-primary);
@@ -239,12 +240,15 @@
 
 .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;
 }

+ 25 - 6
packages/client/ui-workspace/src/client/rows/Rows.tsx

@@ -8,7 +8,7 @@
 import { useEffect, useRef, useState } from 'react'
 import clsx from 'clsx'
 import {
-  HoverCard, IconCloseFill14, IconAlarmClockOutline16, IconArchiveOutline20, IconBranchOutline16,
+  HoverCard, IconAlarmClockOutline16, IconArchiveOutline20, IconBranchOutline16,
   IconEditOutline16, IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16,
   IconPlusOutline16, IconTrashOutline16, IconTriangleRightFill14, Menu, relativeTime,
   StateDot,
@@ -527,16 +527,35 @@ export function ParentFolderRow({ path, expanded, home, onToggle, onRemove, t }:
   t: RowTranslate
 }) {
   const label = abbreviateHomePath(path, home)
+  const [menuOpen, setMenuOpen] = useState(false)
   return (
-    <div className={css.projectRow}>
+    <div className={clsx(css.projectRow, menuOpen && css.menuOpen)}>
       <button type="button" className={css.parentToggle} aria-expanded={expanded} onClick={onToggle} title={path}>
-        <IconTriangleRightFill14 className={clsx(css.arrow, expanded && css.arrowOpen)} />
+        <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}>
-        <button type="button" className={css.iconButton} aria-label={t('parentFolder.remove', { name: label })} onClick={onRemove}>
-          <IconCloseFill14 />
-        </button>
+        <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>
   )

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

@@ -436,7 +436,7 @@
   height: 28px;
   border: none;
   border-radius: 8px;
-  padding: 0 12px 0 28px;
+  padding: 0 12px 0 calc(28px + var(--dsh-workspace-indent, 0px));
   background: transparent;
   cursor: pointer;
   text-align: left;
@@ -511,6 +511,6 @@
 }
 
 .parentChildren {
-  padding-inline-start: 12px;
+  --dsh-workspace-indent: 12px;
   min-width: 0;
 }

+ 3 - 15
packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx

@@ -98,8 +98,7 @@ function useNativeDragAcceptance(active: boolean): void {
 }
 
 /** Grouping and ordering menu; own open state so it resets with the wide chrome. */
-function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, onAddParentFolder, t }: {
-  onAddParentFolder: (() => void) | undefined
+function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
   groupBy: 'workspace' | 'flat'
   orderBy: SessionOrderBy
   onGroupPick: (mode: 'workspace' | 'flat') => void
@@ -119,16 +118,11 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, onAddPare
         { type: 'label' as const, id: 'order-by', text: t('orderBy.label') },
         { id: 'manual', label: t('orderBy.manual') },
         { id: 'updated', label: t('orderBy.updated') },
-        ...(onAddParentFolder === undefined ? [] : [
-          { type: 'separator' as const, id: 'folders-separator' },
-          { id: 'add-parent-folder', label: t('parentFolder.add') },
-        ]),
       ]}
       selectedIds={[groupBy, orderBy]}
       onSelect={(id) => {
         if (id === 'workspace' || id === 'flat') onGroupPick(id)
         else if (id === 'manual' || id === 'updated') onOrderPick(id)
-        else if (id === 'add-parent-folder') onAddParentFolder?.()
         setOpen(false)
       }}
       align="end"
@@ -897,7 +891,6 @@ export function WorkspaceBrowser({
   // Section-header + opens the picker menu (same popover in wide and rail
   // states; the menu anchors on this button).
   const [wsPickerOpen, setWsPickerOpen] = useState(false)
-  const [pickingParentFolder, setPickingParentFolder] = useState(false)
   const wsPlusRef = useRef<HTMLButtonElement>(null)
   const composingRef = useRef(false)
 
@@ -1164,10 +1157,6 @@ export function WorkspaceBrowser({
               orderBy={orderBy}
               onGroupPick={(mode) => { actions.setGroupBy(mode) }}
               onOrderPick={(mode) => { actions.setOrderBy(mode, activeSessionOrders) }}
-              onAddParentFolder={directoryFlowAvailable ? () => {
-                setPickingParentFolder(true)
-                setWsPickerOpen(true)
-              } : undefined}
               t={t}
             />
           )}
@@ -1182,7 +1171,6 @@ export function WorkspaceBrowser({
                 className={css.iconButton}
                 aria-label={t('workspace.add')}
                 onClick={() => {
-                  setPickingParentFolder(false)
                   setWsPickerOpen(v => !v)
                 }}
               >
@@ -1201,10 +1189,10 @@ export function WorkspaceBrowser({
           useDirectoryFlow={useDirectoryFlow}
           renderDirectoryFlow={owner => renderSlot('sidebar.workspaces.directoryFlow', owner)}
           addOnly
-          onPickParentFolder={pickingParentFolder ? (path) => {
+          onPickParentFolder={(path) => {
             actions.setParentFolder(path, true)
             actions.setGroupBy('workspace')
-          } : undefined}
+          }}
           side="right"
           onPick={(workspaceId) => {
             setWsPickerOpen(false)

+ 45 - 5
packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx

@@ -387,9 +387,9 @@ describe('WorkspaceBrowser', () => {
 
     fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
     expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label
-    expect(screen.getAllByRole('separator')).toHaveLength(2)
+    expect(screen.getAllByRole('separator')).toHaveLength(1)
     expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
-      '按工作区', '单列表', '手动排序', '最近更新', '添加父目录分组…',
+      '按工作区', '单列表', '手动排序', '最近更新',
     ])
     expect(screen.getByRole('menuitem', { name: '按工作区' }).querySelector('svg')).toBeTruthy()
     expect(screen.getByRole('menuitem', { name: '手动排序' }).querySelector('svg')).toBeTruthy()
@@ -1643,6 +1643,43 @@ describe('WorkspaceBrowser', () => {
 
 
 describe('parent folders', () => {
+  it('cancels a picked path and can add it as a normal Workspace instead of a group', async () => {
+    const b = mount({
+      useWorkspaces: hook(workspaceState([workspace('alpha', [])])),
+      createWorkspace: vi.fn(async () => workspace('projects', [])),
+      renderSlot: ((_name: string, owner: DirectoryFlowOwnerProps) => owner.open
+        ? <button onClick={() => { owner.onPicked('/projects') }}>Pick directory</button> : null) as WorkspaceBrowserProps['renderSlot'],
+    })
+    fireEvent.click(screen.getByRole('button', { name: '添加工作区' }))
+    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')) })
+    expect(b.props.createWorkspace).toHaveBeenCalledWith({ path: '/projects' })
+    expect(b.store.getSnapshot().parentFolders).toBeUndefined()
+  })
+
+  it('defaults an empty directory to Workspace adoption but allows an empty parent group', () => {
+    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()
+  })
+
   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'])])),
@@ -1650,9 +1687,11 @@ describe('parent folders', () => {
       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('menuitem', { name: '添加父目录分组…' }))
+    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' })
@@ -1662,7 +1701,8 @@ describe('parent folders', () => {
     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('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()