diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 559c5bf198..bd60fe2273 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -252,8 +252,10 @@ # mapping target (user config overrides these engineering defaults). # Directory-picking package, dual-face: the node half serves the gateway's # host.* picker RPCs, the browser half fills ui-workspace's directory-flow -# slots — one row composes the whole interaction. Swap point: mount -# '-browse' instead for the in-app browser (remote-capable). +# slots — one row composes the whole interaction. Swap point: '-browse' +# serves remote-capable listing primitives; its in-app dialog (and this +# row's flip) land in the stacked follow-up PR — until then a '-browse' +# composition has no picking affordance. - id: directory-picker name: '@deepseek-ai/dsh-host-directory-picker-native' diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 9e7eb89872..7effff36b5 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -253,8 +253,7 @@ export function WorkspaceBrowser({ deleteWorkspace, insertSessionBefore, createWorkspace, - hasDirectoryFlow, - subscribeDirectoryFlow, + useDirectoryFlow, renderSlot, }: WorkspaceBrowserProps) { const workspaces = useWorkspaces(state => state.items) @@ -373,8 +372,7 @@ export function WorkspaceBrowser({ anchorRef={wsPlusRef} useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} - hasDirectoryFlow={hasDirectoryFlow} - subscribeDirectoryFlow={subscribeDirectoryFlow} + useDirectoryFlow={useDirectoryFlow} renderDirectoryFlow={owner => renderSlot('sidebar.workspaces.directoryFlow', owner)} createOnly side="right" diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index cc4989c463..fa6290e1f1 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -7,7 +7,7 @@ * opens the flow, adopts the picked path, and owns the error surface. */ import type { ReactNode, RefObject } from 'react' -import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -15,6 +15,7 @@ import { WorkspaceCreateError, type WorkspaceId, type WorkspaceListState, type WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts' import css from './WorkspacePicker.module.css' @@ -33,10 +34,8 @@ export interface WorkspaceCreateFlowProps { useWorkspaces: (selector: (state: WorkspaceListState) => S) => S /** Create or adopt a real Host Workspace. */ createWorkspace: (input: { name: string } | { path: string }) => Promise - /** Whether this surface's directory-flow hole is occupied (empty hides the local-folder entry). */ - hasDirectoryFlow: () => boolean - /** Registration-change subscription for the same hole (the uSES pair of hasDirectoryFlow). */ - subscribeDirectoryFlow: (listener: () => void) => () => void + /** Bound occupancy selector hook for this surface's directory-flow hole (empty hides the local-folder entry). */ + useDirectoryFlow: SnapshotSelectorHook /** 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. */ @@ -61,8 +60,7 @@ export function WorkspaceCreateFlow({ anchorRef, useWorkspaces, createWorkspace, - hasDirectoryFlow, - subscribeDirectoryFlow, + useDirectoryFlow, renderDirectoryFlow, onPick, onClose, @@ -95,9 +93,9 @@ export function WorkspaceCreateFlow({ // The occupied hole gates the picking affordance: with no composed flow the // entry simply is not there (the seam's documented no-flow default). The - // subscription keeps occupancy live: flow plugins activate (and HMR-reload) - // independently of this menu's renders. - const flowAvailable = useSyncExternalStore(subscribeDirectoryFlow, hasDirectoryFlow) + // framework-bound hook keeps occupancy live: flow plugins activate (and + // HMR-reload) independently of this menu's renders. + const flowAvailable = useDirectoryFlow(occupied => occupied) // An occupant that unloads mid-interaction leaves nobody to cancel: an // open flow over an empty hole withdraws so the menu actions come back. useEffect(() => { @@ -296,8 +294,7 @@ export function WorkspacePicker({ onPick, onClose, createWorkspace, - hasDirectoryFlow, - subscribeDirectoryFlow, + useDirectoryFlow, renderSlot, }: WorkspacePickerProps) { return ( @@ -306,8 +303,7 @@ export function WorkspacePicker({ anchorRef={anchorRef} useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} - hasDirectoryFlow={hasDirectoryFlow} - subscribeDirectoryFlow={subscribeDirectoryFlow} + useDirectoryFlow={useDirectoryFlow} renderDirectoryFlow={owner => renderSlot('conversation.hero.workspace.directoryFlow', owner)} selectedId={selectedId} onPick={onPick} diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 34c59d764f..63b60d8051 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -19,7 +19,7 @@ * and a hole has exactly one declaring entry — they carry the same owner * contract and the same occupant. */ -import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostObservable, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook } 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' @@ -59,21 +59,24 @@ export type DirectoryFlowSlotName = | 'conversation.hero.workspace.directoryFlow' | 'sidebar.workspaces.directoryFlow' -/** Directory-picking share both trigger surfaces consume. */ +/** + * Directory-picking share both trigger surfaces consume. Occupancy rides the + * inject face's reserved `hooks` compartment: the renderer binds the source + * into the `useDirectoryFlow` selector hook, so an empty hole hides the + * "Open local folder…" entry reactively and the surface withdraws an open + * flow whose occupant unloaded mid-interaction (nobody is left to cancel). + */ export type DirectoryPickingInjected = { - /** - * Whether this surface's directory-flow hole is occupied — an empty hole - * hides the "Open local folder…" entry (the no-flow composition simply has - * no picking affordance). - */ - hasDirectoryFlow: () => boolean - /** - * Subscribe to the hole's registration changes (the uSES pair of - * {@link hasDirectoryFlow}): the trigger surface withdraws an open flow - * whose occupant unloaded mid-interaction — nobody is left to cancel it. - * @returns the unsubscriber. - */ - subscribeDirectoryFlow: (listener: () => void) => () => void + hooks: { + /** True while this surface's directory-flow hole is occupied. */ + directoryFlow: HostObservable + } +} + +/** Component-side view of the picking share: the bound occupancy selector hook. */ +export type DirectoryPickingHooks = { + /** Selector hook over this surface's directory-flow occupancy. */ + useDirectoryFlow: SnapshotSelectorHook } /** @@ -109,7 +112,8 @@ export type WorkspaceBrowserProps = PropsRuntime<'sidebar.workspaces'> & PropsRenderSlots<'sidebar.workspaces.directoryFlow'> & PropsStore> - & WorkspaceBrowserInjected + & Omit + & DirectoryPickingHooks /** * Picker-private injected share. Pick semantics remain in the owner's onPick @@ -129,4 +133,5 @@ export type WorkspacePickerInjected = DirectoryPickingInjected & { export type WorkspacePickerProps = PropsRuntime<'conversation.hero.workspace'> & PropsRenderSlots<'conversation.hero.workspace.directoryFlow'> - & WorkspacePickerInjected + & Omit + & DirectoryPickingHooks diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 9dbe2ed2e6..1cf5a7ae5a 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -9,6 +9,7 @@ * packages/client/AGENTS.md. */ import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostObservable } 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' @@ -16,7 +17,7 @@ import { WorkspaceBrowser } from './WorkspaceBrowser.tsx' import { WorkspacePicker } from './WorkspacePicker.tsx' export type { - DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingInjected, + DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingHooks, DirectoryPickingInjected, WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps, } from './contract/slots.ts' @@ -37,6 +38,14 @@ export const inject = ['slots', 'sessions', 'workspaces'] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + // Stable per-surface occupancy sources (the renderer's hook cache keys by + // source identity): true while the surface's directory-flow hole is filled. + const flowSource = (hole: 'sidebar.workspaces.directoryFlow' | 'conversation.hero.workspace.directoryFlow'): HostObservable => ({ + getSnapshot: () => ctx.slots.entries(hole).length > 0, + subscribe: listener => ctx.slots.subscribe(hole, listener), + }) + const browserFlowSource = flowSource('sidebar.workspaces.directoryFlow') + const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow') const browserInjected = (): WorkspaceBrowserInjected => ({ // Explicit group actions keep their target; unscoped New Session rides // the runtime's shared action (recent-Workspace projection inside). @@ -48,13 +57,11 @@ export function apply(ctx: ClientContext): void { await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, createWorkspace: input => ctx.workspaces.create(input), - hasDirectoryFlow: () => ctx.slots.entries('sidebar.workspaces.directoryFlow').length > 0, - subscribeDirectoryFlow: listener => ctx.slots.subscribe('sidebar.workspaces.directoryFlow', listener), + hooks: { directoryFlow: browserFlowSource }, }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => ctx.workspaces.create(input), - hasDirectoryFlow: () => ctx.slots.entries('conversation.hero.workspace.directoryFlow').length > 0, - subscribeDirectoryFlow: listener => ctx.slots.subscribe('conversation.hero.workspace.directoryFlow', listener), + hooks: { directoryFlow: pickerFlowSource }, }) // Declaration-aware registration (deferRegistration): each owner's // declaring apply may activate after this one, and a register into an diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 7d5dac71de..817416663e 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -88,18 +88,18 @@ describe('ui-workspace apply', () => { 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. + expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false) + expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false) + // A flow occupant flips exactly its own surface, and the source notifies. const notified = vi.fn() - const unsubscribe = browser.subscribeDirectoryFlow(notified) + const unsubscribe = browser.hooks.directoryFlow.subscribe(notified) const dispose = b.slots.register({ name: 'sidebar.workspaces.directoryFlow' } as never, () => null) - expect(browser.hasDirectoryFlow()).toBe(true) - expect(picker.hasDirectoryFlow()).toBe(false) + expect(browser.hooks.directoryFlow.getSnapshot()).toBe(true) + expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false) await Promise.resolve() expect(notified).toHaveBeenCalled() dispose() - expect(browser.hasDirectoryFlow()).toBe(false) + expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false) unsubscribe() }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index f1d43997f7..a1d6d4ffce 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -59,8 +59,7 @@ function mount(overrides: Partial = {}) { deleteWorkspace: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), - hasDirectoryFlow: () => true, - subscribeDirectoryFlow: () => () => {}, + useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ?
: null)) as never, ...overrides, } diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 1f81ac59bf..18ddd8b1fd 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -6,6 +6,7 @@ import type { } 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 { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' afterEach(cleanup) @@ -50,16 +51,19 @@ function flowProbe() { return { probe, renderSlot } } -/** Manual occupancy source: flip() drives the uSES subscription like a real registration change. */ +/** Manual occupancy source bound like the renderer would: flip() drives the hook like a real registration change. */ function occupancySource(initial = true) { let occupied = initial const listeners = new Set<() => void>() - return { - hasDirectoryFlow: () => occupied, - subscribeDirectoryFlow: (listener: () => void) => { + const useDirectoryFlow = bindSnapshotSelector({ + getSnapshot: () => occupied, + subscribe: (listener: () => void) => { listeners.add(listener) return () => { listeners.delete(listener) } }, + }) + return { + useDirectoryFlow, flip: (next: boolean) => { occupied = next for (const listener of [...listeners]) listener() @@ -85,8 +89,7 @@ function mount( onPick={onPick} onClose={onClose} createWorkspace={createWorkspace} - hasDirectoryFlow={occupancy.hasDirectoryFlow} - subscribeDirectoryFlow={occupancy.subscribeDirectoryFlow} + useDirectoryFlow={occupancy.useDirectoryFlow} renderSlot={renderSlot} /> ) @@ -265,7 +268,7 @@ describe('WorkspacePicker', () => { true} subscribeDirectoryFlow={() => () => {}} renderSlot={renderSlot} + useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} />, ) expect(screen.queryByRole('menu')).toBeNull() @@ -280,7 +283,7 @@ describe('WorkspacePicker', () => { true} subscribeDirectoryFlow={() => () => {}} renderSlot={renderSlot} + useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} />, ) expect(screen.getByRole('status').textContent).toBe('Loading workspaces…') diff --git a/packages/host/directory-picker-native/src/client/flow.ts b/packages/host/directory-picker-native/src/client/flow.ts new file mode 100644 index 0000000000..14f705d071 --- /dev/null +++ b/packages/host/directory-picker-native/src/client/flow.ts @@ -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 +} + +/** + * 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 +} diff --git a/packages/host/directory-picker-native/src/client/index.ts b/packages/host/directory-picker-native/src/client/index.ts index 3ff6a4b3f9..f58bcbc056 100644 --- a/packages/host/directory-picker-native/src/client/index.ts +++ b/packages/host/directory-picker-native/src/client/index.ts @@ -7,63 +7,13 @@ * both sides of the native interaction with one cordis.yml row; no client * code branches on a capability kind. */ -import { useEffect, useRef } from 'react' -import type { ReactElement } from 'react' import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -// Type-only: the SlotMap merge declaring the directory-flow holes and their owner contract. -import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/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' -/** Injected face: the wire call the flow drives (bound in apply's closure). */ -interface NativeFlowInjected { - /** Ask the local Host to open its native single-directory chooser. */ - pick: () => Promise -} - -/** - * 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(() => () => { 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 -} /** Required services (cordis fiber inject): the slot registry and the wire-facing workspace service. */ export const inject = ['slots', 'workspaces'] diff --git a/packages/host/directory-picker-native/tests/client-flow.spec.tsx b/packages/host/directory-picker-native/tests/client-flow.spec.tsx index 656aea74df..a91732fab4 100644 --- a/packages/host/directory-picker-native/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-native/tests/client-flow.spec.tsx @@ -5,7 +5,8 @@ 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, NativeDirectoryFlow } from '../src/client/index.ts' +import { apply, inject } from '../src/client/index.ts' +import { NativeDirectoryFlow } from '../src/client/flow.ts' afterEach(cleanup)