Skip to content

Feature: Display Session Cost in Web UI#416

Open
MartinRoberts-Fountain wants to merge 4 commits intoColeMurray:mainfrom
MartinRoberts-Fountain:feature/opencode-session-cost
Open

Feature: Display Session Cost in Web UI#416
MartinRoberts-Fountain wants to merge 4 commits intoColeMurray:mainfrom
MartinRoberts-Fountain:feature/opencode-session-cost

Conversation

@MartinRoberts-Fountain
Copy link
Copy Markdown
Contributor

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

@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

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

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