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:
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# 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
|
# 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.md: a30675f2d84b6ae68b95ab96df9e32106d6fbf5d
|
||||||
2026-07-28-directory-picker-capability-seam.zh.md: 282f3905c3551912915088f70247260310f442cb
|
2026-07-28-directory-picker-capability-seam.zh.md: 5560aba07424d7307e386e2e8ae6b724486d6068
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Placement and policy rulings folded into this decision:
|
|||||||
|
|
||||||
## Consequences
|
## 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.
|
- 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.
|
- 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.
|
- `ApiProxyDefaults.pickDirectory` (test-only injection) is gone; tests provide a stub `ctx.directoryPicker` like any other service.
|
||||||
|
|||||||
@@ -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 提供确定性浏览树供无密钥组装测试使用。
|
- 协议新增 `host.listDirectory`/`host.createDirectory`、四个错误码与 `describe.directoryPicker` 字段;connection fixture 提供确定性浏览树供无密钥组装测试使用。
|
||||||
- 未来的新交互(或 Electron 的 `dialog` 提供方)只是一个后端包加一个客户端分支——无需网关手术。
|
- 未来的新交互(或 Electron 的 `dialog` 提供方)只是一个后端包加一个客户端分支——无需网关手术。
|
||||||
- `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。
|
- `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。
|
||||||
|
|||||||
@@ -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 () => {
|
it('selects the recent Workspace and opens its blank Session on first load', async () => {
|
||||||
boot('?fixture')
|
boot('?fixture')
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
|
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
|
||||||
README.md: edd6c2f9373d97832def86bb44658d7c1c68dae9
|
README.md: 58aaf56e1953f00417492d766d8f4ae4a0081c81
|
||||||
README.zh.md: f7b73dde953d4294d4d157f479fe932adf1a29c4
|
README.zh.md: 7c7b33e0ea68fefee8f857cb5e67a36ec5a854f5
|
||||||
|
|||||||
@@ -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.
|
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.
|
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot,因此两个表层使用同一菜单和创建流程。
|
共享 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 的插件恢复后重新注册。
|
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
|
||||||
|
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ export function WorkspaceBrowser({
|
|||||||
insertSessionBefore,
|
insertSessionBefore,
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
pickDirectory,
|
pickDirectory,
|
||||||
|
directoryPickerKind,
|
||||||
}: WorkspaceBrowserProps) {
|
}: WorkspaceBrowserProps) {
|
||||||
const workspaces = useWorkspaces(state => state.items)
|
const workspaces = useWorkspaces(state => state.items)
|
||||||
const groupBy = useStore(s => s.groupBy)
|
const groupBy = useStore(s => s.groupBy)
|
||||||
@@ -372,6 +373,7 @@ export function WorkspaceBrowser({
|
|||||||
useWorkspaces={useWorkspaces}
|
useWorkspaces={useWorkspaces}
|
||||||
createWorkspace={createWorkspace}
|
createWorkspace={createWorkspace}
|
||||||
pickDirectory={pickDirectory}
|
pickDirectory={pickDirectory}
|
||||||
|
directoryPickerKind={directoryPickerKind}
|
||||||
onPick={(workspaceId) => {
|
onPick={(workspaceId) => {
|
||||||
setWsPickerOpen(false)
|
setWsPickerOpen(false)
|
||||||
startSession(workspaceId)
|
startSession(workspaceId)
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
* slot registration.
|
* slot registration.
|
||||||
*/
|
*/
|
||||||
import type { RefObject } from 'react'
|
import type { RefObject } from 'react'
|
||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
|
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
|
||||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import {
|
import {
|
||||||
WorkspaceCreateError,
|
WorkspaceCreateError,
|
||||||
type WorkspaceId, type WorkspaceListState, type WorkspaceView,
|
type DirectoryPickerKind, type WorkspaceId, type WorkspaceListState, type WorkspaceView,
|
||||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import type { WorkspacePickerProps } from './contract/slots.ts'
|
import type { WorkspacePickerProps } from './contract/slots.ts'
|
||||||
import css from './WorkspacePicker.module.css'
|
import css from './WorkspacePicker.module.css'
|
||||||
@@ -33,6 +33,8 @@ export interface WorkspaceCreateFlowProps {
|
|||||||
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
|
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
|
||||||
/** Open the Host's native single-directory picker. */
|
/** Open the Host's native single-directory picker. */
|
||||||
pickDirectory: () => Promise<string | null>
|
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. */
|
/** A real Workspace was picked or created. */
|
||||||
onPick: (workspaceId: WorkspaceId) => void
|
onPick: (workspaceId: WorkspaceId) => void
|
||||||
/** Close the popover (outside click / Escape / post-pick). */
|
/** Close the popover (outside click / Escape / post-pick). */
|
||||||
@@ -50,6 +52,7 @@ export function WorkspaceCreateFlow({
|
|||||||
useWorkspaces,
|
useWorkspaces,
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
pickDirectory,
|
pickDirectory,
|
||||||
|
directoryPickerKind,
|
||||||
onPick,
|
onPick,
|
||||||
onClose,
|
onClose,
|
||||||
}: WorkspaceCreateFlowProps) {
|
}: WorkspaceCreateFlowProps) {
|
||||||
@@ -69,6 +72,22 @@ export function WorkspaceCreateFlow({
|
|||||||
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
|
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
|
||||||
&& workspaces.some(workspace => workspace.title === 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[] = [
|
const items: MenuEntry[] = [
|
||||||
...workspaces.map(workspace => ({
|
...workspaces.map(workspace => ({
|
||||||
id: workspace.workspaceId,
|
id: workspace.workspaceId,
|
||||||
@@ -77,7 +96,9 @@ export function WorkspaceCreateFlow({
|
|||||||
disabled: pickingFolder,
|
disabled: pickingFolder,
|
||||||
})),
|
})),
|
||||||
...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []),
|
...(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 },
|
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: pickingFolder },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -229,6 +250,7 @@ export function WorkspacePicker({
|
|||||||
onClose,
|
onClose,
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
pickDirectory,
|
pickDirectory,
|
||||||
|
directoryPickerKind,
|
||||||
}: WorkspacePickerProps) {
|
}: WorkspacePickerProps) {
|
||||||
return (
|
return (
|
||||||
<WorkspaceCreateFlow
|
<WorkspaceCreateFlow
|
||||||
@@ -237,6 +259,7 @@ export function WorkspacePicker({
|
|||||||
useWorkspaces={useWorkspaces}
|
useWorkspaces={useWorkspaces}
|
||||||
createWorkspace={createWorkspace}
|
createWorkspace={createWorkspace}
|
||||||
pickDirectory={pickDirectory}
|
pickDirectory={pickDirectory}
|
||||||
|
directoryPickerKind={directoryPickerKind}
|
||||||
onPick={onPick}
|
onPick={onPick}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
|||||||
// runtime shares below.
|
// runtime shares below.
|
||||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/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'
|
import type { createWorkspaceViewStore } from '../stores.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +44,8 @@ export type WorkspaceBrowserInjected = {
|
|||||||
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
|
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
|
||||||
/** Ask the local Host to open its native single-directory picker. */
|
/** Ask the local Host to open its native single-directory picker. */
|
||||||
pickDirectory: () => Promise<string | null>
|
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. */
|
/** 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>
|
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
|
||||||
/** Ask the local Host to open its native single-directory picker. */
|
/** Ask the local Host to open its native single-directory picker. */
|
||||||
pickDirectory: () => Promise<string | null>
|
pickDirectory: () => Promise<string | null>
|
||||||
|
/** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */
|
||||||
|
directoryPickerKind: () => Promise<DirectoryPickerKind>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -45,10 +45,12 @@ export function apply(ctx: ClientContext): void {
|
|||||||
},
|
},
|
||||||
createWorkspace: input => ctx.workspaces.create(input),
|
createWorkspace: input => ctx.workspaces.create(input),
|
||||||
pickDirectory: () => ctx.workspaces.pickDirectory(),
|
pickDirectory: () => ctx.workspaces.pickDirectory(),
|
||||||
|
directoryPickerKind: () => ctx.workspaces.directoryPickerKind(),
|
||||||
})
|
})
|
||||||
const pickerInjected = (): WorkspacePickerInjected => ({
|
const pickerInjected = (): WorkspacePickerInjected => ({
|
||||||
createWorkspace: input => ctx.workspaces.create(input),
|
createWorkspace: input => ctx.workspaces.create(input),
|
||||||
pickDirectory: () => ctx.workspaces.pickDirectory(),
|
pickDirectory: () => ctx.workspaces.pickDirectory(),
|
||||||
|
directoryPickerKind: () => ctx.workspaces.directoryPickerKind(),
|
||||||
})
|
})
|
||||||
// Declaration-aware registration: each owner's declaring apply may activate
|
// Declaration-aware registration: each owner's declaring apply may activate
|
||||||
// after this one (entry activation order is unconstrained), and a register
|
// after this one (entry activation order is unconstrained), and a register
|
||||||
|
|||||||
@@ -15,16 +15,17 @@ async function bench() {
|
|||||||
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
|
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
|
||||||
}))
|
}))
|
||||||
const pickDirectory = vi.fn(async () => '/tmp/picked')
|
const pickDirectory = vi.fn(async () => '/tmp/picked')
|
||||||
|
const directoryPickerKind = vi.fn(async () => 'dialog' as const)
|
||||||
const startSession = vi.fn()
|
const startSession = vi.fn()
|
||||||
const rename = vi.fn(async () => ({}))
|
const rename = vi.fn(async () => ({}))
|
||||||
const insertSessionBefore = vi.fn(async () => ({}))
|
const insertSessionBefore = vi.fn(async () => ({}))
|
||||||
const open = vi.fn()
|
const open = vi.fn()
|
||||||
const clear = vi.fn()
|
const clear = vi.fn()
|
||||||
ctx.provide('workspaces', {
|
ctx.provide('workspaces', {
|
||||||
create, pickDirectory, startSession, rename, insertSessionBefore,
|
create, pickDirectory, directoryPickerKind, startSession, rename, insertSessionBefore,
|
||||||
} as never)
|
} as never)
|
||||||
ctx.provide('sessions', { open, clear } 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'
|
type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace'
|
||||||
@@ -75,12 +76,16 @@ describe('ui-workspace apply', () => {
|
|||||||
expect(b.create).toHaveBeenCalledWith({ name: 'project' })
|
expect(b.create).toHaveBeenCalledWith({ name: 'project' })
|
||||||
await browser.pickDirectory()
|
await browser.pickDirectory()
|
||||||
expect(b.pickDirectory).toHaveBeenCalledOnce()
|
expect(b.pickDirectory).toHaveBeenCalledOnce()
|
||||||
|
await browser.directoryPickerKind()
|
||||||
|
expect(b.directoryPickerKind).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
|
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
|
||||||
await picker.createWorkspace({ path: '/tmp/project' })
|
await picker.createWorkspace({ path: '/tmp/project' })
|
||||||
expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' })
|
expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' })
|
||||||
await picker.pickDirectory()
|
await picker.pickDirectory()
|
||||||
expect(b.pickDirectory).toHaveBeenCalledTimes(2)
|
expect(b.pickDirectory).toHaveBeenCalledTimes(2)
|
||||||
|
await picker.directoryPickerKind()
|
||||||
|
expect(b.directoryPickerKind).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('unregisters every entry on teardown', async () => {
|
it('unregisters every entry on teardown', async () => {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
|||||||
insertSessionBefore: vi.fn(async () => {}),
|
insertSessionBefore: vi.fn(async () => {}),
|
||||||
createWorkspace: vi.fn(async () => workspace('created', [])),
|
createWorkspace: vi.fn(async () => workspace('created', [])),
|
||||||
pickDirectory: vi.fn(async () => null),
|
pickDirectory: vi.fn(async () => null),
|
||||||
|
directoryPickerKind: vi.fn(async () => 'dialog' as const),
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
const view = render(<WorkspaceBrowser {...props} />)
|
const view = render(<WorkspaceBrowser {...props} />)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ function mount(
|
|||||||
items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
|
items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
|
||||||
createWorkspace = vi.fn(),
|
createWorkspace = vi.fn(),
|
||||||
pickDirectory = vi.fn(async () => null as string | null),
|
pickDirectory = vi.fn(async () => null as string | null),
|
||||||
|
directoryPickerKind = vi.fn(async () => 'dialog'),
|
||||||
) {
|
) {
|
||||||
const onPick = vi.fn()
|
const onPick = vi.fn()
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
@@ -53,19 +54,22 @@ function mount(
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
createWorkspace={createWorkspace}
|
createWorkspace={createWorkspace}
|
||||||
pickDirectory={pickDirectory}
|
pickDirectory={pickDirectory}
|
||||||
|
directoryPickerKind={directoryPickerKind}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
const view = render(
|
const view = render(
|
||||||
renderPicker(items),
|
renderPicker(items),
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
view, onPick, onClose, createWorkspace, pickDirectory,
|
view, onPick, onClose, createWorkspace, pickDirectory, directoryPickerKind,
|
||||||
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
|
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): void {
|
// findByRole, not getByRole: the folder entry renders only after the advertised
|
||||||
fireEvent.click(screen.getByRole('menuitem', { name }))
|
// 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', () => {
|
describe('WorkspacePicker', () => {
|
||||||
@@ -79,7 +83,7 @@ describe('WorkspacePicker', () => {
|
|||||||
const created = workspace('new', 'New')
|
const created = workspace('new', 'New')
|
||||||
const createWorkspace = vi.fn(async () => created)
|
const createWorkspace = vi.fn(async () => created)
|
||||||
const b = mount([], createWorkspace)
|
const b = mount([], createWorkspace)
|
||||||
chooseItem('Create a new workspace')
|
await chooseItem('Create a new workspace')
|
||||||
const input = screen.getByLabelText('New workspace name')
|
const input = screen.getByLabelText('New workspace name')
|
||||||
fireEvent.change(input, { target: { value: 'project-one' } })
|
fireEvent.change(input, { target: { value: 'project-one' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||||
@@ -92,7 +96,7 @@ describe('WorkspacePicker', () => {
|
|||||||
const createWorkspace = vi.fn(async () => created)
|
const createWorkspace = vi.fn(async () => created)
|
||||||
const pickDirectory = vi.fn(async () => '/tmp/project')
|
const pickDirectory = vi.fn(async () => '/tmp/project')
|
||||||
const b = mount([], createWorkspace, pickDirectory)
|
const b = mount([], createWorkspace, pickDirectory)
|
||||||
chooseItem('Open local folder…')
|
await chooseItem('Open local folder…')
|
||||||
expect(pickDirectory).toHaveBeenCalledOnce()
|
expect(pickDirectory).toHaveBeenCalledOnce()
|
||||||
await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) })
|
await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) })
|
||||||
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 () => {
|
it('treats native picker cancellation as a silent no-op', async () => {
|
||||||
const b = mount([], vi.fn(), vi.fn(async () => null))
|
const b = mount([], vi.fn(), vi.fn(async () => null))
|
||||||
chooseItem('Open local folder…')
|
await chooseItem('Open local folder…')
|
||||||
await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() })
|
await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() })
|
||||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||||
expect(b.onPick).not.toHaveBeenCalled()
|
expect(b.onPick).not.toHaveBeenCalled()
|
||||||
@@ -118,7 +122,7 @@ describe('WorkspacePicker', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
const b = mount([], createWorkspace, pickDirectory)
|
const b = mount([], createWorkspace, pickDirectory)
|
||||||
chooseItem('Open local folder…')
|
await chooseItem('Open local folder…')
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy()
|
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
|
let resolve!: (path: string | null) => void
|
||||||
const pending = new Promise<string | null>((settle) => { resolve = settle })
|
const pending = new Promise<string | null>((settle) => { resolve = settle })
|
||||||
const b = mount([], vi.fn(), vi.fn(() => pending))
|
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: 'Open local folder…' }).disabled).toBe(true)
|
||||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).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…' }))
|
fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' }))
|
||||||
@@ -142,23 +146,23 @@ describe('WorkspacePicker', () => {
|
|||||||
|
|
||||||
it('reports non-Error native picker failures', async () => {
|
it('reports non-Error native picker failures', async () => {
|
||||||
const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' }))
|
const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' }))
|
||||||
chooseItem('Open local folder…')
|
await chooseItem('Open local folder…')
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('alert').textContent).toBe('picker unavailable')
|
expect(screen.getByRole('alert').textContent).toBe('picker unavailable')
|
||||||
})
|
})
|
||||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
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([])
|
mount([])
|
||||||
chooseItem('Create a new workspace')
|
await chooseItem('Create a new workspace')
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||||
expect(screen.queryByRole('dialog')).toBeNull()
|
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')])
|
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 ' } })
|
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('alert').textContent).toBe('A workspace named “Alpha” already exists.')
|
||||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Create workspace' }).disabled).toBe(true)
|
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 pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
|
||||||
const created = workspace('fresh', 'same-name')
|
const created = workspace('fresh', 'same-name')
|
||||||
const b = mount([], vi.fn(() => pending))
|
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.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||||
|
|
||||||
@@ -187,7 +191,7 @@ describe('WorkspacePicker', () => {
|
|||||||
const pending = new Promise<WorkspaceView>((_resolve, rejectPromise) => { reject = rejectPromise })
|
const pending = new Promise<WorkspaceView>((_resolve, rejectPromise) => { reject = rejectPromise })
|
||||||
const createWorkspace = vi.fn(() => pending)
|
const createWorkspace = vi.fn(() => pending)
|
||||||
const b = mount([], createWorkspace)
|
const b = mount([], createWorkspace)
|
||||||
chooseItem('Create a new workspace')
|
await chooseItem('Create a new workspace')
|
||||||
const input = screen.getByLabelText('New workspace name')
|
const input = screen.getByLabelText('New workspace name')
|
||||||
fireEvent.keyDown(input, { key: 'ArrowRight' })
|
fireEvent.keyDown(input, { key: 'ArrowRight' })
|
||||||
fireEvent.change(input, { target: { value: 'broken' } })
|
fireEvent.change(input, { target: { value: 'broken' } })
|
||||||
@@ -204,7 +208,7 @@ describe('WorkspacePicker', () => {
|
|||||||
|
|
||||||
it('reports non-Error creation failures', async () => {
|
it('reports non-Error creation failures', async () => {
|
||||||
const b = mount([], vi.fn(async () => { throw 'permission denied' }))
|
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' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied')
|
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied')
|
||||||
@@ -217,6 +221,7 @@ describe('WorkspacePicker', () => {
|
|||||||
<WorkspacePicker
|
<WorkspacePicker
|
||||||
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
|
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
|
||||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
|
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
|
||||||
|
directoryPickerKind={vi.fn(async () => 'dialog')}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
expect(screen.queryByRole('menu')).toBeNull()
|
expect(screen.queryByRole('menu')).toBeNull()
|
||||||
@@ -230,8 +235,37 @@ describe('WorkspacePicker', () => {
|
|||||||
<WorkspacePicker
|
<WorkspacePicker
|
||||||
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
|
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
|
||||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
|
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…')
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# 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
|
# pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md
|
||||||
README.md: f86f74acf4922d490b23c033a281436f1a428f13
|
README.md: 81357269e1d4b075f7e31b5f3ac5d4721811024a
|
||||||
README.zh.md: 0d240630a8b21003c5285bc75e93aac9adf36d92
|
README.zh.md: 06a7f7651b2abc0aa73eba41042f3d1a86661b76
|
||||||
|
|||||||
@@ -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.
|
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
|
## Model Experience
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
[目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 dialog 后端无法触及的远程客户端。
|
[目录选择 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)。
|
||||||
|
|
||||||
## 模型体验
|
## 模型体验
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
import { mkdir, readdir, stat } from 'node:fs/promises'
|
import { mkdir, readdir, stat } from 'node:fs/promises'
|
||||||
import { homedir } from 'node:os'
|
import { homedir } from 'node:os'
|
||||||
import { basename, dirname, join, resolve } from 'node:path'
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
||||||
import {
|
import {
|
||||||
DirectoryPicker, DirectoryPickerError,
|
DirectoryPicker, DirectoryPickerError,
|
||||||
} from '@deepseek-ai/dsh-host-directory-picker'
|
} from '@deepseek-ai/dsh-host-directory-picker'
|
||||||
@@ -81,6 +81,11 @@ export default class BrowseDirectoryPicker extends DirectoryPicker {
|
|||||||
|
|
||||||
private async list(path?: string): Promise<DirectoryListing> {
|
private async list(path?: string): Promise<DirectoryListing> {
|
||||||
const home = homedir()
|
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)
|
const target = resolve(path ?? home)
|
||||||
let names: { name: string; isDirectory: boolean; isSymbolicLink: boolean }[]
|
let names: { name: string; isDirectory: boolean; isSymbolicLink: boolean }[]
|
||||||
try {
|
try {
|
||||||
@@ -100,6 +105,10 @@ export default class BrowseDirectoryPicker extends DirectoryPicker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async createDirectory(path: string, name: string): Promise<string> {
|
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)
|
const parent = resolve(path)
|
||||||
// The backend owns segment validation (the wire schema also refuses these,
|
// The backend owns segment validation (the wire schema also refuses these,
|
||||||
// but direct service consumers must hit the same fence).
|
// but direct service consumers must hit the same fence).
|
||||||
|
|||||||
@@ -70,6 +70,19 @@ describe('BrowseDirectoryPicker', () => {
|
|||||||
expect((failure as DirectoryPickerError).path).toBe(missing)
|
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 () => {
|
it('creates one child directory and surfaces it in the next listing', async () => {
|
||||||
const created = await capability.createDirectory(root, 'fresh')
|
const created = await capability.createDirectory(root, 'fresh')
|
||||||
expect(created).toBe(join(root, 'fresh'))
|
expect(created).toBe(join(root, 'fresh'))
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ export interface DirectoryPickerBrowseCapability {
|
|||||||
* List one directory level.
|
* List one directory level.
|
||||||
* @param path - absolute directory to list; absent lists the home directory.
|
* @param path - absolute directory to list; absent lists the home directory.
|
||||||
* @returns the level's listing with ancestry.
|
* @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>
|
list(path?: string): Promise<DirectoryListing>
|
||||||
/**
|
/**
|
||||||
@@ -68,7 +69,8 @@ export interface DirectoryPickerBrowseCapability {
|
|||||||
* @param path - absolute existing parent directory.
|
* @param path - absolute existing parent directory.
|
||||||
* @param name - single non-blank path segment (no separators, not `.`/`..`).
|
* @param name - single non-blank path segment (no separators, not `.`/`..`).
|
||||||
* @returns the created directory's absolute path.
|
* @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>
|
createDirectory(path: string, name: string): Promise<string>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user