Skip to content

Commit d850004

Browse files
fazxesclaude
andcommitted
perf(agent): comprehensive bundle and runtime optimizations
Bundle Optimizations: - Add manualChunks to split mermaid (1.7MB) and cytoscape (644KB) into separate lazy-loaded chunks - Configure React Compiler (babel-plugin-react-compiler) for automatic memoization - Add rollup-plugin-visualizer for bundle analysis - Lazy load SettingsDialog, TerminalPanel, and BrowserPanel with React.lazy() Chat Virtualization (TanStack Virtual): - Add shouldAdjustScrollPositionOnItemSizeChange to prevent scroll jumping when scrolling backward - Enable useAnimationFrameWithResizeObserver for smoother resize measurements - Use message IDs for stable virtualizer keys (getItemKey) - Increase estimateSize to 200 for streaming messages - Remove smooth scroll behavior (incompatible with dynamic sizes per docs) CSS Performance: - Add content-visibility and contain properties for virtual items - Add CSS containment utilities for scroll containers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent d576be9 commit d850004

13 files changed

Lines changed: 531 additions & 74 deletions

File tree

‎apps/agent/src/components/chat/chat-messages.tsx‎

Lines changed: 139 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { useVirtualizer } from '@tanstack/react-virtual';
12
import { Loader2 } from 'lucide-react';
2-
import { useEffect, useRef } from 'react';
3+
import { useCallback, useEffect, useRef } from 'react';
34

