feat: Cost tracking frontend components#915
Conversation
…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>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
ryaneggz
left a comment
There was a problem hiding this comment.
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:
- Add
CostUpdateEventto theSSEEventunion. - Add a
"custom"case (or a dedicated"cost_update"case) to bothparseEventmethods infetchStreamReader.ts. - Add a
CostUpdateEventSchematovalidations/stream.tsand include it inSSEEventSchema. - Wire
handleCostUpdateinto the stream event handler inuseChat.tsor the relevant context. - 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
useCallbackwith a functional updater inhandleCostUpdateis correct — this pattern avoids stale closures and is the right way to handle accumulated state from async events.resetCostwith an empty dependency array is correct since it only callssetCostStatewith a static value.- The popover breakdown layout is clean and follows the visual style of the Tasks popover in
ChatUtilityRow. formatCostcorrectly handles sub-cent precision which matters for cheap models.
Blockers Before Merge
- Rename
CustomEventto avoid the DOM built-in collision. - Wire
CostUpdateEventintoparseEvent,SSEEventSchema, and the stream event handler — otherwise the component and hook are unreachable dead code. - 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>
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: addedCustomSSEEvent,CostUpdateEvent,"custom"SSE typefrontend/src/components/chat/CostBadge.tsx— new: pill badge with popover cost breakdownfrontend/src/hooks/useCostTracking.ts— new: hook to accumulate cost data from SSE eventsHuman Review Checklist
CustomSSEEventis used (notCustomEventwhich shadows the DOM global)CostBadgereturnsnullwhentotalCost === 0(no empty badge)CostBadgehasaria-labelon the popover trigger button for accessibilityformatCost()is defined at module level (not inside component — avoids re-creation per render)costByPhaseprop is required (not optional) inCostBadgePropsuseCostTrackinguses functional updater pattern (setCostState(prev => ...)) to avoid stale closuresdangerouslySetInnerHTML— cost values rendered as text content onlyCostBadgestyling matches existing pill patterns inChatUtilityRow(h-8 rounded-full border border-border/60 px-3 text-xs)CostBadgeis not yet wired into any parent component — this is intentional for independent review. Wiring happens when this merges with the backend branch.cd frontend && npm run lint && npm run test— should passTest Plan
CostBadgerenders nothing whentotalCost === 0CostBadgedisplays formatted cost ($0.0034,$0.123,$12.34at different scales)useCostTracking.handleCostUpdate()accumulates totals correctly across multiple callsuseCostTracking.resetCost()clears all state to zero🤖 Generated with Claude Code