refactor(client): bind keyed chat sources in renderer

This commit is contained in:
imccyu 2026-09-01 22:20:08 +08:00
parent b8a19413e9
commit 577f0cf7d9
8 changed files with 185 additions and 54 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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