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 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 { LinkedToolItem } from './items/LinkedToolItem';
|
||||||
import { SlashItem } from './items/SlashItem';
|
import { SlashItem } from './items/SlashItem';
|
||||||
import { SubagentItem } from './items/SubagentItem';
|
import { SubagentItem } from './items/SubagentItem';
|
||||||
import { TeammateMessageItem } from './items/TeammateMessageItem';
|
import { TeammateMessageItem } from './items/TeammateMessageItem';
|
||||||
import { TextItem } from './items/TextItem';
|
import { TextItem } from './items/TextItem';
|
||||||
import { ThinkingItem } from './items/ThinkingItem';
|
import { ThinkingItem } from './items/ThinkingItem';
|
||||||
|
import { MarkdownViewer } from './viewers/MarkdownViewer';
|
||||||
|
|
||||||
import type { AIGroupDisplayItem } from '@renderer/types/groups';
|
import type { AIGroupDisplayItem } from '@renderer/types/groups';
|
||||||
import type { TriggerColor } from '@shared/constants/triggerColors';
|
import type { TriggerColor } from '@shared/constants/triggerColors';
|
||||||
|
|
@ -208,6 +222,103 @@ export const DisplayItemList = ({
|
||||||
break;
|
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:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,24 @@
|
||||||
import React, { useState } from 'react';
|
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 { 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 { LinkedToolItem } from './LinkedToolItem';
|
||||||
|
import { TeammateMessageItem } from './TeammateMessageItem';
|
||||||
import { TextItem } from './TextItem';
|
import { TextItem } from './TextItem';
|
||||||
import { ThinkingItem } from './ThinkingItem';
|
import { ThinkingItem } from './ThinkingItem';
|
||||||
|
|
||||||
|
|
@ -142,6 +157,115 @@ export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
|
||||||
</div>
|
</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:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { formatTokensCompact } from '@renderer/utils/formatters';
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
import type { PhaseTokenBreakdown } from '@renderer/types/data';
|
||||||
|
|
||||||
interface MetricsPillProps {
|
interface MetricsPillProps {
|
||||||
mainSessionImpact?: {
|
mainSessionImpact?: {
|
||||||
|
|
@ -30,6 +31,10 @@ interface MetricsPillProps {
|
||||||
};
|
};
|
||||||
/** Label override for the right segment (e.g. "Context Window" for team members) */
|
/** Label override for the right segment (e.g. "Context Window" for team members) */
|
||||||
isolatedLabel?: string;
|
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,
|
mainSessionImpact,
|
||||||
lastUsage,
|
lastUsage,
|
||||||
isolatedLabel,
|
isolatedLabel,
|
||||||
|
isolatedOverride,
|
||||||
|
phaseBreakdown,
|
||||||
}: Readonly<MetricsPillProps>): React.ReactElement | null => {
|
}: Readonly<MetricsPillProps>): React.ReactElement | null => {
|
||||||
const [showTooltip, setShowTooltip] = useState(false);
|
const [showTooltip, setShowTooltip] = useState(false);
|
||||||
const [tooltipStyle, setTooltipStyle] = useState<React.CSSProperties>({});
|
const [tooltipStyle, setTooltipStyle] = useState<React.CSSProperties>({});
|
||||||
|
|
@ -47,14 +54,21 @@ export const MetricsPill = ({
|
||||||
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const hasMainImpact = mainSessionImpact && mainSessionImpact.totalTokens > 0;
|
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
|
const isolatedTotal =
|
||||||
? lastUsage.input_tokens +
|
isolatedOverride ??
|
||||||
lastUsage.output_tokens +
|
(lastUsage
|
||||||
(lastUsage.cache_read_input_tokens ?? 0) +
|
? lastUsage.input_tokens +
|
||||||
(lastUsage.cache_creation_input_tokens ?? 0)
|
lastUsage.output_tokens +
|
||||||
: 0;
|
(lastUsage.cache_read_input_tokens ?? 0) +
|
||||||
|
(lastUsage.cache_creation_input_tokens ?? 0)
|
||||||
|
: 0);
|
||||||
|
|
||||||
|
const hasPhases = phaseBreakdown && phaseBreakdown.length > 1;
|
||||||
|
|
||||||
const clearHideTimeout = (): void => {
|
const clearHideTimeout = (): void => {
|
||||||
if (hideTimeoutRef.current) {
|
if (hideTimeoutRef.current) {
|
||||||
|
|
@ -109,7 +123,7 @@ export const MetricsPill = ({
|
||||||
|
|
||||||
const mainValue = hasMainImpact ? formatTokensCompact(mainSessionImpact.totalTokens) : null;
|
const mainValue = hasMainImpact ? formatTokensCompact(mainSessionImpact.totalTokens) : null;
|
||||||
const isolatedValue = hasIsolated ? formatTokensCompact(isolatedTotal) : null;
|
const isolatedValue = hasIsolated ? formatTokensCompact(isolatedTotal) : null;
|
||||||
const rightLabel = isolatedLabel ?? 'Isolated Usage';
|
const rightLabel = isolatedLabel ?? 'Subagent Context';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -160,6 +174,29 @@ export const MetricsPill = ({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
className="mt-1 pt-1.5 text-[10px]"
|
className="mt-1 pt-1.5 text-[10px]"
|
||||||
style={{ borderTop: `1px solid ${TAG_BORDER}`, color: CARD_ICON_MUTED }}
|
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 { useTabUI } from '@renderer/hooks/useTabUI';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer';
|
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 { getHighlightProps, type TriggerColor } from '@shared/constants/triggerColors';
|
||||||
import { getModelColorClass, parseModelString } from '@shared/utils/modelParser';
|
import { getModelColorClass, parseModelString } from '@shared/utils/modelParser';
|
||||||
import {
|
import {
|
||||||
|
|
@ -154,6 +155,12 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
||||||
return null;
|
return null;
|
||||||
}, [subagent.messages]);
|
}, [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
|
// Search expansion
|
||||||
const searchExpandedSubagentIds = useStore((s) => s.searchExpandedSubagentIds);
|
const searchExpandedSubagentIds = useStore((s) => s.searchExpandedSubagentIds);
|
||||||
const searchCurrentSubagentItemId = useStore((s) => s.searchCurrentSubagentItemId);
|
const searchCurrentSubagentItemId = useStore((s) => s.searchCurrentSubagentItemId);
|
||||||
|
|
@ -196,12 +203,15 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
||||||
// Computed values for metrics
|
// Computed values for metrics
|
||||||
const hasMainImpact = subagent.mainSessionImpact && subagent.mainSessionImpact.totalTokens > 0;
|
const hasMainImpact = subagent.mainSessionImpact && subagent.mainSessionImpact.totalTokens > 0;
|
||||||
const hasIsolated = lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0;
|
const hasIsolated = lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0;
|
||||||
const isolatedTotal = lastUsage
|
const isMultiPhase = phaseData != null && phaseData.compactionCount > 0;
|
||||||
? lastUsage.input_tokens +
|
const isolatedTotal = isMultiPhase
|
||||||
lastUsage.output_tokens +
|
? phaseData.totalConsumption
|
||||||
(lastUsage.cache_read_input_tokens ?? 0) +
|
: lastUsage
|
||||||
(lastUsage.cache_creation_input_tokens ?? 0)
|
? lastUsage.input_tokens +
|
||||||
: 0;
|
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)
|
// Shutdown-only team activations: minimal inline row (no metrics, no expand)
|
||||||
if (isShutdownOnly && teamColors && subagent.team) {
|
if (isShutdownOnly && teamColors && subagent.team) {
|
||||||
|
|
@ -338,6 +348,10 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
||||||
mainSessionImpact={subagent.team ? undefined : subagent.mainSessionImpact}
|
mainSessionImpact={subagent.team ? undefined : subagent.mainSessionImpact}
|
||||||
lastUsage={lastUsage ?? undefined}
|
lastUsage={lastUsage ?? undefined}
|
||||||
isolatedLabel={subagent.team ? 'Context Window' : undefined}
|
isolatedLabel={subagent.team ? 'Context Window' : undefined}
|
||||||
|
isolatedOverride={
|
||||||
|
phaseData && phaseData.compactionCount > 0 ? phaseData.totalConsumption : undefined
|
||||||
|
}
|
||||||
|
phaseBreakdown={phaseData?.phases}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Duration */}
|
{/* Duration */}
|
||||||
|
|
@ -453,7 +467,7 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CircleDot className="size-3" style={{ color: 'rgba(56, 189, 248, 0.7)' }} />
|
<CircleDot className="size-3" style={{ color: 'rgba(56, 189, 248, 0.7)' }} />
|
||||||
<span className="text-xs" style={{ color: COLOR_TEXT_SECONDARY }}>
|
<span className="text-xs" style={{ color: COLOR_TEXT_SECONDARY }}>
|
||||||
{subagent.team ? 'Context Window' : 'Isolated Usage'}
|
{subagent.team ? 'Context Window' : 'Subagent Context'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
|
|
@ -464,6 +478,28 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -253,7 +253,15 @@ export type AIGroupDisplayItem =
|
||||||
| { type: 'subagent'; subagent: Process }
|
| { type: 'subagent'; subagent: Process }
|
||||||
| { type: 'output'; content: string; timestamp: Date; tokenCount?: number }
|
| { type: 'output'; content: string; timestamp: Date; tokenCount?: number }
|
||||||
| { type: 'slash'; slash: SlashItem }
|
| { 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".
|
* The last output in an AI Group - what user sees as "the answer".
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { estimateTokens } from '@shared/utils/tokenFormatting';
|
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';
|
import type { LinkedToolItem } from '../types/groups';
|
||||||
|
|
||||||
const logger = createLogger('Util:aiGroupHelpers');
|
const logger = createLogger('Util:aiGroupHelpers');
|
||||||
|
|
@ -98,3 +98,111 @@ export function attachMainSessionImpact(
|
||||||
}
|
}
|
||||||
return subagents;
|
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);
|
return toDate(item.slash.timestamp);
|
||||||
case 'teammate_message':
|
case 'teammate_message':
|
||||||
return toDate(item.teammateMessage.timestamp);
|
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)
|
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
|
// 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);
|
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)
|
// Check for teammate messages (non-meta user messages with <teammate-message> content)
|
||||||
// One user message may contain multiple <teammate-message> blocks
|
// One user message may contain multiple <teammate-message> blocks
|
||||||
if (msg.type === 'user' && !msg.isMeta) {
|
if (msg.type === 'user' && !msg.isMeta) {
|
||||||
|
|
@ -354,6 +423,16 @@ export function buildDisplayItemsFromMessages(
|
||||||
}
|
}
|
||||||
continue;
|
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)) {
|
if (msg.type === 'assistant' && Array.isArray(msg.content)) {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ export function buildSummary(items: AIGroupDisplayItem[]): string {
|
||||||
subagent: 0,
|
subagent: 0,
|
||||||
slash: 0,
|
slash: 0,
|
||||||
teammate_message: 0,
|
teammate_message: 0,
|
||||||
|
subagent_input: 0,
|
||||||
|
compact_boundary: 0,
|
||||||
};
|
};
|
||||||
const teammateNames = new Set<string>();
|
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'}`
|
`${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';
|
return parts.length > 0 ? parts.join(', ') : 'No items';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue