Merge origin/master into same-basename workspace fix

# Conflicts:
#	packages/client/ui-workspace/README.i18n.yaml
This commit is contained in:
Tianyi Cui
2026-07-31 18:42:20 +08:00
57 changed files with 1665 additions and 81 deletions

View File

@@ -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: 5428c8dbc6165a39eec46504c775a1645dde91ff
README.zh.md: 76614b5694a493328bc0edc73740be6c9e6dbebb
README.md: 2854c678fd93d56267d0aec8515455b77d4bcbf1
README.zh.md: 51db6cacfe83d176a6cc68b01fe3dd91acf49e25

View File

@@ -8,6 +8,8 @@ The browser renders grouped or flat Session rows from the global runtime hooks a
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail exposes the full path. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands.
Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. The card reports the dictionary-driven copied state only after the browser accepts the clipboard write.
The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list.
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.

View File

@@ -8,6 +8,8 @@
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace侧边栏的悬停详情会显示完整路径。每个注册各自声明一个**目录流子洞**`single` kind`conversation.hero.workspace.directoryFlow``sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在本表层的洞被占用时渲染每次菜单渲染读取占用状态洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`每次打开上报一个所选路径owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace取消操作不会显示提示错误落入可重试的文件夹对话框**重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框并以该行的显示标题预填客户端不设名称冲突规则host 负责规范化,可能以 `title-invalid` 拒绝错误渲染在对话框告警区确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档归档集合回声落地后该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失失败只作为控制台诊断输出树保持不变。blank「新会话」行是纯占位不渲染行菜单和时间标签其中还没有发生任何事rename/fork/归档都从首条 prompt 落地后才可用。
Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。只有浏览器接受剪贴板写入后,卡片才会显示由字典提供的已复制状态。
Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。

View File

@@ -47,6 +47,7 @@ export const zh = {
'status.running': '进行中',
'status.idle': '空闲',
'hover.created': '创建于 {time}',
'hover.copied': '已复制',
'date.ymd': '{y}年{m}月{d}日',
'time.now': '刚刚',
'time.minutes': '{n}分钟',
@@ -103,6 +104,7 @@ export const en = {
'status.running': 'Running',
'status.idle': 'Idle',
'hover.created': 'Created {time}',
'hover.copied': 'Copied',
'date.ymd': '{y}-{m}-{d}',
'time.now': 'now',
'time.minutes': '{n}min',

View File

@@ -158,6 +158,9 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: {
anchor={ownRow}
content={<WorkspaceHoverContent label={row.label} cwd={row.cwd} createdAt={row.createdAt} t={t} />}
disabled={menuOpen}
copyText={row.cwd}
copyLabel={t('copy')}
copiedLabel={t('hover.copied')}
/>
)
}
@@ -347,6 +350,9 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
anchor={ownRow}
content={<SessionHoverContent node={node} now={now} t={t} />}
disabled={menuOpen || drag?.active === true}
copyText={row.blank ? undefined : row.title}
copyLabel={t('copy')}
copiedLabel={t('hover.copied')}
/>
)
}

View File

@@ -33,6 +33,19 @@ function dragProps(overrides: Partial<RowDragProps> = {}): RowDragProps {
}
}
/** Install the async browser clipboard and restore its prior host shape. */
function installClipboard(writeText: (text: string) => Promise<void>): () => void {
const prior = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
return () => {
if (prior === undefined) Reflect.deleteProperty(navigator, 'clipboard')
else Object.defineProperty(navigator, 'clipboard', prior)
}
}
const dataTransfer = { effectAllowed: '', dropEffect: '' }
/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
@@ -129,8 +142,10 @@ describe('workspace browser rows', () => {
expect(screen.queryByRole('menu')).toBeNull()
})
it('workspace hover card shows title, directory path, and creation time after the dwell', () => {
it('workspace hover card shows its details and copies the full directory path', async () => {
vi.useFakeTimers()
const writeText = vi.fn(async () => {})
const restoreClipboard = installClipboard(writeText)
try {
const group: GroupNode = {
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
@@ -143,7 +158,11 @@ describe('workspace browser rows', () => {
expect(screen.getAllByText('Project')).toHaveLength(2)
expect(screen.getByText('/projects/project')).toBeTruthy()
expect(screen.getByText(/^创建于 \d+年\d+月\d+日 /)).toBeTruthy()
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制: /projects/project' })) })
expect(writeText).toHaveBeenCalledWith('/projects/project')
expect(screen.getByRole('status').textContent).toBe('已复制')
} finally {
restoreClipboard()
vi.useRealTimers()
}
})
@@ -175,6 +194,7 @@ describe('workspace browser rows', () => {
expect(screen.getAllByText('新会话').length).toBeGreaterThanOrEqual(2)
expect(screen.getByText('空闲')).toBeTruthy()
expect(screen.queryByText('刚刚')).toBeNull()
expect(screen.getByText('空闲').closest('[role="button"]')).toBeNull()
} finally {
vi.useRealTimers()
}