perf(desktop): virtualize the timeline and bound history jumps

This commit is contained in:
brooklyn!
2026-09-16 04:22:11 -07:00
parent 7e69873ca6
commit 2efbbef981
18 changed files with 1406 additions and 376 deletions

View File

@@ -0,0 +1,170 @@
import { useAssistantRuntime } from '@assistant-ui/react'
import { act, render } from '@testing-library/react'
import { atom } from 'nanostores'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { stubThreadEnvironment } from '@/components/assistant-ui/test-utils'
import { type TranscriptWindowValue, useTranscriptWindow } from '@/components/assistant-ui/thread/transcript-window'
import type { ChatMessage } from '@/lib/chat-messages'
import { $transcriptTailBySessionId } from '@/store/transcript-tail'
import { PRIMARY_SESSION_VIEW, SessionViewProvider } from './session-view'
import { ChatRuntimeBoundary } from '.'
stubThreadEnvironment()
const message = (rowId: number): ChatMessage => ({
id: `live-${rowId}`, rowId, role: 'user', parts: [{ type: 'text', text: `prompt ${rowId}` }]
})
const page = (rowId: number) => ({
session_id: 'stored',
pagination: { has_older: true, has_newer: true, limit: 120, offset: 40, returned: 120, order: 'oldest' },
messages: Array.from({ length: 120 }, (_, index) => ({
id: rowId + index, role: 'user' as const, content: `prompt ${rowId + index}`, timestamp: rowId + index
}))
})
beforeEach(() => {
$transcriptTailBySessionId.set({})
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { api: vi.fn() } })
})
function mount() {
const $messages = atom(Array.from({ length: 120 }, (_, index) => message(10_000 + index)))
const view = {
...PRIMARY_SESSION_VIEW, $messages,
$runtimeId: atom<string | null>('runtime'), $storedId: atom<string | null>('stored')
}
let window!: Required<TranscriptWindowValue>
let runtime!: NonNullable<ReturnType<typeof useAssistantRuntime>>
function Observe() {
window = useTranscriptWindow()
runtime = useAssistantRuntime()!
return null
}
const mutations = { onEdit: vi.fn(), onReload: vi.fn(), onCancel: vi.fn(), onThreadMessagesChange: vi.fn() }
const rendered = render(
<SessionViewProvider value={view}>
<ChatRuntimeBoundary busy={false} suppressMessages={false} {...mutations}>
<Observe />
</ChatRuntimeBoundary>
</SessionViewProvider>
)
return { view, mutations, ...rendered, get window() { return window }, get runtime() { return runtime } }
}
describe('bounded direct history runtime', () => {
it('reads one around page and selects it without replacing the live store', async () => {
const api = vi.spyOn(window.hermesDesktop, 'api').mockResolvedValue(page(40))
const mounted = mount()
const live = mounted.view.$messages.get()
let id: string | null = null
await act(async () => { id = await mounted.window.revealRow(40, new AbortController().signal) })
expect(api).toHaveBeenCalledTimes(1)
const url = new URL(api.mock.calls[0][0].path, 'http://test')
expect(url.pathname).toBe('/api/sessions/stored/messages/around')
expect(url.searchParams.get('row_id')).toBe('40')
expect(url.searchParams.get('limit')).toBe('120')
expect(mounted.view.$messages.get()).toBe(live)
expect(mounted.runtime.thread.getState().messages).toHaveLength(120)
expect(mounted.runtime.thread.getState().messages.some(message => message.id === id)).toBe(true)
expect(mounted.window.currentMessages?.find(message => message.rowId === 40)?.id).toBe(id)
expect(mounted.window.isHistorical).toBe(true)
expect(mounted.window.newerAvailable).toBe(true)
act(() => { mounted.window.returnToLatest() })
expect(mounted.window.isHistorical).toBe(false)
})
it('keeps history static during streaming and restores the newest live tail and capabilities', async () => {
vi.spyOn(window.hermesDesktop, 'api').mockResolvedValue(page(40))
const mounted = mount()
await act(async () => { await mounted.window.revealRow(40, new AbortController().signal) })
const historical = mounted.runtime.thread.getState().messages
const snapshot = mounted.window
expect(mounted.runtime.thread.getState().capabilities.edit).toBe(false)
expect(mounted.runtime.thread.getState().capabilities.reload).toBe(false)
expect(mounted.runtime.thread.getState().capabilities.switchToBranch).toBe(false)
expect(mounted.runtime.thread.getState().isDisabled).toBe(true)
act(() => { mounted.view.$messages.set([...mounted.view.$messages.get(), message(20_000)]) })
expect(mounted.runtime.thread.getState().messages).toBe(historical)
expect(mounted.window).toBe(snapshot)
expect(await mounted.window.expandWindow()).toBe(false)
act(() => { mounted.window.returnToLatest() })
expect(mounted.runtime.thread.getState().messages.at(-1)?.id).toBe('live-20000')
expect(mounted.runtime.thread.getState().capabilities.edit).toBe(true)
expect(mounted.runtime.thread.getState().capabilities.reload).toBe(true)
expect(mounted.runtime.thread.getState().isDisabled).toBe(false)
expect(mounted.window.isHistorical).toBe(false)
for (const callback of Object.values(mounted.mutations)) {expect(callback).not.toHaveBeenCalled()}
})
it('latest request wins even when the bridge ignores cancellation', async () => {
const resolves: ((value: ReturnType<typeof page>) => void)[] = []
vi.spyOn(window.hermesDesktop, 'api').mockImplementation(() => new Promise(resolve => resolves.push(resolve)))
const mounted = mount()
let first!: Promise<string | null>
let second!: Promise<string | null>
act(() => {
first = mounted.window.revealRow(40, new AbortController().signal)
second = mounted.window.revealRow(400, new AbortController().signal)
})
expect(await first).toBeNull()
await act(async () => { resolves[1](page(400)); await second })
const selected = mounted.window.currentMessages
await act(async () => { resolves[0](page(40)); await Promise.resolve() })
expect(mounted.window.currentMessages).toBe(selected)
expect(selected[0].rowId).toBe(400)
})
it.each(['abort', 'latest', 'session', 'unmount'] as const)('discards pending reads on %s', async action => {
let resolve!: (value: ReturnType<typeof page>) => void
vi.spyOn(window.hermesDesktop, 'api').mockImplementation(() => new Promise(done => { resolve = done }))
const mounted = mount()
const signal = new AbortController()
let pending!: Promise<string | null>
act(() => { pending = mounted.window.revealRow(40, signal.signal) })
act(() => {
if (action === 'abort') {signal.abort()}
if (action === 'latest') {mounted.window.returnToLatest()}
if (action === 'session') {
mounted.view.$storedId.set('next-session')
mounted.view.$runtimeId.set('next-runtime')
mounted.view.$messages.set([message(30_000)])
}
if (action === 'unmount') {mounted.unmount()}
})
expect(await pending).toBeNull()
await act(async () => { resolve(page(40)); await Promise.resolve() })
expect(mounted.view.$messages.get().some(message => message.rowId === 40)).toBe(false)
expect(mounted.window.isHistorical).toBe(false)
})
it('rejects oversized, missing-target and failed responses without losing the selected page', async () => {
const api = vi.spyOn(window.hermesDesktop, 'api').mockResolvedValue(page(40))
const mounted = mount()
await act(async () => { await mounted.window.revealRow(40, new AbortController().signal) })
const selected = mounted.window.currentMessages
for (const response of [{ ...page(400), messages: [...page(400).messages, ...page(600).messages] }, page(800), null]) {
if (response) {api.mockResolvedValueOnce(response)}
else {api.mockRejectedValueOnce(new Error('offline'))}
await act(async () => { expect(await mounted.window.revealRow(400, new AbortController().signal)).toBeNull() })
expect(mounted.window.currentMessages).toBe(selected)
}
})
})

View File

