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

# Conflicts:
#	apps/cli/cordis.yml
This commit is contained in:
creatixchu
2026-07-29 02:53:19 +08:00
10 changed files with 136 additions and 112 deletions

View File

@@ -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"

View File

@@ -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}

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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,
}

View File

@@ -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…')