Merge branch 'master' into fix/remove-badge
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。只有浏览器接受剪贴板写入后,卡片才会显示由字典提供的已复制状态。
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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': 'Couldn’t open folder',
|
||||
'folderError.retry': 'Choose again',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user