Pārlūkot izejas kodu

perf: ChatNodeSeat use seperated source

imccyu 3 nedēļas atpakaļ
vecāks
revīzija
a731536ecc

+ 5 - 4
packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx

@@ -1,8 +1,8 @@
-import { memo, useCallback, useMemo } from 'react'
+import { memo, useCallback, useMemo, useSyncExternalStore } from 'react'
 import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts'
 import type { ChatNode } from '../contract/chat-nodes.ts'
-import type { ChatNodeStore } from '../contract/snapshot.ts'
+import type { ChatNodeSource, ChatNodeStore } from '../contract/snapshot.ts'
 import {
   decodeTurnProcess, TURN_PROCESS_INDEPENDENT_KINDS, turnProcessGeneration,
   type TurnProcessSpec,
@@ -13,6 +13,7 @@ import css from './ChatView.module.css'
 
 interface ChatNodeSeatProps extends ChatNodeOwnerProps {
   readonly nodeKey: string
+  readonly nodeSource: ChatNodeSource
   readonly historyIncomplete: boolean
   readonly compactTranscript: boolean
   readonly useChat: ChatViewSlotProps['useChat']
@@ -78,11 +79,11 @@ function turnProcessLayout(
 
 /** Subscribe, apply Turn-process visibility, and dispatch one stable Context key. */
 export const ChatNodeSeat = memo(function ChatNodeSeat({
-  nodeKey, historyIncomplete, compactTranscript,
+  nodeKey, nodeSource, historyIncomplete, compactTranscript,
   selectedCallId, cwd, openFile, inspectCall, forkAt,
   renderMessageImages, fileMentions, useChat, useStore, actions, renderSlot, t,
 }: ChatNodeSeatProps) {
-  const node = useChat(snapshot => snapshot.nodes.get(nodeKey))
+  const node = useSyncExternalStore(nodeSource.subscribe, nodeSource.getSnapshot)
   const processSignature = useChat((snapshot) => {
     const current = snapshot.nodes.get(nodeKey)
     const location = current?.location

+ 1 - 0
packages/client/ui-chat/src/client/chat/ChatView.tsx

@@ -746,6 +746,7 @@ export function ChatView({
             <ChatNodeSeat
               key={nodeKey}
               nodeKey={nodeKey}
+              nodeSource={nodeStore.source(nodeKey)}
               historyIncomplete={hasMore}
               compactTranscript={compactTranscript}
               useChat={useChat}

+ 16 - 0
packages/client/ui-chat/src/client/contract/snapshot.ts

@@ -10,10 +10,21 @@ export type {
   ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode,
   UserMessageNode,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
+
+/** Per-key observable used by one mounted Chat Node Seat. */
+export interface ChatNodeSource {
+  /** @returns the current Node for this source's stable key. */
+  getSnapshot(): ChatConversationViewNode | undefined
+  /** @param listener - callback for changes to this key. @returns the unsubscribe function. */
+  subscribe(listener: () => void): () => void
+}
+
 /** Stable live per-key reader for Chat nodes. */
 export interface ChatNodeStore {
   /** @param key - stable Conversation Context key. @returns current Node, when visible or hidden. */
   get(key: string): ChatConversationViewNode | undefined
+  /** @param key - stable Conversation Context key. @returns its identity-stable observable source. */
+  source(key: string): ChatNodeSource
   /** @returns all currently materialized Nodes without imposing render order. */
   values(): readonly ChatConversationViewNode[]
 }
@@ -75,12 +86,17 @@ declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
 
 const EMPTY_LIST: readonly never[] = []
 const EMPTY_TIMELINE: ConversationTimelineSnapshot = { turnOrder: EMPTY_LIST, turns: new Map() }
+const EMPTY_NODE_SOURCE: ChatNodeSource = {
+  getSnapshot: () => undefined,
+  subscribe: () => () => {},
+}
 
 /** Empty Chat target used before a view builder is registered. */
 export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = {
   order: EMPTY_LIST,
   nodes: {
     get: () => undefined,
+    source: () => EMPTY_NODE_SOURCE,
     values: () => EMPTY_LIST,
   },
   locations: {

+ 53 - 4
packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts

@@ -1,4 +1,5 @@
 import type { Context } from '@deepseek-ai/cordis'
+import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
 import type {
   ConversationLocation, ConversationTimelineSnapshot, ConversationViewBuilder,
   ConversationViewDefinition,
@@ -6,7 +7,7 @@ import type {
 import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts'
 import { isRunningTool } from '../contract/chat-nodes.ts'
 import type {
-  ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex, ConversationNode,
+  ChatLocationNodeIndex, ChatNodeSource, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex, ConversationNode,
   LegacyConversationSlice, PartialAssistant, RunningToolCall, TurnNavigationItem,
 } from '../contract/snapshot.ts'
 import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
@@ -22,8 +23,30 @@ function sameReferences<T>(left: readonly T[], right: readonly T[]): boolean {
   return left.length === right.length && left.every((value, index) => value === right[index])
 }
 
+class MutableChatNodeSource implements ChatNodeSource {
+  private readonly listeners = new Set<() => void>()
+
+  constructor(
+    private readonly store: MutableChatNodeStore,
+    private readonly key: string,
+  ) {}
+
+  readonly getSnapshot = (): ChatConversationViewNode | undefined => this.store.get(this.key)
+
+  readonly subscribe = (listener: () => void): (() => void) => {
+    this.listeners.add(listener)
+    return () => { this.listeners.delete(listener) }
+  }
+
+  publish(): void {
+    notifySubscribers(this.listeners, `[ui-chat] node source ${this.key}`)
+  }
+}
+
 class MutableChatNodeStore implements ChatNodeStore {
   private readonly byKey = new Map<string, ChatConversationViewNode>()
+  private readonly sources = new Map<string, MutableChatNodeSource>()
+  private readonly dirtyKeys = new Set<string>()
   private valuesCache: readonly ChatConversationViewNode[] = EMPTY_LIST
   private valuesDirty = false
 
@@ -31,6 +54,15 @@ class MutableChatNodeStore implements ChatNodeStore {
     return this.byKey.get(key)
   }
 
+  source(key: string): ChatNodeSource {
+    let source = this.sources.get(key)
+    if (source === undefined) {
+      source = new MutableChatNodeSource(this, key)
+      this.sources.set(key, source)
+    }
+    return source
+  }
+
   values(): readonly ChatConversationViewNode[] {
     if (this.valuesDirty) {
       this.valuesCache = [...this.byKey.values()]
@@ -40,8 +72,14 @@ class MutableChatNodeStore implements ChatNodeStore {
   }
 
   replace(nodes: readonly ChatConversationViewNode[]): void {
+    const previous = new Map(this.byKey)
     this.byKey.clear()
-    for (const node of nodes) this.byKey.set(node.key, node)
+    for (const node of nodes) {
+      this.byKey.set(node.key, node)
+      if (previous.get(node.key) !== node) this.dirtyKeys.add(node.key)
+      previous.delete(node.key)
+    }
+    for (const key of previous.keys()) this.dirtyKeys.add(key)
     this.valuesCache = [...this.byKey.values()]
     this.valuesDirty = false
   }
@@ -51,10 +89,17 @@ class MutableChatNodeStore implements ChatNodeStore {
     for (const node of nodes) {
       if (this.byKey.get(node.key) === node) continue
       this.byKey.set(node.key, node)
+      this.dirtyKeys.add(node.key)
       changed = true
     }
     if (changed) this.valuesDirty = true
   }
+
+  publish(): void {
+    const dirty = [...this.dirtyKeys]
+    this.dirtyKeys.clear()
+    for (const key of dirty) this.sources.get(key)?.publish()
+  }
 }
 
 class MutableChatLocationIndex implements ChatLocationNodeIndex {
@@ -652,7 +697,9 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
     this.locations.rebuild(this.order, this.store)
     this.navigation.rebuild(input.timeline, this.locations, this.store)
     this.timeline = input.timeline
-    return this.snapshot(input.timeline, this.legacy.replace(nodes, input.timeline))
+    const snapshot = this.snapshot(input.timeline, this.legacy.replace(nodes, input.timeline))
+    this.store.publish()
+    return snapshot
   }
 
   apply(input: {
@@ -685,7 +732,9 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
       this.navigation.touch(turnsOf(contentOnly), this.locations, this.store)
     }
     this.timeline = input.timeline
-    return this.snapshot(input.timeline, this.legacy.apply(upserts, input.timeline))
+    const snapshot = this.snapshot(input.timeline, this.legacy.apply(upserts, input.timeline))
+    this.store.publish()
+    return snapshot
   }
 
   private snapshot(

+ 1 - 1
packages/client/ui-chat/src/client/index.ts

@@ -15,7 +15,7 @@ export type {} from './conversation-nodes/turn-tail.ts'
 
 export type {
   AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
-  AssistantTiming, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex,
+  AssistantTiming, ChatLocationNodeIndex, ChatNodeSource, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex,
   CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode,
   LegacyConversationSlice, ModelRetryNode, PartialAssistant, RunningToolCall,
   SteeringMessageNode, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode,

+ 6 - 1
packages/client/ui-chat/tests/chat-apply.client.spec.tsx

@@ -162,6 +162,7 @@ describe('Chat apply wiring', () => {
     )
     const data = { get: (key: string) => key === 'metric' ? 42 : undefined }
     const turn = { data }
+    const emptySource = { getSnapshot: () => undefined, subscribe: () => () => {} }
 
     snapshot = chatSnapshot({
       nodes: { get: () => ({ location: { kind: 'turn', turn } }), values: () => [] } as never,
@@ -176,7 +177,11 @@ describe('Chat apply wiring', () => {
     })
     expect(useTurnData('metric')).toBeUndefined()
     snapshot = chatSnapshot({
-      nodes: { get: () => undefined, values: () => [] },
+      nodes: {
+        get: () => undefined,
+        source: () => emptySource,
+        values: () => [],
+      },
     })
     expect(useTurnData('metric')).toBeUndefined()
 

+ 51 - 0
packages/client/ui-chat/tests/chat-node-source.client.spec.ts

@@ -0,0 +1,51 @@
+import { describe, expect, it, vi } from 'vitest'
+import type {
+  ChatConversationViewNode, ChatNodeSource,
+} from '@deepseek-ai/dsh-client-ui-chat/client'
+import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
+
+const timeline: ConversationTimelineSnapshot = { turnOrder: [], turns: new Map() }
+
+function userNode(index: number, text = `message ${String(index)}`): ChatConversationViewNode {
+  return {
+    key: `user:${String(index)}`,
+    id: String(index),
+    target: 'chat',
+    kind: 'user',
+    anchorSeq: index,
+    location: { kind: 'session' },
+    visibility: 'visible',
+    data: {
+      kind: 'user',
+      messageId: `message-${String(index)}`,
+      seq: index,
+      time: index,
+      content: [{ type: 'text', text }],
+      source: null,
+    },
+  } as ChatConversationViewNode
+}
+
+describe('Chat Node keyed sources', () => {
+  it('notifies only the updated key among 4,000 mounted sources', () => {
+    const builder = new ChatSnapshotBuilder()
+    const nodes = Array.from({ length: 4_000 }, (_, index) => userNode(index + 1))
+    const initial = builder.replace({ nodes, timeline })
+    const listeners = nodes.map(() => vi.fn())
+    const sources: ChatNodeSource[] = nodes.map((node, index) => {
+      const source = initial.nodes.source(node.key)
+      source.subscribe(listeners[index]!)
+      return source
+    })
+
+    const target = 2_347
+    const changed = userNode(target + 1, 'streamed update')
+    const next = builder.apply({ upserts: [changed], timeline })
+
+    expect(listeners[target]).toHaveBeenCalledOnce()
+    expect(listeners.reduce((count, listener) => count + listener.mock.calls.length, 0)).toBe(1)
+    expect(next.nodes.source(changed.key)).toBe(sources[target])
+    expect(next.nodes.get(changed.key)).toBe(changed)
+  })
+})

+ 52 - 9
packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts

@@ -1,6 +1,6 @@
 import type {
   AssistantChatData, AssistantMessageNode, ChatConversationViewNode, ChatSnapshot, ConversationNode,
-  ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, FinalAssistantChatData,
+  ChatLocationNodeIndex, ChatNodeSource, ChatNodeStore, CompactionSummaryNode, FinalAssistantChatData,
   LegacyConversationSlice, PartialAssistant, RunningToolCall, ToolCallBlock, TurnNavigationItem,
 } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type {
@@ -63,35 +63,77 @@ function toolCallName(call: ToolCallBlock): string | null {
   return 'name' in call ? call.name : call.call?.name ?? null
 }
 
+class FixtureNodeSource implements ChatNodeSource {
+  private readonly listeners = new Set<() => void>()
+
+  constructor(
+    private readonly store: FixtureNodeStore,
+    private readonly key: string,
+  ) {}
+
+  readonly getSnapshot = (): ChatConversationViewNode | undefined => this.store.get(this.key)
+
+  readonly subscribe = (listener: () => void): (() => void) => {
+    this.listeners.add(listener)
+    return () => { this.listeners.delete(listener) }
+  }
+
+  publish(): void {
+    for (const listener of [...this.listeners]) listener()
+  }
+}
+
 class FixtureNodeStore implements ChatNodeStore {
   private byKey = new Map<string, ChatConversationViewNode>()
+  private readonly sources = new Map<string, FixtureNodeSource>()
+  private readonly dirtyKeys = new Set<string>()
   private list: readonly ChatConversationViewNode[] = EMPTY
 
   get(key: string): ChatConversationViewNode | undefined {
     return this.byKey.get(key)
   }
 
+  source(key: string): ChatNodeSource {
+    let source = this.sources.get(key)
+    if (source === undefined) {
+      source = new FixtureNodeSource(this, key)
+      this.sources.set(key, source)
+    }
+    return source
+  }
+
   values(): readonly ChatConversationViewNode[] {
     return this.list
   }
 
   replace(candidates: readonly ChatConversationViewNode[]): void {
+    const previous = this.byKey
     const next = new Map<string, ChatConversationViewNode>()
     const list = candidates.map((candidate) => {
-      const previous = this.byKey.get(candidate.key)
-      const node = previous !== undefined
-        && previous.kind === candidate.kind
-        && previous.anchorSeq === candidate.anchorSeq
-        && sameFixtureLocation(previous.location, candidate.location)
-        && previous.visibility === candidate.visibility
-        && nodeSource(previous) === nodeSource(candidate)
-        ? previous
+      const existing = previous.get(candidate.key)
+      const node = existing !== undefined
+        && existing.kind === candidate.kind
+        && existing.anchorSeq === candidate.anchorSeq
+        && sameFixtureLocation(existing.location, candidate.location)
+        && existing.visibility === candidate.visibility
+        && nodeSource(existing) === nodeSource(candidate)
+        ? existing
         : candidate
       next.set(node.key, node)
       return node
     })
     this.byKey = next
     this.list = sameValues(this.list, list) ? this.list : list
+    const keys = new Set([...previous.keys(), ...next.keys()])
+    for (const key of keys) {
+      if (previous.get(key) !== next.get(key)) this.dirtyKeys.add(key)
+    }
+  }
+
+  publish(): void {
+    const dirty = [...this.dirtyKeys]
+    this.dirtyKeys.clear()
+    for (const key of dirty) this.sources.get(key)?.publish()
   }
 }
 
@@ -420,6 +462,7 @@ export function chatSnapshotFixture(input: {
     && derived.every((item, index) => sameTurnNavigationItem(kept[index], item))
     ? kept
     : derived
+  store.publish()
   return {
     order,
     nodes: store,

+ 1 - 0
packages/client/ui-tool/tests/tool-details-render.client.tsx

@@ -50,6 +50,7 @@ export function toolChatSnapshot(
     order: nodes.map(node => node.key),
     nodes: {
       get: key => byKey.get(key),
+      source: key => ({ getSnapshot: () => byKey.get(key), subscribe: () => () => {} }),
       values: () => nodes,
     },
     locations: {

+ 2 - 1
packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx

@@ -64,9 +64,10 @@ const conversationState: ConversationState = {
   activeTargets: new Set(),
 }
 const emptyKeys: readonly string[] = []
+const emptyNodeSource = { getSnapshot: () => undefined, subscribe: () => () => {} }
 const chatState: ChatState = {
   order: emptyKeys,
-  nodes: { get: () => undefined, values: () => [] },
+  nodes: { get: () => undefined, source: () => emptyNodeSource, values: () => [] },
   locations: { getTurn: () => emptyKeys, getStep: () => emptyKeys },
   navigation: { items: () => [] },
   timeline: { turnOrder: [], turns: new Map() },

+ 2 - 1
packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx

@@ -63,9 +63,10 @@ const conversationState: ConversationState = {
   activeTargets: new Set(),
 }
 const emptyKeys: readonly string[] = []
+const emptyNodeSource = { getSnapshot: () => undefined, subscribe: () => () => {} }
 const chatState: ChatState = {
   order: emptyKeys,
-  nodes: { get: () => undefined, values: () => [] },
+  nodes: { get: () => undefined, source: () => emptyNodeSource, values: () => [] },
   locations: { getTurn: () => emptyKeys, getStep: () => emptyKeys },
   navigation: { items: () => [] },
   timeline: { turnOrder: [], turns: new Map() },