Skip to content

feat(dashboard): add local usage statistics dashboard with session detail - #1225

Open
myk1yt wants to merge 32 commits into
Zoo-Code-Org:mainfrom
myk1yt:feat/dashboard
Open

feat(dashboard): add local usage statistics dashboard with session detail#1225
myk1yt wants to merge 32 commits into
Zoo-Code-Org:mainfrom
myk1yt:feat/dashboard

Conversation

@myk1yt

@myk1yt myk1yt commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes #947

Summary

Adds a local usage statistics dashboard accessible from the sidebar. Tracks LLM API call token usage and costs per session, with daily heatmap, session list/detail views, and export.

Features

Dashboard (Sidebar)

  • Summary cards: Total tokens (input/output/cache), total cost
  • Daily Activity heatmap: 30/60/120/360-day CSS grid heatmap with day-by-day granularity
  • Time range filter: Today / 7 days / 30 days / All time / Custom date range
  • Breakdown: Model / Provider / Mode breakdown with token and cost aggregation
  • Session list: Grouped by root task ID (subtasks appear under parent)
  • Session detail: Expandable API call list with per-call token breakdown
  • Export: CSV format
  • Clear: Nonce-protected confirmation dialog

Usage Recording

  • Single instrumentation point: Task.ts terminal finalize — no streaming duplicates
  • Append-only NDJSON: Each API call = one immutable event (5MiB rotation, 100MiB cap)
  • Local-only storage: globalStorage/usage-stats/ — no cloud sync, no telemetry
  • No sensitive data: API keys, prompts, responses never stored

Cost Calculation

  • On-the-fly recalculation: Old events without costUsd are recalculated at query time
  • Provider model registries: Static pricing tables for cost lookup (16 providers)

Internationalization

  • 18 locale files: en, ca, de, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, ru, tr, vi, zh-CN, zh-TW

Architecture

Webview (React)                     Extension (Node.js)
┌─────────────────┐                ┌──────────────────────┐
│ DashboardView    │──IPC──────────▶│ usageStatsMessageHandler │
│  SessionList     │◀──────────────│  UsageAggregator        │
│  SessionDetail   │               │  UsageRecorder          │
│  UsageHeatmap    │               │  UsageEventStore (cache)│
│  DashboardSummary│               │  costRecalculation      │
└─────────────────┘                └──────────────────────┘

Testing

  • pnpm check-types — 11/11 packages pass
  • pnpm lint — 0 warnings
  • Stats module tests: 200+ tests pass
  • Visual regression tests: Playwright CT with snapshot baselines
  • Extension activation tests: vi.hoisted() pattern for Vitest 4 isolation

Notes for Reviewers

  • Visual snapshot baselines will be generated via the "Update Visual Snapshots" GitHub Actions workflow after merge
  • All Korean comments have been translated to English
  • ClineProvider.ts changes are minimal (dead code removal + dispose safety)

Summary by CodeRabbit

  • New Features
    • Added a usage statistics dashboard with token and cost summaries, breakdowns, activity heatmaps, task hierarchies, session details, and date-range filters.
    • Added live updates, task pagination, refresh, JSON/CSV export, and protected usage-data clearing.
    • Added dashboard shortcuts in the VS Code sidebar and editor title.
  • Enhancements
    • Usage tracking now supports request metadata, custom pricing, and cache-cost estimates.
    • Added dashboard and statistics translations across supported locales.
  • Bug Fixes
    • Improved dashboard synchronization, filtering, timezone handling, and cost calculations.

Zoo (VP) added 29 commits August 11, 2026 06:47
qwen3-coder-plus and qwen3-coder-flash reported zero prices, which made
usage-stat cost recalculation return 0 for those models. Required by the
cost recalculation tests in the stats engine.
Data-minimized usage event V1 schema (no prompts, responses, or
credentials), stats query/snapshot contracts, and dashboard
summary/detail/stream types shared between extension host and webview.
- UsageEventStore: append-only segmented NDJSON with lock/queue,
  idempotency keys, 5MiB rotation, 100MiB cap, corrupt-line quarantine
- UsageStatsDatabase/Migration/Projection: transactional node:sqlite
  rollup (lazy-loaded, degrades gracefully when unavailable)
- UsageStatsStreamCoordinator: epoch-guarded subscription streaming
- UsageAggregator/costRecalculation/statsQueryRange: provider-aware
  cost and timezone-aware range math
- DashboardTaskCatalog/Projection: History-first task catalog fed by
  TaskHistoryStore.onDidChange (new small event surface)
- UsageCapture: endpoint-domain extraction for custom base URLs
OpenAI, OpenAI Codex, Anthropic Vertex, Mistral, Moonshot, and Kenari
handlers now attach totalCost (via shared/cost) to their usage chunks
so recorded usage events carry provider-calculated cost.
Initializes UsageStatsService and DashboardTaskCatalog alongside
TaskHistoryStore, forwards cross-window change notifications to the
webview, and disposes both with the provider. Initialization failure is
non-fatal: the service becomes unavailable and handlers degrade
gracefully.
Single instrumentation point: the captureUsageData boundary (completed
and failed/cancelled paths) records exactly one event per API attempt,
keyed by taskId:apiReqIndex:retryAttempt so tool-use turns do not
dedupe away. Recording is fire-and-forget and silently skipped when
the stats service is unavailable.
- usageStatsMessageHandler: query/clear/export/nonce handlers,
  session and History-first task detail, and subscribe/pause/resume/
  resync stream handlers with typed error codes
- webviewMessageHandler delegates stats message types through a single
  dispatcher guard (+7 lines)
- ExtensionMessage/WebviewMessage contracts for the stats and
  dashboard request/response payloads
- DashboardView with summary cards, time-range filter, breakdowns,
  History-first task list, and session/task detail
- Daily-activity heatmap (UsageHeatmap), animated counters, and
  stream reducer with epoch-guarded incremental updates
- dashboardButtonClicked command wired through package.json menus,
  registerCommands, and App tab routing; ErrorBoundary gains onRetry
stats.json and dashboard.json for all 18 locales plus the shared
error-boundary retry string in common.json.
UsageHeatmap was the only component under components/stats/ and is used
exclusively by DashboardView. Moving it keeps all dashboard UI under a
single components/dashboard/ directory.
The dashboardButtonClicked contribution referenced %command.dashboard.title%
but the key was missing from the default package.nls.json, which failed
vsce package. The 18 locale files already carry their translations.
- Rollup fast path now serves cacheRatio queries: stats_rollup gains
  unreported_cache_input_tokens (schema v6, rebuilt on migrate) so
  per-row cacheRatio estimation is exactly equivalent to the per-event
  path, including mixed-reporting buckets; server-reported cacheRead is
  always kept verbatim
- Ranged event reader (readEventsInRange) replaces full-table scans in
  the event-scan fallback and in export/getFilteredEvents
- Task usage aggregation for bounded ranges runs as GROUP BY SQL with
  json_extract instead of per-row deserialization; only rows missing
  cost are recalculated
- Memoize Intl.DateTimeFormat per timezone in aggregator/projection
Expanded root tasks now display an aggregate strip above the subtask
list: subtree-summed input/output tokens and cost plus every distinct
mode and model used across the subtree. Aggregates come from a single
grouped SQL pass over usage_events (no schema change); stream deltas
flow through the same projection path. Also drops the obsolete
'rebuild' action strings from all locales.
- Export button now downloads JSON scoped to the currently selected
  time range (Today/7d/30d/custom/All); the backend already supported
  JSON, and exports now read events via the ranged DB path
- Rebuild Stats button and its IPC chain are removed (rollup rebuilds
  remain an internal migration/coordinator concern)
- Clear Statistics opens its warning dialog immediately on click; the
  clear nonce is requested in parallel and only the confirm action
  waits for it (host-side nonce validation unchanged)
v6 committed its version marker before rebuilding rollups, so a failed
rebuild left the meta at v6 with pre-v6 rollup values and every later
activation skipped the migration entirely — breaking cacheRatio
estimation (unreported_cache_input_tokens stuck at 0). v6 now rebuilds
before committing its marker so a failure is retried, and v7 performs
an idempotent self-heal rebuild for databases already stranded.
Places the estimation input between Daily Activity and Breakdown so
range/breakdown controls read top-down in one flow.
Events without server-reported cacheRead now receive a ratio-proportional
cache discount on cost: cost(ratio) = storedCost - ratio x discountBase,
where discountBase = input x max(0, inputPrice - cacheReadsPrice) from the
existing pricing tables. Server-reported cacheRead keeps the verbatim
path in both tokens and cost. The per-event discount base is persisted
on usage_events, stats_rollup, and task_usage_metadata (schema v8,
self-healing rebuild) so Breakdown, summary cards, Tasks (rows, parent
aggregate strip), and session/task detail all respond to the slider
without rescanning events.
@myk1yt
myk1yt requested a review from taltas as a code owner August 12, 2026 05:53
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Added a local usage statistics system. It records API usage, stores events, aggregates statistics, streams dashboard updates, and renders a localized dashboard with task and session details. It also adds exports, clearing, cost recalculation, visual tests, and snapshot automation.

Changes

Usage contracts and persistence

Layer / File(s) Summary
Usage contracts and message protocol
packages/types/src/usage-stats.ts, packages/types/src/vscode-extension-host.ts, packages/types/src/index.ts
Added validated usage-event, statistics, dashboard, task, pagination, delta, error, and extension-host message contracts.
Event persistence and statistics processing
src/services/stats/*
Added usage capture, recording, NDJSON storage, migration, cost recalculation, range handling, aggregation, and database projections.
Task catalog, service, and stream coordinator
src/services/stats/DashboardTaskCatalog.ts, src/services/stats/DashboardTaskProjection.ts, src/services/stats/UsageStatsService.ts, src/services/stats/UsageStatsStreamCoordinator.ts
Added History-backed task catalogs, task projections, service lifecycle management, snapshots, deltas, pagination, and subscription coordination.

Extension and webview integration

Layer / File(s) Summary
Extension activation and webview IPC
src/activate/*, src/core/task/Task.ts, src/core/webview/*, src/extension.ts, src/package.json, src/package.nls.*.json
Added dashboard activation, task usage finalization, provider lifecycle management, message routing, commands, and localized command labels.
Dashboard webview and stream state
webview-ui/src/App.tsx, webview-ui/src/components/dashboard/*, webview-ui/src/utils/formatNumber.ts
Added dashboard navigation, subscription state handling, summary cards, heatmaps, task and session views, animations, formatting, exports, clearing, and error states.

Validation and tooling

Layer / File(s) Summary
Validation, regression coverage, and visual workflow
packages/types/src/__tests__/*, src/services/stats/__tests__/*, src/core/task/__tests__/*, src/core/webview/__tests__/*, webview-ui/src/components/dashboard/__tests__/*, .github/workflows/update-visual-snapshots.yml
Added schema, storage, service, coordinator, task, webview, dashboard, performance, regression, component, and visual tests. Added a manually triggered workflow for updating visual snapshots.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Task
  participant UsageRecorder
  participant UsageEventStore
  participant UsageStatsService
  participant UsageStatsStreamCoordinator
  participant DashboardView
  Task->>UsageRecorder: finalize terminal usage event
  UsageRecorder->>UsageEventStore: append idempotent event
  UsageEventStore-->>UsageStatsService: persist usage data
  UsageStatsService->>UsageStatsStreamCoordinator: notify appended event
  UsageStatsStreamCoordinator-->>DashboardView: send snapshot or delta
  DashboardView->>UsageStatsStreamCoordinator: replace or pause subscription
Loading
sequenceDiagram
  participant DashboardView
  participant usageStatsMessageHandler
  participant UsageStatsService
  participant DashboardTaskProjection
  DashboardView->>usageStatsMessageHandler: request dashboard snapshot or task page
  usageStatsMessageHandler->>UsageStatsService: resolve service and stream state
  usageStatsMessageHandler->>DashboardTaskProjection: compute task page or detail
  DashboardTaskProjection-->>usageStatsMessageHandler: return dashboard payload
  usageStatsMessageHandler-->>DashboardView: send correlated response
Loading

Possibly related PRs

  • Zoo-Code-Org/Zoo-Code#948: Extends the earlier usage-statistics implementation with dashboard streaming, task analytics, and the webview dashboard.
  • Zoo-Code-Org/Zoo-Code#1123: Shares the usage-statistics contracts, recorder, aggregation, storage, and service APIs extended here.
  • Zoo-Code-Org/Zoo-Code#526: Provides the Playwright component-testing and visual-snapshot infrastructure used by the dashboard tests and workflow.

Suggested labels: awaiting-review

Suggested reviewers: navedmerchant

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.79% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary change: a local usage statistics dashboard with session details.
Description check ✅ Passed The description includes the linked issue, feature scope, architecture, testing evidence, and reviewer notes, but omits the template checklist sections.
Linked Issues check ✅ Passed The changes implement the linked dashboard objectives, including local recording, aggregation, session details, export, clearing, localization, error handling, and visual coverage.
Out of Scope Changes check ✅ Passed The workflow, mocks, localization, tests, and visual regression updates directly support the dashboard feature and its stated implementation objectives.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

src/services/stats/UsageStatsStreamCoordinator.ts

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.

webview-ui/playwright-ct.config.ts

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.

webview-ui/playwright/ExtensionStateContext.mock.tsx

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.

  • 5 others

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

🟠 Major comments (29)
src/core/task/Task.ts-3189-3228 (1)

3189-3228: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Record completed attempts when usage counters are zero or unavailable.

finalizeUsageEvent() is nested in captureUsageData(). That function only runs when at least one token counter is greater than zero. A completed request with no usage chunk therefore produces no event. The dashboard then undercounts completed calls and omits that activity.

Finalize the completed event outside the positive-token guard. Keep token fields unset when their values are zero.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/task/Task.ts` around lines 3189 - 3228, Move the terminal finalize
block around usageRecorder.finalizeUsageEvent outside captureUsageData’s
positive-token guard so completed attempts are recorded even when all counters
are zero or unavailable. Preserve the existing requestKey, status, and
UsageRecordingContext construction, while leaving zero-valued token fields unset
as required.
src/core/webview/usageStatsMessageHandler.ts-1139-1143 (1)

1139-1143: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Replace the untyped _streamSink property with a typed field on ClineProvider. The stream sink is stored as an undeclared ad-hoc property on the provider and reached from two modules through four double assertions. The two modules even annotate it with different types (ProviderStreamSink here, StatsStreamSink in ClineProvider.ts), so the compiler cannot detect a mismatch. Declare private statsStreamSink?: StatsStreamSink on ClineProvider with a getter and setter, then delete every cast. The coding guidelines require avoiding untyped escapes and reserving double assertions for a last resort with an explanatory comment; these casts have neither.

  • src/core/webview/usageStatsMessageHandler.ts#L1139-L1143: this is the only writer. Replace the read and the write with provider.getStatsStreamSink() and provider.setStatsStreamSink(sink).
  • src/core/webview/usageStatsMessageHandler.ts#L1008-L1013: replace the cast in resolveTaskRangeMs with provider.getStatsStreamSink().
  • src/core/webview/usageStatsMessageHandler.ts#L1021-L1026: replace the cast in resolveTaskCacheRatio with provider.getStatsStreamSink().
  • src/core/webview/ClineProvider.ts#L737-L744: read the new private field directly in clearWebviewResources and set it to undefined after unsubscribing, removing both assertions.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/webview/usageStatsMessageHandler.ts` around lines 1139 - 1143,
Replace the ad-hoc _streamSink casts with a typed private statsStreamSink?:
StatsStreamSink field and getStatsStreamSink/setStatsStreamSink accessors on
ClineProvider. In src/core/webview/usageStatsMessageHandler.ts at lines
1139-1143, use the accessors for reading and storing the sink; at lines
1008-1013 and 1021-1026, use getStatsStreamSink(). In
src/core/webview/ClineProvider.ts at lines 737-744, read the private field
directly in clearWebviewResources and reset it to undefined after unsubscribing,
removing all double assertions.

Source: Coding guidelines

src/core/webview/usageStatsMessageHandler.ts-625-668 (1)

625-668: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Session titles are derived with sequential disk reads.

Line 649 awaits deriveSessionTitle once per session group inside a sequential for loop. Each call reads and JSON-parses the whole ui_messages.json file for that task.

For a user with hundreds of sessions this produces hundreds of serialized file reads on every getDashboardSessions request, and the dashboard issues that request on each time-range change. Resolve the titles concurrently.

⚡ Proposed fix to parallelize title resolution
-	const summaries: SessionSummary[] = []
-
-	for (const [taskId, taskEvents] of groups) {
+	const summaries: SessionSummary[] = await Promise.all(
+		Array.from(groups, async ([taskId, taskEvents]) => {
 		// Sort events within a task by occurredAt ascending so the first
 		// event is the earliest (representative model/provider/mode) and
 		// the last event gives the most recent activity timestamp.
 		const sorted = [...taskEvents].sort(
 			(a, b) => new Date(a.occurredAt).getTime() - new Date(b.occurredAt).getTime(),
 		)
 
 		const first = sorted[0]
 		const last = sorted[sorted.length - 1]
 
 		// Aggregate totals across all events in the task.
 		// Feature 1: Use getEffectiveCost to compute missing costs on-the-fly.
 		let totalTokens = 0
 		let totalCost = 0
 		for (const ev of sorted) {
 			totalTokens += ev.usage.totalTokens?.value ?? 0
 			totalCost += applyCacheDiscount(
 				getEffectiveCost(ev, customPricing),
 				computeCacheDiscountBase(ev, customPricing),
 				cacheRatio,
 			)
 		}
 
 		const title = await deriveSessionTitle(taskId, globalStoragePath)
 
-		summaries.push({
+			return {
 			taskId,
 			title,
 			timestamp: new Date(last.occurredAt).getTime(),
 			model: first.model,
 			provider: first.provider,
 			mode: first.mode,
 			models: [...new Set(sorted.map((e) => e.model))],
 			modes: [...new Set(sorted.map((e) => e.mode))],
 			totalTokens,
 			totalCost,
 			callCount: sorted.length,
-		})
-	}
+			}
+		}),
+	)

If the session count can be large, bound the concurrency instead of using an unbounded Promise.all.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/webview/usageStatsMessageHandler.ts` around lines 625 - 668, Update
the session summary construction around deriveSessionTitle so title resolution
runs concurrently rather than awaiting each title inside the sequential group
loop. Preserve the existing aggregation and summary fields, and use bounded
concurrency if the available implementation supports a concurrency limiter;
otherwise collect per-group work and await the title results together.
src/core/webview/usageStatsMessageHandler.ts-570-578 (1)

570-578: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Resolve the complete parent chain for session grouping.

buildParentMap only includes tasks with usage events. If C points to M, but M has no events, resolveRootTaskId(C) returns M instead of root R. The session list and handleGetDashboardSessionDetail then omit C from R's session. Use DashboardTaskCatalog or task history for parent resolution in both paths, and add a regression test for this case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/webview/usageStatsMessageHandler.ts` around lines 570 - 578, Update
buildParentMap and the resolveRootTaskId/session-grouping flow to resolve parent
relationships from DashboardTaskCatalog or task history, not only usage events,
so chains traverse eventless intermediate tasks to the true root. Ensure both
session listing and handleGetDashboardSessionDetail use the complete parent map,
and add a regression test covering C → M (no events) → R.
webview-ui/src/components/dashboard/DashboardView.tsx-240-282 (1)

240-282: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Superseded task-detail requests leave the row stuck in the loading state.

fetchTaskDetail adds taskId to taskDetailLoading and overwrites latestTaskDetailRequestIdRef. The response handler removes the entry only for the request that matches latestTaskDetailRequestIdRef. If the user expands task A and then expands task B before A responds, the A response is dropped at Line 325 and A stays in taskDetailLoading forever. handleToggleTask then skips a refetch of A because taskDetailLoading.has(taskId) is true, so re-expanding A renders TaskDetailLoading permanently.

Track the request id per task instead of a single latest ref, and clear the loading entry for the task the response belongs to.

🐛 Proposed fix
-	const latestTaskDetailRequestIdRef = useRef<string>("")
-	const latestTaskDetailIdRef = useRef<string | undefined>(undefined)
+	// requestId -> taskId for every in-flight detail request.
+	const taskDetailRequestsRef = useRef<Map<string, string>>(new Map())
 	const fetchTaskDetail = useCallback((taskId: string) => {
 		const requestId = `dashboard-task-detail-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
-		latestTaskDetailRequestIdRef.current = requestId
-		latestTaskDetailIdRef.current = taskId
+		taskDetailRequestsRef.current.set(requestId, taskId)
 			if (message.type === "dashboardTaskDetailResponse") {
-				if (message.requestId !== latestTaskDetailRequestIdRef.current) return
-
-				const taskId = latestTaskDetailIdRef.current
-				if (!taskId) return
+				const requestId = message.requestId
+				if (!requestId) return
+				const taskId = taskDetailRequestsRef.current.get(requestId)
+				if (!taskId) return
+				taskDetailRequestsRef.current.delete(requestId)

Also applies to: 320-348

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@webview-ui/src/components/dashboard/DashboardView.tsx` around lines 240 -
282, Update fetchTaskDetail and the response handler to track the active request
ID per task rather than using the single latestTaskDetailRequestIdRef. When
handling any response, clear taskDetailLoading for that response’s taskId, then
ignore stale results by comparing its request ID with that task’s active request
ID; preserve the existing behavior for applying only the current task detail
response.
webview-ui/src/components/dashboard/dashboardStreamReducer.ts-325-350 (1)

325-350: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Use a dense delivery sequence before adding gap detection.

usage_events.seq is not contiguous: INSERT OR IGNORE can consume an AUTOINCREMENT value. The host can also skip deltas for hidden sinks while advancing lastSequence. Add an explicit per-subscription delivery cursor or gap marker, then set pendingResync from that signal. Do not compare sequence with state.sequence + 1 directly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@webview-ui/src/components/dashboard/dashboardStreamReducer.ts` around lines
325 - 350, Replace direct gap detection based on usage_events.sequence with an
explicit per-subscription dense delivery cursor or gap marker in the DELTA flow
of the dashboard stream reducer. Track the delivery sequence independently from
state.sequence, advance it only for delivered deltas, and set pendingResync from
the explicit gap signal; retain existing stale, generation, and duplicate
handling.
src/core/webview/__tests__/usageStatsMessageRouting.spec.ts-456-493 (1)

456-493: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

This test does not verify production disposal wiring.

The test builds a local object literal with its own dispose() implementation at lines 467-486, calls it, and then asserts that the literal did what the literal was written to do. No symbol from UsageStatsService or ClineProvider is imported or exercised. The test passes even if UsageStatsService.dispose() is deleted, so it cannot detect the regression the file docblock claims it covers at line 11.

Assert against the real service instead. Construct a UsageStatsService with injected coordinator and database doubles, call its dispose(), and check that both doubles were disposed.

🔧 Proposed direction
-			// Simulate the service's dispose chain
-			const service: {
-				coordinator: typeof coordinator | null
-				database: typeof mockDb
-				watcher: { dispose(): void } | null
-				changeListeners: Array<() => void>
-				dispose(): void
-			} = {
-				coordinator,
-				database: mockDb,
-				watcher: null,
-				changeListeners: [],
-				dispose() {
-					this.coordinator?.dispose()
-					this.coordinator = null
-					this.watcher?.dispose()
-					this.watcher = null
-					this.changeListeners.length = 0
-					this.database.close()
-				},
-			}
-
-			service.dispose()
+			const service = new UsageStatsService(/* ...test deps... */)
+			// Install the doubles on the real instance, then exercise the real dispose().
+			;(service as unknown as Record<string, unknown>)["coordinator"] = coordinator
+			;(service as unknown as Record<string, unknown>)["database"] = mockDb
+
+			service.dispose()

