diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index f36ba12728..0c7d87f344 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/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): 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() diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 0476b71132..4f51486398 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/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 diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 28f789afad..bf58d4c0c7 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/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 => ( +/** Per-key selector hook over one Chat Node. */ +export type UseChatNode = KeyedSnapshotSelectorHook + +/** Per-key selector hook over one Chat Node's Turn-process presentation. */ +export type UseChatNodeProcess = KeyedSnapshotSelectorHook + /** 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 } + 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 loadOlder: () => void diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index eac7cf3a8b..83d0dc6b9a 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/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( + resolve: (key: string) => ObservableSnapshot, +): KeyedSnapshotSelectorHook { + const hooks = new WeakMap>() + 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 +} + function makeHarness( init: HarnessUpdate = {}, sessionOverrides: Partial = {}, @@ -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>().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('[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() const promptRow = view.container.querySelector('[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('[data-turn-process-member]')] expect(renderedFlowKinds(view.container)).toEqual([ diff --git a/packages/client/ui-renderer/src/client/scoped-slots.tsx b/packages/client/ui-renderer/src/client/scoped-slots.tsx index 05ae2684d7..3a5ecb8373 100644 --- a/packages/client/ui-renderer/src/client/scoped-slots.tsx +++ b/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>)) { + for (const [name, source] of Object.entries( + (sources ?? {}) as Record>, + )) { const hookName = standardHookPropName(name) bound[hookName] = observableHook(source) } + for (const [name, source] of Object.entries( + (keyedSources ?? {}) as Record, + )) { + const hookName = standardHookPropName(name) + bound[hookName] = keyedObservableHook(source) + } return bound } diff --git a/packages/client/ui-renderer/tests/scoped-slots.client.spec.tsx b/packages/client/ui-renderer/tests/scoped-slots.client.spec.tsx index d4360d1914..1052bc0105 100644 --- a/packages/client/ui-renderer/tests/scoped-slots.client.spec.tsx +++ b/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[] = [] + 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) diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 6d63eb64a2..56a8234ce1 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/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

= (props: P) => ReactNode */ export type HooksSources = Record> +/** Registrant keyed-hooks compartment: stable key-to-observable resolvers. */ +export type KeyedHooksSources = Record + +/** Selector Hook over an open family of keyed observable sources. */ +export type KeyedSnapshotSelectorHook = { + /** @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. + */ + ( + 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 = (ScopeOf extends 'session' ? SessionStandardProps @@ -421,13 +441,26 @@ export type PropsHooks = { SnapshotSelectorHook ? T : never> } +/** Selector-hook share synthesized from an entry inject keyed-hooks compartment. */ +export type PropsKeyedHooks = { + [N in keyof HS & string as `use${Capitalize}`]: KeyedSnapshotSelectorHook< + HS[N] extends (key: string) => HostObservable | undefined ? T : never + > +} + /** * The component-side view of an inject face: the reserved `hooks` * compartment (when declared) arrives as bound `use` selector hooks; * every other member passes through verbatim. */ export type InjectFace = - I extends { hooks: infer HS extends HooksSources } ? Omit & PropsHooks : I + I extends { hooks: infer HS extends HooksSources } + ? I extends { keyedHooks: infer KS extends KeyedHooksSources } + ? Omit & PropsHooks & PropsKeyedHooks + : Omit & PropsHooks + : I extends { keyedHooks: infer KS extends KeyedHooksSources } + ? Omit & PropsKeyedHooks + : I /** * The composed component props intersection: runtime share (SlotMap) +