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:
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }>> =
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user