perf(site): memoize chat rendering hot path (#23720)

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 `<Response>` 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.

<details>
<summary>Profiling data & analysis</summary>

### 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.

</details>
This commit is contained in:
Kyle Carberry
2026-03-27 10:28:27 -04:00
committed by GitHub
parent d973a709df
commit 4ed9094305
4 changed files with 334 additions and 318 deletions
+10 -2
View File
@@ -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<FileViewerThemeType, Components> = {
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 (
<div
@@ -62,7 +62,9 @@ const mockTextAttachmentFetch = () => {
const defaultArgs: Omit<
React.ComponentProps<typeof ConversationTimeline>,
"parsedMessages"
> = {};
> = {
subagentTitles: new Map(),
};
const meta: Meta<typeof ConversationTimeline> = {
title: "pages/AgentsPage/AgentDetail/ConversationTimeline",
@@ -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<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(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<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(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 (
<>
<div ref={sentinelRef} className="h-0" data-user-sentinel />
<div
ref={containerRef}
className={cn(
"relative px-3 -mx-3 -mt-3",
!isTooTall && "sticky z-10",
!isReady && "invisible",
isStuck && !isTooTall && "pointer-events-none",
)}
>
{/* 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 (
<>
<div ref={sentinelRef} className="h-0" data-user-sentinel />
<div
ref={containerRef}
className={cn(
"relative px-3 -mx-3 -mt-3",
!isTooTall && "sticky z-10",
!isReady && "invisible",
isStuck && !isTooTall && "pointer-events-none",
)}
>
{/* Flow element: always in the DOM to preserve
scroll layout. Hidden when stuck so the
clipped overlay takes over visually. */}
<div
className={isStuck && !isTooTall ? undefined : "pointer-events-auto"}
style={
isStuck && !isTooTall
? { opacity: "calc(1 - var(--overlay-ready, 0))" }
: undefined
}
>
<ChatMessageItem
message={message}
parsed={parsed}
onEditUserMessage={handleEditUserMessage}
editingMessageId={editingMessageId}
savingMessageId={savingMessageId}
isAfterEditingMessage={isAfterEditingMessage}
/>
</div>
<div
className={
isStuck && !isTooTall ? undefined : "pointer-events-auto"
}
style={
isStuck && !isTooTall
? { opacity: "calc(1 - var(--overlay-ready, 0))" }
: undefined
}
>
<ChatMessageItem
message={message}
parsed={parsed}
onEditUserMessage={handleEditUserMessage}
editingMessageId={editingMessageId}
savingMessageId={savingMessageId}
isAfterEditingMessage={isAfterEditingMessage}
/>
</div>
{/* 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 && (
<div
className="absolute inset-0"
style={{
opacity: "var(--overlay-ready, 0)",
contain: "layout style",
}}
>
{/* Blur layer: extends 48px beyond the
{isStuck && !isTooTall && (
<div
className="absolute inset-0"
style={{
opacity: "var(--overlay-ready, 0)",
contain: "layout style",
}}
>
{/* 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. */}
<div
className="absolute inset-0 backdrop-blur-[1px] bg-surface-primary/15"
style={{
maxHeight: "calc(var(--clip-h, 100%) + 48px)",
willChange: "max-height, mask-image",
maskImage:
"linear-gradient(to bottom, black calc(var(--clip-h, 100%) + 24px), transparent calc(var(--clip-h, 100%) + 48px))",
WebkitMaskImage:
"linear-gradient(to bottom, black calc(var(--clip-h, 100%) + 24px), transparent calc(var(--clip-h, 100%) + 48px))",
}}
/>
{/* Content layer: px-3 matches the sticky
<div
className="absolute inset-0 backdrop-blur-[1px] bg-surface-primary/15"
style={{
maxHeight: "calc(var(--clip-h, 100%) + 48px)",
willChange: "max-height, mask-image",
maskImage:
"linear-gradient(to bottom, black calc(var(--clip-h, 100%) + 24px), transparent calc(var(--clip-h, 100%) + 48px))",
WebkitMaskImage:
"linear-gradient(to bottom, black calc(var(--clip-h, 100%) + 24px), transparent calc(var(--clip-h, 100%) + 48px))",
}}
/>
{/* Content layer: px-3 matches the sticky
container's padding so the overlay aligns
with the flow element. will-change promotes
to GPU layer. */}
<div className="relative px-3 pointer-events-auto will-change-[max-height]">
<ChatMessageItem
message={message}
parsed={parsed}
onEditUserMessage={handleEditUserMessage}
editingMessageId={editingMessageId}
savingMessageId={savingMessageId}
isAfterEditingMessage={isAfterEditingMessage}
fadeFromBottom
/>
<div className="relative px-3 pointer-events-auto will-change-[max-height]">
<ChatMessageItem
message={message}
parsed={parsed}
onEditUserMessage={handleEditUserMessage}
editingMessageId={editingMessageId}
savingMessageId={savingMessageId}
isAfterEditingMessage={isAfterEditingMessage}
fadeFromBottom
/>
</div>
</div>
</div>
)}
</div>
</>
);
};
)}
</div>
</>
);
},
);
interface ConversationTimelineProps {
parsedMessages: readonly ParsedMessageEntry[];
subagentTitles: Map<string, string>;
onEditUserMessage?: (
messageId: number,
text: string,
@@ -1029,66 +1033,67 @@ interface ConversationTimelineProps {
showDesktopPreviews?: boolean;
}
export const ConversationTimeline: FC<ConversationTimelineProps> = ({
parsedMessages,
onEditUserMessage,
editingMessageId,
savingMessageId,
urlTransform,
mcpServers,
computerUseSubagentIds,
showDesktopPreviews,
}) => {
const subagentTitles = buildSubagentTitles(parsedMessages);
export const ConversationTimeline = memo<ConversationTimelineProps>(
({
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<number>();
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<number>();
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 (
<div className="flex flex-col gap-3">
{parsedMessages.map(({ message, parsed }) =>
message.role === "user" ? (
<StickyUserMessage
key={message.id}
message={message}
parsed={parsed}
onEditUserMessage={onEditUserMessage}
editingMessageId={editingMessageId}
savingMessageId={savingMessageId}
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
/>
) : (
<ChatMessageItem
key={message.id}
message={message}
parsed={parsed}
savingMessageId={savingMessageId}
urlTransform={urlTransform}
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
mcpServers={mcpServers}
subagentTitles={subagentTitles}
computerUseSubagentIds={computerUseSubagentIds}
showDesktopPreviews={showDesktopPreviews}
/>
),
)}
</div>
);
};
return (
<div className="flex flex-col gap-3">
{parsedMessages.map(({ message, parsed }) =>
message.role === "user" ? (
<StickyUserMessage
key={message.id}
message={message}
parsed={parsed}
onEditUserMessage={onEditUserMessage}
editingMessageId={editingMessageId}
savingMessageId={savingMessageId}
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
/>
) : (
<ChatMessageItem
key={message.id}
message={message}
parsed={parsed}
savingMessageId={savingMessageId}
urlTransform={urlTransform}
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
mcpServers={mcpServers}
subagentTitles={subagentTitles}
computerUseSubagentIds={computerUseSubagentIds}
showDesktopPreviews={showDesktopPreviews}
/>
),
)}
</div>
);
},
);
@@ -83,6 +83,7 @@ export const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
renders correctly. */}
<ConversationTimeline
parsedMessages={parsedMessages}
subagentTitles={subagentTitles}
onEditUserMessage={onEditUserMessage}
editingMessageId={editingMessageId}
savingMessageId={savingMessageId}