mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-10-01 14:04:51 +02:00
Merge PR #65 [aidan] chat: virtualize long chat transcripts (windowed rendering)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user