Ver Fonte

Merge pull request #3194 from deepseek-harness/perf/turn-navigator-memo

fix(client): stop rebuilding the turn rail on every chat render
Yichen Jiang há 3 semanas atrás
pai
commit
72f1e19184

+ 3 - 2
packages/client/ui-chat/src/client/chat/ChatView.tsx

@@ -498,7 +498,8 @@ export function ChatView({
     loadOlder()
   }
 
-  const navigateToTurn = (item: TurnNavigationItem): void => {
+  // Identity feeds the memoized rail; a fresh closure per render would defeat it.
+  const navigateToTurn = useCallback((item: TurnNavigationItem): void => {
     const local = listRef.current
     if (local === null) return
     const row = anchorElement(local, item.anchorKey)
@@ -519,7 +520,7 @@ export function ChatView({
     const position = isAtBottom ? null : scrollPosition(local, el)
     if (isAtBottom) chatScroll.save(null)
     else if (position !== null) chatScroll.save(position)
-  }
+  }, [loadingOlder, chatScroll])
 
   return (
     <div className={css.root}>

+ 13 - 3
packages/client/ui-chat/src/client/chat/TurnNavigator.tsx

@@ -1,5 +1,5 @@
 import {
-  useId, useState, type CSSProperties, type MouseEvent, type PointerEvent,
+  memo, useId, useState, type CSSProperties, type MouseEvent, type PointerEvent,
 } from 'react'
 import type { ChatViewSlotProps } from '../contract/slots.ts'
 import type { TurnNavigationItem } from '../contract/snapshot.ts'
@@ -53,8 +53,7 @@ function itemAtPointer(
   return items[Math.round(ratio * (items.length - 1))]
 }
 
-/** Compact rail of the currently loaded Turns with hover and focus previews. */
-export function TurnNavigator({ items, activeTurn, onNavigate, t }: TurnNavigatorProps) {
+function TurnNavigatorRail({ items, activeTurn, onNavigate, t }: TurnNavigatorProps) {
   const [previewTurn, setPreviewTurn] = useState<number | null>(null)
   const previewId = useId()
   if (items.length < 2) return null
@@ -116,3 +115,14 @@ export function TurnNavigator({ items, activeTurn, onNavigate, t }: TurnNavigato
     </div>
   )
 }
+
+/**
+ * Compact rail of the currently loaded Turns with hover and focus previews.
+ *
+ * Memoized because it renders two host elements per loaded Turn while the
+ * enclosing view re-renders on every streaming delta: without the guard a long
+ * session rebuilds hundreds of marks per commit for a rail that only changes
+ * when a Turn is added, removed, or becomes active. Its props must therefore
+ * stay referentially stable across those commits.
+ */
+export const TurnNavigator = memo(TurnNavigatorRail)

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

@@ -412,6 +412,34 @@ describe('Chat node rendering', () => {
 })
 
 describe('ChatView', () => {
+  it('leaves the turn rail unrendered when an unrelated Chat update commits', () => {
+    const snapshot = chatSnapshotFixture({
+      nodes: [
+        userInTurn(1, 'first prompt', 1),
+        assistant(2, 'first response', 1),
+        userInTurn(4, 'second prompt', 2),
+        assistant(5, 'second response', 2),
+      ],
+      turnEnds: new Map([[1, 3], [2, 6]]),
+    })
+    const h = makeHarness({}, {}, snapshot)
+    // The rail asks for its own accessible name once per render, so counting
+    // that key counts renders without reaching into the component.
+    let railRenders = 0
+    const translate = h.props.t
+    const counting = ((key: string, vars?: Record<string, unknown>) => {
+      if (key === 'chat.turnNavigation.label') railRenders += 1
+      return (translate as (k: string, v?: Record<string, unknown>) => string)(key, vars)
+    }) as ChatViewSlotProps['t']
+    render(<h.ChatView {...h.props} t={counting} />)
+    const afterMount = railRenders
+    expect(afterMount).toBeGreaterThan(0)
+
+    act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) })
+
+    expect(railRenders).toBe(afterMount)
+  })
+
   it('projects loaded turns into prompt and response navigation previews', () => {
     const snapshot = chatSnapshotFixture({
       nodes: [