Merge pull request #71789 from NousResearch/bb/timeline-idle

perf(desktop): stop the thread timeline working when nothing can see it
This commit is contained in:
brooklyn! 2026-07-26 01:58:26 -05:00 committed by GitHub
commit 6ffd7302bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 331 additions and 38 deletions

View file

@ -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)

View file

@ -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

View file

@ -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 = <ThreadTimeline />) => 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<HTMLElement>('[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(<ThreadTimeline />)
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(<ThreadTimeline />)
expect(transcriptReads.mock.calls.length).toBeGreaterThan(derivations)
})
})

View file

@ -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() ? <ActiveThreadTimeline /> : 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<TimelineEntry[]>([])
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 }) => (
<div
className={cn(
POPOVER_SHELL,
open ? 'pointer-events-auto opacity-100 translate-x-0' : 'pointer-events-none translate-x-1 opacity-0'
)}
data-slot="thread-timeline-popover"
>
{entries.map((entry, index) => (
<button
aria-label={entry.preview}
className={cn(ROW_CLASS, index === activeIndex && 'bg-(--ui-row-active-background) text-foreground')}
key={entry.id}
onClick={() => onJump(entry.id)}
ref={listRef(rowRefs, index)}
type="button"
{...hoverProps(index, onHover)}
>
<span className="block w-full min-w-0 truncate font-medium leading-snug text-foreground">{entry.preview}</span>
</button>
))}
</div>
)
}> = ({ 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 (
<div
className={cn(
POPOVER_SHELL,
open ? 'pointer-events-auto opacity-100 translate-x-0' : 'pointer-events-none translate-x-1 opacity-0'
)}
data-slot="thread-timeline-popover"
>
{everOpened &&
entries.map((entry, index) => (
<button
aria-label={entry.preview}
className={cn(ROW_CLASS, index === activeIndex && 'bg-(--ui-row-active-background) text-foreground')}
key={entry.id}
onClick={() => onJump(entry.id)}
ref={listRef(rowRefs, index)}
type="button"
{...hoverProps(index, onHover)}
>
<span className="block w-full min-w-0 truncate font-medium leading-snug text-foreground">
{entry.preview}
</span>
</button>
))}
</div>
)
}
const TimelineTicks: FC<{
activeIndex: number