45
import { MessageItem } from './messages';
56
import { QueuedMessageBubble } from './queued-message';
@@ -40,58 +41,150 @@ export const ChatMessages: FC<ChatMessagesProps> = ({
4041
onCancelQueue,
4142
onFeedback,
4243
}) => {
43-
const messagesEndRef = useRef<HTMLDivElement>(null);
44-
45-
// Auto-scroll to bottom when messages change
46-
useEffect(() => {
47-
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
48-
}, [messages]);
44+
const parentRef = useRef<HTMLDivElement>(null);
45+
const shouldAutoScroll = useRef(true);
4946

5047
// Check if any message is still animating
5148
const isAnimating = messages.some((m) => m.displayedContent.length < m.content.length);
5249

50+
// Track last message content length for auto-scroll dependency
51+
const lastMessageContentLength = messages[messages.length - 1]?.displayedContent.length ?? 0;
52+
53+
// Stable callbacks to prevent virtualizer recreation
54+
const estimateSize = useCallback(() => 200, []); // Estimate HIGH for streaming messages
55+
const getItemKey = useCallback((index: number) => messages[index]?.id ?? index, [messages]);
56+
57+
// Virtualizer for message list
58+
const virtualizer = useVirtualizer({
59+
count: messages.length,
60+
getScrollElement: () => parentRef.current,
61+
estimateSize,
62+
overscan: 5,
63+
// Use message IDs for stable keys (not indexes)
64+
getItemKey,
65+
// Smoother resize measurements via requestAnimationFrame
66+
useAnimationFrameWithResizeObserver: true,
67+
});
68+
69+
// Prevent scroll jumping when scrolling backward with dynamic sizes
70+
// This is a property on the instance, not an initialization option
71+
// See: packages/virtual-core/src/index.ts lines 373-379, 900-912
72+
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
73+
// Don't adjust scroll position when actively scrolling backward (up)
74+
// This prevents content jumping when viewing older messages while new ones stream
75+
if (instance.scrollDirection === 'backward') {
76+
return false;
77+
}
78+
// Otherwise use default behavior: adjust if item is above current scroll position
79+
return item.start < (instance.scrollOffset ?? 0);
80+
};
81+
82+
// Auto-scroll to bottom when messages change or during streaming
83+
useEffect(() => {
84+
if (!shouldAutoScroll.current || !parentRef.current) return;
85+
86+
const element = parentRef.current;
87+
const { scrollHeight, clientHeight, scrollTop } = element;
88+
const isNearBottom = scrollHeight - clientHeight - scrollTop < 150;
89+
90+
if (isNearBottom || messages.length === 1) {
91+
// Note: Don't use behavior: 'smooth' with dynamic sizes (per TanStack docs)
92+
virtualizer.scrollToIndex(messages.length - 1, { align: 'end' });
93+
}
94+
}, [messages.length, lastMessageContentLength, virtualizer]);
95+
96+
// Track if user is manually scrolling (disable auto-scroll if scrolled up)
97+
useEffect(() => {
98+
const element = parentRef.current;
99+
if (!element) return;
100+
101+
const handleScroll = (): void => {
102+
const { scrollHeight, clientHeight, scrollTop } = element;
103+
const isAtBottom = scrollHeight - clientHeight - scrollTop < 100;
104+
shouldAutoScroll.current = isAtBottom;
105+
};
106+
107+
element.addEventListener('scroll', handleScroll, { passive: true });
108+
return () => {
109+
element.removeEventListener('scroll', handleScroll);
110+
};
111+
}, []);
112+
53113
return (
54-
<div className="flex-1 overflow-y-auto p-4" style={{ scrollbarGutter: 'stable both-edges' }}>
55-
<div className="max-w-3xl mx-auto flex flex-col gap-y-3">
56-
{messages.map((msg, index) => {
57-
const isLastAssistantMessage =
58-
msg.role === 'assistant' && messages.slice(index + 1).every((m) => m.role === 'user');
59-
60-
return (
61-
<MessageItem
62-
key={msg.id}
63-
message={msg}
64-
tools={getToolsForMessage(msg.id)}
65-
isLastAssistantMessage={isLastAssistantMessage}
66-
onRewind={onRewind}
114+
<div
115+
ref={parentRef}
116+
className="flex-1 overflow-y-auto overflow-x-hidden p-4"
117+
style={{ scrollbarGutter: 'stable both-edges', contain: 'strict' }}
118+
>
119+
<div className="max-w-3xl mx-auto">
120+
{/* Virtualized message container */}
121+
<div
122+
style={{
123+
height: `${String(virtualizer.getTotalSize())}px`,
124+
width: '100%',
125+
position: 'relative',
126+
}}
127+
>
128+
{virtualizer.getVirtualItems().map((virtualItem) => {
129+
const msg = messages[virtualItem.index];
130+
if (!msg) return null;
131+
132+
const isLastAssistantMessage =
133+
msg.role === 'assistant' &&
134+
messages.slice(virtualItem.index + 1).every((m) => m.role === 'user');
135+
136+
return (
137+
<div
138+
key={virtualItem.key}
139+
data-index={virtualItem.index}
140+
ref={virtualizer.measureElement}
141+
className="virtual-item pb-3"
142+
style={{
143+
position: 'absolute',
144+
top: 0,
145+
left: 0,
146+
width: '100%',
147+
transform: `translateY(${String(virtualItem.start)}px)`,
148+
}}
149+
>
150+
<MessageItem
151+
message={msg}
152+
tools={getToolsForMessage(msg.id)}
153+
isLastAssistantMessage={isLastAssistantMessage}
154+
onRewind={onRewind}
155+
onOpenFile={onOpenFile}
156+
onOpenUrl={onOpenUrl}
157+
onFeedback={onFeedback}
158+
/>
159+
</div>
160+
);
161+
})}
162+
</div>
163+
164+
{/* Non-virtualized footer items (always at bottom) */}
165+
<div className="flex flex-col gap-y-3">
166+
{/* Permission modals */}
167+
{pendingPermissions.map((request) => (
168+
<PermissionModal
169+
key={request.requestId}
170+
request={request}
171+
onApprove={onPermissionApprove}
172+
onDeny={onPermissionDeny}
67173
onOpenFile={onOpenFile}
68-
onOpenUrl={onOpenUrl}
69-
onFeedback={onFeedback}
70174
/>
71-
);
72-
})}
73-
{/* Permission modals */}
74-
{pendingPermissions.map((request) => (
75-
<PermissionModal
76-
key={request.requestId}
77-
request={request}
78-
onApprove={onPermissionApprove}
79-
onDeny={onPermissionDeny}
80-
onOpenFile={onOpenFile}
81-
/>
82-
))}
83-
{/* Queued message bubble - shows when user typed while agent was running */}
84-
{queuedMessage !== null ? (
85-
<QueuedMessageBubble message={queuedMessage} onCancel={onCancelQueue} />
86-
) : null}
87-
{/* Progress indicator - shows while agent is running OR text is still animating */}
88-
{isAgentRunning || isAnimating ? (
89-
<div className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground">
90-
<Loader2 className="h-4 w-4 animate-spin" />
91-
<span>Generating...</span>
92-
</div>
93-
) : null}
94-
<div ref={messagesEndRef} />
175+
))}
176+
{/* Queued message bubble - shows when user typed while agent was running */}
177+
{queuedMessage !== null ? (
178+
<QueuedMessageBubble message={queuedMessage} onCancel={onCancelQueue} />
179+
) : null}
180+
{/* Progress indicator - shows while agent is running OR text is still animating */}
181+
{isAgentRunning || isAnimating ? (
182+
<div className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground">
183+
<Loader2 className="h-4 w-4 animate-spin" />
184+
<span>Generating...</span>
185+
</div>
186+
) : null}
187+
</div>
95188
</div>
96189
</div>
97190
);

