Skip to content

feat: Cost tracking frontend components#915

Draft
ryaneggz wants to merge 2 commits intodevelopmentfrom
feat/cost-tracking-ui
Draft

feat: Cost tracking frontend components#915
ryaneggz wants to merge 2 commits intodevelopmentfrom
feat/cost-tracking-ui

Conversation

@ryaneggz
Copy link
Copy Markdown
Collaborator

@ryaneggz ryaneggz commented Mar 25, 2026

Summary

Closes #905

Frontend components for cost tracking — CostBadge display component, useCostTracking accumulation hook, and custom SSE type definitions.

Files Changed

  • frontend/src/lib/entities/stream.ts — modified: added CustomSSEEvent, CostUpdateEvent, "custom" SSE type
  • frontend/src/components/chat/CostBadge.tsx — new: pill badge with popover cost breakdown
  • frontend/src/hooks/useCostTracking.ts — new: hook to accumulate cost data from SSE events

Human Review Checklist

  • Verify CustomSSEEvent is used (not CustomEvent which shadows the DOM global)
  • Verify CostBadge returns null when totalCost === 0 (no empty badge)
  • Verify CostBadge has aria-label on the popover trigger button for accessibility
  • Verify formatCost() is defined at module level (not inside component — avoids re-creation per render)
  • Verify costByPhase prop is required (not optional) in CostBadgeProps
  • Verify useCostTracking uses functional updater pattern (setCostState(prev => ...)) to avoid stale closures
  • Verify no dangerouslySetInnerHTML — cost values rendered as text content only
  • Verify CostBadge styling matches existing pill patterns in ChatUtilityRow (h-8 rounded-full border border-border/60 px-3 text-xs)
  • Note: CostBadge is not yet wired into any parent component — this is intentional for independent review. Wiring happens when this merges with the backend branch.
  • Run cd frontend && npm run lint && npm run test — should pass

Test Plan

  • CostBadge renders nothing when totalCost === 0
  • CostBadge displays formatted cost ($0.0034, $0.123, $12.34 at different scales)
  • Popover opens on click showing phase breakdown
  • useCostTracking.handleCostUpdate() accumulates totals correctly across multiple calls
  • useCostTracking.resetCost() clears all state to zero

🤖 Generated with Claude Code

…ook, custom SSE types

Add frontend infrastructure for real-time cost tracking during chat streams:
- Add CustomEvent and CostUpdateEvent interfaces to stream types
- Add "custom" to SSEEventType union
- Create CostBadge component with popover breakdown panel
- Create useCostTracking hook for accumulating cost data from SSE events

Closes #905

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: ryaneggz <kre8mymedia@gmail.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 25, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 048b74db-e8af-4fba-9958-ec18bfc01506

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cost-tracking-ui

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown
Collaborator Author

@ryaneggz ryaneggz left a comment

Choose a reason for hiding this comment

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

Code Review — Cost Tracking UI

Reviewed files:

  • frontend/src/components/chat/CostBadge.tsx (new)
  • frontend/src/hooks/useCostTracking.ts (new)
  • frontend/src/lib/entities/stream.ts (modified)

Summary

The implementation is well-structured and mostly follows project conventions. There are several issues that should be addressed before merging, ranging from a naming collision that will cause TypeScript build failures in strict environments, to dead code, missing wiring, and a minor accessibility gap. No XSS risks were introduced.


Issues

[CRITICAL] Type Naming Conflict: CustomEvent shadows the DOM built-in
Location: frontend/src/lib/entities/stream.ts:76

CustomEvent is a globally available DOM interface in the browser environment. Exporting an interface with the same name creates a shadowing conflict. In strict TypeScript configurations with lib: ["DOM"], the compiler may emit errors or silently prefer the wrong type depending on import order and context. Any file that imports this interface and also uses the DOM CustomEvent (e.g., addEventListener('my-event', ...)) will have a type collision.

Recommendation — rename to avoid the collision:

// Bad
export interface CustomEvent { ... }

// Good
export interface CustomSSEEvent { ... }

// Update the union accordingly
export type SSEEvent =
  | MetadataEvent
  | ...
  | CustomSSEEvent;

[HIGH] CostUpdateEvent is defined but never wired into the stream pipeline
Location: frontend/src/lib/entities/stream.ts:83–92

CostUpdateEvent is declared as a standalone interface but is not added to the SSEEvent union type, is not handled in either parseEvent method in fetchStreamReader.ts, and is not handled in validations/stream.ts (SSEEventSchema). The useCostTracking hook exports handleCostUpdate but there are no call sites for it anywhere in the codebase.

As a result, cost_update events emitted by the backend will be silently dropped at the default: return null branch of both parseEvent implementations. CostUpdateEvent and handleCostUpdate are currently dead code.

Minimum required changes to make this functional:

  1. Add CostUpdateEvent to the SSEEvent union.
  2. Add a "custom" case (or a dedicated "cost_update" case) to both parseEvent methods in fetchStreamReader.ts.
  3. Add a CostUpdateEventSchema to validations/stream.ts and include it in SSEEventSchema.
  4. Wire handleCostUpdate into the stream event handler in useChat.ts or the relevant context.
  5. Render <CostBadge> somewhere (e.g., ChatUtilityRow) using the hook's state.

[HIGH] CustomEvent.data shape on the branch differs from what development already ships
Location: frontend/src/lib/entities/stream.ts:77–80

The PR diff shows the branch adding:

data: {
  type: string;
  [key: string]: unknown;
};

But development already has CustomEvent defined with data: Record<string, unknown> (no required type discriminator inside data). The branch version introduces a tighter data constraint that is not backward-compatible with any existing custom event consumers. This needs to be reconciled with whatever the backend actually emits for custom-typed SSE events.


[MEDIUM] CostBadge trigger uses a raw <button> instead of the shadcn Button component
Location: frontend/src/components/chat/CostBadge.tsx:30

ChatUtilityRow uses <Button variant="ghost" size="sm" ...> for all pill-style triggers. CostBadge uses a raw <button> with hand-rolled Tailwind classes. The visual output may look similar but it diverges from the established pattern and will not automatically pick up future design token changes applied to the shadcn Button.

Recommendation — align with ChatUtilityRow:

import { Button } from "@/components/ui/button";

<PopoverTrigger asChild>
  <Button
    variant="ghost"
    size="sm"
    className="h-8 rounded-full border border-border/60 px-3 text-xs text-muted-foreground hover:text-foreground"
    aria-label={`Total cost: ${formatCost(totalCost)}, ${turnCount} LLM calls`}
  >
    ...
  </Button>
</PopoverTrigger>

[MEDIUM] Missing aria-label on the popover trigger button
Location: frontend/src/components/chat/CostBadge.tsx:30

The trigger button has no aria-label. Screen readers will announce the concatenation of its text children ("$0.0012 · 3 calls"), which is ambiguous without context. An explicit label stating what the button opens improves accessibility.

aria-label={`Total cost: ${formatCost(totalCost)}, ${turnCount} LLM calls. Click for breakdown.`}

[MEDIUM] totalInputTokens and totalOutputTokens are tracked in state but never exposed to the UI
Location: frontend/src/hooks/useCostTracking.ts:5–7, frontend/src/components/chat/CostBadge.tsx

The CostBadge props interface accepts totalCost, turnCount, and costByPhase, but not totalInputTokens or totalOutputTokens. The hook spreads all five state fields into its return value, including two that have no consumer. Either surface these in the CostBadge popover (token counts are useful context for users) or remove them from CostState until they are needed.


[LOW] formatCost defined inside the render function on every call
Location: frontend/src/components/chat/CostBadge.tsx:21–25

formatCost is a pure function with no dependency on component state or props. It is re-created on every render. Move it outside the component or memoize it. Given how frequently cost badges may update during streaming this is worth keeping clean:

// Outside component
function formatCost(cost: number): string {
  if (cost < 0.01) return `$${cost.toFixed(4)}`;
  if (cost < 1) return `$${cost.toFixed(3)}`;
  return `$${cost.toFixed(2)}`;
}

[LOW] costByPhase prop is optional in CostBadgeProps but useCostTracking always initializes it
Location: frontend/src/components/chat/CostBadge.tsx:10, frontend/src/hooks/useCostTracking.ts:12

Since costByPhase is always present in the hook's return value, making it optional in the CostBadgeProps interface is inconsistent and forces a null-check in the component (costByPhase && ...). Marking it as required (or providing a default value) simplifies the component and improves type accuracy.


[LOW] No tests
Location: frontend/src/hooks/useCostTracking.ts, frontend/src/components/chat/CostBadge.tsx

The existing hook tests live in frontend/src/hooks/ (e.g., useModelVisibility.test.ts) and component tests are co-located or in frontend/src/tests/. useCostTracking has straightforward accumulation logic that is easy to unit test — particularly the costByPhase accumulation and the resetCost path. CostBadge should have at minimum a render test verifying it returns null when totalCost === 0 and renders correctly with phase data present.


XSS

No dangerouslySetInnerHTML usage in the new files. Cost values are rendered through toFixed() which always produces a numeric string. Phase names come from a Record<string, number> key and are rendered as text content. No XSS risk.


Positive Notes

  • useCallback with a functional updater in handleCostUpdate is correct — this pattern avoids stale closures and is the right way to handle accumulated state from async events.
  • resetCost with an empty dependency array is correct since it only calls setCostState with a static value.
  • The popover breakdown layout is clean and follows the visual style of the Tasks popover in ChatUtilityRow.
  • formatCost correctly handles sub-cent precision which matters for cheap models.

Blockers Before Merge

  1. Rename CustomEvent to avoid the DOM built-in collision.
  2. Wire CostUpdateEvent into parseEvent, SSEEventSchema, and the stream event handler — otherwise the component and hook are unreachable dead code.
  3. Add at least smoke-level tests for useCostTracking.

…ccessibility, code quality

- Rename CustomEvent to CustomSSEEvent in stream.ts to avoid collision with DOM CustomEvent
- Move formatCost to module-level function in CostBadge.tsx
- Add aria-label to CostBadge trigger button for accessibility
- Make costByPhase required in CostBadgeProps (always provided by useCostTracking)
- Add documentation comment for totalInputTokens/totalOutputTokens in useCostTracking

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: ryaneggz <kre8mymedia@gmail.com>
@ryaneggz ryaneggz changed the base branch from feat/cost-tracking to development March 25, 2026 02:56
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.

feat: Per-run cost tracking, phase-level metering, and budget caps

1 participant