diff --git a/src/main/types/chunks.ts b/src/main/types/chunks.ts index dd1b94ad..23de6c8b 100644 --- a/src/main/types/chunks.ts +++ b/src/main/types/chunks.ts @@ -12,7 +12,12 @@ * - Constants */ -import { type Session, type SessionMetrics } from './domain'; +import { + type PhaseTokenBreakdown, + type Session, + type SessionMetrics, + type TokenUsage, +} from './domain'; import { type ToolUseResultData } from './jsonl'; import { type ParsedMessage, type ToolCall, type ToolResult } from './messages'; @@ -20,6 +25,45 @@ import { type ParsedMessage, type ToolCall, type ToolResult } from './messages'; // Process Types (Subagent Execution) // ============================================================================= +/** + * Pre-computed display data for a subagent. + * + * Extracted in main process during parsing so the renderer can render the + * collapsed SubagentItem header without holding the full transcript. Keeps + * `Process.messages` empty in the worker output path, reducing per-cached- + * SessionDetail memory by ~MB→KB per subagent. + * + * Full message bodies are loaded lazily via the get-subagent-messages IPC + * when the user expands a subagent or a highlighted-error needs the trace. + */ +export interface SubagentDisplayMeta { + /** Number of assistant messages containing at least one tool_use block. */ + toolCount: number; + /** Model name from the first assistant message that has one (excluding ``). */ + modelName: string | null; + /** Usage block from the LAST assistant message that has one. */ + lastUsage: TokenUsage | null; + /** Count of assistant messages that have a usage block (used for "N turns"). */ + turnCount: number; + /** + * True when this is a team member whose only assistant action is a + * SendMessage(shutdown_response). Used to render the slim shutdown row. + */ + isShutdownOnly: boolean; + /** Multi-phase context breakdown when subagent has compaction events. */ + phaseBreakdown?: { + phases: PhaseTokenBreakdown[]; + totalConsumption: number; + compactionCount: number; + }; + /** + * Every tool_use id and tool_result tool_use_id seen in this subagent's + * messages. Used by AIChatGroup.containsToolUseId and SubagentItem's + * highlighted-error check without iterating messages. + */ + toolUseIds: string[]; +} + /** * Resolved subagent information. */ @@ -28,7 +72,14 @@ export interface Process { id: string; /** Path to the subagent JSONL file */ filePath: string; - /** Parsed messages from the subagent session */ + /** + * Parsed messages from the subagent session. + * + * In the worker output path this is intentionally empty; the renderer + * loads bodies on demand via get-subagent-messages. Direct callers of + * SubagentResolver (drill-down via SubagentDetailBuilder) still get the + * full array. + */ messages: ParsedMessage[]; /** When the subagent started */ startTime: Date; @@ -38,6 +89,12 @@ export interface Process { durationMs: number; /** Aggregated metrics for the subagent */ metrics: SessionMetrics; + /** + * Pre-computed display data for inline rendering without loading messages. + * Optional for backwards compat with code paths that don't compute it, + * but the worker output and SubagentResolver always populate it. + */ + displayMeta?: SubagentDisplayMeta; /** Task description from parent Task call */ description?: string; /** Subagent type from Task call (e.g., "Explore", "Plan") */ @@ -401,6 +458,8 @@ export interface SessionDetail { processes: Process[]; /** Aggregated metrics for the entire session */ metrics: SessionMetrics; + /** Timestamp (ms) when Rust native pipeline was used, or false if JS fallback */ + _nativePipeline?: number | false; } /** @@ -444,6 +503,7 @@ export interface FileChangeEvent { projectId?: string; sessionId?: string; isSubagent: boolean; + fileSize?: number; } // ============================================================================= diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 626477ae..31c9be76 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -187,3 +187,10 @@ export const FIND_SESSION_BY_ID = 'find-session-by-id'; /** Find sessions whose IDs contain a given hex fragment */ export const FIND_SESSIONS_BY_PARTIAL_ID = 'find-sessions-by-partial-id'; + +// ============================================================================= +// Subagent API Channels +// ============================================================================= + +/** Lazy-load a single subagent's parsed messages (renderer expansion path) */ +export const SUBAGENT_GET_MESSAGES = 'subagent:get-messages'; diff --git a/src/preload/index.ts b/src/preload/index.ts index b8850cc1..8d13a5bf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -22,6 +22,7 @@ import { SSH_SAVE_LAST_CONNECTION, SSH_STATUS, SSH_TEST, + SUBAGENT_GET_MESSAGES, UPDATER_CHECK, UPDATER_DOWNLOAD, UPDATER_INSTALL, @@ -96,6 +97,7 @@ interface IpcFileChangePayload { projectId?: string; sessionId?: string; isSubagent: boolean; + fileSize?: number; } /** @@ -152,11 +154,12 @@ const electronAPI: ElectronAPI = { ipcRenderer.invoke('get-waterfall-data', projectId, sessionId), getSubagentDetail: (projectId: string, sessionId: string, subagentId: string) => ipcRenderer.invoke('get-subagent-detail', projectId, sessionId, subagentId), + getSubagentMessages: (projectId: string, sessionId: string, subagentId: string) => + ipcRenderer.invoke(SUBAGENT_GET_MESSAGES, projectId, sessionId, subagentId), getSessionGroups: (projectId: string, sessionId: string) => ipcRenderer.invoke('get-session-groups', projectId, sessionId), getSessionsByIds: (projectId: string, sessionIds: string[], options?: SessionsByIdsOptions) => ipcRenderer.invoke('get-sessions-by-ids', projectId, sessionIds, options), - // Repository grouping (worktree support) getRepositoryGroups: () => ipcRenderer.invoke('get-repository-groups'), getWorktreeSessions: (worktreeId: string) => diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 5a29f29e..02d620f0 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -23,6 +23,7 @@ import type { NotificationsAPI, NotificationTrigger, PaginatedSessionsResult, + ParsedMessage, Project, RepositoryGroup, SearchSessionsResult, @@ -255,6 +256,15 @@ export class HttpAPIClient implements ElectronAPI { `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}` ); + getSubagentMessages = ( + projectId: string, + sessionId: string, + subagentId: string + ): Promise => + this.get( + `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}/messages` + ); + getSessionGroups = (projectId: string, sessionId: string): Promise => this.get( `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/groups` diff --git a/src/renderer/components/chat/AIChatGroup.tsx b/src/renderer/components/chat/AIChatGroup.tsx index 7bf24642..5849067b 100644 --- a/src/renderer/components/chat/AIChatGroup.tsx +++ b/src/renderer/components/chat/AIChatGroup.tsx @@ -4,7 +4,6 @@ import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssV import { useTabUI } from '@renderer/hooks/useTabUI'; import { useStore } from '@renderer/store'; import { enhanceAIGroup, type PrecedingSlashInfo } from '@renderer/utils/aiGroupEnhancer'; -import { extractSlashInfo, isCommandContent } from '@shared/utils/contentSanitizer'; import { getModelColorClass } from '@shared/utils/modelParser'; import { estimateTokens } from '@shared/utils/tokenFormatting'; import { format } from 'date-fns'; @@ -22,39 +21,11 @@ import type { AIGroup, AIGroupDisplayItem, EnhancedAIGroup, - UserGroup, } from '@renderer/types/groups'; import type { TriggerColor } from '@shared/constants/triggerColors'; -/** - * Extract slash info from a UserGroup's message content. - * Returns PrecedingSlashInfo if the user message was a slash invocation, - * null otherwise. - */ -function extractPrecedingSlashInfo( - userGroup: UserGroup | undefined -): PrecedingSlashInfo | undefined { - if (!userGroup) return undefined; - - const msg = userGroup.message; - const content = msg.content; - - // Check if this is a slash message (has tags) - if (typeof content === 'string' && isCommandContent(content)) { - const slashInfo = extractSlashInfo(content); - if (slashInfo) { - return { - name: slashInfo.name, - message: slashInfo.message, - args: slashInfo.args, - commandMessageUuid: msg.uuid, - timestamp: new Date(msg.timestamp), - }; - } - } - - return undefined; -} +// extractPrecedingSlashInfo moved to ChatHistory — pre-computed as a map to +// avoid O(n) scan per visible group per refresh cycle. /** * Format duration in milliseconds to human-readable string. @@ -85,24 +56,31 @@ interface AIChatGroupProps { highlightColor?: TriggerColor; /** Register ref for individual tool items (for precise scroll targeting) */ registerToolRef?: (toolId: string, el: HTMLElement | null) => void; + /** Pre-computed slash info from the preceding user message (avoids O(n) scan per group). */ + precedingSlash?: PrecedingSlashInfo; } /** * Checks if a tool ID exists within the display items (including nested subagents). + * + * For subagents we read the precomputed `displayMeta.toolUseIds` slot rather + * than iterating `subagent.messages`, since the worker output strips the + * messages array. Falls back to message iteration only if displayMeta is + * absent (legacy/uncached path). */ function containsToolUseId(items: AIGroupDisplayItem[], toolUseId: string): boolean { for (const item of items) { if (item.type === 'tool' && item.tool.id === toolUseId) { return true; } - // Check nested subagent messages for the tool ID - if (item.type === 'subagent' && item.subagent.messages) { - for (const msg of item.subagent.messages) { - if (msg.toolCalls?.some((tc) => tc.id === toolUseId)) { - return true; - } - if (msg.toolResults?.some((tr) => tr.toolUseId === toolUseId)) { - return true; + if (item.type === 'subagent') { + const ids = item.subagent.displayMeta?.toolUseIds; + if (ids?.includes(toolUseId)) return true; + // Legacy fallback for code paths that haven't populated displayMeta. + if (!ids && item.subagent.messages) { + for (const msg of item.subagent.messages) { + if (msg.toolCalls?.some((tc) => tc.id === toolUseId)) return true; + if (msg.toolResults?.some((tr) => tr.toolUseId === toolUseId)) return true; } } } @@ -125,6 +103,7 @@ const AIChatGroupInner = ({ highlightToolUseId, highlightColor, registerToolRef, + precedingSlash: precedingSlashProp, }: Readonly): React.JSX.Element => { // Per-tab UI state for expansion (completely isolated per tab) const { @@ -147,12 +126,15 @@ const AIChatGroupInner = ({ return s.sessions.find((sess) => sess.id === id)?.isOngoing ?? false; }); - // Per-tab session data subscriptions, falling back to global state + // Per-tab session data subscriptions, falling back to global state. + // NOTE: `conversation` is intentionally NOT subscribed here — it caused + // all ~16 visible AIChatGroups to fully re-render on every 3s refresh, + // re-running all memos (O(n) precedingSlash scan, enhanceAIGroup, etc). + // precedingSlash is now pre-computed and passed as a prop from ChatHistory. const { sessionClaudeMdStats, sessionContextStats, sessionPhaseInfo, - conversation, searchExpandedAIGroupIds, searchExpandedSubagentIds, searchCurrentDisplayItemId, @@ -163,7 +145,6 @@ const AIChatGroupInner = ({ sessionClaudeMdStats: td?.sessionClaudeMdStats ?? s.sessionClaudeMdStats, sessionContextStats: td?.sessionContextStats ?? s.sessionContextStats, sessionPhaseInfo: td?.sessionPhaseInfo ?? s.sessionPhaseInfo, - conversation: td?.conversation ?? s.conversation, searchExpandedAIGroupIds: s.searchExpandedAIGroupIds, searchExpandedSubagentIds: s.searchExpandedSubagentIds, searchCurrentDisplayItemId: s.searchCurrentDisplayItemId, @@ -191,30 +172,10 @@ const AIChatGroupInner = ({ const phaseNumber = sessionPhaseInfo?.aiGroupPhaseMap.get(aiGroup.id); const totalPhases = sessionPhaseInfo?.phases.length ?? 0; - // Find the preceding UserGroup for this AIGroup to extract slash info - // eslint-disable-next-line react-hooks/preserve-manual-memoization -- React Compiler can't preserve this; manual memo needed for O(n) traversal - const precedingSlash = useMemo(() => { - if (!conversation?.items) return undefined; - - // Find the index of this AIGroup in the conversation - const aiGroupIndex = conversation.items.findIndex( - (item) => item.type === 'ai' && item.group.id === aiGroup.id - ); - - if (aiGroupIndex <= 0) return undefined; - - // Look backwards for the nearest UserGroup - for (let i = aiGroupIndex - 1; i >= 0; i--) { - const item = conversation.items[i]; - if (item.type === 'user') { - return extractPrecedingSlashInfo(item.group); - } - // Stop if we hit another AI group (shouldn't happen in normal flow) - if (item.type === 'ai') break; - } - - return undefined; - }, [conversation?.items, aiGroup.id]); + // precedingSlash is pre-computed in ChatHistory and passed as prop. + // Previously this was an O(n) findIndex scan PER visible group PER refresh cycle, + // causing 16 × O(n) work on every 3s refresh for no benefit. + const precedingSlash = precedingSlashProp; // Enhance the AI group to get display-ready data const enhanced: EnhancedAIGroup = useMemo( @@ -271,7 +232,9 @@ const AIChatGroupInner = ({ const isExpanded = isAIGroupExpandedForTab(aiGroup.id) || containsHighlightedError || shouldExpandForSearch; - // Helper function to find the item ID containing the highlighted tool + // Helper function to find the item ID containing the highlighted tool. + // Subagent lookups use the precomputed displayMeta.toolUseIds set when + // available so we don't need to load the message body just to find an id. const findHighlightedItemId = useCallback( (toolUseId: string): string | null => { for (let i = 0; i < enhanced.displayItems.length; i++) { @@ -279,14 +242,19 @@ const AIChatGroupInner = ({ if (item.type === 'tool' && item.tool.id === toolUseId) { return `tool-${item.tool.id}-${i}`; } - // For subagents, expand the subagent item - if (item.type === 'subagent' && item.subagent.messages) { - for (const msg of item.subagent.messages) { - if ( - msg.toolCalls?.some((tc) => tc.id === toolUseId) || - msg.toolResults?.some((tr) => tr.toolUseId === toolUseId) - ) { - return `subagent-${item.subagent.id}-${i}`; + if (item.type === 'subagent') { + const ids = item.subagent.displayMeta?.toolUseIds; + if (ids?.includes(toolUseId)) { + return `subagent-${item.subagent.id}-${i}`; + } + if (!ids && item.subagent.messages) { + for (const msg of item.subagent.messages) { + if ( + msg.toolCalls?.some((tc) => tc.id === toolUseId) || + msg.toolResults?.some((tr) => tr.toolUseId === toolUseId) + ) { + return `subagent-${item.subagent.id}-${i}`; + } } } } diff --git a/src/renderer/components/chat/items/SubagentItem.tsx b/src/renderer/components/chat/items/SubagentItem.tsx index e1a1efab..b3589216 100644 --- a/src/renderer/components/chat/items/SubagentItem.tsx +++ b/src/renderer/components/chat/items/SubagentItem.tsx @@ -13,10 +13,10 @@ import { COLOR_TEXT_SECONDARY, } from '@renderer/constants/cssVariables'; import { getSubagentTypeColorSet, getTeamColorSet } from '@renderer/constants/teamColors'; +import { useSubagentMessages } from '@renderer/hooks/useSubagentMessages'; import { useTabUI } from '@renderer/hooks/useTabUI'; import { useStore } from '@renderer/store'; import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer'; -import { computeSubagentPhaseBreakdown } from '@renderer/utils/aiGroupHelpers'; import { formatDuration, formatTokensCompact } from '@renderer/utils/formatters'; import { getHighlightProps, type TriggerColor } from '@shared/constants/triggerColors'; import { getModelColorClass, parseModelString } from '@shared/utils/modelParser'; @@ -83,90 +83,95 @@ export const SubagentItem: React.FC = ({ // Type-based colors for non-team subagents (from agent config or deterministic hash) const typeColors = !teamColors ? getSubagentTypeColorSet(subagentType, agentConfigs) : null; - // Detect shutdown-only team activations (trivial: just a shutdown_response) - const isShutdownOnly = useMemo(() => { - if (!subagent.team || !subagent.messages?.length) return false; - const assistantMsgs = subagent.messages.filter((m) => m.type === 'assistant'); - if (assistantMsgs.length !== 1) return false; - const calls = assistantMsgs[0].toolCalls ?? []; - return ( - calls.length === 1 && - calls[0].name === 'SendMessage' && - calls[0].input?.type === 'shutdown_response' - ); - }, [subagent.team, subagent.messages]); - - // Per-tab trace expansion state (replaces local useState for true per-tab isolation) - const { isSubagentTraceExpanded, toggleSubagentTraceExpansion } = useTabUI(); + // Pre-computed display metadata from the worker — replaces all the + // per-render scans of `subagent.messages` we used to do here. Stripping + // `messages: []` in the worker output is what makes the cached + // SessionDetail memory-bounded; this slot is the contract. + const displayMeta = subagent.displayMeta; + + // Per-tab trace expansion state (replaces local useState for true per-tab isolation). + // `tabId` is also used below to source the projectId/sessionId for the + // lazy-load IPC — we MUST read those from the rendered tab's session + // detail (not the global selectedSessionId), otherwise multi-tab and + // split-pane views fetch the wrong session and get back empty arrays. + const { tabId, isSubagentTraceExpanded, toggleSubagentTraceExpansion } = useTabUI(); const isTraceManuallyExpanded = isSubagentTraceExpanded(subagent.id); - // Check if contains highlighted error - // Also matches when the highlight targets the parent Task tool_use that spawned this subagent + // Active project/session ids for the lazy-load IPC. Source from the + // per-tab session detail first, falling back to the global slice when no + // tab context exists (e.g., legacy / unscoped renders). + const projectId = useStore((s) => { + const td = tabId ? s.tabSessionData[tabId] : null; + return (td?.sessionDetail ?? s.sessionDetail)?.session?.projectId ?? null; + }); + const sessionId = useStore((s) => { + const td = tabId ? s.tabSessionData[tabId] : null; + return (td?.sessionDetail ?? s.sessionDetail)?.session?.id ?? null; + }); + + // Detect shutdown-only team activations from pre-computed metadata. + const isShutdownOnly = subagent.team ? (displayMeta?.isShutdownOnly ?? false) : false; + + // Check if this subagent contains the highlighted error. + // Fast path: precomputed `displayMeta.toolUseIds` contains every tool_use + // and tool_result id seen in the transcript, so we don't need to load + // the body just to answer a yes/no question. + const highlightedToolUseIdsSet = useMemo( + () => (displayMeta?.toolUseIds ? new Set(displayMeta.toolUseIds) : null), + [displayMeta?.toolUseIds] + ); const containsHighlightedError = useMemo(() => { if (!highlightToolUseId) return false; - // Match parent Task tool_use ID (trigger matched the Task call itself) if (subagent.parentTaskId === highlightToolUseId) return true; - // Match inner tool calls/results within the subagent - if (!subagent.messages) return false; - for (const msg of subagent.messages) { - if (msg.toolCalls?.some((tc) => tc.id === highlightToolUseId)) return true; - if (msg.toolResults?.some((tr) => tr.toolUseId === highlightToolUseId)) return true; - } + if (highlightedToolUseIdsSet?.has(highlightToolUseId)) return true; return false; - }, [highlightToolUseId, subagent.parentTaskId, subagent.messages]); + }, [highlightToolUseId, subagent.parentTaskId, highlightedToolUseIdsSet]); - // Build display items + // Search expansion (read here so we can include it in the lazy-load gate) + const searchExpandedSubagentIds = useStore((s) => s.searchExpandedSubagentIds); + const searchCurrentSubagentItemId = useStore((s) => s.searchCurrentSubagentItemId); + const shouldExpandForSearch = searchExpandedSubagentIds.has(subagent.id); + + // Lazy-load full message body only when actually needed for rendering: + // expanded view, highlighted error trace, or search auto-expand. + const needsBody = isExpanded || containsHighlightedError || shouldExpandForSearch; + const { + messages: lazyMessages, + isLoading: messagesLoading, + error: messagesError, + } = useSubagentMessages(needsBody, projectId, sessionId, subagent.id); + + // Build display items from lazily-loaded messages (only when needed). const displayItems = useMemo(() => { - if ((!isExpanded && !containsHighlightedError) || !subagent.messages?.length) { - return []; - } - return buildDisplayItemsFromMessages(subagent.messages, []); - }, [isExpanded, containsHighlightedError, subagent.messages]); + if (!needsBody || !lazyMessages?.length) return []; + return buildDisplayItemsFromMessages(lazyMessages, []); + }, [needsBody, lazyMessages]); - // Build summary + // Build summary: tool count comes from displayMeta until we have full + // messages, then switches to the rich summary built from display items. const itemsSummary = useMemo(() => { - if (!isExpanded && !containsHighlightedError) { - const toolCount = - subagent.messages?.filter( - (m) => - m.type === 'assistant' && - Array.isArray(m.content) && - m.content.some((b) => b.type === 'tool_use') - ).length ?? 0; + if (!needsBody) { + const toolCount = displayMeta?.toolCount ?? 0; + return toolCount > 0 ? `${toolCount} tools` : ''; + } + if (lazyMessages === null) { + // Body requested but still loading — fall back to the meta count. + const toolCount = displayMeta?.toolCount ?? 0; return toolCount > 0 ? `${toolCount} tools` : ''; } return buildSummary(displayItems); - }, [isExpanded, containsHighlightedError, displayItems, subagent.messages]); + }, [needsBody, displayItems, displayMeta?.toolCount, lazyMessages]); - // Model info - const modelInfo = useMemo(() => { - const msg = subagent.messages?.find( - (m) => m.type === 'assistant' && m.model && m.model !== '' - ); - return msg?.model ? parseModelString(msg.model) : null; - }, [subagent.messages]); - - // Last usage - const lastUsage = useMemo(() => { - const messages = subagent.messages ?? []; - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].type === 'assistant' && messages[i].usage) { - return messages[i].usage; - } - } - return null; - }, [subagent.messages]); + // Model info — pre-extracted in the worker so we can render the badge + // without ever loading the message body. + const modelInfo = displayMeta?.modelName ? parseModelString(displayMeta.modelName) : null; - // Multi-phase context breakdown (for subagents with compaction) - const phaseData = useMemo(() => { - if (!subagent.messages?.length) return null; - return computeSubagentPhaseBreakdown(subagent.messages); - }, [subagent.messages]); + // Last usage from displayMeta (used by the metrics pill in the header). + const lastUsage = displayMeta?.lastUsage ?? null; - // Search expansion - const searchExpandedSubagentIds = useStore((s) => s.searchExpandedSubagentIds); - const searchCurrentSubagentItemId = useStore((s) => s.searchCurrentSubagentItemId); - const shouldExpandForSearch = searchExpandedSubagentIds.has(subagent.id); + // Multi-phase context breakdown — also pre-computed, so the phase pills + // render in the collapsed view without any message access. + const phaseData = displayMeta?.phaseBreakdown ?? null; // Combine manual expansion with auto-expansion for errors/search const isTraceExpanded = @@ -191,16 +196,14 @@ export const SubagentItem: React.FC = ({ [subagent.parentTaskId, registerToolRef] ); - // Cumulative metrics for team members — show total output generated + // Cumulative metrics for team members — turn count comes from displayMeta. const cumulativeMetrics = useMemo(() => { if (!subagent.team || !subagent.metrics) return undefined; - const turnCount = - subagent.messages?.filter((m) => m.type === 'assistant' && m.usage).length ?? 0; return { outputTokens: subagent.metrics.outputTokens, - turnCount, + turnCount: displayMeta?.turnCount ?? 0, }; - }, [subagent.team, subagent.metrics, subagent.messages]); + }, [subagent.team, subagent.metrics, displayMeta?.turnCount]); // Computed values for metrics const hasMainImpact = subagent.mainSessionImpact && subagent.mainSessionImpact.totalTokens > 0; @@ -506,8 +509,12 @@ export const SubagentItem: React.FC = ({ )} - {/* ========== Level 2: Execution Trace Toggle ========== */} - {displayItems.length > 0 && ( + {/* ========== Level 2: Execution Trace Toggle ========== + Render the trace toggle whenever the subagent could have items + (toolCount > 0 or any items already loaded). While the body is + fetching we show a skeleton instead of the items, then swap in + once `lazyMessages` resolves. */} + {(displayItems.length > 0 || (displayMeta?.toolCount ?? 0) > 0) && (
= ({ · {itemsSummary} + {needsBody && messagesLoading && lazyMessages === null && ( + + )}
{/* Trace Content */} {isTraceExpanded && (
- + {messagesError && lazyMessages === null ? ( +
+ Failed to load subagent messages: {messagesError} +
+ ) : displayItems.length === 0 && messagesLoading ? ( +
+ Loading subagent messages… +
+ ) : ( + + )}
)} diff --git a/src/renderer/hooks/useSubagentMessages.ts b/src/renderer/hooks/useSubagentMessages.ts new file mode 100644 index 00000000..b47e23c8 --- /dev/null +++ b/src/renderer/hooks/useSubagentMessages.ts @@ -0,0 +1,54 @@ +/** + * useSubagentMessages — lazy-loads a subagent's full message body. + * + * Used by SubagentItem on inline expansion. The worker output now ships + * subagents with `messages: []` to bound memory; this hook pulls the body + * over IPC the first time it's needed and caches it in the renderer-side + * `subagentMessageCacheSlice`. + * + * Returns a small status object the component can use to render: + * skeleton during load, error message on failure, or the parsed messages. + * + * Usage: + * const { messages, isLoading, error } = useSubagentMessages( + * enabled, projectId, sessionId, subagentId + * ); + * + * Pass `enabled=false` to defer the fetch until the user actually expands + * the subagent — avoids spamming IPC for collapsed cards. + */ + +import { useEffect } from 'react'; + +import { useStore } from '@renderer/store'; + +import type { ParsedMessage } from '@renderer/types/data'; + +export interface UseSubagentMessagesResult { + messages: ParsedMessage[] | null; + isLoading: boolean; + error: string | null; +} + +export function useSubagentMessages( + enabled: boolean, + projectId: string | null, + sessionId: string | null, + subagentId: string +): UseSubagentMessagesResult { + // Subscribe narrowly so unrelated cache mutations don't re-render us. + const messages = useStore((s) => s.subagentMessageCache.get(subagentId)?.messages ?? null); + const isLoading = useStore((s) => s.loadingSubagentIds.has(subagentId)); + const error = useStore((s) => s.subagentMessageErrors.get(subagentId) ?? null); + const loadSubagentMessages = useStore((s) => s.loadSubagentMessages); + + useEffect(() => { + if (!enabled) return; + if (!projectId || !sessionId) return; + if (messages !== null) return; // already cached + if (isLoading) return; // already in flight + void loadSubagentMessages(projectId, sessionId, subagentId); + }, [enabled, projectId, sessionId, subagentId, messages, isLoading, loadSubagentMessages]); + + return { messages, isLoading, error }; +} diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 98259980..5d7343e9 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -15,6 +15,7 @@ import { createProjectSlice } from './slices/projectSlice'; import { createRepositorySlice } from './slices/repositorySlice'; import { createSessionDetailSlice } from './slices/sessionDetailSlice'; import { createSessionSlice } from './slices/sessionSlice'; +import { createSubagentMessageCacheSlice } from './slices/subagentMessageCacheSlice'; import { createSubagentSlice } from './slices/subagentSlice'; import { createTabSlice } from './slices/tabSlice'; import { createTabUISlice } from './slices/tabUISlice'; @@ -35,6 +36,7 @@ export const useStore = create()((...args) => ({ ...createSessionSlice(...args), ...createSessionDetailSlice(...args), ...createSubagentSlice(...args), + ...createSubagentMessageCacheSlice(...args), ...createConversationSlice(...args), ...createTabSlice(...args), ...createTabUISlice(...args), @@ -63,47 +65,115 @@ export function initializeNotificationListeners(): () => void { const cleanupFns: (() => void)[] = []; const pendingSessionRefreshTimers = new Map>(); const pendingProjectRefreshTimers = new Map>(); + const lastKnownSizes = new Map(); + /** + * Tracks when each session's refresh last *executed* (not scheduled). + * Used to enforce a minimum cooldown between actual refresh cycles. + * During streaming, the adaptive debounce (100ms for small deltas) would + * otherwise fire 10 refreshes/second, each creating a full IPC roundtrip + + * JSONL parse + conversation transformation → 3-6 GB/min allocations that + * outpace GC → unbounded renderer memory growth → crash at 3.4 GB. + * + * A 3 s cooldown floor reduces this to ~0.3/sec (20× fewer allocations) + * while keeping the UI visually responsive for humans. + */ + const lastRefreshTimestamps = new Map(); + const REFRESH_COOLDOWN_MS = 3000; + /** When renderer heap exceeds this, double the cooldown to ease GC pressure. */ + const MEMORY_PRESSURE_THRESHOLD_MB = 1500; + /** When renderer heap exceeds this, skip refreshes entirely (only manual Ctrl+R). */ + const MEMORY_CRITICAL_THRESHOLD_MB = 2500; const SESSION_REFRESH_DEBOUNCE_MS = 150; const PROJECT_REFRESH_DEBOUNCE_MS = 300; + + /** Check renderer memory pressure via Chrome-specific performance.memory API. */ + function getRendererHeapMB(): number { + const mem = (performance as unknown as { memory?: { usedJSHeapSize?: number } }).memory; + return mem?.usedJSHeapSize ? Math.round(mem.usedJSHeapSize / (1024 * 1024)) : 0; + } const getBaseProjectId = (projectId: string | null | undefined): string | null => { if (!projectId) return null; const separatorIndex = projectId.indexOf('::'); return separatorIndex >= 0 ? projectId.slice(0, separatorIndex) : projectId; }; - const scheduleSessionRefresh = (projectId: string, sessionId: string): void => { + const scheduleSessionRefresh = ( + projectId: string, + sessionId: string, + fileSize?: number + ): void => { const key = `${projectId}/${sessionId}`; // Throttle (not trailing debounce): keep at most one pending refresh per session. - // Debounce can delay updates indefinitely while the file is continuously appended. if (pendingSessionRefreshTimers.has(key)) { return; } - // Adaptive debounce: large sessions refresh less frequently to reduce memory churn. - // Uses the TARGET session's cached totalAIGroups so a long session in another pane - // doesn't force the active short session to the default interval. - const state = useStore.getState(); - const tabData = Object.values(state.tabSessionData).find( - (td) => td?.sessionDetail?.session?.id === sessionId - ); - const aiGroupCount = - tabData?.conversation?.totalAIGroups ?? - (state.conversation?.items ?? []).filter((i) => i.type === 'ai').length; - const debounceMs = - aiGroupCount > 1000 - ? 60000 // ~60s for very long sessions (24h+) - : aiGroupCount > 500 - ? 30000 // ~30s for long sessions - : aiGroupCount > 200 - ? 10000 // ~10s for medium sessions - : aiGroupCount > 100 - ? 3000 // ~3s for moderate sessions - : SESSION_REFRESH_DEBOUNCE_MS; // 150ms default + // Memory pressure gate: skip auto-refresh entirely when renderer heap is + // critically high. The user can still force-refresh with Ctrl+R. + const heapMB = getRendererHeapMB(); + if (heapMB > MEMORY_CRITICAL_THRESHOLD_MB) { + return; // Refuse to allocate more — GC needs breathing room + } + + // Delta-based adaptive debounce: use file size change to estimate refresh urgency. + // Small changes (1-2 messages) refresh near-instantly; large changes (bulk writes) + // debounce longer to avoid GC pressure from re-transforming huge conversations. + let debounceMs = SESSION_REFRESH_DEBOUNCE_MS; // 150ms default + if (fileSize != null) { + const isFirstEvent = !lastKnownSizes.has(key); + const lastSize = lastKnownSizes.get(key) ?? 0; + const delta = fileSize - lastSize; + lastKnownSizes.set(key, fileSize); + + // First event for a session: seed baseline and use default debounce + // instead of treating the full file size as a huge delta. + if (!isFirstEvent) { + if (delta < 0) { + // File shrunk (compaction, truncation, atomic rewrite) — refresh immediately + // and reset baseline so subsequent growth deltas are measured correctly. + debounceMs = 100; + lastKnownSizes.set(key, fileSize); + } else if (delta === 0) { + debounceMs = 0; // No change — skip + } else { + debounceMs = + delta < 5000 + ? 100 // Small (~1-2 messages): near-instant + : delta < 50000 + ? 500 // Medium: 500ms + : delta < 200000 + ? 2000 // Large: 2s + : 5000; // Very large: 5s + } + } + } + + if (debounceMs <= 0) return; // Skip if no change + + // Enforce minimum cooldown between actual refresh executions. + // If the last refresh for this session fired recently, push the timer + // forward so it fires at the cooldown boundary instead of immediately. + // Under memory pressure (>1.5 GB heap), double the cooldown to ease GC. + const cooldown = heapMB > MEMORY_PRESSURE_THRESHOLD_MB + ? REFRESH_COOLDOWN_MS * 2 + : REFRESH_COOLDOWN_MS; + const lastFired = lastRefreshTimestamps.get(key) ?? 0; + const elapsed = Date.now() - lastFired; + if (elapsed < cooldown) { + debounceMs = Math.max(debounceMs, cooldown - elapsed); + } const timer = setTimeout(() => { pendingSessionRefreshTimers.delete(key); - const latestState = useStore.getState(); - void latestState.refreshSessionInPlace(projectId, sessionId); + lastRefreshTimestamps.set(key, Date.now()); + // Prune timestamps map to prevent unbounded growth + if (lastRefreshTimestamps.size > 200) { + const entries = [...lastRefreshTimestamps.entries()]; + lastRefreshTimestamps.clear(); + for (const [k, v] of entries.slice(-100)) lastRefreshTimestamps.set(k, v); + } + const state = useStore.getState(); + void state.refreshSessionInPlace(projectId, sessionId); }, debounceMs); pendingSessionRefreshTimers.set(key, timer); }; @@ -259,6 +329,19 @@ export function initializeNotificationListeners(): () => void { } } + // Refresh the project list when a file change arrives from a project + // not currently in our projects array — this handles brand-new projects. + if (event.projectId && isTopLevelSessionEvent) { + const knownProjectIds = new Set(state.projects.map((p) => p.id)); + const eventBaseId = getBaseProjectId(event.projectId); + const isNewProject = + eventBaseId != null && !knownProjectIds.has(event.projectId) && + ![...knownProjectIds].some((id) => getBaseProjectId(id) === eventBaseId); + if (isNewProject) { + void state.fetchProjects(); + } + } + // Keep opened session view in sync on content changes. // Some local writers emit rename/add for in-place updates, so include "add". if ((event.type === 'change' || event.type === 'add') && selectedProjectId) { @@ -274,14 +357,17 @@ export function initializeNotificationListeners(): () => void { (shouldFallbackRefreshActiveSession ? activeSessionId : null); if (sessionIdToRefresh) { - const allTabs = state.getAllPaneTabs(); - const visibleSessionTab = allTabs.find( - (tab) => tab.type === 'session' && tab.sessionId === sessionIdToRefresh - ); - const refreshProjectId = visibleSessionTab?.projectId ?? selectedProjectId; - - // Use refreshSessionInPlace to avoid flickering and preserve UI state - scheduleSessionRefresh(refreshProjectId, sessionIdToRefresh); + // Use event.projectId as authoritative source — it identifies the project + // that actually changed, not the one currently selected in the UI. + // Fixes: new sessions in non-selected projects never auto-loading. + const refreshProjectId = + event.projectId ?? + state.getAllPaneTabs().find( + (tab) => tab.type === 'session' && tab.sessionId === sessionIdToRefresh + )?.projectId ?? + selectedProjectId; + + scheduleSessionRefresh(refreshProjectId, sessionIdToRefresh, event.fileSize); } } }); @@ -390,8 +476,23 @@ export function initializeNotificationListeners(): () => void { } } + // Periodically prune lastKnownSizes to prevent unbounded growth. + // Entries older than 30 minutes are unlikely to be needed for delta estimation. + const PRUNE_INTERVAL_MS = 10 * 60_000; // every 10 minutes + const MAX_LAST_KNOWN_SIZE_ENTRIES = 500; + const pruneInterval = setInterval(() => { + if (lastKnownSizes.size > MAX_LAST_KNOWN_SIZE_ENTRIES) { + // Keep only the most recent half — Map iteration order is insertion order + const entries = [...lastKnownSizes.entries()]; + const keep = entries.slice(entries.length - Math.floor(MAX_LAST_KNOWN_SIZE_ENTRIES / 2)); + lastKnownSizes.clear(); + for (const [k, v] of keep) lastKnownSizes.set(k, v); + } + }, PRUNE_INTERVAL_MS); + // Return cleanup function return () => { + clearInterval(pruneInterval); for (const timer of pendingSessionRefreshTimers.values()) { clearTimeout(timer); } @@ -400,6 +501,7 @@ export function initializeNotificationListeners(): () => void { clearTimeout(timer); } pendingProjectRefreshTimers.clear(); + lastKnownSizes.clear(); cleanupFns.forEach((fn) => fn()); }; } diff --git a/src/renderer/store/slices/sessionDetailSlice.ts b/src/renderer/store/slices/sessionDetailSlice.ts index 86d986a0..2810bc26 100644 --- a/src/renderer/store/slices/sessionDetailSlice.ts +++ b/src/renderer/store/slices/sessionDetailSlice.ts @@ -251,8 +251,14 @@ export const createSessionDetailSlice: StateCreator 100) { + const entries = Array.from(sessionRefreshGeneration.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 50); + sessionRefreshGeneration.clear(); + for (const [key, value] of entries) { + sessionRefreshGeneration.set(key, value); + } + } + try { const detail = await api.getSessionDetail(projectId, sessionId); diff --git a/src/renderer/store/slices/sessionSlice.ts b/src/renderer/store/slices/sessionSlice.ts index 31f9596b..d93870fc 100644 --- a/src/renderer/store/slices/sessionSlice.ts +++ b/src/renderer/store/slices/sessionSlice.ts @@ -266,6 +266,17 @@ export const createSessionSlice: StateCreator = const generation = (projectRefreshGeneration.get(projectId) ?? 0) + 1; projectRefreshGeneration.set(projectId, generation); + // Prune to prevent unbounded growth: when exceeding 100 entries, keep only the 50 most recent + if (projectRefreshGeneration.size > 100) { + const entries = Array.from(projectRefreshGeneration.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 50); + projectRefreshGeneration.clear(); + for (const [key, value] of entries) { + projectRefreshGeneration.set(key, value); + } + } + try { const result = await api.getSessionsPaginated(projectId, null, 20, { includeTotalCount: false, diff --git a/src/renderer/store/slices/subagentMessageCacheSlice.ts b/src/renderer/store/slices/subagentMessageCacheSlice.ts new file mode 100644 index 00000000..46d54f82 --- /dev/null +++ b/src/renderer/store/slices/subagentMessageCacheSlice.ts @@ -0,0 +1,176 @@ +/** + * Subagent message cache slice. + * + * Holds lazily-loaded subagent message bodies in the renderer so that + * `SubagentItem` doesn't have to call `getSubagentMessages` again every + * time it remounts. Sized small (10 entries) because each entry holds a + * full subagent transcript. + * + * Single-flight: concurrent `loadSubagentMessages(id)` calls coalesce — + * the first call kicks off the IPC, subsequent calls await the same + * Promise and return the same array. + * + * This is a per-renderer cache layered on top of the main-process + * `SubagentMessageCache` (which survives across renderer reloads). + */ + +import { api } from '@renderer/api'; + +import type { AppState } from '../types'; +import type { ParsedMessage } from '@renderer/types/data'; +import type { StateCreator } from 'zustand'; + +const MAX_CACHE_ENTRIES = 10; + +interface CacheEntry { + messages: ParsedMessage[]; + /** Insertion order for LRU. Higher = more recent. */ + touchedAt: number; +} + +export interface SubagentMessageCacheSlice { + /** Map id (subagentId) → loaded messages. */ + subagentMessageCache: Map; + /** Subagent ids whose IPC fetch is in flight. */ + loadingSubagentIds: Set; + /** Per-id error from the most recent fetch attempt. */ + subagentMessageErrors: Map; + + /** + * Get cached messages without triggering a fetch. Returns null on miss. + * Used by hooks that need to read the current cache state. + */ + getCachedSubagentMessages: (subagentId: string) => ParsedMessage[] | null; + + /** + * Lazy-load messages for a subagent. Coalesces concurrent calls into one + * IPC. Returns the same array for repeated calls (single-flight). + * Side-effect: updates `subagentMessageCache`, `loadingSubagentIds`, + * `subagentMessageErrors` so subscribers re-render at the right moments. + */ + loadSubagentMessages: ( + projectId: string, + sessionId: string, + subagentId: string + ) => Promise; + + /** Drop every cached entry whose subagent belongs to the given session. */ + invalidateSubagentMessagesForSession: (sessionId: string) => void; + + /** Clear the entire cache (e.g., on context switch). */ + clearSubagentMessageCache: () => void; +} + +// Module-level Promise dedupe map. Lives outside Zustand state because +// Promises don't serialize cleanly and we don't want subscribers to +// re-render every time a Promise resolves elsewhere. +const inflightPromises = new Map>(); + +let touchCounter = 0; + +export const createSubagentMessageCacheSlice: StateCreator< + AppState, + [], + [], + SubagentMessageCacheSlice +> = (set, get) => ({ + subagentMessageCache: new Map(), + loadingSubagentIds: new Set(), + subagentMessageErrors: new Map(), + + getCachedSubagentMessages: (subagentId) => { + const entry = get().subagentMessageCache.get(subagentId); + return entry ? entry.messages : null; + }, + + loadSubagentMessages: async (projectId, sessionId, subagentId) => { + // Cache hit → return immediately, no IPC, no state change. + const cached = get().subagentMessageCache.get(subagentId); + if (cached) { + return cached.messages; + } + + // Single-flight: another caller already fetching this subagent. + const existing = inflightPromises.get(subagentId); + if (existing) { + return existing; + } + + // Start fresh fetch. + const promise = (async (): Promise => { + // Mark loading and clear any previous error for this id. + set((state) => { + const nextLoading = new Set(state.loadingSubagentIds); + nextLoading.add(subagentId); + const nextErrors = new Map(state.subagentMessageErrors); + nextErrors.delete(subagentId); + return { loadingSubagentIds: nextLoading, subagentMessageErrors: nextErrors }; + }); + + try { + const messages = await api.getSubagentMessages(projectId, sessionId, subagentId); + + // Insert into cache with LRU eviction. + set((state) => { + const nextCache = new Map(state.subagentMessageCache); + if (nextCache.size >= MAX_CACHE_ENTRIES && !nextCache.has(subagentId)) { + // Evict the entry with the smallest touchedAt (oldest). + let oldestKey: string | null = null; + let oldestTouched = Infinity; + for (const [k, v] of nextCache) { + if (v.touchedAt < oldestTouched) { + oldestTouched = v.touchedAt; + oldestKey = k; + } + } + if (oldestKey !== null) nextCache.delete(oldestKey); + } + nextCache.set(subagentId, { messages, touchedAt: ++touchCounter }); + + const nextLoading = new Set(state.loadingSubagentIds); + nextLoading.delete(subagentId); + + return { subagentMessageCache: nextCache, loadingSubagentIds: nextLoading }; + }); + + return messages; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + set((state) => { + const nextLoading = new Set(state.loadingSubagentIds); + nextLoading.delete(subagentId); + const nextErrors = new Map(state.subagentMessageErrors); + nextErrors.set(subagentId, message); + return { loadingSubagentIds: nextLoading, subagentMessageErrors: nextErrors }; + }); + // Return empty so callers that can't propagate errors still get + // an array; the error is exposed via subagentMessageErrors. + return []; + } finally { + inflightPromises.delete(subagentId); + } + })(); + + inflightPromises.set(subagentId, promise); + return promise; + }, + + invalidateSubagentMessagesForSession: (_sessionId) => { + // We only key by subagentId in the cache (no session in the key), so + // a coarse clear is correct: when any session refreshes we drop all + // cached subagent bodies. The cache is small so this is cheap. + set({ + subagentMessageCache: new Map(), + subagentMessageErrors: new Map(), + }); + }, + + clearSubagentMessageCache: () => { + inflightPromises.clear(); + set({ + subagentMessageCache: new Map(), + loadingSubagentIds: new Set(), + subagentMessageErrors: new Map(), + }); + }, +}); diff --git a/src/renderer/store/slices/tabSlice.ts b/src/renderer/store/slices/tabSlice.ts index bf7971b8..28a78daa 100644 --- a/src/renderer/store/slices/tabSlice.ts +++ b/src/renderer/store/slices/tabSlice.ts @@ -22,7 +22,10 @@ import { syncFocusedPaneState, updatePane, } from '../utils/paneHelpers'; -import { getFullResetState } from '../utils/stateResetHelpers'; +import { + getConversationExpansionResetState, + getFullResetState, +} from '../utils/stateResetHelpers'; import type { AppState, SearchNavigationContext } from '../types'; import type { PaneLayout } from '@renderer/types/panes'; @@ -302,6 +305,7 @@ export const createTabSlice: StateCreator = (set, ge if (hasCachedData) { // Swap global state from per-tab cache (no re-fetch) set({ + ...getConversationExpansionResetState(), sessionDetail: cachedTabData.sessionDetail, conversation: cachedTabData.conversation, conversationLoading: false, @@ -338,6 +342,7 @@ export const createTabSlice: StateCreator = (set, ge if (hasCachedData) { // Swap global state from per-tab cache (no re-fetch) set({ + ...getConversationExpansionResetState(), sessionDetail: cachedTabData.sessionDetail, conversation: cachedTabData.conversation, conversationLoading: false, diff --git a/src/renderer/store/types.ts b/src/renderer/store/types.ts index 8ef28754..0c35dfc4 100644 --- a/src/renderer/store/types.ts +++ b/src/renderer/store/types.ts @@ -13,6 +13,7 @@ import type { ProjectSlice } from './slices/projectSlice'; import type { RepositorySlice } from './slices/repositorySlice'; import type { SessionDetailSlice } from './slices/sessionDetailSlice'; import type { SessionSlice } from './slices/sessionSlice'; +import type { SubagentMessageCacheSlice } from './slices/subagentMessageCacheSlice'; import type { SubagentSlice } from './slices/subagentSlice'; import type { TabSlice } from './slices/tabSlice'; import type { TabUISlice } from './slices/tabUISlice'; @@ -81,6 +82,7 @@ export type AppState = ProjectSlice & SessionSlice & SessionDetailSlice & SubagentSlice & + SubagentMessageCacheSlice & ConversationSlice & TabSlice & TabUISlice & diff --git a/src/renderer/store/utils/stateResetHelpers.ts b/src/renderer/store/utils/stateResetHelpers.ts index 7fdc4a0b..7d3978fe 100644 --- a/src/renderer/store/utils/stateResetHelpers.ts +++ b/src/renderer/store/utils/stateResetHelpers.ts @@ -24,6 +24,20 @@ export function getSessionResetState(): Partial { }; } +/** + * Reset expansion Maps/Sets that accumulate entries per session. + * Used when switching sessions to prevent unbounded growth over long uptime. + */ +export function getConversationExpansionResetState(): Partial { + return { + aiGroupExpansionLevels: new Map(), + expandedStepIds: new Set(), + expandedDisplayItemIds: new Map(), + expandedAIGroupIds: new Set(), + activeDetailItem: null, + }; +} + /** * Full state reset (session + project + repository + conversation). * Used when closing all tabs or resetting to initial state. @@ -31,6 +45,7 @@ export function getSessionResetState(): Partial { export function getFullResetState(): Partial { return { ...getSessionResetState(), + ...getConversationExpansionResetState(), selectedRepositoryId: null, selectedWorktreeId: null, selectedProjectId: null, diff --git a/src/renderer/utils/modelExtractor.ts b/src/renderer/utils/modelExtractor.ts index 355b5f61..a5d4e113 100644 --- a/src/renderer/utils/modelExtractor.ts +++ b/src/renderer/utils/modelExtractor.ts @@ -59,8 +59,11 @@ export function extractMainModel(steps: SemanticStep[]): ModelInfo | null { * * Strategy: * 1. Iterate through all processes (subagents) - * 2. Find the first assistant message with a valid model in each process - * 3. Parse and collect unique models that differ from mainModel + * 2. Read the model from `displayMeta.modelName` (precomputed in main process). + * This works even when `process.messages` has been stripped by the worker + * output for memory-bound caching. + * 3. Fall back to scanning messages only if displayMeta is absent. + * 4. Parse and collect unique models that differ from mainModel. * * @param processes - Subagent processes from the AI Group * @param mainModel - The main agent's model (to filter out) @@ -73,13 +76,18 @@ export function extractSubagentModels( const uniqueModels = new Map(); for (const process of processes) { - // Find first assistant message with a valid model - const assistantMsg = process.messages?.find( - (m) => m.type === 'assistant' && m.model && m.model !== '' - ); + let modelString: string | undefined = process.displayMeta?.modelName ?? undefined; - if (assistantMsg?.model) { - const modelInfo = parseModelString(assistantMsg.model); + // Legacy fallback for code paths that haven't populated displayMeta. + if (!modelString) { + const assistantMsg = process.messages?.find( + (m) => m.type === 'assistant' && m.model && m.model !== '' + ); + modelString = assistantMsg?.model; + } + + if (modelString) { + const modelInfo = parseModelString(modelString); if (modelInfo && modelInfo.name !== mainModel?.name) { uniqueModels.set(modelInfo.name, modelInfo); } diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index ca2c648c..0d956c4d 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -20,6 +20,7 @@ import type { FindSessionByIdResult, FindSessionsByPartialIdResult, PaginatedSessionsResult, + ParsedMessage, Project, RepositoryGroup, SearchSessionsResult, @@ -349,13 +350,22 @@ export interface ElectronAPI { sessionId: string, subagentId: string ) => Promise; + /** + * Lazy-load a subagent's full parsed message body. Worker output now strips + * `Process.messages` to bound memory; renderer calls this when a subagent + * is expanded inline. Backed by an LRU cache in the main process. + */ + getSubagentMessages: ( + projectId: string, + sessionId: string, + subagentId: string + ) => Promise; getSessionGroups: (projectId: string, sessionId: string) => Promise; getSessionsByIds: ( projectId: string, sessionIds: string[], options?: SessionsByIdsOptions ) => Promise; - // Repository grouping (worktree support) getRepositoryGroups: () => Promise; getWorktreeSessions: (worktreeId: string) => Promise; diff --git a/test/mocks/electronAPI.ts b/test/mocks/electronAPI.ts index fe6fd666..af9c087e 100644 --- a/test/mocks/electronAPI.ts +++ b/test/mocks/electronAPI.ts @@ -31,6 +31,7 @@ export interface MockElectronAPI { getRepositoryGroups: ReturnType; getWorktreeSessions: ReturnType; getSubagentDetail: ReturnType; + getSubagentMessages: ReturnType; searchSessions: ReturnType; readClaudeMdFiles: ReturnType; readDirectoryClaudeMd: ReturnType; @@ -94,6 +95,7 @@ export function createMockElectronAPI(): MockElectronAPI { getRepositoryGroups: vi.fn().mockResolvedValue([]), getWorktreeSessions: vi.fn().mockResolvedValue([]), getSubagentDetail: vi.fn().mockResolvedValue(null), + getSubagentMessages: vi.fn().mockResolvedValue([]), searchSessions: vi.fn().mockResolvedValue({ results: [], totalMatches: 0, diff --git a/test/renderer/store/storeTestUtils.ts b/test/renderer/store/storeTestUtils.ts index 747305be..f8a1dff7 100644 --- a/test/renderer/store/storeTestUtils.ts +++ b/test/renderer/store/storeTestUtils.ts @@ -12,6 +12,7 @@ import { createProjectSlice } from '../../../src/renderer/store/slices/projectSl import { createRepositorySlice } from '../../../src/renderer/store/slices/repositorySlice'; import { createSessionDetailSlice } from '../../../src/renderer/store/slices/sessionDetailSlice'; import { createSessionSlice } from '../../../src/renderer/store/slices/sessionSlice'; +import { createSubagentMessageCacheSlice } from '../../../src/renderer/store/slices/subagentMessageCacheSlice'; import { createSubagentSlice } from '../../../src/renderer/store/slices/subagentSlice'; import { createTabSlice } from '../../../src/renderer/store/slices/tabSlice'; import { createTabUISlice } from '../../../src/renderer/store/slices/tabUISlice'; @@ -30,6 +31,7 @@ export function createTestStore() { ...createSessionSlice(...args), ...createSessionDetailSlice(...args), ...createSubagentSlice(...args), + ...createSubagentMessageCacheSlice(...args), ...createConversationSlice(...args), ...createTabSlice(...args), ...createTabUISlice(...args), diff --git a/test/renderer/store/subagentMessageCacheSlice.test.ts b/test/renderer/store/subagentMessageCacheSlice.test.ts new file mode 100644 index 00000000..99f3a869 --- /dev/null +++ b/test/renderer/store/subagentMessageCacheSlice.test.ts @@ -0,0 +1,132 @@ +/** + * Tests for subagentMessageCacheSlice. + * + * Covers: + * - First load fetches via IPC and caches the result + * - Second call hits the cache (no extra IPC) + * - Concurrent loads of the same id share one in-flight Promise + * - LRU eviction at 10 entries + * - Errors are surfaced via subagentMessageErrors + * - clearSubagentMessageCache wipes everything + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { installMockElectronAPI, type MockElectronAPI } from '../../mocks/electronAPI'; + +import { createTestStore, type TestStore } from './storeTestUtils'; + +import type { ParsedMessage } from '../../../src/renderer/types/data'; + +function fakeMessages(label: string): ParsedMessage[] { + return [ + { + uuid: `uuid-${label}`, + parentUuid: null, + type: 'assistant', + timestamp: new Date(), + content: '', + isSidechain: true, + isMeta: false, + toolCalls: [], + toolResults: [], + }, + ]; +} + +describe('subagentMessageCacheSlice', () => { + let store: TestStore; + let mockAPI: MockElectronAPI; + + beforeEach(() => { + mockAPI = installMockElectronAPI(); + store = createTestStore(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('starts with empty cache state', () => { + const state = store.getState(); + expect(state.subagentMessageCache.size).toBe(0); + expect(state.loadingSubagentIds.size).toBe(0); + expect(state.subagentMessageErrors.size).toBe(0); + }); + + it('fetches on first call and caches the result', async () => { + mockAPI.getSubagentMessages.mockResolvedValue(fakeMessages('a1')); + const messages = await store.getState().loadSubagentMessages('p1', 's1', 'a1'); + + expect(messages).toHaveLength(1); + expect(mockAPI.getSubagentMessages).toHaveBeenCalledTimes(1); + expect(mockAPI.getSubagentMessages).toHaveBeenCalledWith('p1', 's1', 'a1'); + + const cached = store.getState().getCachedSubagentMessages('a1'); + expect(cached).not.toBeNull(); + expect(cached).toHaveLength(1); + }); + + it('returns the cached value on second call without re-invoking IPC', async () => { + mockAPI.getSubagentMessages.mockResolvedValue(fakeMessages('a1')); + await store.getState().loadSubagentMessages('p1', 's1', 'a1'); + await store.getState().loadSubagentMessages('p1', 's1', 'a1'); + + expect(mockAPI.getSubagentMessages).toHaveBeenCalledTimes(1); + }); + + it('coalesces concurrent loads of the same id into one IPC', async () => { + let resolveFn: ((value: ParsedMessage[]) => void) | null = null; + mockAPI.getSubagentMessages.mockImplementation( + () => + new Promise((resolve) => { + resolveFn = resolve; + }) + ); + + const p1 = store.getState().loadSubagentMessages('p1', 's1', 'a1'); + const p2 = store.getState().loadSubagentMessages('p1', 's1', 'a1'); + const p3 = store.getState().loadSubagentMessages('p1', 's1', 'a1'); + + expect(mockAPI.getSubagentMessages).toHaveBeenCalledTimes(1); + expect(store.getState().loadingSubagentIds.has('a1')).toBe(true); + + resolveFn!(fakeMessages('a1')); + const [r1, r2, r3] = await Promise.all([p1, p2, p3]); + + expect(r1).toBe(r2); + expect(r2).toBe(r3); + expect(store.getState().loadingSubagentIds.has('a1')).toBe(false); + }); + + it('records the error when the IPC rejects, and surfaces it via subagentMessageErrors', async () => { + mockAPI.getSubagentMessages.mockRejectedValue(new Error('boom')); + const result = await store.getState().loadSubagentMessages('p1', 's1', 'broken'); + + expect(result).toEqual([]); + expect(store.getState().subagentMessageErrors.get('broken')).toBe('boom'); + expect(store.getState().loadingSubagentIds.has('broken')).toBe(false); + expect(store.getState().subagentMessageCache.has('broken')).toBe(false); + }); + + it('evicts the oldest entry when the cache exceeds 10 entries', async () => { + // Load 11 distinct subagents in order. The first one should fall out. + for (let i = 0; i < 11; i++) { + mockAPI.getSubagentMessages.mockResolvedValueOnce(fakeMessages(`a${i}`)); + await store.getState().loadSubagentMessages('p1', 's1', `a${i}`); + } + + expect(store.getState().subagentMessageCache.size).toBe(10); + expect(store.getState().subagentMessageCache.has('a0')).toBe(false); + expect(store.getState().subagentMessageCache.has('a10')).toBe(true); + }); + + it('clearSubagentMessageCache wipes everything', async () => { + mockAPI.getSubagentMessages.mockResolvedValue(fakeMessages('a1')); + await store.getState().loadSubagentMessages('p1', 's1', 'a1'); + store.getState().clearSubagentMessageCache(); + expect(store.getState().subagentMessageCache.size).toBe(0); + expect(store.getState().loadingSubagentIds.size).toBe(0); + expect(store.getState().subagentMessageErrors.size).toBe(0); + }); +});