fix(host,client): gate the picker affordance on the advertised kind; reject non-absolute browse paths

ds-review-bot round 2. The workspace UI never consulted the advertised
directoryPicker kind: under a browse (or merge-added) backend it still
rendered 'Open local folder…' and called pickDirectory(), which the host
answers with directory-picker-unavailable. The create flow now reads
directoryPickerKind() per menu open and renders the dialog affordance only
under 'dialog' — browse (until its in-app browser UI lands) and unknown
kinds hide the entry, realizing the seam's documented default; a keyless
workspace-flow snapshot pins the hidden entry over the browse fixture.

The browse backend also resolved wire paths, silently rebasing '' or
relative parents under the host process cwd; both primitives now reject
non-absolute explicit paths with their business codes, and the seam JSDoc
carries the contract.
This commit is contained in:
creatixchu
2026-07-28 17:39:15 +08:00
parent 6e299cd55a
commit cd7aa3c7d8
20 changed files with 146 additions and 37 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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md
2026-07-28-directory-picker-capability-seam.md: 8d9d34e7aed4525b243380a4a90801fe59bfc213
2026-07-28-directory-picker-capability-seam.zh.md: 282f3905c3551912915088f70247260310f442cb
2026-07-28-directory-picker-capability-seam.md: a30675f2d84b6ae68b95ab96df9e32106d6fbf5d
2026-07-28-directory-picker-capability-seam.zh.md: 5560aba07424d7307e386e2e8ae6b724486d6068

View File

@@ -30,7 +30,7 @@ Placement and policy rulings folded into this decision:
## Consequences
- `cordis.yml` chooses the interaction; `apps/cli` currently mounts `-dialog` (unchanged behavior), and the in-app browser PR flips the default to `-browse` with the GUI branching on `describe`.
- `cordis.yml` chooses the interaction; `apps/cli` currently mounts `-dialog` (unchanged behavior). The GUI already gates its dialog affordance on `describe.directoryPicker` (non-`dialog` kinds hide it); the in-app browser PR flips the default to `-browse` and adds the browse UI.
- The wire gains `host.listDirectory`/`host.createDirectory`, four error codes, and the `describe.directoryPicker` field; the connection fixture serves a deterministic browse tree for keyless assembled tests.
- A future interaction (or an Electron `dialog` provider) is one backend package plus a client branch — no gateway surgery.
- `ApiProxyDefaults.pickDirectory` (test-only injection) is gone; tests provide a stub `ctx.directoryPicker` like any other service.

View File

@@ -30,7 +30,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick
## 后果
- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-dialog`(行为不变)应用内浏览器 PR 将把默认翻到 `-browse`让 GUI 按 `describe` 分支
- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-dialog`(行为不变)。GUI 已按 `describe.directoryPicker` 门控其对话框入口(非 `dialog` kind 一律隐藏);应用内浏览器 PR 将把默认翻到 `-browse`补上浏览 UI
- 协议新增 `host.listDirectory``host.createDirectory`、四个错误码与 `describe.directoryPicker` 字段connection fixture 提供确定性浏览树供无密钥组装测试使用。
- 未来的新交互(或 Electron 的 `dialog` 提供方)只是一个后端包加一个客户端分支——无需网关手术。
- `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`

View File

