feat(host,client): compose directory picking through slots — dual-face -native, no wire advertisement

ui-workspace's two trigger surfaces each declare a single-kind directory-flow
hole (conversation.hero.workspace.directoryFlow / sidebar.workspaces.directoryFlow,
same owner contract) and keep only the trigger and the adoption: the Open-local-
folder entry renders while the surface's hole is occupied, and the occupant
reports one picked path per open through the hole's owner conversation
(open/busy/onPicked/onCancel/onError).

directory-picker-native becomes dual-face: its browser half fills both holes
with a renderless occupant driving host.pickDirectory, so the cordis.yml row
that mounts the backend also composes the client interaction — a mismatch is
impossible and a second flow package fails at client load.

With composition wiring both sides, the host.describe.directoryPicker
advertisement and the client's kind branching lose their last consumer:
the field, WorkspacesService.directoryPickerKind(), the DirectoryPickerKind
wire type, and the picker's per-open describe read are deleted. The connection
fixture now serves a deterministic pickDirectory path so the keyless snapshot
drives the full pick-then-adopt flow. ui-workspace's hand-rolled declaration
deferral is replaced by the deferRegistration helper it duplicated.
This commit is contained in:
creatixchu
2026-07-28 21:51:01 +08:00
parent 51402ac7af
commit 85ca8be104
59 changed files with 614 additions and 385 deletions

View File

@@ -8,7 +8,7 @@
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing, DirectoryPickerKind,
DirectoryEntry, DirectoryListing,
WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,

View File

