From 48ca2fdf43c06ff7a97e0be0528617baea40792a Mon Sep 17 00:00:00 2001 From: Igor Velho Date: Mon, 24 Aug 2026 14:47:01 +0100 Subject: [PATCH] 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; +}