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 { droppedFileInlineRefs } from './composer/inline-refs'
|
||||||
import { ComposerSurfaceProvider, useComposerScope, useComposerSurfaceId } from './composer/scope'
|
import { ComposerSurfaceProvider, useComposerScope, useComposerSurfaceId } from './composer/scope'
|
||||||
import type { ChatBarState } from './composer/types'
|
import type { ChatBarState } from './composer/types'
|
||||||
|
import { useHistoryWindow } from './history-window'
|
||||||
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
|
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
|
||||||
import { type DragKind, useFileDropZone } from './hooks/use-file-drop-zone'
|
import { type DragKind, useFileDropZone } from './hooks/use-file-drop-zone'
|
||||||
import { shouldShowIntro } from './intro-visibility'
|
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
|
* dots stay live through the separate status atoms) and catch up in one
|
||||||
* commit on reveal — the subscribe fires immediately with the current value.
|
* 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 visible = usePaneVisible()
|
||||||
const [messages, setMessages] = useState(() => $messages.get())
|
const [messages, setMessages] = useState(() => $messages.get())
|
||||||
|
|
||||||
// nanostores types the listener value ReadonlyIfObject; the store publishes
|
// 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.
|
// a fresh array per flush, so the cast is safe and avoids a per-token clone.
|
||||||
useEffect(
|
useEffect(
|
||||||
() => (visible ? $messages.subscribe(value => setMessages(value as ChatMessage[])) : undefined),
|
() => (visible && enabled ? $messages.subscribe(value => setMessages(value as ChatMessage[])) : undefined),
|
||||||
[$messages, visible]
|
[$messages, visible, enabled]
|
||||||
)
|
)
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
@@ -245,7 +246,32 @@ export function ChatRuntimeBoundary({
|
|||||||
}: ChatRuntimeBoundaryProps) {
|
}: ChatRuntimeBoundaryProps) {
|
||||||
const view = useSessionView()
|
const view = useSessionView()
|
||||||
const runtimeId = useStore(view.$runtimeId)
|
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 messages = suppressMessages ? NO_MESSAGES : storeMessages
|
||||||
|
|
||||||
const [windowPages, setWindowPages] = useState(1)
|
const [windowPages, setWindowPages] = useState(1)
|
||||||
@@ -285,29 +311,19 @@ export function ChatRuntimeBoundary({
|
|||||||
return next.window
|
return next.window
|
||||||
}, [messages, windowPages])
|
}, [messages, windowPages])
|
||||||
|
|
||||||
const runtimeMessageRepository = useRuntimeMessageRepository(windowedMessages)
|
const currentMessages = history.page?.messages ?? windowedMessages
|
||||||
|
const runtimeMessageRepository = useRuntimeMessageRepository(currentMessages)
|
||||||
const storedId = useStore(view.$storedId)
|
|
||||||
const connection = useStore($connection)
|
|
||||||
const activeProfile = useStore($activeGatewayProfile)
|
|
||||||
// Subscribed (not read imperatively) so the "Show earlier" affordance
|
// Subscribed (not read imperatively) so the "Show earlier" affordance
|
||||||
// appears/retires as tail hydrations and backfill pages record their state.
|
// appears/retires as tail hydrations and backfill pages record their state.
|
||||||
const transcriptTailStates = useStore($transcriptTailBySessionId)
|
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 tailState = storedId && transcriptTailStates ? transcriptTailState(storedId, tailProfile) : undefined
|
||||||
const restBackfillAvailable = Boolean(tailState?.possiblyTruncated)
|
const restBackfillAvailable = Boolean(tailState?.possiblyTruncated)
|
||||||
|
|
||||||
const expandWindow = useCallback(
|
const expandWindow = useCallback(
|
||||||
async (beforePrepend?: () => void) => {
|
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
|
// 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.
|
// before the store prepend, and only grow a window that has a page to show.
|
||||||
if (
|
if (
|
||||||
@@ -352,24 +368,32 @@ export function ChatRuntimeBoundary({
|
|||||||
|
|
||||||
return true
|
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>({
|
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
|
||||||
messageRepository: runtimeMessageRepository,
|
messageRepository: runtimeMessageRepository,
|
||||||
isRunning: busy,
|
isRunning: !isHistorical && busy,
|
||||||
setMessages: onThreadMessagesChange,
|
isDisabled: isHistorical,
|
||||||
|
setMessages: isHistorical ? undefined : onThreadMessagesChange,
|
||||||
onNew: async () => {
|
onNew: async () => {
|
||||||
// Submission is handled explicitly by ChatBar.
|
// Submission is handled explicitly by ChatBar.
|
||||||
// Keeping this no-op avoids duplicate prompt.submit calls.
|
// Keeping this no-op avoids duplicate prompt.submit calls.
|
||||||
},
|
},
|
||||||
onEdit,
|
onEdit: isHistorical ? undefined : onEdit,
|
||||||
onCancel: async () => onCancel(),
|
onCancel: isHistorical ? undefined : async () => onCancel(),
|
||||||
onReload
|
onReload: isHistorical ? undefined : onReload
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
|||||||
import { useReducedMotion } from 'motion/react'
|
import { useReducedMotion } from 'motion/react'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
|
|
||||||
|
import { useTranscriptWindow } from '@/components/assistant-ui/thread/transcript-window'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import { AnimatedInt } from '@/components/ui/diff-count'
|
import { AnimatedInt } from '@/components/ui/diff-count'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
@@ -38,8 +39,9 @@ export function ScrollToBottomButton({ sessionId }: { sessionId: string | null }
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const surfaceId = useComposerSurfaceId()
|
const surfaceId = useComposerSurfaceId()
|
||||||
const scrollSessionId = sessionId ?? surfaceId
|
const scrollSessionId = sessionId ?? surfaceId
|
||||||
|
const { isHistorical } = useTranscriptWindow()
|
||||||
|
|
||||||
const visible = useStoreSelector($threadJumpButtonVisibleBySession, map =>
|
const scrollVisible = useStoreSelector($threadJumpButtonVisibleBySession, map =>
|
||||||
Boolean(scrollSessionId && map[scrollSessionId])
|
Boolean(scrollSessionId && map[scrollSessionId])
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,6 +49,7 @@ export function ScrollToBottomButton({ sessionId }: { sessionId: string | null }
|
|||||||
scrollSessionId ? (map[scrollSessionId] ?? 0) : 0
|
scrollSessionId ? (map[scrollSessionId] ?? 0) : 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const visible = isHistorical || scrollVisible
|
||||||
const reducedMotion = useReducedMotion()
|
const reducedMotion = useReducedMotion()
|
||||||
const request = useStore(useMemo(() => sessionApprovalRequest(sessionId), [sessionId]))
|
const request = useStore(useMemo(() => sessionApprovalRequest(sessionId), [sessionId]))
|
||||||
// Scrolled away while an approval is pending → the inline Run/Reject bar is
|
// 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 { BackgroundResumeNotice, CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
|
||||||
import { SystemMessage } from '@/components/assistant-ui/thread/system-message'
|
import { SystemMessage } from '@/components/assistant-ui/thread/system-message'
|
||||||
import { ThreadTimeline } from '@/components/assistant-ui/thread/timeline'
|
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 { type RestoreMessageTarget } from '@/components/assistant-ui/thread/types'
|
||||||
import { UserEditComposer } from '@/components/assistant-ui/thread/user-edit-composer'
|
import { UserEditComposer } from '@/components/assistant-ui/thread/user-edit-composer'
|
||||||
import { UserMessage } from '@/components/assistant-ui/thread/user-message'
|
import { UserMessage } from '@/components/assistant-ui/thread/user-message'
|
||||||
@@ -71,6 +72,14 @@ export const Thread = memo(function Thread({
|
|||||||
}: ThreadProps) {
|
}: ThreadProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const copy = t.assistant.thread
|
const copy = t.assistant.thread
|
||||||
|
const { isHistorical } = useTranscriptWindow()
|
||||||
|
|
||||||
|
if (isHistorical) {
|
||||||
|
onBranchInNewChat = undefined
|
||||||
|
onCancel = undefined
|
||||||
|
onDismissError = undefined
|
||||||
|
onRestoreToMessage = undefined
|
||||||
|
}
|
||||||
|
|
||||||
const [restoreConfirmTarget, setRestoreConfirmTarget] = useState<
|
const [restoreConfirmTarget, setRestoreConfirmTarget] = useState<
|
||||||
(RestoreMessageTarget & { messageId: string }) | null
|
(RestoreMessageTarget & { messageId: string }) | null
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { responseMessageRole, ResponseMessages } from './response-group'
|
|||||||
import { resolveShowEarlierAction, shouldAutoShowEarlier, useTranscriptWindow } from './transcript-window'
|
import { resolveShowEarlierAction, shouldAutoShowEarlier, useTranscriptWindow } from './transcript-window'
|
||||||
import { useMessagesBelow } from './use-messages-below'
|
import { useMessagesBelow } from './use-messages-below'
|
||||||
import { useStickyPromptClip } from './use-sticky-prompt-clip'
|
import { useStickyPromptClip } from './use-sticky-prompt-clip'
|
||||||
|
import { useTimelineReveal } from './use-timeline-reveal'
|
||||||
|
|
||||||
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
|
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
|
||||||
|
|
||||||
@@ -461,7 +462,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||||||
targetScrollTop: resolveThreadScrollTarget
|
targetScrollTop: resolveThreadScrollTarget
|
||||||
})
|
})
|
||||||
|
|
||||||
const { olderAvailable, expandWindow } = useTranscriptWindow()
|
const { olderAvailable, expandWindow, isHistorical, returnToLatest } = useTranscriptWindow()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
$mountedTranscriptPanes.set($mountedTranscriptPanes.get() + 1)
|
$mountedTranscriptPanes.set($mountedTranscriptPanes.get() + 1)
|
||||||
@@ -669,8 +670,8 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||||||
const surfaceId = useComposerSurfaceId()
|
const surfaceId = useComposerSurfaceId()
|
||||||
const scrollSessionId = sessionId ?? surfaceId
|
const scrollSessionId = sessionId ?? surfaceId
|
||||||
useEffect(
|
useEffect(
|
||||||
() => publishThreadAtBottom(isAtBottom, { paneVisible, sessionId: scrollSessionId }),
|
() => publishThreadAtBottom(isAtBottom && !isHistorical, { paneVisible, sessionId: scrollSessionId }),
|
||||||
[isAtBottom, paneVisible, scrollSessionId]
|
[isAtBottom, isHistorical, paneVisible, scrollSessionId]
|
||||||
)
|
)
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => resetPublishedThreadScroll({ paneVisible, sessionId: scrollSessionId }),
|
() => () => resetPublishedThreadScroll({ paneVisible, sessionId: scrollSessionId }),
|
||||||
@@ -681,13 +682,15 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||||||
useEffect(
|
useEffect(
|
||||||
() =>
|
() =>
|
||||||
onScrollToBottomRequest(() => {
|
onScrollToBottomRequest(() => {
|
||||||
|
if (isHistorical) {returnToLatest?.()}
|
||||||
|
|
||||||
if (jumpRestoreRef.current) {
|
if (jumpRestoreRef.current) {
|
||||||
jumpRestoreRef.current()
|
jumpRestoreRef.current()
|
||||||
} else {
|
} else {
|
||||||
void scrollToBottom()
|
void scrollToBottom()
|
||||||
}
|
}
|
||||||
}, scrollSessionId),
|
}, scrollSessionId),
|
||||||
[scrollToBottom, scrollSessionId]
|
[scrollToBottom, scrollSessionId, isHistorical, returnToLatest]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Waking from display: hidden (HUD mode hides the main window; OS hide does
|
// 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])
|
}, [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
|
// 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`
|
// 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.
|
// once scrollTop is already 0 — exactly where the reader who wants more is.
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
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', () => {
|
describe('timelinePreview', () => {
|
||||||
it('collapses whitespace to a single line', () => {
|
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', () => {
|
describe('deriveTimelineEntries', () => {
|
||||||
it('keeps non-empty user prompts in order', () => {
|
it('keeps non-empty user prompts in order', () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -3,17 +3,36 @@ import { PROCESS_NOTIFICATION_RE } from './content'
|
|||||||
|
|
||||||
export interface TimelineSourceMessage {
|
export interface TimelineSourceMessage {
|
||||||
id: string
|
id: string
|
||||||
|
rowId?: number
|
||||||
role: string
|
role: string
|
||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimelineEntry {
|
export interface TimelineEntry {
|
||||||
id: string
|
id: string
|
||||||
|
rowId?: number
|
||||||
preview: string
|
preview: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const PREVIEW_MAX = 120
|
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 {
|
export function timelinePreview(text: string, max: number = PREVIEW_MAX): string {
|
||||||
const collapsed = text.replace(/\s+/g, ' ').trim()
|
const collapsed = text.replace(/\s+/g, ' ').trim()
|
||||||
|
|
||||||
@@ -38,7 +57,11 @@ export function deriveTimelineEntries(messages: readonly TimelineSourceMessage[]
|
|||||||
continue
|
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
|
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 { useAui, useAuiState } from '@assistant-ui/react'
|
||||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from '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 { usePaneVisible } from '@/components/pane-shell/pane-visibility'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { cn } from '@/lib/utils'
|
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||||
|
|
||||||
|
import { messageContentText } from './content'
|
||||||
import {
|
import {
|
||||||
activeTimelineIndex,
|
|
||||||
deriveTimelineEntries,
|
deriveTimelineEntries,
|
||||||
|
EARLIER_TIMELINE_ID,
|
||||||
sameTimelineEntries,
|
sameTimelineEntries,
|
||||||
|
TIMELINE_REVEAL_EVENT,
|
||||||
type TimelineEntry,
|
type TimelineEntry,
|
||||||
|
type TimelineRevealRequest,
|
||||||
type TimelineSourceMessage
|
type TimelineSourceMessage
|
||||||
} from './timeline-data'
|
} 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 VIEWPORT = '[data-slot="aui_thread-viewport"]'
|
||||||
const HOVER_CLOSE_MS = 140
|
|
||||||
|
|
||||||
const ROW_CLASS =
|
/** A kept-alive neighbor must never receive this rail's navigation. */
|
||||||
'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.
|
|
||||||
export const ownViewport = (root: HTMLElement | null): HTMLElement | null =>
|
export const ownViewport = (root: HTMLElement | null): HTMLElement | null =>
|
||||||
(root?.closest('[data-session-anchor]') ?? document).querySelector<HTMLElement>(VIEWPORT)
|
(root?.closest('[data-session-anchor]') ?? document).querySelector<HTMLElement>(VIEWPORT)
|
||||||
|
|
||||||
function scrollToPrompt(root: HTMLElement | null, id: string) {
|
/** Hidden panes do not subscribe to streaming messages or measure layout. */
|
||||||
const viewport = ownViewport(root)
|
export const ThreadTimeline: FC = () => (usePaneVisible() ? <ActiveThreadTimeline /> : null)
|
||||||
const node = viewport?.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(id)}"]`)
|
|
||||||
|
|
||||||
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 = () => {
|
const ActiveThreadTimeline: FC = () => {
|
||||||
// Cheap in the selector, expensive only when it changes: the ids alone tell
|
const view = useSessionView()
|
||||||
// us whether the RAIL changed. Prompt text is immutable once sent, and an
|
const { t } = useI18n()
|
||||||
// edit rewinds the transcript (dropping every id after it) and re-appends a
|
const history = useTranscriptWindow()
|
||||||
// fresh message id — so a preview can never go stale behind a stable id.
|
const historyIndex = useTimelineHistory()
|
||||||
// Streaming an assistant reply churns that message's content on every token
|
const { entries: indexedEntries, complete: indexComplete, failed: indexFailed, loadMore } = historyIndex
|
||||||
// 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 aui = useAui()
|
const aui = useAui()
|
||||||
const auiRef = useRef(aui)
|
const auiRef = useRef(aui)
|
||||||
auiRef.current = 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 entries = useMemo(() => {
|
||||||
const rows: TimelineSourceMessage[] = []
|
const all = view.$messages.get()
|
||||||
|
|
||||||
for (const message of auiRef.current.thread().getState().messages) {
|
const rows: TimelineSourceMessage[] = all.length
|
||||||
if (message.role === 'user') {
|
? all
|
||||||
rows.push({ id: message.id, role: 'user', text: userPromptText(message.content) })
|
.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)
|
const next = deriveTimelineEntries(rows)
|
||||||
|
|
||||||
// Hand back the PREVIOUS array when nothing user-visible moved. Blank and
|
if (sameTimelineEntries(previous.current, next)) {
|
||||||
// background-notification prompts are filtered out, so a new id can leave
|
return previous.current
|
||||||
// the rail identical — without this, that re-renders both subtrees and
|
|
||||||
// restarts the measure effect for no visible change.
|
|
||||||
if (sameTimelineEntries(previousRef.current, next)) {
|
|
||||||
return previousRef.current
|
|
||||||
}
|
}
|
||||||
|
|
||||||
previousRef.current = next
|
previous.current = next
|
||||||
|
|
||||||
return next
|
return next
|
||||||
// promptIds is the intentional re-eval TRIGGER, not a value the derivation
|
// The IDs are the change signal; the source messages are read only here.
|
||||||
// reads (the transcript comes off the ref) — same shape as ChatRoutesSurface's
|
|
||||||
// gatewayState memo in app/contrib/controller.tsx.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 [activeIndex, setActiveIndex] = useState(0)
|
||||||
const [open, setOpen] = useState(false)
|
const [loadingId, setLoadingId] = useState<string | null>(null)
|
||||||
const closeTimerRef = useRef<number | undefined>(undefined)
|
|
||||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
const jump = useCallback((id: string) => scrollToPrompt(rootRef.current, id), [])
|
|
||||||
|
|
||||||
// Hover sync lives on the DOM, not in React state — the tick and its popover
|
const cancelJump = useCallback(() => {
|
||||||
// row are siblings in different subtrees, so a shared index-keyed paint() lights
|
pending.current?.abort()
|
||||||
// both without a re-render (and without coupling them through a parent atom).
|
pending.current = null
|
||||||
const tickRefs = useRef<(HTMLSpanElement | null)[]>([])
|
cancelAnimationFrame(jumpFrame.current)
|
||||||
const rowRefs = useRef<(HTMLButtonElement | null)[]>([])
|
setLoadingId(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 keepOpen = useCallback(() => {
|
useEffect(() => cancelJump, [cancelJump, view])
|
||||||
window.clearTimeout(closeTimerRef.current)
|
|
||||||
setOpen(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const closeSoon = useCallback(() => {
|
const jump = useCallback(
|
||||||
window.clearTimeout(closeTimerRef.current)
|
async (id: string) => {
|
||||||
closeTimerRef.current = window.setTimeout(() => setOpen(false), HOVER_CLOSE_MS)
|
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(() => {
|
useEffect(() => {
|
||||||
// Below the threshold the rail renders null, so measuring prompt offsets
|
const viewport = ownViewport(root.current)
|
||||||
// buys nothing — bail before touching layout at all.
|
|
||||||
if (entries.length < MIN_ENTRIES) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewport = ownViewport(rootRef.current)
|
|
||||||
|
|
||||||
if (!viewport) {
|
if (!viewport) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let raf = 0
|
let frame = 0
|
||||||
|
const indexes = new Map(railEntries.map((entry, index) => [entry.id, index]))
|
||||||
|
|
||||||
const compute = () => {
|
const compute = () => {
|
||||||
raf = 0
|
frame = 0
|
||||||
|
|
||||||
// Pinned to the bottom (the entire streaming steady-state): the active
|
if (viewport.dataset.following === 'true' && !history.isHistorical) {
|
||||||
// prompt is simply the last one. Skipping the walk matters — it reads a
|
setActiveIndex(Math.max(0, railEntries.length - 1))
|
||||||
// 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))
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const top = viewport.getBoundingClientRect().top
|
const top = viewport.getBoundingClientRect().top
|
||||||
|
let first = -1
|
||||||
|
let active = -1
|
||||||
|
|
||||||
const offsets = entries.map(entry => {
|
// Walk only mounted messages, never every archived prompt in the rail.
|
||||||
const node = viewport.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(entry.id)}"]`)
|
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 = () => {
|
const schedule = () => {
|
||||||
if (!raf) {
|
if (!frame) {
|
||||||
raf = requestAnimationFrame(compute)
|
frame = requestAnimationFrame(compute)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial compute rides the same rAF batching as scroll. A sync call here
|
const observer = new MutationObserver(schedule)
|
||||||
// reads getBoundingClientRect for every user message while other commit
|
const content = viewport.querySelector('[data-slot="aui_thread-content"]')
|
||||||
// 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
|
if (content) {
|
||||||
// reads batch into a single layout pass, and back-to-back entries updates
|
observer.observe(content, { childList: true })
|
||||||
// (prefetch paint, then resume reconcile) coalesce into one compute.
|
}
|
||||||
onScroll()
|
|
||||||
viewport.addEventListener('scroll', onScroll, { passive: true })
|
viewport.addEventListener('scroll', schedule, { passive: true })
|
||||||
|
viewport.addEventListener('wheel', cancelJump, { passive: true })
|
||||||
|
schedule()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
viewport.removeEventListener('scroll', onScroll)
|
cancelAnimationFrame(frame)
|
||||||
|
observer.disconnect()
|
||||||
if (raf) {
|
viewport.removeEventListener('scroll', schedule)
|
||||||
cancelAnimationFrame(raf)
|
viewport.removeEventListener('wheel', cancelJump)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [entries])
|
}, [cancelJump, railEntries, history.isHistorical])
|
||||||
|
|
||||||
if (entries.length < MIN_ENTRIES) {
|
if (!railEntries.length) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-label="Conversation timeline"
|
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-slot="thread-timeline"
|
||||||
data-suppress-pane-reveal=""
|
data-suppress-pane-reveal=""
|
||||||
onMouseEnter={keepOpen}
|
ref={root}
|
||||||
onMouseLeave={closeSoon}
|
|
||||||
ref={rootRef}
|
|
||||||
role="navigation"
|
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} />
|
<TimelineRail
|
||||||
<TimelinePopover
|
|
||||||
activeIndex={activeIndex}
|
activeIndex={activeIndex}
|
||||||
entries={entries}
|
entries={railEntries}
|
||||||
onHover={paint}
|
loadingId={loadingId}
|
||||||
onJump={jump}
|
onJump={id => void jump(id)}
|
||||||
open={open}
|
|
||||||
rowRefs={rowRefs}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 {
|
export interface TranscriptWindowValue {
|
||||||
/** Store holds older messages the runtime window has not materialized. */
|
/** Store holds older messages the runtime window has not materialized. */
|
||||||
olderAvailable: boolean
|
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.
|
/** Pull a page, capturing the reader immediately before the prepend commits.
|
||||||
* A remote page resolves false on failure; callers can retry without growing
|
* 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. */
|
* an empty render window or holding the reader still during network I/O. */
|
||||||
expandWindow: (beforePrepend?: () => void) => void | Promise<boolean>
|
expandWindow: (beforePrepend?: () => void) => void | Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
const TranscriptWindowContext = createContext<TranscriptWindowValue>({
|
const DEFAULT_TRANSCRIPT_WINDOW: Required<TranscriptWindowValue> = {
|
||||||
olderAvailable: false,
|
olderAvailable: false,
|
||||||
expandWindow: () => {}
|
expandWindow: () => {},
|
||||||
})
|
revealRow: async () => null,
|
||||||
|
returnToLatest: () => {},
|
||||||
export function TranscriptWindowProvider({ children, value }: { children: ReactNode; value: TranscriptWindowValue }) {
|
isHistorical: false,
|
||||||
return <TranscriptWindowContext.Provider value={value}>{children}</TranscriptWindowContext.Provider>
|
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)
|
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