If UsageStatsService cannot be constructed cheaply in this suite, move the assertion into src/services/stats/__tests__/UsageStatsService.spec.ts, which already owns that layer. Do you want me to draft the replacement test?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/webview/__tests__/usageStatsMessageRouting.spec.ts` around lines 456
- 493, Replace the locally implemented service object in the “coordinator
disposal” test with an actual UsageStatsService instance, injecting the existing
coordinator and database doubles, then call its dispose() and assert both
coordinator.dispose and database.close are invoked once. Import and exercise the
production UsageStatsService; if construction is impractical in this suite, move
the test to its existing service-level spec.
src/core/webview/__tests__/usageStatsMessageRouting.spec.ts-1-1 (1)

1-1: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Both usage-stats spec files suppress lint rules instead of satisfying them. The shared root cause is that the stream-handler doubles have no typed shape, so each call site reaches for as any, and the deliberate fire-and-forget handler calls are never marked. The repository guidelines require fixing these rules rather than disabling them.

  • src/core/webview/__tests__/usageStatsMessageRouting.spec.ts#L1-L1: remove the @typescript-eslint/no-explicit-any disable. Replace the 12 as any casts at lines 292, 318, 333, 347, 362, 376, 391, 409, 440, 500, 529, and 561 with a shared typed service double.
  • src/core/webview/__tests__/usageStatsMessageHandler.spec.ts#L1-L1: remove both disables. Replace the as any casts with the same typed double, and prefix the unawaited handler calls at lines 1336, 1360, 1384, 1466, and 1558 with void.

Define the double once in a shared helper so both files import it. That also removes the need for as any on the _streamSink assignments at lines 1759 and 1837 of the handler spec; use bracket notation on a typed cast instead.

As per coding guidelines: "Fix lint violations in new TypeScript code instead of suppressing them." and "Avoid as any; use typed APIs, bracket notation for private members where necessary, or precise test doubles and unknown type guards."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/webview/__tests__/usageStatsMessageRouting.spec.ts` at line 1,
Remove the lint suppressions in
src/core/webview/__tests__/usageStatsMessageRouting.spec.ts:1-1 and
src/core/webview/__tests__/usageStatsMessageHandler.spec.ts:1-1. Define one
shared typed stream-handler test double, import and use it at all listed as-any
call sites in both specs, and replace the handler spec’s _streamSink casts at
lines 1759 and 1837 with bracket notation on a precise typed cast. Prefix the
unawaited handler calls in usageStatsMessageHandler.spec.ts at lines 1336, 1360,
1384, 1466, and 1558 with void.

Source: Coding guidelines

src/services/stats/__tests__/UsageRecorder.spec.ts-32-46 (1)

32-46: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add coverage for idempotency and store-failure isolation.

UsageRecorder.finalizeUsageEvent guarantees two behaviors that this suite does not exercise:

  1. A repeated call with the same requestKey and status must not append a second event. The PR objective states recording is single-point and idempotent, so this is the load-bearing guarantee.
  2. A rejecting sink.append must not reject the caller. The objective states storage failures are handled gracefully.

A third uncovered path: notifyChanged must fire only when append resolves true.

Both fakes are already available through the injected sink, so the tests are cheap.

🧪 Proposed additional tests
 		it("returns true after a request has been finalized", async () => {
 			const sink = { append: vi.fn().mockResolvedValue(true) }
 			const recorder = new UsageRecorder(sink)
 
 			expect(recorder._hasFinalized("task-001:0:1", "completed")).toBe(false)
 
 			await recorder.finalizeUsageEvent("task-001:0:1", "completed", makeContext())
 
 			expect(recorder._hasFinalized("task-001:0:1", "completed")).toBe(true)
 			expect(sink.append).toHaveBeenCalledTimes(1)
 		})
+	})
+
+	describe("finalizeUsageEvent", () => {
+		it("appends only once for a repeated requestKey and status", async () => {
+			const sink = { append: vi.fn().mockResolvedValue(true) }
+			const notifyChanged = vi.fn()
+			const recorder = new UsageRecorder(sink, notifyChanged)
+
+			await recorder.finalizeUsageEvent("task-001:0:1", "completed", makeContext())
+			await recorder.finalizeUsageEvent("task-001:0:1", "completed", makeContext())
+
+			expect(sink.append).toHaveBeenCalledTimes(1)
+			expect(notifyChanged).toHaveBeenCalledTimes(1)
+		})
+
+		it("appends separately for a different status on the same requestKey", async () => {
+			const sink = { append: vi.fn().mockResolvedValue(true) }
+			const recorder = new UsageRecorder(sink)
+
+			await recorder.finalizeUsageEvent("task-001:0:1", "completed", makeContext())
+			await recorder.finalizeUsageEvent("task-001:0:1", "failed", makeContext())
+
+			expect(sink.append).toHaveBeenCalledTimes(2)
+		})
+
+		it("does not reject the caller when the sink throws", async () => {
+			const sink = { append: vi.fn().mockRejectedValue(new Error("store down")) }
+			const notifyChanged = vi.fn()
+			const recorder = new UsageRecorder(sink, notifyChanged)
+
+			await expect(
+				recorder.finalizeUsageEvent("task-001:0:1", "completed", makeContext()),
+			).resolves.toBeUndefined()
+			expect(notifyChanged).not.toHaveBeenCalled()
+		})
+
+		it("does not notify when the sink deduplicates the event", async () => {
+			const sink = { append: vi.fn().mockResolvedValue(false) }
+			const notifyChanged = vi.fn()
+			const recorder = new UsageRecorder(sink, notifyChanged)
+
+			await recorder.finalizeUsageEvent("task-001:0:1", "completed", makeContext())
+
+			expect(notifyChanged).not.toHaveBeenCalled()
+		})
 	})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stats/__tests__/UsageRecorder.spec.ts` around lines 32 - 46,
Extend the UsageRecorder tests around finalizeUsageEvent to verify idempotency
by calling it twice with the same request key and status and asserting
sink.append runs once. Add coverage for a rejecting sink.append to confirm
finalizeUsageEvent resolves without propagating the failure, and verify
notifyChanged is called only when append resolves true, using the injected
sink’s existing fakes.
src/services/stats/__tests__/UsageStatsService.spec.ts-75-88 (1)

75-88: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Dispose the shared service in afterEach.

beforeEach initializes service, which opens a SQLite handle and registers a file watcher. afterEach never calls service.dispose(). Every test in this suite leaks one watcher and one open database. The open handle can also make fs.rm fail on Windows, and the surrounding catch hides that failure, so temp directories accumulate.

♻️ Proposed fix
 	afterEach(async () => {
+		service.dispose()
 		// Clean up temp directory (test isolation)
 		try {
 			await fs.rm(tempDir, { recursive: true, force: true })
 		} catch {
 			// ignore cleanup errors
 		}
 	})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stats/__tests__/UsageStatsService.spec.ts` around lines 75 - 88,
Update the afterEach cleanup for UsageStatsService tests to call
service.dispose() before removing tempDir, ensuring the SQLite handle and file
watcher are released while preserving the existing forced directory cleanup.
webview-ui/src/components/dashboard/__tests__/DashboardSummary.visual.tsx-57-82 (1)

57-82: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

The visual test fails in CI.

The pipeline reports a failure for this test in Webview Visual Regression. The likely cause is a missing or stale committed baseline for dashboard-summary-dark.png.

