Merge PR #65 [aidan] chat: virtualize long chat transcripts (windowed rendering)

This commit is contained in:
ciregenz
2026-06-11 02:52:29 -07:00
4 changed files with 740 additions and 16 deletions
+402 -10
View File
@@ -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<string, number> = {
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; }
@@ -197,8 +277,25 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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<HTMLDivElement>(null);
const lastVisibleItemRef = useRef<HTMLDivElement>(null);
const chatInputRef = useRef<ChatInputHandle>(null);
const isAtBottomRef = useRef(true);
const pendingInitialBottomScrollRef = useRef(false);
const initialBottomScrollSettledRef = useRef(false);
const renderItemsLengthRef = useRef(0);
const renderItemsRef = useRef<RenderItem[]>([]);
const itemHeightsRef = useRef<Map<string, number>>(new Map());
const estimateCacheRef = useRef<Map<string, number>>(new Map());
const viewportWidthRef = useRef(0);
const windowStartRef = useRef(0);
const windowEndRef = useRef(0);
const windowScrollRafRef = useRef<number | null>(null);
const [viewportHeight, setViewportHeight] = useState(0);
const [viewportWidth, setViewportWidth] = useState(0);
const [scrollRoot, setScrollRoot] = useState<HTMLDivElement | null>(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);
@@ -422,13 +519,130 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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(() => {
@@ -453,16 +667,47 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
return () => el.removeEventListener('wheel', onWheel);
}, []);
const scrollToBottomRafRef = useRef<number | null>(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<number | null>(null);
const pinRafRef = useRef<number | null>(null);
const initialPinRafRef = useRef<number | null>(null);
const lastScrollHeightRef = useRef<number>(0);
// Shared scroll-stick routine. Used both by the structural-events
// useEffect below (new message lands / stream starts/ends) and by
@@ -567,6 +812,14 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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
@@ -837,6 +1090,118 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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<HTMLElement>('[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<string>();
let lastAssistantId: string | null = null;
@@ -941,7 +1306,6 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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 };
@@ -1121,6 +1485,12 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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):
@@ -1145,12 +1515,17 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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. */}
<Box sx={{ mt: 'auto' }}>
{(session.mcp_suggestions && session.mcp_suggestions.length > 0) && (
<Box sx={{
mt: 1,
@@ -1318,7 +1693,14 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
</Box>
);
})()}
{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 && (
<Box aria-hidden data-window-spacer="top" sx={{ height: topSpacerHeight, flexShrink: 0, overflowAnchor: 'none' }} />
)}
{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 ? (
<CompactionMarker
@@ -1332,19 +1714,19 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
if (isToolGroup(item)) {
const groupMeta = session.tool_group_meta?.[item.id];
return (
<React.Fragment key={item.id}>
<Box key={item.id} data-window-item-id={item.id} ref={isLastVisibleItem ? lastVisibleItemRef : undefined}>
<ToolGroupBubble group={item} isSessionRunning={sessionRunning} meta={groupMeta} sessionId={session.id} />
{compactionChip}
</React.Fragment>
</Box>
);
}
if (isToolPair(item)) {
const isPending = item.result === null && sessionRunning;
return (
<React.Fragment key={item.id}>
<Box key={item.id} data-window-item-id={item.id} ref={isLastVisibleItem ? lastVisibleItemRef : undefined}>
<ToolCallBubble call={item.call} result={item.result} isPending={isPending} sessionId={session.id} suppressReveal={item.call.id === justStreamedId} />
{compactionChip}
</React.Fragment>
</Box>
);
}
const msg = item;
@@ -1357,8 +1739,8 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
const rawText = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
return (
<Box
key={msg.id}
<Box key={msg.id} data-window-item-id={msg.id} ref={isLastVisibleItem ? lastVisibleItemRef : undefined}>
<Box
sx={{
'&:hover .msg-actions': { opacity: 1 },
// Cheap virtualization: tells the browser to skip
@@ -1378,6 +1760,9 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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))) && (
<MessageActionBar
@@ -1411,8 +1796,14 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
)}
{compactionChip}
</Box>
</Box>
);
})}
{/* Stand-in for items unmounted BELOW the window (newer items not yet
scrolled into view). Zero while following the live tail. */}
{bottomSpacerHeight > 0 && (
<Box aria-hidden data-window-spacer="bottom" sx={{ height: bottomSpacerHeight, flexShrink: 0, overflowAnchor: 'none' }} />
)}
{/* 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
@@ -1464,6 +1855,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
</Box>
</Box>
)}
</Box>
</Box>
{showScrollButton && (
<Tooltip title="Scroll to bottom">
@@ -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<string, number>();
interface OpenSwarmErrorInfo {
kind: 'cap' | 'auth' | 'network' | 'too_many_tools';
title: string;
@@ -853,15 +861,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<HTMLElement | null>;
}
const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, onSaveEdit, onCancelEdit, isStreaming, dynamicTurnLabel, revealRef }) => {
const MessageBubble: React.FC<Props> = 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<HTMLDivElement | null>(null);
const contentRef = React.useRef<HTMLDivElement | null>(null);
const { role, content } = message;
if (role === 'system') {
@@ -900,6 +913,18 @@ const MessageBubble: React.FC<Props> = 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(() => (
<ReactMarkdown
@@ -909,8 +934,18 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
<a {...props} style={{ cursor: 'pointer' }}>{children}</a>
),
}}
>{rawText}</ReactMarkdown>
), [rawText]);
>{markdownWindow.text}</ReactMarkdown>
), [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;
@@ -928,6 +963,65 @@ const MessageBubble: React.FC<Props> = 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') {
@@ -963,6 +1057,7 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
return (
<Box
ref={bubbleRootRef}
data-select-type="message"
data-select-id={message.id}
data-select-meta={JSON.stringify({ role, content: truncatedContent })}
@@ -978,6 +1073,11 @@ const MessageBubble: React.FC<Props> = 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',
@@ -1064,6 +1164,7 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
)
) : (
<Box
ref={contentRef}
sx={{
color: c.text.secondary,
fontSize: '0.875rem',
@@ -1194,10 +1295,42 @@ const MessageBubble: React.FC<Props> = 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. */}
<Box ref={revealRef}>{renderedMarkdown}</Box>
{isOversizedAssistant && !isOversizedInViewport ? (
<Box
sx={{
// Reserve the height this message had when last rendered full
// (cached across unmount) so the box doesn't collapse and the
// scrollbar stays put. Falls back to a content-aware estimate
// (matched to the spacer estimate) until it's been measured.
minHeight: oversizedContentHeights.get(message.id)
|| placeholderFallbackHeight
|| Math.min(420, Math.max(180, viewportHeight || 240)),
opacity: 0,
pointerEvents: 'none',
}}
aria-hidden="true"
/>
) : isOversizedAssistant ? (
// In view, but virtualize WITHIN the message: only blocks near the
// viewport render their markdown, the rest are reserved-height
// placeholders, so an extremely long message never parses/mounts
// more than the on-screen portion plus a buffer.
<WindowedMarkdown
messageId={message.id}
text={rawText}
scrollRoot={scrollRoot}
viewportHeight={viewportHeight}
viewportWidth={viewportWidth}
/>
) : (
// Normal (non-oversized) render. Streaming always lands here
// (oversized requires !isStreaming); the reveal subtree lets
// useSmoothText append chars between parses.
<Box ref={revealRef}>{renderedMarkdown}</Box>
)}
{isStreaming && <StreamingCursor />}
</>
)}
@@ -0,0 +1,144 @@
import React, { useMemo, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { estimateRenderedTextHeight, RECHECK_VISIBILITY_EVENT } from './markdownMeasure';
// Intra-message virtualization for very long assistant messages. The text is split
// into FIXED blocks (each block always covers the same character range), so unlike
// the old growing-tail chunking nothing shifts as you scroll, and no scroll
// correction is needed. Only blocks within a screen of the viewport actually render
// their markdown; the rest are height-reserved placeholders, so an extremely long
// message never parses or mounts more than the on-screen portion plus a buffer.
const BLOCK_TARGET_CHARS = 4_000;
// Remembered measured height per block (`${messageId}#${index}`). Module-scoped so
// it survives the block unmounting/remounting as you scroll, keeping the reserved
// placeholder heights (and thus scroll position) stable.
const blockHeights = new Map<string, number>();
// Split markdown at blank lines that sit OUTSIDE fenced code blocks, so each block
// is a self-contained markdown fragment we can parse on its own. A fence (``` or
// ~~~) toggles "inside code" so we never cut a code block in half. Blocks grow to
// ~targetChars then break at the next safe boundary.
function splitMarkdownIntoBlocks(text: string, targetChars: number): string[] {
if (text.length <= targetChars) return [text];
const lines = text.split('\n');
const blocks: string[] = [];
let cur: string[] = [];
let curLen = 0;
let inFence = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) inFence = !inFence;
cur.push(line);
curLen += line.length + 1;
if (curLen >= targetChars && !inFence && trimmed === '') {
blocks.push(cur.join('\n'));
cur = [];
curLen = 0;
}
}
if (cur.length) blocks.push(cur.join('\n'));
return blocks.length ? blocks : [text];
}
const MD_COMPONENTS = {
a: ({ children, ...props }: any) => (
<a {...props} style={{ cursor: 'pointer' }}>{children}</a>
),
};
const renderBlock = (text: string) => (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MD_COMPONENTS}>{text}</ReactMarkdown>
);
const MarkdownBlock: React.FC<{
blockId: string;
text: string;
scrollRoot: Element | null;
viewportHeight: number;
viewportWidth: number;
}> = React.memo(({ blockId, text, scrollRoot, viewportHeight, viewportWidth }) => {
const ref = useRef<HTMLDivElement | null>(null);
const [inView, setInView] = useState(false);
React.useLayoutEffect(() => {
const node = ref.current;
if (!node) return;
const bufferPx = Math.max(180, Math.round(viewportHeight || 240));
// Resolve visibility synchronously (on mount and on demand) so an on-screen
// block paints its markdown 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();
setInView(nodeRect.bottom >= rootRect.top - bufferPx && nodeRect.top <= rootRect.bottom + bufferPx);
};
evaluate();
const observer = new IntersectionObserver((entries) => {
const entry = entries[0];
if (entry) setInView(entry.isIntersecting);
}, {
root: scrollRoot ?? null,
rootMargin: `${bufferPx}px 0px ${bufferPx}px 0px`,
threshold: 0,
});
observer.observe(node);
// Re-evaluate when a programmatic jump settles (see RECHECK_VISIBILITY_EVENT).
scrollRoot?.addEventListener(RECHECK_VISIBILITY_EVENT, evaluate);
return () => {
observer.disconnect();
scrollRoot?.removeEventListener(RECHECK_VISIBILITY_EVENT, evaluate);
};
}, [scrollRoot, viewportHeight]);
// Remember the rendered height so the placeholder reserves exactly that space.
React.useLayoutEffect(() => {
if (!inView) return;
const node = ref.current;
if (!node) return;
const h = node.offsetHeight;
if (h > 0) blockHeights.set(blockId, h);
}, [inView, blockId, text]);
// chrome=8: block wrappers have minimal padding vs a full bubble.
const reserved = blockHeights.get(blockId) ?? estimateRenderedTextHeight(text, viewportWidth, 8);
return (
<Box ref={ref}>
{inView ? renderBlock(text) : <Box aria-hidden sx={{ height: reserved }} />}
</Box>
);
});
const WindowedMarkdown: React.FC<{
messageId: string;
text: string;
scrollRoot: Element | null;
viewportHeight: number;
viewportWidth: number;
}> = ({ messageId, text, scrollRoot, viewportHeight, viewportWidth }) => {
const blocks = useMemo(() => splitMarkdownIntoBlocks(text, BLOCK_TARGET_CHARS), [text]);
if (blocks.length === 1) {
// Short enough that virtualizing would only add overhead.
return renderBlock(text);
}
return (
<>
{blocks.map((block, i) => (
<MarkdownBlock
key={i}
blockId={`${messageId}#${i}`}
text={block}
scrollRoot={scrollRoot}
viewportHeight={viewportHeight}
viewportWidth={viewportWidth}
/>
))}
</>
);
};
export default WindowedMarkdown;
@@ -0,0 +1,55 @@
// Shared text-measurement heuristics for chat bubbles. These are empirical pixel
// values measured against the bubble styling in MessageBubble; they're used to
// estimate how tall a chunk of text will render (for spacer / placeholder height
// reservation) and how many characters make a message "oversized" enough to
// virtualize. They're estimates only: actual heights are measured once an element
// is on screen and override these everywhere.
// Fired on the scroll container after a programmatic jump (scroll-to-bottom /
// initial open pin) settles, so oversized messages and their blocks re-evaluate
// visibility synchronously instead of waiting on the async IntersectionObserver,
// which occasionally misses the final transition and leaves a stuck placeholder.
export const RECHECK_VISIBILITY_EVENT = 'chat-recheck-visibility';
export const BUBBLE_LINE_HEIGHT_PX = 22; // line-height of bubble body text
export const BUBBLE_AVG_CHAR_WIDTH_PX = 7.2; // average glyph width at the bubble font
export const BUBBLE_WIDTH_RATIO = 0.85; // bubbles are maxWidth: 85% of the column
export const MIN_CHARS_PER_VIEWPORT = 2_000; // floor so tiny/zero viewports still allow real-sized messages
export const OVERSIZED_VIEWPORT_MULTIPLE = 2; // a message taller than ~2 screens gets virtualized
// Tweak on the line-based estimate. Kept at 1.0 so estimates lean accurate/under
// rather than over: the scroll-height lock tolerates under-estimates fine (the
// total just grows monotonically as things measure), but OVER-estimates inflate
// the lock's compensating pad into visible empty space below the chat. Prose in
// particular over-estimated badly at higher values.
const MARKDOWN_DENSITY_FACTOR = 1.0;
// Characters that fit on one rendered line at this width.
function charsPerLine(viewportWidth: number): number {
const readableWidth = Math.max(280, (viewportWidth || 0) * BUBBLE_WIDTH_RATIO);
return Math.max(36, Math.floor(readableWidth / BUBBLE_AVG_CHAR_WIDTH_PX));
}
// Rough pixel height `text` will occupy when rendered in a bubble at this width.
// Counts by SOURCE line (each \n-delimited line takes at least one rendered line,
// plus wraps for long lines) rather than total chars, so dense markdown with many
// short lines isn't wildly under-counted. `chromePx` is the vertical padding/
// margins around the text.
export function estimateRenderedTextHeight(text: string, viewportWidth: number, chromePx = 40): number {
const cpl = charsPerLine(viewportWidth);
const sourceLines = text ? text.split('\n') : [''];
let lines = 0;
for (let i = 0; i < sourceLines.length; i++) {
lines += Math.max(1, Math.ceil(sourceLines[i].length / cpl));
}
return Math.ceil(Math.max(1, lines) * BUBBLE_LINE_HEIGHT_PX * MARKDOWN_DENSITY_FACTOR) + chromePx;
}
// Character count above which an assistant message is treated as "oversized" and
// gets the placeholder + block-virtualization treatment: roughly two screens of
// text, with a floor so it never trips on short messages.
export function oversizedCharThreshold(viewportHeight: number, viewportWidth: number): number {
const visibleLines = Math.max(1, Math.ceil(Math.max(0, viewportHeight) / BUBBLE_LINE_HEIGHT_PX));
const charsPerViewport = Math.max(MIN_CHARS_PER_VIEWPORT, visibleLines * charsPerLine(viewportWidth));
return charsPerViewport * OVERSIZED_VIEWPORT_MULTIPLE;
}