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';
}