diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx index 6b68b0c1..bfe046cf 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx @@ -194,8 +194,13 @@ export const WorkStatusPrimaryGroup: React.FC = ({ 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 = ({ sessionId, directory, )} /> + {/* 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 ? ( +

+ {t('chat.workStatus.cost.breakdown', { + session: formatCost(ownCost), + subagents: formatCost(subagentCost), + })} +

+ ) : null} ) : null} {/* Below the context readout: the goal is a standing instruction, diff --git a/packages/ui/src/components/chat/work-status/useSubagentCostRollup.test.ts b/packages/ui/src/components/chat/work-status/useSubagentCostRollup.test.ts index 9368fe4b..c9d83187 100644 --- a/packages/ui/src/components/chat/work-status/useSubagentCostRollup.test.ts +++ b/packages/ui/src/components/chat/work-status/useSubagentCostRollup.test.ts @@ -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); diff --git a/packages/ui/src/components/chat/work-status/useSubagentCostRollup.ts b/packages/ui/src/components/chat/work-status/useSubagentCostRollup.ts index 189da940..9b21f801 100644 --- a/packages/ui/src/components/chat/work-status/useSubagentCostRollup.ts +++ b/packages/ui/src/components/chat/work-status/useSubagentCostRollup.ts @@ -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; }; -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, visited: Set): 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(); + 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 }; } /** diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index 5ccf8a46..8b1722b2 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -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 = ({ 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 = ({ 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 diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 4f0f3bc2..f10e1620 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index fc9a2717..840ca565 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 17fec35e..5ebfe9e6 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -3099,6 +3099,7 @@ export const dict: Record = { "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', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 0cf10e61..0b2f8d84 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 5796a5b0..4ef723b5 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -3098,6 +3098,7 @@ export const dict: Record = { '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': 'タイトルなしのプルリクエスト', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 80d4f60e..f2574564 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -3098,6 +3098,7 @@ export const dict: Record = { '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': '제목 없는 풀 리퀘스트', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index c29a1dfa..effc87df 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -3115,6 +3115,7 @@ export const dict: Record = { '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', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 5e988ce3..2706e9ba 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -3099,6 +3099,7 @@ export const dict: Record = { "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', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 1f10374f..78c34a67 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -3099,6 +3099,7 @@ export const dict: Record = { "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 без назви', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 2b0c7263..de9afcc6 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -3099,6 +3099,7 @@ export const dict: Record = { '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': '未命名的拉取请求', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 842450c1..68d74e1e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -3098,6 +3098,7 @@ export const dict: Record = { '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': '未命名的提取請求',