@@ -863,12 +863,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
},
},
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, directoryPicker: 'browse' as const }),
pickDirectory: request => err(request, {
code: 'directory-picker-unavailable',
message: 'the fixture host serves the browse capability',
details: { capability: 'browse' },
}),
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
// Deterministic native pick: the keyless lanes drive the full
// pick-then-adopt path without an OS chooser (design-mock content,
// same tree the browse primitives serve).
pickDirectory: request => ok(request, { path: `${FIXTURE_HOME}/Documents/project` }),
listDirectory: (request) => {
const target = request.payload.path ?? FIXTURE_HOME
const children = childrenOf(target)

View File

@@ -13,7 +13,7 @@ import { WebApiClient } from './web-api-client.ts'
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing, DirectoryPickerKind,
DirectoryEntry, DirectoryListing,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,

View File

@@ -75,7 +75,7 @@ describe('connection lifecycle', () => {
try {
await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff
expect(connected).toBe(0) // never announced during the failed generation
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, directoryPicker: 'browse' as const }))
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
await vi.waitFor(() => { expect(connected).toBe(1) })
} finally {
controller.stop()
@@ -199,7 +199,7 @@ describe('connection lifecycle', () => {
controller.start()
try {
await vi.waitFor(() => { expect(describeCalls).toBe(3) })
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, directoryPicker: 'browse' as const }))
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
await vi.waitFor(() => { expect(connected).toBe(1) })
expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission
} finally {

View File

@@ -63,8 +63,8 @@ export class FakeApiClient implements IApiClient {
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number; directoryPicker: 'native' | 'browse' }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0, directoryPicker: 'browse' as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
() => Promise.resolve(ok({ path: null }))
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =

View File

@@ -23,7 +23,7 @@ export type { SessionListPhase } from './sessions/manager.ts'
export type { WorkspaceListPhase } from './workspaces/manager.ts'
export type { WorkspaceListState } from './workspaces/service.ts'
export type {
DirectoryEntry, DirectoryListing, DirectoryPickerKind, WorkspaceId, WorkspaceView,
DirectoryEntry, DirectoryListing, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
// Runtime owns the snapshot store; web-react only binds it to React.
export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts'

View File

@@ -2,7 +2,7 @@
import type { Context } from 'cordis'
import type {
DirectoryListing, DirectoryPickerKind, IApiClient, RpcError,
DirectoryListing, IApiClient, RpcError,
SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '../contract/store.ts'
@@ -191,21 +191,6 @@ export class WorkspacesService {
return response.result.value.path
}
/**
* The directory-picking interaction the Host composed — the fact the picker
* UI branches on (`native` opens the native chooser; `browse` opens the
* in-app browser). Read per flow open: one describe round trip, no cache to
* go stale across reconnects.
* @returns the Host's advertised picker kind.
*/
async directoryPickerKind(): Promise<DirectoryPickerKind> {
const response = await this.api.host.describe({})
if (!response.result.ok) {
throw new Error(`host describe failed: ${response.result.error.message}`)
}
return response.result.value.directoryPicker
}
/**
* List one directory level through the Host's `browse` capability.
* @param path - absolute directory to list; absent lists the Host home directory.

View File

@@ -81,8 +81,8 @@ export class FakeApiClient implements IApiClient {
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number; directoryPicker: 'native' | 'browse' }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0, directoryPicker: 'browse' as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
() => Promise.resolve(ok({ path: null }))
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =

View File

@@ -238,15 +238,6 @@ describe('WorkspacesService', () => {
await expect(workspaces.pickDirectory()).rejects.toThrow(/no chooser/)
})
it('reads the picker kind from describe per call, failing loud on an unreachable host', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api))
await expect(workspaces.directoryPickerKind()).resolves.toBe('browse')
api.onDescribe = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} }))
await expect(workspaces.directoryPickerKind()).rejects.toThrow(/host describe failed/)
})
it('passes listings and creation through the browse wire, wrapping business failures', async () => {
const ctx = new Context()
const api = new FakeApiClient()

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: deaa25184f5ddbfc5980033ce60cef43577ff33c
README.zh.md: e14e8ca6a2e3d65ce5fc403291e45ebc03598e04
README.md: 8acf819121b46512d38b39ff858bb2bf797cfe96
README.zh.md: e97d93f7e38d00af91b43f0df9fb0e3b17ae8ed5

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 renders only when the Host advertises the `native` 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.
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 **Open local folder...** 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). 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. **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.
@@ -19,4 +19,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions.
- **Native folder selection depends on the local Host carrier** — fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal.
- **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow.

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 广播 `native` 选择交互时渲染(每次流程打开时通过 `host.describe` 读取);`browse`(在其应用内浏览器 UI 落地之前)以及未知 kind 都会隐藏该入口,即 seam 文档化的默认行为。显示时它会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace取消操作不会显示提示发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
该选择器通过全局 `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取消操作不会显示提示错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
@@ -19,4 +19,4 @@
## 已知限制与暂缓事项
- **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
- **原生文件夹选择依赖本地 Host 载体**:仅使用 fixture测试前置数据的部署或远程浏览器部署无法打开本地操作系统对话框模态框会显示平台故障并允许重试。
- **原生文件夹选择依赖本地 Host 载体**`-native` 组合下,仅使用 fixture测试前置数据的部署或远程浏览器部署无法打开本地操作系统对话框模态框会显示平台故障并允许重试。可远程的选取是 `-browse` 组合的应用内流程。

View File

@@ -253,8 +253,8 @@ export function WorkspaceBrowser({
deleteWorkspace,
insertSessionBefore,
createWorkspace,
pickDirectory,
directoryPickerKind,
hasDirectoryFlow,
renderSlot,
}: WorkspaceBrowserProps) {
const workspaces = useWorkspaces(state => state.items)
const groupBy = useStore(s => s.groupBy)
@@ -372,8 +372,8 @@ export function WorkspaceBrowser({
anchorRef={wsPlusRef}
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
directoryPickerKind={directoryPickerKind}
hasDirectoryFlow={hasDirectoryFlow}
renderDirectoryFlow={owner => renderSlot('sidebar.workspaces.directoryFlow', owner)}
createOnly
side="right"
onPick={(workspaceId) => {

View File

@@ -2,18 +2,20 @@
* Workspace pick/create flow. WorkspaceCreateFlow is the reusable core
* (menu + path/create dialogs) consumed directly by WorkspaceBrowser (same
* package) and wrapped by WorkspacePicker for the conversation empty-state
* slot registration.
* slot registration. Directory picking itself lives in the composed flow
* package's slot occupant (see the contract module doc): this core only
* opens the flow, adopts the picked path, and owns the error surface.
*/
import type { RefObject } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ReactNode, RefObject } from 'react'
import { useCallback, useRef, useState } from 'react'
import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import {
WorkspaceCreateError,
type DirectoryPickerKind, type WorkspaceId, type WorkspaceListState, type WorkspaceView,
type WorkspaceId, type WorkspaceListState, type WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspacePickerProps } from './contract/slots.ts'
import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts'
import css from './WorkspacePicker.module.css'
const OPEN_LOCAL_FOLDER = '::open-local-folder'
@@ -31,10 +33,10 @@ export interface WorkspaceCreateFlowProps {
useWorkspaces: <S>(selector: (state: WorkspaceListState) => S) => S
/** Create or adopt a real Host Workspace. */
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>
/** Whether this surface's directory-flow hole is occupied (read per menu render; empty hides the local-folder entry). */
hasDirectoryFlow: () => boolean
/** Render this surface's directory-flow hole with the owner conversation (the entry's narrowed renderSlot). */
renderDirectoryFlow: (owner: DirectoryFlowOwnerProps) => ReactNode
/** A real Workspace was picked or created. */
onPick: (workspaceId: WorkspaceId) => void
/** Close the popover (outside click / Escape / post-pick). */
@@ -57,8 +59,8 @@ export function WorkspaceCreateFlow({
anchorRef,
useWorkspaces,
createWorkspace,
pickDirectory,
directoryPickerKind,
hasDirectoryFlow,
renderDirectoryFlow,
onPick,
onClose,
createOnly = false,
@@ -75,6 +77,7 @@ export function WorkspaceCreateFlow({
const [workspaceName, setWorkspaceName] = useState('')
const [creating, setCreating] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const [flowOpen, setFlowOpen] = useState(false)
const [pickingFolder, setPickingFolder] = useState(false)
const [folderConflict, setFolderConflict] = useState(false)
const composingRef = useRef(false)
@@ -82,36 +85,12 @@ export function WorkspaceCreateFlow({
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
// The advertised interaction gates the picking affordance: 'native' 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 [nativePicker, setNativePicker] = useState(false)
useEffect(() => {
if (!open) {
// Close discards the answer: a reconnect or HMR can swap the composed
// backend while the menu is closed, and the reopened menu must never
// paint the previous host's entry before the fresh read lands.
setNativePicker(false)
return
}
// Reset before each read: the injected reader can also change identity
// while the flow stays open, and that prior answer must not leak either;
// a settlement from a superseded read is discarded via the
// cleanup-toggled flag.
setNativePicker(false)
let stale = false
void directoryPickerKind()
.then((kind) => { if (!stale) setNativePicker(kind === 'native') })
// A failed describe hides the entry too: the same Host that cannot
// answer describe cannot serve pickDirectory.
.catch(() => { if (!stale) setNativePicker(false) })
return () => { stale = true }
}, [open, directoryPickerKind])
// The occupied hole gates the picking affordance: with no composed flow the
// entry simply is not there (the seam's documented no-flow default). Read
// per render while the menu is open — registrations land through plugin
// activation, and the menu re-renders on every toggle.
const createEntries: MenuEntry[] = [
...(nativePicker
...(hasDirectoryFlow()
? [{ 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 },
@@ -134,15 +113,10 @@ export function WorkspaceCreateFlow({
setModalError(null)
}
const openLocalFolder = (): void => {
onClose()
setModalKind(null)
setModalError(null)
setFolderConflict(false)
setPickingFolder(true)
void pickDirectory().then(async (path) => {
if (path === null) return
const workspace = await createWorkspace({ path })
/** Adopt a picked directory; failures land in the folder-error dialog (Choose again reopens the flow). */
const adoptDirectory = (path: string): Promise<void> =>
createWorkspace({ path }).then((workspace) => {
setFlowOpen(false)
onPick(workspace.workspaceId)
}).catch((reason: unknown) => {
setFolderConflict(
@@ -150,8 +124,33 @@ export function WorkspaceCreateFlow({
&& reason.rpcError.code === 'workspace-name-conflict',
)
setModalError(reason instanceof Error ? reason.message : String(reason))
setFlowOpen(false)
setModalKind('folder-error')
}).finally(() => { setPickingFolder(false) })
})
const openLocalFolder = (): void => {
onClose()
setModalKind(null)
setModalError(null)
setFolderConflict(false)
setFlowOpen(true)
}
/** Owner side of the flow conversation: adopt keeps the flow open (busy) until the Host answers. */
const flowOwner: DirectoryFlowOwnerProps = {
open: flowOpen,
busy: pickingFolder,
onPicked: (path) => {
setPickingFolder(true)
void adoptDirectory(path).finally(() => { setPickingFolder(false) })
},
onCancel: () => { setFlowOpen(false) },
onError: (message) => {
setFlowOpen(false)
setFolderConflict(false)
setModalError(message)
setModalKind('folder-error')
},
}
const handleSelect = (id: string): void => {
@@ -205,6 +204,7 @@ export function WorkspaceCreateFlow({
getAnchorRect={getAnchorRect}
/>
{open && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">Loading workspaces</div>}
{renderDirectoryFlow(flowOwner)}
<Modal
open={modalKind === 'folder-error'}
onClose={closeModal}
@@ -282,8 +282,8 @@ export function WorkspacePicker({
onPick,
onClose,
createWorkspace,
pickDirectory,
directoryPickerKind,
hasDirectoryFlow,
renderSlot,
}: WorkspacePickerProps) {
return (
<WorkspaceCreateFlow
@@ -291,8 +291,8 @@ export function WorkspacePicker({
anchorRef={anchorRef}
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
directoryPickerKind={directoryPickerKind}
hasDirectoryFlow={hasDirectoryFlow}
renderDirectoryFlow={owner => renderSlot('conversation.hero.workspace.directoryFlow', owner)}
selectedId={selectedId}
onPick={onPick}
onClose={onClose}

View File

@@ -7,21 +7,74 @@
* consumes the shell's two-fact owner share (wide / expandSidebar).
* - WorkspacePicker fills the conversation empty-state hole (menu +
* create dialogs shared with the browser).
*
* Each registration also declares one **directory-flow hole** (`single`
* kind): the slot a composed picker package's client half fills with its
* picking interaction — a renderless native-chooser driver or an in-app
* browsing dialog. ui-workspace owns the trigger (the "Open local folder…"
* menu entry, shown 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.
* Two holes exist because the two menu surfaces are independent slot entries
* and a hole has exactly one declaring entry — they carry the same owner
* contract and the same occupant.
*/
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pull the owner SlotMap merges into programs that resolve the
// 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 { DirectoryPickerKind, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type { createWorkspaceViewStore } from '../stores.ts'
/**
* Owner share of the directory-flow holes: the complete conversation between
* the trigger surface and the picking interaction. The occupant reads `open`
* to run/render its interaction and reports exactly one outcome per open.
*/
export interface DirectoryFlowOwnerProps {
/** True while a picking interaction is requested; flipping back to false withdraws the request. */
open: boolean
/** True while the owner adopts a picked path (`createWorkspace` in flight); occupants disable their commit affordances. */
busy: boolean
/** The operator picked a directory (absolute host path); the owner adopts it. */
onPicked: (path: string) => void
/** The operator dismissed the interaction; the owner just closes the flow. */
onCancel: () => void
/** The interaction itself failed (chooser missing, listing denied); the owner shows its error surface. */
onError: (message: string) => void
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** Directory-flow hole under the conversation empty-state picker (declared by the WorkspacePicker entry). */
'conversation.hero.workspace.directoryFlow': { kind: 'single'; scope: 'root'; owner: DirectoryFlowOwnerProps }
/** Directory-flow hole under the sidebar browsing region (declared by the WorkspaceBrowser entry). */
'sidebar.workspaces.directoryFlow': { kind: 'single'; scope: 'root'; owner: DirectoryFlowOwnerProps }
}
}
/** The two directory-flow holes; a flow package's client half registers its one component into both. */
export type DirectoryFlowSlotName =
| 'conversation.hero.workspace.directoryFlow'
| 'sidebar.workspaces.directoryFlow'
/** Directory-picking share both trigger surfaces consume. */
export type DirectoryPickingInjected = {
/**
* Whether this surface's directory-flow hole is occupied — read when the
* menu opens; an empty hole hides the "Open local folder…" entry (the
* no-flow composition simply has no picking affordance).
*/
hasDirectoryFlow: () => boolean
}
/**
* Browser-private injected share (arrives via the register inject factory).
* Data reads use the global framework hooks; these are the Host actions the
* browsing region drives.
*/
export type WorkspaceBrowserInjected = {
export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
/**
* Start a New Session in a Workspace: reuse-or-create its blank session
* and open it; with no workspace, clear the selection into the New Session
@@ -42,15 +95,12 @@ export type WorkspaceBrowserInjected = {
insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise<void>
/** Explicitly create or adopt a real Workspace before targeting a Session. */
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. */
export type WorkspaceBrowserProps =
PropsRuntime<'sidebar.workspaces'>
& PropsRenderSlots<'sidebar.workspaces.directoryFlow'>
& PropsStore<ReturnType<typeof createWorkspaceViewStore>>
& WorkspaceBrowserInjected
@@ -59,13 +109,9 @@ export type WorkspaceBrowserProps =
* callback; this callback creates only the real Host Workspace. A type alias
* supplies the implicit index signature required by the registry.
*/
export type WorkspacePickerInjected = {
export type WorkspacePickerInjected = DirectoryPickingInjected & {
/** Explicitly create or adopt a real Workspace before targeting a Session. */
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>
}
/**
@@ -74,4 +120,6 @@ export type WorkspacePickerInjected = {
* currency, so one composed type serves both registrations.
*/
export type WorkspacePickerProps =
PropsRuntime<'conversation.hero.workspace'> & WorkspacePickerInjected
PropsRuntime<'conversation.hero.workspace'>
& PropsRenderSlots<'conversation.hero.workspace.directoryFlow'>
& WorkspacePickerInjected

View File

@@ -3,9 +3,12 @@
* the sidebar shell's `sidebar.workspaces` hole (the whole browsing region),
* and WorkspacePicker fills the conversation hero's picker hole
* (`conversation.hero.workspace` — both hero forms). Both read real Host
* Workspaces through the global useWorkspaces hook. Export discipline:
* Workspaces through the global useWorkspaces hook, and each declares its
* own `single` directory-flow child hole for the composed picker package's
* client half (see the contract module doc). Export discipline:
* packages/client/AGENTS.md.
*/
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
import { createWorkspaceViewStore } from './stores.ts'
@@ -13,6 +16,7 @@ import { WorkspaceBrowser } from './WorkspaceBrowser.tsx'
import { WorkspacePicker } from './WorkspacePicker.tsx'
export type {
DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingInjected,
WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps,
} from './contract/slots.ts'
@@ -44,50 +48,40 @@ export function apply(ctx: ClientContext): void {
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
},
createWorkspace: input => ctx.workspaces.create(input),
pickDirectory: () => ctx.workspaces.pickDirectory(),
directoryPickerKind: () => ctx.workspaces.directoryPickerKind(),
hasDirectoryFlow: () => ctx.slots.entries('sidebar.workspaces.directoryFlow').length > 0,
})
const pickerInjected = (): WorkspacePickerInjected => ({
createWorkspace: input => ctx.workspaces.create(input),
pickDirectory: () => ctx.workspaces.pickDirectory(),
directoryPickerKind: () => ctx.workspaces.directoryPickerKind(),
hasDirectoryFlow: () => ctx.slots.entries('conversation.hero.workspace.directoryFlow').length > 0,
})
// Declaration-aware registration: each owner's declaring apply may activate
// after this one (entry activation order is unconstrained), and a register
// into an undeclared slot throws. Register once the declaration is on the
// ledger; the subscription also re-registers after an HMR collapse
// re-declares the slot (the cascade disposed our entry with it).
// Declaration-aware registration (deferRegistration): each owner's
// declaring apply may activate after this one, and a register into an
// undeclared slot throws; the deferral also re-registers after an HMR
// collapse re-declares the slot. Each registration declares its own
// directory-flow child hole in the same call (declaration = render
// authorization, one table).
ctx.effect(() => {
const registrations = [
{
name: 'sidebar.workspaces' as const,
component: WorkspaceBrowser,
register: () => ctx.slots.register(
{ name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected },
const deferred = [
deferRegistration(ctx.slots, 'sidebar.workspaces', WorkspaceBrowser, () =>
ctx.slots.register(
{
name: 'sidebar.workspaces',
children: { 'sidebar.workspaces.directoryFlow': { kind: 'single', scope: 'root' } },
store: createWorkspaceViewStore(),
inject: browserInjected,
},
WorkspaceBrowser,
),
},
{
name: 'conversation.hero.workspace' as const,
component: WorkspacePicker,
register: () => ctx.slots.register(
{ name: 'conversation.hero.workspace', inject: pickerInjected },
)),
deferRegistration(ctx.slots, 'conversation.hero.workspace', WorkspacePicker, () =>
ctx.slots.register(
{
name: 'conversation.hero.workspace',
children: { 'conversation.hero.workspace.directoryFlow': { kind: 'single', scope: 'root' } },
inject: pickerInjected,
},
WorkspacePicker,
),
},
)),
]
const disposers = new Map<string, () => void>()
const tryRegister = (entry: (typeof registrations)[number]): void => {
if (ctx.slots.spec(entry.name) === undefined) return
if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return
disposers.set(entry.name, entry.register())
}
const unsubscribers = registrations.map(entry =>
ctx.slots.subscribe(entry.name, () => { tryRegister(entry) }))
for (const entry of registrations) tryRegister(entry)
return () => {
for (const unsubscribe of unsubscribers) unsubscribe()
for (const dispose of disposers.values()) dispose()
}
return () => { for (const entry of deferred) entry.dispose() }
}, 'ui-workspace: browser + picker registrations')
}

View File

@@ -14,18 +14,16 @@ async function bench() {
path: 'name' in input ? `/projects/${input.name}` : input.path,
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
}))
const pickDirectory = vi.fn(async () => '/tmp/picked')
const directoryPickerKind = vi.fn(async () => 'native' 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, directoryPickerKind, startSession, rename, insertSessionBefore,
create, startSession, rename, insertSessionBefore,
} as never)
ctx.provide('sessions', { open, clear } as never)
return { ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory, directoryPickerKind, startSession, rename, insertSessionBefore, open, clear }
return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open, clear }
}
type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace'
@@ -74,18 +72,30 @@ describe('ui-workspace apply', () => {
expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2')
await browser.createWorkspace({ name: 'project' })
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('declares the two directory-flow holes and reports their occupancy per surface', async () => {
const b = await bench()
declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace')
await b.ctx.plugin({ inject: [...inject], apply }).await()
// Registration declared the child holes (declaration = render authorization).
expect(b.slots.spec('sidebar.workspaces.directoryFlow')).toMatchObject({ kind: 'single' })
expect(b.slots.spec('conversation.hero.workspace.directoryFlow')).toMatchObject({ kind: 'single' })
const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
expect(browser.hasDirectoryFlow()).toBe(false)
expect(picker.hasDirectoryFlow()).toBe(false)
// A flow occupant flips exactly its own surface.
const dispose = b.slots.register({ name: 'sidebar.workspaces.directoryFlow' } as never, () => null)
expect(browser.hasDirectoryFlow()).toBe(true)
expect(picker.hasDirectoryFlow()).toBe(false)
dispose()
expect(browser.hasDirectoryFlow()).toBe(false)
})
it('unregisters every entry on teardown', async () => {

View File

@@ -59,8 +59,8 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
deleteWorkspace: vi.fn(async () => {}),
insertSessionBefore: vi.fn(async () => {}),
createWorkspace: vi.fn(async () => workspace('created', [])),
pickDirectory: vi.fn(async () => null),
directoryPickerKind: vi.fn(async () => 'native' as const),
hasDirectoryFlow: () => true,
renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ? <div data-testid="directory-flow" /> : null)) as never,
...overrides,
}
const view = render(<WorkspaceBrowser {...props} />)
@@ -264,13 +264,11 @@ describe('WorkspaceBrowser', () => {
}
})
it('rail create-workspace toggles the create-only picker in place, without expanding', async () => {
it('rail create-workspace toggles the create-only picker in place, without expanding', () => {
const expandSidebar = vi.fn()
mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(expandSidebar).not.toHaveBeenCalled()
// Flush the advertised-kind read that gates the local-folder entry.
await act(async () => {})
// createOnly: existing workspaces are not listed, only the create actions.
expect(screen.queryByRole('menuitem', { name: 'alpha' })).toBeNull()
expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy()

View File

@@ -5,6 +5,7 @@ import type {
SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client'
import type { DirectoryFlowOwnerProps } from '../src/client/contract/slots.ts'
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
afterEach(cleanup)
@@ -35,15 +36,29 @@ function anchor(): { current: HTMLElement } {
return { current: element }
}
/**
* Probe occupant of the directory-flow hole: records the latest owner
* conversation so tests drive onPicked/onCancel/onError like a composed flow
* package would, and renders a marker element while the flow is open.
*/
function flowProbe() {
const probe: { owner: DirectoryFlowOwnerProps | undefined } = { owner: undefined }
const renderSlot = ((_name: string, owner: DirectoryFlowOwnerProps) => {
probe.owner = owner
return owner.open ? <div data-testid="directory-flow" data-busy={owner.busy} /> : null
}) as never
return { probe, renderSlot }
}
function mount(
items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
createWorkspace = vi.fn(),
pickDirectory = vi.fn(async () => null as string | null),
directoryPickerKind = vi.fn(async () => 'native'),
hasDirectoryFlow: () => boolean = () => true,
) {
const onPick = vi.fn()
const onClose = vi.fn()
const anchorRef = anchor()
const { probe, renderSlot } = flowProbe()
const renderPicker = (nextItems: readonly WorkspaceView[]) => (
<WorkspacePicker
open
@@ -53,23 +68,21 @@ function mount(
onPick={onPick}
onClose={onClose}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
directoryPickerKind={directoryPickerKind}
hasDirectoryFlow={hasDirectoryFlow}
renderSlot={renderSlot}
/>
)
const view = render(
renderPicker(items),
)
return {
view, onPick, onClose, createWorkspace, pickDirectory, directoryPickerKind,
view, onPick, onClose, createWorkspace, probe,
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
}
}
// 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 }))
function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): void {
fireEvent.click(screen.getByRole('menuitem', { name }))
}
describe('WorkspacePicker', () => {
@@ -83,7 +96,7 @@ describe('WorkspacePicker', () => {
const created = workspace('new', 'New')
const createWorkspace = vi.fn(async () => created)
const b = mount([], createWorkspace)
await chooseItem('Create a new workspace')
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' }))
@@ -91,78 +104,84 @@ describe('WorkspacePicker', () => {
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
})
it('opens a native directory picker, adopts its path, and selects the returned Workspace', async () => {
it('opens the composed directory flow, adopts its picked path, and selects the returned Workspace', async () => {
const created = { ...workspace('adopted'), path: '/tmp/project', title: 'project' }
const createWorkspace = vi.fn(async () => created)
const pickDirectory = vi.fn(async () => '/tmp/project')
const b = mount([], createWorkspace, pickDirectory)
await chooseItem('Open local folder…')
expect(pickDirectory).toHaveBeenCalledOnce()
await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) })
const b = mount([], createWorkspace)
expect(screen.queryByTestId('directory-flow')).toBeNull()
chooseItem('Open local folder…')
expect(b.onClose).toHaveBeenCalled()
expect(screen.getByTestId('directory-flow')).toBeTruthy()
await act(async () => { b.probe.owner!.onPicked('/tmp/project') })
expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' })
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
// Successful adoption withdraws the flow request.
expect(screen.queryByTestId('directory-flow')).toBeNull()
})
it('treats native picker cancellation as a silent no-op', async () => {
const b = mount([], vi.fn(), vi.fn(async () => null))
await chooseItem('Open local folder…')
await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() })
it('treats flow cancellation as a silent no-op', () => {
const b = mount([])
chooseItem('Open local folder…')
act(() => { b.probe.owner!.onCancel() })
expect(screen.queryByTestId('directory-flow')).toBeNull()
expect(b.createWorkspace).not.toHaveBeenCalled()
expect(b.onPick).not.toHaveBeenCalled()
expect(screen.queryByRole('dialog')).toBeNull()
})
it('shows a name conflict and retries through the native picker', async () => {
const pickDirectory = vi.fn()
.mockResolvedValueOnce('/one/project')
.mockResolvedValueOnce(null)
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([], createWorkspace, pickDirectory)
await chooseItem('Open local folder…')
const b = mount([], createWorkspace)
chooseItem('Open local folder…')
await act(async () => { b.probe.owner!.onPicked('/one/project') })
await waitFor(() => {
expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy()
})
expect(screen.getByRole('alert').textContent).toBe('Choose a folder with a different name.')
// The failed adoption withdrew the flow; Choose again reopens it.
expect(b.probe.owner!.open).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'Choose again' }))
await waitFor(() => { expect(pickDirectory).toHaveBeenCalledTimes(2) })
expect(b.probe.owner!.open).toBe(true)
expect(b.onPick).not.toHaveBeenCalled()
})
it('disables the folder action while the native picker is already open', async () => {
let resolve!: (path: string | null) => void
const pending = new Promise<string | null>((settle) => { resolve = settle })
const b = mount([], vi.fn(), vi.fn(() => pending))
await chooseItem('Open local folder…')
it('disables the create actions and reports busy to the flow while adopting', async () => {
let resolve!: (workspace: WorkspaceView) => void
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
const created = workspace('adopted')
const b = mount([], vi.fn(() => pending))
chooseItem('Open local folder…')
act(() => { b.probe.owner!.onPicked('/tmp/project') })
expect(b.probe.owner!.busy).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)
fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' }))
expect(b.pickDirectory).toHaveBeenCalledTimes(1)
await act(async () => { resolve(null); await pending })
await act(async () => { resolve(created); await pending })
expect(b.probe.owner!.busy).toBe(false)
})
it('reports non-Error native picker failures', async () => {
const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' }))
await chooseItem('Open local folder…')
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe('picker unavailable')
})
it('shows the flow-reported failure in the folder-error surface', () => {
const b = mount([])
chooseItem('Open local folder…')
act(() => { b.probe.owner!.onError('no chooser installed') })
expect(screen.getByRole('alert').textContent).toBe('no chooser installed')
expect(screen.queryByTestId('directory-flow')).toBeNull()
expect(b.createWorkspace).not.toHaveBeenCalled()
})
it('closes a creation modal when the user cancels', async () => {
it('closes a creation modal when the user cancels', () => {
mount([])
await chooseItem('Create a new workspace')
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', async () => {
it('blocks a create-new name already present in the Workspace list', () => {
const b = mount([workspace('alpha', 'Alpha')])
await chooseItem('Create a new workspace')
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)
@@ -175,7 +194,7 @@ describe('WorkspacePicker', () => {
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
const created = workspace('fresh', 'same-name')
const b = mount([], vi.fn(() => pending))
await chooseItem('Create a new workspace')
chooseItem('Create a new workspace')
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
@@ -191,7 +210,7 @@ describe('WorkspacePicker', () => {
const pending = new Promise<WorkspaceView>((_resolve, rejectPromise) => { reject = rejectPromise })
const createWorkspace = vi.fn(() => pending)
const b = mount([], createWorkspace)
await chooseItem('Create a new workspace')
chooseItem('Create a new workspace')
const input = screen.getByLabelText('New workspace name')
fireEvent.keyDown(input, { key: 'ArrowRight' })
fireEvent.change(input, { target: { value: 'broken' } })
@@ -208,7 +227,7 @@ describe('WorkspacePicker', () => {
it('reports non-Error creation failures', async () => {
const b = mount([], vi.fn(async () => { throw 'permission denied' }))
await chooseItem('Create a new workspace')
chooseItem('Create a new workspace')
// The name field starts empty (no prefill); a name is required to submit.
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'broken' } })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
@@ -219,11 +238,12 @@ describe('WorkspacePicker', () => {
})
it('waits to show its menu until an optional anchor is available', () => {
const { renderSlot } = flowProbe()
render(
<WorkspacePicker
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
directoryPickerKind={vi.fn(async () => 'native')}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
hasDirectoryFlow={() => true} renderSlot={renderSlot}
/>,
)
expect(screen.queryByRole('menu')).toBeNull()
@@ -233,101 +253,31 @@ describe('WorkspacePicker', () => {
const state: WorkspaceListState = {
...workspaceState([]), phase: 'pending', state: 'loading', baselinesReady: false,
}
const { renderSlot } = flowProbe()
render(
<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 () => 'native')}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
hasDirectoryFlow={() => true} renderSlot={renderSlot}
/>,
)
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() })
it('hides the folder entry while the directory-flow hole is empty', () => {
mount([], vi.fn(), () => false)
expect(screen.getByRole('menuitem', { name: 'Create a new workspace' })).toBeTruthy()
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() })
it('shows the folder entry once the hole reports an occupant on a later render', () => {
let occupied = false
const b = mount([], vi.fn(), () => occupied)
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 () => 'native')
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()
})
/** Render the picker with an owner-controlled `open` and a scripted kind read. */
function togglable(directoryPickerKind: () => Promise<string>) {
const anchorRef = anchor()
const props = (open: boolean) => (
<WorkspacePicker
open={open} anchorRef={anchorRef} useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
directoryPickerKind={directoryPickerKind}
/>
)
const view = render(props(true))
return { setOpen: (open: boolean) => { view.rerender(props(open)) } }
}
it('discards a kind settlement from a superseded flow open', async () => {
let resolveFirst!: (kind: string) => void
const first = new Promise<string>((settle) => { resolveFirst = settle })
const directoryPickerKind = vi.fn<() => Promise<string>>()
.mockImplementationOnce(() => first)
.mockImplementation(async () => 'browse')
const t = togglable(directoryPickerKind)
// Close while the first read is in flight, then let it answer 'native':
// the settlement is stale and must not leak into the next open.
t.setOpen(false)
await act(async () => { resolveFirst('native') })
t.setOpen(true)
await screen.findByRole('menuitem', { name: 'Create a new workspace' })
await waitFor(() => { expect(directoryPickerKind).toHaveBeenCalledTimes(2) })
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
})
it('clears the advertised kind on close so a reopen cannot paint the previous host entry', async () => {
const directoryPickerKind = vi.fn<() => Promise<string>>()
.mockImplementationOnce(async () => 'native')
// The reopened read never settles: the assertion below sees the paint
// that precedes any fresh answer.
.mockImplementation(() => new Promise<string>(() => {}))
const t = togglable(directoryPickerKind)
await screen.findByRole('menuitem', { name: 'Open local folder…' })
t.setOpen(false)
t.setOpen(true)
await screen.findByRole('menuitem', { name: 'Create a new workspace' })
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
})
it('discards a stale describe failure after a newer open already answered', async () => {
let rejectFirst!: (reason: Error) => void
const first = new Promise<string>((_settle, reject) => { rejectFirst = reject })
const directoryPickerKind = vi.fn<() => Promise<string>>()
.mockImplementationOnce(() => first)
.mockImplementation(async () => 'native')
const t = togglable(directoryPickerKind)
t.setOpen(false)
t.setOpen(true)
await screen.findByRole('menuitem', { name: 'Open local folder…' })
// The superseded read failing late must not hide the freshly shown entry.
await act(async () => { rejectFirst(new Error('late loss')); await first.catch(() => {}) })
// A flow package activating after the first paint is observed on the
// next render — the same cadence as reopening the menu.
occupied = true
b.rerenderItems([])
expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy()
})
})