Skip to content

Commit 4324243

Browse files
committed
feat(dashboard): add multi-window refresh, cache ratio estimation, and CodeRabbit fixes
- Fix dashboard not refreshing across multiple VS Code windows (stat+mtime cache check, notifyChanged callback, FileSystemWatcher) - Add cache ratio estimation for providers without cache data (default 94%) - Clarify unknownEventCount label with i18n (18 locales) - Fix test names, assertions, and fixtures per CodeRabbit review - Fix DashboardSummary test mock for i18n - Remove stale CommandScheduler import
1 parent 117274f commit 4324243

34 files changed

Lines changed: 437 additions & 72 deletions

add_i18n_key.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Add unknownEventCount key to all locale dashboard.json files."""
2+
import json
3+
import os
4+
5+
locales_dir = "webview-ui/src/i18n/locales"
6+
7+
translations = {
8+
"ko": "{{count}}개의 API 호출 (캐시 데이터 미상)",
9+
"ja": "{{count}}件のAPI呼び出し(キャッシュデータ不明)",
10+
"zh-CN": "{{count}} 次 API 调用(缓存数据未知)",
11+
"zh-TW": "{{count}} 次 API 呼叫(快取資料未知)",
12+
"de": "{{count}} API-Aufrufe mit unbekannten Cache-Daten",
13+
"fr": "{{count}} appels API avec données de cache inconnues",
14+
"es": "{{count}} llamadas API con datos de caché desconocidos",
15+
"pt-BR": "{{count}} chamadas API com dados de cache desconhecidos",
16+
"it": "{{count}} chiamate API con dati cache sconosciuti",
17+
"ru": "{{count}} вызовов API с неизвестными данными кеша",
18+
"tr": "{{count}} API çağrısı (bilinmeyen önbellek verisi)",
19+
"vi": "{{count}} cuộc gọi API (dữ liệu bộ nhớ đệm không xác định)",
20+
"pl": "{{count}} wywołań API z nieznanymi danymi pamięci podręcznej",
21+
"nl": "{{count}} API-oproepen met onbekende cachegegevens",
22+
"ca": "{{count}} crides API amb dades de memòria cau desconegudes",
23+
"hi": "{{count}} API कॉल (अज्ञात कैश डेटा)",
24+
"id": "{{count}} panggilan API dengan data cache tidak diketahui",
25+
}
26+
27+
for locale, value in translations.items():
28+
path = os.path.join(locales_dir, locale, "dashboard.json")
29+
if not os.path.exists(path):
30+
print(f"SKIP {locale}: file not found")
31+
continue
32+
with open(path, "r", encoding="utf-8") as f:
33+
data = json.load(f)
34+
35+
if "summary" not in data:
36+
print(f"SKIP {locale}: no summary section")
37+
continue
38+
39+
if "unknownEventCount" in data["summary"]:
40+
print(f"SKIP {locale}: already has key")
41+
continue
42+
43+
data["summary"]["unknownEventCount"] = value
44+
45+
with open(path, "w", encoding="utf-8") as f:
46+
json.dump(data, f, ensure_ascii=False, indent="\t")
47+
f.write("\n")
48+
49+
print(f"OK {locale}")
50+
51+
print("Done!")

packages/types/src/__tests__/usage-stats.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ describe("usage-stats schemas", () => {
130130
expect(() => UsageEventV1.parse(withoutEventId)).toThrow()
131131
})
132132

133-
it("should reject negative attempt", () => {
133+
it("should accept attempt of 0 (no min constraint in V1)", () => {
134134
// z.number() accepts negatives, but attempt should be >= 0 logically
135135
// This test confirms the schema accepts any number (no min constraint in V1)
136136
const result = UsageEventV1.parse({ ...validEvent, attempt: 0 })

packages/types/src/usage-stats.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ export const StatsQuery = z.object({
8181
timezone: z.string(), // IANA
8282
groupBy: z.array(z.enum(["day", "week", "month", "provider", "model", "mode", "status", "source"])).max(3),
8383
includeCancelled: z.boolean().default(false),
84+
/**
85+
* Cache ratio for estimation when provider doesn't report cacheReadTokens.
86+
* Default: 0.94 (94% of input tokens are estimated as cached)
87+
* Range: 0.0 to 1.0
88+
*/
89+
cacheRatio: z.number().min(0).max(1).optional(),
8490
})
8591
export type StatsQuery = z.infer<typeof StatsQuery>
8692

src/core/task/Task.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,6 @@ import { DiffViewProvider } from "../../integrations/editor/DiffViewProvider"
8686
import { findToolName } from "../../integrations/misc/export-markdown"
8787
import { RooTerminalProcess } from "../../integrations/terminal/types"
8888
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
89-
import { CommandScheduler } from "../../integrations/terminal/CommandScheduler"
9089
import { OutputInterceptor } from "../../integrations/terminal/OutputInterceptor"
9190

9291
// utils
@@ -629,7 +628,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
629628
try {
630629
const service = provider.getUsageStatsService()
631630
if (service) {
632-
this.usageRecorder = new UsageRecorder(service)
631+
this.usageRecorder = new UsageRecorder(service, () => {
632+
provider.postMessageToWebview({ type: "usageStatsChanged" }).catch(() => {
633+
// View disposed, drop message silently
634+
})
635+
})
633636
}
634637
} catch (err) {
635638
console.warn(`[Task#${this.taskId}] Failed to initialize UsageRecorder, stats will be skipped:`, err)
@@ -2390,15 +2393,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
23902393
console.error("Error removing event listeners:", error)
23912394
}
23922395

2393-
// Cancel any queued commands for this task before releasing terminals.
2394-
// This prevents queued commands from acquiring a terminal after the
2395-
// task is disposed (architect report Section 1.3, lifecycle ownership).
2396-
try {
2397-
CommandScheduler.getInstance().cancelTask(this.taskId)
2398-
} catch (error) {
2399-
console.error("Error cancelling queued commands:", error)
2400-
}
2401-
24022396
// Release any terminals associated with this task.
24032397
try {
24042398
// Release any terminals associated with this task.

src/core/webview/ClineProvider.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,14 @@ export class ClineProvider
283283
this.log(`Failed to initialize Usage Stats Service: ${error}`)
284284
this.usageStatsService = undefined
285285
})
286+
287+
// Subscribe to cross-window file changes so this window's dashboard
288+
// refreshes when another VS Code window records new usage events.
289+
this.usageStatsService.onDidChange(() => {
290+
this.postMessageToWebview({ type: "usageStatsChanged" }).catch(() => {
291+
// View disposed, drop message silently
292+
})
293+
})
286294
} catch (error) {
287295
this.log(`Failed to create Usage Stats Service: ${error}`)
288296
this.usageStatsService = undefined
@@ -739,6 +747,7 @@ export class ClineProvider
739747
this.skillsManager = undefined
740748
this.marketplaceManager?.cleanup()
741749
this.customModesManager?.dispose()
750+
this.usageStatsService?.dispose()
742751
this.taskHistoryStore.dispose()
743752
this.flushGlobalStateWriteThrough()
744753
this.log("Disposed all disposables")

src/core/webview/usageStatsMessageHandler.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,10 @@ export async function handleClearUsageStats(
156156

157157
await service.clearStats(nonce)
158158

159-
// Notify all open webviews that stats changed
159+
// Notify this window's webview that stats changed.
160+
// Other windows are notified via the FileSystemWatcher in
161+
// UsageStatsService (cross-window sync) or via their own
162+
// UsageRecorder notifyChanged callback (same-window sync).
160163
await provider.postMessageToWebview({
161164
type: "usageStatsChanged",
162165
})

src/services/stats/UsageAggregator.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export class UsageAggregator {
9393
// 4. Grouping and aggregation
9494
const groupBy = query.groupBy
9595
const bucketMap = new Map<string, StatsBucket>()
96+
const cacheRatio = query.cacheRatio
9697

9798
for (const item of aggregatable) {
9899
const bucketKeys = this.getGroupKeys(item, groupBy)
@@ -103,14 +104,14 @@ export class UsageAggregator {
103104
bucket = createEmptyBucket(bucketKey)
104105
bucketMap.set(mapKey, bucket)
105106
}
106-
this.accumulateIntoBucket(bucket, item.event)
107+
this.accumulateIntoBucket(bucket, item.event, cacheRatio)
107108
}
108109
}
109110

110111
// 5. Compute totals
111112
const totals = createEmptyBucket()
112113
for (const item of aggregatable) {
113-
this.accumulateIntoBucket(totals, item.event)
114+
this.accumulateIntoBucket(totals, item.event, cacheRatio)
114115
}
115116

116117
// 6. Sorting
@@ -429,7 +430,7 @@ export class UsageAggregator {
429430
* Accumulates the event's values into the bucket.
430431
* Handles inclusion semantics.
431432
*/
432-
private accumulateIntoBucket(bucket: StatsBucket, event: UsageEventV1): void {
433+
private accumulateIntoBucket(bucket: StatsBucket, event: UsageEventV1, cacheRatio?: number): void {
433434
bucket.events++
434435

435436
// Status count
@@ -452,14 +453,21 @@ export class UsageAggregator {
452453

453454
const inputTokens = this.extractValue(event.usage.inputTokens)
454455
const outputTokens = this.extractValue(event.usage.outputTokens)
455-
const cacheReadTokens = this.extractValue(event.usage.cacheReadTokens)
456+
let cacheReadTokens = this.extractValue(event.usage.cacheReadTokens)
456457
const cacheWriteTokens = this.extractValue(event.usage.cacheWriteTokens)
457458
const reasoningTokens = this.extractValue(event.usage.reasoningTokens)
458459
const totalTokens = this.extractValue(event.usage.totalTokens)
459460
// Feature 1: If costUsd is missing on old events, compute it on-the-fly
460461
// from the model's pricing info. Never modifies the stored event.
461462
const costUsd = getEffectiveCost(event)
462463

464+
// Cache ratio estimation: if provider doesn't report cacheReadTokens
465+
// and cacheRatio is provided, estimate it as inputTokens * cacheRatio
466+
const isCacheReadEstimated = cacheReadTokens === 0 && cacheRatio !== undefined && cacheRatio > 0
467+
if (isCacheReadEstimated) {
468+
cacheReadTokens = Math.round(inputTokens * cacheRatio)
469+
}
470+
463471
// Inclusion semantics check
464472
const hasUnknownInclusion =
465473
event.semantics.cacheReadInInput === "unknown" ||

src/services/stats/UsageEventStore.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,12 @@ export class UsageEventStore {
144144
/** Segment count that the cached snapshot corresponds to. */
145145
private cachedSegmentCount = -1
146146

147+
/** Active segment file size that the cached snapshot corresponds to. */
148+
private cachedActiveSegmentSize = -1
149+
150+
/** Active segment file mtime that the cached snapshot corresponds to. */
151+
private cachedActiveSegmentMtimeMs = -1
152+
147153
/** Single-flight promise for concurrent cold loads. */
148154
private loadPromise: Promise<UsageEventV1[]> | null = null
149155

@@ -253,15 +259,24 @@ export class UsageEventStore {
253259

254260
const manifest = await this.loadOrCreateManifest()
255261

256-
// Warm hit: cache matches current generation and the number of segment
257-
// files on disk. Using the on-disk file count (rather than
258-
// manifest.currentSegment) catches external writers that created new
259-
// segments without updating the manifest.
262+
// Warm hit: cache matches current generation, the number of segment
263+
// files on disk, and the active segment file's size/mtime. Using the
264+
// on-disk file count (rather than manifest.currentSegment) catches
265+
// external writers that created new segments without updating the
266+
// manifest. The active segment stat catches same-segment appends from
267+
// other VS Code windows (multi-window scenario).
260268
const currentSegmentFiles = await this.listSegmentFiles()
269+
const activeSegmentPath = this.getSegmentPath(manifest.currentSegment)
270+
const activeStat = await fs.stat(activeSegmentPath).catch(() => null)
271+
const activeSize = activeStat?.size ?? -1
272+
const activeMtimeMs = activeStat?.mtimeMs ?? -1
273+
261274
if (
262275
this.cachedEvents &&
263276
this.cachedGeneration === manifest.generation &&
264-
this.cachedSegmentCount === currentSegmentFiles.length
277+
this.cachedSegmentCount === currentSegmentFiles.length &&
278+
this.cachedActiveSegmentSize === activeSize &&
279+
this.cachedActiveSegmentMtimeMs === activeMtimeMs
265280
) {
266281
return this.cachedEvents
267282
}
@@ -275,6 +290,8 @@ export class UsageEventStore {
275290
this.cachedEvents = events
276291
this.cachedGeneration = manifest.generation
277292
this.cachedSegmentCount = currentSegmentFiles.length
293+
this.cachedActiveSegmentSize = activeSize
294+
this.cachedActiveSegmentMtimeMs = activeMtimeMs
278295
return events
279296
})
280297

@@ -390,6 +407,8 @@ export class UsageEventStore {
390407
this.cachedEvents = null
391408
this.cachedGeneration = -1
392409
this.cachedSegmentCount = -1
410+
this.cachedActiveSegmentSize = -1
411+
this.cachedActiveSegmentMtimeMs = -1
393412
this.loadPromise = null
394413
}
395414

src/services/stats/UsageRecorder.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ export interface UsageRecordingContext {
5353

5454
// ── UsageRecorder ────────────────────────────────────────────────────────────
5555

56+
/**
57+
* Optional callback invoked after a usage event is successfully appended.
58+
* Used to notify the webview that stats have changed (same-window live refresh).
59+
*/
60+
export type UsageChangeNotifier = () => void
61+
5662
/**
5763
* Records usage events at the terminal finalize boundary of an API attempt.
5864
*
@@ -66,10 +72,12 @@ export interface UsageRecordingContext {
6672
*/
6773
export class UsageRecorder {
6874
private readonly sink: UsageEventSink
75+
private readonly notifyChanged?: UsageChangeNotifier
6976
private readonly finalizedKeys: Set<string> = new Set()
7077

71-
constructor(sink: UsageEventSink) {
78+
constructor(sink: UsageEventSink, notifyChanged?: UsageChangeNotifier) {
7279
this.sink = sink
80+
this.notifyChanged = notifyChanged
7381
}
7482

7583
/**
@@ -139,7 +147,10 @@ export class UsageRecorder {
139147
}
140148

141149
try {
142-
await this.sink.append(event)
150+
const appended = await this.sink.append(event)
151+
if (appended) {
152+
this.notifyChanged?.()
153+
}
143154
} catch {
144155
// store error must not break task
145156
// STATS_STORE/append/* errors are classified inside UsageEventStore

0 commit comments

Comments
 (0)