Skip to content

Commit 4a73e9c

Browse files
Gkrumbach07claude
andauthored
perf(frontend): message windowing + stable keys in MessagesTab (#819)
## Summary Addresses UI bogging-down and potential memory pressure caused by accumulating large numbers of AG-UI events in long-running sessions. - **Message windowing**: cap the rendered DOM to the last 100 messages (`MAX_VISIBLE_MESSAGES`). A lightweight "Load earlier messages" button reveals older history on demand, keeping the number of mounted React subtrees bounded regardless of session length - **Scroll position preservation**: `useLayoutEffect` + explicit `scrollTop` assignment anchors the viewport when loading earlier messages, overriding browser scroll-anchoring to get deterministic behavior - **Stable React keys**: replace `key={sm-${idx}}` (index-based) with IDs derived from the message itself. Index keys cause React to re-render every message below an insertion point; stable keys mean only the changed node is reconciled - **`handleScroll` guard**: `setIsAtBottom` now uses a functional update that short-circuits when the boolean value hasn't changed, eliminating the re-render triggered on every scroll event - **`streamMessages` useMemo split**: the single O(n) memo that ran on every text delta is split into `committedStreamMessages` (hierarchy traversal, runs infrequently) and `streamMessages` (O(1) append, runs on every `TEXT_MESSAGE_CONTENT` event) ## Jira https://issues.redhat.com/browse/RHOAIENG-52026 ## Test plan - [ ] Open a session with a large number of messages; confirm only the latest 100 are rendered (use DevTools Elements panel to count) - [ ] Click "Load earlier messages"; confirm the viewport stays anchored to the same content and doesn't jump to the top - [ ] Verify new streaming messages still auto-scroll when the user is at the bottom - [ ] Verify the empty-state placeholder appears for sessions with no messages - [ ] Confirm system-message toggle still works with the windowed list - [ ] React DevTools Profiler: confirm `committedStreamMessages` does not re-run on `TEXT_MESSAGE_CONTENT` events 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 8d50ead commit 4a73e9c

2 files changed

Lines changed: 127 additions & 41 deletions

File tree

components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx

Lines changed: 43 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -775,8 +775,10 @@ export default function ProjectSessionDetailPage({
775775
handleWorkflowChange(workflowId);
776776
};
777777

778-
// Convert AG-UI messages to display format with hierarchical tool call rendering
779-
const streamMessages: Array<MessageObject | ToolUseMessages | HierarchicalToolMessage> = useMemo(() => {
778+
// Phase 1: convert committed messages + streaming tool cards into display format.
779+
// Does NOT depend on currentMessage / currentReasoning so it skips the full
780+
// O(n) traversal during text-streaming deltas (the most frequent event type).
781+
const committedStreamMessages: Array<MessageObject | ToolUseMessages | HierarchicalToolMessage> = useMemo(() => {
780782

781783
// Helper function to parse tool arguments
782784
const parseToolArgs = (args: string | undefined): Record<string, unknown> => {
@@ -1043,33 +1045,6 @@ export default function ProjectSessionDetailPage({
10431045
}
10441046
}
10451047

1046-
// Add streaming reasoning if currently reasoning
1047-
const activeReasoning = aguiState.currentReasoning || aguiState.currentThinking;
1048-
if (activeReasoning?.content) {
1049-
result.push({
1050-
type: "agent_message",
1051-
content: {
1052-
type: "reasoning_block",
1053-
thinking: activeReasoning.content,
1054-
signature: "",
1055-
},
1056-
model: "claude",
1057-
timestamp: activeReasoning.timestamp || "",
1058-
streaming: true,
1059-
} as MessageObject & { streaming?: boolean });
1060-
}
1061-
1062-
// Add streaming message if currently streaming
1063-
if (aguiState.currentMessage?.content) {
1064-
result.push({
1065-
type: "agent_message",
1066-
content: { type: "text_block", text: aguiState.currentMessage.content },
1067-
model: "claude",
1068-
timestamp: aguiState.currentMessage.timestamp || "",
1069-
streaming: true,
1070-
} as MessageObject & { streaming?: boolean });
1071-
}
1072-
10731048
// Render ALL currently streaming tool calls (supports parallel tool execution)
10741049
// CRITICAL: This renders tools immediately when TOOL_CALL_START arrives,
10751050
// not waiting until TOOL_CALL_END like the allToolCalls map approach does
@@ -1161,12 +1136,48 @@ export default function ProjectSessionDetailPage({
11611136
return result;
11621137
}, [
11631138
aguiState.messages,
1139+
aguiState.currentToolCall, // Needed in Phase A to avoid orphaned-child promotion
1140+
aguiState.pendingToolCalls, // CRITICAL: Include so UI updates when new tools start
1141+
aguiState.pendingChildren, // CRITICAL: Include so UI updates when children finish
1142+
]);
1143+
1144+
// Phase 2: append streaming text / reasoning bubbles to the committed list.
1145+
// This is O(1) and is the only memo that re-runs on every TEXT_MESSAGE_CONTENT
1146+
// or REASONING_MESSAGE_CONTENT delta (the most frequent events during active runs).
1147+
const streamMessages: Array<MessageObject | ToolUseMessages | HierarchicalToolMessage> = useMemo(() => {
1148+
const result = [...committedStreamMessages];
1149+
1150+
const activeReasoning = aguiState.currentReasoning || aguiState.currentThinking;
1151+
if (activeReasoning?.content) {
1152+
result.push({
1153+
type: "agent_message",
1154+
content: {
1155+
type: "reasoning_block",
1156+
thinking: activeReasoning.content,
1157+
signature: "",
1158+
},
1159+
model: "claude",
1160+
timestamp: activeReasoning.timestamp || "",
1161+
streaming: true,
1162+
} as MessageObject & { streaming?: boolean });
1163+
}
1164+
1165+
if (aguiState.currentMessage?.content) {
1166+
result.push({
1167+
type: "agent_message",
1168+
content: { type: "text_block", text: aguiState.currentMessage.content },
1169+
model: "claude",
1170+
timestamp: aguiState.currentMessage.timestamp || "",
1171+
streaming: true,
1172+
} as MessageObject & { streaming?: boolean });
1173+
}
1174+
1175+
return result;
1176+
}, [
1177+
committedStreamMessages,
11641178
aguiState.currentMessage,
11651179
aguiState.currentReasoning,
11661180
aguiState.currentThinking,
1167-
aguiState.currentToolCall,
1168-
aguiState.pendingToolCalls, // CRITICAL: Include so UI updates when new tools start
1169-
aguiState.pendingChildren, // CRITICAL: Include so UI updates when children finish
11701181
]);
11711182

11721183
// Check if there are any real messages (user or assistant messages, not just system)

components/frontend/src/components/session/MessagesTab.tsx

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,33 @@
11
"use client";
22

3-
import React, { useState, useRef, useEffect, useMemo } from "react";
3+
import React, { useState, useRef, useEffect, useMemo, useLayoutEffect, useCallback } from "react";
44
import { MessageSquare } from "lucide-react";
55
import { StreamMessage } from "@/components/ui/stream-message";
66
import { LoadingDots } from "@/components/ui/message";
7+
import { Button } from "@/components/ui/button";
78
import { ChatInputBox } from "@/components/chat/ChatInputBox";
89
import { QueuedMessageBubble } from "@/components/chat/QueuedMessageBubble";
9-
import type { AgenticSession, MessageObject, ToolUseMessages } from "@/types/agentic-session";
10+
import type { AgenticSession, MessageObject, ToolUseMessages, HierarchicalToolMessage } from "@/types/agentic-session";
1011
import type { WorkflowMetadata } from "@/app/projects/[name]/sessions/[sessionName]/lib/types";
1112
import type { QueuedMessageItem } from "@/hooks/use-session-queue";
1213

14+
/** Maximum number of messages rendered at once. Older messages are loaded on demand. */
15+
const MAX_VISIBLE_MESSAGES = 100;
16+
17+
/** Derive a stable React key for any message variant. */
18+
function getMessageKey(m: MessageObject | ToolUseMessages | HierarchicalToolMessage, idx: number): string {
19+
if ('id' in m && m.id) return m.id;
20+
if ('toolUseBlock' in m && m.toolUseBlock?.id) return `tool-${m.toolUseBlock.id}`;
21+
// Both MessageObject and HierarchicalToolMessage carry type+timestamp; the `in` guard is sufficient.
22+
if ('type' in m && 'timestamp' in m) return `${m.type}-${m.timestamp}-${idx}`;
23+
// Last resort: index within the visible window. This key shifts when the window expands
24+
// (Load earlier), but only affects messages with no other stable identifier.
25+
return `sm-${idx}`;
26+
}
27+
1328
export type MessagesTabProps = {
1429
session: AgenticSession;
15-
streamMessages: Array<MessageObject | ToolUseMessages>;
30+
streamMessages: Array<MessageObject | ToolUseMessages | HierarchicalToolMessage>;
1631
chatInput: string;
1732
setChatInput: (v: string) => void;
1833
onSendChat: () => Promise<void>;
@@ -43,6 +58,12 @@ const MessagesTab: React.FC<MessagesTabProps> = ({ session, streamMessages, chat
4358

4459
const messagesContainerRef = useRef<HTMLDivElement>(null);
4560
const [isAtBottom, setIsAtBottom] = useState(true);
61+
// How many messages (counting from the end) are currently rendered.
62+
const [loadedMessageCount, setLoadedMessageCount] = useState(MAX_VISIBLE_MESSAGES);
63+
// Refs for scroll-position preservation when loading earlier messages.
64+
const prevScrollHeightRef = useRef(0);
65+
const prevScrollTopRef = useRef(0);
66+
const preservingScrollRef = useRef(false);
4667

4768
const phase = session?.status?.phase || "";
4869

@@ -55,16 +76,25 @@ const MessagesTab: React.FC<MessagesTabProps> = ({ session, streamMessages, chat
5576
return true;
5677
});
5778

79+
// Only render the latest N messages; older ones are revealed via "Load earlier" button.
80+
const visibleMessages = useMemo(() => {
81+
if (filteredMessages.length <= loadedMessageCount) return filteredMessages;
82+
return filteredMessages.slice(filteredMessages.length - loadedMessageCount);
83+
}, [filteredMessages, loadedMessageCount]);
84+
85+
const hasMoreMessages = filteredMessages.length > loadedMessageCount;
86+
5887
const checkIfAtBottom = () => {
5988
const container = messagesContainerRef.current;
6089
if (!container) return true;
6190
const threshold = 50;
62-
const isBottom = container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
63-
return isBottom;
91+
return container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
6492
};
6593

6694
const handleScroll = () => {
67-
setIsAtBottom(checkIfAtBottom());
95+
const bottom = checkIfAtBottom();
96+
// Avoid a re-render when the value hasn't changed.
97+
setIsAtBottom((prev) => (prev === bottom ? prev : bottom));
6898
};
6999

70100
const scrollToBottom = () => {
@@ -74,6 +104,32 @@ const MessagesTab: React.FC<MessagesTabProps> = ({ session, streamMessages, chat
74104
}
75105
};
76106

107+
// Load earlier messages and preserve the user's visual scroll position.
108+
const loadEarlierMessages = useCallback(() => {
109+
const container = messagesContainerRef.current;
110+
if (container) {
111+
// Capture BEFORE state update so we have the pre-render values.
112+
prevScrollHeightRef.current = container.scrollHeight;
113+
prevScrollTopRef.current = container.scrollTop;
114+
preservingScrollRef.current = true;
115+
}
116+
setLoadedMessageCount((prev) => prev + MAX_VISIBLE_MESSAGES);
117+
}, []);
118+
119+
// After React commits the expanded list, forcibly set scrollTop so the
120+
// viewport stays anchored to the same content regardless of whether the
121+
// browser's native scroll-anchoring has already adjusted it.
122+
useLayoutEffect(() => {
123+
if (!preservingScrollRef.current) return;
124+
preservingScrollRef.current = false;
125+
const container = messagesContainerRef.current;
126+
if (container) {
127+
const scrollDelta = container.scrollHeight - prevScrollHeightRef.current;
128+
container.scrollTop = prevScrollTopRef.current + scrollDelta;
129+
}
130+
// eslint-disable-next-line react-hooks/exhaustive-deps -- refs are stable; loadedMessageCount is the intended trigger
131+
}, [loadedMessageCount]);
132+
77133
useEffect(() => {
78134
if (isAtBottom) {
79135
scrollToBottom();
@@ -137,8 +193,27 @@ const MessagesTab: React.FC<MessagesTabProps> = ({ session, streamMessages, chat
137193
>
138194
{showWelcomeExperience && welcomeExperienceComponent}
139195

140-
{shouldShowMessages && filteredMessages.map((m, idx) => (
141-
<StreamMessage key={`sm-${idx}`} message={m} isNewest={idx === filteredMessages.length - 1} onGoToResults={onGoToResults} agentName={agentName} />
196+
{shouldShowMessages && hasMoreMessages && (
197+
<div className="flex justify-center py-2">
198+
<Button
199+
variant="ghost"
200+
size="sm"
201+
onClick={loadEarlierMessages}
202+
className="text-xs text-muted-foreground underline underline-offset-2"
203+
>
204+
Load earlier messages ({filteredMessages.length - loadedMessageCount} hidden)
205+
</Button>
206+
</div>
207+
)}
208+
209+
{shouldShowMessages && visibleMessages.map((m, idx) => (
210+
<StreamMessage
211+
key={getMessageKey(m, idx)}
212+
message={m}
213+
isNewest={idx === visibleMessages.length - 1}
214+
onGoToResults={onGoToResults}
215+
agentName={agentName}
216+
/>
142217
))}
143218

144219
{/* Queued messages with cancel buttons */}
@@ -176,7 +251,7 @@ const MessagesTab: React.FC<MessagesTabProps> = ({ session, streamMessages, chat
176251
</div>
177252
)}
178253

179-
{!showWelcomeExperience && !isCreating && filteredMessages.length === 0 && (
254+
{!showWelcomeExperience && !isCreating && visibleMessages.length === 0 && (
180255
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
181256
<MessageSquare className="w-8 h-8 mx-auto mb-2 opacity-50" />
182257
<p className="text-sm">No messages yet</p>

0 commit comments

Comments
 (0)