From 4ed909430586dbc8faa86b07b9d56fcd63d33743 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 27 Mar 2026 10:28:27 -0400 Subject: [PATCH] perf(site): memoize chat rendering hot path (#23720) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses chat page rendering performance. Profiling with React Profiler showed `AgentChat` actual render times of 20–31ms (exceeding the 16ms/60fps budget), with `StickyUserMessage` as the #1 component bottleneck at 35.7% of self time. ## Changes **Hoist `createComponents` to module scope** (`response.tsx`): Previously every `` instance called `createComponents()` per render, creating a fresh components map that forced Streamdown to discard its cached render tree. Now both light/dark variants are precomputed once at module scope. **Wrap `StickyUserMessage` in `memo()`** (`ConversationTimeline.tsx`): Profile-confirmed #1 bottleneck. Each instance carries IntersectionObserver + ResizeObserver + scroll handlers; skipping re-render avoids all that setup. **Wrap `ConversationTimeline` in `memo()`** (`ConversationTimeline.tsx`): Prevents cascade re-renders from the parent when props haven't changed. **Remove duplicate `buildSubagentTitles`** (`ConversationTimeline.tsx` → `AgentDetailContent.tsx`): Was computed in both `AgentDetailTimeline` and `ConversationTimeline`. Now computed once and passed as a prop.
Profiling data & analysis ### Profiler Metrics | Metric | Value | |--------|-------| | INP (Interaction to Next Paint) | 82ms | | Processing duration (event handlers) | 52ms | | AgentChat actual render | 20–31ms (budget: 16ms) | | AgentChat base render (no memo) | ~100ms | ### Top Bottleneck Components (self-time %) | Component | Self Time | % | |-----------|-----------|---| | StickyUserMessage | 11.0ms | 35.7% | | ForwardRef (radix-ui) | 7.4ms | 24.0% | | Presence (radix-ui) | 2.0ms | 6.5% | | AgentChatInput | 1.4ms | 4.5% | ### Decision log - Chose module-scope precomputation over `useMemo` for `createComponents` because there are only two possible theme variants and they're static. - Did not add virtualization — sticky user messages + scroll anchoring make it complex. The memoization fixes should be measured first. - Did not wrap `BlockList` in `memo()` — the React Compiler (enabled for `pages/AgentsPage/`) already auto-memoizes JSX elements inside it. - Phase 2 (verify React Compiler effectiveness on `parseMessagesWithMergedTools`) and Phase 3 (radix-ui Tooltip lazy-mounting) deferred to follow-up PRs.
--- site/src/components/ai-elements/response.tsx | 12 +- .../ConversationTimeline.stories.tsx | 4 +- .../AgentDetail/ConversationTimeline.tsx | 635 +++++++++--------- .../components/AgentDetailContent.tsx | 1 + 4 files changed, 334 insertions(+), 318 deletions(-) diff --git a/site/src/components/ai-elements/response.tsx b/site/src/components/ai-elements/response.tsx index f9b337b301..9675a9c0be 100644 --- a/site/src/components/ai-elements/response.tsx +++ b/site/src/components/ai-elements/response.tsx @@ -230,6 +230,15 @@ const createComponents = ( }; }; +// Precompute component maps for both themes at module scope so +// every Response instance shares the same stable references. +// This prevents Streamdown from discarding its cached render +// tree on each parent re-render. +const componentsByTheme: Record = { + light: createComponents("light", fileViewerTheme.light), + dark: createComponents("dark", fileViewerTheme.dark), +}; + export const Response = ({ className, children, @@ -241,8 +250,7 @@ export const Response = ({ const theme = useTheme(); const fileViewerThemeType: FileViewerThemeType = theme.palette.mode === "dark" ? "dark" : "light"; - const viewerTheme = fileViewerTheme[fileViewerThemeType]; - const components = createComponents(fileViewerThemeType, viewerTheme); + const components = componentsByTheme[fileViewerThemeType]; return (
{ const defaultArgs: Omit< React.ComponentProps, "parsedMessages" -> = {}; +> = { + subagentTitles: new Map(), +}; const meta: Meta = { title: "pages/AgentsPage/AgentDetail/ConversationTimeline", diff --git a/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx index 8900b939c6..2fb0442d4c 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx @@ -37,10 +37,7 @@ import { ImageLightbox } from "../ImageLightbox"; import { TextPreviewDialog } from "../TextPreviewDialog"; import { ChatStatusCallout } from "./ChatStatusCallout"; import type { LiveStatusModel } from "./liveStatusModel"; -import { - buildSubagentTitles, - getEditableUserMessagePayload, -} from "./messageParsing"; +import { getEditableUserMessagePayload } from "./messageParsing"; import { useSmoothStreamingText } from "./SmoothText"; import type { MergedTool, @@ -729,7 +726,7 @@ export const StreamingOutput: FC<{ ); }; -const StickyUserMessage: FC<{ +const StickyUserMessage = memo<{ message: TypesGen.ChatMessage; parsed: ParsedMessageContent; onEditUserMessage?: ( @@ -740,282 +737,289 @@ const StickyUserMessage: FC<{ editingMessageId?: number | null; savingMessageId?: number | null; isAfterEditingMessage?: boolean; -}> = ({ - message, - parsed, - onEditUserMessage, - editingMessageId, - savingMessageId, - isAfterEditingMessage = false, -}) => { - const [isStuck, setIsStuck] = useState(false); - const [isReady, setIsReady] = useState(false); - const [isTooTall, setIsTooTall] = useState(false); - const sentinelRef = useRef(null); - const containerRef = useRef(null); - const updateFnRef = useRef<(() => void) | null>(null); +}>( + ({ + message, + parsed, + onEditUserMessage, + editingMessageId, + savingMessageId, + isAfterEditingMessage = false, + }) => { + const [isStuck, setIsStuck] = useState(false); + const [isReady, setIsReady] = useState(false); + const [isTooTall, setIsTooTall] = useState(false); + const sentinelRef = useRef(null); + const containerRef = useRef(null); + const updateFnRef = useRef<(() => void) | null>(null); - // useLayoutEffect so isStuck and --clip-h are both resolved - // before the browser paints, avoiding a flash on load. - useLayoutEffect(() => { - const sentinel = sentinelRef.current; - if (!sentinel) return; - // Immediate check so the first paint is correct when the - // sentinel is already scrolled out of view. - const scroller = sentinel.closest(".overflow-y-auto"); - if (scroller) { - const stuck = - sentinel.getBoundingClientRect().top < - scroller.getBoundingClientRect().top; - if (stuck) { - setIsStuck(true); - } - } - setIsReady(true); - const observer = new IntersectionObserver( - ([entry]) => setIsStuck(!entry.isIntersecting), - { threshold: 0 }, - ); - observer.observe(sentinel); - return () => observer.disconnect(); - }, []); - - // Sets a single CSS custom property (--clip-h) on the sticky - // container. All visual behaviour (max-height, mask fade) is - // driven by CSS using this variable. - useLayoutEffect(() => { - const sentinel = sentinelRef.current; - const container = containerRef.current; - if (!sentinel || !container) return; - const scroller = sentinel.closest(".overflow-y-auto") as HTMLElement | null; - if (!scroller) return; - - const MIN_HEIGHT = 72; - let scrollerTop = scroller.getBoundingClientRect().top; - let scrollerHeight = scroller.clientHeight; - - const update = () => { - const fullHeight = container.offsetHeight; - - // Skip sticky behavior for messages that take up - // most of the visible area — accounting for the - // chat input and some breathing room. - const tooTall = fullHeight > scrollerHeight * 0.75; - setIsTooTall(tooTall); - if (tooTall) { - container.style.setProperty("--clip-h", `${fullHeight}px`); - container.style.setProperty("--fade-opacity", "0"); - container.style.top = "0px"; - return; - } - const sentinelTop = sentinel.getBoundingClientRect().top; - const scrolledPast = scrollerTop - sentinelTop; - - if (scrolledPast <= 0) { - // Always set a valid value so the overlay has the - // correct height immediately when isStuck flips. - container.style.setProperty("--clip-h", `${fullHeight}px`); - container.style.setProperty("--fade-opacity", "0"); - container.style.top = "0px"; - return; - } - const visible = Math.max(fullHeight - scrolledPast - 48, MIN_HEIGHT); - container.style.setProperty("--clip-h", `${visible}px`); - // Only show the fade gradient once enough content is - // clipped to be visually meaningful. - container.style.setProperty( - "--fade-opacity", - visible < fullHeight - 8 ? "1" : "0", - ); - - // Push-up effect: when the next user message's sentinel - // approaches the bottom of this sticky container, shift - // this container upward so it slides out of view — the - // same visual as the old section-boundary behavior. - let nextSentinel: Element | null = sentinel.nextElementSibling; - while (nextSentinel) { - if (nextSentinel.hasAttribute("data-user-sentinel")) { - break; + // useLayoutEffect so isStuck and --clip-h are both resolved + // before the browser paints, avoiding a flash on load. + useLayoutEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel) return; + // Immediate check so the first paint is correct when the + // sentinel is already scrolled out of view. + const scroller = sentinel.closest(".overflow-y-auto"); + if (scroller) { + const stuck = + sentinel.getBoundingClientRect().top < + scroller.getBoundingClientRect().top; + if (stuck) { + setIsStuck(true); } - nextSentinel = nextSentinel.nextElementSibling; } - if (nextSentinel) { - const nextY = nextSentinel.getBoundingClientRect().top - scrollerTop; - container.style.top = `${Math.min(0, nextY - visible)}px`; - } else { - container.style.top = "0px"; - } - }; - updateFnRef.current = update; + setIsReady(true); + const observer = new IntersectionObserver( + ([entry]) => setIsStuck(!entry.isIntersecting), + { threshold: 0 }, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, []); - const onResize = () => { - scrollerTop = scroller.getBoundingClientRect().top; - scrollerHeight = scroller.clientHeight; - update(); - }; + // Sets a single CSS custom property (--clip-h) on the sticky + // container. All visual behaviour (max-height, mask fade) is + // driven by CSS using this variable. + useLayoutEffect(() => { + const sentinel = sentinelRef.current; + const container = containerRef.current; + if (!sentinel || !container) return; + const scroller = sentinel.closest( + ".overflow-y-auto", + ) as HTMLElement | null; + if (!scroller) return; - // Throttle to one update per animation frame so we don't - // do redundant work on high-refresh-rate displays. - let rafId: number | null = null; - const onScroll = () => { - if (rafId !== null) return; - rafId = requestAnimationFrame(() => { - rafId = null; + const MIN_HEIGHT = 72; + let scrollerTop = scroller.getBoundingClientRect().top; + let scrollerHeight = scroller.clientHeight; + + const update = () => { + const fullHeight = container.offsetHeight; + + // Skip sticky behavior for messages that take up + // most of the visible area — accounting for the + // chat input and some breathing room. + const tooTall = fullHeight > scrollerHeight * 0.75; + setIsTooTall(tooTall); + if (tooTall) { + container.style.setProperty("--clip-h", `${fullHeight}px`); + container.style.setProperty("--fade-opacity", "0"); + container.style.top = "0px"; + return; + } + const sentinelTop = sentinel.getBoundingClientRect().top; + const scrolledPast = scrollerTop - sentinelTop; + + if (scrolledPast <= 0) { + // Always set a valid value so the overlay has the + // correct height immediately when isStuck flips. + container.style.setProperty("--clip-h", `${fullHeight}px`); + container.style.setProperty("--fade-opacity", "0"); + container.style.top = "0px"; + return; + } + const visible = Math.max(fullHeight - scrolledPast - 48, MIN_HEIGHT); + container.style.setProperty("--clip-h", `${visible}px`); + // Only show the fade gradient once enough content is + // clipped to be visually meaningful. + container.style.setProperty( + "--fade-opacity", + visible < fullHeight - 8 ? "1" : "0", + ); + + // Push-up effect: when the next user message's sentinel + // approaches the bottom of this sticky container, shift + // this container upward so it slides out of view — the + // same visual as the old section-boundary behavior. + let nextSentinel: Element | null = sentinel.nextElementSibling; + while (nextSentinel) { + if (nextSentinel.hasAttribute("data-user-sentinel")) { + break; + } + nextSentinel = nextSentinel.nextElementSibling; + } + if (nextSentinel) { + const nextY = nextSentinel.getBoundingClientRect().top - scrollerTop; + container.style.top = `${Math.min(0, nextY - visible)}px`; + } else { + container.style.top = "0px"; + } + }; + updateFnRef.current = update; + + const onResize = () => { + scrollerTop = scroller.getBoundingClientRect().top; + scrollerHeight = scroller.clientHeight; update(); - }); - }; + }; - // Re-run the visual update when the scrollable content height - // changes (e.g. streaming responses growing the transcript). - // In flex-col-reverse, scrollTop stays at 0 when pinned to - // bottom so no scroll event fires — but the content wrapper - // resizes and this observer catches that. - const contentEl = scroller.firstElementChild as HTMLElement | null; - let contentRafId: number | null = null; - const contentObserver = contentEl - ? new ResizeObserver(() => { - if (contentRafId !== null) return; - contentRafId = requestAnimationFrame(() => { - contentRafId = null; - update(); - }); - }) - : null; - contentObserver?.observe(contentEl!); - - scroller.addEventListener("scroll", onScroll, { passive: true }); - window.addEventListener("resize", onResize); - update(); - // Set immediately — both --clip-h and --overlay-ready are - // applied before the browser paints since we're in a - // useLayoutEffect. - container.style.setProperty("--overlay-ready", "1"); - return () => { - scroller.removeEventListener("scroll", onScroll); - window.removeEventListener("resize", onResize); - contentObserver?.disconnect(); - container.style.removeProperty("--overlay-ready"); - if (rafId !== null) cancelAnimationFrame(rafId); - if (contentRafId !== null) cancelAnimationFrame(contentRafId); - }; - }, []); - - // Re-run the height calculation synchronously whenever - // isStuck changes so --clip-h is correct on the same frame - // the overlay appears. Without this, the async - // IntersectionObserver + RAF-throttled scroll handler can - // leave a stale --clip-h for one paint. - // biome-ignore lint/correctness/useExhaustiveDependencies: isStuck is an intentional trigger - useLayoutEffect(() => { - updateFnRef.current?.(); - }, [isStuck]); - - const handleEditUserMessage = onEditUserMessage - ? ( - messageId: number, - text: string, - fileBlocks?: readonly TypesGen.ChatMessagePart[], - ) => { - onEditUserMessage(messageId, text, fileBlocks); - requestAnimationFrame(() => { - const sentinel = sentinelRef.current; - if (!sentinel) return; - const scroller = sentinel.closest( - ".overflow-y-auto", - ) as HTMLElement | null; - if (!scroller) return; - const offset = - sentinel.getBoundingClientRect().top - - scroller.getBoundingClientRect().top; - scroller.scrollBy({ top: offset, behavior: "smooth" }); + // Throttle to one update per animation frame so we don't + // do redundant work on high-refresh-rate displays. + let rafId: number | null = null; + const onScroll = () => { + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + update(); }); - } - : undefined; + }; - return ( - <> -
-
- {/* Flow element: always in the DOM to preserve + // Re-run the visual update when the scrollable content height + // changes (e.g. streaming responses growing the transcript). + // In flex-col-reverse, scrollTop stays at 0 when pinned to + // bottom so no scroll event fires — but the content wrapper + // resizes and this observer catches that. + const contentEl = scroller.firstElementChild as HTMLElement | null; + let contentRafId: number | null = null; + const contentObserver = contentEl + ? new ResizeObserver(() => { + if (contentRafId !== null) return; + contentRafId = requestAnimationFrame(() => { + contentRafId = null; + update(); + }); + }) + : null; + contentObserver?.observe(contentEl!); + + scroller.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", onResize); + update(); + // Set immediately — both --clip-h and --overlay-ready are + // applied before the browser paints since we're in a + // useLayoutEffect. + container.style.setProperty("--overlay-ready", "1"); + return () => { + scroller.removeEventListener("scroll", onScroll); + window.removeEventListener("resize", onResize); + contentObserver?.disconnect(); + container.style.removeProperty("--overlay-ready"); + if (rafId !== null) cancelAnimationFrame(rafId); + if (contentRafId !== null) cancelAnimationFrame(contentRafId); + }; + }, []); + + // Re-run the height calculation synchronously whenever + // isStuck changes so --clip-h is correct on the same frame + // the overlay appears. Without this, the async + // IntersectionObserver + RAF-throttled scroll handler can + // leave a stale --clip-h for one paint. + // biome-ignore lint/correctness/useExhaustiveDependencies: isStuck is an intentional trigger + useLayoutEffect(() => { + updateFnRef.current?.(); + }, [isStuck]); + + const handleEditUserMessage = onEditUserMessage + ? ( + messageId: number, + text: string, + fileBlocks?: readonly TypesGen.ChatMessagePart[], + ) => { + onEditUserMessage(messageId, text, fileBlocks); + requestAnimationFrame(() => { + const sentinel = sentinelRef.current; + if (!sentinel) return; + const scroller = sentinel.closest( + ".overflow-y-auto", + ) as HTMLElement | null; + if (!scroller) return; + const offset = + sentinel.getBoundingClientRect().top - + scroller.getBoundingClientRect().top; + scroller.scrollBy({ top: offset, behavior: "smooth" }); + }); + } + : undefined; + + return ( + <> +
+
+ {/* Flow element: always in the DOM to preserve scroll layout. Hidden when stuck so the clipped overlay takes over visually. */} -
- -
+
+ +
- {/* Overlay: absolutely positioned, matching the + {/* Overlay: absolutely positioned, matching the sticky container. max-height + mask are driven entirely by the --clip-h CSS variable which the scroll handler sets on the container. */} - {isStuck && !isTooTall && ( -
- {/* Blur layer: extends 48px beyond the + {isStuck && !isTooTall && ( +
+ {/* Blur layer: extends 48px beyond the clipped content so the frosted effect is visible around the bubble. Promoted to its own GPU layer via will-change. */} -
- {/* Content layer: px-3 matches the sticky +
+ {/* Content layer: px-3 matches the sticky container's padding so the overlay aligns with the flow element. will-change promotes to GPU layer. */} -
- +
+ +
-
- )} -
- - ); -}; + )} +
+ + ); + }, +); interface ConversationTimelineProps { parsedMessages: readonly ParsedMessageEntry[]; + subagentTitles: Map; onEditUserMessage?: ( messageId: number, text: string, @@ -1029,66 +1033,67 @@ interface ConversationTimelineProps { showDesktopPreviews?: boolean; } -export const ConversationTimeline: FC = ({ - parsedMessages, - onEditUserMessage, - editingMessageId, - savingMessageId, - urlTransform, - mcpServers, - computerUseSubagentIds, - showDesktopPreviews, -}) => { - const subagentTitles = buildSubagentTitles(parsedMessages); +export const ConversationTimeline = memo( + ({ + parsedMessages, + subagentTitles, + onEditUserMessage, + editingMessageId, + savingMessageId, + urlTransform, + mcpServers, + computerUseSubagentIds, + showDesktopPreviews, + }) => { + if (parsedMessages.length === 0) { + return null; + } - if (parsedMessages.length === 0) { - return null; - } - - // Build a set of message IDs that appear after the message - // currently being edited so they can be visually faded. - const afterEditingMessageIds = new Set(); - if (editingMessageId != null) { - let found = false; - for (const entry of parsedMessages) { - if (entry.message.id === editingMessageId) { - found = true; - continue; - } - if (found) { - afterEditingMessageIds.add(entry.message.id); + // Build a set of message IDs that appear after the message + // currently being edited so they can be visually faded. + const afterEditingMessageIds = new Set(); + if (editingMessageId != null) { + let found = false; + for (const entry of parsedMessages) { + if (entry.message.id === editingMessageId) { + found = true; + continue; + } + if (found) { + afterEditingMessageIds.add(entry.message.id); + } } } - } - return ( -
- {parsedMessages.map(({ message, parsed }) => - message.role === "user" ? ( - - ) : ( - - ), - )} -
- ); -}; + return ( +
+ {parsedMessages.map(({ message, parsed }) => + message.role === "user" ? ( + + ) : ( + + ), + )} +
+ ); + }, +); diff --git a/site/src/pages/AgentsPage/components/AgentDetailContent.tsx b/site/src/pages/AgentsPage/components/AgentDetailContent.tsx index 0225262870..1ba3d57afc 100644 --- a/site/src/pages/AgentsPage/components/AgentDetailContent.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetailContent.tsx @@ -83,6 +83,7 @@ export const AgentDetailTimeline: FC = ({ renders correctly. */}