@@ -4,7 +4,6 @@ import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssV
44import { useTabUI } from '@renderer/hooks/useTabUI' ;
55import { useStore } from '@renderer/store' ;
66import { enhanceAIGroup , type PrecedingSlashInfo } from '@renderer/utils/aiGroupEnhancer' ;
7- import { extractSlashInfo , isCommandContent } from '@shared/utils/contentSanitizer' ;
87import { getModelColorClass } from '@shared/utils/modelParser' ;
98import { estimateTokens } from '@shared/utils/tokenFormatting' ;
109import { format } from 'date-fns' ;
@@ -22,39 +21,11 @@ import type {
2221 AIGroup ,
2322 AIGroupDisplayItem ,
2423 EnhancedAIGroup ,
25- UserGroup ,
2624} from '@renderer/types/groups' ;
2725import 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 */
9371function 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