Просмотр исходного кода

Merge remote-tracking branch 'origin/feat/directory-picker' into feat/workspace-directory-browser

# Conflicts:
#	apps/cli/cordis.yml
creatixchu 2 месяцев назад
Родитель
Сommit
882e16fdf0

+ 2 - 4
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"

+ 10 - 14
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: <S>(selector: (state: WorkspaceListState) => S) => S
   /** Create or adopt a real Host Workspace. */
   createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
-  /** 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<boolean>
   /** 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}

+ 22 - 17
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<boolean>
+  }
+}
+
+/** 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<boolean>
 }
 
 /**
@@ -109,7 +112,8 @@ export type WorkspaceBrowserProps =
   PropsRuntime<'sidebar.workspaces'>
   & PropsRenderSlots<'sidebar.workspaces.directoryFlow'>
   & PropsStore<ReturnType<typeof createWorkspaceViewStore>>
-  & WorkspaceBrowserInjected
+  & Omit<WorkspaceBrowserInjected, 'hooks'>
+  & 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<WorkspacePickerInjected, 'hooks'>
+  & DirectoryPickingHooks

+ 12 - 5
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<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 => ({
     // 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

+ 7 - 7
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()
   })
 

+ 1 - 2
packages/client/ui-workspace/tests/workspace-browser.spec.tsx

@@ -59,8 +59,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
     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 ? <div data-testid="directory-flow" /> : null)) as never,
     ...overrides,
   }

+ 11 - 8
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', () => {
       <WorkspacePicker
         open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
         onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
-        hasDirectoryFlow={() => true} subscribeDirectoryFlow={() => () => {}} renderSlot={renderSlot}
+        useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot}
       />,
     )
     expect(screen.queryByRole('menu')).toBeNull()
@@ -280,7 +283,7 @@ describe('WorkspacePicker', () => {
       <WorkspacePicker
         open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
         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…')

+ 65 - 0
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<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
+}

+ 4 - 54
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<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. */
 export const inject = ['slots', 'workspaces']

+ 2 - 1
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)