@@ -174,6 +174,20 @@ it('locks the composer in the New Session view state until a Workspace is chosen
`)
})
it('hides the Open-local-folder entry under the fixture host\'s browse picker capability', async () => {
boot('?fixture=empty')
await findLockedComposer()
fireEvent.click(workspaceChip())
const menu = await screen.findByRole('menu')
// Flush the advertised-kind read (fixture describe resolves in microtasks):
// the fixture serves `browse`, whose in-app UI is not wired yet, so the
// dialog affordance must not render — only the create action remains.
await act(async () => {})
expect(within(menu).getAllByRole('menuitem').map(item => visibleText(item)))
.toEqual(['Create a new workspace'])
})
it('selects the recent Workspace and opens its blank Session on first load', async () => {
boot('?fixture')

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: edd6c2f9373d97832def86bb44658d7c1c68dae9
README.zh.md: f7b73dde953d4294d4d157f479fe932adf1a29c4
README.md: 58aaf56e1953f00417492d766d8f4ae4a0081c81
README.zh.md: 7c7b33e0ea68fefee8f857cb5e67a36ec5a854f5

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sidebar's `sidebar.workspaces` slot and `WorkspacePicker` into the page-local Session Intent hero's `conversation.hero.workspace` slot, so both surfaces use the same menu and creation flow.
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. The flat **Open local folder...** action delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. 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 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. The flat **Open local folder...** action renders only when the Host advertises the `dialog` picker interaction (read per flow open through `host.describe`); `browse` — until its in-app browser UI lands — and unknown kinds hide the entry, the seam's documented default. When shown, it delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. 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.
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

@@ -4,7 +4,7 @@
共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot因此两个表层使用同一菜单和创建流程。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace取消操作不会显示提示发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作仅在 Host 广播 `dialog` 选择交互时渲染(每次流程打开时通过 `host.describe` 读取);`browse`(在其应用内浏览器 UI 落地之前)以及未知 kind 都会隐藏该入口,即 seam 文档化的默认行为。显示时它会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace取消操作不会显示提示发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。

View File

@@ -254,6 +254,7 @@ export function WorkspaceBrowser({
insertSessionBefore,
createWorkspace,
pickDirectory,
directoryPickerKind,
}: WorkspaceBrowserProps) {
const workspaces = useWorkspaces(state => state.items)
const groupBy = useStore(s => s.groupBy)
@@ -372,6 +373,7 @@ export function WorkspaceBrowser({
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
directoryPickerKind={directoryPickerKind}
onPick={(workspaceId) => {
setWsPickerOpen(false)
startSession(workspaceId)

View File

@@ -5,13 +5,13 @@
* slot registration.
*/
import type { RefObject } from 'react'
import { useCallback, useState } from 'react'
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,
type DirectoryPickerKind, type WorkspaceId, type WorkspaceListState, type WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspacePickerProps } from './contract/slots.ts'
import css from './WorkspacePicker.module.css'
@@ -33,6 +33,8 @@ export interface WorkspaceCreateFlowProps {
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Open the Host's native single-directory picker. */
pickDirectory: () => Promise<string | null>
/** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */
directoryPickerKind: () => Promise<DirectoryPickerKind>
/** A real Workspace was picked or created. */
onPick: (workspaceId: WorkspaceId) => void
/** Close the popover (outside click / Escape / post-pick). */
@@ -50,6 +52,7 @@ export function WorkspaceCreateFlow({
useWorkspaces,
createWorkspace,
pickDirectory,
directoryPickerKind,
onPick,
onClose,
}: WorkspaceCreateFlowProps) {
@@ -69,6 +72,22 @@ export function WorkspaceCreateFlow({
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
// The advertised interaction gates the picking affordance: 'dialog' is the
// only kind pickDirectory() can serve, so its entry renders under that kind
// alone; 'browse' (until the in-app browser UI lands) and unknown kinds
// hide the entry, the seam's documented unknown-kind default. Re-read per
// flow open — no cache to go stale across reconnects.
const [dialogPicker, setDialogPicker] = useState(false)
useEffect(() => {
if (!open) return
void directoryPickerKind()
.then((kind) => { setDialogPicker(kind === 'dialog') })
// A failed describe hides the entry too: the same Host that cannot
// answer describe cannot serve pickDirectory. (Post-unmount settlement
// is safe: React 18 no-ops setState on unmounted components.)
.catch(() => { setDialogPicker(false) })
}, [open, directoryPickerKind])
const items: MenuEntry[] = [
...workspaces.map(workspace => ({
id: workspace.workspaceId,
@@ -77,7 +96,9 @@ export function WorkspaceCreateFlow({
disabled: pickingFolder,
})),
...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []),
{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: pickingFolder },
...(dialogPicker
? [{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: pickingFolder }]
: []),
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: pickingFolder },
]
@@ -229,6 +250,7 @@ export function WorkspacePicker({
onClose,
createWorkspace,
pickDirectory,
directoryPickerKind,
}: WorkspacePickerProps) {
return (
<WorkspaceCreateFlow
@@ -237,6 +259,7 @@ export function WorkspacePicker({
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
directoryPickerKind={directoryPickerKind}
onPick={onPick}
onClose={onClose}
/>

View File

@@ -13,7 +13,7 @@ import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
// runtime shares below.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type { DirectoryPickerKind, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type { createWorkspaceViewStore } from '../stores.ts'
/**
@@ -44,6 +44,8 @@ export type WorkspaceBrowserInjected = {
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Ask the local Host to open its native single-directory picker. */
pickDirectory: () => Promise<string | null>
/** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */
directoryPickerKind: () => Promise<DirectoryPickerKind>
}
/** Full browser props: shell owner share + viewing store + injected actions. */
@@ -62,6 +64,8 @@ export type WorkspacePickerInjected = {
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Ask the local Host to open its native single-directory picker. */
pickDirectory: () => Promise<string | null>
/** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */
directoryPickerKind: () => Promise<DirectoryPickerKind>
}
/**

View File

@@ -45,10 +45,12 @@ export function apply(ctx: ClientContext): void {
},
createWorkspace: input => ctx.workspaces.create(input),
pickDirectory: () => ctx.workspaces.pickDirectory(),
directoryPickerKind: () => ctx.workspaces.directoryPickerKind(),
})
const pickerInjected = (): WorkspacePickerInjected => ({
createWorkspace: input => ctx.workspaces.create(input),
pickDirectory: () => ctx.workspaces.pickDirectory(),
directoryPickerKind: () => ctx.workspaces.directoryPickerKind(),
})
// Declaration-aware registration: each owner's declaring apply may activate
// after this one (entry activation order is unconstrained), and a register

View File

@@ -15,16 +15,17 @@ async function bench() {
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
}))
const pickDirectory = vi.fn(async () => '/tmp/picked')
const directoryPickerKind = vi.fn(async () => 'dialog' as const)
const startSession = vi.fn()
const rename = vi.fn(async () => ({}))
const insertSessionBefore = vi.fn(async () => ({}))
const open = vi.fn()
const clear = vi.fn()
ctx.provide('workspaces', {
create, pickDirectory, startSession, rename, insertSessionBefore,
create, pickDirectory, directoryPickerKind, startSession, rename, insertSessionBefore,
} as never)
ctx.provide('sessions', { open, clear } as never)
return { ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory, startSession, rename, insertSessionBefore, open, clear }
return { ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory, directoryPickerKind, startSession, rename, insertSessionBefore, open, clear }
}
type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace'
@@ -75,12 +76,16 @@ describe('ui-workspace apply', () => {
expect(b.create).toHaveBeenCalledWith({ name: 'project' })
await browser.pickDirectory()
expect(b.pickDirectory).toHaveBeenCalledOnce()
await browser.directoryPickerKind()
expect(b.directoryPickerKind).toHaveBeenCalledOnce()
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
await picker.createWorkspace({ path: '/tmp/project' })
expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' })
await picker.pickDirectory()
expect(b.pickDirectory).toHaveBeenCalledTimes(2)
await picker.directoryPickerKind()
expect(b.directoryPickerKind).toHaveBeenCalledTimes(2)
})
it('unregisters every entry on teardown', async () => {

View File

@@ -60,6 +60,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
insertSessionBefore: vi.fn(async () => {}),
createWorkspace: vi.fn(async () => workspace('created', [])),
pickDirectory: vi.fn(async () => null),
directoryPickerKind: vi.fn(async () => 'dialog' as const),
...overrides,
}
const view = render(<WorkspaceBrowser {...props} />)

View File

@@ -39,6 +39,7 @@ function mount(
items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
createWorkspace = vi.fn(),
pickDirectory = vi.fn(async () => null as string | null),
directoryPickerKind = vi.fn(async () => 'dialog'),
) {
const onPick = vi.fn()
const onClose = vi.fn()
@@ -53,19 +54,22 @@ function mount(
onClose={onClose}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
directoryPickerKind={directoryPickerKind}
/>
)
const view = render(
renderPicker(items),
)
return {
view, onPick, onClose, createWorkspace, pickDirectory,
view, onPick, onClose, createWorkspace, pickDirectory, directoryPickerKind,
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
}
}
function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): void {
fireEvent.click(screen.getByRole('menuitem', { name }))
// findByRole, not getByRole: the folder entry renders only after the advertised
// picker kind resolves, one microtask after the menu opens.
async function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): Promise<void> {
fireEvent.click(await screen.findByRole('menuitem', { name }))
}
describe('WorkspacePicker', () => {
@@ -79,7 +83,7 @@ describe('WorkspacePicker', () => {
const created = workspace('new', 'New')
const createWorkspace = vi.fn(async () => created)
const b = mount([], createWorkspace)
chooseItem('Create a new workspace')
await chooseItem('Create a new workspace')
const input = screen.getByLabelText('New workspace name')
fireEvent.change(input, { target: { value: 'project-one' } })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
@@ -92,7 +96,7 @@ describe('WorkspacePicker', () => {
const createWorkspace = vi.fn(async () => created)
const pickDirectory = vi.fn(async () => '/tmp/project')
const b = mount([], createWorkspace, pickDirectory)
chooseItem('Open local folder…')
await chooseItem('Open local folder…')
expect(pickDirectory).toHaveBeenCalledOnce()
await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) })
expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' })
@@ -101,7 +105,7 @@ describe('WorkspacePicker', () => {
it('treats native picker cancellation as a silent no-op', async () => {
const b = mount([], vi.fn(), vi.fn(async () => null))
chooseItem('Open local folder…')
await chooseItem('Open local folder…')
await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() })
expect(b.createWorkspace).not.toHaveBeenCalled()
expect(b.onPick).not.toHaveBeenCalled()
@@ -118,7 +122,7 @@ describe('WorkspacePicker', () => {
})
})
const b = mount([], createWorkspace, pickDirectory)
chooseItem('Open local folder…')
await chooseItem('Open local folder…')
await waitFor(() => {
expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy()
})
@@ -132,7 +136,7 @@ describe('WorkspacePicker', () => {
let resolve!: (path: string | null) => void
const pending = new Promise<string | null>((settle) => { resolve = settle })
const b = mount([], vi.fn(), vi.fn(() => pending))
chooseItem('Open local folder…')
await chooseItem('Open local folder…')
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Open local folder…' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true)
fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' }))
@@ -142,23 +146,23 @@ describe('WorkspacePicker', () => {
it('reports non-Error native picker failures', async () => {
const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' }))
chooseItem('Open local folder…')
await chooseItem('Open local folder…')
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe('picker unavailable')
})
expect(b.createWorkspace).not.toHaveBeenCalled()
})
it('closes a creation modal when the user cancels', () => {
it('closes a creation modal when the user cancels', async () => {
mount([])
chooseItem('Create a new workspace')
await chooseItem('Create a new workspace')
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog')).toBeNull()
})
it('blocks a create-new name already present in the Workspace list', () => {
it('blocks a create-new name already present in the Workspace list', async () => {
const b = mount([workspace('alpha', 'Alpha')])
chooseItem('Create a new workspace')
await chooseItem('Create a new workspace')
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } })
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.')
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Create workspace' }).disabled).toBe(true)
@@ -171,7 +175,7 @@ describe('WorkspacePicker', () => {
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
const created = workspace('fresh', 'same-name')
const b = mount([], vi.fn(() => pending))
chooseItem('Create a new workspace')
await chooseItem('Create a new workspace')
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
@@ -187,7 +191,7 @@ describe('WorkspacePicker', () => {
const pending = new Promise<WorkspaceView>((_resolve, rejectPromise) => { reject = rejectPromise })
const createWorkspace = vi.fn(() => pending)
const b = mount([], createWorkspace)
chooseItem('Create a new workspace')
await chooseItem('Create a new workspace')
const input = screen.getByLabelText('New workspace name')
fireEvent.keyDown(input, { key: 'ArrowRight' })
fireEvent.change(input, { target: { value: 'broken' } })
@@ -204,7 +208,7 @@ describe('WorkspacePicker', () => {
it('reports non-Error creation failures', async () => {
const b = mount([], vi.fn(async () => { throw 'permission denied' }))
chooseItem('Create a new workspace')
await chooseItem('Create a new workspace')
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied')
@@ -217,6 +221,7 @@ describe('WorkspacePicker', () => {
<WorkspacePicker
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
directoryPickerKind={vi.fn(async () => 'dialog')}
/>,
)
expect(screen.queryByRole('menu')).toBeNull()
@@ -230,8 +235,37 @@ describe('WorkspacePicker', () => {
<WorkspacePicker
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
directoryPickerKind={vi.fn(async () => 'dialog')}
/>,
)
expect(screen.getByRole('status').textContent).toBe('Loading workspaces…')
})
it('hides the folder affordance unless the Host advertises the dialog interaction', async () => {
const b = mount([], vi.fn(), vi.fn(async () => null), vi.fn(async () => 'browse'))
await screen.findByRole('menuitem', { name: 'Create a new workspace' })
await waitFor(() => { expect(b.directoryPickerKind).toHaveBeenCalled() })
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
})
it('hides the folder affordance when the Host cannot answer describe', async () => {
const b = mount([], vi.fn(), vi.fn(async () => null), vi.fn(async () => {
throw new Error('host unreachable')
}))
await screen.findByRole('menuitem', { name: 'Create a new workspace' })
await waitFor(() => { expect(b.directoryPickerKind).toHaveBeenCalled() })
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
})
it('does not read the picker kind while the flow is closed', () => {
const directoryPickerKind = vi.fn(async () => 'dialog')
render(
<WorkspacePicker
open={false} anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
directoryPickerKind={directoryPickerKind}
/>,
)
expect(directoryPickerKind).not.toHaveBeenCalled()
})
})

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/host/directory-picker-browse/README.md
README.md: f86f74acf4922d490b23c033a281436f1a428f13
README.zh.md: 0d240630a8b21003c5285bc75e93aac9adf36d92
README.md: 81357269e1d4b075f7e31b5f3ac5d4721811024a
README.zh.md: 06a7f7651b2abc0aa73eba41042f3d1a86661b76

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The **in-app browsing backend** of the [directory-picker seam](../directory-picker/README.md): `BrowseDirectoryPicker` registers `ctx.directoryPicker` with the `browse` capability — one-level directory listing and child-directory creation over Node's stdlib, which already carries the per-OS adaptation. Nothing renders on the host display, so this backend serves remote clients the dialog backend cannot.
Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject a non-absolute explicit path (`directory-unreadable`/`directory-create-failed`) instead of letting `resolve` rebase it under the host process cwd. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
## Model Experience

View File

@@ -4,7 +4,7 @@
[目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**`BrowseDirectoryPicker``browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 dialog 后端无法触及的远程客户端。
行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/``C:\``list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/``C:\``list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非绝对的显式路径(`directory-unreadable``directory-create-failed`),而不是任由 `resolve` 把它重定位到宿主进程 cwd 之下。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
## 模型体验

View File

@@ -11,7 +11,7 @@
import { mkdir, readdir, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { basename, dirname, join, resolve } from 'node:path'
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
import {
DirectoryPicker, DirectoryPickerError,
} from '@deepseek-ai/dsh-host-directory-picker'
@@ -81,6 +81,11 @@ export default class BrowseDirectoryPicker extends DirectoryPicker {
private async list(path?: string): Promise<DirectoryListing> {
const home = homedir()
// The seam contract takes absolute paths only; resolve() would silently
// rebase a relative or empty wire value under the host process cwd.
if (path !== undefined && !isAbsolute(path)) {
throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not an absolute path`)
}
const target = resolve(path ?? home)
let names: { name: string; isDirectory: boolean; isSymbolicLink: boolean }[]
try {
@@ -100,6 +105,10 @@ export default class BrowseDirectoryPicker extends DirectoryPicker {
}
private async createDirectory(path: string, name: string): Promise<string> {
// Same absolute-path fence as list: never rebase a parent under the cwd.
if (!isAbsolute(path)) {
throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not an absolute parent path`)
}
const parent = resolve(path)
// The backend owns segment validation (the wire schema also refuses these,
// but direct service consumers must hit the same fence).

View File

@@ -70,6 +70,19 @@ describe('BrowseDirectoryPicker', () => {
expect((failure as DirectoryPickerError).path).toBe(missing)
})
it('rejects non-absolute paths instead of rebasing them under the process cwd', async () => {
for (const relative of ['', 'projects', './projects', '..']) {
const listFailure = await capability.list(relative).catch((error: unknown) => error)
expect(listFailure).toBeInstanceOf(DirectoryPickerError)
expect((listFailure as DirectoryPickerError).code).toBe('directory-unreadable')
expect((listFailure as DirectoryPickerError).path).toBe(relative)
const createFailure = await capability.createDirectory(relative, 'child').catch((error: unknown) => error)
expect(createFailure).toBeInstanceOf(DirectoryPickerError)
expect((createFailure as DirectoryPickerError).code).toBe('directory-create-failed')
expect((createFailure as DirectoryPickerError).path).toBe(relative)
}
})
it('creates one child directory and surfaces it in the next listing', async () => {
const created = await capability.createDirectory(root, 'fresh')
expect(created).toBe(join(root, 'fresh'))

View File

@@ -60,7 +60,8 @@ export interface DirectoryPickerBrowseCapability {
* List one directory level.
* @param path - absolute directory to list; absent lists the home directory.
* @returns the level's listing with ancestry.
* @throws {DirectoryPickerError} `directory-unreadable` when the target cannot be listed.
* @throws {DirectoryPickerError} `directory-unreadable` when the target is not absolute
* (a wire value must never rebase under the host cwd) or cannot be listed.
*/
list(path?: string): Promise<DirectoryListing>
/**
@@ -68,7 +69,8 @@ export interface DirectoryPickerBrowseCapability {
* @param path - absolute existing parent directory.
* @param name - single non-blank path segment (no separators, not `.`/`..`).
* @returns the created directory's absolute path.
* @throws {DirectoryPickerError} `directory-exists` for an existing child, `directory-create-failed` otherwise.
* @throws {DirectoryPickerError} `directory-exists` for an existing child,
* `directory-create-failed` for a non-absolute parent or any other failure.
*/
createDirectory(path: string, name: string): Promise<string>
}