From 4982dbbfdd591edd195c1d4fb8ea945eaec342c0 Mon Sep 17 00:00:00 2001 From: OpeOginni Date: Fri, 22 May 2026 19:26:05 +0200 Subject: [PATCH] feat(desktop): stable session totals and counts from stored server values --- .../src/components/session-context-usage.tsx | 31 ++++++----- .../session/session-context-metrics.test.ts | 55 +++++++++++-------- .../session/session-context-metrics.ts | 48 ++++++---------- .../session/session-context-tab.tsx | 18 +++--- 4 files changed, 74 insertions(+), 78 deletions(-) diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index 1f65e9adb3c7..02abb52b8b8b 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -8,7 +8,7 @@ import { useLayout } from "@/context/layout" import { useSync } from "@/context/sync" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" -import { getSessionContextMetrics } from "@/components/session/session-context-metrics" +import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" @@ -43,6 +43,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), }) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const usd = createMemo( () => @@ -52,10 +53,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) { }), ) - const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()])) - const context = createMemo(() => metrics().context) + const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) + const tokens = createMemo(() => info()?.tokens) const cost = createMemo(() => { - return usd().format(metrics().totalCost) + return usd().format(info()?.cost ?? 0) }) const openContext = () => { @@ -80,18 +81,20 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const tooltipValue = () => (
+ + {(value) => ( +
+ {getSessionTokenTotal(value())?.toLocaleString(language.intl())} + {language.t("context.usage.tokens")} +
+ )} +
{(ctx) => ( - <> -
- {ctx().total.toLocaleString(language.intl())} - {language.t("context.usage.tokens")} -
-
- {ctx().usage ?? 0}% - {language.t("context.usage.usage")} -
- +
+ {ctx().usage ?? 0}% + {language.t("context.usage.usage")} +
)}
diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts index 0e109a71bdac..5b11bf143393 100644 --- a/packages/app/src/components/session/session-context-metrics.test.ts +++ b/packages/app/src/components/session/session-context-metrics.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { Message } from "@opencode-ai/sdk/v2/client" -import { getSessionContextMetrics } from "./session-context-metrics" +import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics" const assistant = ( id: string, @@ -37,8 +37,8 @@ const user = (id: string) => { } as unknown as Message } -describe("getSessionContextMetrics", () => { - test("computes totals and usage from latest assistant with tokens", () => { +describe("getSessionContext", () => { + test("computes usage from latest assistant with tokens", () => { const messages = [ user("u1"), assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5), @@ -57,45 +57,52 @@ describe("getSessionContextMetrics", () => { }, ] - const metrics = getSessionContextMetrics(messages, providers) + const ctx = getSessionContext(messages, providers) - expect(metrics.totalCost).toBe(1.75) - expect(metrics.context?.message.id).toBe("a2") - expect(metrics.context?.total).toBe(500) - expect(metrics.context?.usage).toBe(50) - expect(metrics.context?.providerLabel).toBe("OpenAI") - expect(metrics.context?.modelLabel).toBe("GPT-4.1") + expect(ctx?.message.id).toBe("a2") + expect(ctx?.usage).toBe(50) + expect(ctx?.providerLabel).toBe("OpenAI") + expect(ctx?.modelLabel).toBe("GPT-4.1") }) test("preserves fallback labels and null usage when model metadata is missing", () => { const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")] const providers = [{ id: "p-1", models: {} }] - const metrics = getSessionContextMetrics(messages, providers) + const ctx = getSessionContext(messages, providers) - expect(metrics.context?.providerLabel).toBe("p-1") - expect(metrics.context?.modelLabel).toBe("m-1") - expect(metrics.context?.limit).toBeUndefined() - expect(metrics.context?.usage).toBeNull() + expect(ctx?.providerLabel).toBe("p-1") + expect(ctx?.modelLabel).toBe("m-1") + expect(ctx?.limit).toBeUndefined() + expect(ctx?.usage).toBeNull() }) test("recomputes when message array is mutated in place", () => { const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)] const providers = [{ id: "openai", models: {} }] - const one = getSessionContextMetrics(messages, providers) + const one = getSessionContext(messages, providers) messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75)) - const two = getSessionContextMetrics(messages, providers) + const two = getSessionContext(messages, providers) - expect(one.context?.message.id).toBe("a1") - expect(two.context?.message.id).toBe("a2") - expect(two.totalCost).toBe(1) + expect(one?.message.id).toBe("a1") + expect(two?.message.id).toBe("a2") }) - test("returns empty metrics when inputs are undefined", () => { - const metrics = getSessionContextMetrics(undefined, undefined) + test("returns undefined when inputs are undefined", () => { + const ctx = getSessionContext(undefined, undefined) - expect(metrics.totalCost).toBe(0) - expect(metrics.context).toBeUndefined() + expect(ctx).toBeUndefined() + }) + + test("computes stored session token totals", () => { + expect( + getSessionTokenTotal({ + input: 10, + output: 20, + reasoning: 30, + cache: { read: 40, write: 50 }, + }), + ).toBe(150) }) }) diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts index 0789b05f1730..84cc984abfab 100644 --- a/packages/app/src/components/session/session-context-metrics.ts +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client" +import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client" type Provider = { id: string @@ -21,19 +21,9 @@ type Context = { modelLabel: string limit: number | undefined input: number - output: number - reasoning: number - cacheRead: number - cacheWrite: number - total: number usage: number | null } -type Metrics = { - totalCost: number - context: Context | undefined -} - const tokenTotal = (msg: AssistantMessage) => { return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write } @@ -47,10 +37,9 @@ const lastAssistantWithTokens = (messages: Message[]) => { } } -const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => { - const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0) +const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => { const message = lastAssistantWithTokens(messages) - if (!message) return { totalCost, context: undefined } + if (!message) return undefined const provider = providers.find((item) => item.id === message.providerID) const model = provider?.models[message.modelID] @@ -58,25 +47,22 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => const total = tokenTotal(message) return { - totalCost, - context: { - message, - provider, - model, - providerLabel: provider?.name ?? message.providerID, - modelLabel: model?.name ?? message.modelID, - limit, - input: message.tokens.input, - output: message.tokens.output, - reasoning: message.tokens.reasoning, - cacheRead: message.tokens.cache.read, - cacheWrite: message.tokens.cache.write, - total, - usage: limit ? Math.round((total / limit) * 100) : null, - }, + message, + provider, + model, + providerLabel: provider?.name ?? message.providerID, + modelLabel: model?.name ?? message.modelID, + limit, + input: message.tokens.input, + usage: limit ? Math.round((total / limit) * 100) : null, } } -export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) { +export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) { return build(messages, providers) } + +export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) { + if (!tokens) return undefined + return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write +} diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 88c3889858c8..d11b0416c87f 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -14,7 +14,7 @@ import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" import { useSessionLayout } from "@/pages/session/session-layout" -import { getSessionContextMetrics } from "./session-context-metrics" +import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { createSessionContextFormatter } from "./session-context-format" @@ -132,12 +132,12 @@ export function SessionContextTab() { }), ) - const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()])) - const ctx = createMemo(() => metrics().context) + const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) + const tokens = createMemo(() => info()?.tokens) const formatter = createMemo(() => createSessionContextFormatter(language.intl())) const cost = createMemo(() => { - return usd().format(metrics().totalCost) + return usd().format(info()?.cost ?? 0) }) const counts = createMemo(() => { @@ -202,14 +202,14 @@ export function SessionContextTab() { { label: "context.stats.provider", value: providerLabel }, { label: "context.stats.model", value: modelLabel }, { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) }, - { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) }, + { label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) }, { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) }, - { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) }, - { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) }, - { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) }, + { label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) }, + { label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) }, + { label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) }, { label: "context.stats.cacheTokens", - value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`, + value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`, }, { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) }, { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },