Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions src/main/types/chunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,58 @@
* - 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';

// =============================================================================
// 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 `<synthetic>`). */
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.
*/
Expand All @@ -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;
Expand All @@ -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") */
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -444,6 +503,7 @@ export interface FileChangeEvent {
projectId?: string;
sessionId?: string;
isSubagent: boolean;
fileSize?: number;
}

// =============================================================================
Expand Down
7 changes: 7 additions & 0 deletions src/preload/constants/ipcChannels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
5 changes: 4 additions & 1 deletion src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
SSH_SAVE_LAST_CONNECTION,
SSH_STATUS,
SSH_TEST,
SUBAGENT_GET_MESSAGES,
UPDATER_CHECK,
UPDATER_DOWNLOAD,
UPDATER_INSTALL,
Expand Down Expand Up @@ -96,6 +97,7 @@ interface IpcFileChangePayload {
projectId?: string;
sessionId?: string;
isSubagent: boolean;
fileSize?: number;
}

/**
Expand Down Expand Up @@ -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) =>
Expand Down
10 changes: 10 additions & 0 deletions src/renderer/api/httpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
NotificationsAPI,
NotificationTrigger,
PaginatedSessionsResult,
ParsedMessage,
Project,
RepositoryGroup,
SearchSessionsResult,
Expand Down Expand Up @@ -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<ParsedMessage[]> =>
this.get<ParsedMessage[]>(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}/messages`
);

getSessionGroups = (projectId: string, sessionId: string): Promise<ConversationGroup[]> =>
this.get<ConversationGroup[]>(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/groups`
Expand Down
118 changes: 43 additions & 75 deletions src/renderer/components/chat/AIChatGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 <command-name> 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.
Comment on lines +27 to +28
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all usages of AIChatGroup to verify precedingSlash is passed
rg -n '<AIChatGroup' --type=tsx --type=ts -A 5

Repository: matt1398/claude-devtools

Length of output: 95


🏁 Script executed:

rg '<AIChatGroup' -t typescript -A 5

Repository: matt1398/claude-devtools

Length of output: 102


🏁 Script executed:

rg -g '*.tsx' '<AIChatGroup' -A 5

Repository: matt1398/claude-devtools

Length of output: 1062


🏁 Script executed:

cat -n src/renderer/components/chat/AIChatGroup.tsx | head -200

Repository: matt1398/claude-devtools

Length of output: 9275


🏁 Script executed:

cat -n src/renderer/components/chat/ChatHistoryItem.tsx | head -150

Repository: matt1398/claude-devtools

Length of output: 5401


🏁 Script executed:

rg -g '*.tsx' 'ChatHistory' --type-add 'tsx:*.tsx' -l | head -20

Repository: matt1398/claude-devtools

Length of output: 365


🏁 Script executed:

cat -n src/renderer/components/chat/ChatHistory.tsx | head -300

Repository: matt1398/claude-devtools

Length of output: 13387


🏁 Script executed:

cat -n src/renderer/components/chat/ChatHistory.tsx | tail -n +300 | head -200

Repository: matt1398/claude-devtools

Length of output: 8757


🏁 Script executed:

cat -n src/renderer/components/chat/ChatHistory.tsx | tail -n +500 | head -200

Repository: matt1398/claude-devtools

Length of output: 8979


🏁 Script executed:

cat -n src/renderer/components/chat/ChatHistory.tsx | tail -n +700 | head -300

Repository: matt1398/claude-devtools

Length of output: 8814


🏁 Script executed:

rg -g '*.tsx' 'precedingSlash' -B 3 -A 3

Repository: matt1398/claude-devtools

Length of output: 3371


🏁 Script executed:

rg -g '*.tsx' 'extractPrecedingSlashInfo\|precedingSlash\s*=' -B 2 -A 2

Repository: matt1398/claude-devtools

Length of output: 50


🏁 Script executed:

rg -g '*.ts' -g '*.tsx' 'extractPrecedingSlashInfo' -B 2 -A 2

Repository: matt1398/claude-devtools

Length of output: 496


🏁 Script executed:

rg -g '*.ts' -g '*.tsx' 'precedingSlash.*=' -A 2

Repository: matt1398/claude-devtools

Length of output: 300


🏁 Script executed:

rg -g '*.ts' -g '*.tsx' 'enhanceAIGroup' -B 5 -A 5

Repository: matt1398/claude-devtools

Length of output: 4031


🏁 Script executed:

cat -n src/renderer/utils/aiGroupEnhancer.ts | head -100

Repository: matt1398/claude-devtools

Length of output: 3866


🏁 Script executed:

cat -n src/renderer/utils/slashCommandExtractor.ts | head -150

Repository: matt1398/claude-devtools

Length of output: 6160


Pass precedingSlash prop from ChatHistory to AIChatGroup instantiation.

The precedingSlash prop is optional but expected to be pre-computed and passed from ChatHistory. However, ChatHistoryItem instantiates AIChatGroup without the precedingSlash prop (lines 116-121):

<AIChatGroup
  aiGroup={item.group}
  highlightToolUseId={toolUseIdForGroup}
  highlightColor={highlightColor}
  registerToolRef={registerToolRef}
/>

This means precedingSlash is always undefined, preventing proper slash command linking. The comments in AIChatGroup.tsx (lines 27-28, 175-177) indicate this was intended to be an optimization—moving the O(n) precedingSlash extraction from component render to ChatHistory—but the implementation is incomplete. Compute precedingSlash for each AI group in ChatHistory and pass it down through ChatHistoryItem to AIChatGroup.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/chat/AIChatGroup.tsx` around lines 27 - 28,
ChatHistory now pre-computes preceding-slash info but ChatHistoryItem fails to
pass it down to AIChatGroup; update ChatHistory to compute the precedingSlash
map (using the existing extractPrecedingSlashInfo or the pre-computed map
already added), thread that value through ChatHistoryItem (add a precedingSlash
prop to its props) and then pass precedingSlash into the AIChatGroup
instantiation (the component expecting the precedingSlash prop), ensuring
AIChatGroup receives the pre-computed value for each aiGroup so slash-command
linking works.


/**
* Format duration in milliseconds to human-readable string.
Expand Down Expand Up @@ -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;
}
}
}
Expand All @@ -125,6 +103,7 @@ const AIChatGroupInner = ({
highlightToolUseId,
highlightColor,
registerToolRef,
precedingSlash: precedingSlashProp,
}: Readonly<AIChatGroupProps>): React.JSX.Element => {
// Per-tab UI state for expansion (completely isolated per tab)
const {
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -271,22 +232,29 @@ 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++) {
const item = enhanced.displayItems[i];
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}`;
}
}
}
}
Expand Down
Loading
Loading