Skip to content

Feature: Display Session Cost in Web UI#416

Merged
ColeMurray merged 7 commits intoColeMurray:mainfrom
MartinRoberts-Fountain:feature/opencode-session-cost
Apr 10, 2026
Merged

Feature: Display Session Cost in Web UI#416
ColeMurray merged 7 commits intoColeMurray:mainfrom
MartinRoberts-Fountain:feature/opencode-session-cost

Conversation

@MartinRoberts-Fountain
Copy link
Copy Markdown
Contributor

@MartinRoberts-Fountain MartinRoberts-Fountain commented Mar 26, 2026

Adding a feature to display the session cost in the web interface
image

Summary by CodeRabbit

  • New Features

    • Real-time session cost tracking: per-step costs are recorded and accumulated into a persistent session total.
    • Session cost visibility: sessions show an accumulated, currency-formatted total in the sidebar.
  • Chores

    • Added persistent storage and migration to store the session total cost.
  • Tests

    • Added unit tests for cost recording, broadcasting, persistence, and cost-formatting utility.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 26, 2026

Greptile Summary

This PR adds end-to-end support for displaying the cumulative session cost in the web UI, covering backend persistence (persisting step_finish events so cost data survives page reload/replay), a new session-cost utility library, and a UI row in the metadata sidebar.

The implementation is clean and well-tested. One functional bug was found:

  • Double $ sign in the cost display (metadata-section.tsx lines 103–107): the <span>$</span> icon element and formatSessionCost (which already returns a $-prefixed string) both emit a dollar sign, so the rendered text becomes $ OpenCode cost $0.0168. Either the icon $ or the $ inside formatSessionCost's output needs to be removed.

A minor edge case also exists in formatSessionCost: for very small costs (e.g. $0.00001), toFixed(4) rounds down to "$0.0000", misleadingly showing zero cost.

Important Files Changed

Filename Overview
packages/web/src/lib/session-cost.ts New utility for summing and formatting session cost from step_finish events. Logic is correct and well-guarded (finite-number check), but formatSessionCost can produce "$0.0000" for very small sub-cent costs.
packages/web/src/lib/session-cost.test.ts Good test coverage for both utilities: correctly verifies non-step_finish events are ignored, missing-cost events are skipped, and both formatting branches are exercised.
packages/web/src/components/sidebar/metadata-section.tsx New totalCost prop wired up and displayed, but the dollar-sign icon element combined with formatSessionCost (which also returns a $-prefixed string) causes a double $ in the rendered output.
packages/web/src/components/session-right-sidebar.tsx Correctly memoises the total cost computation and passes it as undefined when zero, preventing unnecessary re-renders and suppressing the cost row when no cost data exists.
packages/control-plane/src/session/sandbox-events.ts Persists step_finish events so replay can reconstruct session cost. Consistent with the existing fire-and-forget pattern for other createEvent calls in the same file.
packages/control-plane/src/session/sandbox-events.test.ts New test properly verifies both persistence and broadcast behaviour for step_finish events.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: packages/web/src/components/sidebar/metadata-section.tsx
Line: 103-107

Comment:
**Dollar sign displayed twice**

The `<span className="w-4 text-center">$</span>` element renders a `$` character as a visual icon, but `formatSessionCost(totalCost)` (in `session-cost.ts` line 13) already returns a string prefixed with `$` — e.g. `"$0.0168"`. The result is the cost rendering as `$ OpenCode cost $0.0168`.

Either remove the `$` icon span and rely on `formatSessionCost`'s output, or keep the icon span and strip the `$` from the formatted value:

```suggestion
      {typeof totalCost === "number" && totalCost > 0 && (
        <div className="flex items-center gap-2 text-sm text-muted-foreground">
          <span className="w-4 text-center">$</span>
          <span>Session cost: {totalCost >= 1 ? totalCost.toFixed(2) : totalCost.toFixed(4)}</span>
        </div>
      )}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: packages/web/src/lib/session-cost.ts
Line: 11-14

Comment:
**No handling for costs just above zero but effectively zero**

`formatSessionCost` uses a hard threshold of `>= 1` to switch between 2 and 4 decimal places. For very small costs (e.g. `$0.00001`), `toFixed(4)` rounds to `"$0.0000"`, misleadingly suggesting the session was free. Consider using `toPrecision` for sub-cent amounts:

```suggestion
export function formatSessionCost(cost: number): string {
  if (cost >= 1) return `$${cost.toFixed(2)}`;
  if (cost >= 0.01) return `$${cost.toFixed(4)}`;
  return `$${cost.toPrecision(2)}`;
}
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "Display Session Cost in Web UI" | Re-trigger Greptile

