mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-07-10 00:14:27 +00:00
feat(ui): show aggregated total tokens and cost for parent sessions including subagents (#415)
## Summary - Adds three new chips (Total In, Total Out, Total Cost) to the `ContextUsagePanel` that aggregate usage across the parent session and all child sessions (subagents and forks) - Child session messages are proactively loaded to ensure historical data appears without manually navigating to each child - Chips respect the existing `showUsageMetrics` preference toggle and are hidden when the session has no children - Reactive computation is scoped to the current instance to avoid unnecessary re-runs ## Changes | File | Change | |------|--------| | `packages/ui/src/components/session/context-usage-panel.tsx` | Adds `threadTotals` memo, proactive `loadMessages` effect, and three total chips | | `packages/ui/src/lib/i18n/messages/*/settings.ts` (7 locales) | Adds `totalInput`, `totalOutput`, `totalCost` i18n keys | ## Screenshots When viewing a parent session with subagents, the panel shows: ``` [Input] [Output] [Cost] | [Total In] [Total Out] [Total Cost] ``` ## Notes - This PR was primarily AI-generated and has gone through several iterative review and refinement steps - Built and type-checked against `upstream/dev` - Needs review and approval from the CodeNomad team Closes #401 --------- Co-authored-by: Pascal André <pascalandr@gmail.com>
This commit is contained in:
parent
d344ef3132
commit
c9dff33662
13 changed files with 259 additions and 9 deletions
|
|
@ -235,7 +235,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
})
|
||||
|
||||
try {
|
||||
await loadMessages(props.instanceId, sessionId, true)
|
||||
await loadMessages(props.instanceId, sessionId, { force: true })
|
||||
} catch (error) {
|
||||
log.error(`Failed to reload session ${sessionId}:`, error)
|
||||
showToastNotification({ message: t("sessionList.reload.error"), variant: "error" })
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import { createMemo, type Component } from "solid-js"
|
||||
import { getSessionInfo } from "../../stores/sessions"
|
||||
import { createEffect, createMemo, Show, untrack, type Component } from "solid-js"
|
||||
import { getChildSessions, getSessionFamily, getSessionInfo, loadMessages, sessionInfoByInstance } from "../../stores/sessions"
|
||||
import { formatTokenTotal } from "../../lib/formatters"
|
||||
import { computeThreadTotals } from "../../lib/thread-totals"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
import { useConfig } from "../../stores/preferences"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
interface ContextUsagePanelProps {
|
||||
instanceId: string
|
||||
|
|
@ -14,6 +19,8 @@ const chipLabelClass = "uppercase text-[10px] tracking-wide text-muted"
|
|||
|
||||
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const { preferences } = useConfig()
|
||||
const showUsage = createMemo(() => preferences().showUsageMetrics ?? true)
|
||||
const info = createMemo(
|
||||
() =>
|
||||
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||
|
|
@ -29,6 +36,43 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
|||
},
|
||||
)
|
||||
|
||||
const children = createMemo(() => getChildSessions(props.instanceId, props.sessionId))
|
||||
const hasChildren = createMemo(() => children().length > 0)
|
||||
|
||||
const instanceInfoMap = createMemo(() =>
|
||||
sessionInfoByInstance().get(props.instanceId),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (!showUsage()) return
|
||||
const instanceId = props.instanceId
|
||||
const childSessions = children()
|
||||
untrack(() => {
|
||||
for (const child of childSessions) {
|
||||
loadMessages(instanceId, child.id, { skipDiff: true }).catch((error) =>
|
||||
log.error("Failed to load child session messages", {
|
||||
instanceId,
|
||||
sessionId: child.id,
|
||||
error,
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const threadTotals = createMemo(() => {
|
||||
if (!hasChildren()) return { cost: 0, inputTokens: 0, outputTokens: 0, reasoningTokens: 0 }
|
||||
const map = instanceInfoMap()
|
||||
const family = getSessionFamily(props.instanceId, props.sessionId)
|
||||
return computeThreadTotals(family, map)
|
||||
})
|
||||
|
||||
const totalCostDisplay = createMemo(() => `$${threadTotals().cost.toFixed(2)}`)
|
||||
|
||||
const totalInputTokens = createMemo(() => threadTotals().inputTokens)
|
||||
const totalOutputTokens = createMemo(() => threadTotals().outputTokens)
|
||||
const totalReasoningTokens = createMemo(() => threadTotals().reasoningTokens)
|
||||
|
||||
const inputTokens = createMemo(() => info().inputTokens ?? 0)
|
||||
const outputTokens = createMemo(() => info().outputTokens ?? 0)
|
||||
const costValue = createMemo(() => {
|
||||
|
|
@ -53,6 +97,24 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
|||
<span class={chipLabelClass}>{t("contextUsagePanel.labels.cost")}</span>
|
||||
<span class="font-semibold text-primary">{costDisplay()}</span>
|
||||
</div>
|
||||
<Show when={hasChildren() && showUsage()}>
|
||||
<div class={chipClass}>
|
||||
<span class={chipLabelClass}>{t("contextUsagePanel.labels.totalInput")}</span>
|
||||
<span class="font-semibold text-primary">{formatTokenTotal(totalInputTokens())}</span>
|
||||
</div>
|
||||
<div class={chipClass}>
|
||||
<span class={chipLabelClass}>{t("contextUsagePanel.labels.totalOutput")}</span>
|
||||
<span class="font-semibold text-primary">{formatTokenTotal(totalOutputTokens())}</span>
|
||||
</div>
|
||||
<div class={chipClass}>
|
||||
<span class={chipLabelClass}>{t("contextUsagePanel.labels.totalCost")}</span>
|
||||
<span class="font-semibold text-primary">{totalCostDisplay()}</span>
|
||||
</div>
|
||||
<div class={chipClass}>
|
||||
<span class={chipLabelClass}>{t("contextUsagePanel.labels.totalReasoning")}</span>
|
||||
<span class="font-semibold text-primary">{formatTokenTotal(totalReasoningTokens())}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ export const settingsMessages = {
|
|||
"contextUsagePanel.labels.input": "Input",
|
||||
"contextUsagePanel.labels.output": "Output",
|
||||
"contextUsagePanel.labels.cost": "Cost",
|
||||
"contextUsagePanel.labels.totalInput": "Total In",
|
||||
"contextUsagePanel.labels.totalOutput": "Total Out",
|
||||
"contextUsagePanel.labels.totalCost": "Total Cost",
|
||||
"contextUsagePanel.labels.totalReasoning": "Total Reasoning",
|
||||
"contextUsagePanel.labels.used": "Used",
|
||||
"contextUsagePanel.labels.available": "Avail",
|
||||
"contextUsagePanel.unavailable": "--",
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ export const settingsMessages = {
|
|||
"contextUsagePanel.labels.input": "Entrada",
|
||||
"contextUsagePanel.labels.output": "Salida",
|
||||
"contextUsagePanel.labels.cost": "Costo",
|
||||
"contextUsagePanel.labels.totalInput": "Entrada total",
|
||||
"contextUsagePanel.labels.totalOutput": "Salida total",
|
||||
"contextUsagePanel.labels.totalCost": "Costo total",
|
||||
"contextUsagePanel.labels.totalReasoning": "Razonamiento total",
|
||||
"contextUsagePanel.labels.used": "Usado",
|
||||
"contextUsagePanel.labels.available": "Disp.",
|
||||
"contextUsagePanel.unavailable": "--",
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ export const settingsMessages = {
|
|||
"contextUsagePanel.labels.input": "Entrée",
|
||||
"contextUsagePanel.labels.output": "Sortie",
|
||||
"contextUsagePanel.labels.cost": "Coût",
|
||||
"contextUsagePanel.labels.totalInput": "Entrée totale",
|
||||
"contextUsagePanel.labels.totalOutput": "Sortie totale",
|
||||
"contextUsagePanel.labels.totalCost": "Coût total",
|
||||
"contextUsagePanel.labels.totalReasoning": "Raisonnement total",
|
||||
"contextUsagePanel.labels.used": "Utilisé",
|
||||
"contextUsagePanel.labels.available": "Dispo",
|
||||
"contextUsagePanel.unavailable": "--",
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ export const settingsMessages = {
|
|||
"contextUsagePanel.labels.input": "קלט",
|
||||
"contextUsagePanel.labels.output": "פלט",
|
||||
"contextUsagePanel.labels.cost": "עלות",
|
||||
"contextUsagePanel.labels.totalInput": "קלט כולל",
|
||||
"contextUsagePanel.labels.totalOutput": "פלט כולל",
|
||||
"contextUsagePanel.labels.totalCost": "עלות כוללת",
|
||||
"contextUsagePanel.labels.totalReasoning": "חשיבה כוללת",
|
||||
"contextUsagePanel.labels.used": "בשימוש",
|
||||
"contextUsagePanel.labels.available": "זמין",
|
||||
"contextUsagePanel.unavailable": "--",
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ export const settingsMessages = {
|
|||
"contextUsagePanel.labels.input": "入力",
|
||||
"contextUsagePanel.labels.output": "出力",
|
||||
"contextUsagePanel.labels.cost": "コスト",
|
||||
"contextUsagePanel.labels.totalInput": "合計入力",
|
||||
"contextUsagePanel.labels.totalOutput": "合計出力",
|
||||
"contextUsagePanel.labels.totalCost": "合計コスト",
|
||||
"contextUsagePanel.labels.totalReasoning": "合計推論",
|
||||
"contextUsagePanel.labels.used": "使用",
|
||||
"contextUsagePanel.labels.available": "残り",
|
||||
"contextUsagePanel.unavailable": "--",
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ export const settingsMessages = {
|
|||
"contextUsagePanel.labels.input": "Ввод",
|
||||
"contextUsagePanel.labels.output": "Вывод",
|
||||
"contextUsagePanel.labels.cost": "Стоимость",
|
||||
"contextUsagePanel.labels.totalInput": "Всего ввод",
|
||||
"contextUsagePanel.labels.totalOutput": "Всего вывод",
|
||||
"contextUsagePanel.labels.totalCost": "Общая стоимость",
|
||||
"contextUsagePanel.labels.totalReasoning": "Всего рассуждений",
|
||||
"contextUsagePanel.labels.used": "Использовано",
|
||||
"contextUsagePanel.labels.available": "Доступно",
|
||||
"contextUsagePanel.unavailable": "--",
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ export const settingsMessages = {
|
|||
"contextUsagePanel.labels.input": "输入",
|
||||
"contextUsagePanel.labels.output": "输出",
|
||||
"contextUsagePanel.labels.cost": "费用",
|
||||
"contextUsagePanel.labels.totalInput": "总输入",
|
||||
"contextUsagePanel.labels.totalOutput": "总输出",
|
||||
"contextUsagePanel.labels.totalCost": "总费用",
|
||||
"contextUsagePanel.labels.totalReasoning": "总推理",
|
||||
"contextUsagePanel.labels.used": "已用",
|
||||
"contextUsagePanel.labels.available": "可用",
|
||||
"contextUsagePanel.unavailable": "--",
|
||||
|
|
|
|||
124
packages/ui/src/lib/thread-totals.test.ts
Normal file
124
packages/ui/src/lib/thread-totals.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import type { SessionInfo } from "../stores/sessions.js"
|
||||
import { computeThreadTotals } from "./thread-totals.js"
|
||||
|
||||
function makeInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
return {
|
||||
cost: 0,
|
||||
contextWindow: 0,
|
||||
isSubscriptionModel: false,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
actualUsageTokens: 0,
|
||||
modelOutputLimit: 0,
|
||||
contextAvailableTokens: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe("computeThreadTotals", () => {
|
||||
it("sums cost, input, output, and reasoning tokens for normal sessions", () => {
|
||||
const family = [{ id: "s1" }, { id: "s2" }]
|
||||
const infoMap = new Map<string, SessionInfo>([
|
||||
["s1", makeInfo({ cost: 0.15, inputTokens: 500, outputTokens: 200, reasoningTokens: 100 })],
|
||||
["s2", makeInfo({ cost: 0.35, inputTokens: 1000, outputTokens: 400, reasoningTokens: 200 })],
|
||||
])
|
||||
|
||||
const totals = computeThreadTotals(family, infoMap)
|
||||
|
||||
assert.equal(totals.cost, 0.5)
|
||||
assert.equal(totals.inputTokens, 1500)
|
||||
assert.equal(totals.outputTokens, 600)
|
||||
assert.equal(totals.reasoningTokens, 300)
|
||||
})
|
||||
|
||||
it("includes tokens but not cost for subscription model sessions", () => {
|
||||
const family = [{ id: "s1" }]
|
||||
const infoMap = new Map<string, SessionInfo>([
|
||||
[
|
||||
"s1",
|
||||
makeInfo({
|
||||
cost: 1.0,
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
reasoningTokens: 25,
|
||||
isSubscriptionModel: true,
|
||||
}),
|
||||
],
|
||||
])
|
||||
|
||||
const totals = computeThreadTotals(family, infoMap)
|
||||
|
||||
assert.equal(totals.cost, 0)
|
||||
assert.equal(totals.inputTokens, 100)
|
||||
assert.equal(totals.outputTokens, 50)
|
||||
assert.equal(totals.reasoningTokens, 25)
|
||||
})
|
||||
|
||||
it("mixes subscription and normal sessions correctly", () => {
|
||||
const family = [{ id: "normal" }, { id: "sub" }]
|
||||
const infoMap = new Map<string, SessionInfo>([
|
||||
[
|
||||
"normal",
|
||||
makeInfo({ cost: 0.1, inputTokens: 200, outputTokens: 100, reasoningTokens: 50 }),
|
||||
],
|
||||
[
|
||||
"sub",
|
||||
makeInfo({
|
||||
cost: 0.5,
|
||||
inputTokens: 300,
|
||||
outputTokens: 150,
|
||||
reasoningTokens: 75,
|
||||
isSubscriptionModel: true,
|
||||
}),
|
||||
],
|
||||
])
|
||||
|
||||
const totals = computeThreadTotals(family, infoMap)
|
||||
|
||||
assert.equal(totals.cost, 0.1)
|
||||
assert.equal(totals.inputTokens, 500)
|
||||
assert.equal(totals.outputTokens, 250)
|
||||
assert.equal(totals.reasoningTokens, 125)
|
||||
})
|
||||
|
||||
it("returns zeros for an empty family", () => {
|
||||
const totals = computeThreadTotals([], undefined)
|
||||
|
||||
assert.equal(totals.cost, 0)
|
||||
assert.equal(totals.inputTokens, 0)
|
||||
assert.equal(totals.outputTokens, 0)
|
||||
assert.equal(totals.reasoningTokens, 0)
|
||||
})
|
||||
|
||||
it("returns zeros when no session info is available", () => {
|
||||
const family = [{ id: "missing" }]
|
||||
|
||||
const totals = computeThreadTotals(family, undefined)
|
||||
|
||||
assert.equal(totals.cost, 0)
|
||||
assert.equal(totals.inputTokens, 0)
|
||||
assert.equal(totals.outputTokens, 0)
|
||||
assert.equal(totals.reasoningTokens, 0)
|
||||
})
|
||||
|
||||
it("treats missing info as zeros", () => {
|
||||
const family = [{ id: "present" }, { id: "missing" }]
|
||||
const infoMap = new Map<string, SessionInfo>([
|
||||
[
|
||||
"present",
|
||||
makeInfo({ cost: 0.1, inputTokens: 100, outputTokens: 50, reasoningTokens: 10 }),
|
||||
],
|
||||
])
|
||||
|
||||
const totals = computeThreadTotals(family, infoMap)
|
||||
|
||||
assert.equal(totals.cost, 0.1)
|
||||
assert.equal(totals.inputTokens, 100)
|
||||
assert.equal(totals.outputTokens, 50)
|
||||
assert.equal(totals.reasoningTokens, 10)
|
||||
})
|
||||
})
|
||||
28
packages/ui/src/lib/thread-totals.ts
Normal file
28
packages/ui/src/lib/thread-totals.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { SessionInfo } from "../stores/sessions"
|
||||
|
||||
export interface ThreadTotals {
|
||||
cost: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
reasoningTokens: number
|
||||
}
|
||||
|
||||
export function computeThreadTotals(
|
||||
family: { id: string }[],
|
||||
infoMap: Map<string, SessionInfo> | undefined,
|
||||
): ThreadTotals {
|
||||
let cost = 0
|
||||
let inputTokens = 0
|
||||
let outputTokens = 0
|
||||
let reasoningTokens = 0
|
||||
for (const session of family) {
|
||||
const sessionInfo = infoMap?.get(session.id)
|
||||
inputTokens += sessionInfo?.inputTokens ?? 0
|
||||
outputTokens += sessionInfo?.outputTokens ?? 0
|
||||
reasoningTokens += sessionInfo?.reasoningTokens ?? 0
|
||||
if (!sessionInfo?.isSubscriptionModel) {
|
||||
cost += sessionInfo?.cost ?? 0
|
||||
}
|
||||
}
|
||||
return { cost, inputTokens, outputTokens, reasoningTokens }
|
||||
}
|
||||
|
|
@ -595,7 +595,14 @@ async function fetchProviders(instanceId: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
async function loadMessages(instanceId: string, sessionId: string, force = false): Promise<void> {
|
||||
async function loadMessages(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
options?: { force?: boolean; skipDiff?: boolean },
|
||||
): Promise<void> {
|
||||
const force = options?.force ?? false
|
||||
const skipDiff = options?.skipDiff ?? false
|
||||
|
||||
if (force) {
|
||||
setMessagesLoaded((prev) => {
|
||||
const next = new Map(prev)
|
||||
|
|
@ -631,10 +638,11 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
|||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
// Fetch session-level diffs in the background once the session is opened.
|
||||
void loadSessionDiff(instanceId, sessionId).catch((error) => {
|
||||
log.warn("Failed to load session diff", { instanceId, sessionId, error })
|
||||
})
|
||||
if (!skipDiff) {
|
||||
void loadSessionDiff(instanceId, sessionId).catch((error) => {
|
||||
log.warn("Failed to load session diff", { instanceId, sessionId, error })
|
||||
})
|
||||
}
|
||||
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
|
|
|
|||
|
|
@ -614,7 +614,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
|
|||
ensureSessionStatus(instanceId, sessionID, "working", (event as any)?.directory)
|
||||
}
|
||||
|
||||
loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload session after compaction", error))
|
||||
loadMessages(instanceId, sessionID, { force: true }).catch((error) => log.error("Failed to reload session after compaction", error))
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionID)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue