diff --git a/apps/desktop/src/components/assistant-ui/thread/timeline-data.test.ts b/apps/desktop/src/components/assistant-ui/thread/timeline-data.test.ts
index 0c2918aea59a..b24f5391a879 100644
--- a/apps/desktop/src/components/assistant-ui/thread/timeline-data.test.ts
+++ b/apps/desktop/src/components/assistant-ui/thread/timeline-data.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
-import { activeTimelineIndex, deriveTimelineEntries, timelinePreview } from './timeline-data'
+import { activeTimelineIndex, deriveTimelineEntries, sameTimelineEntries, timelinePreview } from './timeline-data'
describe('timelinePreview', () => {
it('collapses whitespace to a single line', () => {
@@ -39,6 +39,33 @@ describe('deriveTimelineEntries', () => {
})
})
+describe('sameTimelineEntries', () => {
+ const rail = [
+ { id: 'u1', preview: 'first' },
+ { id: 'u2', preview: 'second' }
+ ]
+
+ it('treats an identical derivation as unchanged, so the memo can reuse it', () => {
+ expect(sameTimelineEntries(rail, [...rail.map(e => ({ ...e }))])).toBe(true)
+ })
+
+ it('detects a changed preview, id, or length', () => {
+ expect(sameTimelineEntries(rail, [rail[0], { id: 'u2', preview: 'edited' }])).toBe(false)
+ expect(sameTimelineEntries(rail, [rail[0], { id: 'u9', preview: 'second' }])).toBe(false)
+ expect(sameTimelineEntries(rail, [rail[0]])).toBe(false)
+ })
+
+ it('is stable when a filtered-out prompt joins the transcript', () => {
+ const withNoise = deriveTimelineEntries([
+ { id: 'u1', role: 'user', text: 'first' },
+ { id: 'u2', role: 'user', text: 'second' },
+ { id: 'u3', role: 'user', text: '[IMPORTANT: Background process 7 finished]' }
+ ])
+
+ expect(sameTimelineEntries(rail, withNoise)).toBe(true)
+ })
+})
+
describe('activeTimelineIndex', () => {
it('returns the last prompt scrolled to or above the top edge', () => {
expect(activeTimelineIndex([-400, -10, 320])).toBe(1)
diff --git a/apps/desktop/src/components/assistant-ui/thread/timeline-data.ts b/apps/desktop/src/components/assistant-ui/thread/timeline-data.ts
index e52d1d7c780a..35dbb8f14cd3 100644
--- a/apps/desktop/src/components/assistant-ui/thread/timeline-data.ts
+++ b/apps/desktop/src/components/assistant-ui/thread/timeline-data.ts
@@ -46,6 +46,20 @@ export function deriveTimelineEntries(messages: readonly TimelineSourceMessage[]
return entries
}
+/** Do two derivations describe the same rail? Lets a rebuild hand back the
+ * PREVIOUS array so an unchanged transcript costs zero re-renders. */
+export function sameTimelineEntries(a: readonly TimelineEntry[], b: readonly TimelineEntry[]): boolean {
+ if (a === b) {
+ return true
+ }
+
+ if (a.length !== b.length) {
+ return false
+ }
+
+ return a.every((entry, index) => entry.id === b[index].id && entry.preview === b[index].preview)
+}
+
/** Last user prompt at/above the viewport top (with slack); else first rendered. */
export function activeTimelineIndex(offsets: readonly (number | null)[], slack: number = 8): number {
let active = -1
diff --git a/apps/desktop/src/components/assistant-ui/thread/timeline-idle.test.tsx b/apps/desktop/src/components/assistant-ui/thread/timeline-idle.test.tsx
new file mode 100644
index 000000000000..58ed16529ad3
--- /dev/null
+++ b/apps/desktop/src/components/assistant-ui/thread/timeline-idle.test.tsx
@@ -0,0 +1,166 @@
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import type { ReactNode } from 'react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * The timeline must do NO work it can't currently show. Two gates are proven
+ * here by rendering the real component and counting the work it performs:
+ *
+ * - a background (kept-alive but hidden) tab derives nothing and subscribes
+ * to nothing — the transcript selector is never even called;
+ * - an unhovered rail builds its ticks but not the popover's rows.
+ *
+ * The prompt-id selector is also asserted to be content-blind, which is what
+ * keeps a streaming assistant reply from re-deriving previews per token.
+ */
+
+interface FakeMessage {
+ content: unknown
+ id: string
+ role: string
+}
+
+const selectorCalls = vi.fn()
+const transcriptReads = vi.fn()
+let messages: FakeMessage[] = []
+
+vi.mock('@assistant-ui/react', () => ({
+ useAui: () => ({
+ thread: () => ({
+ getState: () => {
+ transcriptReads()
+
+ return { messages }
+ }
+ })
+ }),
+ useAuiState: (selector: (state: { thread: { messages: FakeMessage[] } }) => unknown) => {
+ selectorCalls()
+
+ return selector({ thread: { messages } })
+ }
+}))
+
+let paneActive = true
+
+vi.mock('@/components/pane-shell/pane-visibility', () => ({
+ usePaneVisible: () => paneActive
+}))
+
+vi.mock('@/lib/haptics', () => ({ triggerHaptic: () => {} }))
+
+const { ThreadTimeline } = await import('./timeline')
+
+const userTurn = (id: string, text: string): FakeMessage => ({
+ content: [{ text, type: 'text' }],
+ id,
+ role: 'user'
+})
+
+const transcript = (count: number): FakeMessage[] =>
+ Array.from({ length: count }, (_, i) => userTurn(`u${i}`, `prompt ${i}`))
+
+const renderTimeline = (ui: ReactNode = ) => render(ui)
+
+afterEach(() => {
+ cleanup()
+ selectorCalls.mockClear()
+ transcriptReads.mockClear()
+ paneActive = true
+ messages = []
+})
+
+describe('ThreadTimeline in a background tab', () => {
+ it('renders nothing and never reads the transcript', () => {
+ paneActive = false
+ messages = transcript(6)
+
+ const { container } = renderTimeline()
+
+ expect(container.querySelector('[data-slot="thread-timeline"]')).toBeNull()
+ expect(selectorCalls).not.toHaveBeenCalled()
+ expect(transcriptReads).not.toHaveBeenCalled()
+ })
+
+ it('renders the rail once its pane becomes the visible tab', () => {
+ messages = transcript(6)
+
+ const { container } = renderTimeline()
+
+ expect(container.querySelector('[data-slot="thread-timeline"]')).not.toBeNull()
+ expect(selectorCalls).toHaveBeenCalled()
+ })
+})
+
+describe('ThreadTimeline popover', () => {
+ it('builds no rows until the rail is hovered', () => {
+ messages = transcript(6)
+
+ const { container } = renderTimeline()
+ const popover = container.querySelector('[data-slot="thread-timeline-popover"]')
+
+ // The shell renders (it owns the fade transition); its rows do not.
+ expect(popover).not.toBeNull()
+ expect(popover?.querySelectorAll('button')).toHaveLength(0)
+ expect(screen.queryByText('prompt 0')).toBeNull()
+ })
+
+ it('builds the rows on hover and keeps them for the close fade', () => {
+ messages = transcript(6)
+
+ const { container } = renderTimeline()
+ const rail = container.querySelector('[data-slot="thread-timeline"]')!
+
+ fireEvent.mouseEnter(rail)
+
+ const popover = container.querySelector('[data-slot="thread-timeline-popover"]')
+ expect(popover?.querySelectorAll('button')).toHaveLength(6)
+
+ fireEvent.mouseLeave(rail)
+
+ // Still mounted — the popover fades out, it does not pop out of existence.
+ expect(popover?.querySelectorAll('button')).toHaveLength(6)
+ })
+})
+
+describe('ThreadTimeline below the threshold', () => {
+ it('renders nothing for a short thread', () => {
+ messages = transcript(2)
+
+ const { container } = renderTimeline()
+
+ expect(container.querySelector('[data-slot="thread-timeline"]')).toBeNull()
+ })
+})
+
+describe('ThreadTimeline while a reply streams', () => {
+ it('does not re-derive the rail as assistant content grows', () => {
+ messages = [...transcript(6), { content: [{ text: 'th', type: 'text' }], id: 'a1', role: 'assistant' }]
+
+ const { rerender } = renderTimeline()
+ const derivations = transcriptReads.mock.calls.length
+
+ // A token lands: the assistant message's content changes, the user prompt
+ // ids do not — so the memo's change signal is untouched and the previews
+ // are never rebuilt.
+ messages = [
+ ...messages.slice(0, -1),
+ { content: [{ text: 'thinking…', type: 'text' }], id: 'a1', role: 'assistant' }
+ ]
+ rerender()
+
+ expect(transcriptReads.mock.calls.length).toBe(derivations)
+ })
+
+ it('re-derives once a new prompt is sent', () => {
+ messages = transcript(6)
+
+ const { rerender } = renderTimeline()
+ const derivations = transcriptReads.mock.calls.length
+
+ messages = [...messages, userTurn('u6', 'prompt 6')]
+ rerender()
+
+ expect(transcriptReads.mock.calls.length).toBeGreaterThan(derivations)
+ })
+})
diff --git a/apps/desktop/src/components/assistant-ui/thread/timeline.tsx b/apps/desktop/src/components/assistant-ui/thread/timeline.tsx
index df2a4c7332c6..9dc2c94b3ce1 100644
--- a/apps/desktop/src/components/assistant-ui/thread/timeline.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/timeline.tsx
@@ -1,12 +1,14 @@
-import { useAuiState } from '@assistant-ui/react'
+import { useAui, useAuiState } from '@assistant-ui/react'
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import {
activeTimelineIndex,
deriveTimelineEntries,
+ sameTimelineEntries,
type TimelineEntry,
type TimelineSourceMessage
} from './timeline-data'
@@ -122,26 +124,89 @@ function scrollToPrompt(root: HTMLElement | null, id: string) {
jumpScroll(viewport, Math.max(0, top))
}
-/** Right-edge prompt rail — hover previews, click to jump. ≥4 user turns only. */
+/**
+ * Right-edge prompt rail — hover previews, click to jump. ≥4 user turns only.
+ *
+ * Everything here is DEFERRED until it can actually be seen. A chat surface
+ * stays mounted while its tab is in the background (keep-alive, see
+ * pane-visibility.ts), and a background thread keeps streaming, so a naive
+ * timeline would re-derive previews and re-measure prompt offsets all day for
+ * a rail nobody is looking at. Four gates, cheapest first:
+ *
+ * 1. INACTIVE PANE → render null and subscribe to nothing. The transcript
+ * selector, the scroll listener, and the popover markup all stand down.
+ * 2. ACTIVE BUT UNHOVERED → the ticks paint, but the popover's rows are not
+ * built at all; the previews only exist once the pointer opens it.
+ * 3. BELOW THE THRESHOLD → the rail renders null, so the measure effect never
+ * touches layout for it.
+ * 4. FOLLOWING THE BOTTOM → the active prompt is the last one by definition,
+ * answered from data instead of a rect walk (see compute() below).
+ */
export const ThreadTimeline: FC = () => {
- const sourceSignature = useAuiState(s => {
- const rows: TimelineSourceMessage[] = []
+ // Cheapest possible gate, and it must come first: an inactive tab returns
+ // before any of the work below is even declared.
+ return usePaneVisible() ? : null
+}
+
+/** Derived prompt rail for a VISIBLE surface. Split out so the hook body — and
+ * the transcript subscription it opens — never runs for a background tab. */
+const ActiveThreadTimeline: FC = () => {
+ // Cheap in the selector, expensive only when it changes: the ids alone tell
+ // us whether the RAIL changed. Prompt text is immutable once sent, and an
+ // edit rewinds the transcript (dropping every id after it) and re-appends a
+ // fresh message id — so a preview can never go stale behind a stable id.
+ // Streaming an assistant reply churns that message's content on every token
+ // and leaves this string untouched, which is the whole point.
+ const promptIds = useAuiState(s => {
+ let ids = ''
for (const message of s.thread.messages) {
- if (message.role !== 'user') {
- continue
+ if (message.role === 'user') {
+ ids += `${message.id}\n`
}
-
- rows.push({ id: message.id, role: 'user', text: userPromptText(message.content) })
}
- return JSON.stringify(rows)
+ return ids
})
- const entries = useMemo(
- () => deriveTimelineEntries(JSON.parse(sourceSignature) as TimelineSourceMessage[]),
- [sourceSignature]
- )
+ // `promptIds` is the change signal; the transcript is read imperatively when
+ // it fires, so the selector above never pays for text extraction. The client
+ // goes through a ref so the memo keys on the SIGNAL alone — an accessor whose
+ // identity churned would otherwise re-derive every render, which is exactly
+ // the streaming cost this is here to avoid.
+ const aui = useAui()
+ const auiRef = useRef(aui)
+ auiRef.current = aui
+
+ const previousRef = useRef([])
+
+ const entries = useMemo(() => {
+ const rows: TimelineSourceMessage[] = []
+
+ for (const message of auiRef.current.thread().getState().messages) {
+ if (message.role === 'user') {
+ rows.push({ id: message.id, role: 'user', text: userPromptText(message.content) })
+ }
+ }
+
+ const next = deriveTimelineEntries(rows)
+
+ // Hand back the PREVIOUS array when nothing user-visible moved. Blank and
+ // background-notification prompts are filtered out, so a new id can leave
+ // the rail identical — without this, that re-renders both subtrees and
+ // restarts the measure effect for no visible change.
+ if (sameTimelineEntries(previousRef.current, next)) {
+ return previousRef.current
+ }
+
+ previousRef.current = next
+
+ return next
+ // promptIds is the intentional re-eval TRIGGER, not a value the derivation
+ // reads (the transcript comes off the ref) — same shape as ChatRoutesSurface's
+ // gatewayState memo in app/contrib/controller.tsx.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [promptIds])
const [activeIndex, setActiveIndex] = useState(0)
const [open, setOpen] = useState(false)
@@ -185,9 +250,15 @@ export const ThreadTimeline: FC = () => {
useEffect(() => () => window.clearTimeout(closeTimerRef.current), [])
useEffect(() => {
+ // Below the threshold the rail renders null, so measuring prompt offsets
+ // buys nothing — bail before touching layout at all.
+ if (entries.length < MIN_ENTRIES) {
+ return
+ }
+
const viewport = ownViewport(rootRef.current)
- if (!viewport || entries.length === 0) {
+ if (!viewport) {
return
}
@@ -279,29 +350,44 @@ const TimelinePopover: FC<{
onJump: (id: string) => void
open: boolean
rowRefs: React.RefObject<(HTMLButtonElement | null)[]>
-}> = ({ activeIndex, entries, onHover, onJump, open, rowRefs }) => (
-
- {entries.map((entry, index) => (
-
- ))}
-
-)
+}> = ({ activeIndex, entries, onHover, onJump, open, rowRefs }) => {
+ // The rail is the always-visible part; this list is not built until the
+ // pointer first opens it. The SHELL always renders so the opacity/translate
+ // transition has a node to animate — only the N rows are deferred, and they
+ // stay mounted afterwards so the close fade still has content.
+ const [everOpened, setEverOpened] = useState(open)
+
+ if (open && !everOpened) {
+ setEverOpened(true)
+ }
+
+ return (
+