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: [