Skip to content

Commit 4b8c12a

Browse files
psypealclaude
andcommitted
fix: prevent renderer memory leak from unbounded expansion state
- Clear expansion Maps/Sets (aiGroupExpansionLevels, expandedStepIds, expandedDisplayItemIds, expandedAIGroupIds) when switching sessions to prevent unbounded growth over long uptime - Add periodic pruning for sessionRefreshGeneration and projectRefreshGeneration Maps - Add SubagentDisplayMeta type to Process for memory-efficient subagent rendering without holding full transcripts - Add subagentMessageCacheSlice (Zustand) with single-flight Promise dedup and LRU eviction for on-demand subagent message loading - Refactor SubagentItem to read displayMeta for collapsed headers and lazy-load message bodies only when expanded via useSubagentMessages - Update AIChatGroup and modelExtractor to prefer displayMeta over iterating raw messages Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 34b0aad commit 4b8c12a

19 files changed

Lines changed: 817 additions & 207 deletions

File tree

src/main/types/chunks.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,58 @@
1212
* - Constants
1313
*/
1414

15-
import { type Session, type SessionMetrics } from './domain';
15+
import {
16+
type PhaseTokenBreakdown,
17+
type Session,
18+
type SessionMetrics,
19+
type TokenUsage,
20+
} from './domain';
1621
import { type ToolUseResultData } from './jsonl';
1722
import { type ParsedMessage, type ToolCall, type ToolResult } from './messages';
1823

1924
// =============================================================================
2025
// Process Types (Subagent Execution)
2126
// =============================================================================
2227