@@ -0,0 +1,134 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { capabilityScoped, hermesApi, type ProfileScope } from '@/api/client'
import { type ChatMessage, toChatMessages } from '@/lib/chat-messages'
import type { SessionMessagesResponse } from '@/types/hermes'
export const HISTORY_WINDOW_LIMIT = 120
/** The around route is intentionally isolated from tail/backfill bookkeeping. */
export interface HistoryWindowResponse extends SessionMessagesResponse {
pagination: NonNullable<SessionMessagesResponse['pagination']> & {
has_older: boolean
has_newer: boolean
}
}
interface HistoryPage {
messages: ChatMessage[]
olderAvailable: boolean
newerAvailable: boolean
}
export async function fetchHistoryWindow(
storedId: string,
rowId: number,
scope: ProfileScope,
signal: AbortSignal
): Promise<HistoryPage> {
signal.throwIfAborted()
const route = capabilityScoped(scope)
const query = new URLSearchParams({ row_id: String(rowId), limit: String(HISTORY_WINDOW_LIMIT) })
if (route.profile) {query.set('profile', route.profile)}
// The Electron REST bridge cannot transfer AbortSignal over IPC. Cancellation
// below releases the caller immediately and fences the eventual bounded read;
// it does not pretend to cancel backend I/O or fall back to a full transcript.
const response = await hermesApi<HistoryWindowResponse>({
...route,
...(typeof scope === 'object' && scope?.connectionId === 'local' ? { connectionId: 'local' } : {}),
method: 'GET',
path: `/api/sessions/${encodeURIComponent(storedId)}/messages/around?${query}`
})
signal.throwIfAborted()
if (!Array.isArray(response.messages) || response.messages.length > HISTORY_WINDOW_LIMIT) {
throw new Error('History response exceeds the bounded page size.')
}
return {
messages: toChatMessages(response.messages),
olderAvailable: response.pagination.has_older === true,
newerAvailable: response.pagination.has_newer === true
}
}
interface HistoryWindowOptions {
/** Include runtime, durable id, owner connection/profile, and suppression. */
scopeKey: string
storedId: string | null
scope: ProfileScope
isCurrent: () => boolean
}
/** A single replaceable display page, never merged into the live message store. */
export function useHistoryWindow({ scopeKey, storedId, scope, isCurrent }: HistoryWindowOptions) {
const lifetime = useMemo(() => ({ scopeKey }), [scopeKey])
const latest = useRef({ lifetime, storedId, scope, isCurrent })
latest.current = { lifetime, storedId, scope, isCurrent }
const pending = useRef<AbortController | null>(null)
const [selection, setSelection] = useState<{ lifetime: object; page: HistoryPage } | null>(null)
const page = selection?.lifetime === lifetime ? selection.page : null
const cancel = useCallback(() => {
pending.current?.abort()
pending.current = null
}, [])
useEffect(() => cancel, [cancel, lifetime])
const returnToLatest = useCallback(() => {
cancel()
setSelection(null)
}, [cancel])
const revealRow = useCallback(async (rowId: number, signal: AbortSignal): Promise<string | null> => {
cancel()
if (signal.aborted || !Number.isSafeInteger(rowId) || rowId <= 0) {return null}
const captured = latest.current
if (!captured.storedId || !captured.isCurrent()) {return null}
const controller = new AbortController()
pending.current = controller
const abort = () => controller.abort()
signal.addEventListener('abort', abort, { once: true })
let release!: () => void
const aborted = new Promise<null>(resolve => {
release = () => resolve(null)
controller.signal.addEventListener('abort', release, { once: true })
})
try {
const next = await Promise.race([
fetchHistoryWindow(captured.storedId, rowId, captured.scope, controller.signal),
aborted
])
if (!next || controller.signal.aborted || latest.current.lifetime !== captured.lifetime || !captured.isCurrent()) {
return null
}
const target = next.messages.find(message => message.rowId === rowId)
if (!target) {return null}
setSelection({ lifetime: captured.lifetime, page: next })
return target.id
} catch {
// Missing/older backend, unreadable row, and failed reads preserve the
// current page. The caller reports failure and can retry explicitly.
return null
} finally {
signal.removeEventListener('abort', abort)
controller.signal.removeEventListener('abort', release)
if (pending.current === controller) {pending.current = null}
}
}, [cancel])
return { page, revealRow, returnToLatest }
}

View File

