From 2efbbef981cc75ed6df6214ee6975a0ae0f418d3 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Wed, 16 Sep 2026 04:22:11 -0700 Subject: [PATCH] perf(desktop): virtualize the timeline and bound history jumps --- .../src/app/chat/history-window.test.tsx | 170 ++++++ apps/desktop/src/app/chat/history-window.ts | 134 +++++ apps/desktop/src/app/chat/index.tsx | 78 ++- .../src/app/chat/scroll-to-bottom-button.tsx | 5 +- .../components/assistant-ui/thread/index.tsx | 9 + .../components/assistant-ui/thread/list.tsx | 29 +- .../assistant-ui/thread/timeline-data.test.ts | 22 +- .../assistant-ui/thread/timeline-data.ts | 25 +- .../thread/timeline-index.test.ts | 68 +++ .../assistant-ui/thread/timeline-index.ts | 89 +++ .../thread/timeline-rail.test.tsx | 53 ++ .../assistant-ui/thread/timeline-rail.tsx | 192 ++++++ .../assistant-ui/thread/timeline.css | 69 +++ .../assistant-ui/thread/timeline.tsx | 553 +++++++----------- .../assistant-ui/thread/transcript-window.tsx | 38 +- .../thread/use-timeline-history.ts | 67 +++ .../thread/use-timeline-reveal.test.tsx | 61 ++ .../thread/use-timeline-reveal.ts | 120 ++++ 18 files changed, 1406 insertions(+), 376 deletions(-) create mode 100644 apps/desktop/src/app/chat/history-window.test.tsx create mode 100644 apps/desktop/src/app/chat/history-window.ts create mode 100644 apps/desktop/src/components/assistant-ui/thread/timeline-index.test.ts create mode 100644 apps/desktop/src/components/assistant-ui/thread/timeline-index.ts create mode 100644 apps/desktop/src/components/assistant-ui/thread/timeline-rail.test.tsx create mode 100644 apps/desktop/src/components/assistant-ui/thread/timeline-rail.tsx create mode 100644 apps/desktop/src/components/assistant-ui/thread/timeline.css create mode 100644 apps/desktop/src/components/assistant-ui/thread/use-timeline-history.ts create mode 100644 apps/desktop/src/components/assistant-ui/thread/use-timeline-reveal.test.tsx create mode 100644 apps/desktop/src/components/assistant-ui/thread/use-timeline-reveal.ts diff --git a/apps/desktop/src/app/chat/history-window.test.tsx b/apps/desktop/src/app/chat/history-window.test.tsx new file mode 100644 index 0000000000..0cb8f7b50b --- /dev/null +++ b/apps/desktop/src/app/chat/history-window.test.tsx @@ -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('runtime'), $storedId: atom('stored') + } + + let window!: Required + let runtime!: NonNullable> + + 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( + + + + + + ) + + 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) => void)[] = [] + vi.spyOn(window.hermesDesktop, 'api').mockImplementation(() => new Promise(resolve => resolves.push(resolve))) + const mounted = mount() + let first!: Promise + let second!: Promise + 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) => void + vi.spyOn(window.hermesDesktop, 'api').mockImplementation(() => new Promise(done => { resolve = done })) + const mounted = mount() + const signal = new AbortController() + let pending!: Promise + 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) + } + }) +}) diff --git a/apps/desktop/src/app/chat/history-window.ts b/apps/desktop/src/app/chat/history-window.ts new file mode 100644 index 0000000000..3874d1d7f3 --- /dev/null +++ b/apps/desktop/src/app/chat/history-window.ts @@ -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 & { + 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 { + 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({ + ...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(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 => { + 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(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 } +} diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index c7e529d607..ccc9182d3b 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -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[] { +function useMessagesWhileVisible($messages: ReadableAtom, 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({ 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 ( diff --git a/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx b/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx index d5fa31717e..b5c084bc8a 100644 --- a/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx +++ b/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx @@ -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 diff --git a/apps/desktop/src/components/assistant-ui/thread/index.tsx b/apps/desktop/src/components/assistant-ui/thread/index.tsx index d16f924c24..a1a27da760 100644 --- a/apps/desktop/src/components/assistant-ui/thread/index.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/index.tsx @@ -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 diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index 91757ba4a9..2f2cdb2caf 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx @@ -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['components'] @@ -461,7 +462,7 @@ const ThreadMessageListInner: FC = ({ 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 = ({ 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 = ({ 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 = ({ } }, [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. 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 b24f5391a8..e4f0c4ad1d 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,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( 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 b841d9ff32..118ce07a56 100644 --- a/apps/desktop/src/components/assistant-ui/thread/timeline-data.ts +++ b/apps/desktop/src/components/assistant-ui/thread/timeline-data.ts @@ -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 diff --git a/apps/desktop/src/components/assistant-ui/thread/timeline-index.test.ts b/apps/desktop/src/components/assistant-ui/thread/timeline-index.test.ts new file mode 100644 index 0000000000..77fabe128b --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/timeline-index.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/timeline-index.ts b/apps/desktop/src/components/assistant-ui/thread/timeline-index.ts new file mode 100644 index 0000000000..ef20323905 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/timeline-index.ts @@ -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() +const requests = new Map>() +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 { + 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({ + ...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 +} diff --git a/apps/desktop/src/components/assistant-ui/thread/timeline-rail.test.tsx b/apps/desktop/src/components/assistant-ui/thread/timeline-rail.test.tsx new file mode 100644 index 0000000000..c23e6936a2 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/timeline-rail.test.tsx @@ -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() + + 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() + + 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') + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/timeline-rail.tsx b/apps/desktop/src/components/assistant-ui/thread/timeline-rail.tsx new file mode 100644 index 0000000000..52ff592e72 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/timeline-rail.tsx @@ -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(null) + const hover = useRef(null) + const pointerY = useRef(null) + const frame = useRef(0) + const focused = useRef(false) + const pendingFocus = useRef(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('[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(`[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 ( +
{ + if (!event.currentTarget.contains(event.relatedTarget)) { + focused.current = false + hover.current = null + schedule() + } + }} + onKeyDown={event => { + const index = Number( + (event.target as HTMLElement).closest('[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(`[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} + > +
+ {items.map(item => { + const entry = entries[item.index] + + return ( + + + + ) + })} +
+
+ ) +} diff --git a/apps/desktop/src/components/assistant-ui/thread/timeline.css b/apps/desktop/src/components/assistant-ui/thread/timeline.css new file mode 100644 index 0000000000..c332f4e445 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/timeline.css @@ -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; + } +} diff --git a/apps/desktop/src/components/assistant-ui/thread/timeline.tsx b/apps/desktop/src/components/assistant-ui/thread/timeline.tsx index 9dc2c94b3c..5423b07d2f 100644 --- a/apps/desktop/src/components/assistant-ui/thread/timeline.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/timeline.tsx @@ -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 = - (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(VIEWPORT) -function scrollToPrompt(root: HTMLElement | null, id: string) { - const viewport = ownViewport(root) - const node = viewport?.querySelector(`[data-message-id="${CSS.escape(id)}"]`) +/** Hidden panes do not subscribe to streaming messages or measure layout. */ +export const ThreadTimeline: FC = () => (usePaneVisible() ? : 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() ? : 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([]) + // 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([]) 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(null) + const jumpFrame = useRef(0) + const pending = useRef(null) const [activeIndex, setActiveIndex] = useState(0) - const [open, setOpen] = useState(false) - const closeTimerRef = useRef(undefined) - const rootRef = useRef(null) - const jump = useCallback((id: string) => scrollToPrompt(rootRef.current, id), []) + const [loadingId, setLoadingId] = useState(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(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(`[data-message-id="${CSS.escape(revealed)}"]`) + + if (!node) { + return + } + + const start = viewport.scrollTop + const turn = node.closest('[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(`[data-message-id="${CSS.escape(entry.id)}"]`) + // Walk only mounted messages, never every archived prompt in the rail. + for (const node of viewport.querySelectorAll('[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('[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 (
- - void jump(id)} />
) } - -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 ( -
- {everOpened && - entries.map((entry, index) => ( - - ))} -
- ) -} - -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 }) => ( -
- {entries.map((entry, index) => ( - - ))} -
-) diff --git a/apps/desktop/src/components/assistant-ui/thread/transcript-window.tsx b/apps/desktop/src/components/assistant-ui/thread/transcript-window.tsx index d70d9659c6..aba495adad 100644 --- a/apps/desktop/src/components/assistant-ui/thread/transcript-window.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/transcript-window.tsx @@ -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 + /** 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 } -const TranscriptWindowContext = createContext({ +const DEFAULT_TRANSCRIPT_WINDOW: Required = { olderAvailable: false, - expandWindow: () => {} -}) - -export function TranscriptWindowProvider({ children, value }: { children: ReactNode; value: TranscriptWindowValue }) { - return {children} + expandWindow: () => {}, + revealRow: async () => null, + returnToLatest: () => {}, + isHistorical: false, + newerAvailable: false, + currentMessages: [] } -export function useTranscriptWindow(): TranscriptWindowValue { +const TranscriptWindowContext = createContext>(DEFAULT_TRANSCRIPT_WINDOW) + +export function TranscriptWindowProvider({ children, value }: { + children: ReactNode + value: Pick & Partial +}) { + const complete = useMemo(() => ({ ...DEFAULT_TRANSCRIPT_WINDOW, ...value }), [value]) + + return {children} +} + +export function useTranscriptWindow(): Required { return useContext(TranscriptWindowContext) } diff --git a/apps/desktop/src/components/assistant-ui/thread/use-timeline-history.ts b/apps/desktop/src/components/assistant-ui/thread/use-timeline-history.ts new file mode 100644 index 0000000000..7c702b7ee1 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/use-timeline-history.ts @@ -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(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 } +} diff --git a/apps/desktop/src/components/assistant-ui/thread/use-timeline-reveal.test.tsx b/apps/desktop/src/components/assistant-ui/thread/use-timeline-reveal.test.tsx new file mode 100644 index 0000000000..efe51f4900 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/use-timeline-reveal.test.tsx @@ -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; signal: AbortSignal }) { + const [id, setId] = useState('latest') + + return ( + { + const next = await fetchPage() + + if (next && !signal.aborted) {setId(next)} + + return next + } }}> + + + ) +} + +function List({ id }: { id: string }) { + const viewport = useRef(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
{id}
+} + +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() + 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( 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() + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/use-timeline-reveal.ts b/apps/desktop/src/components/assistant-ui/thread/use-timeline-reveal.ts new file mode 100644 index 0000000000..dc4a1f6e9b --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/use-timeline-reveal.ts @@ -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 + groups: readonly { id: string; weight: number }[] + hiddenCount: number + renderBudget: number + olderAvailable: boolean + revealBudget: (budget: number) => void + expandWindow: (beforePrepend?: () => void) => void | Promise + 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).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(`[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(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('[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]) +}