Comment thread packages/web/src/components/sidebar/metadata-section.tsx
Comment thread packages/web/src/lib/session-cost.ts
MartinRoberts-Fountain and others added 3 commits March 26, 2026 11:18
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
@ColeMurray
Copy link
Copy Markdown
Owner

Architectural feedback: persist cost on SessionRow, not as events

The current approach persists every step_finish event to the events table, then the web client sums event.cost across all of them. I'd recommend moving cost to a total_cost column on SessionRow instead. Here's the reasoning:

Why not persist step_finish events?

  • Event table bloatstep_finish events are deliberately broadcast-only today (like step_start). There can be 10-30+ per user message in a multi-tool agentic task. Persisting them all to derive a scalar sum is wasteful.
  • Replay payload inflation — persisted step_finish events eat into the 500-event replay window, pushing out actually useful events (tool_call, tool_result, etc.).
  • O(n) client computationgetTotalSessionCost iterates the full events array on every change. As sessions grow to hundreds of events, this adds up.
  • Cost unavailable in session list — if you ever want to show cost on the dashboard, you'd need to load all events for every session, which is a non-starter.

Why SessionRow is the right fit

  • Cost is session-level metadata — it's a running aggregate like status or updated_at. It belongs alongside those fields.
  • Cheap to maintain — on each step_finish, just UPDATE session SET total_cost = total_cost + ? WHERE .... Same DO, same SQLite, atomic, sub-millisecond.
  • No D1 migration neededSessionRow is per-DO SQLite (defined in schema.ts), so just add the column to the CREATE TABLE statement. Existing DOs pick it up on next schema init (default 0).
  • Immediately available in SessionStatehandleSubscribe already reads the session row and sends it to the client as state. Adding totalCost there means the client gets it for free on connect, no event scanning required.

Suggested implementation sketch

-- schema.ts: add to session table
total_cost REAL NOT NULL DEFAULT 0
// sandbox-events.ts: in the step_finish branch (no createEvent call needed)
if (event.type === "step_finish" && typeof event.cost === "number") {
  this.deps.repository.addSessionCost(event.cost);
}

// repository.ts:
addSessionCost(cost: number) {
  this.sql.exec("UPDATE session SET total_cost = total_cost + ? WHERE ...", cost);
}

Then expose totalCost through SessionState (shared types) → the web client reads it from sessionState.totalCost directly, no event scanning needed.