Generate the baseline in the container and commit it:

pnpm --filter `@roo-code/vscode-webview` test:visual:docker:update

Do not commit host-rendered baselines. As per coding guidelines: "Run visual comparisons and create or update committed baselines using pnpm test:visual:docker and pnpm test:visual:docker:update; do not commit host-rendered baselines."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@webview-ui/src/components/dashboard/__tests__/DashboardSummary.visual.tsx`
around lines 57 - 82, Update the committed visual baseline for the
DashboardSummary test by running pnpm --filter `@roo-code/vscode-webview`
test:visual:docker:update in the container. Commit the generated
dashboard-summary-dark.png baseline, and do not use or commit host-rendered
screenshots.

Sources: Coding guidelines, Pipeline failures

webview-ui/src/components/dashboard/__tests__/DashboardView.visual.tsx-51-51 (1)

51-51: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Make the visual test clock deterministic.

Date.now() feeds the coverage timestamps, which DashboardView renders with toLocaleString(). TaskList also calls Date.now() for relative timestamps. Freeze the browser clock before mounting and derive all fixture timestamps from the same fixed epoch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@webview-ui/src/components/dashboard/__tests__/DashboardView.visual.tsx` at
line 51, Update the visual test setup around DashboardView mounting to freeze
the browser clock at a fixed epoch before rendering. Replace the standalone
Date.now() fixture value with that shared epoch, and derive all coverage and
TaskList timestamp fixtures from it so toLocaleString() and relative timestamps
remain deterministic.

Source: Coding guidelines

src/services/stats/costRecalculation.ts-227-247 (1)

227-247: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Document the as ModelInfo casts.

Both fallback branches build an object with only four price fields and cast it to ModelInfo. ModelInfo declares additional required fields. The cast is safe only because calculateApiCostAnthropic and calculateApiCostOpenAI read price fields alone. That contract is not stated anywhere near the cast.

Add a short comment at each cast that records this assumption, or type the return as Pick<ModelInfo, "inputPrice" | "outputPrice" | "cacheWritesPrice" | "cacheReadsPrice"> and widen at the call sites.

As per coding guidelines: "If an unavoidable cast is required, document why in a nearby comment."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stats/costRecalculation.ts` around lines 227 - 247, Document
both as ModelInfo casts in the modelPricing and customPricing fallback branches,
stating that calculateApiCostAnthropic and calculateApiCostOpenAI access only
the four price fields provided by these objects. Keep the existing fallback
behavior unchanged.

Source: Coding guidelines

src/services/stats/costRecalculation.ts-213-222 (1)

213-222: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Cache the sorted registry keys instead of sorting on every lookup.

lookupModelInfo calls Object.keys(registry) and sorts the result on each invocation. The sort is O(n log n) over every model id in the provider registry. This function runs once per usage event through computeEventCost, getEffectiveCost, and computeCacheDiscountBase, and providerReportsCache at lines 358-365 repeats the same sort. During a rollup rebuild over full history, or a 100-event drain batch, the repeated sort dominates the lookup cost.

Precompute the sorted key list per provider once at module load.

⚡ Proposed fix
+const SORTED_REGISTRY_IDS: Record<string, string[]> = Object.fromEntries(
+	Object.entries(PROVIDER_MODEL_REGISTRIES).map(([provider, registry]) => [
+		provider,
+		Object.keys(registry).sort((a, b) => b.length - a.length),
+	]),
+)
+
+/** Resolves a model id against a provider registry: exact match, then longest substring match. */
+function resolveRegistryModel(provider: string, model: string): ModelInfo | undefined {
+	const registry = PROVIDER_MODEL_REGISTRIES[provider]
+	if (!registry) return undefined
+	if (model in registry) return registry[model]
+	const lowerModel = model.toLowerCase()
+	for (const knownId of SORTED_REGISTRY_IDS[provider] ?? []) {
+		if (lowerModel.includes(knownId.toLowerCase())) return registry[knownId]
+	}
+	return undefined
+}

Then use resolveRegistryModel in both lookupModelInfo (lines 209-222) and providerReportsCache (lines 350-366). This also removes the duplicated matching logic between the two functions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stats/costRecalculation.ts` around lines 213 - 222, Precompute
each provider registry’s keys sorted by descending length at module load,
exposing or reusing a shared resolveRegistryModel helper. Update lookupModelInfo
and providerReportsCache to call resolveRegistryModel instead of rebuilding and
sorting Object.keys(registry) or duplicating substring matching, while
preserving case-insensitive longest-ID matching and existing fallback behavior.
src/services/stats/statsQueryRange.ts-35-38 (1)

35-38: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Webview-supplied query values reach the host without runtime validation. packages/types/src/usage-stats.ts line 217 states that Zod validation is required for every webview-originated query. The WebviewMessage fields that carry those values are declared as plain TypeScript types, so the compiler enforces nothing at the message boundary and the host consumes the raw values. Parse each webview-originated query payload through its Zod schema in usageStatsMessageHandler before use.

  • src/services/stats/statsQueryRange.ts#L35-L38: reject or drop an unparseable query.from / query.to instead of producing a NaN bound, which makes isStatsQueryRangeBounded report true while isWithinStatsQueryRange admits every timestamp.
  • packages/types/src/vscode-extension-host.ts#L851-L858: clamp dashboardSessionLimit and dashboardTaskLimit to the documented 1–100 range, or parse the page request through the existing DashboardSessionPageRequest schema, which already encodes those bounds.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stats/statsQueryRange.ts` around lines 35 - 38, In
usageStatsMessageHandler, validate every webview-originated query with its
existing Zod schema before use. At src/services/stats/statsQueryRange.ts lines
35-38, reject or omit unparseable query.from and query.to values so no NaN
bounds are produced; at packages/types/src/vscode-extension-host.ts lines
851-858, parse the page request through DashboardSessionPageRequest or clamp
dashboardSessionLimit and dashboardTaskLimit to 1–100.
src/services/stats/costRecalculation.ts-313-318 (1)

313-318: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

getEffectiveCost never recomputes a stored cost of 0.

getEffectiveCost returns the stored value whenever costUsd is defined, including { value: 0 }. computeEventCost at line 274 uses a stricter guard and only short-circuits when costUsd.value > 0, which shows the intent to recompute zero-valued costs.

Because getEffectiveCost is documented at line 307 as the primary entry point, the > 0 branch in computeEventCost is unreachable through it. An event recorded with an explicit costUsd: 0 by a provider that did not price the call keeps a cost of 0 forever, which is the exact failure the module header at lines 5-7 describes.

Align the two guards.

