feat: split session and subagent cost under the context meter

This commit is contained in:
Igor Velho
2026-08-24 15:53:16 +01:00
parent ce870b7134
commit 87509a2395
15 changed files with 68 additions and 7 deletions
@@ -194,8 +194,13 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
// Rollup total: own cost plus every descendant subagent's cost, recursively
// (see useSubagentCostRollup). Shown here instead of session.cost alone, so
// spend that ran in a spawned subagent doesn't hide from the reader.
const { totalCost } = useSubagentCostRollup(sessionId);
const { totalCost, ownCost, subagentCost, subagentCount } = useSubagentCostRollup(sessionId);
const cost = totalCost !== null && totalCost > 0 ? totalCost : null;
// The total answers "what has this cost"; the split answers "why is it more
// than the session I am looking at". Only worth a line once subagents exist —
// without them the total *is* the session's own cost and the row would
// restate the number directly above it.
const showCostBreakdown = cost !== null && subagentCount > 0 && subagentCost > 0;
const hasSession = showSession && (usagePercent !== null || cost !== null || Boolean(goalRow));
const hasRepository = showRepository && Boolean(branch || changed || prSummary || attentionLabel);
@@ -224,6 +229,17 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
)}
/>
<WorkStatusMeter percent={usagePercent} color={meterColor} />
{/* Caption, not a row: it explains the figure above it rather
than reporting a reading of its own, so it carries no icon
and no label column. */}
{showCostBreakdown ? (
<p className="mx-1 mb-1 truncate text-[11px] leading-4 text-muted-foreground tabular-nums">
{t('chat.workStatus.cost.breakdown', {
session: formatCost(ownCost),
subagents: formatCost(subagentCost),
})}
</p>
) : null}
</>
) : null}
{/* Below the context readout: the goal is a standing instruction,
@@ -20,6 +20,20 @@ describe('computeRollup', () => {
expect(result.subagentCount).toBe(3);
});
test('splits the total into the session own cost and the subagent share', () => {
const result = computeRollup(sessions, 'root');
expect(result.ownCost).toBe(1);
expect(result.subagentCost).toBe(10);
expect(result.ownCost + result.subagentCost).toBe(result.totalCost);
});
test('reports a zero subagent share for a session with no children', () => {
const result = computeRollup(sessions, 'a1');
expect(result.ownCost).toBe(5);
expect(result.subagentCost).toBe(0);
expect(result.totalCost).toBe(5);
});
test('maps each direct child to its own subtree cost', () => {
const result = computeRollup(sessions, 'root');
expect(result.perChildCost.get('a')).toBe(7);
@@ -5,11 +5,21 @@ import { buildChildrenIndex, computeSubtreeCost } from './subagentCost';
export type SubagentCostRollup = {
totalCost: number | null;
/** The root session's own spend, excluding every subagent. */
ownCost: number;
/** Everything the subagents cost between them: `totalCost - ownCost`. */
subagentCost: number;
subagentCount: number;
perChildCost: Map<string, number>;
};
const EMPTY_ROLLUP: SubagentCostRollup = { totalCost: null, subagentCount: 0, perChildCost: new Map() };
const EMPTY_ROLLUP: SubagentCostRollup = {
totalCost: null,
ownCost: 0,
subagentCost: 0,
subagentCount: 0,
perChildCost: new Map(),
};
function countDescendants(id: string, childrenByParent: Map<string, Session[]>, visited: Set<string>): number {
if (visited.has(id)) return 0;
@@ -34,13 +44,20 @@ export function computeRollup(liveSessions: Session[], sessionId: string | null)
const totalCost = computeSubtreeCost(sessionId, sessionsById, childrenByParent);
const perChildCost = new Map<string, number>();
let subagentCost = 0;
for (const child of childrenByParent.get(sessionId) ?? []) {
perChildCost.set(child.id, computeSubtreeCost(child.id, sessionsById, childrenByParent));
const childSubtree = computeSubtreeCost(child.id, sessionsById, childrenByParent);
perChildCost.set(child.id, childSubtree);
subagentCost += childSubtree;
}
// Derived by subtraction rather than read back off the session, so the split
// always adds up to the total the panel shows even if a cycle guard trimmed
// part of the walk.
const ownCost = totalCost - subagentCost;
const subagentCount = countDescendants(sessionId, childrenByParent, new Set());
return { totalCost, subagentCount, perChildCost };
return { totalCost, ownCost, subagentCost, subagentCount, perChildCost };
}
/**
@@ -5,7 +5,8 @@ import { SessionDialogs } from '@/components/session/SessionDialogs';
import { ChatView } from '@/components/views/ChatView';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useViewportStore } from '@/sync/viewport-store';
import { useSessions, useDirectorySync, useSession, useSessionMessages, useSessionMessagesResolved } from '@/sync/sync-context';
import { useSessions, useDirectorySync, useSessionMessages, useSessionMessagesResolved } from '@/sync/sync-context';
import { useSubagentCostRollup } from '@/components/chat/work-status/useSubagentCostRollup';
import { useConfigStore } from '@/stores/useConfigStore';
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils';
@@ -666,7 +667,9 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
const providers = useConfigStore((state) => state.providers);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const currentSession = useSession(currentSessionId ?? '');
// Same rollup the work-status panel reports, so the header and the panel
// never disagree about what this session has cost.
const { totalCost: sessionTotalCost } = useSubagentCostRollup(currentSessionId ?? null);
const currentSessionMessages = useSessionMessages(currentSessionId ?? '');
const currentSessionMessagesResolved = useSessionMessagesResolved(currentSessionId ?? '');
const quotaResults = useQuotaStore((state) => state.results);
@@ -1023,7 +1026,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
percentage={stableContextUsage.percentage}
contextLimit={stableContextUsage.contextLimit}
outputLimit={stableContextUsage.outputLimit ?? 0}
cost={(currentSession?.cost ?? 0) > 0 ? currentSession?.cost : null}
cost={(sessionTotalCost ?? 0) > 0 ? sessionTotalCost : null}
className="h-9 shrink-0 pl-1 pr-1 typography-ui-label"
valueClassName="font-semibold leading-none"
hideIcon
+1
View File
@@ -3096,6 +3096,7 @@ export const dict = {
'updateDialog.changelog.title': 'Neuigkeiten',
'chat.workStatus.ariaLabel': 'Arbeitsstatus',
'chat.workStatus.context.label': 'Kontext',
'chat.workStatus.cost.breakdown': 'Sitzung {session} · Unteragenten {subagents}',
'chat.workStatus.git.changedFileSingle': '{count} Datei geändert',
'chat.workStatus.git.changedFilePlural': '{count} Dateien geändert',
'chat.workStatus.pr.untitled': 'Pull Request ohne Titel',
+1
View File
@@ -3098,6 +3098,7 @@ export const dict = {
'quota.window.premiumInteractions': 'Premium interactions',
'chat.workStatus.ariaLabel': 'Work status',
'chat.workStatus.context.label': 'Context',
'chat.workStatus.cost.breakdown': 'Session {session} · Subagents {subagents}',
'chat.workStatus.git.changedFileSingle': '{count} file changed',
'chat.workStatus.git.changedFilePlural': '{count} files changed',
'chat.workStatus.pr.untitled': 'Untitled pull request',
+1
View File
@@ -3099,6 +3099,7 @@ export const dict: Record<I18nKey, string> = {
"quota.window.premiumInteractions": "Premium interactions",
'chat.workStatus.ariaLabel': 'Estado del trabajo',
'chat.workStatus.context.label': 'Contexto',
'chat.workStatus.cost.breakdown': "Sesión {session} · Subagentes {subagents}",
'chat.workStatus.git.changedFileSingle': '{count} archivo modificado',
'chat.workStatus.git.changedFilePlural': '{count} archivos modificados',
'chat.workStatus.pr.untitled': 'Pull request sin título',
+1
View File
@@ -3096,6 +3096,7 @@ export const dict = {
'vscodeLayout.actions.cancel': 'Annuler',
'chat.workStatus.ariaLabel': 'État du travail',
'chat.workStatus.context.label': 'Contexte',
'chat.workStatus.cost.breakdown': 'Session {session} · Sous-agents {subagents}',
'chat.workStatus.git.changedFileSingle': '{count} fichier modifié',
'chat.workStatus.git.changedFilePlural': '{count} fichiers modifiés',
'chat.workStatus.pr.untitled': 'Pull request sans titre',
+1
View File
@@ -3098,6 +3098,7 @@ export const dict: Record<I18nKey, string> = {
'chat.workStatus.ariaLabel': '作業状況',
'chat.workStatus.context.label': 'コンテキスト',
'chat.workStatus.cost.breakdown': 'セッション {session} · サブエージェント {subagents}',
'chat.workStatus.git.changedFileSingle': '{count} 件のファイルを変更',
'chat.workStatus.git.changedFilePlural': '{count} 件のファイルを変更',
'chat.workStatus.pr.untitled': 'タイトルなしのプルリクエスト',
+1
View File
@@ -3098,6 +3098,7 @@ export const dict: Record<I18nKey, string> = {
'quota.window.premiumInteractions': 'Premium interactions',
'chat.workStatus.ariaLabel': '작업 상태',
'chat.workStatus.context.label': '컨텍스트',
'chat.workStatus.cost.breakdown': '세션 {session} · 서브 에이전트 {subagents}',
'chat.workStatus.git.changedFileSingle': '파일 {count}개 변경됨',
'chat.workStatus.git.changedFilePlural': '파일 {count}개 변경됨',
'chat.workStatus.pr.untitled': '제목 없는 풀 리퀘스트',
+1
View File
@@ -3115,6 +3115,7 @@ export const dict: Record<I18nKey, string> = {
'quota.window.premiumInteractions': 'Premium interactions',
'chat.workStatus.ariaLabel': 'Stan pracy',
'chat.workStatus.context.label': 'Kontekst',
'chat.workStatus.cost.breakdown': 'Sesja {session} · Podagenci {subagents}',
'chat.workStatus.git.changedFileSingle': 'Zmieniono {count} plik',
'chat.workStatus.git.changedFilePlural': 'Zmieniono {count} plików',
'chat.workStatus.pr.untitled': 'Pull request bez tytułu',
@@ -3099,6 +3099,7 @@ export const dict: Record<I18nKey, string> = {
"quota.window.premiumInteractions": "Premium interactions",
'chat.workStatus.ariaLabel': 'Status do trabalho',
'chat.workStatus.context.label': 'Contexto',
'chat.workStatus.cost.breakdown': "Sessão {session} · Subagentes {subagents}",
'chat.workStatus.git.changedFileSingle': '{count} arquivo alterado',
'chat.workStatus.git.changedFilePlural': '{count} arquivos alterados',
'chat.workStatus.pr.untitled': 'Pull request sem título',
+1
View File
@@ -3099,6 +3099,7 @@ export const dict: Record<I18nKey, string> = {
"quota.window.premiumInteractions": "Premium interactions",
'chat.workStatus.ariaLabel': 'Стан роботи',
'chat.workStatus.context.label': 'Контекст',
'chat.workStatus.cost.breakdown': "Сеанс {session} · Субагенти {subagents}",
'chat.workStatus.git.changedFileSingle': 'Змінено {count} файл',
'chat.workStatus.git.changedFilePlural': 'Змінено {count} файлів',
'chat.workStatus.pr.untitled': 'Pull request без назви',
@@ -3099,6 +3099,7 @@ export const dict: Record<I18nKey, string> = {
'quota.window.premiumInteractions': 'Premium interactions',
'chat.workStatus.ariaLabel': '工作状态',
'chat.workStatus.context.label': '上下文',
'chat.workStatus.cost.breakdown': '会话 {session} · 子智能体 {subagents}',
'chat.workStatus.git.changedFileSingle': '已更改 {count} 个文件',
'chat.workStatus.git.changedFilePlural': '已更改 {count} 个文件',
'chat.workStatus.pr.untitled': '未命名的拉取请求',
@@ -3098,6 +3098,7 @@ export const dict: Record<I18nKey, string> = {
'quota.window.premiumInteractions': 'Premium interactions',
'chat.workStatus.ariaLabel': '工作狀態',
'chat.workStatus.context.label': '上下文',
'chat.workStatus.cost.breakdown': '工作階段 {session} · 子 Agent {subagents}',
'chat.workStatus.git.changedFileSingle': '已變更 {count} 個檔案',
'chat.workStatus.git.changedFilePlural': '已變更 {count} 個檔案',
'chat.workStatus.pr.untitled': '未命名的提取請求',