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 ( +
+ {everOpened && + entries.map((entry, index) => ( + + ))} +
+ ) +} const TimelineTicks: FC<{ activeIndex: number