From 056351b8a632c3aa8f6bd660419d0bdfd310e2b9 Mon Sep 17 00:00:00 2001 From: matt Date: Mon, 16 Feb 2026 22:13:24 +0900 Subject: [PATCH] feat(chat): implement subagent input and compact boundary display items - Added support for rendering 'subagent_input' and 'compact_boundary' types in the chat display components. - Introduced a new `MarkdownViewer` for displaying content in both item types. - Enhanced the `MetricsPill` and `SubagentItem` components to include phase breakdowns and isolated usage metrics. - Updated the `AIGroupDisplayItem` type to accommodate new item types and their properties. - Implemented logic to compute and display token consumption across multiple phases for subagents. --- .../components/chat/DisplayItemList.tsx | 111 +++++++++++++++ .../components/chat/items/ExecutionTrace.tsx | 126 +++++++++++++++++- .../components/chat/items/MetricsPill.tsx | 53 ++++++-- .../components/chat/items/SubagentItem.tsx | 52 ++++++-- src/renderer/types/groups.ts | 10 +- src/renderer/utils/aiGroupHelpers.ts | 110 ++++++++++++++- src/renderer/utils/displayItemBuilder.ts | 81 ++++++++++- src/renderer/utils/displaySummary.ts | 7 + 8 files changed, 530 insertions(+), 20 deletions(-) diff --git a/src/renderer/components/chat/DisplayItemList.tsx b/src/renderer/components/chat/DisplayItemList.tsx index e0e82e30..8beec61f 100644 --- a/src/renderer/components/chat/DisplayItemList.tsx +++ b/src/renderer/components/chat/DisplayItemList.tsx @@ -1,11 +1,25 @@ import React, { useCallback, useState } from 'react'; +import { + CODE_BG, + CODE_BORDER, + COLOR_TEXT_MUTED, + TOOL_CALL_BG, + TOOL_CALL_BORDER, + TOOL_CALL_TEXT, +} from '@renderer/constants/cssVariables'; +import { formatTokensCompact } from '@renderer/utils/formatters'; +import { format } from 'date-fns'; +import { ChevronRight, Layers, MailOpen } from 'lucide-react'; + +import { BaseItem } from './items/BaseItem'; import { LinkedToolItem } from './items/LinkedToolItem'; import { SlashItem } from './items/SlashItem'; import { SubagentItem } from './items/SubagentItem'; import { TeammateMessageItem } from './items/TeammateMessageItem'; import { TextItem } from './items/TextItem'; import { ThinkingItem } from './items/ThinkingItem'; +import { MarkdownViewer } from './viewers/MarkdownViewer'; import type { AIGroupDisplayItem } from '@renderer/types/groups'; import type { TriggerColor } from '@shared/constants/triggerColors'; @@ -208,6 +222,103 @@ export const DisplayItemList = ({ break; } + case 'subagent_input': { + itemKey = `input-${index}`; + const inputContent = item.content; + const inputTokenCount = item.tokenCount; + element = ( + } + label="Input" + summary={truncateText(inputContent, 80)} + tokenCount={inputTokenCount} + onClick={() => onItemClick(itemKey)} + isExpanded={expandedItemIds.has(itemKey)} + > + + + ); + break; + } + + case 'compact_boundary': { + itemKey = `compact-${index}`; + const compactContent = item.content; + const compactExpanded = expandedItemIds.has(itemKey); + element = ( +
+ + {compactExpanded && compactContent && ( +
+
+ +
+
+ )} +
+ ); + break; + } + default: return null; } diff --git a/src/renderer/components/chat/items/ExecutionTrace.tsx b/src/renderer/components/chat/items/ExecutionTrace.tsx index d6010405..2f762a4c 100644 --- a/src/renderer/components/chat/items/ExecutionTrace.tsx +++ b/src/renderer/components/chat/items/ExecutionTrace.tsx @@ -1,9 +1,24 @@ import React, { useState } from 'react'; -import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables'; +import { + CARD_ICON_MUTED, + CODE_BG, + CODE_BORDER, + COLOR_TEXT_MUTED, + TOOL_CALL_BG, + TOOL_CALL_BORDER, + TOOL_CALL_TEXT, +} from '@renderer/constants/cssVariables'; import { truncateText } from '@renderer/utils/aiGroupEnhancer'; +import { formatTokensCompact } from '@renderer/utils/formatters'; +import { format } from 'date-fns'; +import { ChevronRight, Layers, MailOpen } from 'lucide-react'; +import { MarkdownViewer } from '../viewers/MarkdownViewer'; + +import { BaseItem } from './BaseItem'; import { LinkedToolItem } from './LinkedToolItem'; +import { TeammateMessageItem } from './TeammateMessageItem'; import { TextItem } from './TextItem'; import { ThinkingItem } from './ThinkingItem'; @@ -142,6 +157,115 @@ export const ExecutionTrace: React.FC = ({ ); + case 'subagent_input': { + const itemId = `subagent-input-${index}`; + const isExpanded = expandedItemId === itemId; + return ( + } + label="Input" + summary={truncateText(item.content, 80)} + tokenCount={item.tokenCount} + onClick={() => handleItemClick(itemId)} + isExpanded={isExpanded} + > + + + ); + } + + case 'teammate_message': { + const itemId = `subagent-teammate-${item.teammateMessage.id}-${index}`; + const isExpanded = expandedItemId === itemId; + return ( + handleItemClick(itemId)} + isExpanded={isExpanded} + /> + ); + } + + case 'compact_boundary': { + const itemId = `subagent-compact-${index}`; + const isExpanded = expandedItemId === itemId; + return ( +
+ {/* Header — matches CompactBoundary.tsx amber styling */} + + {/* Expanded content */} + {isExpanded && item.content && ( +
+
+ +
+
+ )} +
+ ); + } + default: return null; } diff --git a/src/renderer/components/chat/items/MetricsPill.tsx b/src/renderer/components/chat/items/MetricsPill.tsx index 73213a29..ff28c668 100644 --- a/src/renderer/components/chat/items/MetricsPill.tsx +++ b/src/renderer/components/chat/items/MetricsPill.tsx @@ -15,6 +15,7 @@ import { formatTokensCompact } from '@renderer/utils/formatters'; // ============================================================================= // Types // ============================================================================= +import type { PhaseTokenBreakdown } from '@renderer/types/data'; interface MetricsPillProps { mainSessionImpact?: { @@ -30,6 +31,10 @@ interface MetricsPillProps { }; /** Label override for the right segment (e.g. "Context Window" for team members) */ isolatedLabel?: string; + /** Override isolated total (for multi-phase total consumption) */ + isolatedOverride?: number; + /** Phase breakdown for tooltip (shown when multiple phases exist) */ + phaseBreakdown?: PhaseTokenBreakdown[]; } // ============================================================================= @@ -40,6 +45,8 @@ export const MetricsPill = ({ mainSessionImpact, lastUsage, isolatedLabel, + isolatedOverride, + phaseBreakdown, }: Readonly): React.ReactElement | null => { const [showTooltip, setShowTooltip] = useState(false); const [tooltipStyle, setTooltipStyle] = useState({}); @@ -47,14 +54,21 @@ export const MetricsPill = ({ const hideTimeoutRef = useRef | null>(null); const hasMainImpact = mainSessionImpact && mainSessionImpact.totalTokens > 0; - const hasIsolated = lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0; + const hasIsolated = + isolatedOverride != null + ? isolatedOverride > 0 + : lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0; - const isolatedTotal = lastUsage - ? lastUsage.input_tokens + - lastUsage.output_tokens + - (lastUsage.cache_read_input_tokens ?? 0) + - (lastUsage.cache_creation_input_tokens ?? 0) - : 0; + const isolatedTotal = + isolatedOverride ?? + (lastUsage + ? lastUsage.input_tokens + + lastUsage.output_tokens + + (lastUsage.cache_read_input_tokens ?? 0) + + (lastUsage.cache_creation_input_tokens ?? 0) + : 0); + + const hasPhases = phaseBreakdown && phaseBreakdown.length > 1; const clearHideTimeout = (): void => { if (hideTimeoutRef.current) { @@ -109,7 +123,7 @@ export const MetricsPill = ({ const mainValue = hasMainImpact ? formatTokensCompact(mainSessionImpact.totalTokens) : null; const isolatedValue = hasIsolated ? formatTokensCompact(isolatedTotal) : null; - const rightLabel = isolatedLabel ?? 'Isolated Usage'; + const rightLabel = isolatedLabel ?? 'Subagent Context'; return ( <> @@ -160,6 +174,29 @@ export const MetricsPill = ({ )} + {hasPhases && + phaseBreakdown.map((phase) => ( +
+ + Phase {phase.phaseNumber} + + + {formatTokensCompact(phase.peakTokens)} + {phase.postCompaction != null && ( + + {' '} + → {formatTokensCompact(phase.postCompaction)} + + )} + +
+ ))}
= ({ return null; }, [subagent.messages]); + // Multi-phase context breakdown (for subagents with compaction) + const phaseData = useMemo(() => { + if (!subagent.messages?.length) return null; + return computeSubagentPhaseBreakdown(subagent.messages); + }, [subagent.messages]); + // Search expansion const searchExpandedSubagentIds = useStore((s) => s.searchExpandedSubagentIds); const searchCurrentSubagentItemId = useStore((s) => s.searchCurrentSubagentItemId); @@ -196,12 +203,15 @@ export const SubagentItem: React.FC = ({ // Computed values for metrics const hasMainImpact = subagent.mainSessionImpact && subagent.mainSessionImpact.totalTokens > 0; const hasIsolated = lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0; - const isolatedTotal = lastUsage - ? lastUsage.input_tokens + - lastUsage.output_tokens + - (lastUsage.cache_read_input_tokens ?? 0) + - (lastUsage.cache_creation_input_tokens ?? 0) - : 0; + const isMultiPhase = phaseData != null && phaseData.compactionCount > 0; + const isolatedTotal = isMultiPhase + ? phaseData.totalConsumption + : lastUsage + ? lastUsage.input_tokens + + lastUsage.output_tokens + + (lastUsage.cache_read_input_tokens ?? 0) + + (lastUsage.cache_creation_input_tokens ?? 0) + : 0; // Shutdown-only team activations: minimal inline row (no metrics, no expand) if (isShutdownOnly && teamColors && subagent.team) { @@ -338,6 +348,10 @@ export const SubagentItem: React.FC = ({ mainSessionImpact={subagent.team ? undefined : subagent.mainSessionImpact} lastUsage={lastUsage ?? undefined} isolatedLabel={subagent.team ? 'Context Window' : undefined} + isolatedOverride={ + phaseData && phaseData.compactionCount > 0 ? phaseData.totalConsumption : undefined + } + phaseBreakdown={phaseData?.phases} /> {/* Duration */} @@ -453,7 +467,7 @@ export const SubagentItem: React.FC = ({
- {subagent.team ? 'Context Window' : 'Isolated Usage'} + {subagent.team ? 'Context Window' : 'Subagent Context'}
= ({
)} + + {/* Per-phase breakdown when multi-phase */} + {isMultiPhase && + phaseData.phases.map((phase) => ( +
+ + Phase {phase.phaseNumber} + + + {formatTokensCompact(phase.peakTokens)} + {phase.postCompaction != null && ( + + {' '} + → {formatTokensCompact(phase.postCompaction)} + + )} + +
+ ))} )} diff --git a/src/renderer/types/groups.ts b/src/renderer/types/groups.ts index 938af9a7..639d0436 100644 --- a/src/renderer/types/groups.ts +++ b/src/renderer/types/groups.ts @@ -253,7 +253,15 @@ export type AIGroupDisplayItem = | { type: 'subagent'; subagent: Process } | { type: 'output'; content: string; timestamp: Date; tokenCount?: number } | { type: 'slash'; slash: SlashItem } - | { type: 'teammate_message'; teammateMessage: TeammateMessage }; + | { type: 'teammate_message'; teammateMessage: TeammateMessage } + | { type: 'subagent_input'; content: string; timestamp: Date; tokenCount?: number } + | { + type: 'compact_boundary'; + content: string; + timestamp: Date; + tokenDelta?: CompactionTokenDelta; + phaseNumber: number; + }; /** * The last output in an AI Group - what user sees as "the answer". diff --git a/src/renderer/utils/aiGroupHelpers.ts b/src/renderer/utils/aiGroupHelpers.ts index f1448ba3..fb07e6d5 100644 --- a/src/renderer/utils/aiGroupHelpers.ts +++ b/src/renderer/utils/aiGroupHelpers.ts @@ -7,7 +7,7 @@ import { createLogger } from '@shared/utils/logger'; import { estimateTokens } from '@shared/utils/tokenFormatting'; -import type { Process } from '../types/data'; +import type { ParsedMessage, PhaseTokenBreakdown, Process } from '../types/data'; import type { LinkedToolItem } from '../types/groups'; const logger = createLogger('Util:aiGroupHelpers'); @@ -98,3 +98,111 @@ export function attachMainSessionImpact( } return subagents; } + +/** + * Computes multi-phase context breakdown for a subagent session. + * Mirrors the algorithm in src/main/utils/jsonl.ts:500-576. + * + * Tracks assistant input tokens across compaction events to compute + * per-phase contribution and total consumption across all phases. + * + * @param messages - Subagent's ParsedMessages + * @returns Phase breakdown with total consumption, or null if no usage data + */ +export function computeSubagentPhaseBreakdown(messages: ParsedMessage[]): { + phases: PhaseTokenBreakdown[]; + totalConsumption: number; + compactionCount: number; +} | null { + let lastMainAssistantInputTokens = 0; + let awaitingPostCompaction = false; + const compactionPhases: { pre: number; post: number }[] = []; + + for (const msg of messages) { + // Track assistant input tokens. + // Unlike jsonl.ts, we don't filter by isSidechain here because subagent messages + // all have isSidechain=true (from the parent session's perspective). + if (msg.type === 'assistant' && msg.model !== '') { + const inputTokens = + (msg.usage?.input_tokens ?? 0) + + (msg.usage?.cache_read_input_tokens ?? 0) + + (msg.usage?.cache_creation_input_tokens ?? 0); + if (inputTokens > 0) { + if (awaitingPostCompaction && compactionPhases.length > 0) { + compactionPhases[compactionPhases.length - 1].post = inputTokens; + awaitingPostCompaction = false; + } + lastMainAssistantInputTokens = inputTokens; + } + } + + // Detect compaction events + if (msg.isCompactSummary) { + compactionPhases.push({ pre: lastMainAssistantInputTokens, post: 0 }); + awaitingPostCompaction = true; + } + } + + if (lastMainAssistantInputTokens <= 0) { + return null; + } + + let phaseBreakdown: PhaseTokenBreakdown[]; + + if (compactionPhases.length === 0) { + // No compaction: single phase + phaseBreakdown = [ + { + phaseNumber: 1, + contribution: lastMainAssistantInputTokens, + peakTokens: lastMainAssistantInputTokens, + }, + ]; + return { + phases: phaseBreakdown, + totalConsumption: lastMainAssistantInputTokens, + compactionCount: 0, + }; + } + + phaseBreakdown = []; + let total = 0; + + // Phase 1: tokens up to first compaction + const phase1Contribution = compactionPhases[0].pre; + total += phase1Contribution; + phaseBreakdown.push({ + phaseNumber: 1, + contribution: phase1Contribution, + peakTokens: compactionPhases[0].pre, + postCompaction: compactionPhases[0].post, + }); + + // Middle phases: contribution = pre[i] - post[i-1] + for (let i = 1; i < compactionPhases.length; i++) { + const contribution = compactionPhases[i].pre - compactionPhases[i - 1].post; + total += contribution; + phaseBreakdown.push({ + phaseNumber: i + 1, + contribution, + peakTokens: compactionPhases[i].pre, + postCompaction: compactionPhases[i].post, + }); + } + + // Last phase: final tokens - last post-compaction + const lastPhase = compactionPhases[compactionPhases.length - 1]; + const lastContribution = lastMainAssistantInputTokens - lastPhase.post; + total += lastContribution; + phaseBreakdown.push({ + phaseNumber: compactionPhases.length + 1, + contribution: lastContribution, + peakTokens: lastMainAssistantInputTokens, + }); + + return { + phases: phaseBreakdown, + totalConsumption: total, + compactionCount: compactionPhases.length, + }; +} diff --git a/src/renderer/utils/displayItemBuilder.ts b/src/renderer/utils/displayItemBuilder.ts index b0681e70..953dc645 100644 --- a/src/renderer/utils/displayItemBuilder.ts +++ b/src/renderer/utils/displayItemBuilder.ts @@ -29,6 +29,9 @@ function getDisplayItemTimestamp(item: AIGroupDisplayItem): Date { return toDate(item.slash.timestamp); case 'teammate_message': return toDate(item.teammateMessage.timestamp); + case 'subagent_input': + case 'compact_boundary': + return toDate(item.timestamp); } } @@ -320,10 +323,76 @@ export function buildDisplayItemsFromMessages( subagents.map((s) => s.parentTaskId).filter((id): id is string => !!id) ); + // Track compaction events for compact_boundary display items + let compactionCount = 0; + + // Helper to get the last assistant's total input tokens before a given index + // Note: don't filter by isSidechain — subagent messages all have isSidechain=true + function getLastAssistantInputTokens(idx: number): number { + for (let i = idx - 1; i >= 0; i--) { + const m = messages[i]; + if (m.type === 'assistant' && m.usage && m.model !== '') { + return ( + (m.usage.input_tokens ?? 0) + + (m.usage.cache_read_input_tokens ?? 0) + + (m.usage.cache_creation_input_tokens ?? 0) + ); + } + } + return 0; + } + + // Helper to get the first assistant's total input tokens after a given index + function getFirstAssistantInputTokens(idx: number): number { + for (let i = idx + 1; i < messages.length; i++) { + const m = messages[i]; + if (m.type === 'assistant' && m.usage && m.model !== '') { + return ( + (m.usage.input_tokens ?? 0) + + (m.usage.cache_read_input_tokens ?? 0) + + (m.usage.cache_creation_input_tokens ?? 0) + ); + } + } + return 0; + } + // First pass: collect tool calls and tool results from messages - for (const msg of messages) { + for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { + const msg = messages[messageIndex]; const msgTimestamp = toDate(msg.timestamp); + // Detect compact boundary (before regular user message handling) + if (msg.isCompactSummary) { + const preTokens = getLastAssistantInputTokens(messageIndex); + const postTokens = getFirstAssistantInputTokens(messageIndex); + const rawText = + typeof msg.content === 'string' + ? msg.content + : Array.isArray(msg.content) + ? msg.content + .filter((b: { type: string; text?: string }) => b.type === 'text') + .map((b: { type: string; text?: string }) => b.text ?? '') + .join('\n\n') + : ''; + displayItems.push({ + type: 'compact_boundary', + content: rawText, + timestamp: msgTimestamp, + tokenDelta: + preTokens > 0 + ? { + preCompactionTokens: preTokens, + postCompactionTokens: postTokens, + delta: postTokens - preTokens, + } + : undefined, + phaseNumber: compactionCount + 2, + }); + compactionCount++; + continue; + } + // Check for teammate messages (non-meta user messages with content) // One user message may contain multiple blocks if (msg.type === 'user' && !msg.isMeta) { @@ -354,6 +423,16 @@ export function buildDisplayItemsFromMessages( } continue; } + // Plain-text user message (subagent input prompt) + if (rawText.trim()) { + displayItems.push({ + type: 'subagent_input', + content: rawText.trim(), + timestamp: msgTimestamp, + tokenCount: estimateTokens(rawText), + }); + } + continue; } if (msg.type === 'assistant' && Array.isArray(msg.content)) { diff --git a/src/renderer/utils/displaySummary.ts b/src/renderer/utils/displaySummary.ts index 28c6c31d..942a8c59 100644 --- a/src/renderer/utils/displaySummary.ts +++ b/src/renderer/utils/displaySummary.ts @@ -26,6 +26,8 @@ export function buildSummary(items: AIGroupDisplayItem[]): string { subagent: 0, slash: 0, teammate_message: 0, + subagent_input: 0, + compact_boundary: 0, }; const teammateNames = new Set(); @@ -62,6 +64,11 @@ export function buildSummary(items: AIGroupDisplayItem[]): string { `${counts.teammate_message} teammate ${counts.teammate_message === 1 ? 'message' : 'messages'}` ); } + if (counts.compact_boundary > 0) { + parts.push( + `${counts.compact_boundary} ${counts.compact_boundary === 1 ? 'compaction' : 'compactions'}` + ); + } return parts.length > 0 ? parts.join(', ') : 'No items'; }