diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index ade617c95e..ecce592e67 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/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 (
diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx index b2533e533c..55818f85cd 100644 --- a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx +++ b/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(null) const previewId = useId() if (items.length < 2) return null @@ -116,3 +115,14 @@ export function TurnNavigator({ items, activeTurn, onNavigate, t }: TurnNavigato
) } + +/** + * 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) 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 ec6af626ee..7de458c73c 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/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) => { + if (key === 'chat.turnNavigation.label') railRenders += 1 + return (translate as (k: string, v?: Record) => string)(key, vars) + }) as ChatViewSlotProps['t'] + render() + 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: [