‎apps/agent/src/components/chat/messages/message-item.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { memo } from 'react';
12
import remarkGfm from 'remark-gfm';
23
import { Streamdown } from 'streamdown';
34

@@ -59,15 +60,15 @@ type Segment =
5960
| { type: 'content'; text: string; key: string }
6061
| { type: 'tool'; tool: ToolExecution; key: string };
6162

62-
export const MessageItem: FC<MessageItemProps> = ({
63+
export const MessageItem: FC<MessageItemProps> = memo(function MessageItem({
6364
message,
6465
tools,
6566
isLastAssistantMessage,
6667
onRewind,
6768
onOpenFile,
6869
onOpenUrl,
6970
onFeedback,
70-
}) => {
71+
}) {
7172
const isComplete =
7273
!message.isStreaming && message.displayedContent.length === message.content.length;
7374

@@ -354,4 +355,4 @@ export const MessageItem: FC<MessageItemProps> = ({
354355
) : null}
355356
</div>
356357
);
357-
};
358+
});

‎apps/agent/src/components/files/file-explorer.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ const FileTreeRow: FC<FileTreeRowProps> = memo(
391391
return (
392392
<button
393393
className={cn(
394-
'flex items-center w-full text-sm hover:bg-accent/50 transition-colors',
394+
'file-tree-item flex items-center w-full text-sm hover:bg-accent/50 transition-colors',
395395
isSelected && 'bg-accent text-accent-foreground'
396396
)}
397397
style={{

‎apps/agent/src/components/layout/chat-area.tsx‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { useCallback, useEffect, useState } from 'react';
1+
import { lazy, Suspense, useCallback, useEffect, useState } from 'react';
22

33
import type { ChatMessage } from '@/components/chat/messages';
4+
import type { TerminalPanelProps } from '@/components/terminal/terminal-panel';
45
import type { FileEntry } from '@/types/context';
56
import type { ExtensionMessage } from '@/types/protocol';
67
import type { FC } from 'react';
@@ -14,7 +15,6 @@ import {
1415
} from '@/components/chat';
1516
import { ResizeHandle } from '@/components/layout/resize-handle';
1617
import { ActivityPanel } from '@/components/panels';
17-
import { TerminalPanel } from '@/components/terminal';
1818
import { useChatMessages } from '@/hooks/use-chat-messages';
1919
import { useTauri } from '@/hooks/use-tauri';
2020
import {
@@ -27,6 +27,16 @@ import {
2727
} from '@/stores/tool-store';
2828
import { useUIStore, useTerminalPosition } from '@/stores/ui-store';
2929

30+
// Lazy load heavy components
31+
const LazyTerminalPanel = lazy(() =>
32+
import('@/components/terminal/terminal-panel').then((m) => ({ default: m.TerminalPanel }))
33+
);
34+
const TerminalPanel: FC<TerminalPanelProps> = (props) => (
35+
<Suspense fallback={null}>
36+
<LazyTerminalPanel {...props} />
37+
</Suspense>
38+
);
39+
3040
export const ChatArea: FC = () => {
3141
const { reviewPanelOpen, bottomPanelOpen, reviewPanelWidth } = useUIStore();
3242
const terminalPosition = useTerminalPosition();

‎apps/agent/src/components/layout/primary-sidebar.tsx‎

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,26 @@ import {
77
Search,
88
Settings,
99
} from 'lucide-react';
10-
import { useCallback, useState } from 'react';
10+
import { lazy, Suspense, useCallback, useState } from 'react';
1111

12+
import type { SettingsDialogProps } from '@/components/modals/settings/settings-dialog';
1213
import type { ConversationSummary } from '@/stores/ui-store';
1314
import type { FC } from 'react';
1415

16+
import { FileExplorer } from '@/components/files';
17+
import { Kbd, KbdGroup } from '@/components/ui/kbd';
18+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
19+
import { useTauri } from '@/hooks/use-tauri';
20+
import { HEIGHTS, SIDEBAR, TRANSITIONS } from '@/lib/constants';
21+
import { cn } from '@/lib/utils';
22+
import {
23+
useUIStore,
24+
useIsLeftSidebarCollapsed,
25+
useWorkspaceName,
26+
useConversations,
27+
useActiveConversationId,
28+
} from '@/stores/ui-store';
29+
1530
// Custom sidebar toggle icon - thicker middle line when expanded
1631
const SidebarToggleIcon: FC<{ expanded: boolean }> = ({ expanded }) => (
1732
<svg
@@ -39,20 +54,17 @@ const SidebarToggleIcon: FC<{ expanded: boolean }> = ({ expanded }) => (
3954
</svg>
4055
);
4156

42-
import { FileExplorer } from '@/components/files';
43-
import { SettingsDialog } from '@/components/modals/settings';
44-
import { Kbd, KbdGroup } from '@/components/ui/kbd';
45-
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
46-
import { useTauri } from '@/hooks/use-tauri';
47-
import { HEIGHTS, SIDEBAR, TRANSITIONS } from '@/lib/constants';
48-
import { cn } from '@/lib/utils';
49-
import {
50-
useUIStore,
51-
useIsLeftSidebarCollapsed,
52-
useWorkspaceName,
53-
useConversations,
54-
useActiveConversationId,
55-
} from '@/stores/ui-store';
57+
// Lazy load heavy components
58+
const LazySettingsDialog = lazy(() =>
59+
import('@/components/modals/settings/settings-dialog').then((m) => ({
60+
default: m.SettingsDialog,
61+
}))
62+
);
63+
const SettingsDialog: FC<SettingsDialogProps> = (props) => (
64+
<Suspense fallback={null}>
65+
<LazySettingsDialog {...props} />
66+
</Suspense>
67+
);
5668

5769
type SidebarTab = 'conversations' | 'explorer';
5870

‎apps/agent/src/components/modals/settings/settings-dialog.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export type SettingsSection =
5151
| 'account'
5252
| 'feedback';
5353

54-
interface SettingsDialogProps {
54+
export interface SettingsDialogProps {
5555
readonly open: boolean;
5656
readonly onOpenChange: (open: boolean) => void;
5757
readonly defaultSection?: SettingsSection;

‎apps/agent/src/components/panels/activity-panel.tsx‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@ import {
88
Search,
99
X,
1010
} from 'lucide-react';
11-
import { useCallback, useEffect, useRef } from 'react';
11+
import { lazy, Suspense, useCallback, useEffect, useRef } from 'react';
1212

13+
import type { BrowserPanelProps } from '@/components/browser/browser-panel';
14+
import type { TerminalPanelProps } from '@/components/terminal/terminal-panel';
1315
import type { FileChange } from '@/stores/file-store';
1416
import type { ViewedFile } from '@/stores/file-viewer-store';
1517
import type { FC } from 'react';
1618

17-
import { BrowserPanel } from '@/components/browser';
1819
import { FileIcon, FileViewer } from '@/components/files';
1920
import { FilesChangedList, SourceControlTab } from '@/components/git';
20-
import { TerminalPanel } from '@/components/terminal';
2121
import { ButtonGroup } from '@/components/ui/button-group';
2222
import { useTauri } from '@/hooks/use-tauri';
2323
import { lspDidClose, lspDidOpen } from '@/lib/backend';
@@ -32,6 +32,25 @@ import {
3232
import { useUIStore, useTerminalPosition, useActivityTab } from '@/stores/ui-store';
3333
import { generateUUID } from '@/types/protocol';
3434

35+
// Lazy load heavy components
36+
const LazyBrowserPanel = lazy(() =>
37+
import('@/components/browser/browser-panel').then((m) => ({ default: m.BrowserPanel }))
38+
);
39+
const BrowserPanel: FC<BrowserPanelProps> = (props) => (
40+
<Suspense fallback={null}>
41+
<LazyBrowserPanel {...props} />
42+
</Suspense>
43+
);
44+
45+
const LazyTerminalPanel = lazy(() =>
46+
import('@/components/terminal/terminal-panel').then((m) => ({ default: m.TerminalPanel }))
47+
);
48+
const TerminalPanel: FC<TerminalPanelProps> = (props) => (
49+
<Suspense fallback={null}>
50+
<LazyTerminalPanel {...props} />
51+
</Suspense>
52+
);
53+
3554
interface ActivityPanelProps {
3655
readonly width: number;
3756
}

‎apps/agent/src/components/terminal/terminal-panel.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { HEIGHTS } from '@/lib/constants';
2222
import { useTerminalStore } from '@/stores/terminal-store';
2323
import { useUIStore, useTerminalPosition } from '@/stores/ui-store';
2424

25-
interface TerminalPanelProps {
25+
export interface TerminalPanelProps {
2626
/**
2727
* Whether this is a full-width panel (spans chat + activity) or embedded in activity panel
2828
*/

0 commit comments

Comments
 (0)