Bugs in the current diff

  1. Duplicate JSX conditional in metadata-section.tsx — the {typeof totalCost === "number" && totalCost > 0 && ( guard line appears twice, which will cause a compile/render error.
  2. formatSessionCost imported but unused — the metadata section imports it but inlines the formatting logic instead. Should use the imported function (it has better handling of sub-penny costs via toPrecision(2)).

The test/utility code in session-cost.ts is well-written and worth keeping for the client-side formatting regardless of where the data comes from.

Copy link
Copy Markdown
Owner

@ColeMurray ColeMurray left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see above architecture suggestion

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 2, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 592371fa-ef71-4ab8-96be-8beb392fa988

📥 Commits

Reviewing files that changed from the base of the PR and between c935f52 and 3f26a25.

📒 Files selected for processing (1)
  • packages/control-plane/src/session/sandbox-events.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/control-plane/src/session/sandbox-events.ts

📝 Walkthrough

Walkthrough

Adds session cost tracking across the stack: DB schema + migration, repository method to accumulate costs, sandbox event handling to record step_finish costs, durable object and socket propagation of totalCost, UI formatting/display, and unit tests.

Changes

Cohort / File(s) Summary
Database Schema
packages/control-plane/src/session/schema.ts
Add total_cost REAL NOT NULL DEFAULT 0 to session table and migration (id: 30) to backfill existing rows.
Repository Layer
packages/control-plane/src/session/repository.ts, packages/control-plane/src/session/repository.test.ts
Add SessionRepository.addSessionCost(cost: number) which runs UPDATE session SET total_cost = total_cost + ? WHERE id = (SELECT id FROM session LIMIT 1); test verifies SQL and parameter.
Sandbox Event Processing
packages/control-plane/src/session/sandbox-events.ts, packages/control-plane/src/session/sandbox-events.test.ts
On step_finish with a finite cost, processor calls repository.addSessionCost(cost); tests cover finite, NaN, and Infinity cases and broadcasting behavior.
Durable Object / Session State
packages/control-plane/src/session/durable-object.ts
getSessionState(...) now includes totalCost sourced from session?.total_cost with default 0.
Types / Shared Contracts
packages/control-plane/src/session/types.ts, packages/shared/src/types/index.ts
Add optional total_cost?: number to SessionRow and optional totalCost?: number to exported SessionState.
Client Socket Handling
packages/web/src/hooks/use-session-socket.ts
On subscribed messages set sessionState.totalCost from incoming state (default 0); on sandbox_event step_finish increment local totalCost when event.cost is finite.
UI Components
packages/web/src/components/session-right-sidebar.tsx, packages/web/src/components/sidebar/metadata-section.tsx
Pass sessionState.totalCost into MetadataSection; MetadataSection conditionally renders a formatted "Session cost" row when totalCost > 0.
Formatting Utility & Tests
packages/web/src/lib/session-cost.ts, packages/web/src/lib/session-cost.test.ts
Add formatSessionCost(cost: number): string with precision rules (>=1 → 2 decimals, >=0.01 → 4 decimals, smaller → toPrecision(2)); tests verify formatting across magnitudes.

Sequence Diagram

sequenceDiagram
    actor Sandbox
    participant EventProc as Event Processor
    participant Repo as Repository
    participant DB as Session DB
    participant Socket
    participant Hook as useSessionSocket
    participant UI as UI Components

    Sandbox->>EventProc: step_finish { cost: N }
    EventProc->>Repo: addSessionCost(N)
    Repo->>DB: UPDATE session SET total_cost = total_cost + N
    DB-->>Repo: OK
    EventProc->>Socket: broadcast({ type: "sandbox_event", event })
    Socket->>Hook: receive sandbox_event
    Hook->>Hook: if finite cost → totalCost = (prev.totalCost ?? 0) + N
    Hook->>UI: set sessionState { totalCost: M }
    UI->>UI: formatSessionCost(M)
    UI->>UI: render "Session cost: $X"
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hopped through rows and added a sum,
I caught each step_finish penny as it came,
From DB tally to a shiny UI frame,
Neat dollars counted, not a single one gone,
A rabbit's ledger hums — behold the running total!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main objective of the PR, which is to add a feature for displaying session cost information in the web UI.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/web/src/lib/session-cost.test.ts (1)

4-16: Consider adding boundary assertions for threshold stability.

Please add exact-threshold checks for Line 2/Line 3 branch boundaries (cost === 1 and cost === 0.01) to lock behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/web/src/lib/session-cost.test.ts` around lines 4 - 16, Add two
exact-threshold tests for formatSessionCost to lock branch behavior: assert
formatSessionCost(1) returns "$1.00" (explicit two-decimal dollar formatting)
and assert formatSessionCost(0.01) returns "$0.0100" (explicit four-decimal
sub-dollar formatting). Place these assertions alongside the existing tests in
the describe("formatSessionCost") block so the cost === 1 and cost === 0.01
boundaries are covered.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/control-plane/src/session/sandbox-events.test.ts`:
- Around line 108-123: The guard that checks event.cost currently uses typeof
(allowing NaN/Infinity) so update the check in the processSandboxEvent handler
to use Number.isFinite(event.cost) before calling repository.addSessionCost;
then add test cases to sandbox-events.test.ts that call
h.processor.processSandboxEvent with event.cost = NaN and event.cost = Infinity
and assert h.repository.addSessionCost is not called and broadcast still occurs
(reference the processSandboxEvent method, the addSessionCost mock on
h.repository, and the existing step_finish test to mirror structure).

---

Nitpick comments:
In `@packages/web/src/lib/session-cost.test.ts`:
- Around line 4-16: Add two exact-threshold tests for formatSessionCost to lock
branch behavior: assert formatSessionCost(1) returns "$1.00" (explicit
two-decimal dollar formatting) and assert formatSessionCost(0.01) returns
"$0.0100" (explicit four-decimal sub-dollar formatting). Place these assertions
alongside the existing tests in the describe("formatSessionCost") block so the
cost === 1 and cost === 0.01 boundaries are covered.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0b9b4101-78ef-40df-8adb-13ef412c65df

📥 Commits

Reviewing files that changed from the base of the PR and between 7b43c4f and e1d520b.

📒 Files selected for processing (13)
  • packages/control-plane/src/session/durable-object.ts
  • packages/control-plane/src/session/repository.test.ts
  • packages/control-plane/src/session/repository.ts
  • packages/control-plane/src/session/sandbox-events.test.ts
  • packages/control-plane/src/session/sandbox-events.ts
  • packages/control-plane/src/session/schema.ts
  • packages/control-plane/src/session/types.ts
  • packages/shared/src/types/index.ts
  • packages/web/src/components/session-right-sidebar.tsx
  • packages/web/src/components/sidebar/metadata-section.tsx
  • packages/web/src/hooks/use-session-socket.ts
  • packages/web/src/lib/session-cost.test.ts
  • packages/web/src/lib/session-cost.ts

Comment thread packages/control-plane/src/session/sandbox-events.test.ts
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/web/src/hooks/use-session-socket.ts (1)

247-252: ⚠️ Potential issue | 🟡 Minor

Sanitize subscribed totalCost before storing it in state.

Line 251 currently uses data.state.totalCost ?? 0, which does not reject NaN/Infinity. If that slips through, downstream formatting can render invalid values and future increments will propagate bad state.

Suggested fix
           setSessionState({
             ...data.state,
             // Backward-compatible default for older sessions that may omit this.
             isProcessing: data.state.isProcessing ?? false,
-            totalCost: data.state.totalCost ?? 0,
+            totalCost:
+              typeof data.state.totalCost === "number" && Number.isFinite(data.state.totalCost)
+                ? data.state.totalCost
+                : 0,
           });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/web/src/hooks/use-session-socket.ts` around lines 247 - 252, The
code stores unvalidated totalCost from the socket payload into state; replace
the direct usage of data.state.totalCost in the setSessionState call with a
sanitized finite-number check (e.g., compute sanitizedTotalCost =
Number.isFinite(Number(data.state.totalCost)) ? Number(data.state.totalCost) :
0) and use sanitizedTotalCost when calling setSessionState so
NaN/Infinity/invalid values are coerced to 0 before being saved (refer to
setSessionState and data.state.totalCost in the diff).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/web/src/hooks/use-session-socket.ts`:
- Around line 247-252: The code stores unvalidated totalCost from the socket
payload into state; replace the direct usage of data.state.totalCost in the
setSessionState call with a sanitized finite-number check (e.g., compute
sanitizedTotalCost = Number.isFinite(Number(data.state.totalCost)) ?
Number(data.state.totalCost) : 0) and use sanitizedTotalCost when calling
setSessionState so NaN/Infinity/invalid values are coerced to 0 before being
saved (refer to setSessionState and data.state.totalCost in the diff).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8671ef4c-1176-49e1-8f39-3d6278c41c0a

📥 Commits

Reviewing files that changed from the base of the PR and between e1d520b and c935f52.

📒 Files selected for processing (12)
  • packages/control-plane/src/session/durable-object.ts
  • packages/control-plane/src/session/repository.test.ts
  • packages/control-plane/src/session/repository.ts
  • packages/control-plane/src/session/sandbox-events.test.ts
  • packages/control-plane/src/session/sandbox-events.ts
  • packages/control-plane/src/session/schema.ts
  • packages/control-plane/src/session/types.ts
  • packages/shared/src/types/index.ts
  • packages/web/src/components/session-right-sidebar.tsx
  • packages/web/src/components/sidebar/metadata-section.tsx
  • packages/web/src/hooks/use-session-socket.ts
  • packages/web/src/lib/session-cost.test.ts
✅ Files skipped from review due to trivial changes (5)
  • packages/control-plane/src/session/schema.ts
  • packages/web/src/lib/session-cost.test.ts
  • packages/control-plane/src/session/repository.test.ts
  • packages/control-plane/src/session/sandbox-events.test.ts
  • packages/control-plane/src/session/durable-object.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/web/src/components/session-right-sidebar.tsx
  • packages/shared/src/types/index.ts
  • packages/control-plane/src/session/types.ts

`Number.isFinite` alone does not narrow `number | undefined` to
`number`. Adding `typeof event.cost === "number"` gives tsc the
type-guard it needs.
@ColeMurray ColeMurray merged commit ba50878 into ColeMurray:main Apr 10, 2026
18 checks passed
ColeMurray added a commit that referenced this pull request Apr 11, 2026
## Summary

Follow-up to #416 (session cost display). Addresses three review
findings:

- **Make `total_cost` non-optional on `SessionRow`** — the DB column is
`NOT NULL DEFAULT 0`, so the TypeScript type should reflect that.
Prevents `undefined` from leaking through to consumers.
- **Update `updated_at` in `addSessionCost`** — every other session
mutation sets `updated_at`; cost increments were the only exception.
This ensures recency queries and staleness checks account for cost
updates.
- **Reject negative costs** — adds `event.cost > 0` guard in both
backend (`sandbox-events.ts`) and frontend (`use-session-socket.ts`) to
prevent malformed events from decrementing the running total.

## Test plan

- [x] All 938 control-plane unit tests pass
- [x] TypeScript typecheck passes for control-plane and web
- [x] New test case for negative cost rejection
- [x] Updated existing `addSessionCost` test to verify `updated_at` is
set
- [x] Updated 7 test fixtures to include required `total_cost: 0`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Session cost tracking now ignores zero or negative cost values and
only applies positive costs.
* Session cost updates now include timestamp information for more
accurate recording.

* **Chores**
* Test fixtures updated across the codebase to reflect the expanded
session data shape.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants