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.
This commit is contained in:
parent
21d4e1c98e
commit
056351b8a6
8 changed files with 530 additions and 20 deletions
|
|
@ -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 = (
|
||||
<BaseItem
|
||||
icon={<MailOpen className="size-4" />}
|
||||
label="Input"
|
||||
summary={truncateText(inputContent, 80)}
|
||||
tokenCount={inputTokenCount}
|
||||
onClick={() => onItemClick(itemKey)}
|
||||
isExpanded={expandedItemIds.has(itemKey)}
|
||||
>
|
||||
<MarkdownViewer content={inputContent} copyable />
|
||||
</BaseItem>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'compact_boundary': {
|
||||
itemKey = `compact-${index}`;
|
||||
const compactContent = item.content;
|
||||
const compactExpanded = expandedItemIds.has(itemKey);
|
||||
element = (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => onItemClick(itemKey)}
|
||||
className="group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 transition-all duration-200"
|
||||
style={{
|
||||
backgroundColor: TOOL_CALL_BG,
|
||||
border: `1px solid ${TOOL_CALL_BORDER}`,
|
||||
}}
|
||||
aria-expanded={compactExpanded}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1.5"
|
||||
style={{ color: TOOL_CALL_TEXT }}
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={`transition-transform duration-200 ${compactExpanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
<Layers size={14} />
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-medium" style={{ color: TOOL_CALL_TEXT }}>
|
||||
Compacted
|
||||
</span>
|
||||
{item.tokenDelta && (
|
||||
<span
|
||||
className="min-w-0 truncate text-[11px] tabular-nums"
|
||||
style={{ color: COLOR_TEXT_MUTED }}
|
||||
>
|
||||
{formatTokensCompact(item.tokenDelta.preCompactionTokens)} →{' '}
|
||||
{formatTokensCompact(item.tokenDelta.postCompactionTokens)}
|
||||
<span style={{ color: '#4ade80' }}>
|
||||
{' '}
|
||||
({formatTokensCompact(Math.abs(item.tokenDelta.delta))} freed)
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-[10px]"
|
||||
style={{
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.15)',
|
||||
color: '#818cf8',
|
||||
}}
|
||||
>
|
||||
Phase {item.phaseNumber}
|
||||
</span>
|
||||
<span
|
||||
className="ml-auto shrink-0 text-[11px]"
|
||||
style={{ color: COLOR_TEXT_MUTED }}
|
||||
>
|
||||
{format(new Date(item.timestamp), 'h:mm:ss a')}
|
||||
</span>
|
||||
</button>
|
||||
{compactExpanded && compactContent && (
|
||||
<div
|
||||
className="mt-1 overflow-hidden rounded-lg"
|
||||
style={{
|
||||
backgroundColor: CODE_BG,
|
||||
border: `1px solid ${CODE_BORDER}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="max-h-64 overflow-y-auto border-l-2 px-3 py-2"
|
||||
style={{ borderColor: 'var(--chat-ai-border)' }}
|
||||
>
|
||||
<MarkdownViewer content={compactContent} copyable />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ExecutionTraceProps> = ({
|
|||
</div>
|
||||
);
|
||||
|
||||
case 'subagent_input': {
|
||||
const itemId = `subagent-input-${index}`;
|
||||
const isExpanded = expandedItemId === itemId;
|
||||
return (
|
||||
<BaseItem
|
||||
key={itemId}
|
||||
icon={<MailOpen className="size-4" />}
|
||||
label="Input"
|
||||
summary={truncateText(item.content, 80)}
|
||||
tokenCount={item.tokenCount}
|
||||
onClick={() => handleItemClick(itemId)}
|
||||
isExpanded={isExpanded}
|
||||
>
|
||||
<MarkdownViewer content={item.content} copyable />
|
||||
</BaseItem>
|
||||
);
|
||||
}
|
||||
|
||||
case 'teammate_message': {
|
||||
const itemId = `subagent-teammate-${item.teammateMessage.id}-${index}`;
|
||||
const isExpanded = expandedItemId === itemId;
|
||||
return (
|
||||
<TeammateMessageItem
|
||||
key={itemId}
|
||||
teammateMessage={item.teammateMessage}
|
||||
onClick={() => handleItemClick(itemId)}
|
||||
isExpanded={isExpanded}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case 'compact_boundary': {
|
||||
const itemId = `subagent-compact-${index}`;
|
||||
const isExpanded = expandedItemId === itemId;
|
||||
return (
|
||||
<div key={itemId}>
|
||||
{/* Header — matches CompactBoundary.tsx amber styling */}
|
||||
<button
|
||||
onClick={() => handleItemClick(itemId)}
|
||||
className="group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 transition-all duration-200"
|
||||
style={{
|
||||
backgroundColor: TOOL_CALL_BG,
|
||||
border: `1px solid ${TOOL_CALL_BORDER}`,
|
||||
}}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1.5"
|
||||
style={{ color: TOOL_CALL_TEXT }}
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={`transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
<Layers size={14} />
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-medium" style={{ color: TOOL_CALL_TEXT }}>
|
||||
Compacted
|
||||
</span>
|
||||
{item.tokenDelta && (
|
||||
<span
|
||||
className="min-w-0 truncate text-[11px] tabular-nums"
|
||||
style={{ color: COLOR_TEXT_MUTED }}
|
||||
>
|
||||
{formatTokensCompact(item.tokenDelta.preCompactionTokens)} →{' '}
|
||||
{formatTokensCompact(item.tokenDelta.postCompactionTokens)}
|
||||
<span style={{ color: '#4ade80' }}>
|
||||
{' '}
|
||||
({formatTokensCompact(Math.abs(item.tokenDelta.delta))} freed)
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-[10px]"
|
||||
style={{
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.15)',
|
||||
color: '#818cf8',
|
||||
}}
|
||||
>
|
||||
Phase {item.phaseNumber}
|
||||
</span>
|
||||
<span
|
||||
className="ml-auto shrink-0 text-[11px]"
|
||||
style={{ color: COLOR_TEXT_MUTED }}
|
||||
>
|
||||
{format(new Date(item.timestamp), 'h:mm:ss a')}
|
||||
</span>
|
||||
</button>
|
||||
{/* Expanded content */}
|
||||
{isExpanded && item.content && (
|
||||
<div
|
||||
className="mt-1 overflow-hidden rounded-lg"
|
||||
style={{
|
||||
backgroundColor: CODE_BG,
|
||||
border: `1px solid ${CODE_BORDER}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="max-h-64 overflow-y-auto border-l-2 px-3 py-2"
|
||||
style={{ borderColor: 'var(--chat-ai-border)' }}
|
||||
>
|
||||
<MarkdownViewer content={item.content} copyable />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MetricsPillProps>): React.ReactElement | null => {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const [tooltipStyle, setTooltipStyle] = useState<React.CSSProperties>({});
|
||||
|
|
@ -47,14 +54,21 @@ export const MetricsPill = ({
|
|||
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 = ({
|
|||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasPhases &&
|
||||
phaseBreakdown.map((phase) => (
|
||||
<div
|
||||
key={phase.phaseNumber}
|
||||
className="flex items-center justify-between gap-3 pl-2"
|
||||
>
|
||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
Phase {phase.phaseNumber}
|
||||
</span>
|
||||
<span
|
||||
className="font-mono text-[10px] tabular-nums"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
>
|
||||
{formatTokensCompact(phase.peakTokens)}
|
||||
{phase.postCompaction != null && (
|
||||
<span style={{ color: '#4ade80' }}>
|
||||
{' '}
|
||||
→ {formatTokensCompact(phase.postCompaction)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="mt-1 pt-1.5 text-[10px]"
|
||||
style={{ borderTop: `1px solid ${TAG_BORDER}`, color: CARD_ICON_MUTED }}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
|||
import { useTabUI } from '@renderer/hooks/useTabUI';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer';
|
||||
import { formatDuration } from '@renderer/utils/formatters';
|
||||
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';
|
||||
import {
|
||||
|
|
@ -154,6 +155,12 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
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<SubagentItemProps> = ({
|
|||
// 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<SubagentItemProps> = ({
|
|||
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<SubagentItemProps> = ({
|
|||
<div className="flex items-center gap-2">
|
||||
<CircleDot className="size-3" style={{ color: 'rgba(56, 189, 248, 0.7)' }} />
|
||||
<span className="text-xs" style={{ color: COLOR_TEXT_SECONDARY }}>
|
||||
{subagent.team ? 'Context Window' : 'Isolated Usage'}
|
||||
{subagent.team ? 'Context Window' : 'Subagent Context'}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
|
|
@ -464,6 +478,28 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-phase breakdown when multi-phase */}
|
||||
{isMultiPhase &&
|
||||
phaseData.phases.map((phase) => (
|
||||
<div key={phase.phaseNumber} className="flex items-center justify-between pl-5">
|
||||
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
Phase {phase.phaseNumber}
|
||||
</span>
|
||||
<span
|
||||
className="font-mono text-[11px] tabular-nums"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
>
|
||||
{formatTokensCompact(phase.peakTokens)}
|
||||
{phase.postCompaction != null && (
|
||||
<span style={{ color: '#4ade80' }}>
|
||||
{' '}
|
||||
→ {formatTokensCompact(phase.postCompaction)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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 !== '<synthetic>') {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 !== '<synthetic>') {
|
||||
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 !== '<synthetic>') {
|
||||
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 <teammate-message> content)
|
||||
// One user message may contain multiple <teammate-message> 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)) {
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue