diff --git a/.gitignore b/.gitignore index 169c09d5..2143926b 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,4 @@ temp/ exp/ .playwright-mcp/ bridge/node_modules/ +webui/.verify-* diff --git a/webui/src/components/MarkdownText.tsx b/webui/src/components/MarkdownText.tsx index fb4322b9..66e92e8c 100644 --- a/webui/src/components/MarkdownText.tsx +++ b/webui/src/components/MarkdownText.tsx @@ -13,6 +13,7 @@ interface MarkdownTextProps { children: string; className?: string; streaming?: boolean; + preserveStreamingLayout?: boolean; onOpenFilePreview?: (path: string) => void; } @@ -74,11 +75,13 @@ export function MarkdownText({ children, className, streaming = false, + preserveStreamingLayout = false, onOpenFilePreview, }: MarkdownTextProps) { const renderedSource = children; const renderPhase = streaming ? "streaming" : "complete"; const highlightCode = !streaming; + const renderWithStreamingLayout = streaming || preserveStreamingLayout; useEffect(() => { if (streaming) void preloadMarkdownText(); @@ -103,7 +106,7 @@ export function MarkdownText({ source={renderedSource} className={className} highlightCode={highlightCode} - streaming={streaming} + streaming={renderWithStreamingLayout} onOpenFilePreview={onOpenFilePreview} /> diff --git a/webui/src/components/MarkdownTextRenderer.tsx b/webui/src/components/MarkdownTextRenderer.tsx index 7e6ce5e5..f43c4207 100644 --- a/webui/src/components/MarkdownTextRenderer.tsx +++ b/webui/src/components/MarkdownTextRenderer.tsx @@ -248,13 +248,6 @@ const rehypePlugins: NonNullable = [rehypeKate const DIRECT_LINKS = { enabled: false } as const; const SAFE_MARKDOWN_PROTOCOL = /^(https?|ircs?|mailto|xmpp)$/i; -const STREAMING_ANIMATION = { - animation: "fadeIn", - duration: 180, - easing: "cubic-bezier(0.16, 1, 0.3, 1)", - sep: "word", - stagger: 18, -} as const; /** Preserve react-markdown's URL policy when rendering through Streamdown. */ const safeMarkdownUrl: NonNullable = (url) => { @@ -727,9 +720,8 @@ export default function MarkdownTextRenderer({ 0 + && (!empty || hasReasoning || media.length > 0); + const completedAtTitle = showCompletedAt ? fmtDateTime(completedAt) : ""; + const showAssistantFooterRow = showCopyButton || showForkButton || showCompletedAt; + const showAssistantFooterSlot = message.role === "assistant" - && latencyMs != null - && !message.isStreaming && (!empty || hasReasoning || media.length > 0); - const showAssistantFooterRow = showCopyButton || showForkButton || showLatencyFooter; return (
{hasReasoning ? ( @@ -266,52 +272,67 @@ export function MessageBubble({ /> ) : null}
+ {/* A mode switch rebuilds Streamdown's subtree and moves the scroll anchor. */} {message.content}
{media.length > 0 ? : null} - {showAssistantFooterRow ? ( - -
- {showCopyButton ? ( - - ) : null} - {showForkButton ? ( - - - - - {forkLabel} - - ) : null} - {showLatencyFooter ? ( - - {formatTurnLatency(latencyMs)} - - ) : null} -
-
- ) : null} )} + {showAssistantFooterSlot ? ( + +
+ {showCopyButton ? ( + + ) : null} + {showForkButton ? ( + + + + + {forkLabel} + + ) : null} + {showCompletedAt ? ( + + ) : null} +
+
+ ) : null}
); } diff --git a/webui/src/components/thread/PromptRail.tsx b/webui/src/components/thread/PromptRail.tsx index 9b53a141..3fc6c901 100644 --- a/webui/src/components/thread/PromptRail.tsx +++ b/webui/src/components/thread/PromptRail.tsx @@ -4,7 +4,6 @@ import { cn } from "@/lib/utils"; import type { UIMessage } from "@/lib/types"; import { findPromptElement, - jumpToPrompt, type PromptAnchor, promptTop, userPromptAnchors, @@ -13,6 +12,7 @@ import { interface PromptRailProps { bottomOffset: number; messages: UIMessage[]; + onJumpToPrompt: (promptId: string) => void; scrollRef: RefObject; } @@ -46,6 +46,7 @@ const HOVER_MARKER_WIDTHS_PX = [28, 22, 16, 11]; export function PromptRail({ bottomOffset, messages, + onJumpToPrompt, scrollRef, }: PromptRailProps) { const railRef = useRef(null); @@ -159,7 +160,7 @@ export function PromptRail({ key={marker.ids.join("|")} type="button" aria-label={`Jump to prompt: ${marker.label}`} - onClick={() => jumpToPrompt(scrollRef.current, marker.ids[marker.ids.length - 1])} + onClick={() => onJumpToPrompt(marker.ids[marker.ids.length - 1])} onBlur={() => setFocusedMarkerIndex(null)} onFocus={() => setFocusedMarkerIndex(index)} onPointerEnter={() => setFocusedMarkerIndex(index)} diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 86f2e0da..1cbcb1e3 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -584,8 +584,6 @@ function RunElapsedStrip({ const stripLabel = goalStateStripPreview(goalState, t); const showGoal = !!stripLabel?.trim(); const active = showTimer || showGoal; - const [renderStrip, setRenderStrip] = useState(active); - const [leaving, setLeaving] = useState(false); const [, setTick] = useState(0); const stripWrapperRef = useRef(null); const panelRef = useRef(null); @@ -602,20 +600,8 @@ function RunElapsedStrip({ } useEffect(() => { - if (active) { - setRenderStrip(true); - setLeaving(false); - return; - } - setGoalPanelOpen(false); - if (!renderStrip) return; - setLeaving(true); - const id = window.setTimeout(() => { - setRenderStrip(false); - setLeaving(false); - }, 180); - return () => window.clearTimeout(id); - }, [active, renderStrip]); + if (!active) setGoalPanelOpen(false); + }, [active]); useEffect(() => { if (startedAt == null || !pageVisible) return; @@ -699,8 +685,6 @@ function RunElapsedStrip({ }; }, [goalPanelOpen]); - if (!renderStrip || !display) return null; - const elapsed = displayStartedAt != null ? Math.max(0, Math.floor(Date.now() / 1000 - displayStartedAt)) : 0; const m = Math.floor(elapsed / 60); @@ -716,8 +700,10 @@ function RunElapsedStrip({ return (
{goalPanelOpen && canExpandGoal && markdownBody ? (
) : null} -
- {displayShowTimer ? ( - - ) : ( - - )} - - {timerTitle ? {timerTitle} : null} - {timerTitle && displayShowGoal ? ( - - · - - ) : null} - {displayShowGoal ? ( - - {t("thread.composer.goalStateStrip", { label: displayStripLabel })} - - ) : null} - - {canExpandGoal ? ( - + + {timerTitle ? {timerTitle} : null} + {timerTitle && displayShowGoal ? ( + + · + + ) : null} + {displayShowGoal ? ( + + {t("thread.composer.goalStateStrip", { label: displayStripLabel })} + + ) : null} + + {canExpandGoal ? ( + + ) : null} +
) : null}
diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx index 4fbb2248..ad7cd18c 100644 --- a/webui/src/components/thread/ThreadMessages.tsx +++ b/webui/src/components/thread/ThreadMessages.tsx @@ -92,7 +92,13 @@ export function ThreadMessages({ unit.type === "activity" && next?.type === "message" && next.message.role === "assistant"; - + const deferOffscreenRender = + index < units.length - 1 + && ( + unit.type === "activity" + ? !liveActivityClusterIndices.has(index) + : unit.message.role === "assistant" && !unit.message.isStreaming + ); const userPromptId = unit.type === "message" && unit.message.role === "user" ? unit.message.id @@ -110,6 +116,7 @@ export function ThreadMessages({ marginTop={marginTop} userPromptId={userPromptId} hasBodyBelow={hasBodyBelow} + deferOffscreenRender={deferOffscreenRender} isTurnStreaming={liveActivityClusterIndices.has(index)} forkIndex={forkIndex} showForkBoundary={index === forkBoundaryAfterUnitIndex} @@ -131,6 +138,7 @@ interface ThreadDisplayUnitProps { marginTop: string; userPromptId?: string; hasBodyBelow: boolean; + deferOffscreenRender: boolean; isTurnStreaming: boolean; forkIndex?: number; showForkBoundary: boolean; @@ -147,6 +155,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({ marginTop, userPromptId, hasBodyBelow, + deferOffscreenRender, isTurnStreaming, forkIndex, showForkBoundary, @@ -157,17 +166,20 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({ onOpenFilePreview, onForkFromMessage, }: ThreadDisplayUnitProps) { + // Introducing content-visibility after a unit has painted can move the + // browser's scroll anchor. Only units deferred on their first render may + // remain deferred. + const hasRenderedEagerlyRef = useRef(!deferOffscreenRender); + if (!deferOffscreenRender) hasRenderedEagerlyRef.current = true; + const stableDeferOffscreenRender = + deferOffscreenRender && !hasRenderedEagerlyRef.current; const onForkFromHere = useCallback(() => { if (forkIndex !== undefined) onForkFromMessage?.(forkIndex); }, [forkIndex, onForkFromMessage]); - const deferOffscreenRender = unit.type === "activity" - ? !isTurnStreaming - : unit.message.role === "assistant" && !unit.message.isStreaming; - return ( <>
{unit.type === "activity" ? ( @@ -206,6 +218,7 @@ function threadDisplayUnitPropsEqual( && previous.marginTop === next.marginTop && previous.userPromptId === next.userPromptId && previous.hasBodyBelow === next.hasBodyBelow + && previous.deferOffscreenRender === next.deferOffscreenRender && previous.isTurnStreaming === next.isTurnStreaming && previous.forkIndex === next.forkIndex && previous.showForkBoundary === next.showForkBoundary diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 91cff38b..a889a88b 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -102,6 +102,18 @@ function isStaleThreadSnapshot(current: UIMessage[], snapshot: UIMessage[]): boo return snapshot.every((message, index) => sameMessageShape(current[index], message)); } +function latestActiveTurnId(messages: UIMessage[]): string | null { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.isStreaming && message.turnId) return message.turnId; + } + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.role === "user" && message.turnId) return message.turnId; + } + return null; +} + const FILE_PREVIEW_DEFAULT_WIDTH = 544; const FILE_PREVIEW_MIN_WIDTH = 360; const FILE_PREVIEW_MAX_WIDTH = 860; @@ -444,8 +456,7 @@ export function ThreadShell({ }); const [settings, setSettings] = useState(settingsSnapshot); const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey); - const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0); - const [scrollToLatestUserPromptSignal, setScrollToLatestUserPromptSignal] = useState(0); + const [submittedViewportTurnId, setSubmittedViewportTurnId] = useState(null); const [filePreviewPath, setFilePreviewPath] = useState(null); const [filePreviewClosing, setFilePreviewClosing] = useState(false); const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH); @@ -457,6 +468,7 @@ export function ThreadShell({ const pendingFirstRef = useRef(null); const [pendingFirstTargetChatId, setPendingFirstTargetChatId] = useState(null); const viewportRef = useRef(null); + const activeViewportTurnByChatIdRef = useRef>(new Map()); const messageCacheRef = useRef>(new Map()); /** Last chatId we associated with the in-memory thread (for cache-on-switch). */ const prevChatIdForCacheRef = useRef(null); @@ -465,18 +477,20 @@ export function ThreadShell({ const appliedHistoryVersionRef = useRef>(new Map()); const pendingCanonicalHydrateRef = useRef>(new Set()); const sessionKeyByChatIdRef = useRef>(new Map()); - const bottomScrolledChatIdRef = useRef(null); const initial = useMemo(() => { if (!chatId) return historical; return messageCacheRef.current.get(chatId) ?? historical; }, [chatId, historical]); const handleTurnEnd = useCallback(() => { + if (chatId) activeViewportTurnByChatIdRef.current.delete(chatId); + setSubmittedViewportTurnId(null); setFallbackModelName(null); onTurnEnd?.(); - }, [onTurnEnd]); + }, [chatId, onTurnEnd]); const { messages, + messagesReady, isStreaming, runStartedAt, goalState, @@ -504,6 +518,7 @@ export function ThreadShell({ setFilePreviewClosing(false); setFilePreviewPath(null); setQuotedContext(null); + setSubmittedViewportTurnId(null); }, [historyKey]); const handleQuoteSelection = useCallback((text: string) => { @@ -520,6 +535,28 @@ export function ThreadShell({ }, []); const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]); + const currentRunStartedAt = messagesReady ? runStartedAt : null; + const currentGoalState = messagesReady ? goalState : undefined; + const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null); + const restoredViewportTurnId = useMemo( + () => turnActive ? latestActiveTurnId(displayMessages) : null, + [displayMessages, turnActive], + ); + const rememberedViewportTurnId = chatId + ? activeViewportTurnByChatIdRef.current.get(chatId) ?? null + : null; + const viewportTurnId = messagesReady && turnActive + ? rememberedViewportTurnId ?? restoredViewportTurnId + : null; + const activeTurnStartedHere = + viewportTurnId !== null && viewportTurnId === submittedViewportTurnId; + useEffect(() => { + if (!chatId || !messagesReady || turnActive) return; + activeViewportTurnByChatIdRef.current.delete(chatId); + setSubmittedViewportTurnId((current) => + current === rememberedViewportTurnId ? null : current, + ); + }, [chatId, messagesReady, rememberedViewportTurnId, turnActive]); const filePreviewAvailabilityCache = useMemo( () => new Map(), [historyKey, token], @@ -695,22 +732,14 @@ export function ThreadShell({ return client.onSessionUpdate((updatedChatId, scope) => { if (updatedChatId !== chatId) return; if (scope === "metadata") return; - viewportRef.current?.cancelAutoScroll(); + // A turn-end thread refresh can arrive while the viewport is easing the + // final layout change. User-driven scrolling already disables following, + // so keep an active programmatic follow alive across canonical hydration. pendingCanonicalHydrateRef.current.add(chatId); refreshHistory(); }); }, [chatId, client, refreshHistory]); - useEffect(() => { - if (!chatId) { - bottomScrolledChatIdRef.current = null; - return; - } - if (loading || bottomScrolledChatIdRef.current === chatId) return; - bottomScrolledChatIdRef.current = chatId; - setScrollToBottomSignal((value) => value + 1); - }, [chatId, loading]); - useEffect(() => { if (chatId) return; setMessages(projectWebuiThreadMessages(historical)); @@ -765,8 +794,11 @@ export function ThreadShell({ } pendingFirstRef.current = null; setPendingFirstTargetChatId(null); - setScrollToLatestUserPromptSignal((value) => value + 1); - send(pending.content, pending.images, pending.options); + const submitted = send(pending.content, pending.images, pending.options); + if (submitted && !submitted.sideChannel) { + activeViewportTurnByChatIdRef.current.set(chatId, submitted.turnId); + setSubmittedViewportTurnId(submitted.turnId); + } setBooting(false); }, [chatId, pendingFirstTargetChatId, send]); @@ -809,10 +841,13 @@ export function ThreadShell({ const handleThreadSend = useCallback( (content: string, images?: SendAttachment[], options?: SendOptions) => { setFallbackModelName(null); - setScrollToLatestUserPromptSignal((value) => value + 1); - send(content, images, withWorkspaceScope(options)); + const submitted = send(content, images, withWorkspaceScope(options)); + if (chatId && submitted && !submitted.sideChannel) { + activeViewportTurnByChatIdRef.current.set(chatId, submitted.turnId); + setSubmittedViewportTurnId(submitted.turnId); + } }, - [send, withWorkspaceScope], + [chatId, send, withWorkspaceScope], ); const handleOpenFilePreview = useCallback((path: string) => { @@ -927,7 +962,7 @@ export function ThreadShell({ > +> = { + ArrowUp: "backward", + PageUp: "backward", + Home: "backward", + ArrowDown: "forward", + PageDown: "forward", + End: "forward", +}; + +function directionFromDelta(deltaY: number): ThreadScrollDirection | null { + return deltaY < 0 ? "backward" : deltaY > 0 ? "forward" : null; +} + +function keyboardScrollDirection( + event: KeyboardEvent, +): ThreadScrollDirection | null { + if (event.key === " ") { + return event.shiftKey ? "backward" : "forward"; + } + return KEYBOARD_SCROLL_DIRECTIONS[event.key] ?? null; +} + +function canScrollInDirection( + element: HTMLElement, + direction: ThreadScrollDirection | null, +): boolean { + switch (direction) { + case "backward": + return element.scrollTop > 0; + case "forward": + return ( + element.scrollTop + < Math.max(0, element.scrollHeight - element.clientHeight) + ); + default: + return false; + } +} + function readSoftKeyboardInsetBottom(container: HTMLElement | null): number { const viewport = window.visualViewport; if (!viewport) return 0; @@ -108,8 +156,10 @@ export const ThreadViewport = forwardRef(null); const contentRef = useRef(null); + const messageRegionRef = useRef(null); + const messageContentRef = useRef(null); const composerDockRef = useRef(null); const bottomRef = useRef(null); const lastConversationKeyRef = useRef(conversationKey); const pendingConversationScrollRef = useRef(true); const pendingPromptJumpRef = useRef(null); - const scrollFrameIdsRef = useRef([]); - const programmaticPromptScrollTopRef = useRef(null); - const handledLatestPromptSignalRef = useRef(0); - const activeTurnPromptRef = useRef(null); const restoreScrollAfterPrependRef = useRef<{ height: number; top: number } | null>(null); - /** User scrolled away from the bottom; do not auto-yank until they return or we reset (new chat / send). */ - const userReadingHistoryRef = useRef(false); + const composerDockHeightRef = useRef(0); const [atBottom, setAtBottom] = useState(true); const [composerDockHeight, setComposerDockHeight] = useState(0); const [keyboardInsetBottom, setKeyboardInsetBottom] = useState(0); const [hasVerticalOverflow, setHasVerticalOverflow] = useState(false); const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_HISTORY_WINDOW); + const threadMotionRef = useRef(null); + if (threadMotionRef.current === null) { + const camera = new ThreadCameraController(() => scrollRef.current); + threadMotionRef.current = new ThreadMotionCoordinator({ + camera, + measure: (promptId) => { + const scrollEl = scrollRef.current; + const composerDock = composerDockRef.current; + if (!scrollEl) return null; + const composerHeight = composerDock + ? composerDock.getBoundingClientRect().height || composerDock.offsetHeight + : 0; + const prompt = promptId ? findPromptElement(scrollEl, promptId) : null; + const scrollHeight = scrollEl.scrollHeight; + const clientHeight = scrollEl.clientHeight; + const maxScrollTop = Math.max(0, scrollHeight - clientHeight); + return { + scrollTop: scrollEl.scrollTop, + scrollHeight, + clientHeight, + maxScrollTop, + composerHeight, + promptTop: prompt + ? Math.min( + maxScrollTop, + Math.max(0, promptTop(scrollEl, prompt) - 16), + ) + : null, + }; + }, + onGeometry: (geometry: ThreadMotionGeometry) => { + if (Math.abs(composerDockHeightRef.current - geometry.composerHeight) >= 1) { + composerDockHeightRef.current = geometry.composerHeight; + setComposerDockHeight(geometry.composerHeight); + } + const nextOverflow = geometry.scrollHeight > geometry.clientHeight + 1; + setHasVerticalOverflow((current) => + current === nextOverflow ? current : nextOverflow, + ); + }, + onAutoFollow: () => setAtBottom(true), + }); + } const hasMessages = messages.length > 0; const visibleMessages = useMemo( () => windowMessages(messages, visibleMessageCount), @@ -168,15 +258,8 @@ export const ThreadViewport = forwardRef 0 ? { bottom: keyboardInsetBottom } : undefined; - const cancelScheduledBottomScroll = useCallback(() => { - for (const id of scrollFrameIdsRef.current) { - window.cancelAnimationFrame(id); - } - scrollFrameIdsRef.current = []; - }, []); - - const markProgrammaticPromptScroll = useCallback((top: number) => { - programmaticPromptScrollTopRef.current = top; + const yieldCameraToUser = useCallback(() => { + threadMotionRef.current?.takeUserControl(); }, []); const scrollToBottomNow = useCallback((smooth = false) => { @@ -185,68 +268,25 @@ export const ThreadViewport = forwardRef { - const el = scrollRef.current; - if (!el) return false; - const target = findPromptElement(el, promptId); - if (!target) return false; - const top = Math.max(0, promptTop(el, target) - 16); - markProgrammaticPromptScroll(top); - try { - el.scrollTo?.({ top, behavior: "auto" }); - el.scrollTop = top; - } catch { - try { - el.scrollTop = top; - } catch { - // Test DOMs can expose read-only scrollTop; browsers keep this writable. - } - } - const near = el.scrollHeight - top - el.clientHeight < NEAR_BOTTOM_PX; - userReadingHistoryRef.current = false; - setAtBottom(near); - return true; - }, [markProgrammaticPromptScroll]); - const scrollToBottom = useCallback( - (smooth = false, frames = 1, options?: { force?: boolean }) => { + (smooth = false, options?: { force?: boolean }) => { const force = options?.force ?? false; - cancelScheduledBottomScroll(); - const run = () => { - if (!force && userReadingHistoryRef.current) return; - scrollToBottomNow(smooth); - }; - const scheduleNext = (remainingFrames: number) => { - if (remainingFrames <= 0) return; - const id = window.requestAnimationFrame(() => { - scrollFrameIdsRef.current = scrollFrameIdsRef.current.filter((frameId) => frameId !== id); - if (!force && userReadingHistoryRef.current) return; - scrollToBottomNow(smooth); - scheduleNext(remainingFrames - 1); - }); - scrollFrameIdsRef.current.push(id); - }; - run(); - scheduleNext(frames - 1); + if (!force && threadMotionRef.current?.isAutoFollowPaused()) return; + threadMotionRef.current?.resumeAutoFollow(); + scrollToBottomNow(smooth); }, - [cancelScheduledBottomScroll, scrollToBottomNow], + [scrollToBottomNow], ); const loadEarlierMessages = useCallback(() => { @@ -257,8 +297,7 @@ export const ThreadViewport = forwardRef 0) { setVisibleMessageCount((count) => @@ -275,58 +314,68 @@ export const ThreadViewport = forwardRef { const el = scrollRef.current; if (!el || !hasMessages || pendingConversationScrollRef.current) return; - if (!userReadingHistoryRef.current) return; + if (!threadMotionRef.current?.isBrowsingHistory()) return; if (el.scrollTop > NEAR_TOP_PX) return; if (hiddenMessageCount <= 0 && !hasMoreBefore) return; loadEarlierMessages(); }, [hasMessages, hasMoreBefore, hiddenMessageCount, loadEarlierMessages]); - const jumpToUserPrompt = useCallback((promptId: string) => { + const navigateToVisiblePrompt = useCallback((promptId: string) => { const scrollEl = scrollRef.current; - if (scrollEl && findPromptElement(scrollEl, promptId)) { - jumpToPrompt(scrollEl, promptId); - return; - } + const prompt = scrollEl ? findPromptElement(scrollEl, promptId) : null; + if (!scrollEl || !prompt) return false; + setAtBottom(false); + const maxScrollTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight); + threadMotionRef.current?.navigateHistoryTo( + Math.min( + maxScrollTop, + Math.max(0, promptTop(scrollEl, prompt) - 16), + ), + ); + return true; + }, []); + + const jumpToUserPrompt = useCallback((promptId: string) => { + if (navigateToVisiblePrompt(promptId)) return; const index = messages.findIndex((message) => message.id === promptId); if (index < 0) return; + threadMotionRef.current?.takeUserControl(); pendingPromptJumpRef.current = promptId; - userReadingHistoryRef.current = true; - activeTurnPromptRef.current = null; setAtBottom(false); setVisibleMessageCount((count) => Math.max(count, messages.length - index)); - }, [messages]); + }, [messages, navigateToVisiblePrompt]); + + const cancelAutoScroll = useCallback(() => { + threadMotionRef.current?.takeUserControl(); + }, []); useImperativeHandle( ref, () => ({ jumpToUserPrompt, - cancelAutoScroll: cancelScheduledBottomScroll, + cancelAutoScroll, }), - [cancelScheduledBottomScroll, jumpToUserPrompt], + [cancelAutoScroll, jumpToUserPrompt], ); - const measureComposerDock = useCallback(() => { - const el = composerDockRef.current; - if (!el) return; - const height = el.getBoundingClientRect().height || el.offsetHeight; - setComposerDockHeight((current) => - Math.abs(current - height) < 1 ? current : height, - ); - }, []); - useLayoutEffect(() => { const updateKeyboardInset = () => { const scrollEl = scrollRef.current; const next = readSoftKeyboardInsetBottom(scrollEl); const active = document.activeElement; const composerFocused = - hasMessages && isKeyboardEditableElement(active) && Boolean(scrollEl?.contains(active)); + hasMessages + && isKeyboardEditableElement(active) + && Boolean(scrollEl?.contains(active)); setKeyboardInsetBottom((current) => Math.abs(current - next) < 1 ? current : next, ); if (composerFocused) { - userReadingHistoryRef.current = false; - scrollToBottom(false, KEYBOARD_SCROLL_FRAMES, { force: true }); + // Focusing the composer establishes a new reference frame at the + // latest message. This is one immediate positioning command; viewport + // events may issue a fresh command, but no command survives into a + // later render as a train of retry frames. + scrollToBottom(false, { force: true }); } }; updateKeyboardInset(); @@ -345,76 +394,62 @@ export const ThreadViewport = forwardRef { - if (keyboardInsetBottom > 0) { - userReadingHistoryRef.current = false; - scrollToBottom(false, KEYBOARD_SCROLL_FRAMES, { force: true }); - return; - } - if (userReadingHistoryRef.current) return; - scrollToBottom(false, 4); - }, [keyboardInsetBottom, scrollToBottom]); - - useEffect(() => { - const scrollEl = scrollRef.current; - if (!scrollEl) return; - - const onComposerFocus = () => { - const active = document.activeElement; - if (!hasMessages || !isKeyboardEditableElement(active) || !scrollEl.contains(active)) return; - userReadingHistoryRef.current = false; - scrollToBottom(false, KEYBOARD_SCROLL_FRAMES, { force: true }); - }; - - document.addEventListener("focusin", onComposerFocus); - return () => document.removeEventListener("focusin", onComposerFocus); - }, [hasMessages, scrollToBottom]); - useEffect(() => { if (scrollToBottomSignal <= 0) return; - userReadingHistoryRef.current = false; - scrollToBottom(false, 8); + scrollToBottom(false, { force: true }); }, [scrollToBottomSignal, scrollToBottom]); - useLayoutEffect(() => { - if (scrollToLatestUserPromptSignal <= handledLatestPromptSignalRef.current) return; - const latest = messages[messages.length - 1]; - if (!latest || latest.role !== "user") return; - handledLatestPromptSignalRef.current = scrollToLatestUserPromptSignal; - cancelScheduledBottomScroll(); - activeTurnPromptRef.current = latest.id; - if (!scrollToPromptTopNow(latest.id)) activeTurnPromptRef.current = null; - }, [ - cancelScheduledBottomScroll, - messages, - scrollToLatestUserPromptSignal, - scrollToPromptTopNow, - ]); - useLayoutEffect(() => { if (lastConversationKeyRef.current === conversationKey) return; lastConversationKeyRef.current = conversationKey; pendingConversationScrollRef.current = true; - userReadingHistoryRef.current = false; - activeTurnPromptRef.current = null; + threadMotionRef.current?.reset(); setAtBottom(true); setVisibleMessageCount(INITIAL_HISTORY_WINDOW); }, [conversationKey]); useLayoutEffect(() => { - const promptId = activeTurnPromptRef.current; - if (!promptId || userReadingHistoryRef.current) return; - const promptIndex = messages.findIndex((message) => message.id === promptId); - if (promptIndex < 0) { - activeTurnPromptRef.current = null; + if (!conversationReady) { + threadMotionRef.current?.reset(); return; } - const hasAgentOutput = messages - .slice(promptIndex + 1) - .some((message) => message.role !== "user"); - if (!hasAgentOutput) return; - scrollToBottom(false, isStreaming ? 3 : 1); - }, [isStreaming, messages, scrollToBottom]); + if (!activeTurnId) { + threadMotionRef.current?.completeTurn(); + return; + } + + const promptIndex = messages.findIndex( + (message) => message.role === "user" && message.turnId === activeTurnId, + ); + if ( + activeTurnStartedHere + && promptIndex >= 0 + && threadMotionRef.current?.snapshot().turnId !== activeTurnId + ) { + // A turn submitted in this mounted viewport establishes a new prompt + // origin. A restored turn must first complete open-at-bottom instead. + pendingConversationScrollRef.current = false; + } + const prompt = promptIndex >= 0 ? messages[promptIndex] : null; + const hasOutput = promptIndex >= 0 + ? messages + .slice(promptIndex + 1) + .some( + (message) => + message.role !== "user" + && (!message.turnId || message.turnId === activeTurnId), + ) + : !activeTurnStartedHere && messages.some( + (message) => + message.role !== "user" && message.turnId === activeTurnId, + ); + threadMotionRef.current?.updateTurn({ + id: activeTurnId, + promptId: prompt?.id ?? null, + hasOutput, + entry: activeTurnStartedHere ? "submitted" : "restored", + }); + }, [activeTurnId, activeTurnStartedHere, conversationReady, messages]); useLayoutEffect(() => { const pending = restoreScrollAfterPrependRef.current; @@ -423,16 +458,11 @@ export const ThreadViewport = forwardRef { @@ -440,65 +470,61 @@ export const ThreadViewport = forwardRef jumpToPrompt(scrollEl, promptId)); + const frame = window.requestAnimationFrame(() => navigateToVisiblePrompt(promptId)); return () => window.cancelAnimationFrame(frame); - }, [visibleMessages.length]); + }, [navigateToVisiblePrompt, visibleMessages.length]); useLayoutEffect(() => { if (!pendingConversationScrollRef.current) return; + if (!conversationReady) return; if (!conversationKey) { pendingConversationScrollRef.current = false; - scrollToBottom(false, 4); + scrollToBottom(false, { force: true }); return; } - scrollToBottom(false, 8); + scrollToBottom(false, { force: true }); if (!hasMessages) return; pendingConversationScrollRef.current = false; - }, [conversationKey, hasMessages, messages, scrollToBottom]); + }, [ + conversationKey, + conversationReady, + hasMessages, + messages, + scrollToBottom, + ]); useLayoutEffect(() => { - measureComposerDock(); - }, [composer, hasMessages, measureComposerDock]); + threadMotionRef.current?.invalidateGeometry(); + }, [composer, hasMessages, visibleMessages.length]); - useLayoutEffect(() => { - if (!hasMessages || userReadingHistoryRef.current) return; - const promptId = activeTurnPromptRef.current; - if (promptId && scrollToPromptTopNow(promptId)) return; - scrollToBottom(false, 2); - }, [composerDockHeight, hasMessages, scrollToBottom, scrollToPromptTopNow]); - - useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]); - - useEffect(() => { - const target = composerDockRef.current; - if (!target || typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver(() => measureComposerDock()); - observer.observe(target); - return () => observer.disconnect(); - }, [hasMessages, measureComposerDock]); - - const measureVerticalOverflow = useCallback(() => { - const el = scrollRef.current; - if (!el) return; - const next = el.scrollHeight > el.clientHeight + 1; - setHasVerticalOverflow((current) => (current === next ? current : next)); - }, []); + useEffect(() => () => threadMotionRef.current?.dispose(), []); useLayoutEffect(() => { const el = scrollRef.current; const content = contentRef.current; + const messageRegion = messageRegionRef.current; + const messageContent = messageContentRef.current; + const composerDock = composerDockRef.current; if (!el) return; - measureVerticalOverflow(); - const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measureVerticalOverflow); + const invalidateGeometry = () => { + threadMotionRef.current?.invalidateGeometry(); + }; + invalidateGeometry(); + const observer = typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(invalidateGeometry); observer?.observe(el); if (content) observer?.observe(content); - window.addEventListener("resize", measureVerticalOverflow); + if (messageRegion) observer?.observe(messageRegion); + if (messageContent) observer?.observe(messageContent); + if (composerDock) observer?.observe(composerDock); + window.addEventListener("resize", invalidateGeometry); return () => { observer?.disconnect(); - window.removeEventListener("resize", measureVerticalOverflow); + window.removeEventListener("resize", invalidateGeometry); }; - }, [composerDockHeight, hasMessages, measureVerticalOverflow, visibleMessages.length]); + }, [hasMessages]); useEffect(() => { const el = scrollRef.current; @@ -507,48 +533,114 @@ export const ThreadViewport = forwardRef { const distance = el.scrollHeight - el.scrollTop - el.clientHeight; const near = distance < NEAR_BOTTOM_PX; - const programmaticPromptTop = programmaticPromptScrollTopRef.current; - const programmatic = - programmaticPromptTop !== null && Math.abs(el.scrollTop - programmaticPromptTop) < 2; - setAtBottom((current) => current === near ? current : near); - if (programmatic) { - programmaticPromptScrollTopRef.current = null; - if (near) userReadingHistoryRef.current = false; - return; - } - programmaticPromptScrollTopRef.current = null; - userReadingHistoryRef.current = !near; - if (!near) activeTurnPromptRef.current = null; - if (allowHistoryLoad && !near) maybeLoadEarlierFromScroll(); + const owner = threadMotionRef.current?.observeScroll(near) ?? "automatic"; + const logicallyAtBottom = owner === "automatic" || near; + setAtBottom((current) => + current === logicallyAtBottom ? current : logicallyAtBottom, + ); + if (allowHistoryLoad && owner === "user") maybeLoadEarlierFromScroll(); }; onScroll(false); const handleScroll = () => onScroll(true); + const handleDirectionalInput = ( + direction: ThreadScrollDirection | null, + ) => { + if (!direction) return; + threadMotionRef.current?.handleUserScrollIntent( + canScrollInDirection(el, direction), + ); + }; + const handleWheel = (event: WheelEvent) => { + if ( + event.defaultPrevented + || event.ctrlKey + || Math.abs(event.deltaY) <= Math.abs(event.deltaX) + ) { + return; + } + handleDirectionalInput(directionFromDelta(event.deltaY)); + }; + const handlePointerDown = (event: PointerEvent) => { + if (event.button === 0 && event.target === el) yieldCameraToUser(); + }; + let touchStartY: number | null = null; + const handleTouchStart = (event: TouchEvent) => { + touchStartY = event.touches[0]?.clientY ?? null; + }; + const handleTouchMove = (event: TouchEvent) => { + const currentY = event.touches[0]?.clientY; + const scrollDeltaY = + touchStartY !== null && currentY !== undefined + ? touchStartY - currentY + : 0; + handleDirectionalInput(directionFromDelta(scrollDeltaY)); + }; + const handleTouchEnd = () => { + touchStartY = null; + }; + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.defaultPrevented + || event.altKey + || event.ctrlKey + || event.metaKey + || isKeyboardEditableElement(event.target as Element | null) + ) { + return; + } + handleDirectionalInput(keyboardScrollDirection(event)); + }; el.addEventListener("scroll", handleScroll, { passive: true }); - return () => el.removeEventListener("scroll", handleScroll); - }, [maybeLoadEarlierFromScroll]); + el.addEventListener("wheel", handleWheel, { passive: true }); + el.addEventListener("touchstart", handleTouchStart, { passive: true }); + el.addEventListener("touchmove", handleTouchMove, { passive: true }); + el.addEventListener("touchend", handleTouchEnd, { passive: true }); + el.addEventListener("touchcancel", handleTouchEnd, { passive: true }); + el.addEventListener("pointerdown", handlePointerDown); + el.addEventListener("keydown", handleKeyDown); + return () => { + el.removeEventListener("scroll", handleScroll); + el.removeEventListener("wheel", handleWheel); + el.removeEventListener("touchstart", handleTouchStart); + el.removeEventListener("touchmove", handleTouchMove); + el.removeEventListener("touchend", handleTouchEnd); + el.removeEventListener("touchcancel", handleTouchEnd); + el.removeEventListener("pointerdown", handlePointerDown); + el.removeEventListener("keydown", handleKeyDown); + }; + }, [maybeLoadEarlierFromScroll, yieldCameraToUser]); return (
- {hasMessages ? ( -
+
+ {hasMessages ? (
-
+
+ ) : ( +
+ {emptyState} +
+ )} +
-
+
{composer}
- ) : ( -
-
-
-
- {emptyState} -
-
{composer}
-
-
-
- )} + +
+
@@ -602,6 +703,7 @@ export const ThreadViewport = forwardRef ) : null} @@ -613,7 +715,7 @@ export const ThreadViewport = forwardRef scrollToBottom(true, 1, { force: true })} + onClick={() => scrollToBottom(true, { force: true })} className={cn( "h-8 w-8 rounded-full shadow-md", "bg-background/90 backdrop-blur", diff --git a/webui/src/components/thread/thread-camera.ts b/webui/src/components/thread/thread-camera.ts new file mode 100644 index 00000000..6ed90c60 --- /dev/null +++ b/webui/src/components/thread/thread-camera.ts @@ -0,0 +1,245 @@ +interface ThreadCameraMotionProfile { + /** + * Time constant for the ease-out chase. Smaller values react faster; the + * camera closes roughly 95% of an uncapped distance in three time constants. + */ + responseTimeMs: number; + /** Prevents a large completion batch from turning into a one-frame jump. */ + maxSpeedPxPerSecond: number; + /** Avoids spending frames chasing sub-pixel layout noise. */ + settleDistancePx: number; + /** Limits catch-up after a throttled or backgrounded animation frame. */ + maxFrameDeltaMs: number; +} + +const THREAD_CAMERA_FOLLOW_MOTION: Readonly = { + responseTimeMs: 90, + maxSpeedPxPerSecond: 1_200, + settleDistancePx: 0.5, + maxFrameDeltaMs: 50, +}; + +const THREAD_CAMERA_NAVIGATION_MOTION: Readonly = { + responseTimeMs: 110, + maxSpeedPxPerSecond: 12_000, + settleDistancePx: 0.5, + maxFrameDeltaMs: 50, +}; + +/** + * Reduced motion still preserves spatial continuity. Snapping a long thread + * to its destination removes the very context that helps users understand + * where the viewport moved; this profile shortens that motion instead. + */ +const THREAD_CAMERA_REDUCED_MOTION: Readonly = { + responseTimeMs: 55, + maxSpeedPxPerSecond: 2_400, + settleDistancePx: 0.5, + maxFrameDeltaMs: 50, +}; + +const THREAD_CAMERA_REDUCED_NAVIGATION_MOTION: Readonly = { + responseTimeMs: 45, + maxSpeedPxPerSecond: 24_000, + settleDistancePx: 0.5, + maxFrameDeltaMs: 50, +}; + +export interface ThreadCameraViewport { + scrollTop: number; + scrollTo?: (options?: ScrollToOptions) => void; +} + +export interface ThreadCameraScheduler { + request: (callback: FrameRequestCallback) => number; + cancel: (id: number) => void; + now: () => number; +} + +export type ThreadCameraFollowResult = "started" | "retargeted" | "settled"; + +interface ThreadCameraOptions { + scheduler?: ThreadCameraScheduler; + prefersReducedMotion?: () => boolean; +} + +type ThreadCameraMotionKind = "follow" | "navigation"; + +/** + * A time-based ease-out chase rather than a start/end tween. The target can + * move on every streamed line without restarting a duration or adding another + * frame loop. + */ +function easeOutChase( + current: number, + target: number, + deltaSeconds: number, + profile: Pick, +): number { + const distance = target - current; + const responseSeconds = Math.max(0.001, profile.responseTimeMs / 1000); + const timeStep = Math.max(0.001, deltaSeconds); + const easeOutFraction = 1 - Math.exp(-timeStep / responseSeconds); + const uncappedStep = distance * easeOutFraction; + const maxStep = Math.max(0, profile.maxSpeedPxPerSecond) * timeStep; + const step = Math.max(-maxStep, Math.min(maxStep, uncappedStep)); + return current + step; +} + +function defaultScheduler(): ThreadCameraScheduler { + return { + request: (callback) => window.requestAnimationFrame(callback), + cancel: (id) => window.cancelAnimationFrame(id), + now: () => performance.now(), + }; +} + +function defaultPrefersReducedMotion(): boolean { + return typeof window !== "undefined" + && typeof window.matchMedia === "function" + && window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +export class ThreadCameraController { + private readonly getViewport: () => ThreadCameraViewport | null; + private readonly scheduler: ThreadCameraScheduler; + private readonly prefersReducedMotion: () => boolean; + private frameId: number | null = null; + private phase: "idle" | "following" = "idle"; + private target = 0; + private lastTimestamp: number | null = null; + private motionKind: ThreadCameraMotionKind = "follow"; + + constructor( + getViewport: () => ThreadCameraViewport | null, + options: ThreadCameraOptions = {}, + ) { + this.getViewport = getViewport; + this.scheduler = options.scheduler ?? defaultScheduler(); + this.prefersReducedMotion = options.prefersReducedMotion ?? defaultPrefersReducedMotion; + } + + isFollowing(): boolean { + return this.phase === "following"; + } + + jumpTo(top: number): void { + const viewport = this.getViewport(); + if (!viewport) return; + this.cancel(); + this.target = Math.max(0, top); + this.write(viewport, this.target); + } + + followTo(top: number): ThreadCameraFollowResult | null { + return this.moveTo(top, "follow"); + } + + navigateTo(top: number): ThreadCameraFollowResult | null { + return this.moveTo(top, "navigation"); + } + + private moveTo( + top: number, + motionKind: ThreadCameraMotionKind, + ): ThreadCameraFollowResult | null { + const viewport = this.getViewport(); + if (!viewport) return null; + const current = viewport.scrollTop; + this.target = Math.max(0, top); + this.motionKind = motionKind; + + const motion = this.currentMotion(motionKind); + if (this.phase === "following") { + return "retargeted"; + } + if (Math.abs(this.target - current) <= motion.settleDistancePx) { + this.write(viewport, this.target); + return "settled"; + } + + this.phase = "following"; + this.lastTimestamp = this.scheduler.now(); + this.frameId = this.scheduler.request(this.advance); + return "started"; + } + + cancel(): void { + if (this.frameId !== null) { + this.scheduler.cancel(this.frameId); + this.frameId = null; + } + this.phase = "idle"; + this.lastTimestamp = null; + this.motionKind = "follow"; + } + + dispose(): void { + this.cancel(); + } + + private readonly advance = (timestamp: number): void => { + this.frameId = null; + const viewport = this.getViewport(); + if (!viewport || this.phase !== "following") { + this.cancel(); + return; + } + + const motion = this.currentMotion(this.motionKind); + const previousTimestamp = this.lastTimestamp ?? timestamp - (1000 / 60); + const deltaMs = Math.min( + motion.maxFrameDeltaMs, + Math.max(1, timestamp - previousTimestamp), + ); + this.lastTimestamp = timestamp; + const current = viewport.scrollTop; + const remainingDistance = this.target - current; + if (Math.abs(remainingDistance) <= motion.settleDistancePx) { + this.write(viewport, this.target); + this.phase = "idle"; + this.lastTimestamp = null; + return; + } + + const deltaSeconds = deltaMs / 1000; + const nextTop = easeOutChase( + current, + this.target, + deltaSeconds, + motion, + ); + const settled = Math.abs(this.target - nextTop) <= motion.settleDistancePx; + this.write(viewport, settled ? this.target : nextTop); + + if (settled) { + this.phase = "idle"; + this.lastTimestamp = null; + return; + } + this.frameId = this.scheduler.request(this.advance); + }; + + private currentMotion(kind: ThreadCameraMotionKind): ThreadCameraMotionProfile { + if (kind === "navigation") { + return this.prefersReducedMotion() + ? THREAD_CAMERA_REDUCED_NAVIGATION_MOTION + : THREAD_CAMERA_NAVIGATION_MOTION; + } + return this.prefersReducedMotion() + ? THREAD_CAMERA_REDUCED_MOTION + : THREAD_CAMERA_FOLLOW_MOTION; + } + + private write(viewport: ThreadCameraViewport, top: number): void { + try { + viewport.scrollTop = top; + } catch { + try { + viewport.scrollTo?.({ top, behavior: "auto" }); + } catch { + // Test DOMs can expose read-only scrollTop; browsers keep this writable. + } + } + } +} diff --git a/webui/src/components/thread/thread-motion.ts b/webui/src/components/thread/thread-motion.ts new file mode 100644 index 00000000..b56a4887 --- /dev/null +++ b/webui/src/components/thread/thread-motion.ts @@ -0,0 +1,382 @@ +import type { + ThreadCameraController, + ThreadCameraFollowResult, +} from "@/components/thread/thread-camera"; + +type ThreadMotionMode = + | "idle" + | "anchor-prompt" + | "follow-output" + | "follow-completion" + | "navigating-history" + | "browsing-history"; + +type AutomaticThreadMotionMode = + | "idle" + | "anchor-prompt" + | "follow-output"; + +type ThreadMotionEvent = + | "navigate-history" + | "navigation-settled" + | "user-scroll" + | "boundary-scroll" + | "turn-completed" + | "resume-follow"; + +type ThreadMotionTransition = ThreadMotionMode | "current-automatic-mode"; + +const THREAD_MOTION_TRANSITIONS: Readonly< + Record< + ThreadMotionMode, + Readonly>> + > +> = { + idle: { + "navigate-history": "navigating-history", + "user-scroll": "browsing-history", + }, + "anchor-prompt": { + "navigate-history": "navigating-history", + "user-scroll": "browsing-history", + "turn-completed": "follow-completion", + }, + "follow-output": { + "navigate-history": "navigating-history", + "user-scroll": "browsing-history", + "turn-completed": "follow-completion", + }, + "follow-completion": { + "navigate-history": "navigating-history", + "user-scroll": "browsing-history", + }, + "navigating-history": { + "navigate-history": "navigating-history", + "navigation-settled": "browsing-history", + "user-scroll": "browsing-history", + "boundary-scroll": "browsing-history", + "resume-follow": "current-automatic-mode", + }, + "browsing-history": { + "navigate-history": "navigating-history", + "resume-follow": "current-automatic-mode", + }, +}; + +export interface ThreadMotionGeometry { + scrollTop: number; + scrollHeight: number; + clientHeight: number; + maxScrollTop: number; + composerHeight: number; + promptTop: number | null; +} + +interface ThreadMotionTurn { + id: string | null; + promptId: string | null; + hasOutput: boolean; + entry?: "submitted" | "restored"; +} + +interface ThreadMotionSnapshot { + mode: ThreadMotionMode; + turnId: string | null; + promptId: string | null; + promptPositioned: boolean; + measurementPending: boolean; +} + +export interface ThreadMotionScheduler { + request: (callback: FrameRequestCallback) => number; + cancel: (id: number) => void; +} + +type ThreadMotionCamera = Pick< + ThreadCameraController, + | "cancel" + | "dispose" + | "followTo" + | "isFollowing" + | "jumpTo" + | "navigateTo" +>; + +interface ThreadMotionCoordinatorOptions { + camera: ThreadMotionCamera; + measure: (promptId: string | null) => ThreadMotionGeometry | null; + onGeometry?: (geometry: ThreadMotionGeometry) => void; + onAutoFollow?: () => void; + scheduler?: ThreadMotionScheduler; +} + +const GEOMETRY_EPSILON_PX = 0.5; + +type ThreadScrollOwner = "automatic" | "navigation" | "user"; + +function defaultScheduler(): ThreadMotionScheduler { + return { + request: (callback) => window.requestAnimationFrame(callback), + cancel: (id) => window.cancelAnimationFrame(id), + }; +} + +/** + * Owns the policy that turns discrete layout events into continuous camera + * motion. Callers only invalidate geometry; one display frame coalesces those + * notifications, reads the authoritative layout, and retargets the camera. + */ +export class ThreadMotionCoordinator { + private readonly camera: ThreadMotionCamera; + private readonly measure: ThreadMotionCoordinatorOptions["measure"]; + private readonly onGeometry?: ThreadMotionCoordinatorOptions["onGeometry"]; + private readonly onAutoFollow?: ThreadMotionCoordinatorOptions["onAutoFollow"]; + private readonly scheduler: ThreadMotionScheduler; + private turn: ThreadMotionTurn = { + id: null, + promptId: null, + hasOutput: false, + }; + private mode: ThreadMotionMode = "idle"; + private promptPositioned = false; + private measurementFrameId: number | null = null; + private geometryDirty = false; + + constructor(options: ThreadMotionCoordinatorOptions) { + this.camera = options.camera; + this.measure = options.measure; + this.onGeometry = options.onGeometry; + this.onAutoFollow = options.onAutoFollow; + this.scheduler = options.scheduler ?? defaultScheduler(); + } + + snapshot(): ThreadMotionSnapshot { + return { + mode: this.mode, + turnId: this.turn.id, + promptId: this.turn.promptId, + promptPositioned: this.promptPositioned, + measurementPending: this.measurementFrameId !== null, + }; + } + + updateTurn(turn: ThreadMotionTurn): void { + if (!turn.id) { + this.completeTurn(); + return; + } + + const isNewTurn = this.turn.id !== turn.id; + this.turn = turn; + if (isNewTurn) { + this.camera.cancel(); + this.promptPositioned = turn.entry === "restored"; + this.mode = this.promptPositioned && turn.hasOutput + ? "follow-output" + : "anchor-prompt"; + } else if (!this.isHistoryMode() && this.promptPositioned) { + this.mode = turn.hasOutput ? "follow-output" : "anchor-prompt"; + } + this.invalidateGeometry(); + } + + completeTurn(): void { + if (!this.turn.id) { + this.invalidateGeometry(); + return; + } + // Protocol completion can share a React commit with the last large text + // batch and begins the run-drawer exit. Keep camera ownership through + // those final layout changes; only a new turn or explicit user navigation + // may end completion follow. + this.transition("turn-completed"); + this.turn = { id: null, promptId: null, hasOutput: false }; + this.promptPositioned = false; + this.invalidateGeometry(); + } + + invalidateGeometry(): void { + this.geometryDirty = true; + if (this.measurementFrameId !== null) return; + this.measurementFrameId = this.scheduler.request(this.flushGeometry); + } + + takeUserControl(): void { + this.handleUserScrollIntent(true); + } + + handleUserScrollIntent(canScroll: boolean): void { + const event = canScroll ? "user-scroll" : "boundary-scroll"; + if (!this.transition(event)) return; + this.camera.cancel(); + } + + resumeAutoFollow(): void { + if (!this.transition("resume-follow")) return; + this.camera.cancel(); + this.invalidateGeometry(); + } + + jumpTo(top: number): void { + this.camera.jumpTo(top); + } + + animateTo(top: number): ThreadCameraFollowResult | null { + return this.camera.navigateTo(top); + } + + navigateHistoryTo(top: number): ThreadCameraFollowResult | null { + this.camera.cancel(); + this.transition("navigate-history"); + const result = this.camera.navigateTo(top); + if (!result || result === "settled") { + this.transition("navigation-settled"); + } + return result; + } + + isAutoFollowPaused(): boolean { + return this.isHistoryMode(); + } + + isBrowsingHistory(): boolean { + return this.mode === "browsing-history"; + } + + /** + * A scroll event reports geometry; it does not prove user intent. Explicit + * input handlers call takeUserControl() before the browser scrolls. Layout, + * sticky positioning, and camera writes therefore remain automatic even + * when the browser emits an intermediate scroll event for them. + */ + observeScroll(nearBottom: boolean): ThreadScrollOwner { + switch (this.mode) { + case "navigating-history": + if (!this.camera.isFollowing()) { + this.transition("navigation-settled"); + if (nearBottom) this.resumeAutoFollow(); + } + return "navigation"; + case "browsing-history": + if (!nearBottom) return "user"; + this.resumeAutoFollow(); + return "automatic"; + default: + if (!nearBottom) this.invalidateGeometry(); + return "automatic"; + } + } + + reset(): void { + if (this.measurementFrameId !== null) { + this.scheduler.cancel(this.measurementFrameId); + this.measurementFrameId = null; + } + this.geometryDirty = false; + this.camera.cancel(); + this.turn = { id: null, promptId: null, hasOutput: false }; + this.mode = "idle"; + this.promptPositioned = false; + } + + dispose(): void { + this.reset(); + this.camera.dispose(); + } + + private isHistoryMode(): boolean { + return ( + this.mode === "navigating-history" + || this.mode === "browsing-history" + ); + } + + private automaticMode(): AutomaticThreadMotionMode { + if (!this.turn.id) return "idle"; + return this.promptPositioned && this.turn.hasOutput + ? "follow-output" + : "anchor-prompt"; + } + + private transition(event: ThreadMotionEvent): boolean { + const transition = THREAD_MOTION_TRANSITIONS[this.mode][event]; + if (!transition) return false; + const nextMode = + transition === "current-automatic-mode" + ? this.automaticMode() + : transition; + if (nextMode === this.mode) return false; + this.mode = nextMode; + return true; + } + + private followGeometry(geometry: ThreadMotionGeometry): void { + const target = geometry.maxScrollTop; + const result = this.camera.followTo(target); + if ( + result + && ( + Math.abs(target - geometry.scrollTop) > GEOMETRY_EPSILON_PX + || result === "retargeted" + ) + ) { + this.onAutoFollow?.(); + } + } + + private readonly flushGeometry = (): void => { + this.measurementFrameId = null; + if (!this.geometryDirty) return; + this.geometryDirty = false; + + const needsPromptGeometry = + !this.isHistoryMode() + && this.turn.id !== null + && !this.promptPositioned; + const geometry = this.measure(needsPromptGeometry ? this.turn.promptId : null); + if (!geometry) return; + this.onGeometry?.(geometry); + + if (this.mode === "follow-completion") { + this.followGeometry(geometry); + return; + } + if (this.isHistoryMode() || !this.turn.id) return; + if (!this.turn.promptId && this.turn.entry !== "restored") { + this.mode = "anchor-prompt"; + return; + } + + if (!this.promptPositioned) { + if (geometry.promptTop === null) { + this.mode = "anchor-prompt"; + return; + } + // Before output exists, the real lower scroll boundary is the only + // position with zero hidden downward travel. Once output exists, start + // from the prompt origin and let the follow camera reveal its growth. + this.camera.jumpTo( + this.turn.hasOutput ? geometry.promptTop : geometry.maxScrollTop, + ); + this.promptPositioned = true; + } else if ( + !this.turn.hasOutput + && Math.abs(geometry.maxScrollTop - geometry.scrollTop) + > GEOMETRY_EPSILON_PX + ) { + // Hero docking, the run drawer, fonts, and responsive chrome can all + // change document height while the model is still silent. Every + // authoritative geometry frame reasserts the real lower boundary so no + // stale downward scroll pocket survives a layout transition. + this.camera.jumpTo(geometry.maxScrollTop); + } + + if (!this.turn.hasOutput) { + this.mode = "anchor-prompt"; + return; + } + + this.mode = "follow-output"; + this.followGeometry(geometry); + }; +} diff --git a/webui/src/globals.css b/webui/src/globals.css index eec7d801..9487c8f5 100644 --- a/webui/src/globals.css +++ b/webui/src/globals.css @@ -265,33 +265,6 @@ @apply list-none pl-0; } - /* Show a caret only while the full markdown renderer is still loading. */ - @keyframes streaming-caret-blink { - 0%, 45% { opacity: 1; } - 55%, 100% { opacity: 0; } - } - .streaming-text-fallback::after { - content: ""; - display: inline-block; - width: 1.5px; - height: 1em; - margin-left: 3px; - vertical-align: -0.12em; - border-radius: 1px; - background: hsl(var(--foreground) / 0.8); - animation: streaming-caret-blink 1s step-end infinite; - } - - @media (prefers-reduced-motion: reduce) { - [data-sd-animate] { - animation: none !important; - } - - .streaming-text-fallback::after { - animation: none; - } - } - /* CJK-friendly line-height: prose paragraphs default to 1.625 which is tight for Chinese/Japanese/Korean characters. Bump to 1.8 for better readability when the browser detects a CJK primary font. */ @@ -371,6 +344,23 @@ } } + /** + * The composer is one persistent grid item in both landing and thread + * layouts. On desktop, the landing's trailing flexible row holds it at the + * visual center; collapsing only that row lets the same element dock at the + * bottom without a remount or a transform clone. + */ + .thread-layout { + grid-template-rows: minmax(min-content, 1fr) auto 0fr; + transition: + grid-template-rows 900ms cubic-bezier(0.33, 1, 0.68, 1); + } + @media (min-width: 640px) { + .thread-layout[data-layout="hero"] { + grid-template-rows: minmax(min-content, 1fr) auto 1fr; + } + } + @keyframes composer-status-strip-enter { 0% { max-height: 0; @@ -410,6 +400,39 @@ .composer-status-strip[data-state="exit"] { animation: composer-status-strip-exit 180ms ease-in both; } + .composer-status-drawer { + --composer-status-drawer-duration: 600ms; + --composer-status-drawer-easing: ease-out; + + display: grid; + grid-template-rows: 0fr; + pointer-events: none; + transition: + grid-template-rows var(--composer-status-drawer-duration) + var(--composer-status-drawer-easing); + } + .composer-status-drawer[data-state="open"] { + grid-template-rows: 1fr; + pointer-events: auto; + } + .composer-status-drawer-clip { + min-height: 0; + overflow: hidden; + } + .composer-status-drawer-content { + opacity: 0; + transform: translateY(8px); + transform-origin: bottom center; + transition: + opacity var(--composer-status-drawer-duration) + var(--composer-status-drawer-easing), + transform var(--composer-status-drawer-duration) + var(--composer-status-drawer-easing); + } + .composer-status-drawer[data-state="open"] .composer-status-drawer-content { + opacity: 1; + transform: translateY(0); + } @keyframes run-pulse-dot { 0%, 100% { @@ -474,6 +497,16 @@ .composer-status-strip[data-state] { animation: none; } + .composer-status-drawer { + transition-duration: 220ms; + } + .composer-status-drawer-content { + transform: translateY(2px); + transition-duration: 140ms, 220ms; + } + .thread-layout { + transition-duration: 360ms; + } .run-pulse-icon, .run-pulse-icon * { animation: none; diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index c78d3bcf..4d2ee120 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -227,9 +227,9 @@ function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] { }); } -function stampLastAssistantLatency( +function stampLastAssistantCompletion( prev: UIMessage[], - latencyMs: number, + completion: Pick, turnId?: string, ): UIMessage[] { for (let i = prev.length - 1; i >= 0; i -= 1) { @@ -239,7 +239,7 @@ function stampLastAssistantLatency( && m.kind !== "trace" && (!turnId || !m.turnId || m.turnId === turnId) ) { - const merged: UIMessage = { ...m, latencyMs, isStreaming: false }; + const merged: UIMessage = { ...m, ...completion, isStreaming: false }; return [...prev.slice(0, i), merged, ...prev.slice(i + 1)]; } } @@ -488,6 +488,12 @@ export interface SendOptions { continueActiveTurn?: boolean; } +export interface SubmittedTurn { + turnId: string; + userMessageId: string; + sideChannel: boolean; +} + function eventExtendsModelActivity(ev: InboundEvent): boolean { if ( ev.event === "delta" @@ -520,12 +526,18 @@ export function useNanobotStream( onTurnEnd?: () => void, ): { messages: UIMessage[]; + /** Whether ``messages`` belongs to the current ``chatId`` after a session switch. */ + messagesReady: boolean; isStreaming: boolean; /** Unix epoch seconds when the current user turn started (WebSocket ``goal_status``). */ runStartedAt: number | null; /** Latest sustained goal for this ``chatId`` (``goal_state`` WS events). */ goalState: GoalStateWsPayload | undefined; - send: (content: string, images?: SendAttachment[], options?: SendOptions) => void; + send: ( + content: string, + images?: SendAttachment[], + options?: SendOptions, + ) => SubmittedTurn | null; transcribeAudio: (dataUrl: string, options?: { durationMs?: number }) => Promise; stop: () => void; setMessages: React.Dispatch>; @@ -537,12 +549,16 @@ export function useNanobotStream( dismissStreamError: () => void; } { const { client } = useClient(); + const initialRunStartedAt = chatId ? client.getRunStartedAt(chatId) : null; const [messages, setMessages] = useState(initialMessages); + const [messageOwnerChatId, setMessageOwnerChatId] = useState(chatId); /** If history ends in unfinished agent activity, keep the loading spinner alive. */ const initialStreaming = hasPendingAgentActivity(initialMessages); - const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls); + const [isStreaming, setIsStreaming] = useState( + initialStreaming || hasPendingToolCalls || initialRunStartedAt !== null, + ); /** Unix epoch seconds when the current user turn started; cleared on ``idle``. */ - const [runStartedAt, setRunStartedAt] = useState(null); + const [runStartedAt, setRunStartedAt] = useState(initialRunStartedAt); const [goalState, setGoalState] = useState(undefined); const [streamError, setStreamError] = useState(null); const buffer = useRef(null); @@ -826,12 +842,16 @@ export function useNanobotStream( // ``initialMessages`` update: a brand-new chat can receive an empty/404 // history response after the optimistic first message has already rendered. useEffect(() => { + const restoredRunStartedAt = chatId ? client.getRunStartedAt(chatId) : null; setMessages(initialMessages); + setMessageOwnerChatId(chatId); setIsStreaming( - hasPendingAgentActivity(initialMessages) || hasPendingToolCalls, + hasPendingAgentActivity(initialMessages) + || hasPendingToolCalls + || restoredRunStartedAt !== null, ); setStreamError(null); - setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null); + setRunStartedAt(restoredRunStartedAt); setGoalState(chatId ? client.getGoalState(chatId) : undefined); buffer.current = null; activeAssistantRef.current = null; @@ -929,8 +949,10 @@ export function useNanobotStream( if (ev.event === "goal_status") { if (ev.status === "running" && typeof ev.started_at === "number") { setRunStartedAt(ev.started_at); + setIsStreaming(true); } else { setRunStartedAt(null); + setIsStreaming(false); } return; } @@ -944,16 +966,22 @@ export function useNanobotStream( // pending debounce timer and stop the loading indicator immediately. cancelStreamEndTimer(); setIsStreaming(false); + const completedAt = Date.now(); setMessages((prev) => { let finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)); finalized = pruneReasoningOnlyPlaceholders(finalized); - if (typeof ev.latency_ms === "number" && ev.latency_ms >= 0) { - finalized = stampLastAssistantLatency( - finalized, - Math.round(ev.latency_ms), - ev.turn_id, - ); - } + const latencyMs = + typeof ev.latency_ms === "number" && ev.latency_ms >= 0 + ? Math.round(ev.latency_ms) + : undefined; + finalized = stampLastAssistantCompletion( + finalized, + { + ...(latencyMs !== undefined ? { latencyMs } : {}), + completedAt, + }, + ev.turn_id, + ); buffer.current = null; activeAssistantRef.current = null; clearActivitySegment(); @@ -1176,11 +1204,11 @@ export function useNanobotStream( const send = useCallback( (content: string, images?: SendAttachment[], options?: SendOptions) => { - if (!chatId) return; + if (!chatId) return null; const hasAttachments = !!images && images.length > 0; // Text is optional when files are attached — the agent will still see // them via ``media`` paths. - if (!hasAttachments && !content.trim()) return; + if (!hasAttachments && !content.trim()) return null; const sideChannel = options?.sideChannel === true; const finalizeActiveTurn = options?.finalizeActiveTurn === true; @@ -1194,6 +1222,7 @@ export function useNanobotStream( setIsStreaming(false); } const turnId = crypto.randomUUID(); + const userMessageId = crypto.randomUUID(); if (sideChannel) sideChannelTurnIdsRef.current.add(turnId); const previews = hasAttachments ? images!.map((i) => i.preview) : undefined; setMessages((prev) => { @@ -1213,7 +1242,7 @@ export function useNanobotStream( return [ ...(sideChannel || continueActiveTurn ? base : pruneReasoningOnlyPlaceholders(base)), { - id: crypto.randomUUID(), + id: userMessageId, role: "user", content: outboundContent, turnId, @@ -1234,6 +1263,7 @@ export function useNanobotStream( delete wireOptions.finalizeActiveTurn; delete wireOptions.continueActiveTurn; client.sendMessage(chatId, outboundContent, wireMedia, wireOptions); + return { turnId, userMessageId, sideChannel }; }, [cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents], ); @@ -1261,6 +1291,7 @@ export function useNanobotStream( return { messages, + messagesReady: messageOwnerChatId === chatId, isStreaming, runStartedAt, goalState, diff --git a/webui/src/lib/format.ts b/webui/src/lib/format.ts index c58ced45..279e318e 100644 --- a/webui/src/lib/format.ts +++ b/webui/src/lib/format.ts @@ -71,6 +71,7 @@ const RELATIVE_THRESHOLDS: [number, Intl.RelativeTimeFormatUnit][] = [ const relativeTimeFormatters = new Map(); const dateTimeFormatters = new Map(); +const clockTimeFormatters = new Map(); function activeLocale(locale?: string): string { return locale || i18n.resolvedLanguage || i18n.language || currentLocale(); @@ -95,6 +96,25 @@ function dateTimeFormatter(locale: string): Intl.DateTimeFormat { return formatter; } +function clockTimeFormatter(locale: string): Intl.DateTimeFormat { + const existing = clockTimeFormatters.get(locale); + if (existing) return existing; + const formatter = new Intl.DateTimeFormat(locale, { + hour: "2-digit", + minute: "2-digit", + }); + clockTimeFormatters.set(locale, formatter); + return formatter; +} + +function isSameLocalCalendarDay(left: Date, right: Date): boolean { + return ( + left.getFullYear() === right.getFullYear() + && left.getMonth() === right.getMonth() + && left.getDate() === right.getDate() + ); +} + export function relativeTime( value: string | number | null | undefined, locale?: string, @@ -120,6 +140,22 @@ export function fmtDateTime( return date ? dateTimeFormatter(activeLocale(locale)).format(date) : ""; } +/** + * Format a completion timestamp in the browser's local timezone. + * Today's messages stay compact; older messages include their date for orientation. + */ +export function formatMessageEndTime( + value: number | null | undefined, + locale?: string, +): string { + const date = parseDate(value); + if (!date) return ""; + const loc = activeLocale(locale); + return isSameLocalCalendarDay(date, new Date()) + ? clockTimeFormatter(loc).format(date) + : dateTimeFormatter(loc).format(date); +} + /** Human-readable turn duration (wall-clock), locale-aware via ``Intl`` (seconds/minutes). */ export function formatTurnLatency(ms: number, locale?: string): string { const loc = activeLocale(locale); diff --git a/webui/src/lib/thread-display-compat.ts b/webui/src/lib/thread-display-compat.ts index 81c70d04..39933fa8 100644 --- a/webui/src/lib/thread-display-compat.ts +++ b/webui/src/lib/thread-display-compat.ts @@ -24,6 +24,44 @@ export function normalizeLegacyLongTaskMessages(messages: UIMessage[]): UIMessag }); } +/** + * Replay timestamps an assistant row when its first output is recorded, while + * latency covers the whole turn. Derive the end from the matching user start + * so the displayed time cannot double-count the pre-output interval. + */ +function deriveAssistantCompletionTimes(messages: UIMessage[]): UIMessage[] { + const userStartedAtByTurn = new Map(); + let latestUserStartedAt: number | undefined; + + return messages.map((message) => { + if (message.role === "user") { + if (Number.isFinite(message.createdAt)) { + latestUserStartedAt = message.createdAt; + if (message.turnId) userStartedAtByTurn.set(message.turnId, message.createdAt); + } + return message; + } + if ( + message.role !== "assistant" + || message.kind === "trace" + || message.completedAt !== undefined + || message.latencyMs === undefined + || !Number.isFinite(message.latencyMs) + || message.latencyMs < 0 + ) { + return message; + } + + const startedAt = message.turnId + ? userStartedAtByTurn.get(message.turnId) + : message.source + ? undefined + : latestUserStartedAt; + if (startedAt === undefined) return message; + return { ...message, completedAt: startedAt + message.latencyMs }; + }); +} + export function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] { const normalized = scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages)); const hiddenTurns = new Set(normalized.flatMap((message) => ( @@ -31,10 +69,11 @@ export function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] { ? [message.turnId] : [] ))); - return normalized.filter((message) => ( + const visible = normalized.filter((message) => ( !isSystemCommandTurnId(message.turnId) && (!message.turnId || !hiddenTurns.has(message.turnId)) && !(message.role === "user" && isModelCommandText(message.content)) && !(message.role === "assistant" && isModelCommandResponseText(message.content)) )); + return deriveAssistantCompletionTimes(visible); } diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 82cb65c7..5a9ce09e 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -68,6 +68,8 @@ export interface UIMessage { reasoningStreaming?: boolean; /** End-to-end wall time for this assistant turn (persisted ``latency_ms`` / ``turn_end``). */ latencyMs?: number; + /** Client epoch milliseconds when the definitive ``turn_end`` was received. */ + completedAt?: number; /** Lightweight provenance for proactive assistant messages. */ source?: UIMessageSource; /** Stable protocol metadata for grouping all activity emitted by one user turn. */ diff --git a/webui/src/tests/format.i18n.test.ts b/webui/src/tests/format.i18n.test.ts index 10adc38e..a79e1b5d 100644 --- a/webui/src/tests/format.i18n.test.ts +++ b/webui/src/tests/format.i18n.test.ts @@ -1,7 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { setAppLanguage } from "@/i18n"; -import { fmtDateTime, formatTurnLatency, relativeTime } from "@/lib/format"; +import { + fmtDateTime, + formatMessageEndTime, + formatTurnLatency, + relativeTime, +} from "@/lib/format"; describe("localized format helpers", () => { beforeEach(() => { @@ -62,6 +67,34 @@ describe("localized format helpers", () => { expect(english).not.toBe(french); }); + it("shows only the local clock time for messages completed today", async () => { + const value = Date.parse("2026-04-18T08:34:56Z"); + const date = new Date(value); + + await setAppLanguage("zh-CN"); + + expect(formatMessageEndTime(value)).toBe( + new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", + minute: "2-digit", + }).format(date), + ); + }); + + it("adds the local date for messages completed before today", async () => { + const value = Date.parse("2026-04-16T08:34:56Z"); + const date = new Date(value); + + await setAppLanguage("zh-CN"); + + expect(formatMessageEndTime(value)).toBe( + new Intl.DateTimeFormat("zh-CN", { + dateStyle: "medium", + timeStyle: "short", + }).format(date), + ); + }); + it("formats turn latency with locale-aware units", async () => { await setAppLanguage("en"); const subMinute = formatTurnLatency(2400, "en"); diff --git a/webui/src/tests/markdown-text-renderer.test.tsx b/webui/src/tests/markdown-text-renderer.test.tsx index e6ba64eb..8feeda30 100644 --- a/webui/src/tests/markdown-text-renderer.test.tsx +++ b/webui/src/tests/markdown-text-renderer.test.tsx @@ -424,31 +424,28 @@ describe("MarkdownTextRenderer", () => { expect(container.firstElementChild).not.toHaveClass("space-y-0"); }); - it("uses Streamdown's incremental reveal while content is streaming", () => { + it("keeps streaming parsing without a competing reveal animation", () => { const { container } = render( 春天, ); - expect(container.firstElementChild).toHaveClass( + expect(container).toHaveTextContent("春天"); + expect(container.firstElementChild).not.toHaveClass( "[&>*:last-child]:after:content-[var(--streamdown-caret)]", ); - const animatedUnits = container.querySelectorAll("[data-sd-animate]"); - expect(animatedUnits).toHaveLength(1); - expect(animatedUnits[0]).toHaveTextContent("春天"); - expect(animatedUnits[0].getAttribute("style")).toContain("--sd-duration: 180ms"); + expect(container.querySelector("[data-sd-animate]")).not.toBeInTheDocument(); }); - it("removes animation markup when a streamed response completes", async () => { + it("does not add animation markup when a streamed response completes", () => { const { container, rerender } = render( 春天, ); - expect(container.querySelector("[data-sd-animate]")).toBeInTheDocument(); + expect(container.querySelector("[data-sd-animate]")).not.toBeInTheDocument(); rerender(春天); - await waitFor(() => { - expect(container.querySelector("[data-sd-animate]")).not.toBeInTheDocument(); - }); + expect(container).toHaveTextContent("春天"); + expect(container.querySelector("[data-sd-animate]")).not.toBeInTheDocument(); }); it("does not create one DOM node per CJK character for long responses", () => { @@ -456,7 +453,7 @@ describe("MarkdownTextRenderer", () => { {"长".repeat(6_001)}, ); - expect(container.querySelectorAll("[data-sd-animate]")).toHaveLength(1); + expect(container.querySelector("[data-sd-animate]")).not.toBeInTheDocument(); expect(container.querySelector("[data-nanobot-stream-unit]")).not.toBeInTheDocument(); }); diff --git a/webui/src/tests/markdown-text.test.tsx b/webui/src/tests/markdown-text.test.tsx index a4d81aa6..8054f348 100644 --- a/webui/src/tests/markdown-text.test.tsx +++ b/webui/src/tests/markdown-text.test.tsx @@ -29,6 +29,7 @@ vi.mock("@/components/MarkdownTextRenderer", () => ({
{children}
@@ -106,6 +107,33 @@ describe("MarkdownText", () => { expect(rendererMountSpy).toHaveBeenCalledTimes(1); }); + it("can complete without replacing the streaming renderer layout", async () => { + const source = "A layout-stable answer"; + const { rerender } = render( + {source}, + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByTestId("markdown-renderer")).toHaveAttribute( + "data-streaming-layout", + "true", + ); + + rerender({source}); + + expect(screen.getByTestId("markdown-renderer")).toHaveAttribute( + "data-streaming-layout", + "true", + ); + expect(screen.getByTestId("markdown-renderer")).toHaveAttribute( + "data-highlight-code", + "true", + ); + }); + it("defers syntax highlighting until the final render", async () => { rendererSpy.mockClear(); const largeCode = `\`\`\`ts\n${"const value = 1;\n".repeat(1_100)}\`\`\``; diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx index 91189faf..9b82b8d8 100644 --- a/webui/src/tests/message-bubble.test.tsx +++ b/webui/src/tests/message-bubble.test.tsx @@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" import { describe, expect, it, vi } from "vitest"; import { MessageBubble } from "@/components/MessageBubble"; +import { fmtDateTime, formatMessageEndTime } from "@/lib/format"; import type { CliAppInfo, McpPresetInfo, @@ -298,6 +299,52 @@ describe("MessageBubble", () => { expect(onForkFromHere).toHaveBeenCalledTimes(1); }); + it("shows the assistant completion time in the former latency slot", () => { + const completedAt = Date.UTC(2026, 6, 25, 12, 34, 56); + const { container } = render( + , + ); + + expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument(); + const time = container.querySelector("[data-assistant-completed-at]"); + expect(time).toHaveTextContent(formatMessageEndTime(completedAt)); + expect(time).toHaveAttribute("dateTime", new Date(completedAt).toISOString()); + expect(time).toHaveAttribute("title", fmtDateTime(completedAt)); + expect(time).toHaveClass( + "text-[11px]", + "leading-none", + "text-muted-foreground/70", + "tabular-nums", + ); + }); + + it("does not infer completion time from the assistant creation timestamp", () => { + const createdAt = Date.UTC(2026, 6, 25, 12, 34, 0); + const latencyMs = 13_000; + const { container } = render( + , + ); + + expect(container.querySelector("[data-assistant-completed-at]")).not.toBeInTheDocument(); + }); + it("renders installed CLI app mentions inside sent user messages", () => { const message: UIMessage = { id: "u-cli", @@ -482,6 +529,37 @@ describe("MessageBubble", () => { expect(screen.queryByRole("button", { name: "Copy" })).not.toBeInTheDocument(); }); + it("keeps assistant footer geometry mounted across stream completion", () => { + const streaming: UIMessage = { + id: "a-footer-stable", + role: "assistant", + content: "Stable answer", + isStreaming: true, + createdAt: Date.now(), + }; + const { container, rerender } = render(); + + const reservedFooter = container.querySelector("[data-assistant-footer]"); + expect(reservedFooter).not.toBeNull(); + expect(reservedFooter).toHaveAttribute("data-state", "reserved"); + expect(reservedFooter).toHaveClass("mt-2", "min-h-8", "opacity-0"); + + rerender( + , + ); + + const visibleFooter = container.querySelector("[data-assistant-footer]"); + expect(visibleFooter).toBe(reservedFooter); + expect(visibleFooter).toHaveAttribute("data-state", "visible"); + expect(visibleFooter).toHaveClass("mt-2", "min-h-8", "opacity-100"); + }); + it("does not show copy when showCopyAction is false", () => { const message: UIMessage = { id: "a-mid", diff --git a/webui/src/tests/thread-camera.test.ts b/webui/src/tests/thread-camera.test.ts new file mode 100644 index 00000000..a755a824 --- /dev/null +++ b/webui/src/tests/thread-camera.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + ThreadCameraController, + type ThreadCameraScheduler, + type ThreadCameraViewport, +} from "@/components/thread/thread-camera"; + +function cameraHarness(prefersReducedMotion = false) { + let now = 0; + let nextFrameId = 1; + const frames = new Map(); + const viewport: ThreadCameraViewport = { + scrollTop: 0, + }; + const scheduler: ThreadCameraScheduler = { + request: vi.fn((callback) => { + const id = nextFrameId; + nextFrameId += 1; + frames.set(id, callback); + return id; + }), + cancel: vi.fn((id) => { + frames.delete(id); + }), + now: () => now, + }; + const camera = new ThreadCameraController(() => viewport, { + scheduler, + prefersReducedMotion: () => prefersReducedMotion, + }); + const advance = (deltaMs: number) => { + now += deltaMs; + const pending = [...frames.entries()]; + frames.clear(); + for (const [, callback] of pending) callback(now); + }; + return { camera, viewport, scheduler, frames, advance }; +} + +describe("ThreadCameraController", () => { + it("responds immediately, then eases out as a static target gets closer", () => { + const { camera, viewport, advance } = cameraHarness(); + + camera.followTo(60); + advance(16); + const firstStep = viewport.scrollTop; + advance(16); + const secondStep = viewport.scrollTop - firstStep; + advance(16); + const thirdStep = viewport.scrollTop - firstStep - secondStep; + + expect(firstStep).toBeGreaterThan(0); + expect(secondStep).toBeGreaterThan(0); + expect(thirdStep).toBeGreaterThan(0); + expect(secondStep).toBeLessThan(firstStep); + expect(thirdStep).toBeLessThan(secondStep); + }); + + it("retargets an active follow without adding another loop", () => { + const { camera, viewport, frames, advance } = cameraHarness(); + + expect(camera.followTo(100)).toBe("started"); + expect(frames).toHaveLength(1); + advance(16); + expect(frames).toHaveLength(1); + + expect(camera.followTo(180)).toBe("retargeted"); + expect(frames).toHaveLength(1); + for (let frame = 0; frame < 120; frame += 1) advance(16); + expect(viewport.scrollTop).toBe(180); + }); + + it("tracks repeated target growth as one monotonic camera movement", () => { + const { camera, viewport, advance } = cameraHarness(); + + camera.followTo(80); + advance(16); + const first = viewport.scrollTop; + camera.followTo(140); + advance(16); + const second = viewport.scrollTop; + camera.followTo(220); + advance(16); + const third = viewport.scrollTop; + + expect(first).toBeGreaterThan(0); + expect(second).toBeGreaterThan(first); + expect(third).toBeGreaterThan(second); + expect(camera.isFollowing()).toBe(true); + }); + + it("uses a faster motion profile for explicit long-distance navigation", () => { + const follow = cameraHarness(); + const navigation = cameraHarness(); + + follow.camera.followTo(1_000); + navigation.camera.navigateTo(1_000); + follow.advance(16); + navigation.advance(16); + + expect(navigation.viewport.scrollTop).toBeGreaterThan(follow.viewport.scrollTop); + expect(navigation.viewport.scrollTop).toBeLessThan(1_000); + }); + + it("gives an immediate jump command priority over an active follow", () => { + const { camera, viewport, scheduler, frames } = cameraHarness(); + + camera.followTo(240); + expect(frames).toHaveLength(1); + camera.jumpTo(40); + + expect(camera.isFollowing()).toBe(false); + expect(viewport.scrollTop).toBe(40); + expect(scheduler.cancel).toHaveBeenCalledTimes(1); + expect(frames).toHaveLength(0); + }); + + it("preserves spatial continuity with a shorter reduced-motion chase", () => { + const regular = cameraHarness(); + const reduced = cameraHarness(true); + + expect(regular.camera.followTo(240)).toBe("started"); + expect(reduced.camera.followTo(240)).toBe("started"); + + regular.advance(16); + reduced.advance(16); + + expect(reduced.viewport.scrollTop).toBeGreaterThan(regular.viewport.scrollTop); + expect(reduced.viewport.scrollTop).toBeLessThan(240); + expect(reduced.camera.isFollowing()).toBe(true); + + for (let frame = 0; frame < 60; frame += 1) reduced.advance(16); + expect(reduced.camera.isFollowing()).toBe(false); + expect(reduced.viewport.scrollTop).toBe(240); + }); +}); diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index fec9b4f1..ee15920d 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -1073,13 +1073,59 @@ describe("ThreadComposer", () => { const status = screen.getByRole("status"); expect(status).toHaveTextContent(/Running/); expect(status).toHaveTextContent(/2:05/); - expect(status.parentElement).toHaveClass("composer-status-strip"); - expect(status.parentElement).toHaveAttribute("data-state", "enter"); + expect(status).toHaveClass("composer-status-drawer-content"); + expect(status.closest("[data-composer-status-drawer]")).toHaveAttribute( + "data-state", + "open", + ); expect(status.querySelector(".run-pulse-icon")).not.toBeNull(); vi.useRealTimers(); }); + it("opens and closes the run timer through one persistent drawer", () => { + const { container, rerender } = render( + , + ); + + const drawer = container.querySelector("[data-composer-status-drawer]"); + expect(drawer).not.toBeNull(); + expect(drawer).toHaveAttribute("data-state", "closed"); + expect(drawer).toHaveAttribute("aria-hidden", "true"); + + rerender( + , + ); + + expect(container.querySelector("[data-composer-status-drawer]")).toBe(drawer); + expect(drawer).toHaveAttribute("data-state", "open"); + expect(drawer).not.toHaveAttribute("aria-hidden"); + const status = screen.getByRole("status"); + expect(status).toHaveClass("composer-status-drawer-content"); + + rerender( + , + ); + + expect(container.querySelector("[data-composer-status-drawer]")).toBe(drawer); + expect(drawer).toHaveAttribute("data-state", "closed"); + expect(drawer).toHaveAttribute("aria-hidden", "true"); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(drawer?.querySelector('[role="status"]')).toBe(status); + }); + it("opens an upward anchored goal panel with markdown content when expand is clicked", async () => { const longObjective = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz0123456789GoalTail"; diff --git a/webui/src/tests/thread-display-compat.test.ts b/webui/src/tests/thread-display-compat.test.ts index f0fe9bb0..f1b7b109 100644 --- a/webui/src/tests/thread-display-compat.test.ts +++ b/webui/src/tests/thread-display-compat.test.ts @@ -7,6 +7,21 @@ import { } from "@/lib/thread-display-compat"; import type { UIMessage } from "@/lib/types"; +const STARTED_AT = Date.UTC(2026, 6, 25, 12, 34, 0); + +function message( + role: UIMessage["role"], + overrides: Partial> = {}, +): UIMessage { + return { + id: role, + role, + content: role, + createdAt: STARTED_AT, + ...overrides, + }; +} + describe("normalizeLegacyLongTaskMessages", () => { it("maps legacy long_task rows to trace lines", () => { const legacy = { @@ -23,17 +38,19 @@ describe("normalizeLegacyLongTaskMessages", () => { }); it("removes model and silent-command turns without hiding concurrent replies", () => { - const message = ( - id: string, - role: UIMessage["role"], - content: string, - turnId?: string, - ): UIMessage => ({ id, role, content, createdAt: 1, turnId }); const visible = projectWebuiThreadMessages([ - message("model", "user", "/model fast", "model-turn"), - message("model-reply", "assistant", "Switched model preset to fast.", "model-turn"), - message("silent", "user", "/restart", "webui-system:restart"), - message("reply", "assistant", "This unrelated reply stays visible.", "other-turn"), + message("user", { id: "model", content: "/model fast", turnId: "model-turn" }), + message("assistant", { + id: "model-reply", + content: "Switched model preset to fast.", + turnId: "model-turn", + }), + message("user", { id: "silent", content: "/restart", turnId: "webui-system:restart" }), + message("assistant", { + id: "reply", + content: "This unrelated reply stays visible.", + turnId: "other-turn", + }), ]); expect(visible.map(({ content }) => content)).toEqual([ @@ -47,3 +64,74 @@ describe("normalizeLegacyLongTaskMessages", () => { expect(deriveTitle("## Model\n- Current model: `gpt-5.5`", "New chat")).toBe("New chat"); }); }); + +describe("projectWebuiThreadMessages", () => { + it("derives replayed completion time from the matching user turn", () => { + const firstOutputAt = STARTED_AT + 5_000; + const latencyMs = 13_000; + const visible = projectWebuiThreadMessages([ + message("user", { turnId: "turn-1" }), + message("assistant", { + turnId: "turn-1", + createdAt: firstOutputAt, + latencyMs, + }), + ]); + + expect(visible[1]?.completedAt).toBe(STARTED_AT + latencyMs); + expect(visible[1]?.completedAt).not.toBe(firstOutputAt + latencyMs); + }); + + it("preserves the exact completion time received for a live turn", () => { + const completedAt = STARTED_AT + 13_000; + const visible = projectWebuiThreadMessages([ + message("user", { turnId: "turn-1" }), + message("assistant", { + turnId: "turn-1", + latencyMs: 13_000, + completedAt, + createdAt: STARTED_AT + 5_000, + }), + ]); + + expect(visible[1]?.completedAt).toBe(completedAt); + }); + + it("does not borrow a timestamp from a different turn", () => { + const visible = projectWebuiThreadMessages([ + message("user", { turnId: "turn-1" }), + message("assistant", { + turnId: "turn-2", + latencyMs: 13_000, + createdAt: STARTED_AT + 60_000, + }), + ]); + + expect(visible[1]?.completedAt).toBeUndefined(); + }); + + it("uses the nearest user start for legacy rows without turn metadata", () => { + const visible = projectWebuiThreadMessages([ + message("user"), + message("assistant", { + latencyMs: 13_000, + createdAt: STARTED_AT + 5_000, + }), + ]); + + expect(visible[1]?.completedAt).toBe(STARTED_AT + 13_000); + }); + + it("does not attach a previous user turn to proactive messages", () => { + const visible = projectWebuiThreadMessages([ + message("user"), + message("assistant", { + source: { kind: "cron" }, + latencyMs: 13_000, + createdAt: STARTED_AT + 26 * 60_000, + }), + ]); + + expect(visible[1]?.completedAt).toBeUndefined(); + }); +}); diff --git a/webui/src/tests/thread-messages.test.tsx b/webui/src/tests/thread-messages.test.tsx index acfa6adf..341d187a 100644 --- a/webui/src/tests/thread-messages.test.tsx +++ b/webui/src/tests/thread-messages.test.tsx @@ -7,6 +7,7 @@ import { ThreadMessages, unitKeysForDisplay, } from "@/components/thread/ThreadMessages"; +import { preloadMarkdownText } from "@/components/MarkdownText"; import type { UIMessage } from "@/lib/types"; afterEach(() => { @@ -15,6 +16,118 @@ afterEach(() => { }); describe("ThreadMessages", () => { + it("does not move a mounted tail answer into offscreen rendering on the next turn", () => { + const completed: UIMessage[] = [ + { id: "u1", role: "user", content: "question", createdAt: 1 }, + { id: "a1", role: "assistant", content: "latest answer", createdAt: 2 }, + ]; + const { rerender } = render( + , + ); + + expect(screen.getByText("latest answer").closest(".thread-render-unit")).toBeNull(); + + rerender( + , + ); + + expect(screen.getByText("latest answer").closest(".thread-render-unit")).toBeNull(); + }); + + it("still defers historical non-tail answers on their initial render", () => { + render( + , + ); + + expect(screen.getByText("historical answer").closest(".thread-render-unit")).not.toBeNull(); + }); + + it("preserves an answer's markdown tree across completion and the next prompt", async () => { + await preloadMarkdownText(); + const turnId = "turn-1"; + const streaming: UIMessage[] = [ + { + id: "u1", + role: "user", + content: "question", + createdAt: 1, + turnId, + turnPhase: "prompt", + }, + { + id: "live-answer", + role: "assistant", + content: "stable final answer", + createdAt: 2, + isStreaming: true, + turnId, + turnPhase: "answer", + }, + ]; + const { container, rerender } = render( + , + ); + await waitFor( + () => expect(container.querySelector(".markdown-content")).not.toBeNull(), + { timeout: 3_000 }, + ); + const paragraph = screen.getByText("stable final answer").closest("p"); + expect(paragraph).not.toBeNull(); + + rerender( + , + ); + + expect(screen.getByText("stable final answer").closest("p")).toBe(paragraph); + + rerender( + , + ); + + expect(screen.getByText("stable final answer").closest("p")).toBe(paragraph); + }); + it("offers a follow-up action for text selected within one completed answer", async () => { const onQuoteSelection = vi.fn(); render( diff --git a/webui/src/tests/thread-motion.test.ts b/webui/src/tests/thread-motion.test.ts new file mode 100644 index 00000000..21b3fcb1 --- /dev/null +++ b/webui/src/tests/thread-motion.test.ts @@ -0,0 +1,443 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + ThreadMotionCoordinator, + type ThreadMotionGeometry, + type ThreadMotionScheduler, +} from "@/components/thread/thread-motion"; + +function motionHarness(initial?: Partial) { + let nextFrameId = 1; + let cameraFollowing = false; + const frames = new Map(); + let geometry: ThreadMotionGeometry = { + scrollTop: 400, + scrollHeight: 1_900, + clientHeight: 500, + maxScrollTop: 1_400, + composerHeight: 120, + promptTop: 404, + ...initial, + }; + const scheduler: ThreadMotionScheduler = { + request: vi.fn((callback) => { + const id = nextFrameId; + nextFrameId += 1; + frames.set(id, callback); + return id; + }), + cancel: vi.fn((id) => { + frames.delete(id); + }), + }; + const camera = { + cancel: vi.fn(() => { + cameraFollowing = false; + }), + dispose: vi.fn(), + jumpTo: vi.fn(), + followTo: vi.fn(() => "started" as const), + isFollowing: vi.fn(() => cameraFollowing), + navigateTo: vi.fn(() => { + cameraFollowing = true; + return "started" as const; + }), + }; + const onGeometry = vi.fn(); + const onAutoFollow = vi.fn(); + const measure = vi.fn(() => geometry); + const coordinator = new ThreadMotionCoordinator({ + camera, + measure, + onGeometry, + onAutoFollow, + scheduler, + }); + const advanceFrame = () => { + const pending = [...frames.entries()]; + frames.clear(); + for (const [, callback] of pending) callback(16); + }; + const setGeometry = (next: Partial) => { + const updated = { ...geometry, ...next }; + geometry = { + ...updated, + maxScrollTop: next.maxScrollTop + ?? Math.max(0, updated.scrollHeight - updated.clientHeight), + }; + }; + const setCameraFollowing = (following: boolean) => { + cameraFollowing = following; + }; + + return { + camera, + coordinator, + frames, + measure, + onAutoFollow, + onGeometry, + scheduler, + advanceFrame, + setCameraFollowing, + setGeometry, + }; +} + +describe("ThreadMotionCoordinator", () => { + it("coalesces discrete invalidations into one authoritative geometry frame", () => { + const { + camera, + coordinator, + frames, + measure, + scheduler, + advanceFrame, + } = motionHarness(); + + coordinator.updateTurn({ + id: "turn-1", + promptId: "prompt-1", + hasOutput: true, + }); + coordinator.invalidateGeometry(); + coordinator.invalidateGeometry(); + + expect(scheduler.request).toHaveBeenCalledTimes(1); + expect(frames).toHaveLength(1); + expect(measure).not.toHaveBeenCalled(); + + advanceFrame(); + + expect(measure).toHaveBeenCalledTimes(1); + expect(measure).toHaveBeenCalledWith("prompt-1"); + expect(camera.jumpTo).toHaveBeenCalledWith(404); + expect(camera.followTo).toHaveBeenCalledWith(1_400); + expect(coordinator.snapshot()).toMatchObject({ + mode: "follow-output", + promptPositioned: true, + measurementPending: false, + }); + }); + + it("retargets repeated output growth without restarting camera ownership", () => { + const { + camera, + coordinator, + advanceFrame, + setGeometry, + } = motionHarness(); + coordinator.updateTurn({ + id: "turn-1", + promptId: "prompt-1", + hasOutput: true, + }); + advanceFrame(); + camera.cancel.mockClear(); + camera.followTo.mockClear(); + + setGeometry({ scrollTop: 1_400, scrollHeight: 1_928 }); + coordinator.invalidateGeometry(); + coordinator.invalidateGeometry(); + advanceFrame(); + setGeometry({ scrollTop: 1_410, scrollHeight: 1_944 }); + coordinator.invalidateGeometry(); + advanceFrame(); + + expect(camera.followTo.mock.calls).toEqual([[1_428], [1_444]]); + expect(camera.cancel).not.toHaveBeenCalled(); + }); + + it.each([ + "geometry-before-completion", + "completion-before-geometry", + ] as const)("keeps following final layout for %s ordering", (ordering) => { + const { + camera, + coordinator, + advanceFrame, + setGeometry, + } = motionHarness(); + coordinator.updateTurn({ + id: "turn-1", + promptId: "prompt-1", + hasOutput: true, + }); + advanceFrame(); + camera.cancel.mockClear(); + camera.followTo.mockClear(); + + const applyFinalGeometry = () => { + setGeometry({ + scrollTop: 1_400, + scrollHeight: 2_400, + }); + coordinator.invalidateGeometry(); + }; + if (ordering === "geometry-before-completion") applyFinalGeometry(); + coordinator.completeTurn(); + if (ordering === "completion-before-geometry") applyFinalGeometry(); + + expect(coordinator.snapshot().mode).toBe("follow-completion"); + expect(camera.cancel).not.toHaveBeenCalled(); + advanceFrame(); + expect(camera.followTo).toHaveBeenLastCalledWith(1_900); + + setGeometry({ + scrollTop: 1_900, + scrollHeight: 2_450, + }); + coordinator.invalidateGeometry(); + advanceFrame(); + expect(camera.followTo).toHaveBeenLastCalledWith(1_950); + }); + + it("keeps turn identity stable when canonical replay replaces DOM ids", () => { + const { + camera, + coordinator, + measure, + advanceFrame, + setGeometry, + } = motionHarness(); + coordinator.updateTurn({ + id: "turn-stable", + promptId: "optimistic-prompt", + hasOutput: true, + }); + advanceFrame(); + camera.jumpTo.mockClear(); + measure.mockClear(); + + setGeometry({ scrollHeight: 2_020, promptTop: 120 }); + coordinator.updateTurn({ + id: "turn-stable", + promptId: "canonical-prompt", + hasOutput: true, + }); + advanceFrame(); + + expect(measure).toHaveBeenCalledWith(null); + expect(camera.jumpTo).not.toHaveBeenCalled(); + expect(camera.followTo).toHaveBeenLastCalledWith(1_520); + }); + + it("continues from the bottom when a running turn is restored before prompt identity", () => { + const { + camera, + coordinator, + advanceFrame, + } = motionHarness({ + scrollTop: 1_400, + }); + + coordinator.updateTurn({ + id: "turn-restored", + promptId: null, + hasOutput: true, + entry: "restored", + }); + advanceFrame(); + + expect(camera.jumpTo).not.toHaveBeenCalled(); + expect(camera.followTo).toHaveBeenCalledWith(1_400); + expect(coordinator.snapshot()).toMatchObject({ + mode: "follow-output", + promptPositioned: true, + }); + }); + + it("continues measuring while browsing history but does not steal the viewport", () => { + const { + camera, + coordinator, + onGeometry, + advanceFrame, + setGeometry, + } = motionHarness(); + coordinator.updateTurn({ + id: "turn-1", + promptId: "prompt-1", + hasOutput: true, + }); + advanceFrame(); + camera.followTo.mockClear(); + onGeometry.mockClear(); + + coordinator.takeUserControl(); + setGeometry({ scrollTop: 200, scrollHeight: 2_100 }); + coordinator.invalidateGeometry(); + advanceFrame(); + + expect(onGeometry).toHaveBeenCalledTimes(1); + expect(camera.followTo).not.toHaveBeenCalled(); + expect(coordinator.snapshot().mode).toBe("browsing-history"); + + coordinator.resumeAutoFollow(); + advanceFrame(); + expect(camera.followTo).toHaveBeenCalledWith(1_600); + }); + + it("treats scroll events as observations until explicit user intent takes control", () => { + const { + camera, + coordinator, + advanceFrame, + setGeometry, + } = motionHarness({ + scrollTop: 700, + scrollHeight: 1_200, + clientHeight: 500, + maxScrollTop: 700, + }); + coordinator.updateTurn({ + id: "turn-1", + promptId: "prompt-1", + hasOutput: false, + }); + advanceFrame(); + camera.jumpTo.mockClear(); + + setGeometry({ + scrollTop: 700, + scrollHeight: 1_280, + }); + expect(coordinator.observeScroll(false)).toBe("automatic"); + expect(coordinator.snapshot().mode).toBe("anchor-prompt"); + advanceFrame(); + expect(camera.jumpTo).toHaveBeenCalledWith(780); + + coordinator.takeUserControl(); + expect(coordinator.observeScroll(false)).toBe("user"); + expect(coordinator.snapshot().mode).toBe("browsing-history"); + + expect(coordinator.observeScroll(true)).toBe("automatic"); + expect(coordinator.snapshot().mode).toBe("anchor-prompt"); + }); + + it("preserves history browsing when an active turn is cleared", () => { + const { + camera, + coordinator, + advanceFrame, + } = motionHarness(); + coordinator.updateTurn({ + id: "turn-1", + promptId: "prompt-1", + hasOutput: true, + }); + advanceFrame(); + coordinator.takeUserControl(); + camera.followTo.mockClear(); + + coordinator.updateTurn({ + id: null, + promptId: null, + hasOutput: false, + }); + advanceFrame(); + + expect(coordinator.snapshot().mode).toBe("browsing-history"); + expect(camera.followTo).not.toHaveBeenCalled(); + }); + + it("keeps history navigation independent from active turn completion", () => { + const { + camera, + coordinator, + advanceFrame, + } = motionHarness(); + coordinator.updateTurn({ + id: "turn-1", + promptId: "prompt-1", + hasOutput: true, + }); + advanceFrame(); + coordinator.navigateHistoryTo(900); + camera.cancel.mockClear(); + + coordinator.completeTurn(); + + expect(camera.cancel).not.toHaveBeenCalled(); + expect(coordinator.snapshot().mode).toBe("navigating-history"); + }); + + it("moves from rail navigation to history browsing on user intent", () => { + const { + camera, + coordinator, + setCameraFollowing, + } = motionHarness(); + + coordinator.handleUserScrollIntent(false); + expect(coordinator.snapshot().mode).toBe("idle"); + expect(camera.cancel).not.toHaveBeenCalled(); + + coordinator.navigateHistoryTo(900); + expect(camera.navigateTo).toHaveBeenCalledWith(900); + expect(coordinator.snapshot().mode).toBe("navigating-history"); + expect(coordinator.observeScroll(false)).toBe("navigation"); + + camera.cancel.mockClear(); + coordinator.handleUserScrollIntent(false); + expect(camera.cancel).toHaveBeenCalledTimes(1); + expect(coordinator.snapshot().mode).toBe("browsing-history"); + expect(coordinator.observeScroll(false)).toBe("user"); + + coordinator.navigateHistoryTo(700); + camera.cancel.mockClear(); + coordinator.takeUserControl(); + expect(camera.cancel).toHaveBeenCalledTimes(1); + expect(coordinator.snapshot().mode).toBe("browsing-history"); + + camera.cancel.mockClear(); + coordinator.takeUserControl(); + expect(camera.cancel).not.toHaveBeenCalled(); + + coordinator.navigateHistoryTo(600); + setCameraFollowing(false); + expect(coordinator.observeScroll(false)).toBe("navigation"); + expect(coordinator.snapshot().mode).toBe("browsing-history"); + }); + + it("pins a waiting prompt to the exact lower boundary across all layout changes", () => { + const { + camera, + coordinator, + advanceFrame, + setGeometry, + } = motionHarness({ + scrollHeight: 930, + clientHeight: 500, + maxScrollTop: 430, + }); + coordinator.updateTurn({ + id: "turn-1", + promptId: "prompt-1", + hasOutput: false, + }); + advanceFrame(); + expect(camera.jumpTo).toHaveBeenLastCalledWith(430); + + setGeometry({ + composerHeight: 148, + scrollHeight: 958, + promptTop: 404, + }); + coordinator.invalidateGeometry(); + advanceFrame(); + + expect(camera.jumpTo.mock.calls).toEqual([[430], [458]]); + expect(camera.followTo).not.toHaveBeenCalled(); + expect(coordinator.snapshot().mode).toBe("anchor-prompt"); + + setGeometry({ + scrollTop: 458, + scrollHeight: 990, + }); + coordinator.invalidateGeometry(); + advanceFrame(); + + expect(camera.jumpTo.mock.calls).toEqual([[430], [458], [490]]); + }); +}); diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index acc9189e..eed302dd 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { preloadMarkdownText } from "@/components/MarkdownText"; +import { ThreadCameraController } from "@/components/thread/thread-camera"; import { ThreadShell } from "@/components/thread/ThreadShell"; import { CLI_APPS_CHANGED_EVENT } from "@/lib/cli-app-events"; import { ClientProvider } from "@/providers/ClientProvider"; @@ -18,6 +19,7 @@ function makeClient() { (modelName: string | null, modelPreset?: string | null) => void >(); const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>(); + const runStartedAtByChatId = new Map(); const goalStateByChatId = new Map(); return { status: "open" as const, @@ -31,7 +33,7 @@ function makeClient() { runtimeModelHandlers.delete(handler); }; }, - getRunStartedAt: () => null, + getRunStartedAt: (chatId: string) => runStartedAtByChatId.get(chatId) ?? null, getGoalState: (chatId: string) => goalStateByChatId.get(chatId), onChat: (chatId: string, handler: (ev: import("@/lib/types").InboundEvent) => void) => { let handlers = chatHandlers.get(chatId); @@ -60,6 +62,18 @@ function makeClient() { for (const h of errorHandlers) h(err); }, _emitChat(chatId: string, ev: import("@/lib/types").InboundEvent) { + if ( + ev.event === "goal_status" + && ev.status === "running" + && typeof ev.started_at === "number" + ) { + runStartedAtByChatId.set(chatId, ev.started_at); + } else if ( + (ev.event === "goal_status" && ev.status === "idle") + || ev.event === "turn_end" + ) { + runStartedAtByChatId.delete(chatId); + } if (ev.event === "goal_state") { goalStateByChatId.set(chatId, ev.goal_state); } @@ -142,6 +156,36 @@ function httpJson(body: unknown) { }; } +interface ThreadResizeObserverInstance { + elements: Element[]; + callback: ResizeObserverCallback; +} + +function stubThreadResizeObserver() { + const original = globalThis.ResizeObserver; + const observers: ThreadResizeObserverInstance[] = []; + class MockResizeObserver { + elements: Element[] = []; + callback: ResizeObserverCallback; + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + observers.push(this); + } + + observe(element: Element) { + this.elements.push(element); + } + + disconnect() {} + } + vi.stubGlobal("ResizeObserver", MockResizeObserver); + return { + observers, + restore: () => vi.stubGlobal("ResizeObserver", original), + }; +} + function modelSettings(model: string, provider: string): SettingsPayload { return { agent: { @@ -1351,6 +1395,40 @@ describe("ThreadShell", () => { await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument()); }); + it("restores the stop control when returning to a running chat", async () => { + const client = makeClient(); + const view = (chatId: string) => wrap( + client, + {}} + onNewChat={() => {}} + />, + ); + const { rerender } = render(view("chat-a")); + + await act(async () => { + client._emitChat("chat-a", { + event: "goal_status", + chat_id: "chat-a", + status: "running", + started_at: Date.now() / 1000, + }); + }); + expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument(); + + await act(async () => { + rerender(view("chat-b")); + }); + expect(screen.queryByRole("button", { name: "Stop response" })).not.toBeInTheDocument(); + + await act(async () => { + rerender(view("chat-a")); + }); + expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument(); + }); + it("keeps live fork replies when a canonical refresh is missing an earlier assistant answer", async () => { const client = makeClient(); let historyCalls = 0; @@ -1526,9 +1604,7 @@ describe("ThreadShell", () => { it("does not scroll again when canonical history refreshes after a session update", async () => { const client = makeClient(); - const scrollTo = vi.fn(); - const originalScrollTo = HTMLElement.prototype.scrollTo; - HTMLElement.prototype.scrollTo = scrollTo; + const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo"); let historyCalls = 0; vi.stubGlobal( "fetch", @@ -1569,13 +1645,13 @@ describe("ThreadShell", () => { ); await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument()); - await waitFor(() => expect(scrollTo).toHaveBeenCalled()); + await waitFor(() => expect(jumpTo).toHaveBeenCalled()); await act(async () => { for (let i = 0; i < 8; i += 1) { await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); } }); - scrollTo.mockClear(); + jumpTo.mockClear(); await act(async () => { client._emitSessionUpdate("chat-a"); @@ -1583,9 +1659,112 @@ describe("ThreadShell", () => { await waitFor(() => expect(historyCalls).toBe(2)); await waitFor(() => expect(screen.getByText("canonical answer")).toBeInTheDocument()); - expect(scrollTo).not.toHaveBeenCalled(); + expect(jumpTo).not.toHaveBeenCalled(); } finally { - HTMLElement.prototype.scrollTo = originalScrollTo; + jumpTo.mockRestore(); + } + }); + + it("keeps an active completion follow alive while canonical history refreshes", async () => { + const resizeObserver = stubThreadResizeObserver(); + const client = makeClient(); + let historyCalls = 0; + const pendingRefresh = new Promise>(() => {}); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("websocket%3Achat-follow/webui-thread")) { + historyCalls += 1; + if (historyCalls > 1) return pendingRefresh; + return httpJson( + transcriptFromSimpleMessages([ + { role: "user", content: "old question" }, + { role: "assistant", content: "old answer" }, + ]), + ); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + const requestFrame = vi.spyOn(window, "requestAnimationFrame"); + const cancelFrame = vi.spyOn(window, "cancelAnimationFrame"); + try { + const { container } = render( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + + await waitFor(() => expect(screen.getByText("old answer")).toBeInTheDocument()); + const scroller = container.querySelector(".thread-viewport-scrollbar") as HTMLElement; + Object.defineProperties(scroller, { + scrollHeight: { configurable: true, value: 2_000 }, + clientHeight: { configurable: true, value: 500 }, + scrollTop: { configurable: true, writable: true, value: 1_500 }, + scrollTo: { + configurable: true, + value: ({ top }: ScrollToOptions) => { + if (typeof top === "number") scroller.scrollTop = top; + }, + }, + }); + + fireEvent.change(screen.getByLabelText("Message input"), { + target: { value: "new question" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Send message" })); + await waitFor(() => expect(screen.getByText("new question")).toBeInTheDocument()); + + await act(async () => { + client._emitChat("chat-follow", { + event: "delta", + chat_id: "chat-follow", + text: "new answer", + }); + }); + await waitFor(() => expect(screen.getByText("new answer")).toBeInTheDocument()); + + requestFrame.mockImplementation(() => 9_001); + cancelFrame.mockImplementation(() => undefined); + cancelFrame.mockClear(); + Object.defineProperty(scroller, "scrollHeight", { + configurable: true, + value: 2_120, + }); + const messageContent = screen.getByTestId("thread-message-region").firstElementChild; + const contentObserver = resizeObserver.observers.find( + (observer) => observer.elements.includes(messageContent!), + ); + expect(contentObserver).toBeDefined(); + + act(() => { + contentObserver!.callback([], contentObserver as unknown as ResizeObserver); + }); + expect(requestFrame).toHaveBeenCalled(); + cancelFrame.mockClear(); + + act(() => { + client._emitSessionUpdate("chat-follow", "thread"); + }); + + expect(cancelFrame).not.toHaveBeenCalledWith(9_001); + expect(historyCalls).toBe(2); + } finally { + requestFrame.mockRestore(); + cancelFrame.mockRestore(); + resizeObserver.restore(); } }); @@ -1649,12 +1828,7 @@ describe("ThreadShell", () => { }); await waitFor(() => expect(screen.getByText("loaded answer")).toBeInTheDocument()); - await waitFor(() => - expect(scrollTo).toHaveBeenCalledWith({ - top: 1800, - behavior: "auto", - }), - ); + await waitFor(() => expect(scroller.scrollTop).toBe(1800)); }); it("opens slash commands on the blank welcome page", async () => { diff --git a/webui/src/tests/thread-viewport.test.tsx b/webui/src/tests/thread-viewport.test.tsx index 6ae3e815..02a65a09 100644 --- a/webui/src/tests/thread-viewport.test.tsx +++ b/webui/src/tests/thread-viewport.test.tsx @@ -1,6 +1,6 @@ -import { useRef } from "react"; +import { createRef, useRef } from "react"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { PromptNavigator } from "@/components/thread/PromptNavigator"; import { @@ -10,6 +10,7 @@ import { type ThreadViewportHandle, windowMessages, } from "@/components/thread/ThreadViewport"; +import { ThreadCameraController } from "@/components/thread/thread-camera"; import type { UIMessage } from "@/lib/types"; const messages: UIMessage[] = [ @@ -23,8 +24,23 @@ const messages: UIMessage[] = [ const emptyMessages: UIMessage[] = []; +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function flushAnimationFrame(): Promise { + await act(async () => { + await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); + }); +} + +function dispatchUserScroll(scroller: HTMLElement): void { + fireEvent.pointerDown(scroller, { button: 0 }); + scroller.dispatchEvent(new Event("scroll")); +} + interface ResizeObserverInstance { - element?: Element; + elements: Element[]; callback: ResizeObserverCallback; disconnect: ReturnType; } @@ -86,7 +102,7 @@ function stubResizeObserver() { const original = globalThis.ResizeObserver; const observers: ResizeObserverInstance[] = []; class MockResizeObserver { - element?: Element; + elements: Element[] = []; callback: ResizeObserverCallback; disconnect = vi.fn(); @@ -96,7 +112,7 @@ function stubResizeObserver() { } observe(element: Element) { - this.element = element; + this.elements.push(element); } } vi.stubGlobal("ResizeObserver", MockResizeObserver); @@ -150,7 +166,7 @@ async function renderPromptRailViewport({ Object.defineProperties(scroller, { scrollHeight: { configurable: true, value: 1800 }, clientHeight: { configurable: true, value: 600 }, - scrollTop: { configurable: true, value: 0 }, + scrollTop: { configurable: true, writable: true, value: 0 }, ...(scrollTo ? { scrollTo: { configurable: true, value: scrollTo } } : {}), }); @@ -169,7 +185,7 @@ async function renderPromptRailViewport({ await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); }); - return { promptEls }; + return { promptEls, scroller }; } function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) { @@ -222,11 +238,12 @@ describe("ThreadViewport", () => { expect(screen.getByTestId("thread-composer-dock")).not.toHaveClass("mt-auto"); }); - it("anchors the latest user prompt after sending instead of scrolling to the bottom", async () => { + it("pins a waiting prompt to the exact lower scroll boundary", async () => { + const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo"); const threaded: UIMessage[] = [ - { id: "u1", role: "user", content: "old question", createdAt: 1 }, - { id: "a1", role: "assistant", content: "old answer", createdAt: 2 }, - { id: "u2", role: "user", content: "new question", createdAt: 3 }, + { id: "u1", role: "user", content: "old question", turnId: "turn-1", createdAt: 1 }, + { id: "a1", role: "assistant", content: "old answer", turnId: "turn-1", createdAt: 2 }, + { id: "u2", role: "user", content: "new question", turnId: "turn-2", createdAt: 3 }, ]; const scrollTo = vi.fn(); const { container, rerender } = render( @@ -234,7 +251,6 @@ describe("ThreadViewport", () => { messages={threaded} isStreaming composer={
composer
} - scrollToLatestUserPromptSignal={0} />, ); @@ -252,6 +268,7 @@ describe("ThreadViewport", () => { value: 420, }); scrollTo.mockClear(); + jumpTo.mockClear(); await act(async () => { rerender( @@ -259,46 +276,36 @@ describe("ThreadViewport", () => { messages={threaded} isStreaming composer={
composer
} - scrollToLatestUserPromptSignal={1} + activeTurnId="turn-2" + activeTurnStartedHere />, ); }); + await flushAnimationFrame(); - expect(scrollTo).toHaveBeenCalledWith({ - top: 404, - behavior: "auto", - }); + expect(jumpTo).toHaveBeenCalledWith(700); expect(screen.getByTestId("thread-message-region")).toHaveClass("justify-start"); }); - it("keeps following active agent output after anchoring the sent prompt", async () => { + it("keeps automatic ownership when layout emits a scroll before its measurement frame", async () => { + const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo"); const threaded: UIMessage[] = [ - { id: "u1", role: "user", content: "old question", createdAt: 1 }, - { id: "a1", role: "assistant", content: "old answer", createdAt: 2 }, - { id: "u2", role: "user", content: "new question", createdAt: 3 }, + { id: "u1", role: "user", content: "old question", turnId: "turn-1", createdAt: 1 }, + { id: "a1", role: "assistant", content: "old answer", turnId: "turn-1", createdAt: 2 }, + { id: "u2", role: "user", content: "new question", turnId: "turn-2", createdAt: 3 }, ]; - const answer: UIMessage = { - id: "a2", - role: "assistant", - content: "streaming answer", - createdAt: 4, - }; - const scrollTo = vi.fn(); const { container, rerender } = render( composer
} - scrollToLatestUserPromptSignal={0} />, ); - const scroller = container.firstElementChild?.firstElementChild as HTMLElement; Object.defineProperties(scroller, { - scrollHeight: { configurable: true, value: 1200 }, + scrollHeight: { configurable: true, value: 1_200 }, clientHeight: { configurable: true, value: 500 }, scrollTop: { configurable: true, writable: true, value: 700 }, - scrollTo: { configurable: true, value: scrollTo }, }); const prompt = container.querySelector('[data-user-prompt-id="u2"]'); expect(prompt).not.toBeNull(); @@ -306,6 +313,7 @@ describe("ThreadViewport", () => { configurable: true, value: 420, }); + jumpTo.mockClear(); await act(async () => { rerender( @@ -313,66 +321,38 @@ describe("ThreadViewport", () => { messages={threaded} isStreaming composer={
composer
} - scrollToLatestUserPromptSignal={1} + activeTurnId="turn-2" + activeTurnStartedHere />, ); }); - expect(scrollTo).toHaveBeenCalledWith({ - top: 404, - behavior: "auto", + Object.defineProperty(scroller, "scrollHeight", { + configurable: true, + value: 1_280, }); - act(() => { scroller.dispatchEvent(new Event("scroll")); }); - Object.defineProperty(scroller, "scrollHeight", { - configurable: true, - value: 1800, - }); - scrollTo.mockClear(); - await act(async () => { - rerender( - composer
} - scrollToLatestUserPromptSignal={1} - />, - ); - }); - - await waitFor(() => - expect(scrollTo).toHaveBeenCalledWith({ - top: 1300, - behavior: "auto", - }), - ); + expect(screen.queryByRole("button", { name: "Scroll to bottom" })) + .not.toBeInTheDocument(); + await flushAnimationFrame(); + expect(jumpTo).toHaveBeenCalledWith(780); + expect(scroller.scrollTop).toBe(780); }); - it("does not follow active agent output after the user manually scrolls away", async () => { - const threaded: UIMessage[] = [ - { id: "u1", role: "user", content: "old question", createdAt: 1 }, - { id: "a1", role: "assistant", content: "old answer", createdAt: 2 }, - { id: "u2", role: "user", content: "new question", createdAt: 3 }, - ]; - const answer: UIMessage = { - id: "a2", - role: "assistant", - content: "streaming answer", - createdAt: 4, - }; + it("lets the first prompt supersede a pending empty-conversation camera command", async () => { + const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo"); const scrollTo = vi.fn(); const { container, rerender } = render( composer
} - scrollToLatestUserPromptSignal={0} + conversationKey={null} />, ); - const scroller = container.firstElementChild?.firstElementChild as HTMLElement; Object.defineProperties(scroller, { scrollHeight: { configurable: true, value: 1200 }, @@ -380,52 +360,340 @@ describe("ThreadViewport", () => { scrollTop: { configurable: true, writable: true, value: 700 }, scrollTo: { configurable: true, value: scrollTo }, }); + scrollTo.mockClear(); + jumpTo.mockClear(); + + await act(async () => { + rerender( + composer
} + conversationKey="chat-a" + activeTurnId="turn-first" + activeTurnStartedHere + />, + ); + }); + await flushAnimationFrame(); + + expect(jumpTo).toHaveBeenCalledTimes(1); + expect(jumpTo).toHaveBeenCalledWith(700); + }); + + it("anchors the latest user prompt even when assistant output is already present", async () => { + const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo"); + const threaded: UIMessage[] = [ + { id: "u1", role: "user", content: "old question", turnId: "turn-1", createdAt: 1 }, + { id: "a1", role: "assistant", content: "old answer", turnId: "turn-1", createdAt: 2 }, + { id: "u2", role: "user", content: "new question", turnId: "turn-2", createdAt: 3 }, + { id: "a2", role: "assistant", content: "fast answer", turnId: "turn-2", createdAt: 4 }, + ]; + const viewportRef = createRef(); + const scrollTo = vi.fn(); + const { container, rerender } = render( + composer
} + />, + ); + + const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + Object.defineProperties(scroller, { + scrollHeight: { configurable: true, value: 1904 }, + clientHeight: { configurable: true, value: 500 }, + scrollTop: { configurable: true, writable: true, value: 1404 }, + scrollTo: { configurable: true, value: scrollTo }, + }); const prompt = container.querySelector('[data-user-prompt-id="u2"]'); expect(prompt).not.toBeNull(); Object.defineProperty(prompt, "offsetTop", { configurable: true, - value: 420, + value: 1420, }); + viewportRef.current?.cancelAutoScroll(); + scrollTo.mockClear(); + jumpTo.mockClear(); await act(async () => { rerender( + composer
} + activeTurnId="turn-2" + activeTurnStartedHere + />, + ); + }); + await flushAnimationFrame(); + + expect(jumpTo).toHaveBeenCalledWith(1404); + }); + + it("drives the camera from a message commit when canonical replay replaces the prompt DOM id", async () => { + const resizeObserver = stubResizeObserver(); + const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo") + .mockReturnValue("started"); + + try { + const optimistic: UIMessage[] = [ + { + id: "optimistic-user", + role: "user", + content: "new question", + turnId: "turn-stable", + createdAt: 1, + }, + { + id: "optimistic-answer", + role: "assistant", + content: "answer", + turnId: "turn-stable", + isStreaming: true, + createdAt: 2, + }, + ]; + const { container, rerender } = render( + composer
} + />, + ); + const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + Object.defineProperties(scroller, { + scrollHeight: { configurable: true, value: 2_000 }, + clientHeight: { configurable: true, value: 500 }, + scrollTop: { configurable: true, writable: true, value: 1_500 }, + scrollTo: { + configurable: true, + value: ({ top }: ScrollToOptions) => { + if (typeof top === "number") scroller.scrollTop = top; + }, + }, + }); + const optimisticPrompt = container.querySelector( + '[data-user-prompt-id="optimistic-user"]', + ); + expect(optimisticPrompt).not.toBeNull(); + Object.defineProperty(optimisticPrompt, "offsetTop", { + configurable: true, + value: 1_420, + }); + + await act(async () => { + rerender( + composer} + activeTurnId="turn-stable" + />, + ); + }); + followTo.mockClear(); + + await act(async () => { + rerender( + composer} + activeTurnId="turn-stable" + />, + ); + }); + await flushAnimationFrame(); + // Streaming React commits only invalidate geometry. The next display + // frame measures once and issues the follow command. + expect(followTo).toHaveBeenCalledWith(1_500); + + followTo.mockClear(); + Object.defineProperty(scroller, "scrollHeight", { + configurable: true, + value: 2_120, + }); + const messageContent = screen.getByTestId("thread-message-region").firstElementChild; + const contentObserver = resizeObserver.observers.find( + (observer) => observer.elements.includes(messageContent!), + ); + expect(contentObserver).toBeDefined(); + + act(() => { + contentObserver!.callback([], contentObserver as unknown as ResizeObserver); + }); + await flushAnimationFrame(); + + // ResizeObserver remains the asynchronous reflow fallback. + expect(followTo).toHaveBeenCalledWith(1_620); + } finally { + followTo.mockRestore(); + resizeObserver.restore(); + } + }); + + it("coalesces streamed layout growth into frame-driven camera targets", async () => { + const resizeObserver = stubResizeObserver(); + const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo") + .mockReturnValue("started"); + + try { + const threaded: UIMessage[] = [ + { id: "u1", role: "user", content: "old question", turnId: "turn-1", createdAt: 1 }, + { id: "a1", role: "assistant", content: "old answer", turnId: "turn-1", createdAt: 2 }, + { id: "u2", role: "user", content: "new question", turnId: "turn-2", createdAt: 3 }, + ]; + const answer: UIMessage = { + id: "a2", + role: "assistant", + content: "streaming answer", + turnId: "turn-2", + isStreaming: true, + createdAt: 4, + }; + const { container, rerender } = render( composer} - scrollToLatestUserPromptSignal={1} />, ); - }); - await act(async () => { - await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); - }); - scroller.scrollTop = 100; - act(() => { - scroller.dispatchEvent(new Event("scroll")); - }); - Object.defineProperty(scroller, "scrollHeight", { - configurable: true, - value: 1800, - }); - scrollTo.mockClear(); + const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + Object.defineProperties(scroller, { + scrollHeight: { configurable: true, value: 1904 }, + clientHeight: { configurable: true, value: 500 }, + scrollTop: { configurable: true, writable: true, value: 1404 }, + }); + const prompt = container.querySelector('[data-user-prompt-id="u2"]'); + expect(prompt).not.toBeNull(); + Object.defineProperty(prompt, "offsetTop", { + configurable: true, + value: 1420, + }); - await act(async () => { - rerender( - composer} - scrollToLatestUserPromptSignal={1} - />, + await act(async () => { + rerender( + composer} + activeTurnId="turn-2" + activeTurnStartedHere + />, + ); + }); + await flushAnimationFrame(); + followTo.mockClear(); + + Object.defineProperty(scroller, "scrollHeight", { + configurable: true, + value: 1932, + }); + await act(async () => { + rerender( + composer} + activeTurnId="turn-2" + />, + ); + }); + await flushAnimationFrame(); + expect(followTo).toHaveBeenCalledTimes(1); + expect(followTo).toHaveBeenLastCalledWith(1432); + followTo.mockClear(); + + const messageRegion = screen.getByTestId("thread-message-region"); + const messageContent = messageRegion.firstElementChild; + expect(messageContent).not.toBeNull(); + const contentObserver = resizeObserver.observers.find( + (observer) => observer.elements.includes(messageContent!), ); - }); + expect(contentObserver).toBeDefined(); - expect(scrollTo).not.toHaveBeenCalled(); + Object.defineProperty(scroller, "scrollHeight", { + configurable: true, + value: 1948, + }); + act(() => { + contentObserver!.callback([], contentObserver as unknown as ResizeObserver); + contentObserver!.callback([], contentObserver as unknown as ResizeObserver); + }); + expect(followTo).not.toHaveBeenCalled(); + await flushAnimationFrame(); + expect(followTo).toHaveBeenCalledTimes(1); + expect(followTo).toHaveBeenLastCalledWith(1448); + followTo.mockClear(); + + Object.defineProperty(scroller, "scrollHeight", { + configurable: true, + value: 2548, + }); + await act(async () => { + rerender( + composer} + />, + ); + }); + await flushAnimationFrame(); + expect(followTo).toHaveBeenLastCalledWith(2048); + followTo.mockClear(); + + scroller.scrollTop = 100; + act(() => { + dispatchUserScroll(scroller); + }); + Object.defineProperty(scroller, "scrollHeight", { + configurable: true, + value: 2560, + }); + act(() => { + contentObserver!.callback([], contentObserver as unknown as ResizeObserver); + }); + await flushAnimationFrame(); + expect(followTo).not.toHaveBeenCalled(); + expect(scroller.scrollTop).toBe(100); + } finally { + followTo.mockRestore(); + resizeObserver.restore(); + } }); - it("keeps the scroll-to-bottom button above a growing composer", () => { + it("keeps the scroll-to-bottom button above a growing composer", async () => { const resizeObserver = stubResizeObserver(); try { @@ -444,7 +712,7 @@ describe("ThreadViewport", () => { }); act(() => { - scroller.dispatchEvent(new Event("scroll")); + dispatchUserScroll(scroller); }); const button = screen.getByRole("button", { name: "Scroll to bottom" }); @@ -467,13 +735,14 @@ describe("ThreadViewport", () => { }) as DOMRect; const composerObserver = resizeObserver.observers.find( - (observer) => observer.element === composerDock, + (observer) => observer.elements.includes(composerDock), ); expect(composerObserver).toBeDefined(); act(() => { composerObserver!.callback([], composerObserver as unknown as ResizeObserver); }); + await flushAnimationFrame(); expect(buttonPositioner).toHaveStyle({ bottom: "256px" }); } finally { @@ -481,14 +750,15 @@ describe("ThreadViewport", () => { } }); - it("keeps the active prompt visible when the composer grows", async () => { + it("pins the waiting boundary across composer and grid-track growth", async () => { const resizeObserver = stubResizeObserver(); + const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo"); try { const threaded: UIMessage[] = [ - { id: "u1", role: "user", content: "old question", createdAt: 1 }, - { id: "a1", role: "assistant", content: "old answer", createdAt: 2 }, - { id: "u2", role: "user", content: "new question", createdAt: 3 }, + { id: "u1", role: "user", content: "old question", turnId: "turn-1", createdAt: 1 }, + { id: "a1", role: "assistant", content: "old answer", turnId: "turn-1", createdAt: 2 }, + { id: "u2", role: "user", content: "new question", turnId: "turn-2", createdAt: 3 }, ]; const scrollTo = vi.fn(); const { container, rerender } = render( @@ -496,7 +766,6 @@ describe("ThreadViewport", () => { messages={threaded} isStreaming composer={
composer
} - scrollToLatestUserPromptSignal={0} />, ); @@ -520,12 +789,14 @@ describe("ThreadViewport", () => { messages={threaded} isStreaming composer={
composer
} - scrollToLatestUserPromptSignal={1} + activeTurnId="turn-2" />, ); }); + await flushAnimationFrame(); scrollTo.mockClear(); + jumpTo.mockClear(); const composerDock = screen.getByTestId("thread-composer-dock"); composerDock.getBoundingClientRect = () => ({ @@ -540,20 +811,35 @@ describe("ThreadViewport", () => { toJSON: () => ({}), }) as DOMRect; const composerObserver = resizeObserver.observers.find( - (observer) => observer.element === composerDock, + (observer) => observer.elements.includes(composerDock), ); expect(composerObserver).toBeDefined(); + expect(composerObserver!.elements).toContain( + screen.getByTestId("thread-message-region"), + ); + Object.defineProperty(scroller, "scrollHeight", { + configurable: true, + value: 1228, + }); act(() => { composerObserver!.callback([], composerObserver as unknown as ResizeObserver); }); + await flushAnimationFrame(); - await waitFor(() => - expect(scrollTo).toHaveBeenCalledWith({ - top: 404, - behavior: "auto", - }), - ); + expect(jumpTo).toHaveBeenCalledWith(728); + + jumpTo.mockClear(); + Object.defineProperty(scroller, "scrollHeight", { + configurable: true, + value: 1268, + }); + act(() => { + composerObserver!.callback([], composerObserver as unknown as ResizeObserver); + }); + await flushAnimationFrame(); + + expect(jumpTo).toHaveBeenCalledWith(768); } finally { resizeObserver.restore(); } @@ -577,7 +863,7 @@ describe("ThreadViewport", () => { }); act(() => { - scroller.dispatchEvent(new Event("scroll")); + dispatchUserScroll(scroller); }); const input = screen.getByLabelText("Message input"); @@ -624,6 +910,47 @@ describe("ThreadViewport", () => { } }); + it("keeps one composer instance while the landing layout docks into a thread", () => { + const { rerender } = render( + + )} + emptyState={
welcome
} + />, + ); + + const initialComposer = screen.getByTestId("persistent-composer"); + fireEvent.change(initialComposer, { target: { value: "kept draft" } }); + + rerender( + + )} + emptyState={
welcome
} + />, + ); + + const dockedComposer = screen.getByTestId("persistent-composer"); + expect(dockedComposer).toBe(initialComposer); + expect(dockedComposer).toHaveValue("kept draft"); + expect(dockedComposer.closest(".thread-layout")).toHaveAttribute( + "data-layout", + "thread", + ); + }); + it("separates the mobile welcome copy and composer into responsive rows", () => { render( { ); const layout = screen.getByTestId("thread-welcome-layout"); - expect(layout).toHaveClass( - "grid", - "gap-8", - "grid-rows-[minmax(min-content,1fr)_auto]", + expect(layout).toHaveClass("thread-layout", "grid"); + expect(layout).toHaveAttribute("data-layout", "hero"); + expect(screen.getByText("welcome").parentElement).toHaveClass( + "min-h-0", + "items-center", + "sm:items-end", + "sm:pb-8", ); - expect(screen.getByText("welcome").parentElement).toHaveClass("min-h-0"); - expect(screen.getByText("welcome").parentElement?.className).toContain( - "sm:bottom-[calc(100%+2rem)]", + expect(screen.getByTestId("thread-composer-motion")).toContainElement( + screen.getByText("composer"), ); }); @@ -667,6 +996,10 @@ describe("ThreadViewport", () => { }); await waitFor(() => expect(scroller).toHaveClass("overflow-y-auto")); + expect(scroller).toHaveClass( + "[scrollbar-width:none]", + "[&::-webkit-scrollbar]:hidden", + ); }); it("scrolls recent messages into view when the composer receives focus", async () => { @@ -687,7 +1020,7 @@ describe("ThreadViewport", () => { }); act(() => { - scroller.dispatchEvent(new Event("scroll")); + dispatchUserScroll(scroller); }); scrollTo.mockClear(); @@ -697,12 +1030,7 @@ describe("ThreadViewport", () => { fireEvent.focusIn(input); }); - await waitFor(() => - expect(scrollTo).toHaveBeenCalledWith({ - top: 1800, - behavior: "auto", - }), - ); + await waitFor(() => expect(scroller.scrollTop).toBe(1800)); }); it("scrolls recent messages into view when the focused composer resizes the visual viewport without an inset", async () => { @@ -735,12 +1063,7 @@ describe("ThreadViewport", () => { visualViewport.viewport.dispatchEvent(new Event("resize")); }); - await waitFor(() => - expect(scrollTo).toHaveBeenCalledWith({ - top: 1800, - behavior: "auto", - }), - ); + await waitFor(() => expect(scroller.scrollTop).toBe(1800)); expect(scroller).not.toHaveStyle({ bottom: "320px" }); } finally { Reflect.deleteProperty(document, "activeElement"); @@ -766,7 +1089,7 @@ describe("ThreadViewport", () => { }); act(() => { - scroller.dispatchEvent(new Event("scroll")); + dispatchUserScroll(scroller); }); expect(screen.queryByRole("button", { name: "Scroll to bottom" })).not.toBeInTheDocument(); @@ -808,7 +1131,7 @@ describe("ThreadViewport", () => { }); act(() => { - scroller.dispatchEvent(new Event("scroll")); + dispatchUserScroll(scroller); }); const firstVisible = @@ -842,13 +1165,15 @@ describe("ThreadViewport", () => { }); act(() => { - scroller.dispatchEvent(new Event("scroll")); + dispatchUserScroll(scroller); }); expect(onLoadOlder).toHaveBeenCalledTimes(1); }); it("renders a prompt rail that jumps to user messages", async () => { + const navigateTo = vi.spyOn(ThreadCameraController.prototype, "navigateTo") + .mockReturnValue("started"); const scrollTo = vi.fn(); const { promptEls } = await renderPromptRailViewport({ scrollTo }); @@ -883,13 +1208,51 @@ describe("ThreadViewport", () => { fireEvent.click(targetPrompt); - expect(scrollTo).toHaveBeenCalledWith({ - top: 1064, - behavior: "smooth", - }); + expect(navigateTo).toHaveBeenCalledWith(1064); + }); + + it("lets direct paging input interrupt prompt rail navigation", async () => { + vi.spyOn(ThreadCameraController.prototype, "navigateTo") + .mockReturnValue("started"); + const cancel = vi.spyOn(ThreadCameraController.prototype, "cancel"); + const { scroller } = await renderPromptRailViewport(); + const targetPrompt = screen.getByRole("button", { name: "Jump to prompt: message 3" }); + const restartNavigation = () => { + fireEvent.click(targetPrompt); + cancel.mockClear(); + }; + + scroller.scrollTop = 500; + restartNavigation(); + fireEvent.wheel(scroller, { deltaY: 120 }); + expect(cancel).toHaveBeenCalledTimes(1); + + restartNavigation(); + fireEvent.wheel(scroller, { deltaY: -120 }); + expect(cancel).toHaveBeenCalledTimes(1); + + restartNavigation(); + fireEvent.keyDown(scroller, { key: "PageDown" }); + expect(cancel).toHaveBeenCalledTimes(1); + + restartNavigation(); + fireEvent.touchStart(scroller, { touches: [{ clientY: 300 }] }); + fireEvent.touchMove(scroller, { touches: [{ clientY: 200 }] }); + expect(cancel).toHaveBeenCalledTimes(1); + + restartNavigation(); + scroller.scrollTop = 1200; + fireEvent.wheel(scroller, { deltaY: 120 }); + expect(cancel).toHaveBeenCalledTimes(1); + + cancel.mockClear(); + fireEvent.wheel(scroller, { deltaY: 120 }); + expect(cancel).not.toHaveBeenCalled(); }); it("opens a prompt navigator list and jumps to a selected prompt", async () => { + const navigateTo = vi.spyOn(ThreadCameraController.prototype, "navigateTo") + .mockReturnValue("started"); const promptMessages = makeLongMessages(5); const { container } = render(); @@ -924,10 +1287,7 @@ describe("ThreadViewport", () => { fireEvent.click(within(dialog).getByRole("button", { name: "Jump to prompt: message 4" })); - expect(scrollTo).toHaveBeenCalledWith({ - top: 1424, - behavior: "smooth", - }); + expect(navigateTo).toHaveBeenCalledWith(1200); }); it("expands the history window before jumping to an older prompt from the navigator", async () => { @@ -980,6 +1340,8 @@ describe("ThreadViewport", () => { }); it("buckets dense prompt rails without rendering every prompt as a marker", async () => { + const navigateTo = vi.spyOn(ThreadCameraController.prototype, "navigateTo") + .mockReturnValue("started"); const promptMessages = makeLongMessages(100); const { container } = render( { fireEvent.click(promptMarkers[promptMarkers.length - 1]); - expect(scrollTo).toHaveBeenCalledWith({ - top: 8894, - behavior: "smooth", - }); + expect(navigateTo).toHaveBeenCalledWith(8894); }); it("expands the window start to avoid cutting an agent activity cluster", () => { @@ -1084,7 +1443,7 @@ describe("ThreadViewport", () => { scrollTo: { configurable: true, value: scrollTo }, }); act(() => { - scroller.dispatchEvent(new Event("scroll")); + dispatchUserScroll(scroller); }); scrollTo.mockClear(); @@ -1097,15 +1456,90 @@ describe("ThreadViewport", () => { />, ); - await waitFor(() => - expect(scrollTo).toHaveBeenCalledWith({ - top: 1800, - behavior: "auto", - }), + await waitFor(() => expect(scroller.scrollTop).toBe(1800)); + }); + + it("waits for the next conversation's transcript before restoring its bottom", async () => { + const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo"); + const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo"); + const oldMessages: UIMessage[] = [ + { + id: "old-user", + role: "user", + content: "old question", + turnId: "old-turn", + createdAt: 1, + }, + ]; + const nextMessages: UIMessage[] = [ + { + id: "next-user", + role: "user", + content: "next question", + turnId: "next-turn", + createdAt: 2, + }, + { + id: "next-answer", + role: "assistant", + content: "next answer", + turnId: "next-turn", + createdAt: 3, + }, + ]; + const { container, rerender } = render( + } + conversationKey="chat-a" + activeTurnId="old-turn" + />, ); + const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + Object.defineProperties(scroller, { + scrollHeight: { configurable: true, value: 2400 }, + clientHeight: { configurable: true, value: 600 }, + scrollTop: { configurable: true, writable: true, value: 300 }, + }); + jumpTo.mockClear(); + + rerender( + } + conversationKey="chat-b" + activeTurnId="old-turn" + conversationReady={false} + />, + ); + expect(scroller.scrollTop).toBe(300); + expect(jumpTo).not.toHaveBeenCalled(); + + Object.defineProperty(scroller, "scrollHeight", { + configurable: true, + value: 3000, + }); + rerender( + } + conversationKey="chat-b" + activeTurnId="next-turn" + conversationReady + />, + ); + + await waitFor(() => expect(scroller.scrollTop).toBe(2400)); + await flushAnimationFrame(); + expect(jumpTo.mock.calls).toEqual([[2400]]); + expect(followTo).toHaveBeenCalledWith(2400); }); it("waits for hydrated messages before fulfilling open-chat bottom scroll", async () => { + const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo"); const scrollTo = vi.fn(); const { container, rerender } = render( { scrollTo: { configurable: true, value: scrollTo }, }); scrollTo.mockClear(); + jumpTo.mockClear(); rerender( { conversationKey="chat-a" />, ); - expect(scrollTo).toHaveBeenCalledWith({ - top: 0, - behavior: "auto", - }); + expect(jumpTo).toHaveBeenCalledWith(0); Object.defineProperty(scroller, "scrollHeight", { configurable: true, value: 2400, }); scrollTo.mockClear(); + jumpTo.mockClear(); rerender( { />, ); - await waitFor(() => - expect(scrollTo).toHaveBeenCalledWith({ - top: 1800, - behavior: "auto", - }), - ); + await waitFor(() => expect(scroller.scrollTop).toBe(1800)); + expect(jumpTo).toHaveBeenCalledWith(1800); }); it("scrolls to the bottom when explicitly signalled after send", async () => { @@ -1188,11 +1617,6 @@ describe("ThreadViewport", () => { />, ); - await waitFor(() => - expect(scrollTo).toHaveBeenCalledWith({ - top: 1800, - behavior: "auto", - }), - ); + await waitFor(() => expect(scroller.scrollTop).toBe(1800)); }); }); diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index dfbb9da3..c5cd49a3 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -1569,6 +1569,33 @@ describe("useNanobotStream", () => { expect(result.current.messages[0].turnPhase).toBe("user"); }); + it("returns the submitted turn identity used by the optimistic row and wire frame", () => { + const fake = fakeClient(); + const { result } = renderHook( + () => useNanobotStream("chat-submitted-turn", EMPTY_MESSAGES), + { wrapper: wrap(fake.client) }, + ); + + let submitted: ReturnType = null; + act(() => { + submitted = result.current.send("bind the camera"); + }); + + expect(submitted).not.toBeNull(); + expect(submitted?.sideChannel).toBe(false); + expect(result.current.messages[0]).toMatchObject({ + id: submitted?.userMessageId, + turnId: submitted?.turnId, + role: "user", + }); + expect(fake.client.sendMessage).toHaveBeenCalledWith( + "chat-submitted-turn", + "bind the camera", + undefined, + expect.objectContaining({ turnId: submitted?.turnId }), + ); + }); + it("adds optimistic user file attachments as media", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-file-send", EMPTY_MESSAGES), { @@ -2097,30 +2124,39 @@ describe("useNanobotStream", () => { }); }); - it("stamps latency on the last assistant bubble from turn_end", () => { - const fake = fakeClient(); - const { result } = renderHook(() => useNanobotStream("chat-lat", EMPTY_MESSAGES), { - wrapper: wrap(fake.client), - }); - - act(() => { - fake.emit("chat-lat", { - event: "delta", - chat_id: "chat-lat", - text: "Hi", + it("stamps completion time and latency on the last assistant bubble from turn_end", () => { + const completedAt = Date.UTC(2026, 6, 25, 12, 34, 56); + const dateNow = vi.spyOn(Date, "now").mockReturnValue(completedAt); + try { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-lat", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), }); - }); - act(() => { - fake.emit("chat-lat", { - event: "turn_end", - chat_id: "chat-lat", - latency_ms: 2400, + act(() => { + fake.emit("chat-lat", { + event: "delta", + chat_id: "chat-lat", + text: "Hi", + }); }); - }); - const lastAssistant = [...result.current.messages].reverse().find((m) => m.role === "assistant"); - expect(lastAssistant?.latencyMs).toBe(2400); + act(() => { + fake.emit("chat-lat", { + event: "turn_end", + chat_id: "chat-lat", + latency_ms: 2400, + }); + }); + + const lastAssistant = [...result.current.messages] + .reverse() + .find((m) => m.role === "assistant"); + expect(lastAssistant?.latencyMs).toBe(2400); + expect(lastAssistant?.completedAt).toBe(completedAt); + } finally { + dateNow.mockRestore(); + } }); it("tracks goal_status running and clears on idle", () => { @@ -2130,6 +2166,7 @@ describe("useNanobotStream", () => { }); expect(result.current.runStartedAt).toBeNull(); + expect(result.current.isStreaming).toBe(false); act(() => { fake.emit("chat-g", { @@ -2140,6 +2177,7 @@ describe("useNanobotStream", () => { }); }); expect(result.current.runStartedAt).toBe(1700); + expect(result.current.isStreaming).toBe(true); act(() => { fake.emit("chat-g", { @@ -2149,6 +2187,7 @@ describe("useNanobotStream", () => { }); }); expect(result.current.runStartedAt).toBeNull(); + expect(result.current.isStreaming).toBe(false); }); it("clears runStartedAt on turn_end even without idle", () => { @@ -2166,6 +2205,7 @@ describe("useNanobotStream", () => { }); }); expect(result.current.runStartedAt).toBe(1700); + expect(result.current.isStreaming).toBe(true); act(() => { fake.emit("chat-g", { @@ -2174,6 +2214,7 @@ describe("useNanobotStream", () => { }); }); expect(result.current.runStartedAt).toBeNull(); + expect(result.current.isStreaming).toBe(false); }); it("restores runStartedAt after switching away and back when goal_status was recorded without a subscriber", () => { @@ -2195,9 +2236,11 @@ describe("useNanobotStream", () => { }); }); expect(result.current.runStartedAt).toBe(4242); + expect(result.current.isStreaming).toBe(true); rerender({ chatId: "chat-b" }); expect(result.current.runStartedAt).toBeNull(); + expect(result.current.isStreaming).toBe(false); act(() => { fake.emit("chat-a", { @@ -2210,6 +2253,7 @@ describe("useNanobotStream", () => { rerender({ chatId: "chat-a" }); expect(result.current.runStartedAt).toBe(9001); + expect(result.current.isStreaming).toBe(true); }); it("tracks goal_state per chat and restores after switching sessions", () => {