Skip to content

Commit bba8896

Browse files
committed
refactor: optimize rendering performance in multiple places
1 parent cb6b857 commit bba8896

13 files changed

Lines changed: 530 additions & 209 deletions

packages/desktop/src/agent/AgentSidebar.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from 'react';
1+
import { useState, useMemo } from 'react';
22
import { useGlobalStore } from '../stores/global.store';
33
import { api } from '../lib/api';
44

@@ -24,14 +24,15 @@ export default function AgentSidebar() {
2424
const setCurrentThread = useGlobalStore((s) => s.setCurrentThread);
2525
const toggleSidebar = useGlobalStore((s) => s.toggleSidebar);
2626

27-
// Fine-grained selector: only extract thread list metadata, not full thread objects
28-
const threadList = useGlobalStore((s) => {
27+
// Subscribe to raw threads, derive list with useMemo for stable reference
28+
const rawThreads = useGlobalStore((s) => s.agent.threads);
29+
const threadList = useMemo(() => {
2930
const normalizedRoot = normalizeCwd(rootPath);
30-
return Object.values(s.agent.threads)
31+
return Object.values(rawThreads)
3132
.filter((t) => normalizeCwd(t.cwd).startsWith(normalizedRoot))
3233
.map((t) => ({ id: t.id, title: t.title, cwd: t.cwd, updatedAt: t.updatedAt }))
3334
.sort((a, b) => b.updatedAt - a.updatedAt);
34-
});
35+
}, [rawThreads, rootPath]);
3536

3637
const [hoveredThreadId, setHoveredThreadId] = useState<string | null>(null);
3738

packages/desktop/src/agent/ApprovalPanel.tsx

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { useState } from 'react';
1+
import { useState, useMemo } from 'react';
22
import type { Item } from '@shared/types';
33
import { useGlobalStore } from '../stores/global.store';
4-
import { useAgent } from '../hooks/useAgent';
4+
import { useAgentApproval } from '../hooks/useAgent';
55
import ToolCallCard from '../shared/ToolCallCard';
66

77
interface ApprovalPanelProps {
@@ -10,15 +10,30 @@ interface ApprovalPanelProps {
1010

1111
export default function ApprovalPanel({ threadId }: ApprovalPanelProps) {
1212
const [collapsed, setCollapsed] = useState(false);
13-
const thread = useGlobalStore((s) => s.agent.threads[threadId]);
14-
const { approveTool, rejectTool } = useAgent();
13+
const { approveTool, rejectTool } = useAgentApproval();
1514

16-
const pendingItems =
17-
thread?.turns.flatMap((turn) =>
15+
// Stable string key: only changes when pending item IDs change, not on every content update
16+
const pendingKey = useGlobalStore((s) => {
17+
const thread = s.agent.threads[threadId];
18+
if (!thread) return '';
19+
return thread.turns
20+
.flatMap((t) => t.items)
21+
.filter((i) => i.type === 'tool_call' && i.status === 'pending')
22+
.map((i) => i.id)
23+
.join(',');
24+
});
25+
26+
// Only compute pending items when the key changes
27+
const pendingItems = useMemo(() => {
28+
if (!pendingKey) return [];
29+
const thread = useGlobalStore.getState().agent.threads[threadId];
30+
if (!thread) return [];
31+
return thread.turns.flatMap((turn) =>
1832
turn.items.filter(
1933
(i): i is Item & { type: 'tool_call' } => i.type === 'tool_call' && i.status === 'pending'
2034
)
21-
) ?? [];
35+
);
36+
}, [pendingKey, threadId]);
2237

2338
if (pendingItems.length === 0) return null;
2439

packages/desktop/src/agent/MessageStream.tsx

Lines changed: 66 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@ import MessageItem from '../shared/MessageItem';
55
import UnifiedDiffView from '../shared/UnifiedDiffView';
66
import type { Item } from '@shared/types';
77
import type { CheckpointDiff } from '../lib/core-api';
8-
import { useAgent } from '../hooks/useAgent';
8+
import { useAgentApproval, useAgentRollback } from '../hooks/useAgent';
99

1010
interface MessageStreamProps {
1111
threadId: string;
1212
}
1313

1414
// ---- Top-level TurnDiffPanel (avoids unmount/remount on parent re-render) ----
1515

16+
const EMPTY_MAPPING: Record<number, string> = {};
17+
1618
interface TurnDiffPanelProps {
1719
uiTurnId: string;
1820
isInterrupted?: boolean;
@@ -205,11 +207,14 @@ function TurnDiffPanel({
205207
// ---- Main component ----
206208

207209
export default function MessageStream({ threadId }: MessageStreamProps) {
208-
const thread = useGlobalStore((s) => s.agent.threads[threadId]);
210+
// Fine-grained subscriptions: only subscribe to what we actually use
211+
const turns = useGlobalStore((s) => s.agent.threads[threadId]?.turns ?? []);
209212
const setCurrentThread = useGlobalStore((s) => s.setCurrentThread);
210213
const {
211214
approveTool,
212215
rejectTool,
216+
} = useAgentApproval();
217+
const {
213218
loadCheckpointDiff,
214219
revertFile,
215220
revertAgentFiles,
@@ -221,68 +226,81 @@ export default function MessageStream({ threadId }: MessageStreamProps) {
221226
undoCodeRollback,
222227
forkThread,
223228
revertedFilesByTurnId,
224-
} = useAgent();
229+
} = useAgentRollback();
225230
const virtuosoRef = useRef<VirtuosoHandle>(null);
226-
const scrollContainerRef = useRef<HTMLDivElement>(null);
227-
const wasAtBottomRef = useRef(true);
228231

229-
// Fine-grained selectors: only subscribe to current thread's data
230-
const checkpointDiffs = useGlobalStore((s) => {
232+
// Subscribe to raw store data (stable references), derive with useMemo below
233+
const rawCheckpointDiffByTurnId = useGlobalStore((s) => s.rollback.checkpointDiffByTurnId);
234+
const rawTurnCheckpointMapping = useGlobalStore((s) => s.rollback.turnCheckpointMapping);
235+
const markScopeRestored = useGlobalStore((s) => s.markScopeRestored);
236+
const markFileRestored = useGlobalStore((s) => s.markFileRestored);
237+
const setPendingInput = useGlobalStore((s) => s.setPendingInput);
238+
239+
// Derive thread-scoped data with useMemo for stable references
240+
const checkpointDiffs = useMemo(() => {
231241
const prefix = `${threadId}:`;
232242
const result: Record<string, CheckpointDiff> = {};
233-
for (const [k, v] of Object.entries(s.rollback.checkpointDiffByTurnId)) {
243+
for (const [k, v] of Object.entries(rawCheckpointDiffByTurnId)) {
234244
if (k.startsWith(prefix)) result[k] = v;
235245
}
236246
return result;
237-
});
238-
const turnCheckpointMapping = useGlobalStore(
239-
(s) => s.rollback.turnCheckpointMapping[threadId] ?? {}
240-
);
241-
const markScopeRestored = useGlobalStore((s) => s.markScopeRestored);
242-
const markFileRestored = useGlobalStore((s) => s.markFileRestored);
243-
const setPendingInput = useGlobalStore((s) => s.setPendingInput);
247+
}, [threadId, rawCheckpointDiffByTurnId]);
248+
249+
const turnCheckpointMapping = rawTurnCheckpointMapping[threadId] ?? EMPTY_MAPPING;
244250

245251
// Rollback modal state
246252
const [showRollbackPanel, setShowRollbackPanel] = useState<{
247253
turnId: string;
248254
preview: any;
249255
} | null>(null);
250256

257+
// ---- Structure signature: only changes when turns structure changes (not content) ----
258+
const turnsStructureKey = useMemo(
259+
() =>
260+
turns
261+
.map((t) => `${t.id}:${t.status}:${t.items.length}:${t.items.map((i) => `${i.type}:${i.id}`).join(',')}`)
262+
.join('|'),
263+
[turns]
264+
);
265+
251266
// ---- Memoized rendering data ----
267+
// Depends on turnsStructureKey instead of thread, so content updates don't trigger rebuild
252268

253-
const { renderEntries, callIdToToolName } = useMemo(() => {
269+
const { renderEntries, callIdToToolName, entryCountByTurnId, turnById } = useMemo(() => {
254270
const entries: Array<{
255271
item: Item;
256272
turnId: string;
257273
toolResult?: Item & { type: 'tool_result' };
258274
}> = [];
259275
const toolResultByCallId: Record<string, Item & { type: 'tool_result' }> = {};
260276
const nameMap: Record<string, string> = {};
277+
const countMap = new Map<string, number>();
278+
const turnMap = new Map<string, (typeof turns)[number]>();
261279

262-
if (thread) {
263-
for (const turn of thread.turns) {
264-
for (const item of turn.items) {
265-
if (item.type === 'tool_result') {
266-
toolResultByCallId[item.callId] = item as any;
267-
} else if (item.type === 'tool_call') {
268-
nameMap[item.id] = item.name;
269-
}
280+
for (const turn of turns) {
281+
turnMap.set(turn.id, turn);
282+
for (const item of turn.items) {
283+
if (item.type === 'tool_result') {
284+
toolResultByCallId[item.callId] = item as any;
285+
} else if (item.type === 'tool_call') {
286+
nameMap[item.id] = item.name;
270287
}
271288
}
272-
for (const turn of thread.turns) {
273-
for (const item of turn.items) {
274-
if (item.type === 'tool_result') continue;
275-
if (item.type === 'tool_call') {
276-
entries.push({ item, turnId: turn.id, toolResult: toolResultByCallId[item.id] });
277-
} else {
278-
entries.push({ item, turnId: turn.id });
279-
}
289+
}
290+
for (const turn of turns) {
291+
for (const item of turn.items) {
292+
if (item.type === 'tool_result') continue;
293+
if (item.type === 'tool_call') {
294+
entries.push({ item, turnId: turn.id, toolResult: toolResultByCallId[item.id] });
295+
} else {
296+
entries.push({ item, turnId: turn.id });
280297
}
298+
countMap.set(turn.id, (countMap.get(turn.id) ?? 0) + 1);
281299
}
282300
}
283301

284-
return { renderEntries: entries, callIdToToolName: nameMap };
285-
}, [thread]);
302+
return { renderEntries: entries, callIdToToolName: nameMap, entryCountByTurnId: countMap, turnById: turnMap };
303+
}, [turnsStructureKey]);
286304

287305
const { turnEndIndices, turnRollbackCallbacks } = useMemo(() => {
288306
const endIndices = new Set<number>();
@@ -293,10 +311,9 @@ export default function MessageStream({ threadId }: MessageStreamProps) {
293311
onForkFromHere?: () => void;
294312
}
295313
>();
296-
const turns = thread?.turns ?? [];
297314
let idx = 0;
298315
for (const turn of turns) {
299-
const turnEntryCount = renderEntries.filter((e) => e.turnId === turn.id).length;
316+
const turnEntryCount = entryCountByTurnId.get(turn.id) ?? 0;
300317
idx += turnEntryCount - 1;
301318
if (turn.status === 'completed' || turn.status === 'error') {
302319
endIndices.add(idx);
@@ -328,32 +345,16 @@ export default function MessageStream({ threadId }: MessageStreamProps) {
328345
idx++;
329346
}
330347
return { turnEndIndices: endIndices, turnRollbackCallbacks: rollbackCbs };
331-
}, [thread, renderEntries, threadId, previewRollback, forkThread, setCurrentThread, setPendingInput]);
348+
}, [turns, entryCountByTurnId, threadId, previewRollback, forkThread, setCurrentThread, setPendingInput]);
332349

333350
const totalCount = renderEntries.length;
334-
const isLargeList = totalCount > 100;
335-
const turns = thread?.turns ?? [];
336351

337352
// Memoized turnStatusKey for auto-load diff effect
338353
const turnStatusKey = useMemo(
339354
() => turns.map((t) => `${t.id}:${t.status}`).join(','),
340355
[turns]
341356
);
342357

343-
const handleScroll = useCallback(() => {
344-
const el = scrollContainerRef.current;
345-
if (!el) return;
346-
wasAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
347-
}, []);
348-
349-
useEffect(() => {
350-
if (totalCount === 0 || isLargeList) return;
351-
if (!wasAtBottomRef.current) return;
352-
const el = scrollContainerRef.current;
353-
if (!el) return;
354-
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
355-
}, [totalCount, isLargeList]);
356-
357358
// Auto-load diff when a turn completes or errors
358359
const handleLoadDiff = useCallback(
359360
async (uiTurnId: string) => {
@@ -416,7 +417,7 @@ export default function MessageStream({ threadId }: MessageStreamProps) {
416417
const isLastInTurn = turnEndIndices.has(index);
417418
const cbs = turnRollbackCallbacks.get(entry.turnId);
418419
const isUserMsg = entry.item.type === 'message' && entry.item.role === 'user';
419-
const turn = turns.find((t) => t.id === entry.turnId);
420+
const turn = turnById.get(entry.turnId);
420421
const isInterrupted = turn?.status === 'error';
421422

422423
return (
@@ -454,7 +455,7 @@ export default function MessageStream({ threadId }: MessageStreamProps) {
454455
renderEntries,
455456
turnEndIndices,
456457
turnRollbackCallbacks,
457-
turns,
458+
turnById,
458459
threadId,
459460
approveTool,
460461
rejectTool,
@@ -469,7 +470,7 @@ export default function MessageStream({ threadId }: MessageStreamProps) {
469470

470471
// ---- Empty state ----
471472

472-
if (!thread || renderEntries.length === 0) {
473+
if (turns.length === 0 || renderEntries.length === 0) {
473474
return (
474475
<div className="flex-1 flex items-center justify-center text-[#444] text-[15px]">
475476
发送消息开始对话
@@ -521,40 +522,18 @@ export default function MessageStream({ threadId }: MessageStreamProps) {
521522
</div>
522523
);
523524

524-
// ---- Virtuoso path (many items) ----
525-
526-
if (isLargeList) {
527-
return (
528-
<div className="flex-1 flex flex-col min-h-0">
529-
<Virtuoso
530-
ref={virtuosoRef}
531-
className="flex-1 select-text"
532-
totalCount={totalCount}
533-
itemContent={renderItem}
534-
followOutput={(isAtBottom: boolean) => (isAtBottom ? 'smooth' : false)}
535-
style={{ flex: 1 }}
536-
/>
537-
{rollbackModal}
538-
</div>
539-
);
540-
}
541-
542-
// ---- Non-Virtuoso path (few items) ----
525+
// ---- Unified Virtuoso path ----
543526

544527
return (
545528
<div className="flex-1 flex flex-col min-h-0">
546-
<div
547-
ref={scrollContainerRef}
548-
onScroll={handleScroll}
549-
className="flex-1 overflow-y-auto select-text"
550-
>
551-
<div className="pt-8 pb-4 max-w-[820px] mx-auto">
552-
{renderEntries.map((entry, i) => {
553-
const key = entry.item.id + (entry.toolResult ? '-' + entry.toolResult.id : '');
554-
return <div key={key}>{renderItem(i)}</div>;
555-
})}
556-
</div>
557-
</div>
529+
<Virtuoso
530+
ref={virtuosoRef}
531+
className="flex-1 select-text"
532+
totalCount={totalCount}
533+
itemContent={renderItem}
534+
followOutput={(isAtBottom: boolean) => (isAtBottom ? 'smooth' : false)}
535+
style={{ flex: 1 }}
536+
/>
558537
{rollbackModal}
559538
</div>
560539
);

packages/desktop/src/agent/ProjectStrip.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from 'react';
1+
import { useState, useMemo } from 'react';
22
import { useGlobalStore } from '../stores/global.store';
33
import { API_BASE, api } from '../lib/api';
44
import type { Project, Thread } from '@shared/types';
@@ -119,7 +119,15 @@ function SessionListPopup({
119119
export default function ProjectStrip() {
120120
const projects = useGlobalStore((s) => s.workspace.projects);
121121
const currentProjectId = useGlobalStore((s) => s.workspace.currentProjectId);
122-
const threads = useGlobalStore((s) => s.agent.threads);
122+
const rawThreads = useGlobalStore((s) => s.agent.threads);
123+
const threadMetadata = useMemo(() => {
124+
return Object.values(rawThreads).map((t) => ({
125+
id: t.id,
126+
title: t.title,
127+
cwd: t.cwd,
128+
updatedAt: t.updatedAt,
129+
}));
130+
}, [rawThreads]);
123131
const currentThreadId = useGlobalStore((s) => s.agent.currentThreadId);
124132
const sidebarCollapsed = useGlobalStore((s) => s.ui.sidebarCollapsed);
125133
const switchProject = useGlobalStore((s) => s.switchProject);
@@ -169,10 +177,10 @@ export default function ProjectStrip() {
169177

170178
const getThreadsForProject = (rootPath: string): Thread[] => {
171179
const normalizedRoot = normalizeCwd(rootPath);
172-
return Object.values(threads).filter((t) => {
180+
return threadMetadata.filter((t) => {
173181
const tcwd = normalizeCwd(t.cwd);
174182
return tcwd.startsWith(normalizedRoot);
175-
});
183+
}) as Thread[];
176184
};
177185

178186
return (

0 commit comments

Comments
 (0)