fix(client,host): review round — hooks-compartment occupancy, StrictMode re-arm, internal flow module, honest swap comment
- Directory-flow occupancy moves onto the inject face's reserved hooks compartment: apply publishes a stable observable per surface and the renderer binds useDirectoryFlow — no hand-rolled component subscriptions (the client contract's channel for registrant-private reactive facts). - The native flow's alive guard re-arms in effect setup: StrictMode's development replay ran the cleanup once and every later outcome was discarded. - NativeDirectoryFlow moves to a package-internal module; ./client exports only the Loader surface, tests import the internal module directly. - The composition swap comment no longer advertises -browse as a complete swap before its dialog lands (stacked follow-up).
This commit is contained in:
+4
-2
@@ -252,8 +252,10 @@
|
|||||||
# mapping target (user config overrides these engineering defaults).
|
# mapping target (user config overrides these engineering defaults).
|
||||||
# Directory-picking package, dual-face: the node half serves the gateway's
|
# Directory-picking package, dual-face: the node half serves the gateway's
|
||||||
# host.* picker RPCs, the browser half fills ui-workspace's directory-flow
|
# host.* picker RPCs, the browser half fills ui-workspace's directory-flow
|
||||||
# slots — one row composes the whole interaction. Swap point: mount
|
# slots — one row composes the whole interaction. Swap point: '-browse'
|
||||||
# '-browse' instead for the in-app browser (remote-capable).
|
# 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
|
- id: directory-picker
|
||||||
name: '@deepseek-ai/dsh-host-directory-picker-native'
|
name: '@deepseek-ai/dsh-host-directory-picker-native'
|
||||||
|
|
||||||
|
|||||||
@@ -253,8 +253,7 @@ export function WorkspaceBrowser({
|
|||||||
deleteWorkspace,
|
deleteWorkspace,
|
||||||
insertSessionBefore,
|
insertSessionBefore,
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
hasDirectoryFlow,
|
useDirectoryFlow,
|
||||||
subscribeDirectoryFlow,
|
|
||||||
renderSlot,
|
renderSlot,
|
||||||
}: WorkspaceBrowserProps) {
|
}: WorkspaceBrowserProps) {
|
||||||
const workspaces = useWorkspaces(state => state.items)
|
const workspaces = useWorkspaces(state => state.items)
|
||||||
@@ -373,8 +372,7 @@ export function WorkspaceBrowser({
|
|||||||
anchorRef={wsPlusRef}
|
anchorRef={wsPlusRef}
|
||||||
useWorkspaces={useWorkspaces}
|
useWorkspaces={useWorkspaces}
|
||||||
createWorkspace={createWorkspace}
|
createWorkspace={createWorkspace}
|
||||||
hasDirectoryFlow={hasDirectoryFlow}
|
useDirectoryFlow={useDirectoryFlow}
|
||||||
subscribeDirectoryFlow={subscribeDirectoryFlow}
|
|
||||||
renderDirectoryFlow={owner => renderSlot('sidebar.workspaces.directoryFlow', owner)}
|
renderDirectoryFlow={owner => renderSlot('sidebar.workspaces.directoryFlow', owner)}
|
||||||
createOnly
|
createOnly
|
||||||
side="right"
|
side="right"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* opens the flow, adopts the picked path, and owns the error surface.
|
* opens the flow, adopts the picked path, and owns the error surface.
|
||||||
*/
|
*/
|
||||||
import type { ReactNode, RefObject } from 'react'
|
import type { ReactNode, RefObject } from 'react'
|
||||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
|
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
|
||||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
WorkspaceCreateError,
|
WorkspaceCreateError,
|
||||||
type WorkspaceId, type WorkspaceListState, type WorkspaceView,
|
type WorkspaceId, type WorkspaceListState, type WorkspaceView,
|
||||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
} 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 type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts'
|
||||||
import css from './WorkspacePicker.module.css'
|
import css from './WorkspacePicker.module.css'
|
||||||
|
|
||||||
@@ -33,10 +34,8 @@ export interface WorkspaceCreateFlowProps {
|
|||||||
useWorkspaces: <S>(selector: (state: WorkspaceListState) => S) => S
|
useWorkspaces: <S>(selector: (state: WorkspaceListState) => S) => S
|
||||||
/** Create or adopt a real Host Workspace. */
|
/** Create or adopt a real Host Workspace. */
|
||||||
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
|
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
|
||||||
/** Whether this surface's directory-flow hole is occupied (empty hides the local-folder entry). */
|
/** Bound occupancy selector hook for this surface's directory-flow hole (empty hides the local-folder entry). */
|
||||||
hasDirectoryFlow: () => boolean
|
useDirectoryFlow: SnapshotSelectorHook<boolean>
|
||||||
/** Registration-change subscription for the same hole (the uSES pair of hasDirectoryFlow). */
|
|
||||||
subscribeDirectoryFlow: (listener: () => void) => () => void
|
|
||||||
/** Render this surface's directory-flow hole with the owner conversation (the entry's narrowed renderSlot). */
|
/** Render this surface's directory-flow hole with the owner conversation (the entry's narrowed renderSlot). */
|
||||||
renderDirectoryFlow: (owner: DirectoryFlowOwnerProps) => ReactNode
|
renderDirectoryFlow: (owner: DirectoryFlowOwnerProps) => ReactNode
|
||||||
/** A real Workspace was picked or created. */
|
/** A real Workspace was picked or created. */
|
||||||
@@ -61,8 +60,7 @@ export function WorkspaceCreateFlow({
|
|||||||
anchorRef,
|
anchorRef,
|
||||||
useWorkspaces,
|
useWorkspaces,
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
hasDirectoryFlow,
|
useDirectoryFlow,
|
||||||
subscribeDirectoryFlow,
|
|
||||||
renderDirectoryFlow,
|
renderDirectoryFlow,
|
||||||
onPick,
|
onPick,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -95,9 +93,9 @@ export function WorkspaceCreateFlow({
|
|||||||
|
|
||||||
// The occupied hole gates the picking affordance: with no composed flow the
|
// 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
|
// entry simply is not there (the seam's documented no-flow default). The
|
||||||
// subscription keeps occupancy live: flow plugins activate (and HMR-reload)
|
// framework-bound hook keeps occupancy live: flow plugins activate (and
|
||||||
// independently of this menu's renders.
|
// HMR-reload) independently of this menu's renders.
|
||||||
const flowAvailable = useSyncExternalStore(subscribeDirectoryFlow, hasDirectoryFlow)
|
const flowAvailable = useDirectoryFlow(occupied => occupied)
|
||||||
// An occupant that unloads mid-interaction leaves nobody to cancel: an
|
// An occupant that unloads mid-interaction leaves nobody to cancel: an
|
||||||
// open flow over an empty hole withdraws so the menu actions come back.
|
// open flow over an empty hole withdraws so the menu actions come back.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -296,8 +294,7 @@ export function WorkspacePicker({
|
|||||||
onPick,
|
onPick,
|
||||||
onClose,
|
onClose,
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
hasDirectoryFlow,
|
useDirectoryFlow,
|
||||||
subscribeDirectoryFlow,
|
|
||||||
renderSlot,
|
renderSlot,
|
||||||
}: WorkspacePickerProps) {
|
}: WorkspacePickerProps) {
|
||||||
return (
|
return (
|
||||||
@@ -306,8 +303,7 @@ export function WorkspacePicker({
|
|||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
useWorkspaces={useWorkspaces}
|
useWorkspaces={useWorkspaces}
|
||||||
createWorkspace={createWorkspace}
|
createWorkspace={createWorkspace}
|
||||||
hasDirectoryFlow={hasDirectoryFlow}
|
useDirectoryFlow={useDirectoryFlow}
|
||||||
subscribeDirectoryFlow={subscribeDirectoryFlow}
|
|
||||||
renderDirectoryFlow={owner => renderSlot('conversation.hero.workspace.directoryFlow', owner)}
|
renderDirectoryFlow={owner => renderSlot('conversation.hero.workspace.directoryFlow', owner)}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onPick={onPick}
|
onPick={onPick}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
* and a hole has exactly one declaring entry — they carry the same owner
|
* and a hole has exactly one declaring entry — they carry the same owner
|
||||||
* contract and the same occupant.
|
* 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
|
// Type-only: pull the owner SlotMap merges into programs that resolve the
|
||||||
// runtime shares below.
|
// runtime shares below.
|
||||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||||
@@ -59,21 +59,24 @@ export type DirectoryFlowSlotName =
|
|||||||
| 'conversation.hero.workspace.directoryFlow'
|
| 'conversation.hero.workspace.directoryFlow'
|
||||||
| 'sidebar.workspaces.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 = {
|
export type DirectoryPickingInjected = {
|
||||||
/**
|
hooks: {
|
||||||
* Whether this surface's directory-flow hole is occupied — an empty hole
|
/** True while this surface's directory-flow hole is occupied. */
|
||||||
* hides the "Open local folder…" entry (the no-flow composition simply has
|
directoryFlow: HostObservable<boolean>
|
||||||
* no picking affordance).
|
}
|
||||||
*/
|
}
|
||||||
hasDirectoryFlow: () => boolean
|
|
||||||
/**
|
/** Component-side view of the picking share: the bound occupancy selector hook. */
|
||||||
* Subscribe to the hole's registration changes (the uSES pair of
|
export type DirectoryPickingHooks = {
|
||||||
* {@link hasDirectoryFlow}): the trigger surface withdraws an open flow
|
/** Selector hook over this surface's directory-flow occupancy. */
|
||||||
* whose occupant unloaded mid-interaction — nobody is left to cancel it.
|
useDirectoryFlow: SnapshotSelectorHook<boolean>
|
||||||
* @returns the unsubscriber.
|
|
||||||
*/
|
|
||||||
subscribeDirectoryFlow: (listener: () => void) => () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,7 +112,8 @@ export type WorkspaceBrowserProps =
|
|||||||
PropsRuntime<'sidebar.workspaces'>
|
PropsRuntime<'sidebar.workspaces'>
|
||||||
& PropsRenderSlots<'sidebar.workspaces.directoryFlow'>
|
& PropsRenderSlots<'sidebar.workspaces.directoryFlow'>
|
||||||
& PropsStore<ReturnType<typeof createWorkspaceViewStore>>
|
& PropsStore<ReturnType<typeof createWorkspaceViewStore>>
|
||||||
& WorkspaceBrowserInjected
|
& Omit<WorkspaceBrowserInjected, 'hooks'>
|
||||||
|
& DirectoryPickingHooks
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Picker-private injected share. Pick semantics remain in the owner's onPick
|
* Picker-private injected share. Pick semantics remain in the owner's onPick
|
||||||
@@ -129,4 +133,5 @@ export type WorkspacePickerInjected = DirectoryPickingInjected & {
|
|||||||
export type WorkspacePickerProps =
|
export type WorkspacePickerProps =
|
||||||
PropsRuntime<'conversation.hero.workspace'>
|
PropsRuntime<'conversation.hero.workspace'>
|
||||||
& PropsRenderSlots<'conversation.hero.workspace.directoryFlow'>
|
& PropsRenderSlots<'conversation.hero.workspace.directoryFlow'>
|
||||||
& WorkspacePickerInjected
|
& Omit<WorkspacePickerInjected, 'hooks'>
|
||||||
|
& DirectoryPickingHooks
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
* packages/client/AGENTS.md.
|
* packages/client/AGENTS.md.
|
||||||
*/
|
*/
|
||||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
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 { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
|
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
|
||||||
import { createWorkspaceViewStore } from './stores.ts'
|
import { createWorkspaceViewStore } from './stores.ts'
|
||||||
@@ -16,7 +17,7 @@ import { WorkspaceBrowser } from './WorkspaceBrowser.tsx'
|
|||||||
import { WorkspacePicker } from './WorkspacePicker.tsx'
|
import { WorkspacePicker } from './WorkspacePicker.tsx'
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingInjected,
|
DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingHooks, DirectoryPickingInjected,
|
||||||
WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps,
|
WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps,
|
||||||
} from './contract/slots.ts'
|
} from './contract/slots.ts'
|
||||||
|
|
||||||
@@ -37,6 +38,14 @@ export const inject = ['slots', 'sessions', 'workspaces']
|
|||||||
* @param ctx - client root context.
|
* @param ctx - client root context.
|
||||||
*/
|
*/
|
||||||
export function apply(ctx: ClientContext): void {
|
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<boolean> => ({
|
||||||
|
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 => ({
|
const browserInjected = (): WorkspaceBrowserInjected => ({
|
||||||
// Explicit group actions keep their target; unscoped New Session rides
|
// Explicit group actions keep their target; unscoped New Session rides
|
||||||
// the runtime's shared action (recent-Workspace projection inside).
|
// 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)
|
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
|
||||||
},
|
},
|
||||||
createWorkspace: input => ctx.workspaces.create(input),
|
createWorkspace: input => ctx.workspaces.create(input),
|
||||||
hasDirectoryFlow: () => ctx.slots.entries('sidebar.workspaces.directoryFlow').length > 0,
|
hooks: { directoryFlow: browserFlowSource },
|
||||||
subscribeDirectoryFlow: listener => ctx.slots.subscribe('sidebar.workspaces.directoryFlow', listener),
|
|
||||||
})
|
})
|
||||||
const pickerInjected = (): WorkspacePickerInjected => ({
|
const pickerInjected = (): WorkspacePickerInjected => ({
|
||||||
createWorkspace: input => ctx.workspaces.create(input),
|
createWorkspace: input => ctx.workspaces.create(input),
|
||||||
hasDirectoryFlow: () => ctx.slots.entries('conversation.hero.workspace.directoryFlow').length > 0,
|
hooks: { directoryFlow: pickerFlowSource },
|
||||||
subscribeDirectoryFlow: listener => ctx.slots.subscribe('conversation.hero.workspace.directoryFlow', listener),
|
|
||||||
})
|
})
|
||||||
// Declaration-aware registration (deferRegistration): each owner's
|
// Declaration-aware registration (deferRegistration): each owner's
|
||||||
// declaring apply may activate after this one, and a register into an
|
// declaring apply may activate after this one, and a register into an
|
||||||
|
|||||||
@@ -88,18 +88,18 @@ describe('ui-workspace apply', () => {
|
|||||||
|
|
||||||
const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
|
const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
|
||||||
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
|
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
|
||||||
expect(browser.hasDirectoryFlow()).toBe(false)
|
expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false)
|
||||||
expect(picker.hasDirectoryFlow()).toBe(false)
|
expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false)
|
||||||
// A flow occupant flips exactly its own surface.
|
// A flow occupant flips exactly its own surface, and the source notifies.
|
||||||
const notified = vi.fn()
|
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)
|
const dispose = b.slots.register({ name: 'sidebar.workspaces.directoryFlow' } as never, () => null)
|
||||||
expect(browser.hasDirectoryFlow()).toBe(true)
|
expect(browser.hooks.directoryFlow.getSnapshot()).toBe(true)
|
||||||
expect(picker.hasDirectoryFlow()).toBe(false)
|
expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false)
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
expect(notified).toHaveBeenCalled()
|
expect(notified).toHaveBeenCalled()
|
||||||
dispose()
|
dispose()
|
||||||
expect(browser.hasDirectoryFlow()).toBe(false)
|
expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false)
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -59,8 +59,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
|||||||
deleteWorkspace: vi.fn(async () => {}),
|
deleteWorkspace: vi.fn(async () => {}),
|
||||||
insertSessionBefore: vi.fn(async () => {}),
|
insertSessionBefore: vi.fn(async () => {}),
|
||||||
createWorkspace: vi.fn(async () => workspace('created', [])),
|
createWorkspace: vi.fn(async () => workspace('created', [])),
|
||||||
hasDirectoryFlow: () => true,
|
useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }),
|
||||||
subscribeDirectoryFlow: () => () => {},
|
|
||||||
renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ? <div data-testid="directory-flow" /> : null)) as never,
|
renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ? <div data-testid="directory-flow" /> : null)) as never,
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import { WorkspaceCreateError } 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 type { DirectoryFlowOwnerProps } from '../src/client/contract/slots.ts'
|
||||||
|
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||||
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
|
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
|
||||||
|
|
||||||
afterEach(cleanup)
|
afterEach(cleanup)
|
||||||
@@ -50,16 +51,19 @@ function flowProbe() {
|
|||||||
return { probe, renderSlot }
|
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) {
|
function occupancySource(initial = true) {
|
||||||
let occupied = initial
|
let occupied = initial
|
||||||
const listeners = new Set<() => void>()
|
const listeners = new Set<() => void>()
|
||||||
return {
|
const useDirectoryFlow = bindSnapshotSelector({
|
||||||
hasDirectoryFlow: () => occupied,
|
getSnapshot: () => occupied,
|
||||||
subscribeDirectoryFlow: (listener: () => void) => {
|
subscribe: (listener: () => void) => {
|
||||||
listeners.add(listener)
|
listeners.add(listener)
|
||||||
return () => { listeners.delete(listener) }
|
return () => { listeners.delete(listener) }
|
||||||
},
|
},
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
useDirectoryFlow,
|
||||||
flip: (next: boolean) => {
|
flip: (next: boolean) => {
|
||||||
occupied = next
|
occupied = next
|
||||||
for (const listener of [...listeners]) listener()
|
for (const listener of [...listeners]) listener()
|
||||||
@@ -85,8 +89,7 @@ function mount(
|
|||||||
onPick={onPick}
|
onPick={onPick}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
createWorkspace={createWorkspace}
|
createWorkspace={createWorkspace}
|
||||||
hasDirectoryFlow={occupancy.hasDirectoryFlow}
|
useDirectoryFlow={occupancy.useDirectoryFlow}
|
||||||
subscribeDirectoryFlow={occupancy.subscribeDirectoryFlow}
|
|
||||||
renderSlot={renderSlot}
|
renderSlot={renderSlot}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -265,7 +268,7 @@ describe('WorkspacePicker', () => {
|
|||||||
<WorkspacePicker
|
<WorkspacePicker
|
||||||
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
|
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
|
||||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
|
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
|
||||||
hasDirectoryFlow={() => true} subscribeDirectoryFlow={() => () => {}} renderSlot={renderSlot}
|
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
expect(screen.queryByRole('menu')).toBeNull()
|
expect(screen.queryByRole('menu')).toBeNull()
|
||||||
@@ -280,7 +283,7 @@ describe('WorkspacePicker', () => {
|
|||||||
<WorkspacePicker
|
<WorkspacePicker
|
||||||
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
|
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
|
||||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
|
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
|
||||||
hasDirectoryFlow={() => true} subscribeDirectoryFlow={() => () => {}} renderSlot={renderSlot}
|
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
expect(screen.getByRole('status').textContent).toBe('Loading workspaces…')
|
expect(screen.getByRole('status').textContent).toBe('Loading workspaces…')
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -7,63 +7,13 @@
|
|||||||
* both sides of the native interaction with one cordis.yml row; no client
|
* both sides of the native interaction with one cordis.yml row; no client
|
||||||
* code branches on a capability kind.
|
* 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 { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
// Type-only: the SlotMap merge declaring the directory-flow holes and their owner contract.
|
// Type-only: pulls the SlotMap merge declaring the directory-flow holes.
|
||||||
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
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<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(() => () => { 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. */
|
/** Required services (cordis fiber inject): the slot registry and the wire-facing workspace service. */
|
||||||
export const inject = ['slots', 'workspaces']
|
export const inject = ['slots', 'workspaces']
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { act, cleanup, render } from '@testing-library/react'
|
|||||||
import { afterEach } from 'vitest'
|
import { afterEach } from 'vitest'
|
||||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/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)
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user