feat: split session and subagent cost under the context meter
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'タイトルなしのプルリクエスト',
|
||||
|
||||
@@ -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': '제목 없는 풀 리퀘스트',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '未命名的提取請求',
|
||||
|
||||
Reference in New Issue
Block a user