🐛 Proposed fix
 export function getEffectiveCost(event: UsageEventV1, customPricing?: CustomModelPricingMap): number {
-	if (event.usage.costUsd !== undefined) {
+	if (event.usage.costUsd !== undefined && event.usage.costUsd.value > 0) {
 		return event.usage.costUsd.value
 	}
 	return computeEventCost(event, customPricing)
 }

If a stored 0 must be treated as authoritative, remove the > 0 condition from computeEventCost instead and update its doc comment. Confirm which semantic the aggregation tests expect before choosing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stats/costRecalculation.ts` around lines 313 - 318, Align the
cost guards used by getEffectiveCost and computeEventCost so stored zero-valued
costs follow the module’s intended recomputation behavior. Update
getEffectiveCost to avoid short-circuiting on costUsd.value === 0, or instead
make computeEventCost treat zero as authoritative and revise its documentation;
use the aggregation tests to confirm the expected semantic.
src/services/stats/UsageStatsStreamCoordinator.ts-271-284 (1)

271-284: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Clamp the caller-supplied lastSequence in resume.

resume accepts lastSequence from the webview and assigns it directly to state.lastSequence at line 281 when the gap is small. The gap check at line 275 only rejects values that are too far behind.

If lastSequence exceeds the database's current last sequence, gap is negative, the check passes, and state.lastSequence moves ahead of the store. The drain filter at line 452 (e.sequence > sub.lastSequence) then matches nothing, and the subscriber receives no further deltas until an unrelated snapshot path fires.

🐛 Proposed fix
 		const gap = currentLastSeq - lastSequence
-		if (gap > MAX_BATCH_EVENTS) {
+		if (gap > MAX_BATCH_EVENTS || gap < 0) {
 			// Gap too large, or the cursor is ahead of the store — send full snapshot
 			state.lastSequence = currentLastSeq
 			this.sendSnapshot(state)
 		} else {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stats/UsageStatsStreamCoordinator.ts` around lines 271 - 284,
Clamp the caller-supplied lastSequence to currentLastSeq in the resume flow
before assigning state.lastSequence or evaluating the gap, so values ahead of
the store cannot advance the subscription cursor. Preserve the existing
full-snapshot behavior for oversized gaps and drain scheduling for valid small
gaps.
src/services/stats/UsageStatsStreamCoordinator.ts-566-607 (1)

566-607: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Extract the duplicated snapshot-assembly block.

Lines 566-607 in sendSnapshot and lines 690-731 in scheduleAsyncRebuild contain the same logic, character for character: the task/session branch selection, the computeTaskPage call and its seven arguments, the state.visibleTaskIds assignment, and the returned snapshot object.

Both copies must stay in sync. state.visibleTaskIds feeds the delta filter at line 487. If one copy changes and the other does not, the two paths produce different visible task sets and the delta stream diverges from the snapshot.

♻️ Proposed extraction
+	/**
+	 * Assembles the snapshot payload for a subscriber and records the visible
+	 * task ids used by the delta filter. Shared by the immediate and the
+	 * post-rebuild snapshot paths so both stay in sync.
+	 */
+	private assembleSnapshotFor(
+		state: SubscriptionState,
+		stats: StatsSnapshot,
+		heatmap: HeatmapSnapshot,
+		generation: number,
+		sequence: number,
+		customPricing: CustomModelPricingMap | undefined,
+	): DashboardStatsSnapshot | DashboardTaskStatsSnapshot {
+		const base = { requestId: state.subscription.requestId, generation, sequence, stats, heatmap }
+
+		if (!this.taskCatalog || !this.database) {
+			const sessions = computeSessionPage(
+				this.database!,
+				state.subscription.requestId,
+				undefined,
+				state.subscription.sessionPageSize,
+			)
+			return { ...base, sessions, cursor: sessions.cursor }
+		}
+
+		const tasks = computeTaskPage(
+			this.taskCatalog,
+			this.database,
+			state.subscription.requestId,
+			undefined,
+			state.subscription.sessionPageSize,
+			resolveStatsQueryRangeMs(state.subscription.range),
+			state.subscription.range.cacheRatio,
+			customPricing,
+		)
+		state.visibleTaskIds = new Set([...tasks.tasks, ...(tasks.childTasks ?? [])].map((task) => task.taskId))
+		return { ...base, tasks, cursor: tasks.cursor }
+	}

Then call this.assembleSnapshotFor(state, stats, heatmap, generation, sequence, customPricing) from both sites. Import StatsSnapshot and HeatmapSnapshot as types.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stats/UsageStatsStreamCoordinator.ts` around lines 566 - 607,
Extract the duplicated task/session snapshot construction from sendSnapshot and
scheduleAsyncRebuild into a shared assembleSnapshotFor method on
UsageStatsStreamCoordinator, accepting state, stats, heatmap, generation,
sequence, and customPricing. Preserve the computeTaskPage arguments,
visibleTaskIds assignment, and returned snapshot fields exactly, then call the
helper from both paths and import StatsSnapshot and HeatmapSnapshot as types.
src/services/stats/UsageStatsStreamCoordinator.ts-658-670 (1)

658-670: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Process rollup rebuilding in bounded asynchronous chunks. setImmediate only defers the call; rebuildRollupsFromEvents() synchronously scans all events and updates multiple derived tables in one transaction. Its runtime grows with event history and can freeze the extension host during dashboard startup. Process batches across event-loop turns or use a worker.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stats/UsageStatsStreamCoordinator.ts` around lines 658 - 670,
Update scheduleAsyncRebuild and rebuildRollupsFromEvents so rollup rebuilding
processes bounded event batches across multiple event-loop turns instead of one
synchronous full-history transaction. Preserve rebuildInFlight,
disposed/database checks, completion state, and error handling while ensuring
each batch yields before the next.
src/services/stats/UsageStatsStreamCoordinator.ts-794-806 (1)

794-806: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve recovery when the dashboard is hidden.

DashboardView does not pass visible, so the hook defaults to true. The host sends didBecomeVisible only when the view reappears. Hidden deltas are skipped, but drain still advances sub.lastSequence; the hook then sends no resume message and remains stale.

Setting snapshotSent = false alone does not recover the subscriber because drain excludes it and resume only schedules another excluded drain for small gaps. Wire actual visibility into the hook, pass the acknowledged sequence in resumeDashboardStats, and send a snapshot when visibility causes delta loss.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stats/UsageStatsStreamCoordinator.ts` around lines 794 - 806,
Update the DashboardView subscription flow and postMessage/drain recovery logic
so actual visibility is propagated instead of defaulting to true. Ensure
resumeDashboardStats carries the acknowledged sequence, and when visibility
resumes after skipped deltas, send a snapshot that restores the subscriber
rather than relying on another excluded drain.
src/services/stats/UsageStatsService.ts-388-394 (1)

388-394: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Export silently omits events when the SQLite migration is incomplete.

readEventsForQuery prefers the database whenever _isInitialized() is true. doInitialize catches migration failures at Lines 215-217 and only logs them. After a partial migration the database holds a subset of the events, but exports, getFilteredEvents, and the session grouping still read from it. The NDJSON segments, which remain the complete record, are never consulted.

Gate the database path on migration completion, for example by checking UsageStatsMigration.isComplete(), and fall back to this.store.readAll() otherwise.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stats/UsageStatsService.ts` around lines 388 - 394, Update
readEventsForQuery to use the database only when
UsageStatsMigration.isComplete() confirms migration completion in addition to
_isInitialized(); otherwise fall back to this.store.readAll() so incomplete
migrations never omit events.
src/services/stats/UsageStatsService.ts-150-166 (1)

150-166: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

The pricing refresh timer runs every 10 seconds for the entire extension lifetime.

buildCustomPricingMapFromAllProfiles(providerSettingsManager) enumerates every provider profile. The interval invokes it every 10 seconds from service construction until dispose(), whether or not the dashboard is open and whether or not any profile changed. Provider profiles hold API keys, so each pass also touches secret storage.

Two lower-cost options exist:

  • Refresh on demand: cache the map with a timestamp and rebuild it inside customPricingProvider only when the cache is older than a threshold.
  • Refresh on change: subscribe to a provider-settings change event, if ProviderSettingsManagerLike exposes one.

The current callback also does not guard against overlap. If one refresh exceeds 10 seconds, a second starts before the first finishes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stats/UsageStatsService.ts` around lines 150 - 166, Replace the
lifetime setInterval refresh in the providerSettingsManager branch of
UsageStatsService with on-demand, timestamp-based cache refresh inside
customPricingProvider, using an appropriate freshness threshold and ensuring
concurrent calls share or skip an in-flight refresh. Preserve the existing
cached pricing on refresh errors and the customPricingProvider fallback path for
externally supplied providers; remove the periodic pricingRefreshTimer setup and
update disposal accordingly.
src/services/stats/UsageStatsMigration.ts-101-105 (1)

101-105: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

The migration reads every segment twice, synchronously, during activation.

buildParentMap() reads and parses all segment files, then the loop at Line 125 reads and parses the same files again. Both passes use fs.readFileSync and JSON.parse on the extension host thread. UsageStatsService.doInitialize() calls migrate() inline, so activation blocks for the full duration.

UsageEventStore caps total segment size at 100 MiB, so the worst case is roughly 200 MiB of synchronous reads plus two full JSON parse passes.

The comment at Line 312 states that the parent map is built "in a streaming fashion to avoid loading all events into memory", but readFileSync loads each whole segment.

Two options reduce the cost:

  • Build the parent map during the single migration pass and resolve rootTaskId in a second, database-only step.
  • Move migrate() off the activation path, for example behind an idle callback or a first dashboard open.

Also applies to: 314-331

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stats/UsageStatsMigration.ts` around lines 101 - 105, The
migration currently performs two synchronous full reads and JSON parses of every
segment during activation. Update UsageStatsMigration.migrate and buildParentMap
to avoid the duplicate segment pass, preferably collecting parent mappings
during the existing migration read and resolving rootTaskId in a database-only
follow-up step; ensure migration is no longer blocking activation if the
existing initialization flow permits deferring it.
src/services/stats/UsageStatsProjection.ts-594-604 (1)

594-604: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Day-axis buckets will not sum to the totals when custom pricing is active.

Lines 552-582 recompute cacheDiscountBase from usage_events and override the totals cost. The model/provider/mode axes get the same treatment at Lines 618-633. The day axis does not: it keeps the stored value, which the comment states is 0 for custom models.

A dashboard that renders a per-day chart next to the totals card therefore shows two different cost figures for the same query. The gap widens with the number of custom-priced events.

Consider deriving the day-axis discount base with a per-day, per-(provider, model) query, or excluding the discount from the totals as well so both figures use the same basis until the query exists.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stats/UsageStatsProjection.ts` around lines 594 - 604, Align the
day-axis bucket costs with the totals by applying the same custom-pricing cache
discount-base recomputation used in the totals and model/provider/mode axes.
Update the day-axis flow around dailyRowToBucket and queryDailyRollupsDetailed
to derive the value per day and provider/model, or consistently exclude this
discount from all corresponding totals until that query is available; do not
leave day buckets using the stored zero value for custom models.
src/services/stats/UsageAggregator.ts-185-220 (1)

185-220: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

resolveTimeRange runs per event and builds uncached Intl.DateTimeFormat objects.

computeEventContribution() calls resolveTimeRange(query) at Line 527 for every event, and UsageStatsProjection.applyEventToProjection() calls it per event as well. Each preset call chain constructs four Intl.DateTimeFormat instances: two in toTimezoneDate, one in startOfDayInTimezone, and one in getTimezoneOffsetMinutes. This file already memoizes formatters at Line 226 for bucket computation, so the same treatment is missing here.

Two changes fix the hot path:

  • Route startOfDayInTimezone and getTimezoneOffsetMinutes through the memoized formatter cache.
  • Resolve the range once per query and pass it into computeEventContribution, instead of resolving it inside the per-event function.

A second, separate problem exists in the preset branches. startOfDayInTimezone returns the UTC instant of midnight in query.timezone, but Lines 194, 199, 201, 206, and 208 then use setDate/getDate, which operate in the host's local timezone. When the host local timezone crosses a DST boundary inside the computed span, the range shifts by one hour. Use UTC arithmetic (setUTCDate/getUTCDate) or add explicit day offsets in milliseconds.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stats/UsageAggregator.ts` around lines 185 - 220, The
resolveTimeRange hot path needs memoized timezone formatters and consistent UTC
date arithmetic. Route startOfDayInTimezone and getTimezoneOffsetMinutes through
the existing formatter cache, resolve the range once per query before per-event
processing, and pass it into computeEventContribution and
UsageStatsProjection.applyEventToProjection rather than resolving per event. In
resolveTimeRange, replace host-local setDate/getDate operations in the today,
7d, and 30d preset branches with UTC-based arithmetic so DST in the host
timezone cannot shift the range.
src/services/stats/DashboardTaskProjection.ts-193-193 (1)

193-193: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

getTotalTokens disagrees with the aggregator's token definition.

getTotalTokens prefers the stored event.usage.totalTokens.value and only falls back to input + output. UsageAggregator.computeEventDelta takes the opposite approach: Lines 462-465 of src/services/stats/UsageAggregator.ts state that totalTokens is recomputed from input + output specifically "to repair historical events that may have been persisted with the old double-counted sum".

For any event written by the earlier recorder, the task detail total at Line 193 shows the double-counted value while the dashboard summary shows the repaired value. The two cards then disagree for the same task.

Align this helper with the aggregator.

🐛 Proposed fix
 function getTotalTokens(event: UsageEventV1): number {
-	return (
-		event.usage.totalTokens?.value ?? (event.usage.inputTokens?.value ?? 0) + (event.usage.outputTokens?.value ?? 0)
-	)
+	// Match UsageAggregator.computeEventDelta: totalTokens is always
+	// input + output. The stored value may carry the old double-counted sum.
+	return (event.usage.inputTokens?.value ?? 0) + (event.usage.outputTokens?.value ?? 0)
 }

Also applies to: 356-360

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stats/DashboardTaskProjection.ts` at line 193, Update
getTotalTokens, used by the totalTokens reduce in DashboardTaskProjection and
the related path at lines 356-360, to always compute totals from inputTokens
plus outputTokens rather than preferring event.usage.totalTokens.value. Match
UsageAggregator.computeEventDelta so historical double-counted totals are
repaired consistently.
src/services/stats/UsageStatsService.ts-232-236 (1)

232-236: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

ensureInitialized() does not initialize.

The method awaits initPromise only when that promise already exists. If a caller invokes ensureInitialized() before initialize() runs, the method resolves immediately and the caller proceeds against a service whose database, store, and coordinator are not ready. The name states the opposite guarantee.

activateDashboard starts initialize() without awaiting it (see src/activate/activateDashboard.ts), so this window is reachable during activation.

🐛 Proposed fix
 	async ensureInitialized(): Promise<void> {
-		if (this.initPromise) {
-			await this.initPromise
-		}
+		await this.initialize()
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stats/UsageStatsService.ts` around lines 232 - 236, Update
ensureInitialized() in UsageStatsService so it starts initialization when
initPromise is absent, then awaits the resulting promise in all cases. Reuse the
existing initialize() flow and initPromise state so callers always proceed only
after the database, store, and coordinator are ready.
src/services/stats/UsageEventStore.ts-247-258 (1)

247-258: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Refresh the cached segment stat after an incremental push.

The incremental update pushes the event into cachedEvents but leaves cachedActiveSegmentSize and cachedActiveSegmentMtimeMs at their pre-append values. The next readAll() compares those stale values against the new on-disk size and mtime, so the warm-hit check at Lines 295-301 always fails. The result is a full scanAllSegments() on every read that follows an append, which removes the benefit of the incremental push.

Stat the active segment after the write and store the new values.

♻️ Proposed fix
 					const manifest = await this.loadOrCreateManifest()
 					if (this.cachedSegmentCount !== manifest.currentSegment) {
 						this.invalidateCache()
 					} else {
 						this.cachedEvents.push(event)
+						const activeStat = await fs
+							.stat(this.getSegmentPath(manifest.currentSegment))
+							.catch(() => null)
+						if (activeStat) {
+							this.cachedActiveSegmentSize = activeStat.size
+							this.cachedActiveSegmentMtimeMs = activeStat.mtimeMs
+						} else {
+							this.invalidateCache()
+						}
 					}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stats/UsageEventStore.ts` around lines 247 - 258, In the
incremental cache-update branch of UsageEventStore, refresh
cachedActiveSegmentSize and cachedActiveSegmentMtimeMs after pushing the event
into cachedEvents by statting the active segment written by the append and
storing its current size and modification time. Preserve the existing manifest
mismatch invalidation path and only update these cached stats when the cache
remains valid.
src/services/stats/UsageEventStore.ts-549-564 (1)

549-564: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Re-read the manifest and re-check the idempotency key inside the lock.

Two problems exist on this path:

  1. The doc comment at Line 217 states that duplicates are checked "within the lock", but the check at Line 550 runs before acquireManifestLock(). A second VS Code window can append the same idempotencyKey concurrently.
  2. loadOrCreateManifest() returns this.manifest from memory once it is cached (Line 636). It never re-reads the file. After acquiring the cross-process lock, this window therefore uses a stale generation and currentSegment. If another window ran clear() (which bumps the generation and moves segments) or rotated a segment, this window appends into the wrong segment and keeps a stale idempotency set.

Read the manifest from disk after the lock is acquired, then evaluate the dedupe set against the current generation.

🐛 Suggested direction
-		// Idempotency check
-		if (this.idempotencyKeys.has(event.idempotencyKey)) {
-			return false
-		}
-
 		let releaseLock: () => Promise<void> = async () => {}
 
 		try {
 			releaseLock = await this.acquireManifestLock()
 		} catch (err) {
 			throw new StatsStoreError("STATS_STORE/append/002", "Failed to acquire manifest lock for append", err)
 		}
 
 		try {
-			const manifest = await this.loadOrCreateManifest()
+			// Discard the in-memory copy so the on-disk manifest wins after
+			// another window mutated generation/currentSegment.
+			this.manifest = null
+			const manifest = await this.loadOrCreateManifest()
+			if (this.idempotencyKeys.has(event.idempotencyKey)) {
+				return false
+			}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stats/UsageEventStore.ts` around lines 549 - 564, Move the
idempotency check in the append flow to after acquireManifestLock(), then reload
the manifest from disk while holding the lock instead of using the cached result
from loadOrCreateManifest(). Refresh the in-memory manifest and
generation-specific idempotency state before checking event.idempotencyKey and
selecting the segment via getSegmentPath, preserving the duplicate return
behavior.

Comment thread src/services/stats/UsageStatsStreamCoordinator.ts
Zoo (VP) added 3 commits August 12, 2026 15:54
…ests

Three dashboard visual tests (DashboardSummary, DashboardView, UsageHeatmap) failed under Playwright CT.

Root causes fixed:

- ExtensionStateContextProvider pulled @roo-code/types/Zod into the browser bundle; added a Zod-free ExtensionStateContext mock and exact-match aliases in playwright-ct.config.ts.

- Playwright CT instantiated @/i18n/TranslationContext twice when translation helpers lived in the test file, so components read a different context and rendered empty labels; moved provider wiring into dedicated component-only fixtures.

- StandardTooltip requires a TooltipProvider ancestor; added it to all three fixtures.

- Corrected summary-card label assertions to match en/dashboard.json casing.

Also includes UsageStatsStreamCoordinator snapshot-fallback fix (delta double-send / cursor pollution guard).

Verified: playwright-ct --update-snapshots 11/11 pass; eslint clean.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
webview-ui/src/components/dashboard/__tests__/UsageHeatmap.visual.fixture.tsx (1)

39-46: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Include zero-intensity days in the visual fixture.

Line 45 always returns at least 500. The fixture does not render the level-0 cells that its comment claims to cover. Add deterministic zero values so the screenshot can detect inactive-cell color and style regressions.

Proposed fix
 const values: number[] = Array.from({ length: 60 }, (_, i) => {
+	if (i % 13 === 0) {
+		return 0
+	}
 	const wave = Math.sin(i / 4) * 0.5 + 0.5
 	const weekend = i % 7 === 0 || i % 7 === 6 ? 0.2 : 1
 	return Math.round(500 + 10_000 * wave * weekend)
 })

As per coding guidelines: “Add a visual snapshot when a change visibly affects … empty states.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@webview-ui/src/components/dashboard/__tests__/UsageHeatmap.visual.fixture.tsx`
around lines 39 - 46, Update the values generator in the UsageHeatmap visual
fixture so some deterministic entries return 0, ensuring level-0 inactive days
are rendered alongside the existing intensity levels. Preserve the 60-day
oldest-first sequence and keep the generated values suitable for exercising
levels 1–5.

Source: Coding guidelines

webview-ui/src/components/dashboard/__tests__/DashboardSummary.visual.fixture.tsx (1)

36-39: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document or replace both double assertions.

Both fixtures use null as unknown as ... for i18n without a nearby explanation. This bypasses TypeScript type checking. Use a typed test double if practical. If the assertion must remain, add a nearby comment that states why it is safe.

  • webview-ui/src/components/dashboard/__tests__/DashboardSummary.visual.fixture.tsx#L36-L39: document why the fixture can supply a null i18n value.
  • webview-ui/src/components/dashboard/__tests__/UsageHeatmap.visual.fixture.tsx#L34-L37: apply the same documentation or typed test-double pattern.

As per coding guidelines: “Use double assertions only as a last resort and explain them with a comment.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@webview-ui/src/components/dashboard/__tests__/DashboardSummary.visual.fixture.tsx`
around lines 36 - 39, Replace the null double assertions for i18n with a typed
test double where practical; otherwise add a nearby comment explaining why null
is safe. Apply this consistently in translationContextValue in
webview-ui/src/components/dashboard/__tests__/DashboardSummary.visual.fixture.tsx
(lines 36-39) and
webview-ui/src/components/dashboard/__tests__/UsageHeatmap.visual.fixture.tsx
(lines 34-37).

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@webview-ui/src/components/dashboard/__tests__/DashboardSummary.visual.fixture.tsx`:
- Around line 36-39: Replace the null double assertions for i18n with a typed
test double where practical; otherwise add a nearby comment explaining why null
is safe. Apply this consistently in translationContextValue in
webview-ui/src/components/dashboard/__tests__/DashboardSummary.visual.fixture.tsx
(lines 36-39) and
webview-ui/src/components/dashboard/__tests__/UsageHeatmap.visual.fixture.tsx
(lines 34-37).

In
`@webview-ui/src/components/dashboard/__tests__/UsageHeatmap.visual.fixture.tsx`:
- Around line 39-46: Update the values generator in the UsageHeatmap visual
fixture so some deterministic entries return 0, ensuring level-0 inactive days
are rendered alongside the existing intensity levels. Preserve the 60-day
oldest-first sequence and keep the generated values suitable for exercising
levels 1–5.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7b323954-c054-4ef3-b399-a3486bc7275d

📥 Commits

Reviewing files that changed from the base of the PR and between e7033c8 and a7f8d63.

⛔ Files ignored due to path filters (3)
  • webview-ui/src/components/dashboard/__tests__/__screenshots__/dashboard-summary-dark.png is excluded by !**/*.png
  • webview-ui/src/components/dashboard/__tests__/__screenshots__/dashboard-view-dark.png is excluded by !**/*.png
  • webview-ui/src/components/dashboard/__tests__/__screenshots__/usage-heatmap-dark.png is excluded by !**/*.png
📒 Files selected for processing (8)
  • src/services/stats/UsageStatsStreamCoordinator.ts
  • webview-ui/playwright-ct.config.ts
  • webview-ui/playwright/ExtensionStateContext.mock.tsx
  • webview-ui/src/components/dashboard/__tests__/DashboardSummary.visual.fixture.tsx
  • webview-ui/src/components/dashboard/__tests__/DashboardSummary.visual.tsx
  • webview-ui/src/components/dashboard/__tests__/DashboardView.visual.fixture.tsx
  • webview-ui/src/components/dashboard/__tests__/UsageHeatmap.visual.fixture.tsx
  • webview-ui/src/components/dashboard/__tests__/UsageHeatmap.visual.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
  • webview-ui/src/components/dashboard/tests/DashboardView.visual.fixture.tsx
  • webview-ui/src/components/dashboard/tests/DashboardSummary.visual.tsx
  • src/services/stats/UsageStatsStreamCoordinator.ts

@github-actions github-actions Bot added the awaiting-review PR changes are ready and waiting for maintainer re-review label Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting-review PR changes are ready and waiting for maintainer re-review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] Local Usage Statistics Dashboard

1 participant