Ver Fonte

feat(gui): useProjection — the fifth framework hook seat through the standard kit

React half of the session-projection client base: the renderer contract gains
an open-key projections face on SessionMaybeProvideInfo (cellOf(key), distinct
from the static hooks roster), web-react mints projectionHook (per-bundle cache;
per-cell uSES binding via the shared observableHook cache; unresolved keys read
undefined through the absent source so hook order stays constant), standardKit
delivers kit.useProjection, and the runtime merges UseProjection into
SessionStandardProps/SessionMaybeStandardProps (overloads mirror useSession).
3 jsdom specs (kit delivery + live re-render, selector over undefined, faceless
bundle = all absent); existing direct-prop-feed specs gain the one-line stub the
new required seat mandates.
imccyu há 1 mês atrás
pai
commit
90addbf53c

+ 11 - 0
packages/client/runtime/src/client/index.ts

@@ -7,6 +7,7 @@ import { SessionsService } from './sessions/service.ts'
 import type { SessionListState } from './sessions/service.ts'
 import { WorkspacesService } from './workspaces/service.ts'
 import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
+import type { UseProjection } from './sessions/projection-cell.ts'
 
 export { SlotsService } from './slots.ts'
 export type { RootOwnerProps } from './slots.ts'
@@ -34,6 +35,12 @@ export type {
 } from './sessions/conversation.ts'
 export { PendingWait } from './sessions/pending.ts'
 export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
+// Projection cells (session-projection RFC): domain plugins register cells at
+// scope materialization via `binding.session.projections.register(spec)`.
+export type {
+  ProjectionCell, ProjectionCellSet, ProjectionCellSpec, ProjectionSchemaLike, ProjectionsBaseline,
+  SessionProjectionMap, UseProjection,
+} from './sessions/projection-cell.ts'
 export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
 
 /** Client-side Cordis context after declaration merging. */
@@ -59,12 +66,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
     useSession: SnapshotSelectorHook<ConversationSnapshot>
     /** The framework-resolved session id (owners never pass it). */
     sessionId: SessionId
+    /** The fifth framework hook seat: key-addressed projection reader (undefined = capability absent). */
+    useProjection: UseProjection
   }
   /** Standard kit for slots that remain mounted while current session changes. */
   interface SessionMaybeStandardProps {
     useSession: MaybeSnapshotSelectorHook<ConversationSnapshot>
     /** Current session id; absent in the no-session state. */
     sessionId: SessionId | undefined
+    /** Key-addressed projection reader; every key reads absent while no session is current. */
+    useProjection: UseProjection
   }
   /** Props injected into every global slot component. */
   interface GlobalStandardProps {

+ 1 - 0
packages/client/ui-conversation/tests/chat-view.spec.tsx

@@ -105,6 +105,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
     useSession: bindSnapshotSelector(source),
     useSessions: emptySessions(),
     useWorkspaces: emptyWorkspaces(),
+    useProjection: (() => undefined),
     useInput: (() => { throw new Error('unused') }),
     inputActions: { setDraft: () => {}, submit: () => {} },
     useStore: bindSnapshotSelector(chat),

+ 2 - 0
packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx

@@ -76,6 +76,7 @@ describe('render branch tails', () => {
         useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
         useSessions={bindSnapshotSelector(emptyList)}
         useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
+        useProjection={(() => undefined)}
         useInput={(() => { throw new Error('unused') })}
         inputActions={{ setDraft: () => {}, submit: () => {} }}
         useStore={bindSnapshotSelector(chat)}
@@ -111,6 +112,7 @@ describe('render branch tails', () => {
         useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
         useSessions={bindSnapshotSelector(emptyList)}
         useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
+        useProjection={(() => undefined)}
         useInput={(() => { throw new Error('unused') })}
         inputActions={{ setDraft: () => {}, submit: () => {} }}
         useStore={bindSnapshotSelector(chat)}

+ 1 - 0
packages/client/ui-conversation/tests/input-bar.spec.tsx

@@ -84,6 +84,7 @@ function bench(over?: BenchOptions) {
       items: [], state: 'idle', phase: 'ready', error: null,
       baselinesReady: true, recentWorkspaceId: undefined,
     })),
+    useProjection: (() => undefined),
     useInput: bindSnapshotSelector(shell.state),
     inputActions: shell.actions,
     keyboard: shell,

+ 1 - 0
packages/client/ui-conversation/tests/input-matrix.spec.tsx

@@ -39,6 +39,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
       items: [], state: 'idle', phase: 'ready', error: null,
       baselinesReady: true, recentWorkspaceId: undefined,
     })),
