feat(web): add <TurnMeter> composable to unify token/cost display #1182

Merged
charles merged 1 commit from code-lead/1162 into main 2026-05-14 22:52:25 +00:00
Collaborator

Summary

Closes #1162.

Replaces the three bespoke token/cost widgets — <TokenMeter> (compact + full variants), the inline transcript TurnMeter pill, and <RunHeaderMeters>'s CostMeter — with one <TurnMeter size="sm|md|lg"> composable so list rows, transcript bubbles, and run headers all share field order and color-tier rules.

  • size="sm" — inline ↑in ↓out [$cost] chip. Replaces <TokenMeter variant="compact"> in task-list rows and the board side panel.
  • size="md" — pill with hover/focus tooltip exposing the cache breakdown. Replaces the inline TurnMeter previously local to <Transcript>.
  • size="lg" — multi-field row with cache_read / cache_creation chips. Wrapped by <RunHeaderMeters> underneath the context-window bar + compaction ticks (those remain — they're context-window state, not turn-level).

Cost API

  • costUsd={number}$0.043 / $1.27 / …
  • costUsd={null}? rate-table-miss indicator (parity with the prior <CostMeter> cost_unknown_model chip).
  • costUsd={undefined} → hide the chip entirely.

Adoption

  • Task list rows + board side panel migrated to <TurnMeter size="sm"> (task.cost_unknown_model ? null : task.cost_usd mapping).
  • Transcript turn footer migrated to <TurnMeter size="md">.
  • <RunHeaderMeters> delegates the token row + cost chip to <TurnMeter size="lg">; keeps the context-window bar + compaction ticks above it.
  • Standalone <TokenMeter variant="full"> calls in task-detail.tsx dropped — the row is now inside <RunHeaderMeters>.
  • apps/web/src/components/token-meter.tsx removed entirely.

Quality

  • just qa passes (typecheck + Biome + 3359 server tests).
  • New turn-meter.test.tsx covers all three sizes, the null / undefined / number cost branches, the active-zero render branch, and the color-tier escalation.
  • Existing test suites for <TaskList>, <TaskDetail>, <Transcript>, and <BoardCard> still pass.

Test plan

  • just qa — typecheck + lint + format + server tests
  • bun x vitest run src/components/turn-meter.test.tsx — 13/13 pass
  • bun x vitest run src/components/{task-list,task-detail,planner/transcript,board/board-card}.test.tsx — 119/119 pass
  • Visual sanity check in dashboard (task list, board side panel, transcript bubble, run header) after merge

🤖 Generated with Claude Code

## Summary Closes #1162. Replaces the three bespoke token/cost widgets — `<TokenMeter>` (compact + full variants), the inline transcript `TurnMeter` pill, and `<RunHeaderMeters>`'s `CostMeter` — with one `<TurnMeter size="sm|md|lg">` composable so list rows, transcript bubbles, and run headers all share field order and color-tier rules. - `size="sm"` — inline `↑in ↓out [$cost]` chip. Replaces `<TokenMeter variant="compact">` in task-list rows and the board side panel. - `size="md"` — pill with hover/focus tooltip exposing the cache breakdown. Replaces the inline `TurnMeter` previously local to `<Transcript>`. - `size="lg"` — multi-field row with `cache_read` / `cache_creation` chips. Wrapped by `<RunHeaderMeters>` underneath the context-window bar + compaction ticks (those remain — they're context-window state, not turn-level). ### Cost API - `costUsd={number}` → `$0.043` / `$1.27` / … - `costUsd={null}` → `?` rate-table-miss indicator (parity with the prior `<CostMeter>` `cost_unknown_model` chip). - `costUsd={undefined}` → hide the chip entirely. ### Adoption - Task list rows + board side panel migrated to `<TurnMeter size="sm">` (`task.cost_unknown_model ? null : task.cost_usd` mapping). - Transcript turn footer migrated to `<TurnMeter size="md">`. - `<RunHeaderMeters>` delegates the token row + cost chip to `<TurnMeter size="lg">`; keeps the context-window bar + compaction ticks above it. - Standalone `<TokenMeter variant="full">` calls in `task-detail.tsx` dropped — the row is now inside `<RunHeaderMeters>`. - `apps/web/src/components/token-meter.tsx` removed entirely. ### Quality - `just qa` passes (typecheck + Biome + 3359 server tests). - New `turn-meter.test.tsx` covers all three sizes, the `null` / `undefined` / `number` cost branches, the active-zero render branch, and the color-tier escalation. - Existing test suites for `<TaskList>`, `<TaskDetail>`, `<Transcript>`, and `<BoardCard>` still pass. ## Test plan - [x] `just qa` — typecheck + lint + format + server tests - [x] `bun x vitest run src/components/turn-meter.test.tsx` — 13/13 pass - [x] `bun x vitest run src/components/{task-list,task-detail,planner/transcript,board/board-card}.test.tsx` — 119/119 pass - [ ] Visual sanity check in dashboard (task list, board side panel, transcript bubble, run header) after merge 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(web): add <TurnMeter> composable to unify token/cost display (#1162)
All checks were successful
qa / sql-layer-check (pull_request) Successful in 7s
qa / i18n-string-check (pull_request) Successful in 14s
qa / db-schema (pull_request) Successful in 15s
qa / dockerfile (pull_request) Successful in 15s
qa / qa-1 (pull_request) Successful in 3m8s
qa / qa (pull_request) Successful in 0s
f49ca14a28
Replaces the three bespoke token/cost widgets (`<TokenMeter>` compact + full,
the inline transcript pill, and `<RunHeaderMeters>`'s cost chip) with one
`<TurnMeter size="sm |md|lg">` so list rows, transcript bubbles, and run
headers share field order and color tiers.

- `size="sm"` — inline `↑in ↓out [$cost]` chip; replaces compact TokenMeter
  in task-list rows and the board side panel.
- `size="md"` — pill with hover/focus tooltip carrying the cache breakdown;
  replaces the inline `TurnMeter` previously local to `<Transcript>`.
- `size="lg"` — multi-field row with cache_read / cache_creation chips;
  wrapped by `<RunHeaderMeters>` underneath the context-window bar +
  compaction ticks (those are context-window state, not turn state, and
  stay where they are).

Cost API: `costUsd={number}` → `$0.043`; `costUsd={null}` → `?` indicator
(rate-table miss; parity with the prior `<CostMeter>`); `undefined` hides
the chip. `<TokenMeter>` is removed entirely — every call site now reaches
`<TurnMeter>` directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
reviewer approved these changes 2026-05-14 22:32:15 +00:00
reviewer left a comment

Review: feat(web): add <TurnMeter> composable to unify token/cost display

CI — all 13 new tests pass, just qa reported green, existing suites (119 tests) pass.

All ACs from #1162 are met. The spec called for a temporary <TokenMeter> wrapper as an interim step, but the PR skips straight to full migration and deletion — that's strictly better; no objection.


What's good

  • Clean three-size API — the sm | md | lg sizing decision boundary is well-documented at the top of the file and each size maps cleanly to its call site.
  • costUsd: number | null | undefined tri-state — models the three semantically distinct states (known cost / rate-table miss / no cost data) correctly, with all three paths tested.
  • active prop — cleanly preserves the pre-#952 zero-render guard without duplicating the logic across callers.
  • Re-export of DEFAULT_SOFT_BUDGET_USD from run-header-meters.tsx keeps the old import path working without a separate shim.
  • Tests — all meaningful branches covered; the Playwright locator approach for the null-render assertion (getByTestId(...).not.toBeInTheDocument()) is the right idiom for vitest-browser-react.

Non-blocking suggestions

1. tokenColorClass and costColorClass are identical — merge them

// turn-meter.tsx lines ~72–80
function tokenColorClass(ratio: number): string { ... }
function costColorClass(ratio: number): string { ... } // ← exact same body

Both return text-error / text-warning / text-text-dim at the same thresholds. Either collapse into one colorClass(ratio) or at least make one call the other. As written, a future threshold change must be applied in two places.

2. Replace Math.random() tooltip ID with useId()

// MeterPill — inherited from the old transcript.tsx TurnMeter
const tooltipId = `meter-tooltip-${Math.random().toString(36).slice(2)}`;

Math.random() generates a new ID on every render, which breaks the aria-describedby association during re-renders (the button's attribute points at a stale ID). React's useId() is stable, SSR-safe, and zero cost:

import { useId } from "react";
// …
const tooltipId = useId();

This was a pre-existing issue in transcript.tsx — good opportunity to fix it in the rewrite.

3. MeterPill tooltip shows "Cache created: 0 tok / Cache read: 0 tok" when both are zero

<span className="block">Cache created: {fmtTokens(cacheCreation)} tok</span>
<span className="block">Cache read: {fmtTokens(cacheRead)} tok</span>

The lg size correctly gates these on > 0; the md tooltip unconditionally renders them. On turns with no cache activity the tooltip body is two useless zero-lines. The old transcript TurnMeter had the same flaw — worth fixing here since this is a ground-up rewrite:

{cacheCreation > 0 && <span className="block">Cache created: {fmtTokens(cacheCreation)} tok</span>}
{cacheRead > 0 && <span className="block">Cache read: {fmtTokens(cacheRead)} tok</span>}

(If both are 0 and the tooltip is empty, you can hide the tooltip entirely or drop the button's aria-describedby.)

4. active prop behaviour for md/lg is untested

active=true with zero tokens + size="md" renders a pill showing 0+0 tok — probably fine, but there's no test for it. Worth a single case if someone ever wires active to a transcript bubble during the queued phase.

5. Minor: $? vs icon in md unknown state

sm/lg render CircleDollarSign icon + ? via CostChip. The md pill renders plain $? text (no icon). Intentional for the dense pill format, but the cost-meter-unknown testid is shared between both shapes, which could confuse future snapshot tests. A comment in the code noting the intentional difference would help.


Items 1–3 are worth a follow-up issue or a quick fixup commit; they don't block merge. APPROVED.

## Review: `feat(web): add <TurnMeter> composable to unify token/cost display` CI ✅ — all 13 new tests pass, `just qa` reported green, existing suites (119 tests) pass. All ACs from #1162 are met. The spec called for a temporary `<TokenMeter>` wrapper as an interim step, but the PR skips straight to full migration and deletion — that's strictly better; no objection. --- ### What's good - **Clean three-size API** — the `sm | md | lg` sizing decision boundary is well-documented at the top of the file and each size maps cleanly to its call site. - **`costUsd: number | null | undefined` tri-state** — models the three semantically distinct states (known cost / rate-table miss / no cost data) correctly, with all three paths tested. - **`active` prop** — cleanly preserves the pre-#952 zero-render guard without duplicating the logic across callers. - **Re-export of `DEFAULT_SOFT_BUDGET_USD`** from `run-header-meters.tsx` keeps the old import path working without a separate shim. - **Tests** — all meaningful branches covered; the Playwright locator approach for the null-render assertion (`getByTestId(...).not.toBeInTheDocument()`) is the right idiom for vitest-browser-react. --- ### Non-blocking suggestions **1. `tokenColorClass` and `costColorClass` are identical — merge them** ```ts // turn-meter.tsx lines ~72–80 function tokenColorClass(ratio: number): string { ... } function costColorClass(ratio: number): string { ... } // ← exact same body ``` Both return `text-error / text-warning / text-text-dim` at the same thresholds. Either collapse into one `colorClass(ratio)` or at least make one call the other. As written, a future threshold change must be applied in two places. **2. Replace `Math.random()` tooltip ID with `useId()`** ```ts // MeterPill — inherited from the old transcript.tsx TurnMeter const tooltipId = `meter-tooltip-${Math.random().toString(36).slice(2)}`; ``` `Math.random()` generates a new ID on every render, which breaks the `aria-describedby` association during re-renders (the button's attribute points at a stale ID). React's `useId()` is stable, SSR-safe, and zero cost: ```ts import { useId } from "react"; // … const tooltipId = useId(); ``` This was a pre-existing issue in `transcript.tsx` — good opportunity to fix it in the rewrite. **3. `MeterPill` tooltip shows "Cache created: 0 tok / Cache read: 0 tok" when both are zero** ```tsx <span className="block">Cache created: {fmtTokens(cacheCreation)} tok</span> <span className="block">Cache read: {fmtTokens(cacheRead)} tok</span> ``` The `lg` size correctly gates these on `> 0`; the `md` tooltip unconditionally renders them. On turns with no cache activity the tooltip body is two useless zero-lines. The old transcript `TurnMeter` had the same flaw — worth fixing here since this is a ground-up rewrite: ```tsx {cacheCreation > 0 && <span className="block">Cache created: {fmtTokens(cacheCreation)} tok</span>} {cacheRead > 0 && <span className="block">Cache read: {fmtTokens(cacheRead)} tok</span>} ``` (If both are 0 and the tooltip is empty, you can hide the tooltip entirely or drop the button's `aria-describedby`.) **4. `active` prop behaviour for `md`/`lg` is untested** `active=true` with zero tokens + `size="md"` renders a pill showing `0+0 tok` — probably fine, but there's no test for it. Worth a single case if someone ever wires `active` to a transcript bubble during the queued phase. **5. Minor: `$?` vs icon in `md` unknown state** `sm`/`lg` render `CircleDollarSign` icon + `?` via `CostChip`. The `md` pill renders plain `$?` text (no icon). Intentional for the dense pill format, but the `cost-meter-unknown` testid is shared between both shapes, which could confuse future snapshot tests. A comment in the code noting the intentional difference would help. --- Items 1–3 are worth a follow-up issue or a quick fixup commit; they don't block merge. APPROVED.
charles deleted branch code-lead/1162 2026-05-14 22:52:26 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/agent-hooks!1182
No description provided.