Merge branch 'master' into fix/remove-badge

This commit is contained in:
imccyu
2026-07-31 19:47:55 +08:00
committed by GitHub
43 changed files with 203 additions and 135 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: 8ebc55d7ad202622bbbc50b3d91a6985a1be29b9
README.zh.md: d13a8941889654f05b8cafd3f6f4251a13e0b694
README.md: 2854c678fd93d56267d0aec8515455b77d4bcbf1
README.zh.md: 51db6cacfe83d176a6cc68b01fe3dd91acf49e25

View File

@@ -6,7 +6,7 @@ Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar
The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace add/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
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. 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.
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.

View File

@@ -6,7 +6,7 @@
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL将查询限制在传输 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair并保留现有的防抖与取消行为。每次新查询都会中止前一个请求内容搜索失败时元数据匹配项仍会显示同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**`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 落地后才可用。
该选择器通过全局 `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 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。只有浏览器接受剪贴板写入后,卡片才会显示由字典提供的已复制状态。

View File

@@ -13,9 +13,8 @@ import { useCallback, useEffect, useState } from 'react'
import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import {
WorkspaceCreateError,
type WorkspaceId, type WorkspaceListState, type WorkspaceView,
import type {
WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts'
@@ -80,7 +79,6 @@ export function WorkspacePickFlow({
const [modalError, setModalError] = useState<string | null>(null)
const [flowOpen, setFlowOpen] = useState(false)
const [pickingFolder, setPickingFolder] = useState(false)
const [folderConflict, setFolderConflict] = 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
@@ -130,10 +128,6 @@ export function WorkspacePickFlow({
setFlowOpen(false)
onPick(workspace.workspaceId)
}).catch((reason: unknown) => {
setFolderConflict(
reason instanceof WorkspaceCreateError
&& reason.rpcError.code === 'workspace-name-conflict',
)
setModalError(reason instanceof Error ? reason.message : String(reason))
setFlowOpen(false)
setErrorOpen(true)
@@ -143,7 +137,6 @@ export function WorkspacePickFlow({
onClose()
setErrorOpen(false)
setModalError(null)
setFolderConflict(false)
setFlowOpen(true)
}, [onClose])
@@ -174,7 +167,6 @@ export function WorkspacePickFlow({
onCancel: () => { setFlowOpen(false) },
onError: (message) => {
setFlowOpen(false)
setFolderConflict(false)
setModalError(message)
setErrorOpen(true)
},
@@ -208,7 +200,7 @@ export function WorkspacePickFlow({
open={errorOpen}
onClose={closeModal}
closeLabel={t('close')}
title={folderConflict ? t('conflict.title') : t('folderError.title')}
title={t('folderError.title')}
footer={(
<>
<Button variant="outline" className={css.modalAction} onClick={closeModal}>{t('cancel')}</Button>
@@ -218,11 +210,7 @@ export function WorkspacePickFlow({
</>
)}
>
<div className={css.modalError} role="alert">
{folderConflict
? t('conflict.hint')
: modalError}
</div>
<div className={css.modalError} role="alert">{modalError}</div>
</Modal>
</>
)

View File

@@ -13,8 +13,8 @@
* picking interaction — a renderless native-chooser driver or an in-app
* browsing dialog. ui-workspace owns the trigger (the "Add workspace…"
* entry, present only while the hole is occupied) and the adoption
* semantics (`createWorkspace({ path })`, the conflict/error dialog, Choose
* again); the occupant owns everything between `open` and the picked path,
* semantics (`createWorkspace({ path })`, the retryable error dialog,
* Choose again); the occupant owns everything between `open` and the picked path,
* including creating a new directory to hand back. That occupant-owned
* creation is why adding a workspace has a single route: an unoccupied hole
* leaves the surface with no add affordance at all.

View File

@@ -26,8 +26,6 @@ export const zh = {
'search.hasMore': '仅显示前 {n} 条结果,请缩小搜索范围。',
'menu.addWorkspace': '添加工作区…',
'picker.loading': '正在加载工作区…',
'conflict.title': '已存在同名工作区',
'conflict.hint': '请选择其他名称的文件夹。',
'conflict.named': '已存在名为“{name}”的工作区。',
'folderError.title': '无法打开文件夹',
'folderError.retry': '重新选择',
@@ -85,8 +83,6 @@ export const en = {
'search.hasMore': 'Showing the first {n} results. Narrow your search.',
'menu.addWorkspace': 'Add workspace…',
'picker.loading': 'Loading workspaces…',
'conflict.title': 'A workspace with this name already exists',
'conflict.hint': 'Choose a folder with a different name.',
'conflict.named': 'A workspace named “{name}” already exists.',
'folderError.title': 'Couldnt open folder',
'folderError.retry': 'Choose again',

View File

@@ -725,7 +725,7 @@ describe('WorkspaceBrowser', () => {
await act(async () => { resolveDelete() })
// RPC success alone does not close: the component waits until its
// useWorkspaces projection has committed the removal, preventing a stale
// duplicate-name frame from leaking into the next create gesture.
// Workspace frame from leaking into the next gesture.
expect(screen.getByRole('dialog', { name: '删除工作区' })).toBeTruthy()
rerender(browser, { useWorkspaces: hook(workspaceState([])) })
expect(screen.queryByRole('dialog', { name: '删除工作区' })).toBeNull()

View File

@@ -4,7 +4,6 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
import type {
SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from '../src/client/contract/slots.ts'
@@ -115,10 +114,12 @@ function chooseAdd(): void {
}
describe('WorkspacePicker', () => {
it('lists real Workspaces from useWorkspaces and forwards a selected id', () => {
const b = mount()
fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' }))
expect(b.onPick).toHaveBeenCalledWith(wid('alpha'))
it('lists same-title Workspaces separately and forwards the selected id', () => {
const b = mount([workspace('alpha', 'Shared'), workspace('beta', 'Shared')])
const entries = screen.getAllByRole('menuitem', { name: 'Shared' })
expect(entries).toHaveLength(2)
fireEvent.click(entries[1]!)
expect(b.onPick).toHaveBeenCalledWith(wid('beta'))
})
it('opens the composed directory flow, adopts its picked path, and selects the returned Workspace', async () => {
@@ -156,26 +157,6 @@ describe('WorkspacePicker', () => {
expect(screen.queryByRole('dialog')).toBeNull()
})
it('shows a name conflict and retries by reopening the flow', async () => {
const createWorkspace = vi.fn(async () => {
throw new WorkspaceCreateError({
code: 'workspace-name-conflict', message: 'project already exists', details: { name: 'project' },
})
})
const b = mount([workspace('alpha', 'Alpha')], createWorkspace)
chooseAdd()
await act(async () => { b.probe.owner!.onPicked('/one/project') })
await waitFor(() => {
expect(screen.getByRole('dialog', { name: '已存在同名工作区' })).toBeTruthy()
})
expect(screen.getByRole('alert').textContent).toBe('请选择其他名称的文件夹。')
// The failed adoption withdrew the flow; Choose again reopens it.
expect(b.probe.owner!.open).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '重新选择' }))
expect(b.probe.owner!.open).toBe(true)
expect(b.onPick).not.toHaveBeenCalled()
})
it('reports a non-Error adoption failure in the folder-error surface', async () => {
const b = mount([workspace('alpha', 'Alpha')], vi.fn(async () => { throw 'permission denied' }))
chooseAdd()
@@ -184,6 +165,9 @@ describe('WorkspacePicker', () => {
expect(screen.getByRole('dialog', { name: '无法打开文件夹' })).toBeTruthy()
})
expect(screen.getByRole('alert').textContent).toBe('permission denied')
expect(b.probe.owner!.open).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '重新选择' }))
expect(b.probe.owner!.open).toBe(true)
expect(b.onPick).not.toHaveBeenCalled()
})