From 1cc298e16d20f0129c872185b70392ece785bd1c Mon Sep 17 00:00:00 2001 From: abccodes Date: Wed, 10 Jun 2026 04:15:38 -0700 Subject: [PATCH] feat(chat): virtualize long chat transcripts (lazy load/unload + oversized message blocks) --- .../src/app/pages/AgentChat/AgentChat.tsx | 412 +++++++++++++++++- .../pages/AgentChat/bubbles/MessageBubble.tsx | 145 +++++- .../AgentChat/bubbles/WindowedMarkdown.tsx | 144 ++++++ .../AgentChat/bubbles/markdownMeasure.ts | 55 +++ 4 files changed, 740 insertions(+), 16 deletions(-) create mode 100644 frontend/src/app/pages/AgentChat/bubbles/WindowedMarkdown.tsx create mode 100644 frontend/src/app/pages/AgentChat/bubbles/markdownMeasure.ts diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index b0925caf..da74aaf6 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -44,6 +44,7 @@ import { fetchModes } from '@/shared/state/modesSlice'; import { createSessionWs, acquireSessionWs, releaseSessionWs } from '@/shared/ws/WebSocketManager'; import StreamingBubble from './bubbles/StreamingBubble'; import MessageBubble from './bubbles/MessageBubble'; +import { estimateRenderedTextHeight, RECHECK_VISIBILITY_EVENT } from './bubbles/markdownMeasure'; import CompactionMarker from './bubbles/CompactionMarker'; import MessageActionBar from './shell/MessageActionBar'; import ToolCallBubble, { ToolPair } from './tool-bubbles/ToolCallBubble'; @@ -64,12 +65,91 @@ const CONTEXT_WINDOWS: Record = { haiku: 200_000, }; +// Only a fallback for never-rendered items; real heights are measured once on +// screen. +const RENDER_ITEM_ESTIMATED_HEIGHT = 140; +// Conservative estimate for an unmeasured tool row: tool groups/pairs render +// collapsed (~40-50px) far more often than expanded. Leaning low keeps scrollHeight +// (and the scrollbar thumb) from jumping when a tool row measures shorter. +const COLLAPSED_TOOL_ROW_HEIGHT = 44; +// How many screens of real content to keep mounted on EACH side of the viewport. +// Beyond it, items unmount and are replaced by a measured-height spacer, so +// render/memory stays bounded no matter how long the transcript is. +const WINDOW_BUFFER_SCREENS_PER_SIDE = 3; +// Floor on the mounted item count so a single very tall item can't strand us with +// an effectively empty window. +const MIN_WINDOW_BUFFER_ITEMS = 6; + +// Bootstrap count for the initial bottom-anchored slice (on open and on +// scroll-to-bottom): enough rows to cover the viewport + buffer using the same +// row-height estimate the solver uses, floored. It's only a seed — the pixel +// solver (computeDesiredWindow) refines the window to exact from measured heights +// on the next frame, so this never needs to be precise. +function initialSeedItems(viewportHeight: number): number { + const fillPx = (1 + WINDOW_BUFFER_SCREENS_PER_SIDE) * Math.max(1, viewportHeight); + return Math.max(MIN_WINDOW_BUFFER_ITEMS, Math.ceil(fillPx / RENDER_ITEM_ESTIMATED_HEIGHT)); +} + +// Pure window solver: given the current scroll position and a per-index height +// accessor (measured where known, estimated otherwise), return the [start, end) +// slice of render items that should be mounted. The buffer is measured in PIXELS +// (N screens of real content on each side of the viewport), not item count, so a +// few very tall messages can't blow the mounted set up to the whole transcript. +// A huge viewport naturally yields start=0/end=total (mount all). +function computeDesiredWindow( + scrollTop: number, + clientHeight: number, + total: number, + heightOf: (index: number) => number, + bufferPx: number, +): { start: number; end: number } { + if (total <= 0) return { start: 0, end: 0 }; + const keepTop = scrollTop - bufferPx; + const keepBottom = scrollTop + clientHeight + bufferPx; + let offset = 0; + let start = -1; + let end = total; + for (let i = 0; i < total; i++) { + const h = heightOf(i); + const itemTop = offset; + const itemBottom = offset + h; + if (start === -1 && itemBottom > keepTop) start = i; + if (itemTop < keepBottom) { + end = i + 1; + } else { + // Everything past here starts below the keep band. + break; + } + offset += h; + } + if (start === -1) start = Math.max(0, total - 1); + end = Math.min(total, Math.max(end, start + 1)); + // Always keep at least a small floor of items mounted around the viewport so a + // single under-measured item can't strand us with an empty window. + if (end - start < MIN_WINDOW_BUFFER_ITEMS) { + start = Math.max(0, Math.min(start, end - MIN_WINDOW_BUFFER_ITEMS)); + } + return { start: Math.max(0, start), end }; +} + function stringifyContent(content: any): string { if (content == null) return ''; if (typeof content === 'string') return content; return JSON.stringify(content); } +// Content-aware height estimate for a render item that has never been measured. +// Tool rows and tiny system/thinking rows keep the flat fallback; message bubbles +// scale with their FULL text length (messages render in full once on-screen, so +// the estimate matches both the rendered bubble and MessageBubble's placeholder +// fallback). +function estimateItemHeight(item: RenderItem, viewportWidth: number): number { + if (isToolGroup(item) || isToolPair(item)) return COLLAPSED_TOOL_ROW_HEIGHT; + const msg = item as AgentMessage; + if (msg.role === 'thinking' || msg.role === 'system') return 60; + return estimateRenderedTextHeight(stringifyContent(msg.content), viewportWidth); +} + const thinkingShimmerKeyframes = ` @keyframes thinking-shimmer { 0% { background-position: 200% 0; } @@ -196,8 +276,25 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose // tokens to every request; Haiku 4.5's 200K window can't hold 5+ of them. const toolItems = useAppSelector((state) => state.tools.items); const scrollContainerRef = useRef(null); + const lastVisibleItemRef = useRef(null); const chatInputRef = useRef(null); const isAtBottomRef = useRef(true); + const pendingInitialBottomScrollRef = useRef(false); + const initialBottomScrollSettledRef = useRef(false); + const renderItemsLengthRef = useRef(0); + const renderItemsRef = useRef([]); + const itemHeightsRef = useRef>(new Map()); + const estimateCacheRef = useRef>(new Map()); + const viewportWidthRef = useRef(0); + const windowStartRef = useRef(0); + const windowEndRef = useRef(0); + const windowScrollRafRef = useRef(null); + const [viewportHeight, setViewportHeight] = useState(0); + const [viewportWidth, setViewportWidth] = useState(0); + const [scrollRoot, setScrollRoot] = useState(null); + const [windowStart, setWindowStart] = useState(0); + const [windowEnd, setWindowEnd] = useState(0); + const [heightVersion, setHeightVersion] = useState(0); const [showScrollButton, setShowScrollButton] = useState(false); const [showResumeBubble, setShowResumeBubble] = useState(false); const [awaitingResponse, setAwaitingResponse] = useState(false); @@ -421,13 +518,130 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const SCROLL_THRESHOLD = 50; + // Reserved pixel height for a render item: the measured height once we have one, + // otherwise a content-aware estimate (cached per id). The spacer math and the + // window solver both go through this so unmounted spacers, freshly-mounted + // placeholders, and the real rendered bubble all reserve the same space. + const reservedHeightForItem = useCallback((item: RenderItem | undefined): number => { + if (!item) return RENDER_ITEM_ESTIMATED_HEIGHT; + const measured = itemHeightsRef.current.get(item.id); + if (measured != null) return measured; + const cached = estimateCacheRef.current.get(item.id); + if (cached != null) return cached; + const est = estimateItemHeight(item, viewportWidthRef.current); + estimateCacheRef.current.set(item.id, est); + return est; + }, []); + + // Measured-or-estimated pixel height of render item at `index`, for the window + // solver (reads the renderItems ref so it is valid inside rAF callbacks). + const heightOf = useCallback((index: number): number => { + return reservedHeightForItem(renderItemsRef.current[index]); + }, [reservedHeightForItem]); + + // Solve the mounted window from the live scroll position and push it to state + // when it changes. Scroll position itself is preserved by the container's + // overflow-anchor plus the measured-height spacers, so we never touch + // scrollTop here. Following (pinned to bottom) always keeps the newest item. + const applyWindowFromScroll = useCallback(() => { + const el = scrollContainerRef.current; + if (!el) return; + if (!initialBottomScrollSettledRef.current) return; + const total = renderItemsLengthRef.current; + const clientHeight = Math.max(1, el.clientHeight); + const tightPx = WINDOW_BUFFER_SCREENS_PER_SIDE * clientHeight; + // Mount with the tight buffer, but keep already-mounted items until + // they drift a full extra screen past it. Without this, an item sitting + // right on the buffer edge flip-flops mounted/unmounted forever: mounting it + // shifts content above the viewport, overflow-anchor nudges scrollTop a few px, + // that re-runs the solver, which now excludes it, and round it goes. + const loosePx = tightPx + clientHeight; + const tight = computeDesiredWindow(el.scrollTop, clientHeight, total, heightOf, tightPx); + const loose = computeDesiredWindow(el.scrollTop, clientHeight, total, heightOf, loosePx); + const curStart = windowStartRef.current; + const curEnd = windowEndRef.current; + // Must-mount the tight band; keep current edges only while still inside loose. + let start = Math.max(loose.start, Math.min(curStart, tight.start)); + let end = Math.min(loose.end, Math.max(curEnd, tight.end)); + if (isAtBottomRef.current) end = total; + start = Math.max(0, Math.min(start, Math.max(0, end - 1))); + if (start === curStart && end === curEnd) return; + windowStartRef.current = start; + windowEndRef.current = end; + setWindowStart(start); + setWindowEnd(end); + }, [heightOf]); + + const scheduleWindowRecompute = useCallback(() => { + if (windowScrollRafRef.current != null) return; + windowScrollRafRef.current = requestAnimationFrame(() => { + windowScrollRafRef.current = null; + applyWindowFromScroll(); + }); + }, [applyWindowFromScroll]); + + useEffect(() => { + const el = scrollContainerRef.current; + if (!el) return; + setScrollRoot(el); + + const updateViewport = () => { + setViewportHeight(el.clientHeight); + setViewportWidth(el.clientWidth); + // Width drives the char-per-line estimate; drop cached estimates so they + // recompute at the new width (measured heights are unaffected and kept). + estimateCacheRef.current.clear(); + // Resize changes the budgets and how many items fit; re-solve the window + // off the current scroll position WITHOUT resetting it (only session / + // branch changes reset). overflow-anchor holds the visible content. + scheduleWindowRecompute(); + }; + + updateViewport(); + const observer = new ResizeObserver(updateViewport); + observer.observe(el); + return () => { + observer.disconnect(); + if (windowScrollRafRef.current != null) { + cancelAnimationFrame(windowScrollRafRef.current); + windowScrollRafRef.current = null; + } + setScrollRoot(null); + }; + }, [id, session?.id, scheduleWindowRecompute]); + + React.useLayoutEffect(() => { + const seed = initialSeedItems(viewportHeight); + const total = renderItemsLengthRef.current; + const end = total; + const start = Math.max(0, end - seed); + windowStartRef.current = start; + windowEndRef.current = end; + setWindowStart(start); + setWindowEnd(end); + itemHeightsRef.current.clear(); + estimateCacheRef.current.clear(); + if (initialPinRafRef.current != null) { + cancelAnimationFrame(initialPinRafRef.current); + initialPinRafRef.current = null; + } + pendingInitialBottomScrollRef.current = true; + initialBottomScrollSettledRef.current = false; + isAtBottomRef.current = true; + setShowScrollButton(false); + }, [id, session?.active_branch_id]); + const handleScroll = useCallback(() => { const el = scrollContainerRef.current; if (!el) return; + // Measure against the real content bottom, not the locked-height pad below it. const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD; isAtBottomRef.current = atBottom; setShowScrollButton(!atBottom); - }, []); + // Slide the mounted window to follow the viewport (loads newer/older items + // and unloads ones that drifted past the buffer on either side). + scheduleWindowRecompute(); + }, [scheduleWindowRecompute]); // Prevent scroll from leaking into the dashboard canvas when at boundaries useEffect(() => { @@ -452,16 +666,47 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose return () => el.removeEventListener('wheel', onWheel); }, []); + const scrollToBottomRafRef = useRef(null); const scrollToBottom = useCallback(() => { const el = scrollContainerRef.current; if (!el) return; - el.scrollTop = el.scrollHeight; isAtBottomRef.current = true; setShowScrollButton(false); + // When scrolled far up the newest items are unmounted behind the bottom + // spacer (estimated height). Jump the window to the bottom slice so they + // actually mount, then pin across several frames: a single scrollTop=scrollHeight + // lands short because the spacer collapses and the freshly-mounted items + // replace their estimates with real measured heights, changing scrollHeight. + const total = renderItemsLengthRef.current; + const start = Math.max(0, total - initialSeedItems(el.clientHeight)); + windowStartRef.current = start; + windowEndRef.current = total; + setWindowStart(start); + setWindowEnd(total); + if (scrollToBottomRafRef.current != null) cancelAnimationFrame(scrollToBottomRafRef.current); + let frame = 0; + const FRAMES = 16; // ~260ms, enough for the window + oversized blocks to settle + const pin = () => { + const c = scrollContainerRef.current; + if (!c) { scrollToBottomRafRef.current = null; return; } + c.scrollTop = c.scrollHeight; + lastScrollHeightRef.current = c.scrollHeight; + isAtBottomRef.current = true; + if (++frame < FRAMES) { + scrollToBottomRafRef.current = requestAnimationFrame(pin); + } else { + scrollToBottomRafRef.current = null; + // Jump has settled: re-evaluate oversized message / block visibility + // synchronously so nothing now in view is stuck as a placeholder. + c.dispatchEvent(new CustomEvent(RECHECK_VISIBILITY_EVENT)); + } + }; + pin(); }, []); const scrollRafRef = useRef(null); const pinRafRef = useRef(null); + const initialPinRafRef = useRef(null); const lastScrollHeightRef = useRef(0); // Shared scroll-stick routine. Used both by the structural-events // useEffect below (new message lands / stream starts/ends) and by @@ -566,6 +811,14 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose cancelAnimationFrame(pinRafRef.current); pinRafRef.current = null; } + if (initialPinRafRef.current != null) { + cancelAnimationFrame(initialPinRafRef.current); + initialPinRafRef.current = null; + } + if (scrollToBottomRafRef.current != null) { + cancelAnimationFrame(scrollToBottomRafRef.current); + scrollToBottomRafRef.current = null; + } }, []); // useCallback so ChatInput's memo equality holds across AgentChat @@ -835,6 +1088,118 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose return items; }, [activeBranchMessages]); + React.useLayoutEffect(() => { + const total = renderItems.length; + renderItemsLengthRef.current = total; + renderItemsRef.current = renderItems; + const seed = initialSeedItems(viewportHeight); + let start = windowStartRef.current; + let end = windowEndRef.current; + if (isAtBottomRef.current || end === 0) { + // Following the live tail: keep the newest item mounted and unload the + // oldest beyond a bounded recent slice so memory stays flat as the + // transcript grows. The pixel solver refines this seed on the next scroll. + end = total; + start = Math.max(0, end - seed); + } else { + // Scrolled up: just keep the existing window valid against the new length. + end = Math.min(end, total); + start = Math.min(start, Math.max(0, end - 1)); + } + if (start !== windowStartRef.current) { windowStartRef.current = start; setWindowStart(start); } + if (end !== windowEndRef.current) { windowEndRef.current = end; setWindowEnd(end); } + }, [id, renderItems, viewportHeight]); + + const total = renderItems.length; + const safeWindowEnd = windowEnd > 0 ? Math.min(windowEnd, total) : total; + const safeWindowStart = Math.min(Math.max(0, windowStart), Math.max(0, safeWindowEnd - 1)); + const visibleStartIndex = safeWindowStart; + const visibleRenderItems = useMemo( + () => renderItems.slice(safeWindowStart, safeWindowEnd), + [renderItems, safeWindowStart, safeWindowEnd] + ); + const renderedVisibleItems = useMemo( + () => visibleRenderItems.filter((item) => !streamingMessageId || item.id !== streamingMessageId), + [streamingMessageId, visibleRenderItems] + ); + // Keep the ref the height estimator reads in sync with the live viewport width. + viewportWidthRef.current = viewportWidth; + + // Measure mounted item heights so the spacers that stand in for unmounted + // items keep the scrollbar geometry stable (no jump when unloading above). + React.useLayoutEffect(() => { + const el = scrollContainerRef.current; + if (!el) return; + let changed = false; + el.querySelectorAll('[data-window-item-id]').forEach((node) => { + const itemId = node.dataset.windowItemId; + if (!itemId) return; + const h = node.offsetHeight; + if (h <= 0) return; + const prev = itemHeightsRef.current.get(itemId); + if (prev === undefined || Math.abs(prev - h) > 1) { + itemHeightsRef.current.set(itemId, h); + changed = true; + } + }); + // Guarded so this converges: once heights stop moving, no more version bumps. + if (changed) setHeightVersion((v) => v + 1); + }); + + // Spacers reserve the cumulative height of the unmounted items above/below the + // window. heightVersion gates recompute off the ref-held measurements; we index + // the render-scope renderItems directly so id->height stays correct on the + // frame the transcript changes. + const topSpacerHeight = useMemo(() => { + let h = 0; + for (let i = 0; i < safeWindowStart; i++) h += reservedHeightForItem(renderItems[i]); + return h; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [renderItems, safeWindowStart, heightVersion, reservedHeightForItem]); + const bottomSpacerHeight = useMemo(() => { + let h = 0; + for (let i = safeWindowEnd; i < total; i++) h += reservedHeightForItem(renderItems[i]); + return h; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [renderItems, safeWindowEnd, total, heightVersion, reservedHeightForItem]); + + React.useLayoutEffect(() => { + if (pendingInitialBottomScrollRef.current) { + const el = scrollContainerRef.current; + if (!el || visibleRenderItems.length === 0) return; + let frame = 0; + const FRAMES = 8; + const pin = () => { + const c = scrollContainerRef.current; + if (!c) return; + lastVisibleItemRef.current?.scrollIntoView({ block: 'end' }); + c.scrollTop = Math.max(0, Math.min(c.scrollTop, c.scrollHeight - c.clientHeight)); + lastScrollHeightRef.current = c.scrollHeight; + isAtBottomRef.current = true; + setShowScrollButton(false); + if (++frame < FRAMES) { + initialPinRafRef.current = requestAnimationFrame(pin); + } else { + initialPinRafRef.current = null; + initialBottomScrollSettledRef.current = true; + // The open slice is sized by item COUNT; now that we're settled and + // measured, trim it down to the pixel-based band so tall messages high + // in the slice unload instead of sitting fully rendered off-screen. + scheduleWindowRecompute(); + // Re-evaluate visibility now the open jump has settled, so an oversized + // newest message isn't left stuck as a placeholder. + c.dispatchEvent(new CustomEvent(RECHECK_VISIBILITY_EVENT)); + } + }; + if (initialPinRafRef.current != null) { + cancelAnimationFrame(initialPinRafRef.current); + initialPinRafRef.current = null; + } + pendingInitialBottomScrollRef.current = false; + pin(); + } + }, [id, session?.active_branch_id, renderItems.length, renderedVisibleItems.length, visibleStartIndex]); + const lastAssistantIdsInTurn = useMemo(() => { const ids = new Set(); let lastAssistantId: string | null = null; @@ -939,7 +1304,6 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose ); } - const isActive = session.status === 'running' || session.status === 'waiting_approval' || session.status === 'draft'; const branchNavLocked = agentBusy || hasStreaming; const statusStyle = STATUS_STYLES[session.status] || { color: c.text.tertiary, bg: c.bg.secondary }; @@ -1128,6 +1492,12 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose sx={{ height: '100%', overflow: 'auto', + scrollbarGutter: 'stable', + // Flex column + the mt:auto content wrapper below bottom-anchors the + // transcript: short chats sit flush above the input (no whitespace + // under the last message) while long chats scroll normally. + display: 'flex', + flexDirection: 'column', px: 2, py: 1, // Smoothness bundle (perf-only , no behavior change): @@ -1152,12 +1522,17 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose '&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 3, + minHeight: 48, '&:hover': { background: c.border.strong }, }, scrollbarWidth: 'thin', scrollbarColor: `${c.border.medium} transparent`, }} > + {/* mt:auto pins the transcript to the bottom when it's shorter than the + viewport (no empty space below the last message); collapses to 0 and + scrolls normally once the content overflows. */} + {(session.mcp_suggestions && session.mcp_suggestions.length > 0) && ( = ({ sessionId: sessionIdProp, onClose ); })()} - {renderItems.filter((item) => !streamingMessageId || item.id !== streamingMessageId).map((item) => { + {/* Stand-in for items unmounted ABOVE the window. Its measured + height keeps the scrollbar geometry and scroll position stable + while overflow-anchor pins the visible content. */} + {topSpacerHeight > 0 && ( + + )} + {renderedVisibleItems.map((item, itemIdx) => { + const isLastVisibleItem = itemIdx === renderedVisibleItems.length - 1; const isCompactionAnchor = !!session.compacted_through_msg_id && item.id === session.compacted_through_msg_id; const compactionChip = isCompactionAnchor ? ( = ({ sessionId: sessionIdProp, onClose if (isToolGroup(item)) { const groupMeta = session.tool_group_meta?.[item.id]; return ( - + {compactionChip} - + ); } if (isToolPair(item)) { const isPending = item.result === null && sessionRunning; return ( - + {compactionChip} - + ); } const msg = item; @@ -1364,8 +1746,8 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const rawText = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); return ( - + = ({ sessionId: sessionIdProp, onClose editing={isEditing} onSaveEdit={handleSaveEdit} onCancelEdit={handleCancelEdit} + viewportHeight={viewportHeight} + viewportWidth={viewportWidth} + scrollRoot={scrollRoot} /> {!isEditing && (msg.role === 'user' || (msg.role === 'assistant' && lastAssistantIdsInTurn.has(msg.id))) && ( = ({ sessionId: sessionIdProp, onClose )} {compactionChip} + ); })} + {/* Stand-in for items unmounted BELOW the window (newer items not yet + scrolled into view). Zero while following the live tail. */} + {bottomSpacerHeight > 0 && ( + + )} {/* overflow-anchor: none on the two elements that grow every frame (live stream + thinking dots) keeps Chromium's scroll anchoring from fighting our jam-to-bottom for the scroll position. The @@ -1471,6 +1862,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose )} + {showScrollButton && ( diff --git a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx index b9cb6f03..fee4a19d 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx +++ b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx @@ -18,6 +18,8 @@ import PsychologyOutlinedIcon from '@mui/icons-material/PsychologyOutlined'; import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import WindowedMarkdown from './WindowedMarkdown'; +import { estimateRenderedTextHeight, oversizedCharThreshold, RECHECK_VISIBILITY_EVENT } from './markdownMeasure'; import { AgentMessage } from '@/shared/state/agentsSlice'; import { openSettingsModal } from '@/shared/state/settingsSlice'; import { shallowEqual } from 'react-redux'; @@ -63,6 +65,12 @@ const StreamingCursor: React.FC = () => { const ELEMENT_SEPARATOR = '\n\n---\nSelected UI Elements:\n'; +// Remembered full-render content height per oversized message id. Module-scoped so +// it survives the transcript window unmounting/remounting the bubble: when a big +// message goes off-screen we reserve the exact height it had when rendered, so its +// box doesn't collapse and the scrollbar doesn't jump as it crosses the viewport. +const oversizedContentHeights = new Map(); + interface OpenSwarmErrorInfo { kind: 'cap' | 'auth' | 'network' | 'too_many_tools'; title: string; @@ -833,15 +841,20 @@ interface Props { onCancelEdit?: () => void; isStreaming?: boolean; dynamicTurnLabel?: string | null; + viewportHeight?: number; + viewportWidth?: number; + scrollRoot?: Element | null; /** Streaming only: useSmoothText appends revealed chars into this subtree between parses. */ revealRef?: React.RefObject; } -const MessageBubble: React.FC = React.memo(({ message, editing = false, onSaveEdit, onCancelEdit, isStreaming, dynamicTurnLabel, revealRef }) => { +const MessageBubble: React.FC = React.memo(({ message, editing = false, onSaveEdit, onCancelEdit, isStreaming, dynamicTurnLabel, viewportHeight = 0, viewportWidth = 0, scrollRoot = null, revealRef }) => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); const [editText, setEditText] = useState(''); const [pickerOpen, setPickerOpen] = useState(false); + const bubbleRootRef = React.useRef(null); + const contentRef = React.useRef(null); const { role, content } = message; if (role === 'system') { @@ -880,6 +893,18 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o const { userMessage: displayText, elements: selectedElements } = isUser ? parseElementContext(rawText) : { userMessage: rawText, elements: [] }; + // A message longer than ~2 screens of text gets the placeholder + block + // virtualization treatment (full render in view, reserved-height placeholder off). + const isOversizedAssistant = !isUser && !isStreaming + && rawText.length > oversizedCharThreshold(viewportHeight, viewportWidth); + const [isOversizedInViewport, setIsOversizedInViewport] = useState(false); + const shouldRenderMarkdown = !isOversizedAssistant || isOversizedInViewport; + const markdownWindow = useMemo(() => { + if (!shouldRenderMarkdown) { + return { text: '', start: rawText.length, end: rawText.length, windowed: true }; + } + return { text: rawText, start: 0, end: rawText.length, windowed: false }; + }, [rawText, shouldRenderMarkdown]); const renderedMarkdown = useMemo(() => ( = React.memo(({ message, editing = false, o {children} ), }} - >{rawText} - ), [rawText]); + >{markdownWindow.text} + ), [markdownWindow.text]); + + // Height to reserve for this message's off-screen placeholder before it has ever + // been measured. Estimated from the FULL text length (we render in full when in + // view) with the same model as AgentChat's spacer estimate, so the placeholder + // and the spacer reserve the same space. Once rendered, oversizedContentHeights + // wins over this. + const placeholderFallbackHeight = useMemo( + () => estimateRenderedTextHeight(rawText, viewportWidth), + [rawText, viewportWidth], + ); const overflowCtx = useAppSelector((state) => { const sid = state.agents.activeSessionId; @@ -908,6 +943,65 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o }, shallowEqual); const openswarmError = !isUser ? parseOpenSwarmError(rawText, overflowCtx) : null; + // Reports asynchronously, bc without this an oversized message that mounts in + // view (e.g. scrolling up into the agent's reply) would paint the blank + // placeholder box for a frame and then pop in the real markdown. + React.useLayoutEffect(() => { + if (!isOversizedAssistant) { + setIsOversizedInViewport(false); + return; + } + + const node = bubbleRootRef.current; + if (!node) return; + + // One-screen rootMargin so the message renders its full markdown for a screen + // above and below the visible area; only once it drifts a screen past the + // viewport does it drop to the height-reserved placeholder. + const bufferPx = Math.max(180, Math.round(viewportHeight || 240)); + + // Resolve visibility synchronously (on mount and on demand) so the correct + // content paints without waiting on the observer's async callback. + const rootEl: Element = (scrollRoot as Element) ?? document.scrollingElement ?? document.documentElement; + const evaluate = () => { + const rootRect = rootEl.getBoundingClientRect(); + const nodeRect = node.getBoundingClientRect(); + setIsOversizedInViewport(nodeRect.bottom >= rootRect.top - bufferPx && nodeRect.top <= rootRect.bottom + bufferPx); + }; + evaluate(); + + const observer = new IntersectionObserver((entries) => { + const entry = entries[0]; + if (entry) setIsOversizedInViewport(entry.isIntersecting); + }, { + root: scrollRoot ?? null, + rootMargin: `${bufferPx}px 0px ${bufferPx}px 0px`, + threshold: 0, + }); + + observer.observe(node); + // A programmatic jump (scroll-to-bottom / open pin) settles after this mounts; + // re-evaluate synchronously when it does, since the observer sometimes misses + // the final transition and leaves this stuck as a placeholder. + scrollRoot?.addEventListener(RECHECK_VISIBILITY_EVENT, evaluate); + return () => { + observer.disconnect(); + scrollRoot?.removeEventListener(RECHECK_VISIBILITY_EVENT, evaluate); + }; + }, [isOversizedAssistant, message.id, scrollRoot, viewportHeight]); + + // Remember the full-render height of an oversized message while it is on-screen, + // so its off-screen placeholder can reserve exactly that height (see the + // module-level oversizedContentHeights cache). Measured on the content box only, + // which excludes the action bar (rendered by the parent) to avoid a feedback loop. + React.useLayoutEffect(() => { + if (!isOversizedAssistant || !shouldRenderMarkdown) return; + const node = contentRef.current; + if (!node) return; + const h = node.offsetHeight; + if (h > 0) oversizedContentHeights.set(message.id, h); + }, [isOversizedAssistant, shouldRenderMarkdown, message.id, markdownWindow.text]); + // (message.id, kind) keys so cap card analytics fire once, not on edits. React.useEffect(() => { if (openswarmError?.kind === 'cap') { @@ -943,6 +1037,7 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o return ( = React.memo(({ message, editing = false, o sx={{ maxWidth: '85%', minWidth: 0, + // Oversized messages are block-virtualized, so the set of rendered + // blocks (and thus the widest visible content) changes as you scroll. + // Pin them to a stable width so the bubble doesn't shrink-to-fit and + // resize horizontally frame to frame. Normal messages keep shrink-to-fit. + ...(isOversizedAssistant ? { width: '85%' } : {}), bgcolor: isUser ? c.user.bubble : c.bg.surface, border: isUser ? (isFailed ? `1px solid ${c.status.error}` : 'none') : `1px solid ${c.border.subtle}`, borderRadius: isUser ? '16px 16px 4px 16px' : '16px 16px 16px 4px', @@ -1044,6 +1144,7 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o ) ) : ( = React.memo(({ message, editing = false, o {/* Render markdown live (not just at the end) so code is mono, bold is bold, lists/headings format from the first character. Killing the old plain-text -> markdown swap removes the big - layout snap at stream end, which was the "glitch" people felt. - While streaming, useSmoothText appends pending chars into this + Re-parse is memoized on the (smoothed) text and cheap at chat sizes. + While streaming, useSmoothText appends pending chars into the reveal subtree between parses, so the re-parse runs per commit, not per frame. */} - {renderedMarkdown} + {isOversizedAssistant && !isOversizedInViewport ? ( +