@@ -63,6 +63,7 @@ import { requestComposerInsert } from './composer/focus'
import { droppedFileInlineRefs } from './composer/inline-refs'
import { ComposerSurfaceProvider, useComposerScope, useComposerSurfaceId } from './composer/scope'
import type { ChatBarState } from './composer/types'
import { useHistoryWindow } from './history-window'
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
import { type DragKind, useFileDropZone } from './hooks/use-file-drop-zone'
import { shouldShowIntro } from './intro-visibility'
@@ -210,15 +211,15 @@ const NO_MESSAGES: ChatMessage[] = []
* dots stay live through the separate status atoms) and catch up in one
* commit on reveal — the subscribe fires immediately with the current value.
*/
function useMessagesWhileVisible($messages: ReadableAtom<ChatMessage[]>): ChatMessage[] {
function useMessagesWhileVisible($messages: ReadableAtom<ChatMessage[]>, enabled = true): ChatMessage[] {
const visible = usePaneVisible()
const [messages, setMessages] = useState(() => $messages.get())
// nanostores types the listener value ReadonlyIfObject; the store publishes
// a fresh array per flush, so the cast is safe and avoids a per-token clone.
useEffect(
() => (visible ? $messages.subscribe(value => setMessages(value as ChatMessage[])) : undefined),
[$messages, visible]
() => (visible && enabled ? $messages.subscribe(value => setMessages(value as ChatMessage[])) : undefined),
[$messages, visible, enabled]
)
return messages
@@ -245,7 +246,32 @@ export function ChatRuntimeBoundary({
}: ChatRuntimeBoundaryProps) {
const view = useSessionView()
const runtimeId = useStore(view.$runtimeId)
const storeMessages = useMessagesWhileVisible(view.$messages)
const storedId = useStore(view.$storedId)
const connection = useStore($connection)
const activeProfile = useStore($activeGatewayProfile)
const connectionId = connection?.connectionId || (connection?.mode === 'local' ? 'local' : '')
const ownerRoute = storedId
? getSessionOwnerHint(storedId, connectionId ? { connectionId, profile: activeProfile } : undefined)
: undefined
const ownerConnection = ownerRoute?.connectionId
const ownerProfile = ownerRoute?.targetProfile || ownerRoute?.profile
const tailProfile = useMemo(() => ownerProfile
? { connectionId: ownerConnection, profile: ownerProfile }
: undefined, [ownerConnection, ownerProfile])
const history = useHistoryWindow({
scopeKey: JSON.stringify([runtimeId, storedId, tailProfile, connectionId, activeProfile, suppressMessages]),
storedId,
scope: tailProfile ?? { connectionId: connectionId || undefined, profile: activeProfile },
isCurrent: () => !suppressMessages && view.$storedId.get() === storedId && view.$runtimeId.get() === runtimeId
})
// History is a static display page. The live store continues streaming but
// no delta subscribes/reconverts this historical runtime until return.
const storeMessages = useMessagesWhileVisible(view.$messages, !history.page)
const messages = suppressMessages ? NO_MESSAGES : storeMessages
const [windowPages, setWindowPages] = useState(1)
@@ -285,29 +311,19 @@ export function ChatRuntimeBoundary({
return next.window
}, [messages, windowPages])
const runtimeMessageRepository = useRuntimeMessageRepository(windowedMessages)
const storedId = useStore(view.$storedId)
const connection = useStore($connection)
const activeProfile = useStore($activeGatewayProfile)
const currentMessages = history.page?.messages ?? windowedMessages
const runtimeMessageRepository = useRuntimeMessageRepository(currentMessages)
// Subscribed (not read imperatively) so the "Show earlier" affordance
// appears/retires as tail hydrations and backfill pages record their state.
const transcriptTailStates = useStore($transcriptTailBySessionId)
const connectionId = connection?.connectionId || (connection?.mode === 'local' ? 'local' : '')
const ownerRoute = storedId
? getSessionOwnerHint(storedId, connectionId ? { connectionId, profile: activeProfile } : undefined)
: undefined
const tailProfile = ownerRoute
? { connectionId: ownerRoute.connectionId, profile: ownerRoute.targetProfile || ownerRoute.profile }
: undefined
const tailState = storedId && transcriptTailStates ? transcriptTailState(storedId, tailProfile) : undefined
const restBackfillAvailable = Boolean(tailState?.possiblyTruncated)
const expandWindow = useCallback(
async (beforePrepend?: () => void) => {
// A historical page is not the live tail: never backfill into its store.
if (history.page) {return false}
// Network latency is not scroll intent. Capture at arrival, immediately
// before the store prepend, and only grow a window that has a page to show.
if (
@@ -352,24 +368,32 @@ export function ChatRuntimeBoundary({
return true
},
[runtimeId, storedId, tailProfile, view]
[runtimeId, storedId, tailProfile, view, history.page]
)
const olderAvailable = windowed || restBackfillAvailable
// Page navigation stays on the timeline while inspecting history; the
// existing prepend action is specifically a live-tail operation.
const olderAvailable = !history.page && (windowed || restBackfillAvailable)
const isHistorical = Boolean(history.page)
const newerAvailable = history.page?.newerAvailable ?? false
const { revealRow, returnToLatest } = history
const transcriptWindow = useMemo(() => ({ olderAvailable, expandWindow }), [expandWindow, olderAvailable])
const transcriptWindow = useMemo(() => ({
olderAvailable, expandWindow, revealRow, returnToLatest, currentMessages, isHistorical, newerAvailable
}), [expandWindow, olderAvailable, revealRow, returnToLatest, currentMessages, isHistorical, newerAvailable])
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
messageRepository: runtimeMessageRepository,
isRunning: busy,
setMessages: onThreadMessagesChange,
isRunning: !isHistorical && busy,
isDisabled: isHistorical,
setMessages: isHistorical ? undefined : onThreadMessagesChange,
onNew: async () => {
// Submission is handled explicitly by ChatBar.
// Keeping this no-op avoids duplicate prompt.submit calls.
},
onEdit,
onCancel: async () => onCancel(),
onReload
onEdit: isHistorical ? undefined : onEdit,
onCancel: isHistorical ? undefined : async () => onCancel(),
onReload: isHistorical ? undefined : onReload
})
return (

View File

@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { useReducedMotion } from 'motion/react'
import { useMemo, useRef } from 'react'
import { useTranscriptWindow } from '@/components/assistant-ui/thread/transcript-window'
import { Codicon } from '@/components/ui/codicon'
import { AnimatedInt } from '@/components/ui/diff-count'
import { useI18n } from '@/i18n'
@@ -38,8 +39,9 @@ export function ScrollToBottomButton({ sessionId }: { sessionId: string | null }
const { t } = useI18n()
const surfaceId = useComposerSurfaceId()
const scrollSessionId = sessionId ?? surfaceId
const { isHistorical } = useTranscriptWindow()
const visible = useStoreSelector($threadJumpButtonVisibleBySession, map =>
const scrollVisible = useStoreSelector($threadJumpButtonVisibleBySession, map =>
Boolean(scrollSessionId && map[scrollSessionId])
)
@@ -47,6 +49,7 @@ export function ScrollToBottomButton({ sessionId }: { sessionId: string | null }
scrollSessionId ? (map[scrollSessionId] ?? 0) : 0
)
const visible = isHistorical || scrollVisible
const reducedMotion = useReducedMotion()
const request = useStore(useMemo(() => sessionApprovalRequest(sessionId), [sessionId]))
// Scrolled away while an approval is pending → the inline Run/Reject bar is

View File

@@ -6,6 +6,7 @@ import { ThreadMessageList } from '@/components/assistant-ui/thread/list'
import { BackgroundResumeNotice, CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
import { SystemMessage } from '@/components/assistant-ui/thread/system-message'
import { ThreadTimeline } from '@/components/assistant-ui/thread/timeline'
import { useTranscriptWindow } from '@/components/assistant-ui/thread/transcript-window'
import { type RestoreMessageTarget } from '@/components/assistant-ui/thread/types'
import { UserEditComposer } from '@/components/assistant-ui/thread/user-edit-composer'
import { UserMessage } from '@/components/assistant-ui/thread/user-message'
@@ -71,6 +72,14 @@ export const Thread = memo(function Thread({
}: ThreadProps) {
const { t } = useI18n()
const copy = t.assistant.thread
const { isHistorical } = useTranscriptWindow()
if (isHistorical) {
onBranchInNewChat = undefined
onCancel = undefined
onDismissError = undefined
onRestoreToMessage = undefined
}
const [restoreConfirmTarget, setRestoreConfirmTarget] = useState<
(RestoreMessageTarget & { messageId: string }) | null

View File

@@ -48,6 +48,7 @@ import { responseMessageRole, ResponseMessages } from './response-group'
import { resolveShowEarlierAction, shouldAutoShowEarlier, useTranscriptWindow } from './transcript-window'
import { useMessagesBelow } from './use-messages-below'
import { useStickyPromptClip } from './use-sticky-prompt-clip'
import { useTimelineReveal } from './use-timeline-reveal'
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
@@ -461,7 +462,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
targetScrollTop: resolveThreadScrollTarget
})
const { olderAvailable, expandWindow } = useTranscriptWindow()
const { olderAvailable, expandWindow, isHistorical, returnToLatest } = useTranscriptWindow()
useEffect(() => {
$mountedTranscriptPanes.set($mountedTranscriptPanes.get() + 1)
@@ -669,8 +670,8 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
const surfaceId = useComposerSurfaceId()
const scrollSessionId = sessionId ?? surfaceId
useEffect(
() => publishThreadAtBottom(isAtBottom, { paneVisible, sessionId: scrollSessionId }),
[isAtBottom, paneVisible, scrollSessionId]
() => publishThreadAtBottom(isAtBottom && !isHistorical, { paneVisible, sessionId: scrollSessionId }),
[isAtBottom, isHistorical, paneVisible, scrollSessionId]
)
useEffect(
() => () => resetPublishedThreadScroll({ paneVisible, sessionId: scrollSessionId }),
@@ -681,13 +682,15 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
useEffect(
() =>
onScrollToBottomRequest(() => {
if (isHistorical) {returnToLatest?.()}
if (jumpRestoreRef.current) {
jumpRestoreRef.current()
} else {
void scrollToBottom()
}
}, scrollSessionId),
[scrollToBottom, scrollSessionId]
[scrollToBottom, scrollSessionId, isHistorical, returnToLatest]
)
// Waking from display: hidden (HUD mode hides the main window; OS hide does
@@ -1174,6 +1177,24 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
}
}, [anchorBeforePrepend, growWindow, hiddenCount, olderAvailable, paneBudget])
useTimelineReveal({
viewport: scrollRef,
groups: weightedGroups,
hiddenCount,
renderBudget,
olderAvailable,
expandWindow,
sessionKey,
revealBudget: budget => setRenderBudget(current => Math.max(current, budget)),
prepare: () => {
cancelRestoreRef.current?.()
applyRestoreRef.current = null
restoreFromBottomRef.current = null
loadSettledRef.current = true
stopScroll()
}
})
// Scroll/wheel at the top edge pages older turns through the same showEarlier
// path as the button. Wheel is required because browsers emit no `scroll`
// once scrollTop is already 0 — exactly where the reader who wants more is.

View File

@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest'
import { activeTimelineIndex, deriveTimelineEntries, sameTimelineEntries, timelinePreview } from './timeline-data'
import {
activeTimelineIndex,
deriveTimelineEntries,
sameTimelineEntries,
timelineBarWidth,
timelinePreview
} from './timeline-data'
describe('timelinePreview', () => {
it('collapses whitespace to a single line', () => {
@@ -14,6 +20,20 @@ describe('timelinePreview', () => {
})
})
describe('timelineBarWidth', () => {
it('keeps active maximal and uses the same curve for hover', () => {
expect(timelineBarWidth(10, 10, 50)).toBe(1)
expect(timelineBarWidth(50, 10, 50)).toBe(1)
expect(timelineBarWidth(11, 10, null)).toBe(timelineBarWidth(51, 10, 50))
})
it('does not change bars outside the local focus neighborhood', () => {
expect(timelineBarWidth(30, 10, 50)).toBe(0.5)
expect(timelineBarWidth(11, 10, null)).toBeGreaterThan(timelineBarWidth(12, 10, null))
expect(timelineBarWidth(11, 0, 10.5)).toBe(timelineBarWidth(10, 0, 10.5))
})
})
describe('deriveTimelineEntries', () => {
it('keeps non-empty user prompts in order', () => {
expect(

View File

@@ -3,17 +3,36 @@ import { PROCESS_NOTIFICATION_RE } from './content'
export interface TimelineSourceMessage {
id: string
rowId?: number
role: string
text: string
}
export interface TimelineEntry {
id: string
rowId?: number
preview: string
}
const PREVIEW_MAX = 120
/** Shared, localized focus curve; the active tick always retains its maximum. */
export function timelineBarWidth(index: number, activeIndex: number, hoverIndex: number | null): number {
const curve = (distance: number) => (Math.abs(distance) > 3 ? 0 : Math.exp(-(distance ** 2) / 1.35))
return 0.5 + 0.5 * Math.max(curve(index - activeIndex), hoverIndex === null ? 0 : curve(index - hoverIndex))
}
export const TIMELINE_REVEAL_EVENT = 'hermes:timeline-reveal'
export const EARLIER_TIMELINE_ID = '__earlier-history__'
export interface TimelineRevealRequest {
id: string
rowId?: number
signal: AbortSignal
complete: (revealedId: string | false) => void
}
export function timelinePreview(text: string, max: number = PREVIEW_MAX): string {
const collapsed = text.replace(/\s+/g, ' ').trim()
@@ -38,7 +57,11 @@ export function deriveTimelineEntries(messages: readonly TimelineSourceMessage[]
continue
}
entries.push({ id: message.id, preview: timelinePreview(text) })
entries.push({
id: message.id,
preview: timelinePreview(text),
...(message.rowId !== undefined ? { rowId: message.rowId } : {})
})
}
return entries

View File

@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { api } = vi.hoisted(() => ({ api: vi.fn() }))
vi.mock('@/api/client', () => ({
capabilityScoped: (scope: object) => scope,
hermesApi: api
}))
beforeEach(() => {
vi.resetModules()
api.mockReset()
})
const page = (id = 1, more = false) => ({
entries: [{ row_id: id, preview: `Prompt ${id}` }],
pagination: { next_cursor: more ? id : null, has_more: more }
})
describe('timeline metadata index', () => {
it('fetches one lightweight page and coalesces concurrent callers', async () => {
api.mockResolvedValue(page(1, true))
const { fetchTimelineIndex } = await import('./timeline-index')
const scope = { connectionId: 'local', profile: 'default' }
const a = fetchTimelineIndex('session', scope)
const b = fetchTimelineIndex('session', scope)
expect(a).toBe(b)
expect((await a).entries).toEqual([{ id: 'history:1', rowId: 1, preview: 'Prompt 1' }])
expect(api).toHaveBeenCalledTimes(1)
expect(api.mock.calls[0][0]).toMatchObject({ connectionId: 'local', profile: 'default', passive: true })
expect(api.mock.calls[0][0].path).toContain('/timeline?limit=500')
expect(api.mock.calls[0][0].path).not.toContain('/messages')
})
it('continues only on demand and deduplicates overlapping metadata', async () => {
api.mockResolvedValueOnce(page(1, true)).mockResolvedValueOnce({
entries: [
{ row_id: 1, preview: 'Prompt 1' },
{ row_id: 2, preview: 'Prompt 2' }
],
pagination: { next_cursor: null, has_more: false }
})
const { fetchTimelineIndex } = await import('./timeline-index')
await fetchTimelineIndex('session', 'default')
expect(api).toHaveBeenCalledTimes(1)
const result = await fetchTimelineIndex('session', 'default')
expect(result.entries.map(e => e.rowId)).toEqual([1, 2])
expect(result.complete).toBe(true)
expect(api.mock.calls[1][0].path).toContain('after_row_id=1')
await fetchTimelineIndex('session', 'default')
expect(api).toHaveBeenCalledTimes(2)
})
it('isolates identical session IDs by owning connection and profile', async () => {
api.mockResolvedValueOnce(page(1)).mockResolvedValueOnce(page(2))
const { fetchTimelineIndex } = await import('./timeline-index')
const a = await fetchTimelineIndex('same', { connectionId: 'local', profile: 'one' })
const b = await fetchTimelineIndex('same', { connectionId: 'remote', profile: 'two' })
expect(a.entries[0].rowId).toBe(1)
expect(b.entries[0].rowId).toBe(2)
})
it('clears failed requests so an explicit retry works without caching a false empty result', async () => {
api.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce(page())
const { fetchTimelineIndex } = await import('./timeline-index')
await expect(fetchTimelineIndex('session', 'default')).rejects.toThrow('offline')
expect((await fetchTimelineIndex('session', 'default')).complete).toBe(true)
})
})

View File

@@ -0,0 +1,89 @@
import { capabilityScoped, hermesApi, type ProfileScope } from '@/api/client'
import type { TimelineEntry } from './timeline-data'
interface TimelinePage {
entries: Array<{ row_id: number; preview: string; timestamp?: number }>
pagination: { next_cursor: number | null; has_more: boolean }
}
export interface TimelineIndex {
entries: TimelineEntry[]
complete: boolean
cursor?: number
expires: number
}
const cache = new Map<string, TimelineIndex>()
const requests = new Map<string, Promise<TimelineIndex>>()
const MAX_CACHED_SESSIONS = 12
const TTL = 60_000
export const timelineIndexKey = (id: string, scope: ProfileScope) => JSON.stringify([id, scope])
export const cachedTimelineIndex = (key: string) => cache.get(key)
/** One bounded metadata page per request; never fetch tool or assistant bodies. */
export function fetchTimelineIndex(id: string, scope: ProfileScope): Promise<TimelineIndex> {
const key = timelineIndexKey(id, scope)
const cached = cache.get(key)
const previous = cached?.complete && cached.expires <= Date.now() ? undefined : cached
if (cached?.complete && cached.expires > Date.now()) {
return Promise.resolve(cached)
}
const inflight = requests.get(key)
if (inflight) {
return inflight
}
const route = {
...capabilityScoped(scope),
...(typeof scope === 'object' && scope?.connectionId === 'local' ? { connectionId: 'local' } : {})
}
const query = new URLSearchParams({ limit: '500' })
if (route.profile) {
query.set('profile', route.profile)
}
if (previous?.cursor !== undefined) {
query.set('after_row_id', String(previous.cursor))
}
const request = hermesApi<TimelinePage>({
...route,
path: `/api/sessions/${encodeURIComponent(id)}/timeline?${query}`,
passive: true
})
.then(page => {
const merged = new Map(previous?.entries.map(entry => [entry.rowId, entry]))
for (const entry of page.entries) {
merged.set(entry.row_id, { id: `history:${entry.row_id}`, rowId: entry.row_id, preview: entry.preview })
}
const value = {
entries: [...merged.values()],
complete: !page.pagination.has_more,
cursor: page.pagination.next_cursor ?? previous?.cursor,
expires: Date.now() + TTL
}
cache.delete(key)
cache.set(key, value)
while (cache.size > MAX_CACHED_SESSIONS) {
cache.delete(cache.keys().next().value!)
}
return value
})
.finally(() => requests.delete(key))
requests.set(key, request)
return request
}

View File

@@ -0,0 +1,53 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TimelineRail } from './timeline-rail'
vi.mock('@/components/ui/tooltip', () => ({ Tip: ({ children }: { children: ReactNode }) => children }))
beforeEach(() => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
width: 48,
height: 300,
top: 0,
left: 0,
bottom: 300,
right: 48,
x: 0,
y: 0,
toJSON: () => ({})
})
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(300)
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(48)
HTMLElement.prototype.scrollTo = vi.fn()
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
const entries = Array.from({ length: 5000 }, (_, index) => ({ id: `message-${index}`, preview: `Message ${index}` }))
describe('TimelineRail', () => {
it('bounds mounted buttons for thousands of prompts', () => {
const view = render(<TimelineRail activeIndex={0} entries={entries} loadingId={null} onJump={vi.fn()} />)
expect(view.container.querySelectorAll('button').length).toBeGreaterThan(0)
expect(view.container.querySelectorAll('button').length).toBeLessThan(60)
expect(view.container.querySelector('.thread-timeline-track')?.getAttribute('style')).toContain('35000px')
})
it('keeps the active bar at maximum width and routes selection by stable ID', () => {
const onJump = vi.fn()
render(<TimelineRail activeIndex={0} entries={entries} loadingId={null} onJump={onJump} />)
const active = screen.getByRole('button', { name: 'Message 0' })
expect(active.getAttribute('aria-current')).toBe('location')
expect((active.firstElementChild as HTMLElement).style.width).toBe('1rem')
fireEvent.click(active)
expect(onJump).toHaveBeenCalledWith('message-0')
})
})

View File

@@ -0,0 +1,192 @@
import './timeline.css'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Tip } from '@/components/ui/tooltip'
import { timelineBarWidth, type TimelineEntry } from './timeline-data'
interface TimelineRailProps {
activeIndex: number
entries: TimelineEntry[]
loadingId: string | null
onJump: (id: string) => void
}
/** Only the visible slice owns DOM or tooltip instances. Pointer paint is local. */
export function TimelineRail({ activeIndex, entries, loadingId, onJump }: TimelineRailProps) {
const root = useRef<HTMLDivElement>(null)
const hover = useRef<number | null>(null)
const pointerY = useRef<number | null>(null)
const frame = useRef(0)
const focused = useRef(false)
const pendingFocus = useRef<number | null>(null)
const [pitch, setPitch] = useState(7)
const virtualizer = useVirtualizer({
count: entries.length,
estimateSize: () => pitch,
getItemKey: index => entries[index].id,
getScrollElement: () => root.current,
initialRect: { height: 300, width: 48 },
overscan: 4
})
const items = virtualizer.getVirtualItems()
const visibleKey = `${items[0]?.index}:${items.at(-1)?.index}`
useLayoutEffect(() => {
const element = root.current
if (!element) {
return
}
const measure = () => {
const rem = parseFloat(getComputedStyle(element.ownerDocument.documentElement).fontSize)
if (Number.isFinite(rem) && rem > 0) {setPitch(rem * 0.4375)}
}
const observer = new ResizeObserver(measure)
observer.observe(element)
measure()
return () => observer.disconnect()
}, [])
useEffect(() => virtualizer.measure(), [pitch, virtualizer])
const paint = useCallback(() => {
frame.current = 0
for (const bar of root.current?.querySelectorAll<HTMLElement>('[data-slot="timeline-bar"]') ?? []) {
const width = `${Number(timelineBarWidth(Number(bar.dataset.index), activeIndex, hover.current).toFixed(4))}rem`
if (bar.style.width !== width) {
bar.style.width = width
}
}
}, [activeIndex])
const schedule = useCallback(() => {
if (!frame.current) {
frame.current = requestAnimationFrame(paint)
}
}, [paint])
useLayoutEffect(() => {
paint()
if (pendingFocus.current !== null) {
const button = root.current?.querySelector<HTMLButtonElement>(`[data-timeline-index="${pendingFocus.current}"]`)
if (button) {
button.focus({ preventScroll: true })
pendingFocus.current = null
}
}
return () => {
cancelAnimationFrame(frame.current)
frame.current = 0
}
}, [paint, visibleKey])
useEffect(() => {
if (hover.current === null && !focused.current) {
virtualizer.scrollToIndex(activeIndex, { align: 'auto' })
}
}, [activeIndex, virtualizer])
const updatePointer = useCallback(() => {
const scroller = root.current
if (!scroller || pointerY.current === null) {
return
}
const y = pointerY.current - scroller.getBoundingClientRect().top + scroller.scrollTop
hover.current = Math.max(0, Math.min(entries.length - 1, y / pitch - 0.5))
schedule()
}, [entries.length, pitch, schedule])
return (
<div
className="thread-timeline-ticks"
data-slot="thread-timeline-ticks"
onBlur={event => {
if (!event.currentTarget.contains(event.relatedTarget)) {
focused.current = false
hover.current = null
schedule()
}
}}
onKeyDown={event => {
const index = Number(
(event.target as HTMLElement).closest<HTMLElement>('[data-timeline-index]')?.dataset.timelineIndex
)
const next = { ArrowUp: index - 1, ArrowDown: index + 1, Home: 0, End: entries.length - 1 }[event.key]
if (next === undefined || !Number.isFinite(next)) {
return
}
event.preventDefault()
pendingFocus.current = Math.max(0, Math.min(entries.length - 1, next))
virtualizer.scrollToIndex(pendingFocus.current, { align: 'auto' })
root.current
?.querySelector<HTMLButtonElement>(`[data-timeline-index="${pendingFocus.current}"]`)
?.focus({ preventScroll: true })
}}
onPointerLeave={() => {
pointerY.current = null
hover.current = null
schedule()
}}
onPointerMove={event => {
pointerY.current = event.clientY
updatePointer()
}}
onScroll={updatePointer}
ref={root}
>
<div className="thread-timeline-track" style={{ height: virtualizer.getTotalSize() }}>
{items.map(item => {
const entry = entries[item.index]
return (
<Tip
key={entry.id}
label={entry.preview}
placement="right-rail"
sideOffset={8}
style={{ textAlign: 'right' }}
>
<button
aria-busy={loadingId === entry.id || undefined}
aria-current={item.index === activeIndex ? 'location' : undefined}
aria-label={entry.preview}
className="thread-timeline-tick"
data-timeline-id={entry.id}
data-timeline-index={item.index}
onClick={() => onJump(entry.id)}
onFocus={() => {
focused.current = true
hover.current = item.index
schedule()
}}
style={{ top: item.start, height: pitch }}
type="button"
>
<span aria-hidden data-index={item.index} data-slot="timeline-bar" />
</button>
</Tip>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,69 @@
.thread-timeline-ticks {
width: 3rem;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior-y: contain;
scrollbar-width: none;
}
.thread-timeline-ticks::-webkit-scrollbar {
display: none;
}
.thread-timeline-track {
position: relative;
width: 100%;
}
.thread-timeline-tick {
position: absolute;
left: 0;
display: flex;
align-items: center;
justify-content: flex-end;
height: 0.4375rem;
width: 100%;
padding: 0 0.375rem 0 0;
cursor: pointer;
outline: none;
}
.thread-timeline-tick [data-slot='timeline-bar'] {
display: block;
flex: none;
width: 0.5rem;
height: 0.0625rem;
border-radius: 999px;
background: var(--ui-text-tertiary);
opacity: 0.65;
transition: width 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.thread-timeline-tick[aria-current='location'] [data-slot='timeline-bar'] {
background: var(--theme-primary);
opacity: 1;
}
.thread-timeline-tick:focus-visible {
box-shadow: inset 0 0 0 1px var(--ui-stroke-secondary);
border-radius: 0.25rem;
}
.thread-timeline-tick[aria-busy='true'] [data-slot='timeline-bar'] {
animation: timeline-loading 700ms ease-in-out infinite alternate;
}
@keyframes timeline-loading {
to {
opacity: 0.3;
}
}
@media (prefers-reduced-motion: reduce) {
.thread-timeline-tick [data-slot='timeline-bar'] {
transition: none;
animation: none;
}
}

View File

@@ -1,419 +1,304 @@
import { useAui, useAuiState } from '@assistant-ui/react'
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { useStoreSelector } from '@/lib/use-session-slice'
import { messageContentText } from './content'
import {
activeTimelineIndex,
deriveTimelineEntries,
EARLIER_TIMELINE_ID,
sameTimelineEntries,
TIMELINE_REVEAL_EVENT,
type TimelineEntry,
type TimelineRevealRequest,
type TimelineSourceMessage
} from './timeline-data'
import { TimelineRail } from './timeline-rail'
import { useTranscriptWindow } from './transcript-window'
import { useTimelineHistory } from './use-timeline-history'
const MIN_ENTRIES = 4
const VIEWPORT = '[data-slot="aui_thread-viewport"]'
const HOVER_CLOSE_MS = 140
const ROW_CLASS =
'row-hover relative flex w-full min-w-0 max-w-full select-none overflow-hidden rounded-md px-2 py-1 text-left outline-hidden'
// Surface (border-color/bg/shadow/blur) comes from the shared
// `[data-slot='thread-timeline-popover']` rule in styles.css, so it's 1:1 with
// the dropdown/select/dialog menus. We only own layout + the border/radius here.
const POPOVER_SHELL =
'absolute right-full top-1/2 z-50 max-h-[min(22rem,calc(100vh-8rem))] w-80 max-w-[min(20rem,calc(100vw-2rem))] -translate-y-1/2 overflow-x-hidden overflow-y-auto overscroll-contain rounded-lg border p-1 text-popover-foreground transition-[opacity,transform] duration-100 ease-out group-hover/timeline:transition-none'
function userPromptText(content: unknown): string {
if (typeof content === 'string') {
return content
}
if (!Array.isArray(content)) {
return ''
}
let out = ''
for (const part of content) {
if (typeof part === 'string') {
out += part
continue
}
if (!part || typeof part !== 'object') {
continue
}
const row = part as { text?: unknown; type?: unknown }
if ((!row.type || row.type === 'text') && typeof row.text === 'string') {
out += row.text
}
}
return out
}
/** Index-keyed ref-array setter — `ref={listRef(refs, i)}`. */
const listRef =
<T,>(refs: React.RefObject<(T | null)[]>, index: number) =>
(node: T | null) => {
refs.current[index] = node
}
/** Mouse enter/leave pair forwarding `on` to the shared paint(). */
const hoverProps = (index: number, paint: (index: number, on: boolean) => void) => ({
onMouseEnter: () => paint(index, true),
onMouseLeave: () => paint(index, false)
})
// Constant-duration jump (eased), NOT native `behavior:'smooth'` — Chromium's
// smooth scroll animates proportional to distance, so jumping across a long
// thread crawls for seconds. A fixed ~260ms feels instant near or far. A
// shared rAF handle cancels a prior jump so rapid tick clicks don't fight.
let jumpRaf = 0
function jumpScroll(viewport: HTMLElement, top: number, duration = 170): void {
cancelAnimationFrame(jumpRaf)
const start = viewport.scrollTop
const delta = top - start
if (Math.abs(delta) < 2) {
viewport.scrollTop = top
return
}
const t0 = performance.now()
const ease = (t: number) => 1 - (1 - t) ** 3 // easeOutCubic
const step = (now: number) => {
const p = Math.min(1, (now - t0) / duration)
viewport.scrollTop = start + delta * ease(p)
if (p < 1) {
jumpRaf = requestAnimationFrame(step)
}
}
jumpRaf = requestAnimationFrame(step)
}
// A timeline belongs to ONE chat surface, and several are mounted at once — side
// by side in a split, and stacked (hidden but kept alive) as inactive tabs. Walk
// up to this timeline's own surface before looking for the viewport; a
// document-wide lookup scrolls somebody else's thread.
/** A kept-alive neighbor must never receive this rail's navigation. */
export const ownViewport = (root: HTMLElement | null): HTMLElement | null =>
(root?.closest('[data-session-anchor]') ?? document).querySelector<HTMLElement>(VIEWPORT)
function scrollToPrompt(root: HTMLElement | null, id: string) {
const viewport = ownViewport(root)
const node = viewport?.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(id)}"]`)
/** Hidden panes do not subscribe to streaming messages or measure layout. */
export const ThreadTimeline: FC = () => (usePaneVisible() ? <ActiveThreadTimeline /> : null)
if (!viewport || !node) {
return
}
const top = viewport.scrollTop + (node.getBoundingClientRect().top - viewport.getBoundingClientRect().top) - 8
triggerHaptic('selection')
jumpScroll(viewport, Math.max(0, top))
}
/**
* 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 = () => {
// 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') {
ids += `${message.id}\n`
}
}
return ids
})
// `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 view = useSessionView()
const { t } = useI18n()
const history = useTranscriptWindow()
const historyIndex = useTimelineHistory()
const { entries: indexedEntries, complete: indexComplete, failed: indexFailed, loadMore } = historyIndex
const aui = useAui()
const auiRef = useRef(aui)
auiRef.current = aui
const previousRef = useRef<TimelineEntry[]>([])
// The store contains older prompts even when the runtime/DOM only paints a tail.
// Read text only when user IDs change, never for assistant streaming deltas.
const storeIds = useStoreSelector(view.$messages, messages =>
messages
.filter(m => m.role === 'user')
.map(m => m.id)
.join('\n')
)
const runtimeIds = useAuiState(s =>
s.thread.messages
.filter(m => m.role === 'user')
.map(m => m.id)
.join('\n')
)
const previous = useRef<TimelineEntry[]>([])
const entries = useMemo(() => {
const rows: TimelineSourceMessage[] = []
const all = view.$messages.get()
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 rows: TimelineSourceMessage[] = all.length
? all
.filter(m => m.role === 'user')
.map(m => ({ id: m.id, rowId: m.rowId, role: m.role, text: messageContentText(m.parts) }))
: auiRef.current
.thread()
.getState()
.messages.filter(m => m.role === 'user')
.map(m => ({ id: m.id, role: m.role, text: messageContentText(m.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
if (sameTimelineEntries(previous.current, next)) {
return previous.current
}
previousRef.current = next
previous.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.
// The IDs are the change signal; the source messages are read only here.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [promptIds])
}, [storeIds, runtimeIds, view])
const railEntries = useMemo(() => {
const indexed = indexedEntries ?? []
const selected = history.isHistorical ? deriveTimelineEntries((history.currentMessages ?? []).map(message => ({ id: message.id, rowId: message.rowId, role: message.role, text: messageContentText(message.parts) }))) : []
const loaded = new Map([...entries, ...selected].filter(entry => entry.rowId !== undefined).map(entry => [entry.rowId, entry]))
const seen = new Set(indexed.map(entry => entry.rowId))
const merged = [
...indexed.map(entry => loaded.get(entry.rowId) ?? entry),
...entries.filter(entry => entry.rowId === undefined || !seen.has(entry.rowId))
]
return (history.olderAvailable || indexedEntries) && !indexComplete
? [{ id: EARLIER_TIMELINE_ID, preview: t.assistant.thread.showEarlier }, ...merged]
: merged
}, [entries, indexedEntries, indexComplete, history.olderAvailable, history.currentMessages, history.isHistorical, t.assistant.thread.showEarlier])
const root = useRef<HTMLDivElement>(null)
const jumpFrame = useRef(0)
const pending = useRef<AbortController | null>(null)
const [activeIndex, setActiveIndex] = useState(0)
const [open, setOpen] = useState(false)
const closeTimerRef = useRef<number | undefined>(undefined)
const rootRef = useRef<HTMLDivElement | null>(null)
const jump = useCallback((id: string) => scrollToPrompt(rootRef.current, id), [])
const [loadingId, setLoadingId] = useState<string | null>(null)
// Hover sync lives on the DOM, not in React state — the tick and its popover
// row are siblings in different subtrees, so a shared index-keyed paint() lights
// both without a re-render (and without coupling them through a parent atom).
const tickRefs = useRef<(HTMLSpanElement | null)[]>([])
const rowRefs = useRef<(HTMLButtonElement | null)[]>([])
// Hover sync: light the tick + its popover row, and scroll that row into view
// when the list overflows so the hovered prompt is always visible.
const paint = useCallback((index: number, on: boolean) => {
const tick = tickRefs.current[index]
if (tick) {
tick.style.opacity = on ? '1' : ''
}
const row = rowRefs.current[index]
row?.classList.toggle('bg-(--ui-row-hover-background)', on)
if (on) {
row?.scrollIntoView({ block: 'nearest' })
}
const cancelJump = useCallback(() => {
pending.current?.abort()
pending.current = null
cancelAnimationFrame(jumpFrame.current)
setLoadingId(null)
}, [])
const keepOpen = useCallback(() => {
window.clearTimeout(closeTimerRef.current)
setOpen(true)
}, [])
useEffect(() => cancelJump, [cancelJump, view])
const closeSoon = useCallback(() => {
window.clearTimeout(closeTimerRef.current)
closeTimerRef.current = window.setTimeout(() => setOpen(false), HOVER_CLOSE_MS)
}, [])
const jump = useCallback(
async (id: string) => {
cancelJump()
useEffect(() => () => window.clearTimeout(closeTimerRef.current), [])
if (id === EARLIER_TIMELINE_ID && indexedEntries && !indexComplete && !indexFailed) {
await loadMore()
return
}
const viewport = ownViewport(root.current)
if (!viewport) {
return
}
const controller = new AbortController()
pending.current = controller
setLoadingId(id)
triggerHaptic('selection')
try {
// The list owns loading and render budgets. Request it on THIS viewport,
// then wait for its commit rather than clicking a translated button label.
const revealed = await new Promise<string | false>(resolve => {
let settled = false
const finish = (value: string | false) => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
resolve(value)
}
const timeout = window.setTimeout(() => finish(false), 15000)
controller.signal.addEventListener('abort', () => finish(false), { once: true })
const detail: TimelineRevealRequest = { id, rowId: railEntries.find(entry => entry.id === id)?.rowId, signal: controller.signal, complete: finish }
viewport.dispatchEvent(new CustomEvent(TIMELINE_REVEAL_EVENT, { detail }))
})
if (!revealed || controller.signal.aborted) {
return
}
const node = viewport.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(revealed)}"]`)
if (!node) {
return
}
const start = viewport.scrollTop
const turn = node.closest<HTMLElement>('[data-slot="aui_turn-pair"]') ?? node
const destination = Math.max(
0,
start + turn.getBoundingClientRect().top - viewport.getBoundingClientRect().top - 8
)
const duration = matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 170
const began = performance.now()
const step = (now: number) => {
if (controller.signal.aborted) {
return
}
const progress = duration ? Math.min(1, (now - began) / duration) : 1
viewport.scrollTop = start + (destination - start) * (1 - (1 - progress) ** 3)
if (progress < 1) {
jumpFrame.current = requestAnimationFrame(step)
}
}
jumpFrame.current = requestAnimationFrame(step)
} finally {
if (pending.current === controller) {
setLoadingId(null)
}
}
},
[cancelJump, indexedEntries, indexComplete, indexFailed, loadMore, railEntries]
)
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)
const viewport = ownViewport(root.current)
if (!viewport) {
return
}
let raf = 0
let frame = 0
const indexes = new Map(railEntries.map((entry, index) => [entry.id, index]))
const compute = () => {
raf = 0
frame = 0
// Pinned to the bottom (the entire streaming steady-state): the active
// prompt is simply the last one. Skipping the walk matters — it reads a
// rect per user message per scroll frame, and interleaved with React's
// streaming style writes each read forces a full reflow (the single
// hottest frame in the multitab profile).
if (viewport.dataset.following === 'true') {
setActiveIndex(prev => (prev === entries.length - 1 ? prev : entries.length - 1))
if (viewport.dataset.following === 'true' && !history.isHistorical) {
setActiveIndex(Math.max(0, railEntries.length - 1))
return
}
const top = viewport.getBoundingClientRect().top
let first = -1
let active = -1
const offsets = entries.map(entry => {
const node = viewport.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(entry.id)}"]`)
// Walk only mounted messages, never every archived prompt in the rail.
for (const node of viewport.querySelectorAll<HTMLElement>('[data-message-id]')) {
const index = indexes.get(node.dataset.messageId!)
return node ? node.getBoundingClientRect().top - top : null
})
if (index === undefined) {
continue
}
const next = activeTimelineIndex(offsets)
if (first === -1) {
first = index
}
setActiveIndex(prev => (prev === next ? prev : next))
const turn = node.closest<HTMLElement>('[data-slot="aui_turn-pair"]') ?? node
if (turn.getBoundingClientRect().top - top <= 8) {
active = index
}
}
setActiveIndex(active === -1 ? Math.max(0, first) : active)
}
const onScroll = () => {
if (!raf) {
raf = requestAnimationFrame(compute)
const schedule = () => {
if (!frame) {
frame = requestAnimationFrame(compute)
}
}
// Initial compute rides the same rAF batching as scroll. A sync call here
// reads getBoundingClientRect for every user message while other commit
// effects are still writing styles — on a session switch that interleaving
// forces a full reflow per read on a large transcript. One rAF later the
// reads batch into a single layout pass, and back-to-back entries updates
// (prefetch paint, then resume reconcile) coalesce into one compute.
onScroll()
viewport.addEventListener('scroll', onScroll, { passive: true })
const observer = new MutationObserver(schedule)
const content = viewport.querySelector('[data-slot="aui_thread-content"]')
if (content) {
observer.observe(content, { childList: true })
}
viewport.addEventListener('scroll', schedule, { passive: true })
viewport.addEventListener('wheel', cancelJump, { passive: true })
schedule()
return () => {
viewport.removeEventListener('scroll', onScroll)
if (raf) {
cancelAnimationFrame(raf)
}
cancelAnimationFrame(frame)
observer.disconnect()
viewport.removeEventListener('scroll', schedule)
viewport.removeEventListener('wheel', cancelJump)
}
}, [entries])
}, [cancelJump, railEntries, history.isHistorical])
if (entries.length < MIN_ENTRIES) {
if (!railEntries.length) {
return null
}
return (
<div
aria-label="Conversation timeline"
className="group/timeline pointer-events-auto absolute right-0 top-1/2 z-40 flex -translate-y-1/2 flex-col items-end"
data-slot="thread-timeline"
data-suppress-pane-reveal=""
onMouseEnter={keepOpen}
onMouseLeave={closeSoon}
ref={rootRef}
ref={root}
role="navigation"
style={{
position: 'absolute',
right: 0,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 40,
pointerEvents: 'auto',
height: `min(${railEntries.length * 0.4375}rem, 50%)`
}}
>
<TimelineTicks activeIndex={activeIndex} entries={entries} onHover={paint} onJump={jump} tickRefs={tickRefs} />
<TimelinePopover
<TimelineRail
activeIndex={activeIndex}
entries={entries}
onHover={paint}
onJump={jump}
open={open}
rowRefs={rowRefs}
entries={railEntries}
loadingId={loadingId}
onJump={id => void jump(id)}
/>
</div>
)
}
const TimelinePopover: FC<{
activeIndex: number
entries: TimelineEntry[]
onHover: (index: number, on: boolean) => void
onJump: (id: string) => void
open: boolean
rowRefs: React.RefObject<(HTMLButtonElement | null)[]>
}> = ({ 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
entries: TimelineEntry[]
onHover: (index: number, on: boolean) => void
onJump: (id: string) => void
tickRefs: React.RefObject<(HTMLSpanElement | null)[]>
}> = ({ activeIndex, entries, onHover, onJump, tickRefs }) => (
<div className="flex flex-col items-end py-1" data-slot="thread-timeline-ticks">
{entries.map((entry, index) => (
<button
aria-label={entry.preview}
className="flex h-2 w-7 cursor-pointer items-center justify-end pr-1"
key={entry.id}
onClick={() => onJump(entry.id)}
type="button"
{...hoverProps(index, onHover)}
>
<span
className={cn(
'block h-px w-3 transition-opacity duration-100 ease-out',
index === activeIndex ? 'bg-(--theme-primary)' : 'dither text-(--ui-text-quaternary) opacity-70'
)}
ref={listRef(tickRefs, index)}
/>
</button>
))}
</div>
)

View File

@@ -1,24 +1,46 @@
import { createContext, type ReactNode, useContext } from 'react'
import { createContext, type ReactNode, useContext, useMemo } from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
export interface TranscriptWindowValue {
/** Store holds older messages the runtime window has not materialized. */
olderAvailable: boolean
/** Direct row addressing replaces only the display page; null means no jump. */
revealRow?: (rowId: number, signal: AbortSignal) => Promise<string | null>
/** Cancel any pending jump and reselect the current live tail. */
returnToLatest?: () => void
isHistorical?: boolean
newerAvailable?: boolean
/** Exactly the selected bounded source slice, not the full live store. */
currentMessages?: readonly ChatMessage[]
/** Pull a page, capturing the reader immediately before the prepend commits.
* A remote page resolves false on failure; callers can retry without growing
* an empty render window or holding the reader still during network I/O. */
expandWindow: (beforePrepend?: () => void) => void | Promise<boolean>
}
const TranscriptWindowContext = createContext<TranscriptWindowValue>({
const DEFAULT_TRANSCRIPT_WINDOW: Required<TranscriptWindowValue> = {
olderAvailable: false,
expandWindow: () => {}
})
export function TranscriptWindowProvider({ children, value }: { children: ReactNode; value: TranscriptWindowValue }) {
return <TranscriptWindowContext.Provider value={value}>{children}</TranscriptWindowContext.Provider>
expandWindow: () => {},
revealRow: async () => null,
returnToLatest: () => {},
isHistorical: false,
newerAvailable: false,
currentMessages: []
}
export function useTranscriptWindow(): TranscriptWindowValue {
const TranscriptWindowContext = createContext<Required<TranscriptWindowValue>>(DEFAULT_TRANSCRIPT_WINDOW)
export function TranscriptWindowProvider({ children, value }: {
children: ReactNode
value: Pick<TranscriptWindowValue, 'olderAvailable' | 'expandWindow'> & Partial<TranscriptWindowValue>
}) {
const complete = useMemo(() => ({ ...DEFAULT_TRANSCRIPT_WINDOW, ...value }), [value])
return <TranscriptWindowContext.Provider value={complete}>{children}</TranscriptWindowContext.Provider>
}
export function useTranscriptWindow(): Required<TranscriptWindowValue> {
return useContext(TranscriptWindowContext)
}

View File

@@ -0,0 +1,67 @@
import { useCallback, useEffect, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { useStoreSelector } from '@/lib/use-session-slice'
import { $activeGatewayProfile } from '@/store/profile'
import { $connection, getSessionOwnerHint } from '@/store/session'
import { transcriptTailState } from '@/store/transcript-tail'
import { cachedTimelineIndex, fetchTimelineIndex, type TimelineIndex, timelineIndexKey } from './timeline-index'
/** A bounded metadata page after paint; more titles load only on explicit demand. */
export function useTimelineHistory() {
const view = useSessionView()
const storedId = useStoreSelector(view.$storedId, id => id)
const runtimeId = useStoreSelector(view.$runtimeId, id => id)
const connectionId = useStoreSelector(
$connection,
connection => connection?.connectionId || (connection?.mode === 'local' ? 'local' : '')
)
const activeProfile = useStoreSelector($activeGatewayProfile, profile => profile)
const owner = storedId
? getSessionOwnerHint(storedId, connectionId ? { connectionId, profile: activeProfile } : undefined)
: undefined
const scope = owner
? { connectionId: owner.connectionId, profile: owner.targetProfile || owner.profile }
: (transcriptTailState(storedId)?.profile ?? { connectionId, profile: activeProfile })
const key = timelineIndexKey(storedId ?? '', scope)
const [index, setIndex] = useState<{ key: string; value: TimelineIndex } | null>(null)
const [failed, setFailed] = useState<string | null>(null)
const loadMore = useCallback(async () => {
if (!storedId) {
return
}
try {
const value = await fetchTimelineIndex(storedId, scope)
if (view.$storedId.get() === storedId && view.$runtimeId.get() === runtimeId) {
setIndex({ key, value })
setFailed(null)
}
} catch {
// Old backends keep the loaded rail and their explicit Show earlier path.
setFailed(key)
}
// Owner is represented by key; do not restart on object identity alone.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, storedId, runtimeId, view])
useEffect(() => {
const timer = window.setTimeout(() => {
void loadMore()
}, 200)
return () => window.clearTimeout(timer)
}, [loadMore])
const value = index?.key === key ? index.value : cachedTimelineIndex(key)
return { ...value, failed: failed === key, loadMore }
}

View File

@@ -0,0 +1,61 @@
import { act, cleanup, render } from '@testing-library/react'
import { useRef, useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TIMELINE_REVEAL_EVENT } from './timeline-data'
import { TranscriptWindowProvider } from './transcript-window'
import { useTimelineReveal } from './use-timeline-reveal'
vi.mock('@/app/chat/session-view', () => ({ useSessionView: () => view }))
const view = { $messages: { get: () => [] } }
beforeEach(() => vi.stubGlobal('CSS', { escape: (id: string) => id }))
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
function Harness({ fetchPage, signal }: { fetchPage: () => Promise<string | null>; signal: AbortSignal }) {
const [id, setId] = useState('latest')
return (
<TranscriptWindowProvider value={{ olderAvailable: true, expandWindow: vi.fn(), revealRow: async () => {
const next = await fetchPage()
if (next && !signal.aborted) {setId(next)}
return next
} }}>
<List id={id} />
</TranscriptWindowProvider>
)
}
function List({ id }: { id: string }) {
const viewport = useRef<HTMLDivElement>(null)
useTimelineReveal({ viewport, groups: [{ id, weight: 1 }], hiddenCount: 0, renderBudget: 600, olderAvailable: true, revealBudget: vi.fn(), expandWindow: vi.fn(), prepare: vi.fn(), sessionKey: 'session' })
return <div data-testid="viewport" ref={viewport}><div data-message-id={id}>{id}</div></div>
}
describe('timeline direct reveal', () => {
it('waits for a single bounded around-page commit and resolves the mounted id', async () => {
const controller = new AbortController()
const fetchPage = vi.fn(async () => 'old-target')
const complete = vi.fn()
const ui = render(<Harness fetchPage={fetchPage} signal={controller.signal} />)
await act(async () => {
ui.getByTestId('viewport').dispatchEvent(new CustomEvent(TIMELINE_REVEAL_EVENT, { detail: { id: 'history:42', rowId: 42, signal: controller.signal, complete } }))
})
expect(fetchPage).toHaveBeenCalledOnce()
expect(complete).toHaveBeenCalledWith('old-target')
})
it('leaves existing content when a direct read fails', async () => {
const controller = new AbortController()
const complete = vi.fn()
const ui = render(<Harness fetchPage={async () => null} signal={controller.signal} />)
await act(async () => {
ui.getByTestId('viewport').dispatchEvent(new CustomEvent(TIMELINE_REVEAL_EVENT, { detail: { id: 'history:42', signal: controller.signal, complete } }))
})
expect(complete).toHaveBeenCalledWith(false)
expect(ui.getByText('latest')).toBeTruthy()
})
})

View File

@@ -0,0 +1,120 @@
import { type RefObject, useEffect, useRef } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { EARLIER_TIMELINE_ID, TIMELINE_REVEAL_EVENT, type TimelineRevealRequest } from './timeline-data'
import { useTranscriptWindow } from './transcript-window'
interface TimelineRevealOptions {
viewport: RefObject<HTMLElement | null>
groups: readonly { id: string; weight: number }[]
hiddenCount: number
renderBudget: number
olderAvailable: boolean
revealBudget: (budget: number) => void
expandWindow: (beforePrepend?: () => void) => void | Promise<boolean>
prepare: () => void
sessionKey?: string | null
}
/** Direct archive jumps select one bounded page, never every intervening turn. */
export function useTimelineReveal(options: TimelineRevealOptions) {
const view = useSessionView()
const history = useTranscriptWindow()
const latest = useRef({ ...options, history })
latest.current = { ...options, history }
useEffect(() => {
const viewport = options.viewport.current
if (!viewport) {return}
let current: AbortController | null = null
const onReveal = (event: Event) => {
const request = (event as CustomEvent<TimelineRevealRequest>).detail
current?.abort()
const controller = new AbortController()
current = controller
const abort = () => controller.abort()
request.signal.addEventListener('abort', abort, { once: true })
if (request.signal.aborted) {controller.abort()}
const timeout = window.setTimeout(abort, 15000)
const valid = () => !controller.signal.aborted
const find = (id: string) => viewport.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(id)}"]`)
void (async () => {
latest.current.prepare()
let id = request.id
const rowId = request.rowId ?? (id.startsWith('history:') ? Number(id.slice(8)) : undefined)
const loaded = rowId === undefined ? undefined : [...(latest.current.history.currentMessages ?? []), ...view.$messages.get()].find(message => message.rowId === rowId)
id = loaded?.id ?? id
if (find(id)) {return id}
if (rowId !== undefined && !latest.current.groups.some(group => group.id === id) && latest.current.history.revealRow) {
const target = await latest.current.history.revealRow(rowId, controller.signal)
if (!target || !valid()) {return false}
id = target
} else if (id === EARLIER_TIMELINE_ID) {
const state = latest.current
if (!state.olderAvailable && !state.hiddenCount) {return false}
state.revealBudget(state.renderBudget + 600)
if (!state.hiddenCount) {await state.expandWindow()}
}
if (!valid()) {return false}
// React commits and late row mounts wake this observer; no polling loop.
return await new Promise<string | false>(resolve => {
const first = viewport.querySelector('[data-message-id]')?.getAttribute('data-message-id')
const finish = (result: string | false) => {
observer.disconnect()
controller.signal.removeEventListener('abort', cancelled)
resolve(result)
}
const cancelled = () => finish(false)
const check = () => {
if (!valid()) {return finish(false)}
const state = latest.current
const index = id === EARLIER_TIMELINE_ID ? Math.max(0, state.hiddenCount - 1) : state.groups.findIndex(group => group.id === id)
if (index >= 0) {
const budget = state.groups.slice(index).reduce((sum, group) => sum + group.weight, 0) + 1
if (budget > state.renderBudget) {state.revealBudget(budget)}
}
const node = id === EARLIER_TIMELINE_ID ? viewport.querySelector<HTMLElement>('[data-message-id]') : find(id)
if (node && (id !== EARLIER_TIMELINE_ID || node.dataset.messageId !== first)) {
latest.current.prepare()
finish(node.dataset.messageId!)
}
}
const observer = new MutationObserver(check)
observer.observe(viewport, { subtree: true, childList: true, attributes: true, attributeFilter: ['data-message-id'] })
controller.signal.addEventListener('abort', cancelled, { once: true })
check()
})
})().then(result => request.complete(result), () => request.complete(false)).finally(() => {
clearTimeout(timeout)
request.signal.removeEventListener('abort', abort)
})
}
viewport.addEventListener(TIMELINE_REVEAL_EVENT, onReveal)
return () => {
current?.abort()
viewport.removeEventListener(TIMELINE_REVEAL_EVENT, onReveal)
}
}, [options.viewport, options.sessionKey, view])
}