Jelajahi Sumber

refactor(client): bind keyed chat sources in renderer

imccyu 1 bulan lalu
induk
melakukan
577f0cf7d9

+ 8 - 2
packages/client/ui-chat/src/client/apply.ts

@@ -104,10 +104,16 @@ export function apply(ctx: Context): void {
       },
       store: chatStore,
       inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
-        const session = ctx.sessions.binding(sessionId)?.session
-        if (session === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`)
+        const binding = ctx.sessions.binding(sessionId)
+        if (binding === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`)
+        const session = binding.session
+        const chat = chatSource(binding)
         return {
           hooks: { transcriptView: transcriptView.mode },
+          keyedHooks: {
+            chatNode: key => chat.getSnapshot().nodes.source(key),
+            chatNodeProcess: key => chat.getSnapshot().nodes.processSource(key),
+          },
           openDetails: (target) => {
             actions.select(target)
             ctx.layout.openDetails()

+ 7 - 10
packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx

@@ -1,17 +1,17 @@
-import { memo, useCallback, useMemo, useSyncExternalStore } from 'react'
+import { memo, useCallback, useMemo } from 'react'
 import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ConversationLocationDataStore, ConversationTurnDataMap } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts'
 import type { ChatNode } from '../contract/chat-nodes.ts'
-import type { ChatNodeProcessSource, ChatNodeSource } from '../contract/snapshot.ts'
 import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
 import { storedTurnProcessEntry } from '../stores.ts'
 import { useSearchableHidden } from './searchable-hidden.ts'
 import css from './ChatView.module.css'
 
 interface ChatNodeSeatProps extends ChatNodeOwnerProps {
-  readonly nodeSource: ChatNodeSource
-  readonly processSource: ChatNodeProcessSource
+  readonly nodeKey: string
+  readonly useChatNode: ChatViewSlotProps['useChatNode']
+  readonly useChatNodeProcess: ChatViewSlotProps['useChatNodeProcess']
   readonly historyIncomplete: boolean
   readonly compactTranscript: boolean
   readonly useStore: ChatViewSlotProps['useStore']
@@ -36,17 +36,14 @@ function turnOf(node: ChatNode | undefined): number | undefined {
 
 /** Subscribe, apply Turn-process visibility, and dispatch one stable Context key. */
 export const ChatNodeSeat = memo(function ChatNodeSeat({
-  nodeSource, processSource, historyIncomplete, compactTranscript,
+  nodeKey, useChatNode, useChatNodeProcess, historyIncomplete, compactTranscript,
   selectedCallId, cwd, openFile, inspectCall, forkAt,
   renderMessageImages, fileMentions, useStore, actions, renderSlot, t,
 }: ChatNodeSeatProps) {
-  const node = useSyncExternalStore(nodeSource.subscribe, nodeSource.getSnapshot)
+  const node = useChatNode(nodeKey)
   const routedNode = node as ChatNode | undefined
   const turn = turnOf(routedNode)
-  const processPresentation = useSyncExternalStore(
-    processSource.subscribe,
-    processSource.getSnapshot,
-  )
+  const processPresentation = useChatNodeProcess(nodeKey)
   const processSpec = processPresentation?.spec
   const storedEntry = useStore(state => processSpec === undefined
     ? undefined

+ 6 - 4
packages/client/ui-chat/src/client/chat/ChatView.tsx

@@ -204,8 +204,9 @@ function TurnStatus({ startTime, t }: {
  * ordered business Node crosses the keyed renderer seat.
  */
 export function ChatView({
-  useSession, useChat, useSessions, useStore, actions, renderSlot, sessionId, openFile, loadOlder, loadThrough,
-  loadImage, openView, chatScroll, forkAt, fileMentions, useTranscriptView, useProjection, t,
+  useSession, useChat, useChatNode, useChatNodeProcess, useSessions, useStore, actions, renderSlot,
+  sessionId, openFile, loadOlder, loadThrough, loadImage, openView, chatScroll, forkAt, fileMentions,
+  useTranscriptView, useProjection, t,
 }: ChatViewSlotProps) {
   const order = useChat(s => s.order)
   const nodeStore = useChat(s => s.nodes)
@@ -745,8 +746,9 @@ export function ChatView({
           {order.map(nodeKey => (
             <ChatNodeSeat
               key={nodeKey}
-              nodeSource={nodeStore.source(nodeKey)}
-              processSource={nodeStore.processSource(nodeKey)}
+              nodeKey={nodeKey}
+              useChatNode={useChatNode}
+              useChatNodeProcess={useChatNodeProcess}
               historyIncomplete={hasMore}
               compactTranscript={compactTranscript}
               useStore={useStore}

+ 19 - 4
packages/client/ui-chat/src/client/contract/slots.ts

@@ -6,22 +6,31 @@ import type {
   MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {
-  InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory,
-  SnapshotSelectorHook,
+  InjectFace, KeyedSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
+  SlotHookFactory, SnapshotSelectorHook,
 } from '@deepseek-ai/dsh-client-ui-slots'
 import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
 import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
 import type { createChatStore } from '../stores.ts'
 import type { ToolCallId, SelectionTarget } from './store.ts'
-import type { ChatNode, ChatNodeKind } from './chat-nodes.ts'
-import type { ChatSnapshot, CommandNode, CompactionSummaryNode, ToolCallBlock } from './snapshot.ts'
+import type { ChatConversationViewNode, ChatNode, ChatNodeKind } from './chat-nodes.ts'
+import type {
+  ChatNodeProcessSource, ChatNodeSource, ChatSnapshot, ChatTurnProcessPresentation, CommandNode,
+  CompactionSummaryNode, ToolCallBlock,
+} from './snapshot.ts'
 import type { TurnProcessSpec } from './turn-process.ts'
 import type { TranscriptViewMode } from '../../chat-settings.ts'
 
 /** Selector hook over the current Conversation binding's Chat target. */
 export type UseChat = SnapshotSelectorHook<ChatSnapshot>
 
+/** Per-key selector hook over one Chat Node. */
+export type UseChatNode = KeyedSnapshotSelectorHook<ChatConversationViewNode | undefined>
+
+/** Per-key selector hook over one Chat Node's Turn-process presentation. */
+export type UseChatNodeProcess = KeyedSnapshotSelectorHook<ChatTurnProcessPresentation | undefined>
+
 /** Owner currency of the completed-Turn extension chain. */
 export interface TurnTailOwnerProps {
   turn: TurnLocation
@@ -117,6 +126,12 @@ export interface ChatViewInjected {
     /** Persisted completed-Turn transcript presentation. */
     transcriptView: SnapshotStore<TranscriptViewMode>
   }
+  keyedHooks: {
+    /** Resolve the stable source for one Chat Node key. */
+    chatNode: (key: string) => ChatNodeSource
+    /** Resolve the stable Turn-process source for one Chat Node key. */
+    chatNodeProcess: (key: string) => ChatNodeProcessSource
+  }
   openDetails: (target: SelectionTarget) => void
   openFile: (path: string) => Promise<void>
   loadOlder: () => void

+ 59 - 22
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -19,8 +19,9 @@ import type {
 import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
+import type { KeyedSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import { createChatStore } from '../src/client/stores.ts'
@@ -204,6 +205,21 @@ function emptyWorkspaces() {
   return bindSnapshotSelector(store)
 }
 
+function bindKeyedSnapshotSelector<Value>(
+  resolve: (key: string) => ObservableSnapshot<Value>,
+): KeyedSnapshotSelectorHook<Value> {
+  const hooks = new WeakMap<object, SnapshotSelectorHook<Value>>()
+  return ((key: string, selector?: (value: Value) => unknown, equal?: (left: unknown, right: unknown) => boolean) => {
+    const source = resolve(key)
+    let useValue = hooks.get(source)
+    if (useValue === undefined) {
+      useValue = bindSnapshotSelector(source)
+      hooks.set(source, useValue)
+    }
+    return useValue(selector ?? ((value: Value) => value), equal)
+  }) as KeyedSnapshotSelectorHook<Value>
+}
+
 function makeHarness(
   init: HarnessUpdate = {},
   sessionOverrides: Partial<SessionSnapshot> = {},
@@ -223,6 +239,12 @@ function makeHarness(
   }
   const session = makeSessionSource({ ...sessionInit, ...sessionOverrides })
   const chatSource = makeChatSource(chatSlice, initialChat ?? chatSnapshot)
+  const useChatNode = bindKeyedSnapshotSelector(
+    key => chatSource.source.getSnapshot().nodes.source(key),
+  )
+  const useChatNodeProcess = bindKeyedSnapshotSelector(
+    key => chatSource.source.getSnapshot().nodes.processSource(key),
+  )
   const openDetails = vi.fn<(t: SelectionTarget) => void>()
   const openFile = vi.fn<(path: string) => Promise<void>>().mockResolvedValue(undefined)
   const loadOlder = vi.fn()
@@ -344,6 +366,8 @@ function makeHarness(
     sessionId: SID,
     useSession: bindSnapshotSelector(session.source),
     useChat: bindSnapshotSelector(chatSource.source),
+    useChatNode,
+    useChatNodeProcess,
     useConversation: bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT)),
     useTrajectory: (() => { throw new Error('unused') }),
     useSessions: emptySessions(),
@@ -421,7 +445,11 @@ function turnProcessControl(container: HTMLElement): HTMLButtonElement | null {
   return container.querySelector<HTMLButtonElement>('[data-turn-process]')
 }
 
-function withSystemPrompt(snapshot: ChatSnapshot, text = '# System'): ChatSnapshot {
+function withSystemPrompt(
+  snapshot: ChatSnapshot,
+  builder = new ChatSnapshotBuilder(),
+  text = '# System',
+): ChatSnapshot {
   const turn = snapshot.timeline.turns.get(1)
   if (turn === undefined) throw new Error('fixture lacks Turn 1')
   const prompt: ChatNode<'system-prompt'> = {
@@ -434,7 +462,7 @@ function withSystemPrompt(snapshot: ChatSnapshot, text = '# System'): ChatSnapsh
     visibility: 'visible',
     data: { text },
   }
-  return new ChatSnapshotBuilder().replace({
+  return builder.replace({
     nodes: [prompt, ...snapshot.nodes.values()],
     timeline: snapshot.timeline,
   })
@@ -1315,25 +1343,10 @@ describe('ChatView', () => {
   })
 
   it('keeps the first System prompt above User and outside Process through completion and expansion', () => {
+    const builder = new ChatSnapshotBuilder()
     const initial = withSystemPrompt(chatSnapshotFixture({
       nodes: [userInTurn(2, 'question', 1), context(3, 'runtime policy', 1)],
-    }))
-    const running = withSystemPrompt(chatSnapshotFixture({
-      nodes: [
-        userInTurn(2, 'question', 1),
-        context(3, 'runtime policy', 1),
-        reasoningAssistant(4, 'inspect', 1, 1),
-      ],
-    }))
-    const completed = withSystemPrompt(chatSnapshotFixture({
-      nodes: [
-        userInTurn(2, 'question', 1),
-        context(3, 'runtime policy', 1),
-        reasoningAssistant(4, 'inspect', 1, 1),
-        assistant(6, 'final answer', 1, 2),
-      ],
-      turnEnds: new Map([[1, 7]]),
-    }))
+    }), builder)
     const h = makeHarness({ chat: initial }, { running: true })
     const view = render(<h.ChatView {...h.props} />)
     const promptRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="system-prompt"]')!
@@ -1342,14 +1355,38 @@ describe('ChatView', () => {
     expect(promptRow.getAttribute('hidden')).toBeNull()
     expect(promptRow.hasAttribute('data-turn-process-member')).toBe(false)
 
-    act(() => { h.set({ chat: running, running: true }) })
+    act(() => {
+      h.set({
+        chat: withSystemPrompt(chatSnapshotFixture({
+          nodes: [
+            userInTurn(2, 'question', 1),
+            context(3, 'runtime policy', 1),
+            reasoningAssistant(4, 'inspect', 1, 1),
+          ],
+        }), builder),
+        running: true,
+      })
+    })
     expect(renderedFlowKinds(view.container)).toEqual([
       'system-prompt', 'user', 'turn-process', 'context', 'assistant-step',
     ])
     expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBe(promptRow)
     expect(promptRow.getAttribute('hidden')).toBeNull()
 
-    act(() => { h.set({ chat: completed, running: false }) })
+    act(() => {
+      h.set({
+        chat: withSystemPrompt(chatSnapshotFixture({
+          nodes: [
+            userInTurn(2, 'question', 1),
+            context(3, 'runtime policy', 1),
+            reasoningAssistant(4, 'inspect', 1, 1),
+            assistant(6, 'final answer', 1, 2),
+          ],
+          turnEnds: new Map([[1, 7]]),
+        }), builder),
+        running: false,
+      })
+    })
     const toggle = turnProcessControl(view.container)!
     const members = [...view.container.querySelectorAll<HTMLElement>('[data-turn-process-member]')]
     expect(renderedFlowKinds(view.container)).toEqual([

+ 16 - 10
packages/client/ui-renderer/src/client/scoped-slots.tsx

@@ -5,7 +5,7 @@
 import { Component, useMemo, useState, useSyncExternalStore, type FC, type ReactNode } from 'react'
 import {
   SlotOwnershipError, StaleAuthorizationError, standardHookPropName,
-  type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts,
+  type ChainRenderOpts, type HostObservable, type KeyedStandardSource, type LocaleFace, type RenderOpts,
   type ScopedStandardSourceBinding, type SessionAreaProps, type SessionProviderComponent, type SlotRenderer,
   type SlotRendererHost, type SlotScope, type SlotScopeAdapter, type StandardSourceBinding,
   type StoredEntry, type Translate,
@@ -109,22 +109,28 @@ function runInject(entry: StoredEntry, binding: StandardSourceBinding | undefine
   const args: unknown[] = []
   if (binding !== undefined) args.push(binding.key)
   if (actions !== undefined) args.push(actions)
-  return bindInjectHooks((inject as (...args: unknown[]) => InjectedProps)(...args))
+  return bindInjectSources((inject as (...args: unknown[]) => InjectedProps)(...args))
 }
 
-/**
- * Normalize one entry-owned inject face on its existing cache axis. Its hooks
- * compartment remains the original Observable-only contract.
- */
-function bindInjectHooks(face: InjectedProps): InjectedProps {
+/** Bind one entry-owned inject face on its existing cache axis. */
+function bindInjectSources(face: InjectedProps): InjectedProps {
   const sources = face['hooks']
-  if (sources === undefined) return face
-  const { hooks: _hooks, ...rest } = face
+  const keyedSources = face['keyedHooks']
+  if (sources === undefined && keyedSources === undefined) return face
+  const { hooks: _hooks, keyedHooks: _keyedHooks, ...rest } = face
   const bound: InjectedProps = rest
-  for (const [name, source] of Object.entries(sources as Record<string, HostObservable<unknown>>)) {
+  for (const [name, source] of Object.entries(
+    (sources ?? {}) as Record<string, HostObservable<unknown>>,
+  )) {
     const hookName = standardHookPropName(name)
     bound[hookName] = observableHook(source)
   }
+  for (const [name, source] of Object.entries(
+    (keyedSources ?? {}) as Record<string, KeyedStandardSource>,
+  )) {
+    const hookName = standardHookPropName(name)
+    bound[hookName] = keyedObservableHook(source)
+  }
   return bound
 }
 

+ 35 - 0
packages/client/ui-renderer/tests/scoped-slots.client.spec.tsx

@@ -930,6 +930,41 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
     expect(seen.at(-1)!['read']).toBe('hot')
   })
 
+  it('binds the inject keyedHooks compartment without exposing source resolvers', () => {
+    const h = makeHost()
+    h.declare('k.single', SINGLE_ROOT)
+    const badges = new Map([
+      ['first', observable('cold')],
+      ['second', observable('idle')],
+    ])
+    const seen: Record<string, unknown>[] = []
+    h.add('k.single', {
+      component: (props: {
+        useBadge?: (key: string) => string | undefined
+        keyedHooks?: unknown
+        plain?: string
+      }) => {
+        seen.push({
+          keyedHooks: props.keyedHooks,
+          plain: props.plain,
+          read: props.useBadge!('first'),
+        })
+        return null
+      },
+      inject: () => ({
+        plain: 'kept',
+        keyedHooks: { badge: (key: string) => badges.get(key) },
+      }),
+    })
+    mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
+
+    expect(seen.at(-1)).toEqual({ keyedHooks: undefined, plain: 'kept', read: 'cold' })
+    act(() => { badges.get('second')!.set('ignored') })
+    expect(seen).toHaveLength(1)
+    act(() => { badges.get('first')!.set('hot') })
+    expect(seen.at(-1)!['read']).toBe('hot')
+  })
+
   it('session inject receives sessionId and caches per (entry x session): switch-back reuses', () => {
     const h = makeHost()
     h.declare('k.session', SINGLE_SESSION)

+ 35 - 2
packages/client/ui-slots/src/index.ts

@@ -17,7 +17,7 @@ import type { ReactNode } from 'react'
 import type {
   BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDecl,
 } from '@deepseek-ai/dsh-client-store'
-import type { HostObservable } from './renderer.ts'
+import type { HostObservable, KeyedStandardSource } from './renderer.ts'
 
 export * from './store.ts'
 export * from './renderer.ts'
@@ -375,6 +375,26 @@ export type SlotComponent<P> = (props: P) => ReactNode
  */
 export type HooksSources = Record<string, HostObservable<unknown>>
 
+/** Registrant keyed-hooks compartment: stable key-to-observable resolvers. */
+export type KeyedHooksSources = Record<string, KeyedStandardSource>
+
+/** Selector Hook over an open family of keyed observable sources. */
+export type KeyedSnapshotSelectorHook<Snapshot> = {
+  /** @param key - source key. @returns the current value, or absence when the source is unavailable. */
+  (key: string): Snapshot | undefined
+  /**
+   * @param key - source key.
+   * @param selector - projection over the current keyed value.
+   * @param equal - optional selected-value equality.
+   * @returns the selected value.
+   */
+  <Selected>(
+    key: string,
+    selector: (value: Snapshot | undefined) => Selected,
+    equal?: (left: Selected, right: Selected) => boolean,
+  ): Selected
+}
+
 /** Framework-owned props visible while a slot-level contextual Hook is bound. */
 export type StandardPropsOf<K extends keyof SlotMap & string> =
   (ScopeOf<K> extends 'session' ? SessionStandardProps
@@ -421,13 +441,26 @@ export type PropsHooks<HS extends HooksSources> = {
   SnapshotSelectorHook<HS[N] extends HostObservable<infer T> ? T : never>
 }
 
+/** Selector-hook share synthesized from an entry inject keyed-hooks compartment. */
+export type PropsKeyedHooks<HS extends KeyedHooksSources> = {
+  [N in keyof HS & string as `use${Capitalize<N>}`]: KeyedSnapshotSelectorHook<
+    HS[N] extends (key: string) => HostObservable<infer T> | undefined ? T : never
+  >
+}
+
 /**
  * The component-side view of an inject face: the reserved `hooks`
  * compartment (when declared) arrives as bound `use<Name>` selector hooks;
  * every other member passes through verbatim.
  */
 export type InjectFace<I extends object> =
-  I extends { hooks: infer HS extends HooksSources } ? Omit<I, 'hooks'> & PropsHooks<HS> : I
+  I extends { hooks: infer HS extends HooksSources }
+    ? I extends { keyedHooks: infer KS extends KeyedHooksSources }
+      ? Omit<I, 'hooks' | 'keyedHooks'> & PropsHooks<HS> & PropsKeyedHooks<KS>
+      : Omit<I, 'hooks'> & PropsHooks<HS>
+    : I extends { keyedHooks: infer KS extends KeyedHooksSources }
+      ? Omit<I, 'keyedHooks'> & PropsKeyedHooks<KS>
+      : I
 
 /**
  * The composed component props intersection: runtime share (SlotMap) +