perf(desktop): virtualize the timeline and bound history jumps
This commit is contained in:
170
apps/desktop/src/app/chat/history-window.test.tsx
Normal file
170
apps/desktop/src/app/chat/history-window.test.tsx
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
134
apps/desktop/src/app/chat/history-window.ts
Normal file
134
apps/desktop/src/app/chat/history-window.ts
Normal 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 }
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
69
apps/desktop/src/components/assistant-ui/thread/timeline.css
Normal file
69
apps/desktop/src/components/assistant-ui/thread/timeline.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user