+    useProjection: (() => undefined),
     useInput: bindSnapshotSelector(shell.state),
     inputActions: shell.actions,
     keyboard: shell,

+ 1 - 0
packages/client/ui-conversation/tests/input-scenarios.spec.tsx

@@ -125,6 +125,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
       items: [], state: 'idle', phase: 'ready', error: null,
       baselinesReady: true, recentWorkspaceId: undefined,
     })),
+    useProjection: (() => undefined),
     useInput: bindSnapshotSelector(shell.state),
     inputActions: shell.actions,
     keyboard: shell,

+ 1 - 0
packages/client/ui-conversation/tests/queue-dock.spec.tsx

@@ -53,6 +53,7 @@ function kitFor(snapshot: ConversationSnapshot) {
     sessionId: SID,
     useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
     useWorkspaces: (() => { throw new Error('unused') }) as never,
+    useProjection: (() => undefined) as never,
     useInput: (() => { throw new Error('unused') }) as never,
     inputActions: { setDraft: () => {}, submit: () => {} } as never,
     session: snapshot,

+ 3 - 0
packages/client/ui-conversation/tests/skeleton.spec.tsx

@@ -93,6 +93,7 @@ function mount(
           useSession={useSession}
           useSessions={props.useSessions}
           useWorkspaces={props.useWorkspaces}
+          useProjection={(() => undefined) as never}
           useInput={useInput}
           inputActions={inputActions}
           useStore={bindSnapshotSelector(chat)}
@@ -115,6 +116,7 @@ function mount(
           useSession={useSession}
           useSessions={props.useSessions}
           useWorkspaces={props.useWorkspaces}
+          useProjection={(() => undefined) as never}
           useInput={useInput}
           inputActions={inputActions}
           keyboard={wiring}
@@ -133,6 +135,7 @@ function mount(
     useSession,
     useSessions: bindSnapshotSelector(sessions),
     useWorkspaces: bindSnapshotSelector(workspaces),
+    useProjection: (() => undefined) as never,
     useInput,
     inputActions,
     renderSlot,

+ 1 - 0
packages/client/ui-question/tests/question-composer.spec.tsx

@@ -25,6 +25,7 @@ const kit = {
   useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
   useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
   useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<WorkspaceListState>,
+  useProjection: (() => undefined) as never,
   useInput: (() => { throw new Error('unused') }) as never,
   inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
 }

+ 8 - 0
packages/client/ui-slots/src/renderer.ts

@@ -44,6 +44,14 @@ export interface SessionMaybeProvideInfo {
   hooks: Record<string, HostObservable<unknown> | undefined>
   /** Static plain-member roster; values are undefined with the session. */
   props: Record<string, unknown>
+  /**
+   * Key-addressed projection-cell sources (the useProjection framework seat,
+   * session-projection RFC). Unlike `hooks`, the key space is open — cells
+   * come and go with domain plugins — so the render side binds per resolved
+   * cell instead of per static roster member. Absent with the session; an
+   * unresolved key uniformly reads as capability absent.
+   */
+  projections?: { cellOf(key: string): HostObservable<unknown> | undefined } | undefined
 }
 
 /** Definite per-session standard props resolved for strict session slots. */

+ 4 - 0
packages/client/ui-trajectory/tests/views.spec.tsx

@@ -77,6 +77,7 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
     useSession: fakeSession(nodes).useSession,
     useSessions: emptySessions(),
     useWorkspaces: emptyWorkspaces(),
+    useProjection: (() => undefined) as never,
   } as unknown as ConvViewProps
 }
 
@@ -135,6 +136,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
       useSession={useSession}
       useSessions={emptySessions()}
       useWorkspaces={emptyWorkspaces()}
+      useProjection={(() => undefined) as never}
       useStore={bindSnapshotSelector(chat)}
       actions={chat.actions}
       renderSlot={renderSlot}
@@ -330,6 +332,7 @@ describe('deriveSubSpans (waterfall lanes)', () => {
       useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
       useSessions: emptySessions(),
       useWorkspaces: emptyWorkspaces(),
+      useProjection: (() => undefined) as never,
     } as unknown as ConvViewProps
     const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
     const lane = view.container.querySelector('[data-subspan]')
@@ -356,6 +359,7 @@ describe('deriveSubSpans (waterfall lanes)', () => {
       useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
       useSessions: emptySessions(),
       useWorkspaces: emptyWorkspaces(),
+      useProjection: (() => undefined) as never,
     } as unknown as ConvViewProps
     const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
     const bar = view.container.querySelector('[data-timing="unknown"]')

+ 4 - 1
packages/client/web-react/src/scoped-slots.tsx

@@ -10,7 +10,7 @@ import {
 } from '@deepseek-ai/dsh-client-ui-slots'
 import {
   HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
-  observableHook, useHost, useSessionMaybeProvideInfo,
+  observableHook, projectionHook, useHost, useSessionMaybeProvideInfo,
 } from './session-provider.tsx'
 
 type InjectedProps = Record<string, unknown>
@@ -219,6 +219,9 @@ function standardKit(
     }
     Object.assign(kit, info.props)
     kit['sessionId'] = info.sessionId
+    // The useProjection seat (fifth framework hook): key-addressed cell
+    // reader, bound per provide bundle (cached by info identity).
+    kit['useProjection'] = projectionHook(info)
   }
   const store = scope === 'session-maybe' && info?.sessionId === undefined
     ? undefined

+ 33 - 0
packages/client/web-react/src/session-provider.tsx

@@ -83,6 +83,39 @@ function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S,
   return undefined
 }
 
+/**
+ * The useProjection framework seat (session-projection RFC), one bound
+ * function per provide bundle (cached by info identity — components may hold
+ * it across renders). Key-addressed: the key resolves a per-session cell
+ * source, whose bound selector hook comes from the same per-source cache as
+ * every other kit hook, so exactly one uSES subscription runs per call and
+ * the subscribe reference stays stable while the cell lives. An unresolved
+ * key (no cell, no session, plugin unloaded) reads `undefined` — capability
+ * absence — through the absent source, keeping the hook order constant.
+ */
+export function projectionHook(info: SessionMaybeProvideInfo): (
+  key: string, selector?: (value: unknown) => unknown, eq?: (a: unknown, b: unknown) => boolean
+) => unknown {
+  let hook = projectionHookCache.get(info)
+  if (hook === undefined) {
+    hook = (key, selector, eq) => {
+      const cell = info.projections?.cellOf(key)
+      // The absent branch binds the shared absent source so the caller's
+      // selector still runs over `undefined` (absence flows through the
+      // selector) and the uSES call count stays constant across resolution.
+      const useCell = observableHook(cell ?? absentSource)
+      // Whole values are frozen event/wire data (identical reference between
+      // events), so the identity selector needs no equality function.
+      return useCell(selector ?? (value => value), eq)
+    }
+    projectionHookCache.set(info, hook)
+  }
+  return hook
+}
+const projectionHookCache = new WeakMap<SessionMaybeProvideInfo, (
+  key: string, selector?: (value: unknown) => unknown, eq?: (a: unknown, b: unknown) => boolean
+) => unknown>()
+
 /**
  * Root-level binding provider. It follows current selection without a key, so
  * session-maybe entries retain their React identity while the context value

+ 125 - 0
packages/client/web-react/tests/use-projection.spec.tsx

@@ -0,0 +1,125 @@
+// @vitest-environment jsdom
+/**
+ * useProjection standard-kit delivery (session-projection RFC): the fifth
+ * framework hook seat rides the same provide channel as useSession — a
+ * session slot component receives `useProjection` in its kit, key-addressed
+ * over the bundle's projection face; unresolved keys (no cell, no face, no
+ * session) uniformly read `undefined`; live cell changes re-render; the
+ * selector overload runs over the whole value.
+ */
+import { describe, expect, it } from 'vitest'
+import { act, render } from '@testing-library/react'
+import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
+import { createSlotRenderer, type SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
+
+function observable<T>(initial: T) {
+  let value = initial
+  const subs = new Set<() => void>()
+  return {
+    getSnapshot: () => value,
+    subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } },
+    set: (next: T) => { value = next; for (const fn of [...subs]) fn() },
+  }
+}
+
+type UseProjectionProp = (key: string, selector?: (v: unknown) => unknown) => unknown
+
+function makeHost() {
+  const current = observable<string | undefined>(undefined)
+  const cells = new Map<string, ReturnType<typeof observable<unknown>>>()
+  const sessionEntries: StoredEntry[] = []
+  let withFace = true
+  const rootEntry: StoredEntry = {
+    component: (props: { renderSlot: (key: string, owner: object) => React.ReactNode }) =>
+      <>{props.renderSlot('k.session', {})}</>,
+    options: {},
+    children: { 'k.session': { kind: 'single', scope: 'session' } },
+  }
+  const info = (id: string) => ({
+    sessionId: id,
+    hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } },
+    props: {},
+    ...(withFace ? { projections: { cellOf: (key: string) => cells.get(key) } } : {}),
+  })
+  const host: SlotRendererHost = {
+    subscribe: () => () => {},
+    getVersion: () => 0,
+    entriesOf: (key) => key === 'root' ? [rootEntry] : sessionEntries,
+    specOf: (key) => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
+    isLive: () => true,
+    storeOf: () => undefined,
+    sessions: {
+      list: observable<unknown>({ ids: [] }),
+      current,
+      provideInfo: (id) => info(id),
+      maybeProvideInfo: (id) => (id === undefined
+        ? { sessionId: undefined, hooks: { session: undefined }, props: {} }
+        : info(id)),
+    },
+    workspaces: { list: observable<unknown>({ items: [] }) },
+  }
+  return {
+    host, current, cells,
+    dropFace: () => { withFace = false },
+    registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
+  }
+}
+
+describe('useProjection standard-kit delivery', () => {
+  it('reads the cell value through the kit, undefined for unresolved keys, and follows live changes', () => {
+    const h = makeHost()
+    const cell = observable<unknown>({ marks: ['a'] })
+    h.cells.set('test/marks', cell)
+    const reads: Record<string, unknown>[] = []
+    h.registerSession({
+      component: (props: { useProjection: UseProjectionProp }) => {
+        reads.push({
+          marks: props.useProjection('test/marks'),
+          ghost: props.useProjection('test/ghost'),
+        })
+        return null
+      },
+      options: {},
+    })
+    render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
+    act(() => { h.current.set('s1') })
+    expect(reads.at(-1)).toEqual({ marks: { marks: ['a'] }, ghost: undefined })
+    // Live change re-renders with the new whole value.
+    act(() => { cell.set({ marks: ['a', 'b'] }) })
+    expect(reads.at(-1)).toEqual({ marks: { marks: ['a', 'b'] }, ghost: undefined })
+  })
+
+  it('runs the selector overload over the whole value (and over undefined when absent)', () => {
+    const h = makeHost()
+    h.cells.set('test/marks', observable<unknown>({ marks: ['x', 'y'] }))
+    const reads: unknown[] = []
+    h.registerSession({
+      component: (props: { useProjection: UseProjectionProp }) => {
+        reads.push(props.useProjection('test/marks', v => (v as { marks: string[] } | undefined)?.marks.length ?? -1))
+        reads.push(props.useProjection('test/ghost', v => (v === undefined ? 'absent' : 'present')))
+        return null
+      },
+      options: {},
+    })
+    render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
+    act(() => { h.current.set('s1') })
+    expect(reads.slice(-2)).toEqual([2, 'absent'])
+  })
+
+  it('treats a bundle without the projections face as all-absent (capability absence)', () => {
+    const h = makeHost()
+    h.cells.set('test/marks', observable<unknown>({ marks: ['a'] }))
+    h.dropFace()
+    const reads: unknown[] = []
+    h.registerSession({
+      component: (props: { useProjection: UseProjectionProp }) => {
+        reads.push(props.useProjection('test/marks'))
+        return null
+      },
+      options: {},
+    })
+    render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
+    act(() => { h.current.set('s1') })
+    expect(reads.at(-1)).toBeUndefined()
+  })
+})