28+
/**
29+
* Pre-computed display data for a subagent.
30+
*
31+
* Extracted in main process during parsing so the renderer can render the
32+
* collapsed SubagentItem header without holding the full transcript. Keeps
33+
* `Process.messages` empty in the worker output path, reducing per-cached-
34+
* SessionDetail memory by ~MB→KB per subagent.
35+
*
36+
* Full message bodies are loaded lazily via the get-subagent-messages IPC
37+
* when the user expands a subagent or a highlighted-error needs the trace.
38+
*/
39+
export interface SubagentDisplayMeta {
40+
/** Number of assistant messages containing at least one tool_use block. */
41+
toolCount: number;
42+
/** Model name from the first assistant message that has one (excluding `<synthetic>`). */
43+
modelName: string | null;
44+
/** Usage block from the LAST assistant message that has one. */
45+
lastUsage: TokenUsage | null;
46+
/** Count of assistant messages that have a usage block (used for "N turns"). */
47+
turnCount: number;
48+
/**
49+
* True when this is a team member whose only assistant action is a
50+
* SendMessage(shutdown_response). Used to render the slim shutdown row.
51+
*/
52+
isShutdownOnly: boolean;
53+
/** Multi-phase context breakdown when subagent has compaction events. */
54+
phaseBreakdown?: {
55+
phases: PhaseTokenBreakdown[];
56+
totalConsumption: number;
57+
compactionCount: number;
58+
};
59+
/**
60+
* Every tool_use id and tool_result tool_use_id seen in this subagent's
61+
* messages. Used by AIChatGroup.containsToolUseId and SubagentItem's
62+
* highlighted-error check without iterating messages.
63+
*/
64+
toolUseIds: string[];
65+
}
66+
2367
/**
2468
* Resolved subagent information.
2569
*/
@@ -28,7 +72,14 @@ export interface Process {
2872
id: string;
2973
/** Path to the subagent JSONL file */
3074
filePath: string;
31-
/** Parsed messages from the subagent session */
75+
/**
76+
* Parsed messages from the subagent session.
77+
*
78+
* In the worker output path this is intentionally empty; the renderer
79+
* loads bodies on demand via get-subagent-messages. Direct callers of
80+
* SubagentResolver (drill-down via SubagentDetailBuilder) still get the
81+
* full array.
82+
*/
3283
messages: ParsedMessage[];
3384
/** When the subagent started */
3485
startTime: Date;
@@ -38,6 +89,12 @@ export interface Process {
3889
durationMs: number;
3990
/** Aggregated metrics for the subagent */
4091
metrics: SessionMetrics;
92+
/**
93+
* Pre-computed display data for inline rendering without loading messages.
94+
* Optional for backwards compat with code paths that don't compute it,
95+
* but the worker output and SubagentResolver always populate it.
96+
*/
97+
displayMeta?: SubagentDisplayMeta;
4198
/** Task description from parent Task call */
4299
description?: string;
43100
/** Subagent type from Task call (e.g., "Explore", "Plan") */
@@ -401,6 +458,8 @@ export interface SessionDetail {
401458
processes: Process[];
402459
/** Aggregated metrics for the entire session */
403460
metrics: SessionMetrics;
461+
/** Timestamp (ms) when Rust native pipeline was used, or false if JS fallback */
462+
_nativePipeline?: number | false;
404463
}
405464

406465
/**
@@ -444,6 +503,7 @@ export interface FileChangeEvent {
444503
projectId?: string;
445504
sessionId?: string;
446505
isSubagent: boolean;
506+
fileSize?: number;
447507
}
448508

449509
// =============================================================================

src/preload/constants/ipcChannels.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,10 @@ export const FIND_SESSION_BY_ID = 'find-session-by-id';
187187

188188
/** Find sessions whose IDs contain a given hex fragment */
189189
export const FIND_SESSIONS_BY_PARTIAL_ID = 'find-sessions-by-partial-id';
190+
191+
// =============================================================================
192+
// Subagent API Channels
193+
// =============================================================================
194+
195+
/** Lazy-load a single subagent's parsed messages (renderer expansion path) */
196+
export const SUBAGENT_GET_MESSAGES = 'subagent:get-messages';

src/preload/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
SSH_SAVE_LAST_CONNECTION,
2323
SSH_STATUS,
2424
SSH_TEST,
25+
SUBAGENT_GET_MESSAGES,
2526
UPDATER_CHECK,
2627
UPDATER_DOWNLOAD,
2728
UPDATER_INSTALL,
@@ -96,6 +97,7 @@ interface IpcFileChangePayload {
9697
projectId?: string;
9798
sessionId?: string;
9899
isSubagent: boolean;
100+
fileSize?: number;
99101
}
100102

101103
/**
@@ -152,11 +154,12 @@ const electronAPI: ElectronAPI = {
152154
ipcRenderer.invoke('get-waterfall-data', projectId, sessionId),
153155
getSubagentDetail: (projectId: string, sessionId: string, subagentId: string) =>
154156
ipcRenderer.invoke('get-subagent-detail', projectId, sessionId, subagentId),
157+
getSubagentMessages: (projectId: string, sessionId: string, subagentId: string) =>
158+
ipcRenderer.invoke(SUBAGENT_GET_MESSAGES, projectId, sessionId, subagentId),
155159
getSessionGroups: (projectId: string, sessionId: string) =>
156160
ipcRenderer.invoke('get-session-groups', projectId, sessionId),
157161
getSessionsByIds: (projectId: string, sessionIds: string[], options?: SessionsByIdsOptions) =>
158162
ipcRenderer.invoke('get-sessions-by-ids', projectId, sessionIds, options),
159-
160163
// Repository grouping (worktree support)
161164
getRepositoryGroups: () => ipcRenderer.invoke('get-repository-groups'),
162165
getWorktreeSessions: (worktreeId: string) =>

src/renderer/api/httpClient.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
NotificationsAPI,
2424
NotificationTrigger,
2525
PaginatedSessionsResult,
26+
ParsedMessage,
2627
Project,
2728
RepositoryGroup,
2829
SearchSessionsResult,
@@ -255,6 +256,15 @@ export class HttpAPIClient implements ElectronAPI {
255256
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}`
256257
);
257258

259+
getSubagentMessages = (
260+
projectId: string,
261+
sessionId: string,
262+
subagentId: string
263+
): Promise<ParsedMessage[]> =>
264+
this.get<ParsedMessage[]>(
265+
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}/messages`
266+
);
267+
258268
getSessionGroups = (projectId: string, sessionId: string): Promise<ConversationGroup[]> =>
259269
this.get<ConversationGroup[]>(
260270
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/groups`

src/renderer/components/chat/AIChatGroup.tsx

Lines changed: 43 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssV
44
import { useTabUI } from '@renderer/hooks/useTabUI';
55
import { useStore } from '@renderer/store';
66
import { enhanceAIGroup, type PrecedingSlashInfo } from '@renderer/utils/aiGroupEnhancer';
7-
import { extractSlashInfo, isCommandContent } from '@shared/utils/contentSanitizer';
87
import { getModelColorClass } from '@shared/utils/modelParser';
98
import { estimateTokens } from '@shared/utils/tokenFormatting';
109
import { format } from 'date-fns';
@@ -22,39 +21,11 @@ import type {
2221
AIGroup,
2322
AIGroupDisplayItem,
2423
EnhancedAIGroup,
25-
UserGroup,
2624
} from '@renderer/types/groups';
2725
import type { TriggerColor } from '@shared/constants/triggerColors';
2826

29-
/**
30-
* Extract slash info from a UserGroup's message content.
31-
* Returns PrecedingSlashInfo if the user message was a slash invocation,
32-
* null otherwise.
33-
*/
34-
function extractPrecedingSlashInfo(
35-
userGroup: UserGroup | undefined
36-
): PrecedingSlashInfo | undefined {
37-
if (!userGroup) return undefined;
38-
39-
const msg = userGroup.message;
40-
const content = msg.content;
41-
42-
// Check if this is a slash message (has <command-name> tags)
43-
if (typeof content === 'string' && isCommandContent(content)) {
44-
const slashInfo = extractSlashInfo(content);
45-
if (slashInfo) {
46-
return {
47-
name: slashInfo.name,
48-
message: slashInfo.message,
49-
args: slashInfo.args,
50-
commandMessageUuid: msg.uuid,
51-
timestamp: new Date(msg.timestamp),
52-
};
53-
}
54-
}
55-
56-
return undefined;
57-
}
27+
// extractPrecedingSlashInfo moved to ChatHistory — pre-computed as a map to
28+
// avoid O(n) scan per visible group per refresh cycle.
5829

5930
/**
6031
* Format duration in milliseconds to human-readable string.
@@ -85,24 +56,31 @@ interface AIChatGroupProps {
8556
highlightColor?: TriggerColor;
8657
/** Register ref for individual tool items (for precise scroll targeting) */
8758
registerToolRef?: (toolId: string, el: HTMLElement | null) => void;
59+
/** Pre-computed slash info from the preceding user message (avoids O(n) scan per group). */
60+
precedingSlash?: PrecedingSlashInfo;
8861
}
8962

9063
/**
9164
* Checks if a tool ID exists within the display items (including nested subagents).
65+
*
66+
* For subagents we read the precomputed `displayMeta.toolUseIds` slot rather
67+
* than iterating `subagent.messages`, since the worker output strips the
68+
* messages array. Falls back to message iteration only if displayMeta is
69+
* absent (legacy/uncached path).
9270
*/
9371
function containsToolUseId(items: AIGroupDisplayItem[], toolUseId: string): boolean {
9472
for (const item of items) {
9573
if (item.type === 'tool' && item.tool.id === toolUseId) {
9674
return true;
9775
}
98-
// Check nested subagent messages for the tool ID
99-
if (item.type === 'subagent' && item.subagent.messages) {
100-
for (const msg of item.subagent.messages) {
101-
if (msg.toolCalls?.some((tc) => tc.id === toolUseId)) {
102-
return true;
103-
}
104-
if (msg.toolResults?.some((tr) => tr.toolUseId === toolUseId)) {
105-
return true;
76+
if (item.type === 'subagent') {
77+
const ids = item.subagent.displayMeta?.toolUseIds;
78+
if (ids?.includes(toolUseId)) return true;
79+
// Legacy fallback for code paths that haven't populated displayMeta.
80+
if (!ids && item.subagent.messages) {
81+
for (const msg of item.subagent.messages) {
82+
if (msg.toolCalls?.some((tc) => tc.id === toolUseId)) return true;
83+
if (msg.toolResults?.some((tr) => tr.toolUseId === toolUseId)) return true;
10684
}
10785
}
10886
}
@@ -125,6 +103,7 @@ const AIChatGroupInner = ({
125103
highlightToolUseId,
126104
highlightColor,
127105
registerToolRef,
106+
precedingSlash: precedingSlashProp,
128107
}: Readonly<AIChatGroupProps>): React.JSX.Element => {
129108
// Per-tab UI state for expansion (completely isolated per tab)
130109
const {
@@ -147,12 +126,15 @@ const AIChatGroupInner = ({
147126
return s.sessions.find((sess) => sess.id === id)?.isOngoing ?? false;
148127
});
149128

150-
// Per-tab session data subscriptions, falling back to global state
129+
// Per-tab session data subscriptions, falling back to global state.
130+
// NOTE: `conversation` is intentionally NOT subscribed here — it caused
131+
// all ~16 visible AIChatGroups to fully re-render on every 3s refresh,
132+
// re-running all memos (O(n) precedingSlash scan, enhanceAIGroup, etc).
133+
// precedingSlash is now pre-computed and passed as a prop from ChatHistory.
151134
const {
152135
sessionClaudeMdStats,
153136
sessionContextStats,
154137
sessionPhaseInfo,
155-
conversation,
156138
searchExpandedAIGroupIds,
157139
searchExpandedSubagentIds,
158140
searchCurrentDisplayItemId,
@@ -163,7 +145,6 @@ const AIChatGroupInner = ({
163145
sessionClaudeMdStats: td?.sessionClaudeMdStats ?? s.sessionClaudeMdStats,
164146
sessionContextStats: td?.sessionContextStats ?? s.sessionContextStats,
165147
sessionPhaseInfo: td?.sessionPhaseInfo ?? s.sessionPhaseInfo,
166-
conversation: td?.conversation ?? s.conversation,
167148
searchExpandedAIGroupIds: s.searchExpandedAIGroupIds,
168149
searchExpandedSubagentIds: s.searchExpandedSubagentIds,
169150
searchCurrentDisplayItemId: s.searchCurrentDisplayItemId,
@@ -191,30 +172,10 @@ const AIChatGroupInner = ({
191172
const phaseNumber = sessionPhaseInfo?.aiGroupPhaseMap.get(aiGroup.id);
192173
const totalPhases = sessionPhaseInfo?.phases.length ?? 0;
193174

194-
// Find the preceding UserGroup for this AIGroup to extract slash info
195-
// eslint-disable-next-line react-hooks/preserve-manual-memoization -- React Compiler can't preserve this; manual memo needed for O(n) traversal
196-
const precedingSlash = useMemo(() => {
197-
if (!conversation?.items) return undefined;
198-
199-
// Find the index of this AIGroup in the conversation
200-
const aiGroupIndex = conversation.items.findIndex(
201-
(item) => item.type === 'ai' && item.group.id === aiGroup.id
202-
);
203-
204-
if (aiGroupIndex <= 0) return undefined;
205-
206-
// Look backwards for the nearest UserGroup
207-
for (let i = aiGroupIndex - 1; i >= 0; i--) {
208-
const item = conversation.items[i];
209-
if (item.type === 'user') {
210-
return extractPrecedingSlashInfo(item.group);
211-
}
212-
// Stop if we hit another AI group (shouldn't happen in normal flow)
213-
if (item.type === 'ai') break;
214-
}
215-
216-
return undefined;
217-
}, [conversation?.items, aiGroup.id]);
175+
// precedingSlash is pre-computed in ChatHistory and passed as prop.
176+
// Previously this was an O(n) findIndex scan PER visible group PER refresh cycle,
177+
// causing 16 × O(n) work on every 3s refresh for no benefit.
178+
const precedingSlash = precedingSlashProp;
218179

219180
// Enhance the AI group to get display-ready data
220181
const enhanced: EnhancedAIGroup = useMemo(
@@ -271,22 +232,29 @@ const AIChatGroupInner = ({
271232
const isExpanded =
272233
isAIGroupExpandedForTab(aiGroup.id) || containsHighlightedError || shouldExpandForSearch;
273234

274-
// Helper function to find the item ID containing the highlighted tool
235+
// Helper function to find the item ID containing the highlighted tool.
236+
// Subagent lookups use the precomputed displayMeta.toolUseIds set when
237+
// available so we don't need to load the message body just to find an id.
275238
const findHighlightedItemId = useCallback(
276239
(toolUseId: string): string | null => {
277240
for (let i = 0; i < enhanced.displayItems.length; i++) {
278241
const item = enhanced.displayItems[i];
279242
if (item.type === 'tool' && item.tool.id === toolUseId) {
280243
return `tool-${item.tool.id}-${i}`;
281244
}
282-
// For subagents, expand the subagent item
283-
if (item.type === 'subagent' && item.subagent.messages) {
284-
for (const msg of item.subagent.messages) {
285-
if (
286-
msg.toolCalls?.some((tc) => tc.id === toolUseId) ||
287-
msg.toolResults?.some((tr) => tr.toolUseId === toolUseId)
288-
) {
289-
return `subagent-${item.subagent.id}-${i}`;
245+
if (item.type === 'subagent') {
246+
const ids = item.subagent.displayMeta?.toolUseIds;
247+
if (ids?.includes(toolUseId)) {
248+
return `subagent-${item.subagent.id}-${i}`;
249+
}
250+
if (!ids && item.subagent.messages) {
251+
for (const msg of item.subagent.messages) {
252+
if (
253+
msg.toolCalls?.some((tc) => tc.id === toolUseId) ||
254+
msg.toolResults?.some((tr) => tr.toolUseId === toolUseId)
255+
) {
256+
return `subagent-${item.subagent.id}-${i}`;
257+
}
290258
}
291259
}
292260
}

0 commit comments

Comments
 (0)