refactor(picker): split the directory-picker faces into their own packages

The browse and native backends were dual-face packages: a Node backend plus a
browser surface under one tsconfig that referenced Client packages. That put
Client projects — and through them the Client runtime — inside the Host
compiler aggregate, which builds before the generated Remote contributions
exist. Each browser half moves to its own Client package, and both backends
become Node-only.

The interaction is still one choice: the adaptive chooser mounts the backend
and its surface as a pair of Loader entries and tears both down in reverse, so
a resolved kind still swaps both faces. Compositions that pin an interaction
directly now pin the pair, and the chooser's runtime-string package list keeps
naming everything a composing app must resolve.
This commit is contained in:
imccyu
2026-08-11 19:10:50 +08:00
parent 070a2a7f1e
commit 40af20cafe
33 changed files with 407 additions and 136 deletions

View File

@@ -0,0 +1,72 @@
{
"name": "@deepseek-ai/dsh-client-ui-directory-picker-native",
"description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser",
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/ui-directory-picker-native"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-workspace"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts"
]
}

View File

@@ -0,0 +1,65 @@
/**
* The native picking occupant (package-internal; the `./client` surface
* exposes only the Loader exports). Same-package tests exercise it directly
* through this module.
*/
import { useEffect, useRef } from 'react'
import type { ReactElement } from 'react'
// Type-only: the owner contract of the directory-flow holes.
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
/** Injected face: the wire call the flow drives (bound in apply's closure). */
export interface NativeFlowInjected {
/** Ask the local Host to open its native single-directory chooser. */
pick: () => Promise<string | null>
}
/**
* Renderless flow occupant: each rising `open` edge runs exactly one pick and
* reports exactly one outcome; the ref arms once per open so re-renders (and
* an adoption keeping `open` true while `busy`) never launch a second
* chooser. The owner withdrawing `open` re-arms the next request.
* @param props - owner conversation plus the injected pick call.
* @returns nothing — the native chooser renders on the host display.
*/
export function NativeDirectoryFlow(props: DirectoryFlowOwnerProps & NativeFlowInjected): ReactElement | null {
const { open, pick } = props
const armed = useRef(false)
// Callbacks ride a ref so the settled pick reports through the owner's
// latest handlers, not the ones captured when the chooser opened.
const outcome = useRef(props)
outcome.current = props
// Unmount (HMR replacing the occupant) discards settlements wholesale: the
// dead instance must neither adopt a path nor drive the owner's error
// surface. The wire carries no per-request abort, so the host-side chooser
// survives until answered — its answer just lands nowhere; the replacement
// instance re-arms under the owner's still-open request. An injected-face
// identity change alone (re-registration) keeps the pending settlement:
// the chooser on the host display is still the same dialog.
const alive = useRef(true)
useEffect(() => {
// StrictMode's development replay runs the cleanup once before the real
// lifetime: re-arm on setup or every outcome would be discarded.
alive.current = true
return () => { alive.current = false }
}, [])
useEffect(() => {
if (!open) {
armed.current = false
return
}
if (armed.current) return
armed.current = true
pick().then(
(path) => {
if (!alive.current) return
if (path === null) outcome.current.onCancel(); else outcome.current.onPicked(path)
},
(reason: unknown) => {
if (!alive.current) return
outcome.current.onError(reason instanceof Error ? reason.message : String(reason))
},
)
}, [open, pick])
return null
}

View File

