diff --git a/web/src/chat/outline.test.ts b/web/src/chat/outline.test.ts index 39fa19cc..b801b13f 100644 --- a/web/src/chat/outline.test.ts +++ b/web/src/chat/outline.test.ts @@ -2,13 +2,19 @@ import { describe, expect, it } from 'vitest' import type { AgentEvent, ChatBlock } from '@/chat/types' import { buildConversationOutline, truncateOutlineLabel } from '@/chat/outline' -function userBlock(id: string, text: string, createdAt: number): ChatBlock { +function userBlock( + id: string, + text: string, + createdAt: number, + overrides: Partial> = {} +): ChatBlock { return { kind: 'user-text', id, localId: null, createdAt, - text + text, + ...overrides } } @@ -54,6 +60,17 @@ describe('conversation outline', () => { expect(truncateOutlineLabel('a '.repeat(80), 20)).toBe('a a a a a a a a a...') }) + it('filters queued user messages that are not yet locatable in the thread', () => { + const items = buildConversationOutline([ + userBlock('queued', 'Queued prompt', 1000, { status: 'queued', invokedAt: null }), + userBlock('sent', 'Visible prompt', 2000, { status: 'sent', invokedAt: 2500 }), + ]) + + expect(items.map((item) => item.id)).toEqual([ + 'outline:user:sent' + ]) + }) + it('keeps block order stable', () => { const items = buildConversationOutline([ userBlock('first', 'First', 1000), diff --git a/web/src/chat/outline.ts b/web/src/chat/outline.ts index d70a9ed2..24134abd 100644 --- a/web/src/chat/outline.ts +++ b/web/src/chat/outline.ts @@ -33,11 +33,16 @@ function userBlockToOutlineItem(block: UserTextBlock): ConversationOutlineItem { } } +function isLocatableOutlineBlock(block: ChatBlock): block is UserTextBlock { + return block.kind === 'user-text' + && !(block.invokedAt === null && block.status !== 'failed') +} + export function buildConversationOutline(blocks: readonly ChatBlock[]): ConversationOutlineItem[] { const items: ConversationOutlineItem[] = [] for (const block of blocks) { - if (block.kind === 'user-text') { + if (isLocatableOutlineBlock(block)) { items.push(userBlockToOutlineItem(block)) } } diff --git a/web/src/components/AssistantChat/HappyThread.test.tsx b/web/src/components/AssistantChat/HappyThread.test.tsx index 45176b53..f3ea814c 100644 --- a/web/src/components/AssistantChat/HappyThread.test.tsx +++ b/web/src/components/AssistantChat/HappyThread.test.tsx @@ -6,6 +6,7 @@ import { ConversationOutlinePanel, captureScrollAnchor, getScrollIntent, + locateOutlineTargetMessage, restoreScrollAnchor, shouldCancelInitialScrollSettling, } from '@/components/AssistantChat/HappyThread' @@ -183,3 +184,46 @@ describe('scroll anchor helpers', () => { viewport.remove() }) }) + +describe('outline target loading', () => { + it('loads older messages through the scroll-preserving wrapper until the target appears', async () => { + const loadOlderPreservingScroll = vi.fn<() => Promise>() + let loadCount = 0 + loadOlderPreservingScroll.mockImplementation(async () => { + loadCount += 1 + return true + }) + + const findTarget = vi.fn((anchorId: string) => { + if (anchorId !== 'hapi-message-user:target') { + return null + } + return loadCount >= 2 ? document.createElement('div') : null + }) + + const target = await locateOutlineTargetMessage({ + targetMessageId: 'user:target', + findTarget, + hasMoreMessages: () => loadCount < 2, + loadOlderPreservingScroll + }) + + expect(target).toBeInstanceOf(HTMLElement) + expect(loadOlderPreservingScroll).toHaveBeenCalledTimes(2) + expect(findTarget).toHaveBeenCalledWith('hapi-message-user:target') + }) + + it('stops when history is exhausted before the target is loaded', async () => { + const loadOlderPreservingScroll = vi.fn(async () => false) + + const target = await locateOutlineTargetMessage({ + targetMessageId: 'user:missing', + findTarget: () => null, + hasMoreMessages: () => true, + loadOlderPreservingScroll + }) + + expect(target).toBeNull() + expect(loadOlderPreservingScroll).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 0494bb18..8c4d1dbe 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -36,6 +36,13 @@ type ScrollIntent = { isScrollingUp: boolean } +type LocateOutlineTargetOptions = { + targetMessageId: string + findTarget: (anchorId: string) => HTMLElement | null + hasMoreMessages: () => boolean + loadOlderPreservingScroll: () => Promise +} + export function getScrollIntent(params: { scrollTop: number scrollHeight: number @@ -82,6 +89,19 @@ export function restoreScrollAnchor(viewport: HTMLElement, anchor: ScrollAnchor) return true } +export async function locateOutlineTargetMessage(options: LocateOutlineTargetOptions): Promise { + const anchorId = getConversationMessageAnchorId(options.targetMessageId) + let target = options.findTarget(anchorId) + while (!target && options.hasMoreMessages()) { + const loaded = await options.loadOlderPreservingScroll() + if (!loaded) { + break + } + target = options.findTarget(anchorId) + } + return target +} + function NewMessagesIndicator(props: { count: number; onClick: () => void }) { const { t } = useTranslation() if (props.count === 0) { @@ -254,8 +274,12 @@ export function HappyThread(props: { const isLoadingMoreRef = useRef(props.isLoadingMoreMessages) const hasMoreMessagesRef = useRef(props.hasMoreMessages) const isLoadingMessagesRef = useRef(props.isLoadingMessages) + const messagesVersionRef = useRef(props.messagesVersion) const onLoadMoreRef = useRef(props.onLoadMore) const handleLoadMoreRef = useRef<() => void>(() => {}) + const pendingLoadPromiseRef = useRef | null>(null) + const pendingLoadResolveRef = useRef<((value: boolean) => void) | null>(null) + const pendingLoadBaselineRef = useRef<{ messagesVersion: number; hasMoreMessages: boolean } | null>(null) const atBottomRef = useRef(true) const onAtBottomChangeRef = useRef(props.onAtBottomChange) const onFlushPendingRef = useRef(props.onFlushPending) @@ -280,6 +304,9 @@ export function HappyThread(props: { useEffect(() => { isLoadingMessagesRef.current = props.isLoadingMessages }, [props.isLoadingMessages]) + useEffect(() => { + messagesVersionRef.current = props.messagesVersion + }, [props.messagesVersion]) useEffect(() => { onLoadMoreRef.current = props.onLoadMore }, [props.onLoadMore]) @@ -299,6 +326,25 @@ export function HappyThread(props: { initialScrollTimersRef.current = [] }, []) + const settlePendingLoad = useCallback((result: boolean) => { + const resolve = pendingLoadResolveRef.current + const baseline = pendingLoadBaselineRef.current + pendingLoadResolveRef.current = null + pendingLoadPromiseRef.current = null + pendingLoadBaselineRef.current = null + if (!resolve) { + return + } + if (!result || !baseline) { + resolve(result) + return + } + resolve( + messagesVersionRef.current !== baseline.messagesVersion + || hasMoreMessagesRef.current !== baseline.hasMoreMessages + ) + }, []) + // Track scroll position to toggle autoScroll (stable listener using refs) useEffect(() => { const viewport = viewportRef.current @@ -393,10 +439,14 @@ export function HappyThread(props: { atBottomRef.current = true onAtBottomChangeRef.current(true) forceScrollTokenRef.current = props.forceScrollToken + pendingScrollRef.current = null + loadLockRef.current = false + loadStartedRef.current = false initialScrollSessionRef.current = null initialScrollDeadlineRef.current = 0 clearInitialScrollTimers() - }, [props.sessionId, clearInitialScrollTimers]) + settlePendingLoad(false) + }, [props.sessionId, clearInitialScrollTimers, settlePendingLoad]) useLayoutEffect(() => { if ( @@ -438,8 +488,9 @@ export function HappyThread(props: { useEffect(() => { return () => { clearInitialScrollTimers() + settlePendingLoad(false) } - }, [clearInitialScrollTimers]) + }, [clearInitialScrollTimers, settlePendingLoad]) useEffect(() => { if (forceScrollTokenRef.current === props.forceScrollToken) { @@ -449,7 +500,10 @@ export function HappyThread(props: { scrollToBottom() }, [props.forceScrollToken, scrollToBottom]) - const handleLoadMore = useCallback(() => { + const loadOlderPreservingScroll = useCallback((): Promise => { + if (pendingLoadPromiseRef.current) { + return pendingLoadPromiseRef.current + } if ( isInitialScrollSettling() || isLoadingMessagesRef.current @@ -457,11 +511,11 @@ export function HappyThread(props: { || isLoadingMoreRef.current || loadLockRef.current ) { - return + return Promise.resolve(false) } const viewport = viewportRef.current if (!viewport) { - return + return Promise.resolve(false) } pendingScrollRef.current = { anchor: captureScrollAnchor(viewport), @@ -471,39 +525,58 @@ export function HappyThread(props: { autoScrollEnabledRef.current = false loadLockRef.current = true loadStartedRef.current = false - let loadPromise: Promise + pendingLoadBaselineRef.current = { + messagesVersion: messagesVersionRef.current, + hasMoreMessages: hasMoreMessagesRef.current + } + const loadPromise = new Promise((resolve) => { + pendingLoadResolveRef.current = resolve + }) + pendingLoadPromiseRef.current = loadPromise try { - loadPromise = onLoadMoreRef.current() + void onLoadMoreRef.current().catch((error) => { + pendingScrollRef.current = null + loadLockRef.current = false + settlePendingLoad(false) + console.error('Failed to load older messages:', error) + }).finally(() => { + if (!loadStartedRef.current && !isLoadingMoreRef.current) { + if (pendingScrollRef.current) { + pendingScrollRef.current = null + loadLockRef.current = false + } + settlePendingLoad(true) + } + }) } catch (error) { pendingScrollRef.current = null loadLockRef.current = false - throw error - } - void loadPromise.catch((error) => { - pendingScrollRef.current = null - loadLockRef.current = false + settlePendingLoad(false) console.error('Failed to load older messages:', error) - }).finally(() => { - if (!loadStartedRef.current && !isLoadingMoreRef.current && pendingScrollRef.current) { - pendingScrollRef.current = null - loadLockRef.current = false - } - }) - }, [isInitialScrollSettling]) + } + return loadPromise + }, [isInitialScrollSettling, settlePendingLoad]) - const handleOutlineSelect = useCallback((item: ConversationOutlineItem) => { - const target = document.getElementById(getConversationMessageAnchorId(item.targetMessageId)) + const handleOutlineSelect = useCallback(async (item: ConversationOutlineItem) => { + const target = await locateOutlineTargetMessage({ + targetMessageId: item.targetMessageId, + findTarget: (anchorId) => document.getElementById(anchorId), + hasMoreMessages: () => hasMoreMessagesRef.current, + loadOlderPreservingScroll + }) if (target) { target.scrollIntoView({ block: 'start', behavior: 'smooth' }) autoScrollEnabledRef.current = false } props.onOutlineItemClick?.(item) props.onOutlineOpenChange(false) - }, [props.onOutlineItemClick, props.onOutlineOpenChange]) + }, [loadOlderPreservingScroll, props.onOutlineItemClick, props.onOutlineOpenChange]) useEffect(() => { - handleLoadMoreRef.current = handleLoadMore - }, [handleLoadMore]) + handleLoadMoreRef.current = () => { + void loadOlderPreservingScroll() + } + }, [loadOlderPreservingScroll]) useEffect(() => { const sentinel = topSentinelRef.current @@ -573,24 +646,28 @@ export function HappyThread(props: { lastScrollTopRef.current = viewport.scrollTop pendingScrollRef.current = null loadLockRef.current = false + settlePendingLoad(true) return } if (atBottomRef.current && autoScrollEnabledRef.current) { scrollToBottomInstant() } - }, [props.messagesVersion, scrollToBottomInstant]) + }, [props.messagesVersion, scrollToBottomInstant, settlePendingLoad]) useEffect(() => { isLoadingMoreRef.current = props.isLoadingMoreMessages if (props.isLoadingMoreMessages) { loadStartedRef.current = true } - if (prevLoadingMoreRef.current && !props.isLoadingMoreMessages && pendingScrollRef.current) { - pendingScrollRef.current = null - loadLockRef.current = false + if (prevLoadingMoreRef.current && !props.isLoadingMoreMessages) { + if (pendingScrollRef.current) { + pendingScrollRef.current = null + loadLockRef.current = false + } + settlePendingLoad(true) } prevLoadingMoreRef.current = props.isLoadingMoreMessages - }, [props.isLoadingMoreMessages]) + }, [props.isLoadingMoreMessages, settlePendingLoad]) const showSkeleton = props.isLoadingMessages && props.rawMessagesCount === 0 && props.pendingCount === 0 @@ -630,7 +707,9 @@ export function HappyThread(props: {