diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 0d054a293d..c89df410bc 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -316,7 +316,6 @@ async function bootPreview(origin: string, browser: Browser): Promise { const exercised = await page.evaluate(async () => { type Result = { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } } interface PreviewApi { - host: { createDirectory(payload: { path: string; name: string }): Promise> } skills: { list(payload: { sessionId: string }): Promise> } settings: { describe(payload: object): Promise }>> @@ -349,12 +348,26 @@ async function bootPreview(origin: string, browser: Browser): Promise { const sessionId = sessions.result.value.items[0]?.sessionId if (sessionId === undefined) throw new Error('workspace adoption created no Session') + // Remote namespaces answer over the same unary carrier; the args object + // keys every wire parameter by its name. + const remote = async (endpoint: string, args: object): Promise => { + const answered = await transport.fetch(`/api/${endpoint}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', rpcId: `preview-${endpoint.replace('/', '-')}`, + method: endpoint, payload: { args }, + }), + }) + const body = await answered.json() as Result + if (!body.result.ok) throw new Error(`${endpoint} failed: ${body.result.error.message}`) + return body.result.value + } const api = transport.createApiClient() const skills = await api.skills.list({ sessionId }) if (!skills.result.ok) throw new Error(`skill.list failed: ${skills.result.error.message}`) const createDirectory = async (path: string, name: string): Promise => { - const created = await api.host.createDirectory({ path, name }) - if (!created.result.ok) throw new Error(`host.createDirectory failed: ${created.result.error.message}`) + await remote('directoryPicker/createDirectory', { path, name }) await new Promise((resolve) => { setTimeout(resolve, 250) }) const refreshed = await api.skills.list({ sessionId }) if (!refreshed.result.ok) throw new Error(`skill.list refresh failed: ${refreshed.result.error.message}`) diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 14be075879..473abfbe88 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -53,7 +53,7 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/types' */ export type { ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock, - CredentialView, DirectoryListing, DiscoveredModelView, IApiClient, + CredentialView, DiscoveredModelView, IApiClient, MessageId, ModelCatalog, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection, RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId, SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk, diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 5c7e53bb70..395e443ea3 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -53,6 +53,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -65,6 +66,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index f3a20282ed..0e0eab03e5 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -29,6 +29,7 @@ import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client' // wire-fabrication boundary (the schema layer's one-cast-point posture). import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CommandDescriptor, CommandExecution, CommandResult } from '@deepseek-ai/dsh-commands/types' +import type { DirectoryListing as FixtureDirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface' import type { ApiProxy, ClientRequest, @@ -2178,6 +2179,55 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, } + /** + * Canonical fixture implementation of the generated Directory Picker Remote + * contract. The pick is deterministic — the keyless lanes drive the full + * pick-then-adopt path without an OS chooser — over the same design-mock + * tree the browse primitives serve. + */ + const directoryPickerRemotes = { + pick(): ConnectionRpcResult { + return { ok: true, value: `${FIXTURE_HOME}/Documents/project` } + }, + list(path?: string): ConnectionRpcResult { + const target = path ?? FIXTURE_HOME + const children = childrenOf(target) + if (children === undefined) { + return { + ok: false, + error: { code: 'directory-unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } }, + } + } + return { + ok: true, + value: { + path: target, + home: FIXTURE_HOME, + crumbs: crumbsOf(target), + entries: [...children].sort((a, b) => a.localeCompare(b)) + .map(name => ({ name, path: target === '/' ? `/${name}` : `${target}/${name}`, hidden: name.startsWith('.') })), + // The fixture tree is tiny; no level ever reaches a backend bound. + truncated: false, + }, + } + }, + createDirectory(parent: string, name: string): ConnectionRpcResult { + const children = childrenOf(parent) + if (children === undefined) { + return { ok: false, error: { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } } } + } + // Same root special case as list's entry paths: a plain join under '/' + // would mint '//name' and fork the tree's identity. + const target = parent === '/' ? `/${name}` : `${parent}/${name}` + if (children.includes(name)) { + return { ok: false, error: { code: 'directory-exists', message: `${target} already exists`, details: { path: target } } } + } + directoryTree.set(parent, [...children, name]) + directoryTree.set(target, []) + return { ok: true, value: target } + }, + } + const goalRemotes = { create(id: SessionId, request: { objective: string; maxGoalRounds?: number }): RpcResult<{ ref: FxGoalRef }> { const missing = requireGoalSession(id) @@ -3425,6 +3475,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { agentId: SessionId line?: string query?: string + path?: string + name?: string images?: readonly unknown[] ref?: { id: string; revision: number } agentPreset?: string @@ -3442,6 +3494,10 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string, args.images ?? [])) case 'fileReferences/list': return Promise.resolve(referenceRemotes.files(sessionId, args.query ?? '')) case 'sessionReferenceResolver/candidates': return Promise.resolve(referenceRemotes.sessions(sessionId, args.query ?? '')) + case 'directoryPicker/pick': return Promise.resolve(directoryPickerRemotes.pick()) + case 'directoryPicker/list': return Promise.resolve(directoryPickerRemotes.list(args.path)) + case 'directoryPicker/createDirectory': + return Promise.resolve(directoryPickerRemotes.createDirectory(args.path ?? '', args.name ?? '')) case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, { objective: (request as { objective?: string } | undefined)?.objective as string, ...(request as { maxGoalRounds?: number } | undefined)?.maxGoalRounds === undefined diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index f17af5b978..b69d775510 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -17,8 +17,9 @@ import { type FixtureOptions, } from '../src/client/fixture.ts' import type { - ClientConnectionRpc, + ClientConnectionRpc, ConnectionRpcResult, } from '../src/rpc.ts' +import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' const sid = (id: string): SessionId => id as SessionId type WorkspaceId = string & { readonly __fixtureWorkspaceId: 'WorkspaceId' } @@ -286,6 +287,12 @@ interface FixtureRemoteEventStream extends AsyncIterable['api'] & { + /** The directory-picking Remote namespace as the fixture serves it. */ + readonly directoryPickerRemote: { + pick: () => Promise> + list: (path?: string) => Promise> + createDirectory: (path: string, name: string) => Promise> + } readonly sessions: FixtureSessionApi readonly sessionRemote: FixtureSessionRemote readonly workspace: FixtureWorkspaceApi @@ -298,6 +305,15 @@ type FixtureTestApi = ReturnType['api'] & { function createFixtureApi(options: FixtureOptions = {}): FixtureTestApi { const { api, rpc } = createFixtureFaces(options) return Object.assign(api, { + directoryPickerRemote: { + pick: () => rpc.call('/api', 'directoryPicker/pick', { args: {} }) as + Promise>, + list: (path?: string) => rpc.call('/api', 'directoryPicker/list', { args: { path } }) as + Promise>, + createDirectory: (path: string, name: string) => + rpc.call('/api', 'directoryPicker/createDirectory', { args: { path, name } }) as + Promise>, + }, sessions: createSessionApi(rpc), sessionRemote: createSessionRemote(rpc), workspace: createWorkspaceApi(rpc), @@ -1049,18 +1065,18 @@ describe('createFixtureApi', () => { it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => { const api = createFixtureApi() - const created = await api.host.createDirectory(req({ path: '/', name: 'srv' })) - if (!created.result.ok) throw new Error('create failed') - expect(created.result.value.path).toBe('/srv') - const listed = await api.host.listDirectory(req({ path: '/srv' }), new AbortController().signal) - if (!listed.result.ok) throw new Error('list failed') - expect(listed.result.value.crumbs).toEqual([ + const created = await api.directoryPickerRemote.createDirectory('/', 'srv') + if (!created.ok) throw new Error('create failed') + expect(created.value).toBe('/srv') + const listed = await api.directoryPickerRemote.list('/srv') + if (!listed.ok) throw new Error('list failed') + expect(listed.value.crumbs).toEqual([ { name: '/', path: '/', hidden: false }, { name: 'srv', path: '/srv', hidden: false }, ]) - const root = await api.host.listDirectory(req({ path: '/' }), new AbortController().signal) - if (!root.result.ok) throw new Error('root list failed') - expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false }) + const root = await api.directoryPickerRemote.list('/') + if (!root.ok) throw new Error('root list failed') + expect(root.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false }) }) it('workspace/follow serves the resident baseline and create reuses on path collision', async () => { diff --git a/packages/client/connection/tests/http-bridge.host.spec.ts b/packages/client/connection/tests/http-bridge.host.spec.ts index 1b45ecc014..2e267ac966 100644 --- a/packages/client/connection/tests/http-bridge.host.spec.ts +++ b/packages/client/connection/tests/http-bridge.host.spec.ts @@ -35,11 +35,11 @@ describe('HTTP bridge abort', () => { it('aborts a pending native picker request when the browser disconnects', async () => { const body = JSON.stringify({ - type: 'client-request', rpcId: 'picker-1', method: 'host.pickDirectory', payload: {}, + type: 'client-request', rpcId: 'picker-1', method: 'directoryPicker/pick', payload: { args: {} }, }) const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage Object.assign(request, { - url: '/api/host.pickDirectory', + url: '/api/directoryPicker/pick', method: 'POST', headers: { 'content-type': 'application/json' }, }) diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json index c40e2cbadb..cb409edde0 100644 --- a/packages/client/connection/tsconfig.client.json +++ b/packages/client/connection/tsconfig.client.json @@ -36,6 +36,9 @@ { "path": "../../host/apiproxy" }, + { + "path": "../../host/directory-picker" + }, { "path": "../../interaction/commands" }, diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index 9c134a13ff..2d0003c1e8 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -32,7 +32,7 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-workspace", "@deepseek-ai/dsh-client-locale" @@ -49,7 +49,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", @@ -57,7 +57,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", diff --git a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx index 571ab4f2a9..d96e513c34 100644 --- a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -40,7 +40,7 @@ import { Button, IconCheckOutline16, IconChevronRightOutline14, IconEditOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' import type { Translate } from '@deepseek-ai/dsh-client-locale/client' import css from './DirectoryBrowser.module.css' diff --git a/packages/client/ui-directory-picker-browse/src/client/flow.ts b/packages/client/ui-directory-picker-browse/src/client/flow.ts index dbe80e9415..e532eaf762 100644 --- a/packages/client/ui-directory-picker-browse/src/client/flow.ts +++ b/packages/client/ui-directory-picker-browse/src/client/flow.ts @@ -5,7 +5,7 @@ */ import { createElement } from 'react' import type { ReactElement } from 'react' -import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import type { DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' import type { Translate } from '@deepseek-ai/dsh-client-locale/client' // Type-only: the owner contract of the directory-flow holes. import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' diff --git a/packages/client/ui-directory-picker-browse/src/client/index.ts b/packages/client/ui-directory-picker-browse/src/client/index.ts index 8730d48098..9e7b2833ea 100644 --- a/packages/client/ui-directory-picker-browse/src/client/index.ts +++ b/packages/client/ui-directory-picker-browse/src/client/index.ts @@ -2,10 +2,11 @@ * Browser half of the browse directory-picker backend: fills ui-workspace's * two directory-flow holes with the in-app Select Workspace Directory dialog * (figma `Harness` 813-23126 family), driving the node half's - * `host.listDirectory`/`host.createDirectory` primitives. Mounting this - * package therefore composes both sides of the browse interaction with one - * cordis.yml row; no client code branches on a capability kind. The dialog's - * copy is locale-registered here — the flow package owns its own strings. + * `directoryPicker/list`/`directoryPicker/createDirectory` primitives. + * Mounting this package therefore composes both sides of the browse + * interaction with one cordis.yml row; no client code branches on a + * capability kind. The dialog's copy is locale-registered here — the flow + * package owns its own strings. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: pulls the SlotMap merge declaring the directory-flow holes. diff --git a/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx index 6008e58ddc..f98d959478 100644 --- a/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import type { DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' diff --git a/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx b/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx index 69a624eb38..5f386c8723 100644 --- a/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx +++ b/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' -import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import type { DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx' afterEach(cleanup) diff --git a/packages/client/ui-directory-picker-browse/tsconfig.json b/packages/client/ui-directory-picker-browse/tsconfig.json index 24535dac3f..aaa77a76d9 100644 --- a/packages/client/ui-directory-picker-browse/tsconfig.json +++ b/packages/client/ui-directory-picker-browse/tsconfig.json @@ -18,7 +18,7 @@ "path": "../ui-primitives" }, { - "path": "../connection/tsconfig.client.json" + "path": "../../api/remotes/tsconfig.client.json" }, { "path": "../locale" diff --git a/packages/client/ui-directory-picker-native/src/client/index.ts b/packages/client/ui-directory-picker-native/src/client/index.ts index 951efc2356..68589c2982 100644 --- a/packages/client/ui-directory-picker-native/src/client/index.ts +++ b/packages/client/ui-directory-picker-native/src/client/index.ts @@ -1,7 +1,7 @@ /** * Browser half of the native directory-picker backend: fills ui-workspace's * two directory-flow holes with a renderless occupant that answers each - * `open` by driving `host.pickDirectory` (the node half's OS chooser) and + * `open` by driving `directoryPicker/pick` (the node half's OS chooser) and * reporting the one outcome — picked path, cancellation, or failure — back * through the owner conversation. Mounting this package therefore composes * both sides of the native interaction with one cordis.yml row; no client diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index a50f80911e..b009ef95eb 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -32,6 +32,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-api-workspace-controller", "@deepseek-ai/dsh-client-connection", @@ -53,6 +54,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", @@ -62,11 +64,13 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", @@ -80,6 +84,7 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index da06e95cc9..7f87202365 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -59,7 +59,9 @@ const NS = 'workspace' * provides a waitable service. apply therefore depends on each slot * declaration through `slots.inject()` instead of assuming order. */ -export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'connection'] +export const inject = [ + 'slots', 'sessions', 'workspaces', 'locale', 'connection', 'remote', 'remote.directoryPicker', +] /** * Register the browser and picker once their slot declarations are on the @@ -72,7 +74,8 @@ export function apply(ctx: Context): void { const sessions = ctx.get('sessions') as ISessions const workspaces = ctx.get('workspaces') as IWorkspaces const hostDescription = connection.hostDescription - const uiWorkspace = new UiWorkspaceService(ctx, connection.api, workspaces, sessions) + const uiWorkspace = new UiWorkspaceService( + ctx, connection.api, ctx.remote.directoryPicker, workspaces, sessions) ctx.slots.provideRoot({ hooks: { workspaces: workspaces.list } }) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries') diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 1135f60b2e..4d0f3e3fa9 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -1,9 +1,9 @@ /** Workspace archive and directory UI capability. */ import { Service, type Context } from '@deepseek-ai/cordis' -import type { - DirectoryListing, IApiClient, RpcError, -} from '@deepseek-ai/dsh-client-connection/client' +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' +import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import type { ISessions, SessionListState, @@ -69,7 +69,7 @@ export class DirectoryBrowseError extends Error { override readonly name = 'DirectoryBrowseError' /** @param rpcError - Host directory business failure. */ - constructor(readonly rpcError: RpcError) { + constructor(readonly rpcError: RemoteFailure) { super(`directory browse failed: ${rpcError.code}: ${rpcError.message}`) } } @@ -81,12 +81,14 @@ class UiWorkspaceService extends Service implements UiWorkspace { /** * @param ctx - Client root Context. * @param api - shared Host API carrier. + * @param directoryPicker - the directory-picking Remote namespace. * @param workspaces - pure Workspace Controller. * @param sessions - pure Session Controller. */ constructor( ctx: Context, private readonly api: IApiClient, + private readonly directoryPicker: ClientRemote['directoryPicker'], private readonly workspaces: IWorkspaces, private readonly sessions: ISessions, ) { @@ -144,23 +146,21 @@ class UiWorkspaceService extends Service implements UiWorkspace { } async pickDirectory(): Promise { - const response = await this.api.host.pickDirectory({}) - if (!response.result.ok) { - throw new Error(`directory picker failed: ${response.result.error.message}`) - } - return response.result.value.path + const result = await this.directoryPicker.pick() + if (!result.ok) throw new Error(`directory picker failed: ${result.error.message}`) + return result.value } async listDirectory(path?: string, signal?: AbortSignal): Promise { - const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal) - if (!response.result.ok) throw new DirectoryBrowseError(response.result.error) - return response.result.value + const result = await this.directoryPicker.list(path, signal) + if (!result.ok) throw new DirectoryBrowseError(result.error) + return result.value } async createDirectory(path: string, name: string): Promise { - const response = await this.api.host.createDirectory({ path, name }) - if (!response.result.ok) throw new DirectoryBrowseError(response.result.error) - return response.result.value.path + const result = await this.directoryPicker.createDirectory(path, name) + if (!result.ok) throw new DirectoryBrowseError(result.error) + return result.value } async openPath(path: string): Promise { diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 79aef72717..3834358288 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -1,6 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -60,6 +61,10 @@ async function bench() { ctx.provide('connection', { hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, } as never) + const pickDirectory = vi.fn(() => Promise.resolve({ ok: true as const, value: '/projects/picked' })) + const directoryPicker = { pick: pickDirectory } + Object.assign(new TestRemote(ctx), { directoryPicker }) + ctx.provide('remote.directoryPicker', directoryPicker as never) const locale = new LocaleRuntime(ctx) // These specs assert the shipped Chinese copy. There is no jsdom `window` // in this lane, so browser-language detection never runs and the locale @@ -68,7 +73,7 @@ async function bench() { ctx.provide('locale', locale) return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, - insertSessionBefore, open, clear, search, renameSession, binding, fork, + insertSessionBefore, open, clear, search, renameSession, binding, fork, pickDirectory, } } @@ -83,7 +88,7 @@ function declare(slots: SlotRegistry, ...names: HoleName[]): () => void { describe('ui-workspace apply', () => { it('declares the services it drives', () => { expect(inject).toEqual([ - 'slots', 'sessions', 'workspaces', 'locale', 'connection', + 'slots', 'sessions', 'workspaces', 'locale', 'connection', 'remote', 'remote.directoryPicker', ]) }) diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index 7db2ebd545..cc698e7391 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -17,7 +17,7 @@ import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { SlotTestRuntime, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -37,6 +37,11 @@ async function createRuntime(): Promise { runtime.ctx.provide('connection', { hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) + // The rename flow never picks a directory; the namespace only has to be there + // for ui-workspace's inject to settle. + const directoryPicker = {} + Object.assign(new TestRemote(runtime.ctx), { directoryPicker }) + runtime.ctx.provide('remote.directoryPicker', directoryPicker as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 875709a409..95d113bbfd 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -8,11 +8,12 @@ import type { } from '@deepseek-ai/dsh-api-workspace-controller/client' import { RpcId, - type DirectoryListing, type IApiClient, type RpcError, type RpcResponse, } from '@deepseek-ai/dsh-client-connection/client' +import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { SessionId } from '@deepseek-ai/dsh-session/types' import { DirectoryBrowseError, UiWorkspaceService } from '../src/client/navigation.ts' @@ -180,9 +181,6 @@ class FakeApiClient implements IApiClient { home: '/home/u', canOpenPath: true, })) - onPickDirectory: IApiClient['host']['pickDirectory'] = () => Promise.resolve(ok({ path: null })) - onListDirectory: IApiClient['host']['listDirectory'] = () => Promise.resolve(ok(listing)) - onCreateDirectory: IApiClient['host']['createDirectory'] = () => Promise.resolve(ok({ path: '/home/u/new' })) onOpenPath: IApiClient['host']['openPath'] = () => Promise.resolve(ok({ opened: true })) declare readonly skills: IApiClient['skills'] @@ -193,9 +191,6 @@ class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: (payload, signal) => this.record('host.describe', payload, this.onDescribe(payload, signal)), - pickDirectory: (payload, signal) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload, signal)), - listDirectory: (payload, signal) => this.record('host.listDirectory', payload, this.onListDirectory(payload, signal)), - createDirectory: (payload, signal) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload, signal)), openPath: (payload, signal) => this.record('host.openPath', payload, this.onOpenPath(payload, signal)), } @@ -209,6 +204,32 @@ class FakeApiClient implements IApiClient { } } +/** The directory-picking Remote namespace, recorded and scripted per case. */ +class FakeDirectoryPicker { + readonly calls: { method: string; payload: unknown }[] = [] + + onPick: () => Promise> = () => Promise.resolve({ ok: true, value: null }) + onList: () => Promise> = () => Promise.resolve({ ok: true, value: listing }) + onCreateDirectory: () => Promise> = + () => Promise.resolve({ ok: true, value: '/home/u/new' }) + + readonly remote: ClientRemote['directoryPicker'] = { + pick: () => this.record('pick', {}, this.onPick()), + list: (path?: string) => this.record('list', { path }, this.onList()), + createDirectory: (path: string, name: string) => + this.record('createDirectory', { path, name }, this.onCreateDirectory()), + } + + callsOf(method: string): unknown[] { + return this.calls.filter(call => call.method === method).map(call => call.payload) + } + + private record(method: string, payload: unknown, result: Promise): Promise { + this.calls.push({ method, payload }) + return result + } +} + interface BenchOptions { readonly workspaces?: WorkspaceSnapshot readonly sessions?: SessionListState @@ -217,15 +238,17 @@ interface BenchOptions { function bench(options: BenchOptions = {}) { const ctx = new Context() const api = new FakeApiClient() + const directoryPicker = new FakeDirectoryPicker() const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending')) const sessions = new FakeSessions(options.sessions ?? sessionState([], undefined, 'pending')) const uiWorkspace = new UiWorkspaceService( ctx, api, + directoryPicker.remote, workspaces, sessions as unknown as ISessions, ) - return { api, ctx, sessions, uiWorkspace, workspaces } + return { api, ctx, directoryPicker, sessions, uiWorkspace, workspaces } } async function flush(): Promise { @@ -451,31 +474,33 @@ describe('UiWorkspaceService', () => { it('passes directory operations to the Host and preserves structured browse failures', async () => { const b = bench() - b.api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' })) + b.directoryPicker.onPick = () => Promise.resolve({ ok: true, value: '/w/alpha' }) await expect(b.uiWorkspace.pickDirectory()).resolves.toBe('/w/alpha') - b.api.onPickDirectory = () => Promise.resolve(ok({ path: null })) + b.directoryPicker.onPick = () => Promise.resolve({ ok: true, value: null }) await expect(b.uiWorkspace.pickDirectory()).resolves.toBeNull() - expect(b.api.callsOf('host.pickDirectory')).toEqual([{}, {}]) + expect(b.directoryPicker.callsOf('pick')).toEqual([{}, {}]) await expect(b.uiWorkspace.listDirectory()).resolves.toEqual(listing) await expect(b.uiWorkspace.listDirectory('/home/u')).resolves.toEqual(listing) - expect(b.api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }]) + expect(b.directoryPicker.callsOf('list')).toEqual([{ path: undefined }, { path: '/home/u' }]) await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).resolves.toBe('/home/u/new') - expect(b.api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'new' }]) + expect(b.directoryPicker.callsOf('createDirectory')).toEqual([{ path: '/home/u', name: 'new' }]) await expect(b.uiWorkspace.openPath('/w/alpha/file.ts')).resolves.toBeUndefined() expect(b.api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/file.ts' }]) - b.api.onPickDirectory = () => Promise.resolve(failed({ code: 'internal', message: 'no chooser', details: {} })) + b.directoryPicker.onPick = () => Promise.resolve({ + ok: false, error: { code: 'internal', message: 'no chooser', details: {} }, + }) await expect(b.uiWorkspace.pickDirectory()).rejects.toThrow('directory picker failed: no chooser') - b.api.onListDirectory = () => Promise.resolve(failed({ - code: 'directory-unreadable', message: 'denied', details: { path: '/private' }, - })) + b.directoryPicker.onList = () => Promise.resolve({ + ok: false, error: { code: 'directory-unreadable', message: 'denied', details: { path: '/private' } }, + }) const listFailure = b.uiWorkspace.listDirectory('/private') await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError) await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } }) - b.api.onCreateDirectory = () => Promise.resolve(failed({ - code: 'directory-exists', message: 'taken', details: { path: '/home/u/new' }, - })) + b.directoryPicker.onCreateDirectory = () => Promise.resolve({ + ok: false, error: { code: 'directory-exists', message: 'taken', details: { path: '/home/u/new' } }, + }) await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).rejects.toMatchObject({ rpcError: { code: 'directory-exists' }, }) diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index 5d7708e031..398f9c2128 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../connection/tsconfig.client.json" }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, { "path": "../store" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80cb2119f1..11f1d8f5d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1615,6 +1615,9 @@ importers: '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../host/apiproxy + '@deepseek-ai/dsh-host-directory-picker': + specifier: workspace:^ + version: link:../../host/directory-picker '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver @@ -2252,9 +2255,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-connection': + '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ - version: link:../connection + version: link:../../api/remotes '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -3624,6 +3627,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller @@ -3666,6 +3672,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:^ version: link:../../util/workspace-path