@@ -0,0 +1,40 @@
/**
* 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
* 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
* code branches on a capability kind.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the SlotMap merge declaring the directory-flow holes.
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
import type { NativeFlowInjected } from './flow.ts'
import { NativeDirectoryFlow } from './flow.ts'
/** Required services (cordis fiber inject): the slot registry and the wire-facing workspace service. */
export const inject = ['slots', 'workspaces']
/**
* Client plugin body: register the renderless native flow into both
* directory-flow holes through `slots.inject()` because the ui-workspace
* entries may activate later or replace their declarations.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const injected = (): NativeFlowInjected => ({ pick: () => ctx.workspaces.pickDirectory() })
// Both declaration lifetimes must be live before the pair installs; the
// generator makes the two registrations one transactional effect. The
// outer/inner nesting order is arbitrary; neither hole has precedence.
ctx.slots.inject('conversation.hero.workspace.directoryFlow', () =>
ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
yield ctx.slots.register({
name: 'conversation.hero.workspace.directoryFlow', inject: injected,
}, NativeDirectoryFlow)
yield ctx.slots.register({
name: 'sidebar.workspaces.directoryFlow', inject: injected,
}, NativeDirectoryFlow)
}))
}

View File

@@ -0,0 +1,10 @@
/**
* Native directory-picker surface, node half. Pure UI plugin: the empty apply
* exists so the plugin appears in the host cordis.yml / Loader; the browser
* half ships via exports["./client"], discovered through the package.json
* dsh.client declaration. The OS chooser it drives lives in
* `@deepseek-ai/dsh-host-directory-picker-native`.
*/
/** Host plugin body — no host-side behavior for this surface plugin. */
export function apply(): void {}

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-directory-picker-native`.
* @module @deepseek-ai/dsh-client-ui-directory-picker-native/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-directory-picker-native'
/** Cordis companion plugin name. */
export const name = 'client-ui-directory-picker-native-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the plugin registers a renderless flow occupant into
* two workspace holes as one transactional effect, whose disposal the
* HMR-safety spec proves, and it retains no state between picks.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,227 @@
// @vitest-environment jsdom
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { afterEach } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
import { apply, inject } from '../src/client/index.ts'
import { NativeDirectoryFlow } from '../src/client/flow.ts'
afterEach(cleanup)
const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const pickDirectory = vi.fn(async (): Promise<string | null> => '/tmp/picked')
ctx.provide('workspaces', { pickDirectory } as never)
const slots = ctx.get('slots') as SlotsService
const declare = () => slots.register({
name: 'root',
children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
} as never, () => null)
return { ctx, slots, pickDirectory, declare }
}
function owner(overrides: Partial<DirectoryFlowOwnerProps> = {}): DirectoryFlowOwnerProps {
return {
open: true, busy: false,
onPicked: vi.fn(), onCancel: vi.fn(), onError: vi.fn(),
...overrides,
}
}
describe('directory-picker-native client half', () => {
it('declares the services it drives', () => {
expect(inject).toEqual(['slots', 'workspaces'])
})
it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => {
const before = await bench()
before.declare()
const fiber = before.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(1)
// Registry-contribution disposal proof: the fiber going down empties the holes.
await fiber.dispose()
for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(0)
const after = await bench()
await after.ctx.plugin({ inject: [...inject], apply }).await()
for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(0)
after.declare()
await Promise.resolve()
for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1)
})
it('fails loudly instead of deduplicating a duplicate package row', async () => {
const b = await bench()
b.declare()
await b.ctx.plugin({ inject: [...inject], apply }).await()
const duplicate = b.ctx.plugin({ inject: [...inject], apply })
await expect(duplicate.await()).rejects.toThrow(/already has a registration/)
for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1)
})
it('rolls back wholesale and reports loudly when a rival injection wins declaration activation', async () => {
const b = await bench()
const rejections: unknown[] = []
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
// queueMicrotask throws surface as uncaughtException, not a rejection.
process.on('unhandledRejection', onUnhandled)
process.on('uncaughtException', onUnhandled)
try {
// The rival subscribes first, so synchronous declaration notifications
// let it occupy the pair before this provider's waiting injection runs.
b.slots.inject(HOLES[0], () => b.slots.inject(HOLES[1], function* () {
yield b.slots.register({ name: HOLES[0] } as never, () => null)
yield b.slots.register({ name: HOLES[1] } as never, () => null)
}))
await b.ctx.plugin({ inject: [...inject], apply }).await()
b.declare()
await new Promise(resolve => setTimeout(resolve, 20))
// The rival keeps both holes; this provider rolled back wholesale and
// surfaced the conflict on the fail-loud channel — no partial mix.
for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1)
expect(rejections.map(String).join('\n')).toContain('already has a registration')
// Non-Error conflicts wrap before the loud rethrow (same channel).
const c = await bench()
await c.ctx.plugin({ inject: [...inject], apply }).await()
const original = c.slots.register.bind(c.slots)
const slotsAny = c.slots as { register: typeof original }
slotsAny.register = ((options: never, component: never) => {
if ((options as { name?: string }).name === HOLES[0]) throw 'string conflict'
return original(options, component)
}) as typeof original
c.declare()
await new Promise(resolve => setTimeout(resolve, 20))
expect(rejections.map(String).join('\n')).toContain('string conflict')
} finally {
process.off('unhandledRejection', onUnhandled)
process.off('uncaughtException', onUnhandled)
}
})
it('rolls back the outer injection when the second hole is already occupied', async () => {
const b = await bench()
b.declare()
// Foreign occupant in the SECOND registered hole: the pair construction
// throws after the outer injection installed its subscription.
b.slots.register({ name: HOLES[1] } as never, () => null)
const rejections: unknown[] = []
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
process.on('unhandledRejection', onUnhandled)
try {
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await expect(fiber.await()).rejects.toThrow(/already has a registration/)
// A leaked first deferral would now race this probe registration and
// throw from its orphaned subscription against the HERO hole; the
// rollback leaves only the activation failure itself (cordis re-raises
// the apply throw as a late rejection — installFailLoud's contract).
const disposeProbe = b.slots.register({ name: HOLES[0] } as never, () => null)
await new Promise(resolve => setTimeout(resolve, 20))
expect(rejections.map(String).filter(text => text.includes(HOLES[0]))).toEqual([])
disposeProbe()
} finally {
process.off('unhandledRejection', onUnhandled)
}
})
it('rejects a second flow occupant at load (single-kind hole)', async () => {
const b = await bench()
b.declare()
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(() => b.slots.register({ name: HOLES[0] } as never, () => null))
.toThrow(/already has a registration/)
})
it('drives the injected pick through the hole entry and reports the picked path', async () => {
const b = await bench()
b.declare()
await b.ctx.plugin({ inject: [...inject], apply }).await()
const entry = b.slots.entries(HOLES[0])[0]!
const injected = (entry.inject as () => { pick: () => Promise<string | null> })()
await expect(injected.pick()).resolves.toBe('/tmp/picked')
expect(b.pickDirectory).toHaveBeenCalledOnce()
})
it('runs one pick per open edge and reports the path to the latest onPicked', async () => {
let resolve!: (path: string | null) => void
const pick = vi.fn(() => new Promise<string | null>((settle) => { resolve = settle }))
const first = owner()
const view = render(<NativeDirectoryFlow {...first} pick={pick} />)
expect(pick).toHaveBeenCalledOnce()
// Re-renders while open (busy flips, handler identity changes) must not relaunch the chooser.
const second = owner()
view.rerender(<NativeDirectoryFlow {...second} busy pick={pick} />)
expect(pick).toHaveBeenCalledOnce()
// Even a fresh injected face (re-registration re-runs the inject factory)
// must not relaunch while the same request is still open.
const replacedPick = vi.fn(() => new Promise<string | null>(() => {}))
view.rerender(<NativeDirectoryFlow {...second} busy pick={replacedPick} />)
expect(replacedPick).not.toHaveBeenCalled()
await act(async () => { resolve('/tmp/project') })
expect(second.onPicked).toHaveBeenCalledWith('/tmp/project')
expect(first.onPicked).not.toHaveBeenCalled()
})
it('discards a settlement that lands after the flow unmounted', async () => {
let resolve!: (path: string | null) => void
const pick = vi.fn(() => new Promise<string | null>((settle) => { resolve = settle }))
const props = owner()
const view = render(<NativeDirectoryFlow {...props} pick={pick} />)
expect(pick).toHaveBeenCalledOnce()
view.unmount()
// The dead instance must neither adopt nor error; the owner's callbacks
// stay untouched by the orphaned chooser's answer.
await act(async () => { resolve('/tmp/late') })
expect(props.onPicked).not.toHaveBeenCalled()
expect(props.onCancel).not.toHaveBeenCalled()
expect(props.onError).not.toHaveBeenCalled()
// The failure arm is discarded the same way.
let reject!: (reason: unknown) => void
const failing = vi.fn(() => new Promise<string | null>((_settle, rejectPick) => { reject = rejectPick }))
const late = owner()
const failingView = render(<NativeDirectoryFlow {...late} pick={failing} />)
failingView.unmount()
await act(async () => { reject(new Error('too late')) })
expect(late.onError).not.toHaveBeenCalled()
})
it('reports null as cancellation and re-arms after the owner withdraws open', async () => {
const pick = vi.fn(async () => null as string | null)
const props = owner()
const view = render(<NativeDirectoryFlow {...props} pick={pick} />)
await act(async () => {})
expect(props.onCancel).toHaveBeenCalledOnce()
expect(props.onPicked).not.toHaveBeenCalled()
// Withdraw and reopen: a fresh request runs a fresh pick.
view.rerender(<NativeDirectoryFlow {...props} open={false} pick={pick} />)
view.rerender(<NativeDirectoryFlow {...props} pick={pick} />)
await act(async () => {})
expect(pick).toHaveBeenCalledTimes(2)
})
it('folds pick failures into onError messages', async () => {
const props = owner()
render(<NativeDirectoryFlow {...props} pick={vi.fn(async () => { throw new Error('no chooser installed') })} />)
await act(async () => {})
expect(props.onError).toHaveBeenCalledWith('no chooser installed')
const nonError = owner()
render(<NativeDirectoryFlow {...nonError} pick={vi.fn(async () => { throw 'denied' })} />)
await act(async () => {})
expect(nonError.onError).toHaveBeenCalledWith('denied')
})
it('renders nothing while closed and while open', () => {
const closed = render(<NativeDirectoryFlow {...owner({ open: false })} pick={vi.fn(async () => null)} />)
expect(closed.container.innerHTML).toBe('')
const opened = render(<NativeDirectoryFlow {...owner()} pick={vi.fn(async () => null)} />)
expect(opened.container.innerHTML).toBe('')
})
})

View File

@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../support/invariants"
},
{
"path": "../ui-slots"
},
{
"path": "../runtime"
},
{
"path": "../ui-workspace"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js'])