From 11582ab62b8c29ef2175bdd7837ae61ac29116c4 Mon Sep 17 00:00:00 2001 From: Igor Velho Date: Mon, 24 Aug 2026 14:45:51 +0100 Subject: [PATCH 1/9] test: add failing test for subagent cost index builder --- .../chat/work-status/subagentCost.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 packages/ui/src/components/chat/work-status/subagentCost.test.ts diff --git a/packages/ui/src/components/chat/work-status/subagentCost.test.ts b/packages/ui/src/components/chat/work-status/subagentCost.test.ts new file mode 100644 index 00000000..be6bf4e1 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/subagentCost.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from 'bun:test'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { buildChildrenIndex } from './subagentCost'; + +function makeSession(id: string, cost: number, parentID?: string): Session { + return { id, cost, parentID } as unknown as Session; +} + +describe('buildChildrenIndex', () => { + test('groups sessions by parentID', () => { + const root = makeSession('root', 1); + const childA = makeSession('a', 2, 'root'); + const childB = makeSession('b', 3, 'root'); + const index = buildChildrenIndex([root, childA, childB]); + expect(index.get('root')).toEqual([childA, childB]); + }); +}); From 48ca2fdf43c06ff7a97e0be0528617baea40792a Mon Sep 17 00:00:00 2001 From: Igor Velho Date: Mon, 24 Aug 2026 14:47:01 +0100 Subject: [PATCH 2/9] feat: add buildChildrenIndex and shared formatCost helper --- .../chat/work-status/subagentCost.test.ts | 10 +++- .../chat/work-status/subagentCost.ts | 57 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/components/chat/work-status/subagentCost.ts diff --git a/packages/ui/src/components/chat/work-status/subagentCost.test.ts b/packages/ui/src/components/chat/work-status/subagentCost.test.ts index be6bf4e1..8c3fbeb3 100644 --- a/packages/ui/src/components/chat/work-status/subagentCost.test.ts +++ b/packages/ui/src/components/chat/work-status/subagentCost.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; import type { Session } from '@opencode-ai/sdk/v2'; -import { buildChildrenIndex } from './subagentCost'; +import { buildChildrenIndex, formatCost } from './subagentCost'; function makeSession(id: string, cost: number, parentID?: string): Session { return { id, cost, parentID } as unknown as Session; @@ -15,3 +15,11 @@ describe('buildChildrenIndex', () => { expect(index.get('root')).toEqual([childA, childB]); }); }); + +describe('formatCost', () => { + test('prefixes with $ and trims trailing zeros', () => { + expect(formatCost(1.5)).toBe('$1.5'); + expect(formatCost(0.0001)).toBe('$0.0001'); + expect(formatCost(2)).toBe('$2'); + }); +}); diff --git a/packages/ui/src/components/chat/work-status/subagentCost.ts b/packages/ui/src/components/chat/work-status/subagentCost.ts new file mode 100644 index 00000000..f02f54e8 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/subagentCost.ts @@ -0,0 +1,57 @@ +import type { Session } from '@opencode-ai/sdk/v2'; + +// Spend is read against a budget, so it keeps its real precision instead of +// collapsing to two decimals. Trailing zeros are dropped so exact values stay +// short. Relocated from WorkStatusPrimaryGroup.tsx so both that component and +// WorkStatusSubagentsSection share one implementation. +const trimZeros = (value: string): string => + (value.includes('.') ? value.replace(/0+$/, '').replace(/\.$/, '') : value); + +export const formatCost = (cost: number): string => `$${trimZeros(cost.toFixed(4))}`; + +/** + * Groups a flat live-session list by parentID. One pass, O(n). Sessions + * without a parentID (roots) are simply absent as keys — callers look up a + * specific id's children via `.get(id) ?? []`. + */ +export function buildChildrenIndex(sessions: Session[]): Map { + const index = new Map(); + for (const session of sessions) { + const parentID = (session as unknown as { parentID?: string }).parentID; + if (!parentID) continue; + const existing = index.get(parentID); + if (existing) { + existing.push(session); + } else { + index.set(parentID, [session]); + } + } + return index; +} + +function sessionCost(session: Session | undefined): number { + const cost = (session as unknown as { cost?: number } | undefined)?.cost; + return typeof cost === 'number' ? cost : 0; +} + +/** + * Own cost plus every descendant's cost, recursively. Cycle-guarded with a + * visited set: parentID should form a tree, but this does not trust that + * invariant blindly (mirrors opencode-session-cost's src/cost.ts). + */ +export function computeSubtreeCost( + id: string, + sessionsById: Map, + childrenByParent: Map, + visited: Set = new Set(), +): number { + if (visited.has(id)) return 0; + visited.add(id); + + let total = sessionCost(sessionsById.get(id)); + const children = childrenByParent.get(id) ?? []; + for (const child of children) { + total += computeSubtreeCost(child.id, sessionsById, childrenByParent, visited); + } + return total; +} From 9ae30bd412c504adc0bb845232a9ae5cacd66896 Mon Sep 17 00:00:00 2001 From: Igor Velho Date: Mon, 24 Aug 2026 14:47:59 +0100 Subject: [PATCH 3/9] test: add computeSubtreeCost coverage (flat, nested, cycle, zero-cost, unknown id) --- .../chat/work-status/subagentCost.test.ts | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/chat/work-status/subagentCost.test.ts b/packages/ui/src/components/chat/work-status/subagentCost.test.ts index 8c3fbeb3..059f2724 100644 --- a/packages/ui/src/components/chat/work-status/subagentCost.test.ts +++ b/packages/ui/src/components/chat/work-status/subagentCost.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; import type { Session } from '@opencode-ai/sdk/v2'; -import { buildChildrenIndex, formatCost } from './subagentCost'; +import { buildChildrenIndex, computeSubtreeCost, formatCost } from './subagentCost'; function makeSession(id: string, cost: number, parentID?: string): Session { return { id, cost, parentID } as unknown as Session; @@ -23,3 +23,50 @@ describe('formatCost', () => { expect(formatCost(2)).toBe('$2'); }); }); + +describe('computeSubtreeCost', () => { + test('sums a flat root with two direct children', () => { + const root = makeSession('root', 1); + const childA = makeSession('a', 2, 'root'); + const childB = makeSession('b', 3, 'root'); + const sessions = [root, childA, childB]; + const sessionsById = new Map(sessions.map((s) => [s.id, s])); + const childrenByParent = buildChildrenIndex(sessions); + expect(computeSubtreeCost('root', sessionsById, childrenByParent)).toBe(6); + }); + + test('rolls up cost through nested descendants', () => { + const root = makeSession('root', 1); + const child = makeSession('child', 2, 'root'); + const grandchild = makeSession('grandchild', 4, 'child'); + const sessions = [root, child, grandchild]; + const sessionsById = new Map(sessions.map((s) => [s.id, s])); + const childrenByParent = buildChildrenIndex(sessions); + expect(computeSubtreeCost('root', sessionsById, childrenByParent)).toBe(7); + expect(computeSubtreeCost('child', sessionsById, childrenByParent)).toBe(6); + }); + + test('does not double-count or infinite-loop on a cycle', () => { + const a = makeSession('a', 1, 'b'); + const b = makeSession('b', 2, 'a'); + const sessions = [a, b]; + const sessionsById = new Map(sessions.map((s) => [s.id, s])); + const childrenByParent = buildChildrenIndex(sessions); + expect(computeSubtreeCost('a', sessionsById, childrenByParent)).toBe(3); + }); + + test('treats zero and undefined cost as zero, not a break', () => { + const root = makeSession('root', 0); + const child = { id: 'child', parentID: 'root' } as unknown as Session; + const sessions = [root, child]; + const sessionsById = new Map(sessions.map((s) => [s.id, s])); + const childrenByParent = buildChildrenIndex(sessions); + expect(computeSubtreeCost('root', sessionsById, childrenByParent)).toBe(0); + }); + + test('returns 0 for an unknown id', () => { + const sessionsById = new Map(); + const childrenByParent = new Map(); + expect(computeSubtreeCost('missing', sessionsById, childrenByParent)).toBe(0); + }); +}); From 7fde0a01b5e8975050663d4216215bdf7ea63e89 Mon Sep 17 00:00:00 2001 From: Igor Velho Date: Mon, 24 Aug 2026 14:50:34 +0100 Subject: [PATCH 4/9] feat: add useSubagentCostRollup hook --- .../work-status/useSubagentCostRollup.test.ts | 46 ++++++++++++++++ .../chat/work-status/useSubagentCostRollup.ts | 54 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 packages/ui/src/components/chat/work-status/useSubagentCostRollup.test.ts create mode 100644 packages/ui/src/components/chat/work-status/useSubagentCostRollup.ts diff --git a/packages/ui/src/components/chat/work-status/useSubagentCostRollup.test.ts b/packages/ui/src/components/chat/work-status/useSubagentCostRollup.test.ts new file mode 100644 index 00000000..9368fe4b --- /dev/null +++ b/packages/ui/src/components/chat/work-status/useSubagentCostRollup.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'bun:test'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { computeRollup } from './useSubagentCostRollup'; + +function makeSession(id: string, cost: number, parentID?: string): Session { + return { id, cost, parentID } as unknown as Session; +} + +const sessions: Session[] = [ + makeSession('root', 1), + makeSession('a', 2, 'root'), + makeSession('b', 3, 'root'), + makeSession('a1', 5, 'a'), +]; + +describe('computeRollup', () => { + test('sums own cost plus every descendant', () => { + const result = computeRollup(sessions, 'root'); + expect(result.totalCost).toBe(11); + expect(result.subagentCount).toBe(3); + }); + + test('maps each direct child to its own subtree cost', () => { + const result = computeRollup(sessions, 'root'); + expect(result.perChildCost.get('a')).toBe(7); + expect(result.perChildCost.get('b')).toBe(3); + }); + + test('returns null total for a null sessionId', () => { + const result = computeRollup(sessions, null); + expect(result.totalCost).toBeNull(); + expect(result.subagentCount).toBe(0); + }); + + test('returns null total for an unknown sessionId', () => { + const result = computeRollup(sessions, 'missing'); + expect(result.totalCost).toBeNull(); + }); + + test('sum of perChildCost plus root cost equals totalCost', () => { + const result = computeRollup(sessions, 'root'); + const childSum = Array.from(result.perChildCost.values()).reduce((sum, v) => sum + v, 0); + const rootOwnCost = 1; + expect(childSum + rootOwnCost).toBe(result.totalCost); + }); +}); diff --git a/packages/ui/src/components/chat/work-status/useSubagentCostRollup.ts b/packages/ui/src/components/chat/work-status/useSubagentCostRollup.ts new file mode 100644 index 00000000..189da940 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/useSubagentCostRollup.ts @@ -0,0 +1,54 @@ +import React from 'react'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { useAllLiveSessions } from '@/sync/sync-context'; +import { buildChildrenIndex, computeSubtreeCost } from './subagentCost'; + +export type SubagentCostRollup = { + totalCost: number | null; + subagentCount: number; + perChildCost: Map; +}; + +const EMPTY_ROLLUP: SubagentCostRollup = { totalCost: null, subagentCount: 0, perChildCost: new Map() }; + +function countDescendants(id: string, childrenByParent: Map, visited: Set): number { + if (visited.has(id)) return 0; + visited.add(id); + const kids = childrenByParent.get(id) ?? []; + let count = kids.length; + for (const kid of kids) count += countDescendants(kid.id, childrenByParent, visited); + return count; +} + +/** + * Pure core of useSubagentCostRollup, kept separate so it can be unit-tested + * directly against a plain session array instead of rendering the hook. + */ +export function computeRollup(liveSessions: Session[], sessionId: string | null): SubagentCostRollup { + if (!sessionId) return EMPTY_ROLLUP; + + const sessionsById = new Map(liveSessions.map((session) => [session.id, session])); + if (!sessionsById.has(sessionId)) return EMPTY_ROLLUP; + + const childrenByParent = buildChildrenIndex(liveSessions); + const totalCost = computeSubtreeCost(sessionId, sessionsById, childrenByParent); + + const perChildCost = new Map(); + for (const child of childrenByParent.get(sessionId) ?? []) { + perChildCost.set(child.id, computeSubtreeCost(child.id, sessionsById, childrenByParent)); + } + + const subagentCount = countDescendants(sessionId, childrenByParent, new Set()); + + return { totalCost, subagentCount, perChildCost }; +} + +/** + * Own cost plus every descendant subagent's cost, recursively summed, for a + * given root session. Reads the same `useAllLiveSessions()` subscription + * WorkStatusSubagentsSection already holds — no new store subscription. + */ +export function useSubagentCostRollup(sessionId: string | null): SubagentCostRollup { + const liveSessions = useAllLiveSessions(); + return React.useMemo(() => computeRollup(liveSessions, sessionId), [liveSessions, sessionId]); +} From a41788d7dcb8dcf65e093c2951ce4b7ed6f4b0fc Mon Sep 17 00:00:00 2001 From: Igor Velho Date: Mon, 24 Aug 2026 14:53:42 +0100 Subject: [PATCH 5/9] feat: show subagent-inclusive cost total in WorkStatusPrimaryGroup --- .../chat/work-status/WorkStatusPrimaryGroup.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx index 4db88c6c..6b68b0c1 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx @@ -4,7 +4,7 @@ import { useGitStore } from '@/stores/useGitStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { runBackgroundNetworkTask } from '@/lib/background-network'; import { useFreshestPrVisualSummaryForBranch } from '@/stores/useGitHubPrStatusStore'; -import { useSession, useSessionMessages } from '@/sync/sync-context'; +import { useSessionMessages } from '@/sync/sync-context'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; @@ -14,6 +14,8 @@ import { resolveUsageTone } from '@/lib/quota'; import { sessionEvents } from '@/lib/sessionEvents'; import { normalizePath } from '@/lib/pathNormalization'; import { computeContextUsage } from './contextUsage'; +import { formatCost } from './subagentCost'; +import { useSubagentCostRollup } from './useSubagentCostRollup'; import { WorkStatusCallout, WorkStatusMeter, @@ -33,11 +35,6 @@ type Props = { showRepository: boolean; }; -// Spend is read against a budget, so it keeps its real precision instead of -// collapsing to two decimals. Trailing zeros are dropped so exact values stay -// short. -const trimZeros = (value: string): string => (value.includes('.') ? value.replace(/0+$/, '').replace(/\.$/, '') : value); -const formatCost = (cost: number): string => `$${trimZeros(cost.toFixed(4))}`; // Matches the header readout exactly: one decimal, capped the same way, so the // two places that report context fill never disagree by a rounding step. const formatPercent = (percent: number): string => `${Math.min(percent, 999).toFixed(1)}%`; @@ -49,7 +46,6 @@ const formatPercent = (percent: number): string => `${Math.min(percent, 999).toF */ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, goalRow, showSession, showRepository }) => { const { t } = useI18n(); - const session = useSession(sessionId ?? '', directory ?? undefined); const { git } = useRuntimeAPIs(); const ensureStatus = useGitStore((state) => state.ensureStatus); const fetchStatus = useGitStore((state) => state.fetchStatus); @@ -195,7 +191,11 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, : usageTone === 'warn' ? 'var(--status-warning)' : 'var(--status-success)'; - const cost = typeof session?.cost === 'number' && session.cost > 0 ? session.cost : null; + // 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 cost = totalCost !== null && totalCost > 0 ? totalCost : null; const hasSession = showSession && (usagePercent !== null || cost !== null || Boolean(goalRow)); const hasRepository = showRepository && Boolean(branch || changed || prSummary || attentionLabel); From 9db9d402834af173f31c49d93e74863f51ff0ba8 Mon Sep 17 00:00:00 2001 From: Igor Velho Date: Mon, 24 Aug 2026 14:55:35 +0100 Subject: [PATCH 6/9] feat: show per-subagent subtree cost in WorkStatusSubagentsSection --- .../WorkStatusSubagentsSection.tsx | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/chat/work-status/WorkStatusSubagentsSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusSubagentsSection.tsx index 6c354e76..f37ab1c5 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusSubagentsSection.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusSubagentsSection.tsx @@ -7,6 +7,8 @@ import { isVSCodeRuntime } from '@/lib/desktop'; import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat'; import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives'; import { useReportWorkStatusPresence } from './presenceContext'; +import { formatCost } from './subagentCost'; +import { useSubagentCostRollup } from './useSubagentCostRollup'; import type { State } from '@/sync/types'; type Props = { @@ -32,6 +34,11 @@ export const WorkStatusSubagentsSection: React.FC = ({ sessionId, directo [liveSessions, sessionId], ); + // Each child's own subtree total (its cost plus every descendant of its + // own), so nested subagent-of-subagent cost rolls up under the immediate + // child row shown here rather than disappearing. + const { perChildCost } = useSubagentCostRollup(sessionId); + // One subscription covers every child: per-session hooks would multiply // store subscriptions by the number of subagents. const permissions = useDirectorySync(React.useCallback((state: State) => state.permission, [])); @@ -88,20 +95,26 @@ export const WorkStatusSubagentsSection: React.FC = ({ sessionId, directo const asked = (questions[child.id]?.length ?? 0) > 0; const busy = statuses[child.id]?.type === 'busy'; const label = child.title?.trim() || t('chat.workStatus.subagent.untitled'); + const childCost = perChildCost.get(child.id) ?? 0; return ( openChildSession(child.id, label) : undefined} ariaLabel={t('chat.workStatus.action.openSubagent', { name: label })} label={label} - value={blocked ? ( - {t('chat.workStatus.subagent.needsPermission')} - ) : asked ? ( - {t('chat.workStatus.subagent.askedQuestion')} - ) : busy ? ( - {t('chat.workStatus.subagent.working')} - ) : ( - {t('chat.workStatus.subagent.done')} + value={( + <> + {blocked ? ( + {t('chat.workStatus.subagent.needsPermission')} + ) : asked ? ( + {t('chat.workStatus.subagent.askedQuestion')} + ) : busy ? ( + {t('chat.workStatus.subagent.working')} + ) : ( + {t('chat.workStatus.subagent.done')} + )} + {childCost > 0 ? {formatCost(childCost)} : null} + )} /> ); From ce870b713498822760ab277d495a29c2e57cd037 Mon Sep 17 00:00:00 2001 From: Igor Velho Date: Mon, 24 Aug 2026 14:56:31 +0100 Subject: [PATCH 7/9] docs: document subagent cost rollup data flow --- packages/ui/src/components/chat/work-status/DOCUMENTATION.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md index 0c310f50..f23be5cc 100644 --- a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md @@ -95,11 +95,11 @@ which requests only providers enabled for this panel. | Block | Source | Notes | |---|---|---| -| Context + cost | `contextUsage.ts` over `useSessionMessages`, `Session.cost` | see below — the store getters cannot serve this | +| Context + cost | `contextUsage.ts` over `useSessionMessages`; cost via `useSubagentCostRollup` (own cost + every descendant subagent, recursively) | see below — the store getters cannot serve this | | Branch, ahead/behind, attention | `useGitStore` directory state | warmed via `runBackgroundNetworkTask(ensureStatus)` and refreshed from Git mutation hints | | Changed files | `useGitStore` status `files` + `diffStats` | working tree, not session-authored edits | | PR + checks | `useFreshestPrVisualSummaryForBranch` | **read-only**; follows the freshest remote-keyed entry for the branch | -| Subagents | child sessions from `useAllLiveSessions` (`parentID`) + `useAllSessionStatuses` | | +| Subagents | child sessions from `useAllLiveSessions` (`parentID`) + `useAllSessionStatuses`; per-row cost from `useSubagentCostRollup`'s `perChildCost` (each child's own subtree total, so nested subagent-of-subagent cost rolls up under its immediate parent row) | | | Subagent blockers | directory `permission` / `question` maps | one subscription covers every child | | Usage | `components/usage/usageGroups.ts` over `useQuotaStore` | grouping shared with the mobile popover; presentation is not | | Linked threads | `lib/linkedIssues.ts` over session metadata | written by the flows that attach an issue or PR | From 87509a23951c155702ce7e8dbe6423e8b4459131 Mon Sep 17 00:00:00 2001 From: Igor Velho Date: Mon, 24 Aug 2026 15:53:16 +0100 Subject: [PATCH 8/9] feat: split session and subagent cost under the context meter --- .../work-status/WorkStatusPrimaryGroup.tsx | 18 ++++++++++++++- .../work-status/useSubagentCostRollup.test.ts | 14 +++++++++++ .../chat/work-status/useSubagentCostRollup.ts | 23 ++++++++++++++++--- .../ui/src/components/layout/VSCodeLayout.tsx | 9 +++++--- packages/ui/src/lib/i18n/messages/de.ts | 1 + packages/ui/src/lib/i18n/messages/en.ts | 1 + packages/ui/src/lib/i18n/messages/es.ts | 1 + packages/ui/src/lib/i18n/messages/fr.ts | 1 + packages/ui/src/lib/i18n/messages/ja.ts | 1 + packages/ui/src/lib/i18n/messages/ko.ts | 1 + packages/ui/src/lib/i18n/messages/pl.ts | 1 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 1 + packages/ui/src/lib/i18n/messages/uk.ts | 1 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 1 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 1 + 15 files changed, 68 insertions(+), 7 deletions(-) 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': '未命名的提取請求', From 4247a7ed8ed0204adaf9c353f1a192ce464631aa Mon Sep 17 00:00:00 2001 From: Igor Velho Date: Mon, 24 Aug 2026 15:56:00 +0100 Subject: [PATCH 9/9] docs: changelog entries for subagent cost rollup --- CHANGELOG.md | 1 + packages/vscode/CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06bbfa1a..2369b834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Work status: the session cost now counts what its subagents spent, with a line under the context meter splitting the session's own cost from the subagents' share, and each subagent's cost shown next to it in the Subagents list. Previously a session that delegated most of its work looked far cheaper than it was. - **Chat context attachments:** everything you attach to a message — diff/file/plan comments, terminal selections, browser annotations, PR comments and failed checks, linked issues and PRs — now shows up in the conversation as a compact context card: a header naming the source, the captured content behind an expander, and your comment below it. Previously most of these arrived as a wall of raw text inside your message. - **Chat: comment on a reply.** Select text in a chat message and choose Comment to attach that quote with your note to the next message. The selection stays highlighted while you type, and the selection menu itself was restyled — Add to chat is now Add to input. - **Diff: comment like a review.** Hovering a line shows a + button in the gutter; clicking it, clicking a line, or dragging across lines opens the comment editor for that line or range. The comment editor and saved-comment cards now match the chat's comment style. diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 2c7f9eef..6aee14d0 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,5 +1,6 @@ ## [Unreleased] +- The context usage readout now reports the session cost including everything its subagents spent, matching the work status panel instead of showing a lower figure. - **Chat context attachments:** diff and file comments, terminal selections, and linked issues/PRs now show in the conversation as compact context cards — source header, captured content behind an expander, your comment below — instead of raw text inside the message. - **Chat: comment on a reply.** Select text in a chat message and choose Comment to attach that quote with your note to the next message; the selection stays highlighted while you type. Add to chat is now Add to input. - Diff: hovering a line shows a + button that opens a comment for the line; clicking a line or dragging across lines opens the editor for that range. The comment editor and saved-comment cards match the chat's comment style.