diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 813f6f1fc..792a6d749 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -365,7 +365,8 @@ printf %s "$TOKEN" | ade secrets set TOKEN --stdin ade secrets set TOKEN --value-file token.txt ade secrets delete STRIPE_API_KEY ade usage snapshot --text -ade usage refresh --text +ade --role cto usage refresh --text # live Claude/Codex quota only +ade --role cto usage refresh --history --text # local provider history + costs ade usage budget get --text ade usage budget set --from-file budget.json ade usage budget check --provider claude --scope global diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 6f6178d59..0a23c27cc 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -132,7 +132,9 @@ function createRuntime() { daily: [], })), getUsageSnapshot: vi.fn(() => ({ available: true, entries: [] })), + noteQuotaDemand: vi.fn(() => ({ available: true, entries: [] })), forceRefresh: vi.fn(async () => ({ available: true, entries: [] })), + refreshHistory: vi.fn(async () => ({ available: true, entries: [] })), poll: vi.fn(async () => ({ available: true, entries: [] })), start: vi.fn(() => {}), stop: vi.fn(() => {}), @@ -2599,6 +2601,12 @@ describe("adeRpcServer", () => { expect(usageActions.structuredContent.actions).toContainEqual( expect.objectContaining({ domain: "usage", action: "getAdeUsageStats", name: "usage.getAdeUsageStats" }), ); + expect(usageActions.structuredContent.actions).toContainEqual( + expect.objectContaining({ domain: "usage", action: "noteQuotaDemand", name: "usage.noteQuotaDemand" }), + ); + expect(usageActions.structuredContent.actions).not.toContainEqual( + expect.objectContaining({ domain: "usage", action: "refreshHistory" }), + ); const allDomains = await callTool(handler, "list_ade_actions", { domain: "all" }); expect(allDomains?.isError).toBeUndefined(); @@ -2727,6 +2735,15 @@ describe("adeRpcServer", () => { expect(fixture.runtime.usageTrackingService.getAdeUsageStats).toHaveBeenCalledWith({ preset: "7d" }); expect(usageStats.structuredContent.result).toMatchObject({ preset: "7d", daily: [] }); + const quotaDemand = await callTool(handler, "run_ade_action", { + domain: "usage", + action: "noteQuotaDemand", + args: {}, + }); + expect(quotaDemand?.isError).toBeUndefined(); + expect(fixture.runtime.usageTrackingService.noteQuotaDemand).toHaveBeenCalledWith(undefined); + expect(quotaDemand.structuredContent.result).toEqual({ available: true, entries: [] }); + }); it("records normalized ADE Code actions without recording terminal keystrokes", async () => { diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index dd2dc7510..ea1ea8bd3 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -1300,7 +1300,12 @@ export async function createAdeRuntime(args: { }); const detachPushSources = publishPushEvents ? pushPublisherService.attachSources(projectId, { - agentChatService: agentChatService ?? null, + // The lightweight no-agent headless chat stub intentionally exposes + // only its request/response surface. Do not treat it as an event + // source unless it implements the full subscription contract. + agentChatService: typeof agentChatService?.subscribeToEvents === "function" + ? agentChatService + : null, ptyService, subscribePrNotifications: (cb) => { pushPrNotificationSubscribers.add(cb); diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 293328879..a7a181939 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -7382,6 +7382,15 @@ describe("ADE CLI", () => { expect(polled.kind).toBe("execute"); if (polled.kind !== "execute") return; expect(polled.steps[0]?.params).toEqual(plan.steps[0]?.params); + + const history = buildCliPlan(["usage", "refresh", "--history"]); + expect(history.kind).toBe("execute"); + if (history.kind !== "execute") return; + expect(history.label).toBe("usage history refresh"); + expect(history.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { domain: "usage", action: "refreshHistory", args: {} }, + }); }); it("usage budget get routes to the budget.getConfig action", () => { diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index e6969928d..3e9706c03 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1961,19 +1961,17 @@ const HELP_BY_COMMAND: Record = { usage: `${ADE_BANNER} Usage and provider quotas - Reads live provider quota usage (Claude five-hour + weekly, Codex five-hour + - weekly, Cursor monthly via the team Admin API), pacing, costs, and budget - guardrails. The desktop app surfaces this same data in the top-bar Usage popup. + Reads authoritative Claude and Codex quota windows, pacing, cached local + history, and budget guardrails. Live quota refresh is intentionally separate + from local provider-ledger scans. - $ ade usage snapshot --text Cached snapshot (windows, pacing, costs, errors) - $ ade usage refresh --text Force a fresh poll (invalidates cost cache) + $ ade usage snapshot --text Cached quota, source/stale state, and history + $ ade --role cto usage refresh --text Refresh live provider quota only + $ ade --role cto usage refresh --history --text Scan local provider history and costs $ ade usage budget get --text Read budget guardrail config $ ade usage budget set --from-file budget.json Save budget guardrail config $ ade usage budget check --provider claude --scope global $ ade usage budget cumulative --scope global Cumulative spend for the current week - - Cursor uses the Admin API (https://api.cursor.com/teams/spend) — set - CURSOR_ADMIN_API_KEY (or CURSOR_API_KEY) so the poll can authenticate. `, secrets: `${ADE_BANNER} ADE project secrets @@ -9764,10 +9762,11 @@ function buildUsagePlan(args: string[]): CliPlan { }; } if (sub === "refresh" || sub === "poll") { + const history = args.includes("--history"); return { kind: "execute", - label: "usage refresh", - steps: [actionStep("result", "usage", "forceRefresh", {})], + label: history ? "usage history refresh" : "usage refresh", + steps: [actionStep("result", "usage", history ? "refreshHistory" : "forceRefresh", {})], }; } if (sub === "budget") { diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index 32e698633..57f8e7218 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -112,7 +112,10 @@ function makePairingConnectInfo( describe("createSyncRemoteCommandService", () => { it("serves the cross-client usage snapshot to paired mobile and web clients", async () => { const getAdeUsageStats = vi.fn().mockResolvedValue({ generatedAt: "2026-07-09T12:00:00.000Z", daily: [] }); - const { service } = createService({ usageTrackingService: { getAdeUsageStats } }); + const quotaSnapshot = { windows: [], lastPolledAt: "2026-07-09T12:00:00.000Z", errors: [] }; + const getUsageSnapshot = vi.fn(() => quotaSnapshot); + const forceRefresh = vi.fn(async () => ({ ...quotaSnapshot, lastPolledAt: "2026-07-09T12:01:00.000Z" })); + const { service } = createService({ usageTrackingService: { getAdeUsageStats, getUsageSnapshot, forceRefresh } }); expect(service.getDescriptor("usage.getAdeStats")).toEqual({ action: "usage.getAdeStats", @@ -127,6 +130,29 @@ describe("createSyncRemoteCommandService", () => { await expect(service.execute(makePayload("usage.getAdeStats", { preset: "decade" }))).rejects.toThrow( "usage.getAdeStats preset must be today, 7d, 30d, year, or all.", ); + expect(service.getDescriptor("usage.getQuotaSnapshot")).toEqual({ + action: "usage.getQuotaSnapshot", + scope: "runtime", + policy: { viewerAllowed: true }, + }); + expect(service.getDescriptor("usage.refreshQuota")).toEqual({ + action: "usage.refreshQuota", + scope: "runtime", + policy: { viewerAllowed: true }, + }); + await expect(service.execute(makePayload("usage.getQuotaSnapshot"))).resolves.toEqual(quotaSnapshot); + await expect(service.execute(makePayload("usage.refreshQuota"))).resolves.toMatchObject({ + lastPolledAt: "2026-07-09T12:01:00.000Z", + }); + expect(getUsageSnapshot).toHaveBeenCalledTimes(1); + expect(forceRefresh).toHaveBeenCalledWith({ allowInteractiveAuth: false }); + + getAdeUsageStats.mockClear(); + await service.execute(makePayload("usage.getAdeStats", { preset: "7d", scope: "project" })); + expect(getAdeUsageStats).toHaveBeenCalledWith({ preset: "7d", scope: "project" }); + await expect(service.execute(makePayload("usage.getAdeStats", { scope: "galaxy" }))).rejects.toThrow( + "usage.getAdeStats scope must be machine or project.", + ); }); it("advertises and executes machine personal chats as runtime commands", async () => { diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index f1afc60db..64d60643f 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -176,7 +176,7 @@ import type { UpdatePrTitleArgs, WriteTextAtomicArgs, } from "../../../../desktop/src/shared/types"; -import { isAdeUsageRangePreset } from "../../../../desktop/src/shared/types"; +import { isAdeUsageRangePreset, isAdeUsageScope } from "../../../../desktop/src/shared/types"; import type { OrchestrationRunCreateRequest } from "../../../../desktop/src/shared/types/orchestration"; import { PERSONAL_CHAT_ACTIONS, isPersonalChatActionQueueable } from "../../../../desktop/src/shared/types/personalChats"; import { @@ -4527,11 +4527,26 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg if (preset && !isAdeUsageRangePreset(preset)) { throw new Error("usage.getAdeStats preset must be today, 7d, 30d, year, or all."); } + const scope = asTrimmedString(payload.scope); + if (scope && !isAdeUsageScope(scope)) { + throw new Error("usage.getAdeStats scope must be machine or project."); + } return await args.usageTrackingService.getAdeUsageStats({ ...(isAdeUsageRangePreset(preset) ? { preset } : {}), + ...(isAdeUsageScope(scope) ? { scope } : {}), }); }); + register("usage.getQuotaSnapshot", { viewerAllowed: true }, async () => { + if (!args.usageTrackingService) throw new Error("Usage quota is not available in this runtime."); + return args.usageTrackingService.getUsageSnapshot(); + }, "runtime"); + + register("usage.refreshQuota", { viewerAllowed: true }, async () => { + if (!args.usageTrackingService) throw new Error("Usage quota is not available in this runtime."); + return await args.usageTrackingService.forceRefresh({ allowInteractiveAuth: false }); + }, "runtime"); + registerLaneRemoteCommands({ args, register }); registerWorkRemoteCommands({ args, register }); registerProcessRemoteCommands({ args, register }); diff --git a/apps/ade-cli/src/services/sync/syncService.test.ts b/apps/ade-cli/src/services/sync/syncService.test.ts index 2e030e635..169536d6d 100644 --- a/apps/ade-cli/src/services/sync/syncService.test.ts +++ b/apps/ade-cli/src/services/sync/syncService.test.ts @@ -87,8 +87,10 @@ describe("createSyncService", () => { preset: "year", daily: [], })); + const getUsageSnapshot = vi.fn(() => ({ windows: [], lastPolledAt: "2026-07-09T12:00:00.000Z", errors: [] })); + const forceRefresh = vi.fn(async () => ({ windows: [], lastPolledAt: "2026-07-09T12:01:00.000Z", errors: [] })); const service = createService(db, projectRoot, { - usageTrackingService: { getAdeUsageStats } as any, + usageTrackingService: { getAdeUsageStats, getUsageSnapshot, forceRefresh } as any, }); try { @@ -103,6 +105,18 @@ describe("createSyncService", () => { args: { preset: "year" }, })).resolves.toMatchObject({ preset: "year", daily: [] }); expect(getAdeUsageStats).toHaveBeenCalledWith({ preset: "year" }); + await expect(service.executeRemoteCommand({ + commandId: "cmd-usage-quota", + action: "usage.getQuotaSnapshot", + args: {}, + })).resolves.toMatchObject({ lastPolledAt: "2026-07-09T12:00:00.000Z" }); + await expect(service.executeRemoteCommand({ + commandId: "cmd-refresh-quota", + action: "usage.refreshQuota", + args: {}, + })).resolves.toMatchObject({ lastPolledAt: "2026-07-09T12:01:00.000Z" }); + expect(getUsageSnapshot).toHaveBeenCalledTimes(1); + expect(forceRefresh).toHaveBeenCalledWith({ allowInteractiveAuth: false }); } finally { await service.dispose(); db.close(); diff --git a/apps/ade-cli/src/tuiClient/__tests__/RightPane.usage.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/RightPane.usage.test.tsx index 3d56dae71..fc008d674 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/RightPane.usage.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/RightPane.usage.test.tsx @@ -19,11 +19,20 @@ describe("RightPane usage", () => { const { lastFrame } = renderPane({ kind: "usage", title: "Usage", + providerStatuses: [{ + id: "claude", + label: "Claude", + state: "ok", + source: "oauth", + updatedAt: new Date().toISOString(), + }], quotaWindows: [{ id: "rate-limit", label: "Rate limit", percent: 42, resetAt }], session: { input: 12_000, output: 3_400, cost: 0.42 }, }); const text = lastFrame() ?? ""; expect(text).toContain("USAGE"); + expect(text).toContain("Claude"); + expect(text).toContain("OAUTH · just now"); expect(text).toContain("Rate limit"); expect(text).toContain("42%"); // Live reset countdown marker. @@ -35,6 +44,28 @@ describe("RightPane usage", () => { expect(text).toContain("$0.42"); }); + it("marks carried-forward provider data as stale without hiding quota windows", () => { + const { lastFrame } = renderPane({ + kind: "usage", + title: "Usage", + providerStatuses: [{ + id: "codex", + label: "Codex", + state: "stale", + source: "http", + updatedAt: new Date(Date.now() - 2 * 60_000).toISOString(), + message: "Rate limited; retrying soon", + }], + quotaWindows: [{ id: "codex:weekly", label: "Codex weekly", percent: 61, resetAt: null }], + session: null, + }); + const text = lastFrame() ?? ""; + expect(text).toContain("HTTP · 2m ago"); + expect(text).toContain("STALE · Rate limited; retrying soon"); + expect(text).toContain("Codex weekly"); + expect(text).toContain("61%"); + }); + it("degrades to the session block when quota windows are unavailable", () => { const { lastFrame } = renderPane({ kind: "usage", diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index ae9b172e1..e979447da 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -222,9 +222,13 @@ describe("commands", () => { it("keeps provider-agnostic skills visible outside Claude chats", () => { const rows = paletteCommands("/", [], { provider: "codex" }); - for (const name of ["/agents", "/init", "/usage", "/insights", "/fast"]) { + for (const name of ["/agents", "/init", "/insights", "/fast"]) { expect(rows).not.toContainEqual(expect.objectContaining({ name })); } + expect(rows).toContainEqual(expect.objectContaining({ + name: "/usage", + description: "Show Claude and Codex limits plus session usage", + })); expect(rows).toContainEqual(expect.objectContaining({ name: "/skills", description: "List agent skills from project, user, and ADE bundled roots", diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 8855c86d8..61a6aab32 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -41,7 +41,7 @@ import type { LaneDeleteRisk, LaneLinearIssue, LaneSummary } from "../../../desk import type { FeedbackPreparedDraft, FeedbackSubmission } from "../../../desktop/src/shared/types/feedback"; import type { ProjectSecretsListResult, ProjectSecretValueResult } from "../../../desktop/src/shared/types/projectSecrets"; import type { SearchQueryResult, SearchResultItem } from "../../../desktop/src/shared/types/search"; -import type { ChatTerminalPreviewResult, ChatTerminalSession } from "../../../desktop/src/shared/types"; +import type { ChatTerminalPreviewResult, ChatTerminalSession, UsageSnapshot } from "../../../desktop/src/shared/types"; import { DEFAULT_CODEX_REASONING_EFFORT, approveToolUse, @@ -9094,38 +9094,52 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return; } if (name === "/usage") { - if (!sessionId) { - addNotice("Usage needs an active chat.", "error"); - return; - } - if (activeCommandProvider !== "claude") { - addNotice("Usage is available for Claude chats.", "error"); - return; - } // Session tokens/cost come straight off the local event stream — always // available even when the daemon snapshot carries no quota window. Mirror // the token-summary effect's fallback-context resolution. const usageFallbackContext = activeSession?.modelId ? getModelById(activeSession.modelId)?.contextWindow ?? null : null; - const stats = latestTokenStats(events, usageFallbackContext); - const sessionBlock = { - input: stats.inputTokens, - output: stats.outputTokens, - cost: stats.costUsd, - }; - // Quota windows are reuse-only: the daemon exposes at most a single - // rate-limit window (parsed into stats.rateLimit). Render it when present, - // otherwise degrade to the session block. - const quotaWindows = stats.rateLimit?.usedPercentage != null - ? [{ - id: "rate-limit", - label: "Rate limit", - percent: stats.rateLimit.usedPercentage, - resetAt: stats.rateLimit.resetsAt, - }] - : undefined; - setRightPane({ kind: "usage", title: "Usage", quotaWindows, session: sessionBlock }); + const stats = sessionId ? latestTokenStats(events, usageFallbackContext) : null; + const sessionBlock = stats + ? { input: stats.inputTokens, output: stats.outputTokens, cost: stats.costUsd } + : null; + setRightPane({ kind: "usage", title: "Usage", loading: true, session: sessionBlock }); + try { + const snapshot = await conn.action("usage", "getUsageSnapshot", {}); + const providerStatuses = (["claude", "codex", "cursor"] as const).flatMap((provider) => { + const status = snapshot.providerStatus?.[provider]; + if (!status) return []; + return [{ + id: provider, + label: providerLabel(provider), + state: status.state, + source: status.source, + updatedAt: status.updatedAt ?? status.lastSuccessAt, + message: status.message, + }]; + }); + const quotaWindows = snapshot.windows.map((window, index) => { + const provider = providerLabel(window.provider); + const label = window.windowType === "five_hour" + ? "5-hour" + : window.windowType.replaceAll("_", " "); + return { + id: `${window.provider}:${window.windowType}:${index}`, + label: `${provider} ${label}`, + percent: window.percentUsed, + resetAt: Math.floor(Date.parse(window.resetsAt) / 1000), + }; + }); + setRightPane({ kind: "usage", title: "Usage", providerStatuses, quotaWindows, session: sessionBlock }); + } catch (error) { + setRightPane({ + kind: "usage", + title: "Usage", + session: sessionBlock, + error: error instanceof Error ? error.message : String(error), + }); + } return; } if (name === "/agents") { diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index 025a1865a..949b86b73 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -69,7 +69,7 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/secrets", description: "List project secret names and copy masked values", placement: "right", category: "Nav" }, { name: "/compact", description: "Compact the active chat context", placement: "chat", argumentHint: "[instructions]", providers: ["claude", "codex"], category: "Model" }, { name: "/init", description: "Generate AGENTS.md and Claude pointer files", placement: "right", providers: ["claude"], category: "Nav" }, - { name: "/usage", description: "Show Claude usage through the active SDK session", placement: "chat", providers: ["claude"], category: "Model" }, + { name: "/usage", description: "Show Claude and Codex limits plus session usage", placement: "chat", category: "Model" }, { name: "/insights", description: "Generate Claude session insights through the active SDK session", placement: "chat", providers: ["claude"], category: "Model" }, { name: "/fast", description: "Toggle Claude fast mode through the active SDK session", placement: "chat", argumentHint: "[on|off]", providers: ["claude"], category: "Model" }, { name: "/goal", description: "Set, clear, or inspect the active chat goal", placement: "chat", argumentHint: "[|clear|status active|paused|complete]", providers: ["claude", "codex"], category: "Model" }, diff --git a/apps/ade-cli/src/tuiClient/components/UsagePane.tsx b/apps/ade-cli/src/tuiClient/components/UsagePane.tsx index 4f490af63..570919199 100644 --- a/apps/ade-cli/src/tuiClient/components/UsagePane.tsx +++ b/apps/ade-cli/src/tuiClient/components/UsagePane.tsx @@ -7,6 +7,7 @@ import { useShimmerTick } from "../spinTick"; type UsageContent = Extract; type QuotaWindow = NonNullable[number]; +type ProviderStatus = NonNullable[number]; function endTruncate(value: string, max: number): string { if (max <= 1) return value.length ? "…" : ""; @@ -28,6 +29,36 @@ function formatCost(value: number | null | undefined): string { return `$${value.toFixed(2)}`; } +function formatUpdatedAt(updatedAt: string | null | undefined, nowMs: number): string { + if (!updatedAt) return "not updated"; + const timestamp = Date.parse(updatedAt); + if (!Number.isFinite(timestamp)) return "not updated"; + const ageMs = Math.max(0, nowMs - timestamp); + if (ageMs < 60_000) return "just now"; + if (ageMs < 3_600_000) return `${Math.max(1, Math.floor(ageMs / 60_000))}m ago`; + if (ageMs < 86_400_000) return `${Math.floor(ageMs / 3_600_000)}h ago`; + return `${Math.floor(ageMs / 86_400_000)}d ago`; +} + +function ProviderStatusRow({ status, nowMs }: { status: ProviderStatus; nowMs: number }) { + const source = status.source?.toUpperCase() ?? "WAITING"; + const isHealthy = status.state === "ok"; + const stateLabel = status.state === "unauthed" ? "SIGN IN" : status.state.toUpperCase(); + return ( + + + {status.label} + {`${source} · ${formatUpdatedAt(status.updatedAt, nowMs)}`} + + {!isHealthy ? ( + + {`${stateLabel} · ${status.message ?? (status.state === "stale" ? "Showing last known quota" : "Quota unavailable")}`} + + ) : null} + + ); +} + /** * Reset countdown. `resetAt` is an epoch in *seconds* (matching the daemon's * rate-limit `resetsAt` from `latestTokenStats`). `nowMs` is read fresh each @@ -113,10 +144,17 @@ export function UsagePane({ content, width }: { content: UsageContent; width: nu } const windows = content.quotaWindows ?? []; + const providerStatuses = content.providerStatuses ?? []; const session = content.session ?? null; return ( + {providerStatuses.map((status, index) => ( + + + + ))} + {windows.length ? ( windows.map((window, index) => ( 0 || index > 0 ? 1 : 0} /> )) ) : ( diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index ff698ce9b..ad7dc1e51 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -25,6 +25,7 @@ import type { ExternalSessionSummary, } from "../../../desktop/src/shared/types/externalSessions"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import type { UsageProviderSource, UsageProviderState } from "../../../desktop/src/shared/types/usage"; import type { BufferedEvent } from "../eventBuffer"; import type { HelpGroup } from "./helpIndex"; @@ -296,14 +297,19 @@ export type RightPaneContent = loadedAt?: number | null; } | { - // /usage pane: provider quota window(s) + this session's tokens & cost. - // `quotaWindows` undefined ⇒ no quota data in the snapshot (the daemon - // exposes at most a single rate-limit window today), so the pane degrades - // to the session block only. + // /usage pane: provider quota windows plus this session's tokens and cost. kind: "usage"; title?: string; loading?: boolean; error?: string | null; + providerStatuses?: Array<{ + id: string; + label: string; + state: UsageProviderState; + source?: UsageProviderSource; + updatedAt?: string | null; + message?: string | null; + }>; quotaWindows?: Array<{ id: string; label: string; percent: number; resetAt?: number | null }>; session?: { input: number | null; output: number | null; cost: number | null } | null; } diff --git a/apps/desktop/scripts/usage-oracle.mjs b/apps/desktop/scripts/usage-oracle.mjs new file mode 100644 index 000000000..ebeadb1fc --- /dev/null +++ b/apps/desktop/scripts/usage-oracle.mjs @@ -0,0 +1,347 @@ +#!/usr/bin/env -S npx tsx + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { _testing } from "../src/main/services/usage/usageTrackingService.ts"; + +const PROVIDERS = ["claude", "codex", "cursor", "gemini", "droid", "opencode", "copilot"]; +const NATIVE_PROVIDERS = new Set(["claude", "codex", "gemini", "opencode"]); +const METRICS = ["input", "output", "cacheRead"]; +const DEFAULT_CLOSED_RANGE = { label: "closed", from: "2026-07-01", to: "2026-07-09" }; + +const NATIVE_DIAGNOSES = { + claude: "Recursive Claude workflow-subagent discovery is enabled; remaining variance needs a sampled-ledger review.", + gemini: "Gemini uses native message token fields; remaining variance needs a sampled-session review.", + opencode: "OpenCode uses native SQLite message token fields; remaining variance needs a sampled-row review.", +}; + +function parseArgs(argv) { + const args = {}; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (!value.startsWith("--")) continue; + const key = value.slice(2); + const next = argv[index + 1]; + if (!next || next.startsWith("--")) args[key] = true; + else { + args[key] = next; + index += 1; + } + } + return args; +} + +function requireDirectory(value, label) { + if (!value || !fs.existsSync(value) || !fs.statSync(value).isDirectory()) { + throw new Error(`${label} is required and must be a directory: ${value ?? "(unset)"}`); + } + return path.resolve(value); +} + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function codeburnEnvironment() { + const env = { ...process.env, NO_COLOR: "1" }; + delete env.FORCE_COLOR; + return env; +} + +function runCodeburnReport(codeburnDir, range, provider) { + const args = [ + path.join(codeburnDir, "dist", "cli.js"), + "report", + "--from", range.from, + "--to", range.to, + "--format", "json", + ]; + if (provider) args.push("--provider", provider); + const raw = execFileSync(process.execPath, args, { + cwd: codeburnDir, + encoding: "utf8", + env: codeburnEnvironment(), + maxBuffer: 512 * 1024 * 1024, + stdio: ["ignore", "pipe", "inherit"], + }); + return JSON.parse(raw); +} + +function runCodeburnDailyExport(codeburnDir, range, provider) { + const tempPath = path.join(os.tmpdir(), `ade-codeburn-export-${process.pid}-${provider}-${range.label}-${Date.now()}.json`); + try { + execFileSync(process.execPath, [ + path.join(codeburnDir, "dist", "cli.js"), + "export", + "--from", range.from, + "--to", range.to, + "--provider", provider, + "--format", "json", + "--output", tempPath, + ], { + cwd: codeburnDir, + encoding: "utf8", + env: codeburnEnvironment(), + maxBuffer: 512 * 1024 * 1024, + stdio: ["ignore", "ignore", "inherit"], + }); + const report = JSON.parse(fs.readFileSync(tempPath, "utf8")); + return report.periods?.[0]?.daily ?? []; + } finally { + fs.rmSync(tempPath, { force: true }); + } +} + +function sumBreakdown(breakdown) { + return Object.values(breakdown ?? {}).reduce((total, model) => ({ + input: total.input + Number(model.input ?? 0), + output: total.output + Number(model.output ?? 0), + cacheRead: total.cacheRead + Number(model.cached ?? 0), + }), { input: 0, output: 0, cacheRead: 0 }); +} + +function filterEntries(entries, range, localDayKey) { + return entries.filter((entry) => { + const day = localDayKey(entry.timestamp); + return day && day >= range.from && day <= range.to; + }); +} + +function summarizeAde(entries, provider, range, aggregateCosts, buildCostSnapshots, localDayKey) { + const filtered = filterEntries(entries, range, localDayKey); + const adeOriginatedEntries = filtered.filter((entry) => ( + entry.adeOriginated || entry.originator?.trim().toLowerCase().startsWith("ade") + )); + const aggregate = aggregateCosts(filtered, provider); + const adeOriginatedAggregate = aggregateCosts(adeOriginatedEntries, provider); + const built = buildCostSnapshots(new Map([[provider, filtered]]), "machine", null)[0]; + const totals = sumBreakdown(aggregate.tokenBreakdownByPreset?.all); + const daily = Object.fromEntries(Object.entries(aggregate.dailyTokenBreakdownByPreset?.all ?? {}) + .map(([day, breakdown]) => [day, sumBreakdown(breakdown)])); + return { + ...totals, + entries: filtered.length, + daily, + estimation: built?.estimation ?? null, + adeOriginatedTokens: built?.adeOriginatedTokensByPreset?.all ?? 0, + adeOriginated: sumBreakdown(adeOriginatedAggregate.tokenBreakdownByPreset?.all), + }; +} + +function summarizeCodeburn(report) { + const tokens = report.overview?.tokens ?? {}; + return { + input: Number(tokens.input ?? 0), + output: Number(tokens.output ?? 0), + cacheRead: Number(tokens.cacheRead ?? 0), + }; +} + +function compareTotals(ade, codeburn) { + return Object.fromEntries(METRICS.map((metric) => { + const absolute = Math.abs(ade[metric] - codeburn[metric]); + const percent = codeburn[metric] === 0 + ? absolute === 0 ? 0 : Number.POSITIVE_INFINITY + : absolute / codeburn[metric] * 100; + return [metric, { ade: ade[metric], codeburn: codeburn[metric], absolute, percent }]; + })); +} + +function diagnosisFor(provider, ade, codeburn) { + if (provider !== "codex") return NATIVE_DIAGNOSES[provider]; + const externalOnly = Object.fromEntries(METRICS.map((metric) => [ + metric, + Math.max(0, ade[metric] - (ade.adeOriginated?.[metric] ?? 0)), + ])); + const externalComparison = compareTotals(externalOnly, codeburn); + return "Fixed recursive history truncation, cached-input double counting, and reasoning/output conflation. " + + "The remaining table delta is expected because ADE includes ADE-originated Codex sessions while codeburn only accepts `codex*` originators; " + + `external-only deltas are input ${formatPercent(externalComparison.input.percent)}, output ${formatPercent(externalComparison.output.percent)}, and cache read ${formatPercent(externalComparison.cacheRead.percent)}.`; +} + +function largestDailyDelta(adeDaily, codeburnDaily) { + const codeburnByDay = Object.fromEntries(codeburnDaily.map((row) => [row.Date, { + input: Number(row["Input Tokens"] ?? 0), + output: Number(row["Output Tokens"] ?? 0), + cacheRead: Number(row["Cache Read Tokens"] ?? 0), + }])); + const days = new Set([...Object.keys(adeDaily), ...Object.keys(codeburnByDay)]); + let largest = null; + for (const day of days) { + const ade = adeDaily[day] ?? { input: 0, output: 0, cacheRead: 0 }; + const codeburn = codeburnByDay[day] ?? { input: 0, output: 0, cacheRead: 0 }; + const absolute = METRICS.reduce((sum, metric) => sum + Math.abs(ade[metric] - codeburn[metric]), 0); + if (!largest || absolute > largest.absolute) largest = { day, absolute, ade, codeburn }; + } + return largest; +} + +function formatInteger(value) { + return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(value); +} + +function formatPercent(value) { + return Number.isFinite(value) ? `${value.toFixed(2)}%` : "∞"; +} + +function tableForRange(range, comparisons) { + const lines = [ + `### ${range.label === "closed" ? `${range.from} through ${range.to}` : `Today (${range.from})`}`, + "", + "| Provider | ADE input | codeburn input | Abs Δ | Δ% | ADE output | codeburn output | Abs Δ | Δ% | ADE cache read | codeburn cache read | Abs Δ | Δ% |", + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", + ]; + for (const provider of PROVIDERS) { + const result = comparisons[provider]; + lines.push(`| ${provider} | ${formatInteger(result.input.ade)} | ${formatInteger(result.input.codeburn)} | ${formatInteger(result.input.absolute)} | ${formatPercent(result.input.percent)} | ${formatInteger(result.output.ade)} | ${formatInteger(result.output.codeburn)} | ${formatInteger(result.output.absolute)} | ${formatPercent(result.output.percent)} | ${formatInteger(result.cacheRead.ade)} | ${formatInteger(result.cacheRead.codeburn)} | ${formatInteger(result.cacheRead.absolute)} | ${formatPercent(result.cacheRead.percent)} |`); + } + return lines.join("\n"); +} + +async function scanAdeLedgers() { + const { + scanClaudeLogs, + scanCodexLogs, + scanCursorLogs, + scanGeminiLogs, + scanDroidLogs, + scanOpenCodeLogs, + scanCopilotLogs, + } = _testing; + const values = await Promise.all([ + scanClaudeLogs(), + scanCodexLogs(), + scanCursorLogs(), + scanGeminiLogs(), + scanDroidLogs(), + scanOpenCodeLogs(), + scanCopilotLogs(), + ]); + return new Map(PROVIDERS.map((provider, index) => [provider, values[index]])); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const scriptDir = path.dirname(fileURLToPath(import.meta.url)); + const codeburnDir = requireDirectory(args["codeburn-dir"] ?? process.env.CODEBURN_DIR, "--codeburn-dir/CODEBURN_DIR"); + const outputDir = path.resolve(args["output-dir"] ?? process.env.USAGE_ORACLE_OUTPUT_DIR ?? path.join(os.tmpdir(), "ade-usage-oracle")); + fs.mkdirSync(outputDir, { recursive: true }); + + const closedRange = { + ...DEFAULT_CLOSED_RANGE, + from: args.from ?? DEFAULT_CLOSED_RANGE.from, + to: args.to ?? DEFAULT_CLOSED_RANGE.to, + }; + const todayKey = _testing.localDayKey(Date.now()); + const ranges = [closedRange, { label: "today", from: todayKey, to: todayKey }]; + const generatedAt = new Date().toISOString(); + + process.stderr.write("Scanning ADE provider ledgers...\n"); + const adeEntries = await scanAdeLedgers(); + const adeByRange = {}; + for (const range of ranges) { + adeByRange[range.label] = Object.fromEntries(PROVIDERS.map((provider) => [provider, summarizeAde( + adeEntries.get(provider) ?? [], + provider, + range, + _testing.aggregateCosts, + _testing.buildCostSnapshots, + _testing.localDayKey, + )])); + } + + const providerReports = {}; + const comparisonsByRange = {}; + for (const range of ranges) { + process.stderr.write(`Capturing codeburn ${range.label} provider reports...\n`); + providerReports[range.label] = {}; + comparisonsByRange[range.label] = {}; + for (const provider of PROVIDERS) { + const report = runCodeburnReport(codeburnDir, range, provider); + providerReports[range.label][provider] = report; + comparisonsByRange[range.label][provider] = compareTotals( + adeByRange[range.label][provider], + summarizeCodeburn(report), + ); + } + } + + process.stderr.write("Capturing combined codeburn reports...\n"); + const combinedReports = Object.fromEntries(ranges.map((range) => [range.label, runCodeburnReport(codeburnDir, range)])); + writeJson(path.join(outputDir, "codeburn-oracle-range.json"), combinedReports.closed); + writeJson(path.join(outputDir, "codeburn-oracle-today.json"), combinedReports.today); + + const diagnostics = []; + for (const range of ranges) { + for (const provider of PROVIDERS.filter((value) => NATIVE_PROVIDERS.has(value))) { + const comparison = comparisonsByRange[range.label][provider]; + if (!METRICS.some((metric) => comparison[metric].percent > 1)) continue; + process.stderr.write(`Sampling codeburn daily totals for ${provider} (${range.label})...\n`); + const daily = runCodeburnDailyExport(codeburnDir, range, provider); + diagnostics.push({ + range: range.label, + provider, + diagnosis: diagnosisFor( + provider, + adeByRange[range.label][provider], + summarizeCodeburn(providerReports[range.label][provider]), + ), + largestDailyDelta: largestDailyDelta(adeByRange[range.label][provider].daily, daily), + }); + } + } + + const capture = { + generatedAt, + desktopRoot: path.resolve(scriptDir, ".."), + codeburnDir, + ranges, + ade: adeByRange, + codeburn: providerReports, + comparisons: comparisonsByRange, + diagnostics, + }; + writeJson(path.join(outputDir, "usage-oracle-captures.json"), capture); + + const diagnosisLines = diagnostics.length === 0 + ? ["- None; every native-count provider is within 1% for input, output, and cache read."] + : diagnostics.map((diagnostic) => { + const sample = diagnostic.largestDailyDelta + ? ` Largest sampled day: ${diagnostic.largestDailyDelta.day}.` + : ""; + return `- **${diagnostic.provider} (${diagnostic.range})** — ${diagnostic.diagnosis}${sample}`; + }); + const markdown = [ + "# ADE usage accuracy oracle", + "", + `Generated: ${generatedAt}`, + `Closed range: ${closedRange.from} through ${closedRange.to}, inclusive (machine-local days).`, + `Today: ${todayKey} (in progress; provider ledgers can change during capture).`, + "Token fields only; dollar totals are intentionally ignored. Absolute deltas are unsigned.", + "", + tableForRange(closedRange, comparisonsByRange.closed), + "", + tableForRange(ranges[1], comparisonsByRange.today), + "", + "## Native-provider diagnoses (>1%)", + "", + ...diagnosisLines, + "", + "## Estimated-provider note", + "", + "Cursor, Droid, and Copilot use provider-specific estimates or session-level distribution in at least one tool. Their deltas are reported but are not parity gates.", + "", + ].join("\n"); + + const resultPath = path.join(outputDir, "oracle-results.md"); + fs.writeFileSync(resultPath, markdown); + process.stdout.write(markdown); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index ecff18246..a6f20dd2f 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -4216,9 +4216,11 @@ app.whenReady().then(async () => { const logger = createFileLogger(path.join(adePaths.logsDir, "main.jsonl")); const project = toProjectInfo(projectRoot, baseRef); const runtimeProject = await localRuntimePool.ensureProject(projectRoot); + const db = await openKvDb(adePaths.dbPath, logger); const shellContext = createDormantProjectContext(projectRoot, { enableUsageTracking: false }); const usageTrackingService = createUsageTrackingService({ logger, + db, pollIntervalMs: 120_000, onUpdate: (snapshot) => { emitProjectEvent(projectRoot, IPC.usageEvent, snapshot); @@ -4234,6 +4236,7 @@ app.whenReady().then(async () => { }); return { ...shellContext, + db, logger, project, projectId: runtimeProject.projectId, diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index fc6d00b0a..38c3f2631 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -154,7 +154,7 @@ export const ADE_ACTION_CTO_ONLY: Partial { - if (process.platform === "darwin") { +export type ClaudeCredentialReadOptions = { + /** Keychain reads can display macOS UI. Automatic/background callers must disable them. */ + allowKeychain?: boolean; +}; + +export async function readClaudeCredentials( + options: ClaudeCredentialReadOptions = {}, +): Promise { + if (process.platform === "darwin" && options.allowKeychain !== false) { try { const result = await runShellCommand( "security find-generic-password -s 'Claude Code-credentials' -w", @@ -215,18 +223,35 @@ export async function refreshClaudeCredentials(refreshToken: string): Promise { +export async function readClaudeCredentialsWithRefresh( + logger: Logger, + options: ClaudeCredentialReadOptions = {}, +): Promise { if (cachedClaudeCreds && !isClaudeTokenExpiredOrExpiring(cachedClaudeCreds)) { return cachedClaudeCreds; } - const creds = await readClaudeCredentials(); - if (!creds) return null; + const userInitiated = options.allowKeychain !== false; + if (!userInitiated && cachedClaudeMissUntilMs > Date.now()) return null; + + const creds = await readClaudeCredentials(options); + if (!creds) { + cachedClaudeMissUntilMs = Date.now() + CLAUDE_CREDENTIAL_MISS_TTL_MS; + return null; + } + cachedClaudeMissUntilMs = 0; if (!isClaudeTokenExpiredOrExpiring(creds)) { cachedClaudeCreds = creds; diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 74b20531e..1f2ec0845 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -12,7 +12,7 @@ import { areAutomationsEnabledForPackagedState } from "../../../shared/automatio import { findRecentProjectForRepo } from "../projects/repoProjectResolver"; import { getModelById } from "../../../shared/modelRegistry"; import { appendEvent as perfAppend, isRunActive as isPerfRunActive } from "../perf/perfLog"; -import { buildPrAiResolutionContextKey } from "../../../shared/types"; +import { buildPrAiResolutionContextKey, isAdeUsageScope } from "../../../shared/types"; import { detectCliAuthStatuses } from "../ai/authDetector"; import { resolveClaudeCodeExecutable } from "../ai/claudeCodeExecutable"; import { buildProviderConnections } from "../ai/providerConnectionStatus"; @@ -4876,6 +4876,10 @@ export function registerIpc({ // ── Usage tracking + budget cap IPC ────────────────────────── ipcMain.handle(IPC.usageGetAdeStats, async (_event, arg: GetAdeUsageStatsArgs | undefined): Promise => { const ctx = getCtx(); + if (arg != null && !isRecord(arg)) throw new Error("usage stats expects an object payload."); + if (arg?.scope != null && !isAdeUsageScope(arg.scope)) { + throw new Error("usage stats scope must be machine or project."); + } return ctx.usageTrackingService?.getAdeUsageStats(arg ?? {}) ?? null; }); @@ -4889,6 +4893,16 @@ export function registerIpc({ return (await ctx.usageTrackingService?.forceRefresh()) ?? null; }); + ipcMain.handle(IPC.usageRefreshHistory, async (): Promise => { + const ctx = getCtx(); + return (await ctx.usageTrackingService?.refreshHistory()) ?? null; + }); + + ipcMain.handle(IPC.usageNoteDemand, async (): Promise => { + const ctx = getCtx(); + return ctx.usageTrackingService?.noteQuotaDemand() ?? null; + }); + ipcMain.handle( IPC.usageCheckBudget, async ( diff --git a/apps/desktop/src/main/services/usage/ledgers/localUsageLedgers.ts b/apps/desktop/src/main/services/usage/ledgers/localUsageLedgers.ts index 7165fb730..cae9a9505 100644 --- a/apps/desktop/src/main/services/usage/ledgers/localUsageLedgers.ts +++ b/apps/desktop/src/main/services/usage/ledgers/localUsageLedgers.ts @@ -11,9 +11,6 @@ const LOCAL_COST_SCAN_MAX_FILES = 5_000; const LOCAL_COST_SCAN_MAX_FILE_BYTES = 768 * 1024 * 1024; const LOCAL_COST_SCAN_MAX_ENTRIES = 1_000_000; const LOCAL_COST_SCAN_ALL_DAYS = 3650; -const CODEX_COST_SCAN_MAX_FILES = 250; -const CODEX_COST_SCAN_MAX_FILE_BYTES = 32 * 1024 * 1024; -const CODEX_COST_SCAN_MAX_DAYS = 14; const LOCAL_SQLITE_SCAN_MAX_ROWS = 250_000; const LOCAL_CURSOR_SQLITE_RECENT_ROWS = 250_000; const CURSOR_CHARS_PER_TOKEN = 4; @@ -50,6 +47,10 @@ export interface TokenEntry { messageId: string; model: string; originator?: string; + projectPath?: string; + projectKey?: string; + adeOriginated?: boolean; + estimation?: "chars" | "mixed" | "distribution"; inputTokens: number; billableInputTokens?: number; outputTokens: number; @@ -63,6 +64,14 @@ export interface TokenEntry { timestamp: number; } +export function sanitizeClaudeProjectPath(value: string): string { + return value.replaceAll("/", "-").replaceAll("\\", "-").replaceAll(".", "-"); +} + +function isAdeWorktreePath(value: string): boolean { + return value.replace(/\\/g, "/").includes("/.ade/worktrees/"); +} + export function optionalNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } @@ -273,6 +282,12 @@ export async function scanClaudeLogs(projectDirsOverride?: string[]): Promise path.resolve(projectDir)) + .filter((projectDir) => resolvedFilePath === projectDir || resolvedFilePath.startsWith(`${projectDir}${path.sep}`)) + .sort((a, b) => b.length - a.length)[0]; + const sourceProjectKey = sourceProjectDir ? path.basename(sourceProjectDir) : ""; const firstTimestampByMessageId = new Map(); const lastEntryByMessageId = new Map(); const messageOrder: string[] = []; @@ -296,6 +311,7 @@ export async function scanClaudeLogs(projectDirsOverride?: string[]): Promise { const entries: TokenEntry[] = []; const seen = new Set(); + const seenForkReplayKeys = new Set(); const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex"); const sessionsDir = path.join(codexHome, "sessions"); - - try { - await fs.promises.access(sessionsDir); - } catch { - return entries; - } - - const jsonlFiles = await findJsonlFiles(sessionsDir, CODEX_COST_SCAN_MAX_DAYS, { - maxFiles: CODEX_COST_SCAN_MAX_FILES, - maxFileBytes: CODEX_COST_SCAN_MAX_FILE_BYTES, - }); + const archivedSessionsDir = path.join(codexHome, "archived_sessions"); + const sessionRoots = [sessionsDir, archivedSessionsDir].filter((root) => fs.existsSync(root)); + const jsonlFiles = (await Promise.all(sessionRoots.map((root) => findJsonlFiles( + root, + LOCAL_COST_SCAN_ALL_DAYS, + { + maxFiles: LOCAL_COST_SCAN_MAX_FILES, + maxFileBytes: LOCAL_COST_SCAN_MAX_FILE_BYTES, + }, + )))).flat(); for (const filePath of jsonlFiles) { try { - if (!await isSupportedCodexUsageSession(filePath)) continue; let sessionId = path.basename(filePath, ".jsonl"); let sessionModel = "codex"; let sessionOriginator = ""; + let sessionProjectPath = ""; + let forkedFromId = ""; let previousTotals: { input: number; cached: number; output: number; reasoning: number; total: number } | null = null; for await (const line of readJsonlLines(filePath)) { @@ -386,6 +411,13 @@ export async function scanCodexLogs(): Promise { if (payloadSessionId) sessionId = payloadSessionId; if (typeof payload.model === "string" && payload.model.trim()) sessionModel = payload.model; if (typeof payload.originator === "string" && payload.originator.trim()) sessionOriginator = payload.originator; + if (typeof payload.cwd === "string" && payload.cwd.trim()) sessionProjectPath = payload.cwd; + const payloadForkedFromId = typeof payload.forkedFromId === "string" + ? payload.forkedFromId + : typeof payload.forked_from_id === "string" + ? payload.forked_from_id + : ""; + if (payloadForkedFromId.trim()) forkedFromId = payloadForkedFromId; continue; } @@ -430,12 +462,28 @@ export async function scanCodexLogs(): Promise { }; } + const replayInput = totalUsage ? numberFromRecord(totalUsage, "input_tokens") : rawInputTokens; + const replayCached = totalUsage + ? numberFromRecord(totalUsage, "cached_input_tokens", "cache_read_input_tokens") + : cachedTokens; + const replayOutput = totalUsage ? numberFromRecord(totalUsage, "output_tokens") : outputTokens; + const replayReasoning = totalUsage ? numberFromRecord(totalUsage, "reasoning_output_tokens") : reasoningTokens; + if (cumulativeTotal > 0) { + const replayKey = `${forkedFromId || sessionId}:${cumulativeTotal}:${replayInput}:${replayCached}:${replayOutput}:${replayReasoning}`; + if (seenForkReplayKeys.has(replayKey)) continue; + seenForkReplayKeys.add(replayKey); + } + rawInputTokens = Math.max(0, rawInputTokens); cachedTokens = Math.max(0, cachedTokens); - outputTokens = Math.max(0, outputTokens) + Math.max(0, reasoningTokens); - const billableInputTokens = Math.max(0, rawInputTokens - cachedTokens); - const inputTokens = Math.max(0, rawInputTokens); - if (inputTokens + outputTokens + cachedTokens === 0) continue; + outputTokens = Math.max(0, outputTokens); + const billableOutputTokens = outputTokens + Math.max(0, reasoningTokens); + // Codex reports cached input as a subset of input_tokens. Normalize + // to the same mutually exclusive split used by Claude/codeburn so + // input + cache read does not double-count the cached portion. + const inputTokens = Math.max(0, rawInputTokens - cachedTokens); + const billableInputTokens = inputTokens; + if (inputTokens + billableOutputTokens + cachedTokens === 0) continue; const timestamp = timestampMsFromValue(record.timestamp); const model = typeof payload.model === "string" && payload.model.trim() @@ -449,15 +497,20 @@ export async function scanCodexLogs(): Promise { messageId: dedupeKey, model, originator: sessionOriginator, + ...(sessionProjectPath ? { projectPath: sessionProjectPath } : {}), + adeOriginated: sessionOriginator.trim().toLowerCase().startsWith("ade"), inputTokens, billableInputTokens, outputTokens, + billableOutputTokens, cachedTokens, billableCachedTokens: cachedTokens, cacheWriteTokens: 0, timestamp, }); - if (entries.length >= LOCAL_COST_SCAN_MAX_ENTRIES) return entries; + if (entries.length >= LOCAL_COST_SCAN_MAX_ENTRIES) { + return reconcileCodexStateTotals(entries, codexHome); + } continue; } @@ -480,33 +533,158 @@ export async function scanCodexLogs(): Promise { const model = typeof record.model === "string" ? record.model : "codex"; - const inputTokens = typeof record.input_tokens === "number" ? record.input_tokens : - typeof record.prompt_tokens === "number" ? record.prompt_tokens : 0; + const rawInputTokens = typeof record.input_tokens === "number" ? record.input_tokens : + typeof record.prompt_tokens === "number" ? record.prompt_tokens : 0; const outputTokens = typeof record.output_tokens === "number" ? record.output_tokens : typeof record.completion_tokens === "number" ? record.completion_tokens : 0; const tokenCount = typeof record.token_count === "number" ? record.token_count : 0; + const cachedTokens = typeof record.cached_tokens === "number" ? record.cached_tokens : 0; - if (inputTokens === 0 && outputTokens === 0 && tokenCount === 0) continue; + if (rawInputTokens === 0 && outputTokens === 0 && tokenCount === 0) continue; + + const inputTokens = rawInputTokens > 0 + ? Math.max(0, rawInputTokens - cachedTokens) + : Math.floor(tokenCount * 0.4); entries.push({ - messageId: dedupeKey, + messageId: `${sessionId}:${dedupeKey === ":" ? record.timestamp ?? entries.length : dedupeKey}`, model, originator: sessionOriginator, - inputTokens: inputTokens || Math.floor(tokenCount * 0.4), + ...(sessionProjectPath ? { projectPath: sessionProjectPath } : {}), + adeOriginated: sessionOriginator.trim().toLowerCase().startsWith("ade"), + inputTokens, + billableInputTokens: inputTokens, outputTokens: outputTokens || Math.ceil(tokenCount * 0.6), - cachedTokens: typeof record.cached_tokens === "number" ? record.cached_tokens : 0, - billableCachedTokens: typeof record.cached_tokens === "number" ? record.cached_tokens : 0, + cachedTokens, + billableCachedTokens: cachedTokens, cacheWriteTokens: 0, timestamp: typeof record.timestamp === "number" ? record.timestamp : typeof record.timestamp === "string" ? new Date(record.timestamp).getTime() : Date.now(), }); - if (entries.length >= LOCAL_COST_SCAN_MAX_ENTRIES) return entries; + if (entries.length >= LOCAL_COST_SCAN_MAX_ENTRIES) { + return reconcileCodexStateTotals(entries, codexHome); + } } } catch { // Skip unreadable files } } + return reconcileCodexStateTotals(entries, codexHome); +} + +type CodexStateThreadRow = { + id?: unknown; + tokens_used?: unknown; + model?: unknown; + cwd?: unknown; + source?: unknown; + thread_source?: unknown; + created_at?: unknown; + updated_at?: unknown; +}; + +function newestCodexStateDatabase(codexHome: string): string | null { + try { + const candidates = fs.readdirSync(codexHome) + .map((name) => { + const match = /^state_(\d+)\.sqlite$/.exec(name); + return match ? { path: path.join(codexHome, name), version: Number(match[1]) } : null; + }) + .filter((value): value is { path: string; version: number } => value !== null) + .sort((left, right) => right.version - left.version); + return candidates[0]?.path ?? null; + } catch { + return null; + } +} + +/** + * Codex Desktop's thread index owns the lifetime total shown in its profile. + * JSONL reconstruction keeps the detailed model/day/cost split, but can miss + * cumulative context retained by the thread index. Add only the positive + * per-thread remainder, distributed across the observed token mix and priced + * at zero so the exact lifetime count does not fabricate cost. + */ +function reconcileCodexStateTotals(entries: TokenEntry[], codexHome: string): TokenEntry[] { + const dbPath = newestCodexStateDatabase(codexHome); + if (!dbPath) return entries; + const db = openReadonlyUsageDatabase(dbPath); + if (!db) return entries; + + let rows: CodexStateThreadRow[] = []; + try { + rows = usageSqliteAll(db, ` + select id, tokens_used, model, cwd, source, thread_source, created_at, updated_at + from threads + where tokens_used > 0 + `); + } catch { + try { + rows = usageSqliteAll(db, ` + select id, tokens_used, created_at, updated_at + from threads + where tokens_used > 0 + `); + } catch { + rows = []; + } + } finally { + db.close(); + } + if (rows.length === 0) return entries; + + const observedByThread = new Map(); + for (const entry of entries) { + const separator = entry.messageId.indexOf(":"); + if (separator <= 0) continue; + const threadId = entry.messageId.slice(0, separator); + const observed = observedByThread.get(threadId) ?? { input: 0, output: 0, cached: 0, cacheWrite: 0 }; + observed.input += toNonNegativeInt(entry.inputTokens); + observed.output += toNonNegativeInt(entry.outputTokens); + observed.cached += toNonNegativeInt(entry.cachedTokens); + observed.cacheWrite += toNonNegativeInt(entry.cacheWriteTokens); + observedByThread.set(threadId, observed); + } + + for (const row of rows) { + const threadId = typeof row.id === "string" ? row.id.trim() : ""; + const stateTotal = toNonNegativeInt(row.tokens_used); + if (!threadId || stateTotal <= 0) continue; + const observed = observedByThread.get(threadId) ?? { input: 0, output: 0, cached: 0, cacheWrite: 0 }; + const observedTotal = observed.input + observed.output + observed.cached + observed.cacheWrite; + const remainder = stateTotal - observedTotal; + if (remainder <= 0) continue; + + const inputShare = observedTotal > 0 ? observed.input / observedTotal : 1; + const outputShare = observedTotal > 0 ? observed.output / observedTotal : 0; + const inputTokens = Math.floor(remainder * inputShare); + const outputTokens = Math.floor(remainder * outputShare); + const cachedTokens = Math.max(0, remainder - inputTokens - outputTokens); + const originator = typeof row.thread_source === "string" + ? row.thread_source + : typeof row.source === "string" ? row.source : "Codex Desktop"; + const projectPath = typeof row.cwd === "string" ? row.cwd : ""; + + entries.push({ + messageId: `${threadId}:state-total-remainder`, + model: typeof row.model === "string" && row.model.trim() ? row.model : "codex", + originator, + ...(projectPath ? { projectPath } : {}), + adeOriginated: originator.trim().toLowerCase().startsWith("ade") || isAdeWorktreePath(projectPath), + estimation: "distribution", + inputTokens, + billableInputTokens: 0, + outputTokens, + billableOutputTokens: 0, + cachedTokens, + billableCachedTokens: 0, + cacheWriteTokens: 0, + costOverrideUsd: 0, + timestamp: timestampMsFromUnixish(row.updated_at ?? row.created_at), + }); + } + return entries; } @@ -685,6 +863,7 @@ export async function scanDroidLogs(sessionsDir = defaultDroidSessionsDir()): Pr billableCachedTokens: isLast ? totalCacheRead - cacheReadPerCall * i : cacheReadPerCall, cacheWriteTokens: isLast ? totalCacheWrite - cacheWritePerCall * i : cacheWritePerCall, timestamp: call.timestamp, + estimation: "distribution", }); if (entries.length >= LOCAL_COST_SCAN_MAX_ENTRIES) return entries; } @@ -787,6 +966,7 @@ export function parseCopilotEvents(raw: string, sourcePath: string): TokenEntry[ cachedTokens: 0, cacheWriteTokens: 0, timestamp: timestampMsFromValue(record.timestamp), + estimation: numberFromRecord(data, "outputTokens") > 0 ? "mixed" : "chars", }); pendingUserMessage = ""; } @@ -816,6 +996,7 @@ export function parseCopilotEvents(raw: string, sourcePath: string): TokenEntry[ cachedTokens: 0, cacheWriteTokens: 0, timestamp: timestampMsFromValue(record.timestamp), + estimation: "mixed", }); pendingUserMessage = ""; } @@ -1150,6 +1331,7 @@ export async function scanCursorLogs(dbPath = defaultCursorDbPath()): Promise= LOCAL_COST_SCAN_MAX_ENTRIES) return entries; } @@ -1301,6 +1483,7 @@ export async function scanCursorAgentLogs(projectsDir = path.join(os.homedir(), cachedTokens: 0, cacheWriteTokens: 0, timestamp: stat.mtimeMs, + estimation: "chars", }); if (entries.length >= LOCAL_COST_SCAN_MAX_ENTRIES) return entries; } @@ -1312,25 +1495,6 @@ export async function scanCursorAgentLogs(projectsDir = path.join(os.homedir(), return entries; } -async function isSupportedCodexUsageSession(filePath: string): Promise { - let file: fs.promises.FileHandle | null = null; - try { - file = await fs.promises.open(filePath, "r"); - const buffer = Buffer.alloc(1024 * 1024); - const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); - const firstLine = buffer.subarray(0, bytesRead).toString("utf8").split(/\r?\n/, 1)[0]?.trim(); - if (!firstLine) return false; - const entry = safeJsonParse>(firstLine, {}); - if (entry.type !== "session_meta" || !isRecord(entry.payload)) return false; - const originator = normalizeUsageLabel(entry.payload.originator, "").toLowerCase(); - return !originator.startsWith("ade"); - } catch { - return false; - } finally { - await file?.close().catch(() => {}); - } -} - export async function findRecentFiles( dir: string, maxAgeDays: number, @@ -1411,10 +1575,35 @@ async function findClaudeJsonlFilesInProjectDirs(projectDirs: string[], maxAgeDa })); } + async function addJsonlFilesInTree(dir: string): Promise { + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + await Promise.all(entries.map(async (entry) => { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await addJsonlFilesInTree(entryPath); + return; + } + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) return; + try { + const stat = await fs.promises.stat(entryPath); + if (stat.mtimeMs >= cutoff && stat.size <= LOCAL_COST_SCAN_MAX_FILE_BYTES) { + files.set(entryPath, { path: entryPath, mtimeMs: stat.mtimeMs }); + } + } catch { + // Skip files we can't stat. + } + })); + } + for (const projectDir of projectDirs) { const projectPath = projectDir; await addJsonlFilesInDir(projectPath); - await addJsonlFilesInDir(path.join(projectPath, "subagents")); + await addJsonlFilesInTree(path.join(projectPath, "subagents")); let childEntries: fs.Dirent[]; try { @@ -1424,7 +1613,7 @@ async function findClaudeJsonlFilesInProjectDirs(projectDirs: string[], maxAgeDa } await Promise.all(childEntries .filter((entry) => entry.isDirectory()) - .map((entry) => addJsonlFilesInDir(path.join(projectPath, entry.name, "subagents")))); + .map((entry) => addJsonlFilesInTree(path.join(projectPath, entry.name, "subagents")))); } return newestCandidatePaths(Array.from(files.values())); diff --git a/apps/desktop/src/main/services/usage/localDay.ts b/apps/desktop/src/main/services/usage/localDay.ts new file mode 100644 index 000000000..938ddc107 --- /dev/null +++ b/apps/desktop/src/main/services/usage/localDay.ts @@ -0,0 +1,56 @@ +const LOCAL_DAY_KEY = /^(\d{4})-(\d{2})-(\d{2})$/; + +function padCalendarComponent(value: number): string { + return String(value).padStart(2, "0"); +} + +function toDate(value: Date | string | number): Date { + return value instanceof Date ? value : new Date(value); +} + +/** Returns the machine-local calendar day for an instant, or an empty string when invalid. */ +export function localDayKey(value: Date | string | number): string { + if (typeof value === "string" && LOCAL_DAY_KEY.test(value)) { + return localDayStart(value) ? value : ""; + } + const date = toDate(value); + if (!Number.isFinite(date.getTime())) return ""; + return `${date.getFullYear()}-${padCalendarComponent(date.getMonth() + 1)}-${padCalendarComponent(date.getDate())}`; +} + +/** Parses a local day key at local midnight, rejecting normalized invalid dates. */ +export function localDayStart(dayKey: string): Date | null { + const match = LOCAL_DAY_KEY.exec(dayKey); + if (!match) return null; + const year = Number(match[1]); + const monthIndex = Number(match[2]) - 1; + const day = Number(match[3]); + const date = new Date(year, monthIndex, day, 0, 0, 0, 0); + if ( + date.getFullYear() !== year + || date.getMonth() !== monthIndex + || date.getDate() !== day + ) { + return null; + } + return date; +} + +export function localDayOffset(value: Date | string | number, days: number): Date | null { + let date: Date | null; + if (typeof value === "string" && LOCAL_DAY_KEY.test(value)) { + date = localDayStart(value); + } else { + date = toDate(value); + } + if (!date) return null; + if (!Number.isFinite(date.getTime())) return null; + return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days, 0, 0, 0, 0); +} + +/** Stable ordinal for comparing local calendar keys without DST-length assumptions. */ +export function localDayOrdinal(dayKey: string): number | null { + const date = localDayStart(dayKey); + if (!date) return null; + return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000; +} diff --git a/apps/desktop/src/main/services/usage/usageEndToEnd.test.ts b/apps/desktop/src/main/services/usage/usageEndToEnd.test.ts new file mode 100644 index 000000000..7e097bca9 --- /dev/null +++ b/apps/desktop/src/main/services/usage/usageEndToEnd.test.ts @@ -0,0 +1,364 @@ +import { createRequire } from "node:module"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CostSnapshot } from "../../../shared/types"; +import type { TokenEntry } from "./ledgers/localUsageLedgers"; +import { sanitizeClaudeProjectPath } from "./ledgers/localUsageLedgers"; +import { _testing } from "./usageTrackingService"; + +const requireForTest = createRequire(path.join(process.cwd(), "usage-e2e-test.cjs")); + +const { + buildCostSnapshots, + scanClaudeLogs, + scanCodexLogs, + scanCursorLogs, + scanDroidLogs, + scanGeminiLogs, +} = _testing; + +type TokenTotals = { input: number; output: number; cached: number; cacheWrite: number }; +type TokenBreakdownValue = Omit & { cacheWrite?: number }; + +function writeJsonl(filePath: string, records: unknown[], mtime: Date): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${records.map((record) => JSON.stringify(record)).join("\n")}\n`); + fs.utimesSync(filePath, mtime, mtime); +} + +function sumBreakdown(breakdown: Record | undefined): TokenTotals { + return Object.values(breakdown ?? {}).reduce((total, model) => ({ + input: total.input + model.input, + output: total.output + model.output, + cached: total.cached + model.cached, + cacheWrite: total.cacheWrite + (model.cacheWrite ?? 0), + }), { input: 0, output: 0, cached: 0, cacheWrite: 0 }); +} + +function totalsFor(snapshot: CostSnapshot): TokenTotals { + return sumBreakdown(snapshot.tokenBreakdownByPreset?.all); +} + +function dailyTotalsFor(snapshot: CostSnapshot): Record { + return Object.fromEntries(Object.entries(snapshot.dailyTokenBreakdownByPreset?.all ?? {}) + .map(([day, breakdown]) => [day, sumBreakdown(breakdown)])); +} + +function totalTokens(totals: TokenTotals): number { + return totals.input + totals.output + totals.cached + totals.cacheWrite; +} + +function snapshotsByProvider(snapshots: CostSnapshot[]): Record { + return Object.fromEntries(snapshots.map((snapshot) => [snapshot.provider, snapshot])); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("usage ledger end-to-end accuracy", () => { + it("matches independently calculated provider, day, scope, origin, and estimation totals", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-usage-e2e-")); + const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + const originalClaudeConfigDirs = process.env.CLAUDE_CONFIG_DIRS; + const originalCodexHome = process.env.CODEX_HOME; + const originalFactoryDir = process.env.FACTORY_DIR; + const originalTimezone = process.env.TZ; + + try { + process.env.TZ = "America/New_York"; + const beforeMidnight = new Date(2026, 9, 31, 23, 59, 0, 0); + const afterMidnight = new Date(2026, 10, 1, 0, 1, 0, 0); + // Use explicit offsets so the DST assertion is portable even when a + // test worker cannot apply a runtime TZ change (as on Linux CI). + const beforeDstChange = new Date("2026-11-01T00:30:00-04:00"); + const afterDstChange = new Date("2026-11-01T03:30:00-05:00"); + const today = new Date(2026, 10, 2, 9, 0, 0, 0); + const now = new Date(2026, 10, 2, 12, 0, 0, 0); + vi.useFakeTimers(); + vi.setSystemTime(now); + + const projectA = path.join(tmpDir, "project-a"); + const projectB = path.join(tmpDir, "project-b"); + const claudeConfigDir = path.join(tmpDir, "claude"); + const claudeProjectA = path.join(claudeConfigDir, "projects", sanitizeClaudeProjectPath(projectA)); + const claudeProjectB = path.join(claudeConfigDir, "projects", sanitizeClaudeProjectPath(projectB)); + const claudeEntry = ({ + id, + timestamp, + cwd, + input, + output, + cacheRead = 0, + cacheWrite = 0, + cache5m, + cache1h, + }: { + id: string; + timestamp: Date; + cwd: string; + input: number; + output: number; + cacheRead?: number; + cacheWrite?: number; + cache5m?: number; + cache1h?: number; + }) => ({ + type: "assistant", + timestamp: timestamp.toISOString(), + cwd, + message: { + id, + model: "claude-opus-4-6", + usage: { + input_tokens: input, + output_tokens: output, + cache_read_input_tokens: cacheRead, + cache_creation_input_tokens: cacheWrite, + ...(cache5m != null || cache1h != null ? { + cache_creation: { + ephemeral_5m_input_tokens: cache5m ?? 0, + ephemeral_1h_input_tokens: cache1h ?? 0, + }, + } : {}), + server_tool_use: { web_search_requests: 1 }, + }, + }, + }); + + const resumedClaudeMessage = claudeEntry({ + id: "claude-resume", + timestamp: beforeMidnight, + cwd: projectA, + input: 10, + output: 2, + cacheRead: 3, + cacheWrite: 4, + }); + writeJsonl(path.join(claudeProjectA, "session-a.jsonl"), [ + resumedClaudeMessage, + claudeEntry({ id: "claude-stream", timestamp: afterMidnight, cwd: projectA, input: 20, output: 4, cacheRead: 5, cacheWrite: 4 }), + claudeEntry({ id: "claude-stream", timestamp: new Date(2026, 10, 1, 0, 2), cwd: projectA, input: 30, output: 7, cacheRead: 8, cacheWrite: 6 }), + claudeEntry({ + id: "claude-stream", + timestamp: new Date(2026, 10, 1, 0, 3), + cwd: projectA, + input: 40, + output: 9, + cacheRead: 10, + cacheWrite: 7, + cache5m: 3, + cache1h: 5, + }), + ], now); + writeJsonl(path.join(claudeProjectA, "session-b.jsonl"), [ + resumedClaudeMessage, + claudeEntry({ + id: "claude-ade", + timestamp: afterDstChange, + cwd: path.join(projectA, ".ade", "worktrees", "lane-a"), + input: 11, + output: 4, + cacheRead: 2, + cacheWrite: 3, + }), + ], new Date(now.getTime() - 1_000)); + writeJsonl(path.join(claudeProjectA, "subagents", "workflows", "wf-1", "agent-explore.jsonl"), [ + claudeEntry({ id: "claude-subagent", timestamp: new Date(2026, 10, 1, 3, 45), cwd: projectA, input: 7, output: 3, cacheRead: 1, cacheWrite: 2 }), + ], new Date(now.getTime() - 2_000)); + writeJsonl(path.join(claudeProjectB, "session-c.jsonl"), [ + claudeEntry({ id: "claude-project-b", timestamp: today, cwd: projectB, input: 13, output: 5, cacheRead: 4, cacheWrite: 6 }), + ], new Date(now.getTime() - 3_000)); + + process.env.CLAUDE_CONFIG_DIR = claudeConfigDir; + delete process.env.CLAUDE_CONFIG_DIRS; + + const codexHome = path.join(tmpDir, "codex"); + const codexSessions = path.join(codexHome, "sessions", "2026", "11", "01"); + const codexUsage = ({ timestamp, input, cached, output, reasoning, total }: { + timestamp: Date; + input: number; + cached: number; + output: number; + reasoning: number; + total: number; + }) => ({ + timestamp: timestamp.toISOString(), + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { + input_tokens: input, + cached_input_tokens: cached, + output_tokens: output, + reasoning_output_tokens: reasoning, + total_tokens: total, + }, + last_token_usage: { + input_tokens: input, + cached_input_tokens: cached, + output_tokens: output, + reasoning_output_tokens: reasoning, + total_tokens: total, + }, + }, + }, + }); + writeJsonl(path.join(codexSessions, "parent.jsonl"), [ + { type: "session_meta", payload: { id: "codex-parent", originator: "codex_cli_rs", cwd: projectA, model: "gpt-5.5" } }, + codexUsage({ timestamp: beforeDstChange, input: 50, cached: 10, output: 5, reasoning: 1, total: 56 }), + ], now); + writeJsonl(path.join(codexSessions, "fork.jsonl"), [ + { type: "session_meta", payload: { id: "codex-fork", forked_from_id: "codex-parent", originator: "ade_desktop", cwd: projectA, model: "gpt-5.5" } }, + codexUsage({ timestamp: new Date(2026, 10, 1, 3, 1), input: 50, cached: 10, output: 5, reasoning: 1, total: 56 }), + { + ...codexUsage({ timestamp: new Date(2026, 10, 1, 3, 15), input: 70, cached: 15, output: 8, reasoning: 2, total: 80 }), + payload: { + type: "token_count", + info: { + total_token_usage: { input_tokens: 70, cached_input_tokens: 15, output_tokens: 8, reasoning_output_tokens: 2, total_tokens: 80 }, + last_token_usage: { input_tokens: 20, cached_input_tokens: 5, output_tokens: 3, reasoning_output_tokens: 1, total_tokens: 24 }, + }, + }, + }, + ], new Date(now.getTime() - 1_000)); + writeJsonl(path.join(codexHome, "sessions", "2026", "11", "02", "external-b.jsonl"), [ + { type: "session_meta", payload: { id: "codex-project-b", originator: "codex_cli_rs", cwd: projectB, model: "gpt-5.5" } }, + codexUsage({ timestamp: new Date(2026, 10, 2, 9, 15), input: 30, cached: 6, output: 4, reasoning: 0, total: 34 }), + ], new Date(now.getTime() - 2_000)); + process.env.CODEX_HOME = codexHome; + + const cursorDbPath = path.join(tmpDir, "cursor", "state.vscdb"); + fs.mkdirSync(path.dirname(cursorDbPath), { recursive: true }); + const { DatabaseSync } = requireForTest("node:sqlite") as { + DatabaseSync: new (dbPath: string) => { + exec: (sql: string) => void; + prepare: (sql: string) => { run: (...args: unknown[]) => void }; + close: () => void; + }; + }; + const cursorDb = new DatabaseSync(cursorDbPath); + cursorDb.exec("create table cursorDiskKV (key text, value text);"); + const insertCursor = cursorDb.prepare("insert into cursorDiskKV values (?, ?)"); + insertCursor.run("bubbleId:cursor-1", JSON.stringify({ + createdAt: new Date(2026, 10, 1, 0, 40).toISOString(), + tokenCount: { inputTokens: 12, outputTokens: 3 }, + modelInfo: { modelName: "cursor-auto" }, + type: 1, + text: "native", + })); + insertCursor.run("bubbleId:cursor-2", JSON.stringify({ + createdAt: new Date(2026, 10, 1, 3, 40).toISOString(), + tokenCount: {}, + modelInfo: { modelName: "default" }, + type: 2, + text: "abcdefghijklmnop", + })); + cursorDb.close(); + + const geminiTmp = path.join(tmpDir, "gemini", "tmp"); + writeJsonl(path.join(geminiTmp, "project-a", "chats", "session-gemini.jsonl"), [ + { sessionId: "gemini-session", startTime: afterMidnight.toISOString(), projectHash: "project-a" }, + { id: "gemini-before-dst", type: "gemini", timestamp: new Date(2026, 10, 1, 0, 45).toISOString(), model: "gemini-3.1-pro-preview", tokens: { input: 30, output: 7, cached: 5, thoughts: 2 } }, + { id: "gemini-after-dst", type: "gemini", timestamp: new Date(2026, 10, 1, 3, 15).toISOString(), model: "gemini-3.1-pro-preview", tokens: { input: 20, output: 5, cached: 4, thoughts: 1 } }, + ], now); + + const factoryDir = path.join(tmpDir, "factory"); + const droidSession = path.join(factoryDir, "sessions", "project-a", "session-droid.jsonl"); + writeJsonl(droidSession, [ + { type: "session_start", id: "droid-session", cwd: projectA }, + { type: "message", id: "droid-before-dst", timestamp: new Date(2026, 10, 1, 0, 50).toISOString(), message: { role: "assistant", content: [{ type: "text", text: "before" }] } }, + { type: "message", id: "droid-after-dst", timestamp: new Date(2026, 10, 1, 3, 20).toISOString(), message: { role: "assistant", content: [{ type: "tool_use", name: "Execute" }] } }, + ], now); + fs.writeFileSync(droidSession.replace(/\.jsonl$/, ".settings.json"), JSON.stringify({ + model: "custom:[anthropic]-claude-sonnet-5-20260501", + tokenUsage: { inputTokens: 21, outputTokens: 9, thinkingTokens: 3, cacheReadTokens: 5, cacheCreationTokens: 3 }, + })); + process.env.FACTORY_DIR = factoryDir; + + const entriesByProvider = new Map([ + ["claude", await scanClaudeLogs([claudeProjectA, claudeProjectB])], + ["codex", await scanCodexLogs()], + ["cursor", await scanCursorLogs(cursorDbPath)], + ["gemini", await scanGeminiLogs(geminiTmp)], + ["droid", await scanDroidLogs()], + ]); + const machine = snapshotsByProvider(buildCostSnapshots(entriesByProvider, "machine", projectA)); + const project = snapshotsByProvider(buildCostSnapshots(entriesByProvider, "project", projectA)); + + const expectedMachineTotals: Record = { + claude: { input: 81, output: 23, cached: 20, cacheWrite: 23 }, + codex: { input: 79, output: 12, cached: 21, cacheWrite: 0 }, + cursor: { input: 12, output: 7, cached: 0, cacheWrite: 0 }, + gemini: { input: 41, output: 15, cached: 9, cacheWrite: 0 }, + droid: { input: 21, output: 12, cached: 5, cacheWrite: 3 }, + }; + const expectedProjectTotals: Record = { + claude: { input: 68, output: 18, cached: 16, cacheWrite: 17 }, + codex: { input: 55, output: 8, cached: 15, cacheWrite: 0 }, + cursor: { input: 0, output: 0, cached: 0, cacheWrite: 0 }, + gemini: { input: 0, output: 0, cached: 0, cacheWrite: 0 }, + droid: { input: 0, output: 0, cached: 0, cacheWrite: 0 }, + }; + const expectedDailyTotals: Record> = { + claude: { + "2026-10-31": { input: 10, output: 2, cached: 3, cacheWrite: 4 }, + "2026-11-01": { input: 58, output: 16, cached: 13, cacheWrite: 13 }, + "2026-11-02": { input: 13, output: 5, cached: 4, cacheWrite: 6 }, + }, + codex: { + "2026-11-01": { input: 55, output: 8, cached: 15, cacheWrite: 0 }, + "2026-11-02": { input: 24, output: 4, cached: 6, cacheWrite: 0 }, + }, + cursor: { "2026-11-01": { input: 12, output: 7, cached: 0, cacheWrite: 0 } }, + gemini: { "2026-11-01": { input: 41, output: 15, cached: 9, cacheWrite: 0 } }, + droid: { "2026-11-01": { input: 21, output: 12, cached: 5, cacheWrite: 3 } }, + }; + + for (const provider of Object.keys(expectedMachineTotals)) { + expect(totalsFor(machine[provider]!), `${provider} machine totals`).toEqual(expectedMachineTotals[provider]); + expect(totalsFor(project[provider]!), `${provider} project totals`).toEqual(expectedProjectTotals[provider]); + expect(dailyTotalsFor(machine[provider]!), `${provider} daily totals`).toEqual(expectedDailyTotals[provider]); + } + + expect(Object.fromEntries(Object.entries(machine).map(([provider, snapshot]) => { + const total = totalTokens(totalsFor(snapshot)); + const ade = snapshot.adeOriginatedTokensByPreset?.all ?? 0; + return [provider, { ade, external: total - ade }]; + }))).toEqual({ + claude: { ade: 20, external: 127 }, + codex: { ade: 23, external: 89 }, + cursor: { ade: 0, external: 19 }, + gemini: { ade: 0, external: 65 }, + droid: { ade: 0, external: 41 }, + }); + + expect(Object.fromEntries(Object.entries(machine).map(([provider, snapshot]) => [provider, { + estimation: snapshot.estimation ?? null, + scopeSupported: snapshot.scopeSupported, + }]))).toEqual({ + claude: { estimation: null, scopeSupported: true }, + codex: { estimation: null, scopeSupported: true }, + cursor: { estimation: "mixed", scopeSupported: false }, + gemini: { estimation: null, scopeSupported: false }, + droid: { estimation: "distribution", scopeSupported: false }, + }); + + expect(afterDstChange.getTime() - beforeDstChange.getTime()).toBe(4 * 60 * 60 * 1_000); + } finally { + const restoreEnv = (key: "CLAUDE_CONFIG_DIR" | "CLAUDE_CONFIG_DIRS" | "CODEX_HOME" | "FACTORY_DIR" | "TZ", value: string | undefined) => { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + }; + restoreEnv("CLAUDE_CONFIG_DIR", originalClaudeConfigDir); + restoreEnv("CLAUDE_CONFIG_DIRS", originalClaudeConfigDirs); + restoreEnv("CODEX_HOME", originalCodexHome); + restoreEnv("FACTORY_DIR", originalFactoryDir); + restoreEnv("TZ", originalTimezone); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/main/services/usage/usageProviderStrategies.ts b/apps/desktop/src/main/services/usage/usageProviderStrategies.ts new file mode 100644 index 000000000..ec26494e1 --- /dev/null +++ b/apps/desktop/src/main/services/usage/usageProviderStrategies.ts @@ -0,0 +1,35 @@ +import type { + ExtraUsage, + UsageProvider, + UsageProviderErrorKind, + UsageProviderMessage, + UsageProviderSource, + UsageWindow, +} from "../../../shared/types"; + +export type UsageRefreshReason = "automatic" | "remote" | "user"; + +export type UsageProviderPollContext = { + reason: UsageRefreshReason; +}; + +export type UsageProviderPollResult = { + windows: UsageWindow[]; + source?: UsageProviderSource; + errors: string[]; + errorKind?: UsageProviderErrorKind; + retryAfterMs?: number; + extraUsage?: ExtraUsage | null; + dailyUsage7d?: number[]; + providerMessages?: UsageProviderMessage[]; +}; + +/** + * Boundary between the quota scheduler and provider-specific auth/fallback + * behavior. Historical ledger scanners intentionally do not implement this + * interface: quota refresh must remain independent from corpus size. + */ +export type UsageProviderStrategy = { + provider: Extract; + poll(context: UsageProviderPollContext): Promise; +}; diff --git a/apps/desktop/src/main/services/usage/usageStatsStore.ts b/apps/desktop/src/main/services/usage/usageStatsStore.ts index f33252ce2..1d8bf80ea 100644 --- a/apps/desktop/src/main/services/usage/usageStatsStore.ts +++ b/apps/desktop/src/main/services/usage/usageStatsStore.ts @@ -12,6 +12,10 @@ import type { AdeUsageProviderSummary, } from "../../../shared/types"; import type { AdeDb, SqlValue } from "../state/kvDb"; +import type { Logger } from "../logging/logger"; +import { localDayKey, localDayOffset, localDayOrdinal } from "./localDay"; + +const DAILY_BUCKET_SCAN_MAX_ROWS = 250_000; export type AdeUsageStatsRange = { since: string | null; @@ -320,8 +324,7 @@ function isChatSession(row: { tool_type: string | null; chat_session_id: string function isoDate(value: unknown): string | null { if (typeof value !== "string") return null; - const timestamp = Date.parse(value); - return Number.isFinite(timestamp) ? new Date(timestamp).toISOString().slice(0, 10) : null; + return localDayKey(value) || null; } function calculateStreaks(activeDateValues: Iterable, until: string): { activeDays: number; current: number; longest: number } { @@ -329,23 +332,22 @@ function calculateStreaks(activeDateValues: Iterable, until: string): { const ordered = [...activeDates].sort(); let longest = 0; let run = 0; - let previousMs: number | null = null; + let previousOrdinal: number | null = null; for (const date of ordered) { - const timestamp = Date.parse(`${date}T00:00:00.000Z`); - if (!Number.isFinite(timestamp)) continue; - run = previousMs != null && timestamp - previousMs === 86_400_000 ? run + 1 : 1; + const ordinal = localDayOrdinal(date); + if (ordinal == null) continue; + run = previousOrdinal != null && ordinal - previousOrdinal === 1 ? run + 1 : 1; longest = Math.max(longest, run); - previousMs = timestamp; + previousOrdinal = ordinal; } let current = 0; - const cursor = new Date(until); - if (!Number.isFinite(cursor.getTime())) return { activeDays: activeDates.size, current, longest }; - cursor.setUTCHours(0, 0, 0, 0); - if (!activeDates.has(cursor.toISOString().slice(0, 10))) cursor.setUTCDate(cursor.getUTCDate() - 1); - while (activeDates.has(cursor.toISOString().slice(0, 10))) { + let cursor = localDayOffset(until, 0); + if (!cursor) return { activeDays: activeDates.size, current, longest }; + if (!activeDates.has(localDayKey(cursor))) cursor = localDayOffset(cursor, -1); + while (cursor && activeDates.has(localDayKey(cursor))) { current += 1; - cursor.setUTCDate(cursor.getUTCDate() - 1); + cursor = localDayOffset(cursor, -1); } return { activeDays: activeDates.size, current, longest }; } @@ -353,6 +355,7 @@ function calculateStreaks(activeDateValues: Iterable, until: string): { export function collectAdeDatabaseUsageStats( db: AdeDb | null | undefined, range: AdeUsageStatsRange, + logger?: Pick, ): AdeDatabaseUsageStats | null { if (!db) return null; @@ -415,28 +418,26 @@ export function collectAdeDatabaseUsageStats( client_surface: AdeUsageClientSurface; interactions: number; sessions: number; - active_days: number; last_active_at: string | null; }>(db, ` select client_surface, count(*) interactions, count(distinct session_id) sessions, - count(distinct substr(occurred_at, 1, 10)) active_days, max(occurred_at) last_active_at from usage_events where ${eventRange.sql} group by client_surface `, eventRange.params); - const clientDailyRows = safeAll<{ - date: string; + const clientEventRows = safeAll<{ + occurred_at: string; client_surface: AdeUsageClientSurface; - interactions: number; }>(db, ` - select substr(occurred_at, 1, 10) date, client_surface, count(*) interactions + select occurred_at, client_surface from usage_events where ${eventRange.sql} - group by date, client_surface - `, eventRange.params); + order by occurred_at desc + limit ? + `, [...eventRange.params, DAILY_BUCKET_SCAN_MAX_ROWS]); const interactionRows = safeAll<{ action: string; count: number }>(db, ` select action, count(*) count @@ -453,14 +454,15 @@ export function collectAdeDatabaseUsageStats( and kind in ('git_commit', 'git_push', 'pr_land', 'git_pull', 'git_sync_merge', 'git_sync_rebase') group by kind `, operationRange.params); - const operationDailyRows = safeAll<{ date: string; kind: string; count: number }>(db, ` - select substr(started_at, 1, 10) date, kind, count(*) count + const operationDailyRows = safeAll<{ started_at: string; kind: string }>(db, ` + select started_at, kind from operations where ${operationRange.sql} and status = 'succeeded' and kind in ('git_commit', 'pr_land') - group by date, kind - `, operationRange.params); + order by started_at desc + limit ? + `, [...operationRange.params, DAILY_BUCKET_SCAN_MAX_ROWS]); const operationCounts = new Map(operationRows.map((row) => [row.kind, int(row.count)])); const activityCounts = new Map(interactionRows.map((row) => [row.action, int(row.count)])); const operationActivityNames: Record = { @@ -597,25 +599,39 @@ export function collectAdeDatabaseUsageStats( return existing; }; const aiDailyRows = safeAll<{ - date: string; + timestamp: string; input_tokens: number; output_tokens: number; duration_ms: number; }>(db, ` - select substr(timestamp, 1, 10) date, - sum(coalesce(input_tokens, 0)) input_tokens, - sum(coalesce(output_tokens, 0)) output_tokens, - sum(coalesce(duration_ms, 0)) duration_ms + select timestamp, + coalesce(input_tokens, 0) input_tokens, + coalesce(output_tokens, 0) output_tokens, + coalesce(duration_ms, 0) duration_ms from ai_usage_log where ${aiRange.sql} - group by date - `, aiRange.params); + order by timestamp desc + limit ? + `, [...aiRange.params, DAILY_BUCKET_SCAN_MAX_ROWS]); + const cappedDailySources = [ + clientEventRows.length === DAILY_BUCKET_SCAN_MAX_ROWS ? "usage_events" : null, + operationDailyRows.length === DAILY_BUCKET_SCAN_MAX_ROWS ? "operations" : null, + aiDailyRows.length === DAILY_BUCKET_SCAN_MAX_ROWS ? "ai_usage_log" : null, + ].filter((source): source is string => source !== null); + if (cappedDailySources.length > 0) { + logger?.debug("usage.daily_bucket_scan_capped", { + maxRows: DAILY_BUCKET_SCAN_MAX_ROWS, + sources: cappedDailySources, + }); + } for (const row of aiDailyRows) { - const day = ensureDay(row.date); - day.inputTokens = int(row.input_tokens); - day.outputTokens = int(row.output_tokens); - day.totalTokens = int(row.input_tokens) + int(row.output_tokens); - day.durationMs = int(row.duration_ms); + const date = isoDate(row.timestamp); + if (!date) continue; + const day = ensureDay(date); + day.inputTokens = int(day.inputTokens) + int(row.input_tokens); + day.outputTokens = int(day.outputTokens) + int(row.output_tokens); + day.totalTokens = int(day.totalTokens) + int(row.input_tokens) + int(row.output_tokens); + day.durationMs = int(day.durationMs) + int(row.duration_ms); } for (const row of sessionRows) { const date = isoDate(row.started_at); @@ -631,15 +647,26 @@ export function collectAdeDatabaseUsageStats( day.insertions = int(day.insertions) + int(row.insertions); day.deletions = int(day.deletions) + int(row.deletions); } - for (const row of clientDailyRows) { - const day = ensureDay(row.date); - day.interactions = int(day.interactions) + int(row.interactions); - day.clients = { ...(day.clients ?? {}), [row.client_surface]: int(row.interactions) }; + const activeDaysByClient = new Map>(); + for (const row of clientEventRows) { + const date = isoDate(row.occurred_at); + if (!date) continue; + const day = ensureDay(date); + day.interactions = int(day.interactions) + 1; + day.clients = { + ...(day.clients ?? {}), + [row.client_surface]: int(day.clients?.[row.client_surface]) + 1, + }; + const activeDays = activeDaysByClient.get(row.client_surface) ?? new Set(); + activeDays.add(date); + activeDaysByClient.set(row.client_surface, activeDays); } for (const row of operationDailyRows) { - const day = ensureDay(row.date); - if (row.kind === "git_commit") day.commits = int(day.commits) + int(row.count); - if (row.kind === "pr_land") day.prs = int(day.prs) + int(row.count); + const date = isoDate(row.started_at); + if (!date) continue; + const day = ensureDay(date); + if (row.kind === "git_commit") day.commits = int(day.commits) + 1; + if (row.kind === "pr_land") day.prs = int(day.prs) + 1; } const activeDates = new Set(); @@ -667,7 +694,7 @@ export function collectAdeDatabaseUsageStats( .map((row) => ({ client: row.client_surface, interactions: int(row.interactions), - activeDays: int(row.active_days), + activeDays: activeDaysByClient.get(row.client_surface)?.size ?? 0, sessions: int(row.sessions), lastActiveAt: row.last_active_at, })) diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts index ab1def96b..5894eda51 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts @@ -41,12 +41,17 @@ import { createUsageTrackingService, _testing } from "./usageTrackingService"; const { aggregateCosts, + localDayKey, + makeDailySkeleton, + dateIntersectsRange, + scanGithubPullRequestPages, calculatePacing, MIN_POLL_INTERVAL_MS, MAX_POLL_INTERVAL_MS, isCodexTokenStale, isTokenExpiredOrExpiring, parseClaudeWindows, + parseClaudeCliUsage, parseCodexRateLimitWindows, calculatePacingByProvider, buildProviderWindows, @@ -54,6 +59,7 @@ const { collectAdeUsageStats, pollCodexUsage, pollCodexViaCliRpc, + readClaudeCredentials, resolveTokenPrice, resetDynamicTokenPricingForTest, setDynamicTokenPricingForTest, @@ -354,10 +360,140 @@ describe("aggregateCosts", () => { }); }); +describe("local daily aggregation", () => { + it("daily points carry output and cache split", () => { + const now = new Date(2026, 4, 29, 12, 0, 0, 0); + const date = localDayKey(now); + const stats = collectAdeUsageStats({ + snapshot: { + windows: [], + pacing: calculatePacing([]), + costs: [{ + provider: "codex", + todayCostUsd: 0, + last30dCostUsd: 0, + tokenBreakdown: {}, + dailyTokenBreakdownByPreset: { + today: { + [date]: { + "gpt-5.5": { input: 100, output: 40, cached: 25, cacheWrite: 5 }, + }, + }, + }, + }], + adeCosts: [], + extraUsage: [], + lastPolledAt: now.toISOString(), + errors: [], + }, + args: { preset: "today" }, + nowMs: now.getTime(), + } as any); + + expect(stats.daily.find((point) => point.date === date)).toMatchObject({ + inputTokens: 100, + outputTokens: 40, + cachedTokens: 30, + totalTokens: 170, + }); + }); + + it("legacy daily totals do not masquerade as input tokens", () => { + const now = new Date(2026, 4, 29, 12, 0, 0, 0); + const date = localDayKey(now); + const stats = collectAdeUsageStats({ + snapshot: { + windows: [], + pacing: calculatePacing([]), + costs: [{ + provider: "claude", + todayCostUsd: 0, + last30dCostUsd: 0, + tokenBreakdown: {}, + dailyTokensByPreset: { today: { [date]: 55 } }, + }], + adeCosts: [], + extraUsage: [], + lastPolledAt: now.toISOString(), + errors: [], + }, + args: { preset: "today" }, + nowMs: now.getTime(), + } as any); + + expect(stats.daily.find((point) => point.date === date)).toMatchObject({ + inputTokens: 0, + outputTokens: 0, + totalTokens: 55, + }); + }); + + it("day keys are local-timezone stable across DST calendar fixtures", () => { + expect(localDayKey("2026-03-08")).toBe("2026-03-08"); + const fixtures = [ + { + timezone: "America/Los_Angeles", + start: new Date(2026, 2, 7, 12), + until: new Date(2026, 2, 10, 12), + expected: ["2026-03-07", "2026-03-08", "2026-03-09", "2026-03-10"], + }, + { + timezone: "Pacific/Auckland", + start: new Date(2026, 3, 3, 12), + until: new Date(2026, 3, 6, 12), + expected: ["2026-04-03", "2026-04-04", "2026-04-05", "2026-04-06"], + }, + ]; + + for (const fixture of fixtures) { + const range = { + preset: "7d" as const, + since: new Date( + fixture.start.getFullYear(), + fixture.start.getMonth(), + fixture.start.getDate(), + ).toISOString(), + until: fixture.until.toISOString(), + }; + expect(makeDailySkeleton(range, fixture.until.getTime()).map((point) => point.date), fixture.timezone) + .toEqual(fixture.expected); + expect(dateIntersectsRange(fixture.expected[1]!, range), fixture.timezone).toBe(true); + } + }); + + it("extends the skeleton instead of dropping an observed local day", () => { + const now = new Date(2026, 4, 29, 12); + const oldDate = "2024-01-02"; + const stats = collectAdeUsageStats({ + snapshot: { + windows: [], + pacing: calculatePacing([]), + costs: [{ + provider: "claude", + todayCostUsd: 0, + last30dCostUsd: 0, + tokenBreakdown: {}, + dailyTokenBreakdownByPreset: { + all: { [oldDate]: { opus: { input: 1, output: 2, cached: 3 } } }, + }, + }], + adeCosts: [], + extraUsage: [], + lastPolledAt: now.toISOString(), + errors: [], + }, + args: { preset: "all" }, + nowMs: now.getTime(), + } as any); + + expect(stats.daily.find((point) => point.date === oldDate)?.totalTokens).toBe(6); + }); +}); + // ── collectAdeUsageStats ───────────────────────────────────────── describe("collectAdeUsageStats", () => { - it("uses local runtime scans and GitHub activity without ADE database counting", () => { + it("keeps GitHub and local activity separate", () => { const nowMs = Date.parse("2026-05-29T12:00:00.000Z"); const snapshot = { windows: [], @@ -417,6 +553,32 @@ describe("collectAdeUsageStats", () => { const stats = collectAdeUsageStats({ snapshot, githubStats, + databaseStats: { + summary: { + commitsCreated: 2, + pushOperations: 3, + prLandings: 1, + filesChanged: 4, + insertions: 120, + deletions: 20, + }, + providers: [], + models: [], + agentProviders: [], + agentModels: [], + features: [], + lanes: [], + activities: [], + clients: [], + daily: [{ + date: "2026-05-29", + commits: 2, + prs: 1, + insertions: 120, + deletions: 20, + filesChanged: 4, + }], + } as any, args: { preset: "7d" }, nowMs, }); @@ -433,20 +595,105 @@ describe("collectAdeUsageStats", () => { expect(stats.summary.prsOpen).toBe(3); expect(stats.summary.prsMerged).toBe(6); expect(stats.summary.prsClosed).toBe(1); - expect(stats.summary.commitsCreated).toBe(9); + expect(stats.summary.commitsCreated).toBe(2); expect(stats.summary.prAdditions).toBe(9_106); expect(stats.summary.prDeletions).toBe(1_313); expect(stats.github.repo).toBe("arul28/ADE"); + expect(stats.githubActivity).toMatchObject({ commits: 9, prsTracked: 7, prAdditions: 9_106 }); + expect(stats.localActivity).toMatchObject({ commits: 2, prLandings: 1, insertions: 120 }); expect(stats.providers.map((provider) => provider.provider)).toEqual(["codex"]); expect(stats.adeProviders).toEqual([]); expect(stats.agentProviders).toEqual([]); expect(stats.daily.find((point) => point.date === "2026-05-29")).toMatchObject({ - commits: 9, - prs: 7, - insertions: 9_106, - deletions: 1_313, - filesChanged: 86, + commits: 2, + prs: 1, + insertions: 120, + deletions: 20, + filesChanged: 4, + githubCommits: 9, + githubPrs: 7, + githubAdditions: 9_106, + githubDeletions: 1_313, + }); + }); +}); + +describe("GitHub activity scan", () => { + it("stops pull request pagination once a page's oldest update crosses the range start", async () => { + const runCommand = vi.fn(async (_command: string, _args: string[]) => JSON.stringify({ + nodes: [ + { number: 3, createdAt: "2026-05-29T12:00:00.000Z", updatedAt: "2026-05-29T12:00:00.000Z", author: { login: "arul" } }, + { number: 2, createdAt: "2026-05-28T12:00:00.000Z", updatedAt: "2026-05-20T12:00:00.000Z", author: { login: "arul" } }, + ], + pageInfo: { hasNextPage: true, endCursor: "next-page" }, + })); + + const rows = await scanGithubPullRequestPages({ + projectRoot: "/repo", + repoParts: { owner: "arul", name: "ADE" }, + viewer: "arul", + range: { + preset: "7d", + since: "2026-05-23T00:00:00.000Z", + until: "2026-05-29T23:59:59.000Z", + }, + runCommand, + }); + + expect(rows.map((row) => row.number)).toEqual([3, 2]); + expect(runCommand).toHaveBeenCalledTimes(1); + expect(runCommand.mock.calls[0]?.[1]).toEqual(expect.arrayContaining([ + expect.stringContaining("field: UPDATED_AT"), + ])); + }); + + it("continues past old creations to count a long-lived PR merged in range", async () => { + const pages = [ + { + nodes: [ + { + number: 2, + createdAt: "2026-01-10T12:00:00.000Z", + updatedAt: "2026-05-29T12:00:00.000Z", + author: { login: "arul" }, + }, + ], + pageInfo: { hasNextPage: true, endCursor: "next-page" }, + }, + { + nodes: [ + { + number: 1, + state: "MERGED", + createdAt: "2025-12-01T12:00:00.000Z", + updatedAt: "2026-05-28T12:00:00.000Z", + mergedAt: "2026-05-28T12:00:00.000Z", + author: { login: "arul" }, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + ]; + const runCommand = vi.fn(async () => JSON.stringify(pages.shift())); + + const rows = await scanGithubPullRequestPages({ + projectRoot: "/repo", + repoParts: { owner: "arul", name: "ADE" }, + viewer: "arul", + range: { + preset: "7d", + since: "2026-05-23T00:00:00.000Z", + until: "2026-05-29T23:59:59.000Z", + }, + runCommand, }); + + expect(runCommand).toHaveBeenCalledTimes(2); + expect(rows).toContainEqual(expect.objectContaining({ + number: 1, + createdAt: "2025-12-01T12:00:00.000Z", + mergedAt: "2026-05-28T12:00:00.000Z", + })); }); }); @@ -717,6 +964,43 @@ describe("parseClaudeWindows", () => { }); }); +describe("parseClaudeCliUsage", () => { + it("parses Claude's interactive usage panel as a bounded fallback", () => { + const windows = parseClaudeCliUsage(` +Settings: Usage +Current session +75% left +Resets 5pm +Current week (all models) +40% used +Resets Jul 12 at 3pm +`); + + expect(windows).toHaveLength(2); + expect(windows.find((window) => window.windowType === "five_hour")?.percentUsed).toBe(25); + expect(windows.find((window) => window.windowType === "weekly")?.percentUsed).toBe(40); + expect(windows.every((window) => Number.isFinite(Date.parse(window.resetsAt)))).toBe(true); + }); +}); + +describe("readClaudeCredentials", () => { + it("skips macOS Keychain access for background reads", async () => { + const originalPlatform = process.platform; + const readFileSpy = vi.spyOn(fs.promises, "readFile").mockRejectedValue( + Object.assign(new Error("ENOENT"), { code: "ENOENT" }), + ); + setPlatform("darwin"); + + try { + await expect(readClaudeCredentials({ allowKeychain: false })).resolves.toBeNull(); + expect(mockState.spawn).not.toHaveBeenCalled(); + } finally { + readFileSpy.mockRestore(); + setPlatform(originalPlatform); + } + }); +}); + describe("parseCodexRateLimitWindows", () => { it("accepts the wham HTTP response shape", () => { const result = parseCodexRateLimitWindows({ @@ -942,7 +1226,7 @@ describe("pollCodexViaCliRpc", () => { } }); - it("does not spawn the CLI fallback for non-auth Codex 4xx responses", async () => { + it.each([403, 409, 429] as const)("does not spawn the CLI fallback for Codex HTTP %s", async (status) => { const tmpDir = makeTmpDir(); const originalCodexHome = process.env.CODEX_HOME; fs.writeFileSync(path.join(tmpDir, "auth.json"), JSON.stringify({ @@ -951,7 +1235,7 @@ describe("pollCodexViaCliRpc", () => { process.env.CODEX_HOME = tmpDir; vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, - status: 429, + status, json: async () => ({}), })); @@ -960,7 +1244,8 @@ describe("pollCodexViaCliRpc", () => { const result = await pollCodexUsage(logger as any); expect(result.windows).toEqual([]); - expect(result.errors).toEqual(["codex: API returned 429"]); + expect(result.errors).toEqual([`codex: API returned ${status}`]); + expect(result.errorKind).toBe(status === 403 ? "forbidden" : status === 409 ? "conflict" : "rate_limited"); expect(mockState.spawn).not.toHaveBeenCalled(); } finally { if (originalCodexHome === undefined) { @@ -973,7 +1258,98 @@ describe("pollCodexViaCliRpc", () => { } }); - it("merges app-server daily usage and workspace messages when HTTP rate-limit windows succeed", async () => { + it("falls back to Codex RPC after retryable HTTP server errors", async () => { + const tmpDir = makeTmpDir(); + const originalCodexHome = process.env.CODEX_HOME; + fs.writeFileSync(path.join(tmpDir, "auth.json"), JSON.stringify({ + tokens: { access_token: "ok-token" }, + })); + process.env.CODEX_HOME = tmpDir; + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + json: async () => ({}), + }); + vi.stubGlobal("fetch", fetchMock); + const fake = createFakeCodexChild({ + stdout: `${JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + rateLimits: { + primary: { usedPercent: 17, resetsAt: 1773446952 }, + secondary: { usedPercent: 64, resetsAt: 1773853354 }, + }, + }, + })}\n`, + }); + mockState.resolveCodexExecutable.mockReturnValue({ path: "codex", source: "path" }); + mockState.spawn.mockReturnValue(fake.child); + + try { + const result = await pollCodexUsage(createLogger() as any); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result.windows).toHaveLength(2); + expect(result.source).toBe("cli"); + expect(result.errors).toEqual([]); + expect(mockState.spawn).toHaveBeenCalledTimes(1); + } finally { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } + vi.unstubAllGlobals(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("falls back to the Codex RPC when a successful HTTP response drifts", async () => { + const tmpDir = makeTmpDir(); + const originalCodexHome = process.env.CODEX_HOME; + fs.writeFileSync(path.join(tmpDir, "auth.json"), JSON.stringify({ + tokens: { access_token: "ok-token" }, + })); + process.env.CODEX_HOME = tmpDir; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ renamed_rate_limit: {} }), + })); + const fake = createFakeCodexChild({ + stdout: `${JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + rateLimits: { + primary: { usedPercent: 17, resetsAt: 1773446952 }, + secondary: { usedPercent: 64, resetsAt: 1773853354 }, + }, + }, + })}\n`, + }); + mockState.resolveCodexExecutable.mockReturnValue({ path: "codex", source: "path" }); + mockState.spawn.mockReturnValue(fake.child); + + try { + const result = await pollCodexUsage(createLogger() as any); + + expect(result.windows).toHaveLength(2); + expect(result.source).toBe("cli"); + expect(mockState.spawn).toHaveBeenCalledTimes(1); + } finally { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } + vi.unstubAllGlobals(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("does not launch app-server when HTTP returns complete rate-limit windows", async () => { const tmpDir = makeTmpDir(); const originalCodexHome = process.env.CODEX_HOME; fs.writeFileSync(path.join(tmpDir, "auth.json"), JSON.stringify({ @@ -1031,15 +1407,10 @@ describe("pollCodexViaCliRpc", () => { expect(result.errors).toEqual([]); expect(result.windows).toHaveLength(2); - expect(result.dailyUsage7d?.some((value) => value === 123)).toBe(true); - expect(result.providerMessages).toEqual([ - expect.objectContaining({ - provider: "codex", - id: "msg-1", - kind: "headline", - message: "Native usage ready", - }), - ]); + expect(result.source).toBe("http"); + expect(result.dailyUsage7d).toBeUndefined(); + expect(result.providerMessages).toBeUndefined(); + expect(mockState.spawn).not.toHaveBeenCalled(); } finally { if (originalCodexHome === undefined) { delete process.env.CODEX_HOME; @@ -1086,19 +1457,46 @@ describe("createUsageTrackingService", () => { it("clamps out-of-range poll intervals internally", () => { const logger = createLogger(); const dependencies = createFastDependencies(); - const setIntervalSpy = vi.spyOn(globalThis, "setInterval"); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); const service1 = createUsageTrackingService({ logger, pollIntervalMs: 100, dependencies }); service1.start(); - expect(setIntervalSpy).toHaveBeenLastCalledWith(expect.any(Function), MIN_POLL_INTERVAL_MS); + expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), MIN_POLL_INTERVAL_MS); service1.dispose(); const service2 = createUsageTrackingService({ logger, pollIntervalMs: 60 * 60 * 1000, dependencies }); service2.start(); - expect(setIntervalSpy).toHaveBeenLastCalledWith(expect.any(Function), MAX_POLL_INTERVAL_MS); + expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), MAX_POLL_INTERVAL_MS); service2.dispose(); - setIntervalSpy.mockRestore(); + setTimeoutSpy.mockRestore(); + }); + + it("does not reschedule after stop while the startup poll is in flight", async () => { + const logger = createLogger(); + let resolveClaude!: (result: { windows: never[]; extraUsage: null; errors: never[] }) => void; + const dependencies = { + ...createFastDependencies(), + pollClaudeUsage: vi.fn(() => new Promise[0]>((resolve) => { + resolveClaude = resolve; + })), + }; + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + const service = createUsageTrackingService({ logger, dependencies }); + + service.start(); + await Promise.resolve(); + expect(dependencies.pollClaudeUsage).toHaveBeenCalledTimes(1); + service.stop(); + const timeoutCallsAfterStop = setTimeoutSpy.mock.calls.length; + + resolveClaude({ windows: [], extraUsage: null, errors: [] }); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + + expect(setTimeoutSpy).toHaveBeenCalledTimes(timeoutCallsAfterStop); + service.dispose(); + setTimeoutSpy.mockRestore(); }); it("calls onUpdate when poll completes", async () => { @@ -1119,6 +1517,121 @@ describe("createUsageTrackingService", () => { service.dispose(); }); + it("notifies onUpdate after a GitHub-only background refresh completes", async () => { + const logger = createLogger(); + const onUpdate = vi.fn(); + let resolveGithub!: (stats: { + repo: string; + available: boolean; + fetchedAt: string; + error: null; + commitsCreated: number; + prsTracked: number; + prsOpen: number; + prsMerged: number; + prsClosed: number; + prAdditions: number; + prDeletions: number; + filesChanged: number; + daily: never[]; + }) => void; + const scanGitHubStats = vi.fn(() => new Promise[0]>((resolve) => { + resolveGithub = resolve; + })); + const service = createUsageTrackingService({ + logger, + onUpdate, + dependencies: { + ...createFastDependencies(), + scanGitHubStats, + }, + }); + + await service.poll(); + await service.refreshHistory(); + onUpdate.mockClear(); + + await service.getAdeUsageStats({ preset: "7d" }); + await vi.waitFor(() => expect(scanGitHubStats).toHaveBeenCalledTimes(1)); + expect(onUpdate).not.toHaveBeenCalled(); + + resolveGithub({ + repo: "arul28/ADE", + available: true, + fetchedAt: "2026-05-29T12:00:00.000Z", + error: null, + commitsCreated: 1, + prsTracked: 0, + prsOpen: 0, + prsMerged: 0, + prsClosed: 0, + prAdditions: 0, + prDeletions: 0, + filesChanged: 0, + daily: [], + }); + + await vi.waitFor(() => expect(onUpdate).toHaveBeenCalledTimes(1)); + service.dispose(); + }); + + it("notifies again when GitHub finishes after a combined background refresh", async () => { + const logger = createLogger(); + const onUpdate = vi.fn(); + let resolveClaude!: (entries: never[]) => void; + let resolveGithub!: (stats: { + repo: string; + available: boolean; + fetchedAt: string; + error: null; + commitsCreated: number; + prsTracked: number; + prsOpen: number; + prsMerged: number; + prsClosed: number; + prAdditions: number; + prDeletions: number; + filesChanged: number; + daily: never[]; + }) => void; + const dependencies = { + ...createFastDependencies(), + scanClaudeLogs: vi.fn(() => new Promise((resolve) => { + resolveClaude = resolve; + })), + scanGitHubStats: vi.fn(() => new Promise[0]>((resolve) => { + resolveGithub = resolve; + })), + }; + const service = createUsageTrackingService({ logger, onUpdate, dependencies }); + + await service.getAdeUsageStats({ preset: "7d" }); + await vi.waitFor(() => expect(dependencies.scanClaudeLogs).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(dependencies.scanGitHubStats).toHaveBeenCalledTimes(1)); + + resolveClaude([]); + await vi.waitFor(() => expect(onUpdate).toHaveBeenCalledTimes(1)); + + resolveGithub({ + repo: "arul28/ADE", + available: true, + fetchedAt: "2026-05-29T12:00:00.000Z", + error: null, + commitsCreated: 1, + prsTracked: 0, + prsOpen: 0, + prsMerged: 0, + prsClosed: 0, + prAdditions: 0, + prDeletions: 0, + filesChanged: 0, + daily: [], + }); + + await vi.waitFor(() => expect(onUpdate).toHaveBeenCalledTimes(2)); + service.dispose(); + }); + it("does not scan local provider ledgers during automatic startup polls", async () => { const logger = createLogger(); const dependencies = createFastDependencies(); @@ -1161,12 +1674,12 @@ describe("createUsageTrackingService", () => { expect(pacing?.codex?.status).toBe("far-ahead"); }); - it("forceRefresh invalidates cost cache and re-polls", async () => { + it("refreshHistory invalidates cost cache without coupling it to quota refresh", async () => { const logger = createLogger(); const dependencies = createFastDependencies(); const service = createUsageTrackingService({ logger, dependencies }); - const s1 = await service.forceRefresh(); + const s1 = await service.refreshHistory(); expect(s1).toBeDefined(); expect(s1.lastPolledAt).toBeTruthy(); expect(dependencies.scanClaudeLogs).toHaveBeenCalledTimes(1); @@ -1182,6 +1695,43 @@ describe("createUsageTrackingService", () => { service.dispose(); }); + it("keeps an explicit quota refresh responsive while a large history scan is pending", async () => { + const logger = createLogger(); + let resolveSlowScan!: (entries: never[]) => void; + const dependencies = { + ...createFastDependencies(), + scanClaudeLogs: vi.fn(() => new Promise((resolve) => { + resolveSlowScan = resolve; + })), + }; + const service = createUsageTrackingService({ logger, dependencies }); + + const historyRefresh = service.refreshHistory(); + await new Promise((resolve) => setImmediate(resolve)); + + await expect(service.forceRefresh()).resolves.toBeDefined(); + expect(dependencies.pollClaudeUsage).toHaveBeenCalledTimes(1); + expect(dependencies.pollCodexUsage).toHaveBeenCalledTimes(1); + + resolveSlowScan([]); + await expect(historyRefresh).resolves.toBeDefined(); + service.dispose(); + }); + + it("does not scan provider ledgers during an explicit quota-only refresh", async () => { + const logger = createLogger(); + const dependencies = createFastDependencies(); + const service = createUsageTrackingService({ logger, dependencies }); + + await service.forceRefresh(); + + expect(dependencies.scanClaudeLogs).not.toHaveBeenCalled(); + expect(dependencies.scanCodexLogs).not.toHaveBeenCalled(); + expect(dependencies.scanCursorLogs).not.toHaveBeenCalled(); + expect(dependencies.scanGeminiLogs).not.toHaveBeenCalled(); + service.dispose(); + }); + it("caches a completed empty provider scan instead of rescanning on every stats read", async () => { const logger = createLogger(); const dependencies = { @@ -1216,7 +1766,7 @@ describe("createUsageTrackingService", () => { service.dispose(); }); - it("waits for a startup no-cost poll before running an explicit cost refresh", async () => { + it("runs an explicit history scan independently from a pending startup quota poll", async () => { const logger = createLogger(); const dependencies = createFastDependencies(); let resolveStartupPoll!: (value: { windows: never[]; extraUsage: null; errors: never[] }) => void; @@ -1231,17 +1781,17 @@ describe("createUsageTrackingService", () => { await new Promise((resolve) => setImmediate(resolve)); expect(dependencies.pollClaudeUsage).toHaveBeenCalledTimes(1); - const refresh = service.forceRefresh(); + const refresh = service.refreshHistory(); await new Promise((resolve) => setImmediate(resolve)); - expect(dependencies.scanClaudeLogs).not.toHaveBeenCalled(); - - resolveStartupPoll({ windows: [] as never[], extraUsage: null, errors: [] as never[] }); await expect(refresh).resolves.toBeDefined(); - - expect(dependencies.pollClaudeUsage).toHaveBeenCalledTimes(2); expect(dependencies.scanClaudeLogs).toHaveBeenCalledTimes(1); expect(dependencies.scanCodexLogs).toHaveBeenCalledTimes(1); + resolveStartupPoll({ windows: [] as never[], extraUsage: null, errors: [] as never[] }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(dependencies.pollClaudeUsage).toHaveBeenCalledTimes(1); + service.dispose(); }); @@ -1297,8 +1847,8 @@ describe("createUsageTrackingService", () => { expect(firstLoading.freshness?.state).toBe("refreshing"); expect(secondLoading.freshness?.state).toBe("refreshing"); - expect(first.summary.commitsCreated).toBe(10); - expect(second.summary.commitsCreated).toBe(11); + expect(first.githubActivity?.commits).toBe(10); + expect(second.githubActivity?.commits).toBe(11); expect(scanGitHubStats).toHaveBeenCalledTimes(2); service.dispose(); @@ -1372,10 +1922,25 @@ describe("createUsageTrackingService", () => { timestamp: now, }, ] as any), + scanGitHubStats: vi.fn(async () => ({ + repo: null, + available: false, + fetchedAt: null, + error: null, + commitsCreated: 0, + prsTracked: 0, + prsOpen: 0, + prsMerged: 0, + prsClosed: 0, + prAdditions: 0, + prDeletions: 0, + filesChanged: 0, + daily: [], + })), }; const service = createUsageTrackingService({ logger, dependencies }); - const snapshot = await service.forceRefresh(); + const snapshot = await service.refreshHistory(); expect(snapshot.costs.find((cost) => cost.provider === "codex")?.tokenBreakdownByPreset?.today?.["gpt-5.5"]).toMatchObject({ input: 150, @@ -1383,6 +1948,148 @@ describe("createUsageTrackingService", () => { cached: 60, }); expect(snapshot.adeCosts).toEqual([]); + expect(snapshot.costs.find((cost) => cost.provider === "codex")?.adeOriginatedTokensByPreset?.today).toBe(80); + const stats = await service.getAdeUsageStats({ preset: "today" }); + expect(stats.providers.find((provider) => provider.provider === "codex")).toMatchObject({ + adeOriginatedTokens: 80, + externalTokens: 160, + }); + + service.dispose(); + }); + + it("project scope filters ledgers without rescanning and excludes unsupported totals", async () => { + const logger = createLogger(); + const now = Date.now(); + const dependencies = { + ...createFastDependencies(), + scanClaudeLogs: vi.fn(async () => [ + { + messageId: "project", + model: "claude-opus-4-6", + projectPath: "/repo/.ade/worktrees/lane-a", + adeOriginated: true, + inputTokens: 10, + outputTokens: 5, + cachedTokens: 0, + timestamp: now, + }, + { + messageId: "other", + model: "claude-opus-4-6", + projectPath: "/other/repo", + inputTokens: 20, + outputTokens: 10, + cachedTokens: 0, + timestamp: now, + }, + ] as any), + scanCursorLogs: vi.fn(async () => [{ + messageId: "cursor-machine-only", + model: "cursor-auto", + inputTokens: 30, + outputTokens: 10, + cachedTokens: 0, + timestamp: now, + }] as any), + scanGitHubStats: vi.fn(async () => ({ + repo: null, + available: false, + fetchedAt: null, + error: null, + commitsCreated: 0, + prsTracked: 0, + prsOpen: 0, + prsMerged: 0, + prsClosed: 0, + prAdditions: 0, + prDeletions: 0, + filesChanged: 0, + daily: [], + })), + }; + const service = createUsageTrackingService({ logger, dependencies, projectRoot: "/repo" }); + await service.refreshHistory(); + + const machine = await service.getAdeUsageStats({ preset: "today", scope: "machine" }); + const project = await service.getAdeUsageStats({ preset: "today", scope: "project" }); + + expect(machine.scope).toBe("machine"); + expect(machine.summary.observedProviderTokens).toBe(85); + expect(machine.providers.find((provider) => provider.provider === "claude")).toMatchObject({ + totalTokens: 45, + adeOriginatedTokens: 15, + externalTokens: 30, + scopeSupported: true, + }); + expect(project.scope).toBe("project"); + expect(project.summary.observedProviderTokens).toBe(15); + expect(project.providers.find((provider) => provider.provider === "claude")).toMatchObject({ + totalTokens: 15, + adeOriginatedTokens: 15, + externalTokens: 0, + scopeSupported: true, + }); + expect(project.providers.find((provider) => provider.provider === "cursor")).toMatchObject({ + totalTokens: 0, + scopeSupported: false, + estimation: "mixed", + }); + expect(dependencies.scanClaudeLogs).toHaveBeenCalledTimes(1); + expect(dependencies.scanCursorLogs).toHaveBeenCalledTimes(1); + + service.dispose(); + }); + + it("sets estimation flags on provider snapshots and summaries", async () => { + const logger = createLogger(); + const now = Date.now(); + const tokenEntry = (messageId: string, model: string, estimation?: "chars" | "mixed" | "distribution") => ({ + messageId, + model, + inputTokens: 8, + outputTokens: 2, + cachedTokens: 0, + timestamp: now, + ...(estimation ? { estimation } : {}), + }); + const dependencies = { + ...createFastDependencies(), + scanClaudeLogs: vi.fn(async () => [tokenEntry("claude", "claude-opus-4-6")] as any), + scanCursorLogs: vi.fn(async () => [tokenEntry("cursor", "cursor-auto")] as any), + scanCursorAgentLogs: vi.fn(async () => [tokenEntry("cursor-agent", "cursor-agent-auto", "chars")] as any), + scanDroidLogs: vi.fn(async () => [tokenEntry("droid", "droid-auto", "distribution")] as any), + scanCopilotLogs: vi.fn(async () => [tokenEntry("copilot", "copilot-auto", "chars")] as any), + scanGitHubStats: vi.fn(async () => ({ + repo: null, + available: false, + fetchedAt: null, + error: null, + commitsCreated: 0, + prsTracked: 0, + prsOpen: 0, + prsMerged: 0, + prsClosed: 0, + prAdditions: 0, + prDeletions: 0, + filesChanged: 0, + daily: [], + })), + }; + const service = createUsageTrackingService({ logger, dependencies }); + const snapshot = await service.refreshHistory(); + const snapshotFlags = Object.fromEntries(snapshot.costs.map((cost) => [cost.provider, cost.estimation])); + expect(snapshotFlags).toMatchObject({ + cursor: "mixed", + "cursor-agent": "chars", + droid: "distribution", + copilot: "chars", + }); + expect(snapshotFlags.claude).toBeUndefined(); + + const stats = await service.getAdeUsageStats({ preset: "today" }); + const summaryFlags = Object.fromEntries(stats.providers.map((provider) => [provider.provider, provider.estimation])); + expect(summaryFlags).toMatchObject(snapshotFlags); service.dispose(); }); @@ -1515,6 +2222,100 @@ describe("scanClaudeLogs (via aggregateCosts)", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + + it("deduplicates Claude message ids across files", async () => { + const tmpDir = makeTmpDir(); + const projectDir = path.join(tmpDir, "projects", "-repo"); + fs.mkdirSync(projectDir, { recursive: true }); + try { + const assistant = (timestamp: string) => JSON.stringify({ + type: "assistant", + timestamp, + cwd: "/repo", + message: { + id: "msg-shared", + model: "claude-opus-4-6", + usage: { input_tokens: 10, output_tokens: 2 }, + }, + }); + fs.writeFileSync(path.join(projectDir, "session-a.jsonl"), `${assistant("2026-05-29T12:00:00.000Z")}\n`); + fs.writeFileSync(path.join(projectDir, "session-b.jsonl"), `${assistant("2026-05-29T12:01:00.000Z")}\n`); + + const entries = await scanClaudeLogs([projectDir]); + + expect(entries).toHaveLength(1); + expect(entries[0]?.messageId).toBe("msg-shared"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("keeps the last Claude streaming partial at the first timestamp", async () => { + const tmpDir = makeTmpDir(); + const projectDir = path.join(tmpDir, "projects", "-repo"); + fs.mkdirSync(projectDir, { recursive: true }); + try { + const firstTimestamp = "2026-05-29T12:00:00.000Z"; + fs.writeFileSync( + path.join(projectDir, "session.jsonl"), + [ + JSON.stringify({ + type: "assistant", + timestamp: firstTimestamp, + cwd: "/repo", + message: { id: "msg-stream", model: "claude-opus-4-6", usage: { input_tokens: 10, output_tokens: 2 } }, + }), + JSON.stringify({ + type: "assistant", + timestamp: "2026-05-29T12:00:03.000Z", + cwd: "/repo", + message: { id: "msg-stream", model: "claude-opus-4-6", usage: { input_tokens: 25, output_tokens: 8 } }, + }), + "", + ].join("\n"), + ); + + const entries = await scanClaudeLogs([projectDir]); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ inputTokens: 25, outputTokens: 8 }); + expect(entries[0]?.timestamp).toBe(Date.parse(firstTimestamp)); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("attributes Claude sessions launched from ADE worktrees", async () => { + const tmpDir = makeTmpDir(); + const projectDir = path.join(tmpDir, "projects", "-repo--ade-worktrees-lane"); + fs.mkdirSync(projectDir, { recursive: true }); + try { + fs.writeFileSync( + path.join(projectDir, "session.jsonl"), + `${JSON.stringify({ + type: "assistant", + timestamp: new Date().toISOString(), + cwd: "/repo/.ade/worktrees/lane", + message: { + id: "msg-ade", + model: "claude-opus-4-6", + usage: { input_tokens: 10, output_tokens: 2 }, + }, + })}\n`, + ); + + const entries = await scanClaudeLogs([projectDir]); + const cost = aggregateCosts(entries, "claude"); + + expect(entries[0]).toMatchObject({ + projectPath: "/repo/.ade/worktrees/lane", + adeOriginated: true, + }); + expect(cost.adeOriginatedTokensByPreset?.today).toBe(12); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); describe("scanCodexLogs", () => { @@ -1575,9 +2376,10 @@ describe("scanCodexLogs", () => { expect(entries).toHaveLength(1); expect(entries[0]).toMatchObject({ model: "gpt-5.5", - inputTokens: 1200, + inputTokens: 900, billableInputTokens: 900, - outputTokens: 100, + outputTokens: 80, + billableOutputTokens: 100, cachedTokens: 300, billableCachedTokens: 300, }); @@ -1591,7 +2393,116 @@ describe("scanCodexLogs", () => { } }); - it("counts Codex-owned session files while skipping ADE-originated launcher files", async () => { + it("reconciles lifetime totals with Codex Desktop's read-only thread index", async () => { + const tmpDir = makeTmpDir(); + const originalCodexHome = process.env.CODEX_HOME; + const { DatabaseSync } = requireForTest("node:sqlite") as { DatabaseSync: new (dbPath: string) => any }; + try { + process.env.CODEX_HOME = tmpDir; + const sessionDir = path.join(tmpDir, "sessions", "2026", "05", "29"); + fs.mkdirSync(sessionDir, { recursive: true }); + fs.writeFileSync( + path.join(sessionDir, "session-1.jsonl"), + [ + JSON.stringify({ + timestamp: "2026-05-29T12:00:00.000Z", + type: "session_meta", + payload: { id: "session-1", originator: "Codex Desktop", cwd: "/repo", model: "gpt-5.5" }, + }), + JSON.stringify({ + timestamp: "2026-05-29T12:00:01.000Z", + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 }, + last_token_usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 }, + }, + }, + }), + "", + ].join("\n"), + ); + const db = new DatabaseSync(path.join(tmpDir, "state_5.sqlite")); + db.exec(` + create table threads ( + id text primary key, + tokens_used integer not null, + model text, + cwd text, + source text, + thread_source text, + created_at integer, + updated_at integer + ); + insert into threads values ( + 'session-1', 100, 'gpt-5.5', '/repo', 'Codex Desktop', + 'Codex Desktop', 1770000000, 1770000100 + ); + `); + db.close(); + + const entries = await scanCodexLogs(); + const total = entries.reduce((sum, entry) => ( + sum + entry.inputTokens + entry.outputTokens + entry.cachedTokens + (entry.cacheWriteTokens ?? 0) + ), 0); + + expect(total).toBe(100); + expect(entries).toContainEqual(expect.objectContaining({ + messageId: "session-1:state-total-remainder", + model: "gpt-5.5", + estimation: "distribution", + costOverrideUsd: 0, + })); + } finally { + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("includes Codex archived session ledgers in lifetime usage", async () => { + const tmpDir = makeTmpDir(); + const originalCodexHome = process.env.CODEX_HOME; + try { + process.env.CODEX_HOME = tmpDir; + const archivedDir = path.join(tmpDir, "archived_sessions"); + fs.mkdirSync(archivedDir, { recursive: true }); + fs.writeFileSync( + path.join(archivedDir, "archived.jsonl"), + [ + JSON.stringify({ + timestamp: "2025-08-01T12:00:00.000Z", + type: "session_meta", + payload: { id: "archived-1", originator: "codex_cli_rs", model: "gpt-5.5" }, + }), + JSON.stringify({ + timestamp: "2025-08-01T12:00:01.000Z", + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { input_tokens: 20, output_tokens: 3, total_tokens: 23 }, + last_token_usage: { input_tokens: 20, output_tokens: 3, total_tokens: 23 }, + }, + }, + }), + "", + ].join("\n"), + ); + + const entries = await scanCodexLogs(); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ inputTokens: 20, outputTokens: 3 }); + } finally { + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("includes Codex ADE sessions and attributes their origin", async () => { const tmpDir = makeTmpDir(); const originalCodexHome = process.env.CODEX_HOME; try { @@ -1629,8 +2540,14 @@ describe("scanCodexLogs", () => { const entries = await scanCodexLogs(); - expect(entries).toHaveLength(2); - expect(entries.map((entry) => entry.originator).sort()).toEqual(["Codex Desktop", "codex_cli_rs"]); + expect(entries).toHaveLength(4); + expect(entries.filter((entry) => entry.adeOriginated)).toHaveLength(2); + expect(entries.map((entry) => entry.originator).sort()).toEqual([ + "Codex Desktop", + "ade", + "ade_desktop", + "codex_cli_rs", + ]); } finally { if (originalCodexHome === undefined) { delete process.env.CODEX_HOME; @@ -1641,6 +2558,65 @@ describe("scanCodexLogs", () => { } }); + it("ignores Codex fork replay while preserving genuine cumulative deltas", async () => { + const tmpDir = makeTmpDir(); + const originalCodexHome = process.env.CODEX_HOME; + try { + process.env.CODEX_HOME = tmpDir; + const sessionDir = path.join(tmpDir, "sessions", "2026", "05", "29"); + fs.mkdirSync(sessionDir, { recursive: true }); + const usageEvent = (timestamp: string, totalInput: number, totalOutput: number, lastInput: number, lastOutput: number) => JSON.stringify({ + timestamp, + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { + input_tokens: totalInput, + output_tokens: totalOutput, + total_tokens: totalInput + totalOutput, + }, + last_token_usage: { + input_tokens: lastInput, + output_tokens: lastOutput, + total_tokens: lastInput + lastOutput, + }, + }, + }, + }); + fs.writeFileSync( + path.join(sessionDir, "parent.jsonl"), + [ + JSON.stringify({ type: "session_meta", payload: { id: "parent", cwd: "/repo", originator: "codex_cli_rs" } }), + usageEvent("2026-05-29T12:00:00.000Z", 10, 2, 10, 2), + "", + ].join("\n"), + ); + fs.writeFileSync( + path.join(sessionDir, "fork.jsonl"), + [ + JSON.stringify({ + type: "session_meta", + payload: { id: "fork", forked_from_id: "parent", cwd: "/repo", originator: "codex_cli_rs" }, + }), + usageEvent("2026-05-29T12:01:00.000Z", 10, 2, 10, 2), + usageEvent("2026-05-29T12:02:00.000Z", 15, 2, 5, 0), + "", + ].join("\n"), + ); + + const entries = await scanCodexLogs(); + + expect(entries).toHaveLength(2); + expect(entries.reduce((sum, entry) => sum + entry.inputTokens, 0)).toBe(15); + expect(entries.reduce((sum, entry) => sum + entry.outputTokens, 0)).toBe(2); + } finally { + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("skips oversized Codex session files during local usage scans", async () => { const tmpDir = makeTmpDir(); const originalCodexHome = process.env.CODEX_HOME; @@ -1671,7 +2647,7 @@ describe("scanCodexLogs", () => { "", ].join("\n"), ); - fs.truncateSync(filePath, 40 * 1024 * 1024); + fs.truncateSync(filePath, 769 * 1024 * 1024); await expect(scanCodexLogs()).resolves.toEqual([]); } finally { @@ -2140,9 +3116,20 @@ describe("buildProviderWindows", () => { it("marks ok and stamps lastSuccessAt when fresh windows arrive", () => { const fresh = [win(10)]; - const result = buildProviderWindows("claude", fresh, [], [], null, "2026-06-07T00:00:00Z"); + const result = buildProviderWindows( + "claude", + fresh, + [], + [], + null, + "2026-06-07T00:00:00Z", + "oauth", + ); expect(result.status.state).toBe("ok"); expect(result.status.lastSuccessAt).toBe("2026-06-07T00:00:00Z"); + expect(result.status.updatedAt).toBe("2026-06-07T00:00:00Z"); + expect(result.status.lastAttemptAt).toBe("2026-06-07T00:00:00Z"); + expect(result.status.source).toBe("oauth"); expect(result.lastSuccessAt).toBe("2026-06-07T00:00:00Z"); expect(result.windows).toBe(fresh); }); @@ -2228,9 +3215,24 @@ describe("buildProviderWindows", () => { it("frames a 401/expired-token failure as reconnect, not unreachable", () => { const result = buildProviderWindows("claude", [], ["claude: API returned 401"], [], null, "t"); - expect(result.status.state).toBe("error"); + expect(result.status.state).toBe("unauthed"); expect(result.status.message).toMatch(/sign-in expired|reconnect/i); }); + + it("preserves a forbidden error kind while showing reconnect guidance", () => { + const result = buildProviderWindows( + "claude", + [], + ["claude: API returned 403"], + [], + null, + "t", + "oauth", + "forbidden", + ); + expect(result.status.state).toBe("unauthed"); + expect(result.status.errorKind).toBe("forbidden"); + }); }); describe("usage reliability: non-destructive merge", () => { @@ -2266,12 +3268,12 @@ describe("usage reliability: non-destructive merge", () => { }, }); - const first = await service.poll({ includeCosts: false }); + const first = await service.poll(); expect(first.windows.filter((w) => w.provider === "claude")).toHaveLength(1); expect(first.extraUsage).toEqual([claudeExtraUsage]); expect(first.providerStatus?.claude?.state).toBe("ok"); - const second = await service.poll({ includeCosts: false }); + const second = await service.poll(); const claudeWindows = second.windows.filter((w) => w.provider === "claude"); expect(claudeWindows).toHaveLength(1); expect(claudeWindows[0]?.percentUsed).toBe(40); @@ -2301,7 +3303,7 @@ describe("usage reliability: non-destructive merge", () => { }, }); - const snap = await service.poll({ includeCosts: false }); + const snap = await service.poll(); const five = snap.windows.find((w) => w.windowType === "five_hour"); const weekly = snap.windows.find((w) => w.windowType === "weekly"); expect(five?.pacing).toBeDefined(); @@ -2352,6 +3354,20 @@ describe("fetchJsonWithRetry", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("preserves Retry-After metadata for provider backoff", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 429, + headers: { get: (name: string) => name.toLowerCase() === "retry-after" ? "120" : null }, + json: async () => ({}), + }); + vi.stubGlobal("fetch", fetchMock); + + const res = await fetchJsonWithRetry("https://example.test/usage", {}, { attempts: 2, backoffMs: 0 }); + expect(res.retryAfterMs).toBe(120_000); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it("does not retry a 429 with an empty or non-JSON body", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false, @@ -2415,6 +3431,167 @@ describe("ADE database usage aggregation", () => { return db; } + const createDatabaseServiceDependencies = () => ({ + pollClaudeUsage: vi.fn(async () => ({ windows: [] as never[], extraUsage: null, errors: [] as never[] })), + pollCodexUsage: vi.fn(async () => ({ windows: [] as never[], errors: [] as never[] })), + scanClaudeLogs: vi.fn(async () => [] as never[]), + scanCodexLogs: vi.fn(async () => [] as never[]), + scanCursorLogs: vi.fn(async () => [] as never[]), + scanCursorAgentLogs: vi.fn(async () => [] as never[]), + scanOpenClawLogs: vi.fn(async () => [] as never[]), + scanOpenCodeLogs: vi.fn(async () => [] as never[]), + scanDroidLogs: vi.fn(async () => [] as never[]), + scanCopilotLogs: vi.fn(async () => [] as never[]), + scanGeminiLogs: vi.fn(async () => [] as never[]), + scanGitHubStats: vi.fn(async () => ({ + repo: null, + available: false, + fetchedAt: null, + error: null, + commitsCreated: 0, + prsTracked: 0, + prsOpen: 0, + prsMerged: 0, + prsClosed: 0, + prAdditions: 0, + prDeletions: 0, + filesChanged: 0, + daily: [], + })), + }); + + it("prod context wires db aggregates into the usage service", async () => { + const db = await createStatsDb(); + db.run( + `insert into operations(id, project_id, lane_id, kind, started_at, ended_at, status) + values (?, ?, ?, ?, ?, ?, ?)`, + ["op-prod", "project-1", "lane-1", "git_commit", "2026-07-08T12:00:00.000Z", "2026-07-08T12:00:01.000Z", "succeeded"], + ); + const service = createUsageTrackingService({ + logger: createLogger(), + db, + projectRoot: "/repo", + dependencies: createDatabaseServiceDependencies(), + }); + + const stats = await service.getAdeUsageStats({ + preset: "all", + until: "2026-07-09T00:00:00.000Z", + }); + + expect(stats.localActivity?.commits).toBe(1); + expect(stats.summary.commitsCreated).toBe(1); + expect(stats.daily.find((point) => point.date === "2026-07-08")?.commits).toBe(1); + service.dispose(); + }); + + it("without db yields null database stats and zero local aggregates", async () => { + const range = { since: "2026-07-08T00:00:00.000Z", until: "2026-07-09T00:00:00.000Z" }; + expect(collectAdeDatabaseUsageStats(undefined, range)).toBeNull(); + const service = createUsageTrackingService({ + logger: createLogger(), + dependencies: createDatabaseServiceDependencies(), + }); + + const stats = await service.getAdeUsageStats({ preset: "all", ...range }); + + expect(stats.localActivity).toMatchObject({ + commits: 0, + pushOperations: 0, + prLandings: 0, + filesChanged: 0, + insertions: 0, + deletions: 0, + }); + service.dispose(); + }); + + it("buckets database timestamps by the local calendar day", async () => { + const db = await createStatsDb(); + const localInstant = new Date(2026, 0, 15, 0, 30, 0, 0); + const expectedDay = localDayKey(localInstant); + db.run( + `insert into ai_usage_log(id, timestamp, feature, provider, model, input_tokens, output_tokens, duration_ms, success) + values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ["ai-local-day", localInstant.toISOString(), "chat", "claude", "opus", 4, 2, 10, 1], + ); + + const stats = collectAdeDatabaseUsageStats(db, { + since: new Date(2026, 0, 15, 0, 0, 0, 0).toISOString(), + until: new Date(2026, 0, 15, 23, 59, 59, 999).toISOString(), + }); + + expect(stats?.daily).toContainEqual(expect.objectContaining({ + date: expectedDay, + inputTokens: 4, + outputTokens: 2, + totalTokens: 6, + })); + }); + + it("caps raw daily bucket scans newest-first without throwing", async () => { + const db = await createStatsDb(); + const logger = createLogger(); + const originalAll = db.all.bind(db); + const newestRow = { + timestamp: "2026-07-08T12:00:00.000Z", + input_tokens: 1, + output_tokens: 0, + duration_ms: 0, + }; + const oldestRow = { + ...newestRow, + timestamp: "2026-07-07T12:00:00.000Z", + }; + const rowsBeyondCap = Array(250_001).fill(newestRow); + rowsBeyondCap[0] = oldestRow; + let checkedClientQuery = false; + let checkedOperationQuery = false; + let checkedAiQuery = false; + const cappedDb: AdeDb = { + ...db, + all: ((sql: string, params = []) => { + const normalized = sql.replace(/\s+/g, " ").trim(); + if (normalized.startsWith("select occurred_at, client_surface")) { + expect(normalized).toContain("order by occurred_at desc limit ?"); + checkedClientQuery = true; + } + if (normalized.startsWith("select started_at, kind")) { + expect(normalized).toContain("order by started_at desc limit ?"); + checkedOperationQuery = true; + } + if (normalized.startsWith("select timestamp,")) { + expect(normalized).toContain("order by timestamp desc limit ?"); + const limit = Number(params.at(-1)); + expect(rowsBeyondCap.length).toBeGreaterThan(limit); + checkedAiQuery = true; + return rowsBeyondCap.slice(1, limit + 1); + } + return originalAll(sql, params); + }) as AdeDb["all"], + }; + + const stats = collectAdeDatabaseUsageStats(cappedDb, { + since: null, + until: "2026-07-09T00:00:00.000Z", + }, logger); + + expect(checkedClientQuery).toBe(true); + expect(checkedOperationQuery).toBe(true); + expect(checkedAiQuery).toBe(true); + expect(stats?.daily).toContainEqual(expect.objectContaining({ + date: "2026-07-08", + inputTokens: 250_000, + totalTokens: 250_000, + })); + expect(stats?.daily.some((point) => point.date === "2026-07-07")).toBe(false); + expect(logger.debug).toHaveBeenCalledTimes(1); + expect(logger.debug).toHaveBeenCalledWith("usage.daily_bucket_scan_capped", { + maxRows: 250_000, + sources: ["ai_usage_log"], + }); + }); + it("combines successful ADE operations, sessions, tokens, and client activity without double-counting", async () => { const db = await createStatsDb(); db.run( diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.ts b/apps/desktop/src/main/services/usage/usageTrackingService.ts index d970231b2..72e6421e6 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.ts @@ -14,23 +14,26 @@ import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; import type { AdeUsageDailyPoint, + AdeUsageEstimationKind, AdeUsageModelSummary, AdeUsageProviderSummary, AdeUsageRangePreset, + AdeUsageScope, AdeUsageStats, GetAdeUsageStatsArgs, UsageProvider, UsageWindow, UsagePacing, + UsageProviderErrorKind, + UsageProviderSource, UsageProviderStatus, UsageProviderStatusMap, CostSnapshot, CostTokenBreakdown, ExtraUsage, - UsageProviderMessage, UsageSnapshot, } from "../../../shared/types"; -import { ADE_USAGE_RANGE_PRESETS, isAdeUsageRangePreset } from "../../../shared/types"; +import { ADE_USAGE_RANGE_PRESETS, isAdeUsageRangePreset, isAdeUsageScope } from "../../../shared/types"; import { isRecord, nowIso, getErrorMessage, safeJsonParse } from "../shared/utils"; import { decodeOpenCodeRegistryId, @@ -40,6 +43,7 @@ import { type ModelDescriptor, } from "../../../shared/modelRegistry"; import { + cacheClaudeCredentials, clearClaudeCredentialCache, isClaudeTokenExpiredOrExpiring, isCodexTokenStale, @@ -48,8 +52,10 @@ import { readCodexCredentials, refreshClaudeCredentials, } from "../ai/providerCredentialSources"; +import { resolveClaudeCodeExecutable } from "../ai/claudeCodeExecutable"; import { resolveCodexExecutable } from "../ai/codexExecutable"; import { resolveCliSpawnInvocation, terminateProcessTree } from "../shared/processExecution"; +import { stripAnsi } from "../../utils/ansiStrip"; import { ONE_HOUR_CACHE_WRITE_MULTIPLIER, refreshDynamicTokenPricing, @@ -75,27 +81,42 @@ import { scanGeminiLogs, scanOpenClawLogs, scanOpenCodeLogs, + sanitizeClaudeProjectPath, } from "./ledgers/localUsageLedgers"; import { collectAdeDatabaseUsageStats, type AdeDatabaseUsageStats, } from "./usageStatsStore"; +import type { + UsageProviderPollContext, + UsageProviderPollResult, + UsageProviderStrategy, + UsageRefreshReason, +} from "./usageProviderStrategies"; +import { localDayKey, localDayOffset, localDayStart } from "./localDay"; // ── Constants ──────────────────────────────────────────────────── const DEFAULT_POLL_INTERVAL_MS = 2 * 60_000; // 2 min const MIN_POLL_INTERVAL_MS = 60_000; // 1 min const MAX_POLL_INTERVAL_MS = 15 * 60_000; // 15 min +const ACTIVE_POLL_INTERVAL_MS = 60_000; +const IDLE_POLL_INTERVAL_MS = 5 * 60_000; +const IDLE_AFTER_MS = 15 * 60_000; +const QUOTA_DEMAND_LEASE_MS = 90_000; const COST_CACHE_TTL_MS = 10 * 60_000; // 10 min const CODEX_CLI_RPC_TIMEOUT_MS = 10_000; -const FORCE_REFRESH_RESPONSE_TIMEOUT_MS = 60_000; -const USAGE_SNAPSHOT_CACHE_VERSION = 2; +const CLAUDE_CLI_USAGE_TIMEOUT_MS = 16_000; +const QUOTA_REFRESH_RESPONSE_TIMEOUT_MS = 20_000; +const USAGE_SNAPSHOT_CACHE_VERSION = 3; const USAGE_SNAPSHOT_CACHE_TTL_MS = 24 * 60 * 60 * 1000; const USAGE_SNAPSHOT_CACHE_PATH = path.join(os.homedir(), ".ade", "cache", "usage-snapshot.json"); const GITHUB_STATS_CACHE_TTL_MS = 10 * 60_000; const GITHUB_STATS_COMMAND_TIMEOUT_MS = 60_000; const GITHUB_STATS_FAST_RESPONSE_TIMEOUT_MS = 2_500; const GITHUB_STATS_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +let usageSnapshotCacheWriteTail: Promise = Promise.resolve(); +let usageSnapshotCacheWriteSequence = 0; function isBenignStdinCloseError(error: unknown): boolean { if (!error || typeof error !== "object") return false; @@ -139,15 +160,23 @@ function readCachedUsageSnapshot(logger: Logger): UsageSnapshot | null { async function writeCachedUsageSnapshot(snapshot: UsageSnapshot, logger: Logger): Promise { if (!shouldUseSnapshotCache()) return; - try { - await fs.promises.mkdir(path.dirname(USAGE_SNAPSHOT_CACHE_PATH), { recursive: true }); - await fs.promises.writeFile( - USAGE_SNAPSHOT_CACHE_PATH, - JSON.stringify({ version: USAGE_SNAPSHOT_CACHE_VERSION, snapshot }), - ); - } catch (error) { - logger.debug("usage.snapshot_cache_write_failed", { error: getErrorMessage(error) }); - } + const sequence = ++usageSnapshotCacheWriteSequence; + const tempPath = `${USAGE_SNAPSHOT_CACHE_PATH}.${process.pid}.${sequence}.tmp`; + const write = usageSnapshotCacheWriteTail.then(async () => { + try { + await fs.promises.mkdir(path.dirname(USAGE_SNAPSHOT_CACHE_PATH), { recursive: true }); + await fs.promises.writeFile( + tempPath, + JSON.stringify({ version: USAGE_SNAPSHOT_CACHE_VERSION, snapshot }), + ); + await fs.promises.rename(tempPath, USAGE_SNAPSHOT_CACHE_PATH); + } catch (error) { + await fs.promises.rm(tempPath, { force: true }).catch(() => {}); + logger.debug("usage.snapshot_cache_write_failed", { error: getErrorMessage(error) }); + } + }); + usageSnapshotCacheWriteTail = write; + await write; } const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; @@ -160,7 +189,7 @@ async function fetchJson( headers: Record, timeoutMs = 15_000, init?: { method?: string; body?: string }, -): Promise<{ ok: boolean; status: number; data: unknown }> { +): Promise<{ ok: boolean; status: number; data: unknown; retryAfterMs?: number }> { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { @@ -176,7 +205,23 @@ async function fetchJson( } catch { data = null; } - return { ok: resp.ok, status: resp.status, data }; + const retryAfterHeader = resp.headers?.get?.("retry-after")?.trim(); + let retryAfterMs: number | undefined; + if (retryAfterHeader) { + const seconds = Number(retryAfterHeader); + if (Number.isFinite(seconds) && seconds >= 0) { + retryAfterMs = Math.round(seconds * 1000); + } else { + const retryAt = Date.parse(retryAfterHeader); + if (Number.isFinite(retryAt)) retryAfterMs = Math.max(0, retryAt - Date.now()); + } + } + return { + ok: resp.ok, + status: resp.status, + data, + ...(retryAfterMs != null ? { retryAfterMs } : {}), + }; } finally { clearTimeout(timer); } @@ -209,11 +254,11 @@ async function fetchJsonWithRetry( url: string, headers: Record, opts: RetryOptions = {}, -): Promise<{ ok: boolean; status: number; data: unknown }> { +): Promise<{ ok: boolean; status: number; data: unknown; retryAfterMs?: number }> { const attempts = Math.max(1, opts.attempts ?? 2); const timeoutMs = opts.perAttemptTimeoutMs ?? 8_000; const backoffMs = opts.backoffMs ?? 400; - let last: { ok: boolean; status: number; data: unknown } | null = null; + let last: { ok: boolean; status: number; data: unknown; retryAfterMs?: number } | null = null; for (let attempt = 0; attempt < attempts; attempt++) { if (attempt > 0 && backoffMs > 0) { await delay(backoffMs * 3 ** (attempt - 1)); @@ -236,6 +281,245 @@ function computeResetsInMs(resetsAt: string): number { return Math.max(0, new Date(resetsAt).getTime() - Date.now()); } +function errorKindForHttpStatus(status: number): UsageProviderErrorKind { + if (status === 401) return "auth"; + if (status === 403) return "forbidden"; + if (status === 409) return "conflict"; + if (status === 429) return "rate_limited"; + if (status === 0) return "network"; + return "unknown"; +} + +function errorKindForThrown(error: unknown): UsageProviderErrorKind { + const message = getErrorMessage(error).toLowerCase(); + if (message.includes("abort") || message.includes("timed out") || message.includes("timeout")) { + return "timeout"; + } + return "network"; +} + +async function measureUsagePhase( + logger: Logger, + args: { + provider?: UsageProvider; + phase: string; + reason: UsageRefreshReason; + }, + work: () => Promise, +): Promise { + const startedAt = Date.now(); + try { + const value = await work(); + logger.debug("usage.refresh.phase", { + ...args, + durationMs: Date.now() - startedAt, + outcome: "ok", + }); + return value; + } catch (error) { + logger.debug("usage.refresh.phase", { + ...args, + durationMs: Date.now() - startedAt, + outcome: "error", + errorKind: errorKindForThrown(error), + }); + throw error; + } +} + +function parseClaudeCliReset(raw: string | null, fallbackDurationMs: number, nowMs = Date.now()): string { + if (!raw) return new Date(nowMs + fallbackDurationMs).toISOString(); + const cleaned = raw + .replace(/^\s*resets?\s*(?:at\s*)?/i, "") + .replace(/\([^)]*\)/g, "") + .replace(/\bat\b/gi, " ") + .replace(/\s+/g, " ") + .trim(); + + const timeOnly = cleaned.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/i); + if (timeOnly) { + let hour = Number(timeOnly[1]); + const minute = Number(timeOnly[2] ?? 0); + const suffix = timeOnly[3]?.toLowerCase(); + if (suffix === "pm" && hour < 12) hour += 12; + if (suffix === "am" && hour === 12) hour = 0; + const next = new Date(nowMs); + next.setHours(hour, minute, 0, 0); + if (next.getTime() <= nowMs) next.setDate(next.getDate() + 1); + return next.toISOString(); + } + + const hasYear = /\b\d{4}\b/.test(cleaned); + const candidateText = hasYear ? cleaned : `${cleaned} ${new Date(nowMs).getFullYear()}`; + let parsed = Date.parse(candidateText); + if (Number.isFinite(parsed)) { + if (!hasYear && parsed < nowMs - 86_400_000) { + parsed = Date.parse(`${cleaned} ${new Date(nowMs).getFullYear() + 1}`); + } + if (Number.isFinite(parsed) && parsed > nowMs - 86_400_000) return new Date(parsed).toISOString(); + } + return new Date(nowMs + fallbackDurationMs).toISOString(); +} + +function parseClaudeCliWindow( + text: string, + label: RegExp, + windowType: UsageWindow["windowType"], + durationMs: number, +): UsageWindow | null { + const match = label.exec(text); + if (!match || match.index == null) return null; + const tail = text.slice(match.index, match.index + 1_400); + const boundary = tail.slice(1).search(/current\s+(?:session|week)/i); + const block = boundary >= 0 ? tail.slice(0, boundary + 1) : tail; + const percentMatch = block.match(/([0-9]{1,3}(?:\.[0-9]+)?)\s*%\s*(used|spent|consumed|left|remaining|available)?/i); + if (!percentMatch) return null; + const rawPercent = Math.max(0, Math.min(100, Number(percentMatch[1]))); + if (!Number.isFinite(rawPercent)) return null; + const qualifier = percentMatch[2]?.toLowerCase(); + const percentUsed = qualifier === "used" || qualifier === "spent" || qualifier === "consumed" + ? rawPercent + : 100 - rawPercent; + const resetMatch = block.match(/resets?\s*(?:at\s*)?([^\n\r]+)/i); + const resetsAt = parseClaudeCliReset(resetMatch?.[0] ?? null, durationMs); + return { + provider: "claude", + windowType, + percentUsed, + resetsAt, + resetsInMs: computeResetsInMs(resetsAt), + windowDurationMs: durationMs, + }; +} + +function parseClaudeCliUsage(text: string): UsageWindow[] { + const clean = stripAnsi(text); + const panelStart = clean.toLowerCase().lastIndexOf("settings:"); + const panel = panelStart >= 0 ? clean.slice(panelStart) : clean; + return [ + parseClaudeCliWindow(panel, /current\s+session/i, "five_hour", 5 * 60 * 60_000), + parseClaudeCliWindow(panel, /current\s+week\s*\(all\s+models\)/i, "weekly", 7 * 24 * 60 * 60_000), + ].filter((window): window is UsageWindow => window != null); +} + +async function captureClaudeCliUsage(logger: Logger): Promise { + const module = await import("node-pty"); + const nodePty = ((module as unknown as { default?: typeof module }).default ?? module); + const resolved = resolveClaudeCodeExecutable(); + const env = { ...process.env, TERM: "xterm-256color" }; + const invocation = resolveCliSpawnInvocation(resolved.path, [], env); + const cwd = path.join(os.homedir(), ".ade", "cache", "claude-usage-probe"); + await fs.promises.mkdir(cwd, { recursive: true }); + + return await new Promise((resolve, reject) => { + let output = ""; + let settled = false; + let settleTimer: ReturnType | null = null; + const answeredPrompts = new Set(); + const terminal = nodePty.spawn(invocation.command, invocation.args, { + name: "xterm-256color", + cols: 100, + rows: 40, + cwd, + env, + }); + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + clearInterval(enterTimer); + if (settleTimer) clearTimeout(settleTimer); + try { + terminal.kill(); + } catch { + // already exited + } + if (error) reject(error); + else resolve(output); + }; + const timeout = setTimeout(() => { + finish(new Error(`Claude CLI /usage timed out after ${CLAUDE_CLI_USAGE_TIMEOUT_MS}ms`)); + }, CLAUDE_CLI_USAGE_TIMEOUT_MS); + timeout.unref?.(); + const enterTimer = setInterval(() => { + try { + terminal.write("\r"); + } catch { + // process is exiting + } + }, 800); + enterTimer.unref?.(); + + terminal.onData((chunk) => { + if (output.length < 120_000) output += chunk.slice(0, 120_000 - output.length); + const clean = stripAnsi(output); + const lower = clean.toLowerCase(); + const promptResponses = [ + ["do you trust the files in this folder?", "y\r"], + ["quick safety check:", "\r"], + ["yes, i trust this folder", "\r"], + ["ready to code here?", "\r"], + ["press enter to continue", "\r"], + ] as const; + for (const [prompt, response] of promptResponses) { + if (lower.includes(prompt) && !answeredPrompts.has(prompt)) { + answeredPrompts.add(prompt); + terminal.write(response); + } + } + if (lower.includes("login") && (lower.includes("not signed in") || lower.includes("authentication required"))) { + finish(new Error("Claude CLI sign-in is required.")); + return; + } + if (parseClaudeCliUsage(clean).length > 0 && !settleTimer) { + settleTimer = setTimeout(() => finish(), 700); + } + }); + terminal.onExit(({ exitCode }) => { + if (parseClaudeCliUsage(output).length > 0) finish(); + else finish(new Error(`Claude CLI exited before returning usage (${exitCode}).`)); + }); + + setTimeout(() => { + if (settled) return; + try { + terminal.write("/usage\r"); + } catch (error) { + logger.debug("usage.refresh.phase", { + provider: "claude", + phase: "cli_write", + outcome: "error", + errorKind: errorKindForThrown(error), + }); + finish(error instanceof Error ? error : new Error(String(error))); + } + }, 500).unref?.(); + }); +} + +async function pollClaudeViaCli(logger: Logger): Promise { + try { + const output = await captureClaudeCliUsage(logger); + const windows = parseClaudeCliUsage(output); + if (windows.length === 0) { + return { + windows: [], + source: "cli", + errors: ["claude: CLI usage response contained no recognized windows"], + errorKind: "invalid_response", + }; + } + return { windows, source: "cli", errors: [] }; + } catch (error) { + return { + windows: [], + source: "cli", + errors: [`claude: CLI fallback failed: ${getErrorMessage(error)}`], + errorKind: errorKindForThrown(error) === "timeout" ? "timeout" : "unavailable", + }; + } +} + // ── Claude Usage Polling ───────────────────────────────────────── interface ClaudeUsageResponse { @@ -443,197 +727,219 @@ function codexWindowDurationMs(value: unknown): number | null { return Math.round(value * 60_000); } -function parseCodexDailyUsage7d(data: Record, nowMs = Date.now()): number[] | null { - const bucketsValue = Array.isArray(data.dailyUsageBuckets) - ? data.dailyUsageBuckets - : Array.isArray(data.daily_usage_buckets) - ? data.daily_usage_buckets - : null; - if (!bucketsValue?.length) return null; - const buckets = new Array(7).fill(0); - const todayStart = new Date(nowMs); - todayStart.setHours(0, 0, 0, 0); - const oldestStart = todayStart.getTime() - 6 * 86_400_000; - for (const rawBucket of bucketsValue) { - const bucket = isRecord(rawBucket) ? rawBucket : null; - if (!bucket) continue; - const dateText = typeof bucket.startDate === "string" - ? bucket.startDate - : typeof bucket.start_date === "string" - ? bucket.start_date - : ""; - const tokens = typeof bucket.tokens === "number" - ? bucket.tokens - : typeof bucket.tokens === "string" - ? Number(bucket.tokens) - : 0; - if (!dateText || !Number.isFinite(tokens) || tokens <= 0) continue; - const date = new Date(dateText); - if (!Number.isFinite(date.getTime())) continue; - date.setHours(0, 0, 0, 0); - const offset = Math.floor((date.getTime() - oldestStart) / 86_400_000); - if (offset < 0 || offset > 6) continue; - buckets[offset] += tokens; - } - return buckets.some((value) => value > 0) ? buckets : null; -} - -function parseCodexWorkspaceMessages(data: Record): UsageProviderMessage[] { - const messages = Array.isArray(data.messages) ? data.messages : []; - return messages.flatMap((rawMessage) => { - const message = isRecord(rawMessage) ? rawMessage : null; - if (!message) return []; - const body = typeof message.messageBody === "string" - ? message.messageBody.trim() - : typeof message.message_body === "string" - ? message.message_body.trim() - : ""; - if (!body) return []; - const archivedAt = message.archivedAt ?? message.archived_at; - if (archivedAt != null) return []; - const kindRaw = typeof message.messageType === "string" - ? message.messageType - : typeof message.message_type === "string" - ? message.message_type - : "unknown"; - const kind: UsageProviderMessage["kind"] = - kindRaw === "headline" || kindRaw === "announcement" ? kindRaw : "unknown"; - const createdAtValue = typeof message.createdAt === "number" - ? message.createdAt - : typeof message.created_at === "number" - ? message.created_at - : null; - return [{ - provider: "codex" as const, - id: String(message.messageId ?? message.message_id ?? body), - kind, - message: body, - ...(createdAtValue != null ? { createdAt: new Date(createdAtValue * 1000).toISOString() } : {}), - }]; - }); -} - // Cursor usage polling was removed in 2026-05 — Cursor only exposes // team-admin endpoints (/teams/spend, /teams/filtered-usage-events, // /teams/daily-usage-data) with no personal-user surface, so the per-user // drawer state could never be meaningful for the typical ADE user. -async function pollClaudeUsage(logger: Logger): Promise<{ windows: UsageWindow[]; extraUsage: ExtraUsage | null; errors: string[] }> { - const windows: UsageWindow[] = []; - const errors: string[] = []; - - const creds = await readClaudeCredentialsWithRefresh(logger); +async function pollClaudeUsage( + logger: Logger, + context: UsageProviderPollContext = { reason: "user" }, +): Promise { + const allowInteractiveSources = context.reason === "user"; + const creds = await measureUsagePhase( + logger, + { provider: "claude", phase: "credentials", reason: context.reason }, + () => readClaudeCredentialsWithRefresh(logger, { allowKeychain: allowInteractiveSources }), + ); if (!creds) { - errors.push("claude: no credentials found"); - return { windows, extraUsage: null, errors }; + if (allowInteractiveSources) { + return await measureUsagePhase( + logger, + { provider: "claude", phase: "cli_fallback", reason: context.reason }, + () => pollClaudeViaCli(logger), + ); + } + return { + windows: [], + source: "oauth", + extraUsage: null, + errors: ["claude: no non-interactive credentials found"], + errorKind: "auth", + }; } try { - const result = await fetchJsonWithRetry(CLAUDE_USAGE_URL, { - Authorization: `Bearer ${creds.accessToken}`, - "anthropic-beta": "oauth-2025-04-20", - }); + const result = await measureUsagePhase( + logger, + { provider: "claude", phase: "oauth_http", reason: context.reason }, + () => fetchJsonWithRetry(CLAUDE_USAGE_URL, { + Authorization: `Bearer ${creds.accessToken}`, + "anthropic-beta": "oauth-2025-04-20", + }), + ); if (!result.ok) { - // On 401, try one refresh cycle and retry if (result.status === 401 && creds.refreshToken) { logger.info("usage.token_refresh.401_retry"); clearClaudeCredentialCache(); - const refreshed = await refreshClaudeCredentials(creds.refreshToken); + const refreshed = await measureUsagePhase( + logger, + { provider: "claude", phase: "token_refresh", reason: context.reason }, + () => refreshClaudeCredentials(creds.refreshToken!), + ); if (refreshed) { - const retry = await fetchJsonWithRetry(CLAUDE_USAGE_URL, { - Authorization: `Bearer ${refreshed.accessToken}`, - "anthropic-beta": "oauth-2025-04-20", - }); + cacheClaudeCredentials(refreshed); + const retry = await measureUsagePhase( + logger, + { provider: "claude", phase: "oauth_http_retry", reason: context.reason }, + () => fetchJsonWithRetry(CLAUDE_USAGE_URL, { + Authorization: `Bearer ${refreshed.accessToken}`, + "anthropic-beta": "oauth-2025-04-20", + }), + ); if (retry.ok) { const parsed = parseClaudeWindows(retry.data as ClaudeUsageResponse); - return { windows: parsed.windows, extraUsage: parsed.extraUsage, errors }; + if (parsed.windows.length > 0) { + return { windows: parsed.windows, source: "oauth", extraUsage: parsed.extraUsage, errors: [] }; + } } } } - errors.push(`claude: API returned ${result.status}`); - return { windows, extraUsage: null, errors }; + + if (allowInteractiveSources) { + const fallback = await measureUsagePhase( + logger, + { provider: "claude", phase: "cli_fallback", reason: context.reason }, + () => pollClaudeViaCli(logger), + ); + if (fallback.windows.length > 0) return fallback; + } + return { + windows: [], + source: "oauth", + extraUsage: null, + errors: [`claude: API returned ${result.status}`], + errorKind: errorKindForHttpStatus(result.status), + ...(result.retryAfterMs != null ? { retryAfterMs: result.retryAfterMs } : {}), + }; } const parsed = parseClaudeWindows(result.data as ClaudeUsageResponse); - windows.push(...parsed.windows); if (parsed.windows.length === 0) { - errors.push("claude: usage response contained no recognized windows"); logger.warn("usage.poll.claude_unrecognized_shape", { keys: isRecord(result.data) ? Object.keys(result.data).slice(0, 12) : [], }); + if (allowInteractiveSources) { + const fallback = await measureUsagePhase( + logger, + { provider: "claude", phase: "cli_fallback", reason: context.reason }, + () => pollClaudeViaCli(logger), + ); + if (fallback.windows.length > 0) return fallback; + } + return { + windows: [], + source: "oauth", + extraUsage: null, + errors: ["claude: usage response contained no recognized windows"], + errorKind: "invalid_response", + }; } - return { windows, extraUsage: parsed.extraUsage, errors }; - } catch (err) { - errors.push(`claude: ${getErrorMessage(err)}`); + return { windows: parsed.windows, source: "oauth", extraUsage: parsed.extraUsage, errors: [] }; + } catch (error) { + if (allowInteractiveSources) { + const fallback = await measureUsagePhase( + logger, + { provider: "claude", phase: "cli_fallback", reason: context.reason }, + () => pollClaudeViaCli(logger), + ); + if (fallback.windows.length > 0) return fallback; + } + return { + windows: [], + source: "oauth", + extraUsage: null, + errors: [`claude: ${getErrorMessage(error)}`], + errorKind: errorKindForThrown(error), + }; } - - return { windows, extraUsage: null, errors }; } // ── Codex Usage Polling ────────────────────────────────────────── -async function pollCodexUsage(logger: Logger): Promise<{ windows: UsageWindow[]; errors: string[]; dailyUsage7d?: number[]; providerMessages?: UsageProviderMessage[] }> { - const windows: UsageWindow[] = []; - const errors: string[] = []; - - const creds = await readCodexCredentials(); +async function pollCodexUsage( + logger: Logger, + context: UsageProviderPollContext = { reason: "user" }, +): Promise { + const creds = await measureUsagePhase( + logger, + { provider: "codex", phase: "credentials", reason: context.reason }, + () => readCodexCredentials(), + ); if (!creds) { - errors.push("codex: no credentials found"); - return { windows, errors }; + return { + windows: [], + source: "http", + errors: ["codex: no credentials found"], + errorKind: "auth", + }; } - // Try HTTP API first — skip the stale-token gate; the API will 401 if - // the token is truly dead, and tokens often remain valid well past the - // local last_refresh timestamp. try { - const result = await fetchJsonWithRetry(CODEX_USAGE_URL, { - Authorization: `Bearer ${creds.accessToken}`, - }); + const result = await measureUsagePhase( + logger, + { provider: "codex", phase: "quota_http", reason: context.reason }, + () => fetchJsonWithRetry(CODEX_USAGE_URL, { + Authorization: `Bearer ${creds.accessToken}`, + }), + ); - if (!result.ok) { - errors.push(`codex: API returned ${result.status}`); - if (result.status >= 400 && result.status < 500 && result.status !== 401) { - return { windows, errors }; + if (result.ok && isRecord(result.data)) { + const windows = parseCodexRateLimitWindows(result.data); + if (windows.length > 0) { + return { windows, source: "http", errors: [] }; } - } else if (result.data && typeof result.data === "object") { - windows.push(...parseCodexRateLimitWindows(result.data as Record)); + const fallback = await measureUsagePhase( + logger, + { provider: "codex", phase: "cli_rpc_fallback", reason: context.reason }, + () => pollCodexViaCliRpc(logger), + ); + return fallback.windows.length > 0 + ? { ...fallback, source: "cli", errors: [] } + : { + ...fallback, + source: "cli", + errorKind: fallback.errorKind ?? "invalid_response", + }; } - } catch { - // Fall through to CLI RPC - } - // Codex CLI JSON-RPC supplies native app-server usage/messages. It also - // remains the fallback source for rate-limit windows when the legacy HTTP - // endpoint is unavailable. - try { - const rpcResult = await pollCodexViaCliRpc(logger); - if (windows.length === 0) { - windows.push(...rpcResult.windows); + if (!result.ok && (result.status === 401 || RETRYABLE_STATUS.has(result.status))) { + const fallback = await measureUsagePhase( + logger, + { provider: "codex", phase: "cli_rpc_fallback", reason: context.reason }, + () => pollCodexViaCliRpc(logger), + ); + if (fallback.windows.length > 0) return { ...fallback, source: "cli", errors: [] }; + return { + ...fallback, + source: "cli", + errors: [`codex: API returned ${result.status}`, ...fallback.errors], + errorKind: fallback.errorKind ?? errorKindForHttpStatus(result.status), + ...(result.retryAfterMs != null ? { retryAfterMs: result.retryAfterMs } : {}), + }; } - const dailyUsage7d = rpcResult.dailyUsage7d; - const providerMessages = rpcResult.providerMessages; - if (rpcResult.errors.length > 0) errors.push(...rpcResult.errors); - if (windows.length === 0 && errors.length === 0) { - errors.push("codex: usage response contained no recognized windows"); - } - return { windows, errors, ...(dailyUsage7d ? { dailyUsage7d } : {}), ...(providerMessages?.length ? { providerMessages } : {}) }; - } catch (err) { - errors.push(`codex: CLI RPC failed: ${getErrorMessage(err)}`); - } - if (windows.length === 0 && errors.length === 0) { - errors.push("codex: usage response contained no recognized windows"); + return { + windows: [], + source: "http", + errors: [`codex: API returned ${result.status}`], + errorKind: errorKindForHttpStatus(result.status), + ...(result.retryAfterMs != null ? { retryAfterMs: result.retryAfterMs } : {}), + }; + } catch (error) { + return { + windows: [], + source: "http", + errors: [`codex: ${getErrorMessage(error)}`], + errorKind: errorKindForThrown(error), + }; } - - return { windows, errors }; } -async function pollCodexViaCliRpc(logger: Logger): Promise<{ windows: UsageWindow[]; errors: string[]; dailyUsage7d?: number[]; providerMessages?: UsageProviderMessage[] }> { +async function pollCodexViaCliRpc(logger: Logger): Promise { const windows: UsageWindow[] = []; const errors: string[] = []; - let dailyUsage7d: number[] | undefined; - let providerMessages: UsageProviderMessage[] | undefined; try { const initPayload = JSON.stringify({ @@ -664,21 +970,7 @@ async function pollCodexViaCliRpc(logger: Logger): Promise<{ windows: UsageWindo params: {}, }); - const usagePayload = JSON.stringify({ - jsonrpc: "2.0", - id: 2, - method: "account/usage/read", - params: {}, - }); - - const workspaceMessagesPayload = JSON.stringify({ - jsonrpc: "2.0", - id: 3, - method: "account/workspaceMessages/read", - params: {}, - }); - - const combined = `${initPayload}\n${initializedPayload}\n${rateLimitsPayload}\n${usagePayload}\n${workspaceMessagesPayload}\n`; + const combined = `${initPayload}\n${initializedPayload}\n${rateLimitsPayload}\n`; const codexPath = resolveCodexExecutable().path; const env = { ...process.env }; @@ -787,38 +1079,43 @@ async function pollCodexViaCliRpc(logger: Logger): Promise<{ windows: UsageWindo if (parsedWindows.length > 0) { windows.push(...parsedWindows); } - } else if (id === 2) { - const parsedDailyUsage = parseCodexDailyUsage7d(res); - if (parsedDailyUsage) { - dailyUsage7d = parsedDailyUsage; - } - } else if (id === 3) { - const parsedMessages = parseCodexWorkspaceMessages(res); - if (parsedMessages.length > 0) { - providerMessages = parsedMessages; - } } } } catch (err) { errors.push(`codex: CLI RPC error: ${getErrorMessage(err)}`); + return { + windows, + source: "cli", + errors, + errorKind: errorKindForThrown(err) === "timeout" ? "timeout" : "unavailable", + }; } - return { windows, errors, ...(dailyUsage7d ? { dailyUsage7d } : {}), ...(providerMessages ? { providerMessages } : {}) }; + if (windows.length === 0 && errors.length === 0) { + errors.push("codex: CLI RPC returned no recognized rate limits"); + } + return { + windows, + source: "cli", + errors, + ...(windows.length === 0 ? { errorKind: "invalid_response" as const } : {}), + }; } // ── Local Cost Scanning ────────────────────────────────────────── function bucketDaily7d(entries: TokenEntry[], nowMs: number): number[] { const buckets = new Array(7).fill(0); - const todayStart = new Date(nowMs); - todayStart.setHours(0, 0, 0, 0); - const todayStartMs = todayStart.getTime(); - const oldestStart = todayStartMs - 6 * 86_400_000; + const today = new Date(nowMs); + const bucketByDay = new Map(); + for (let index = 0; index < 7; index += 1) { + const day = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 6 + index); + bucketByDay.set(localDayKey(day), index); + } for (const entry of entries) { - if (entry.timestamp < oldestStart) continue; if (entry.timestamp > nowMs) continue; - const dayIndex = Math.floor((entry.timestamp - oldestStart) / 86_400_000); - const bucketIndex = Math.min(6, Math.max(0, dayIndex)); + const bucketIndex = bucketByDay.get(localDayKey(entry.timestamp)); + if (bucketIndex == null) continue; buckets[bucketIndex] += entry.inputTokens + entry.outputTokens + entry.cachedTokens + toNonNegativeInt(entry.cacheWriteTokens); } return buckets; @@ -841,13 +1138,15 @@ function addTokenBreakdownEntry(breakdown: TokenBreakdown, entry: TokenEntry): v } function addDailyTokenEntry(breakdown: DailyTokenBreakdown, entry: TokenEntry): void { - const date = new Date(entry.timestamp).toISOString().slice(0, 10); + const date = localDayKey(entry.timestamp); + if (!date) return; const tokens = entry.inputTokens + entry.outputTokens + entry.cachedTokens + toNonNegativeInt(entry.cacheWriteTokens); breakdown[date] = (breakdown[date] ?? 0) + tokens; } function addDailyModelTokenEntry(breakdown: DailyModelTokenBreakdown, entry: TokenEntry): void { - const date = new Date(entry.timestamp).toISOString().slice(0, 10); + const date = localDayKey(entry.timestamp); + if (!date) return; if (!breakdown[date]) breakdown[date] = {}; addTokenBreakdownEntry(breakdown[date]!, entry); } @@ -876,6 +1175,10 @@ function calculateTokenEntryCost(entry: TokenEntry): number { function aggregateCosts( entries: TokenEntry[], provider: string, + options: { + estimation?: AdeUsageEstimationKind; + scopeSupported?: boolean; + } = {}, ): CostSnapshot { const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); @@ -902,11 +1205,13 @@ function aggregateCosts( tokenBreakdown: {} as TokenBreakdown, dailyTokens: {} as DailyTokenBreakdown, dailyModelTokens: {} as DailyModelTokenBreakdown, + adeOriginatedTokens: 0, }])) as Record; for (const entry of entries) { @@ -919,11 +1224,23 @@ function aggregateCosts( addTokenBreakdownEntry(accumulator.tokenBreakdown, entry); addDailyTokenEntry(accumulator.dailyTokens, entry); addDailyModelTokenEntry(accumulator.dailyModelTokens, entry); + if (entry.adeOriginated || entry.originator?.trim().toLowerCase().startsWith("ade")) { + accumulator.adeOriginatedTokens += entry.inputTokens + + entry.outputTokens + + entry.cachedTokens + + toNonNegativeInt(entry.cacheWriteTokens); + } } } const roundedCost = (preset: AdeUsageRangePreset) => Math.round(accumulators[preset].costUsd * 100) / 100; + const entryEstimations = new Set( + entries.flatMap((entry) => entry.estimation ? [entry.estimation] : []), + ); + const estimation = options.estimation + ?? (entryEstimations.size === 1 ? [...entryEstimations][0] : entryEstimations.size > 1 ? "mixed" : undefined); + return { provider, last30dCostUsd: roundedCost("30d"), @@ -933,9 +1250,84 @@ function aggregateCosts( tokenBreakdownByPreset: Object.fromEntries(ADE_USAGE_RANGE_PRESETS.map((preset) => [preset, accumulators[preset].tokenBreakdown])), dailyTokenBreakdownByPreset: Object.fromEntries(ADE_USAGE_RANGE_PRESETS.map((preset) => [preset, accumulators[preset].dailyModelTokens])), dailyTokensByPreset: Object.fromEntries(ADE_USAGE_RANGE_PRESETS.map((preset) => [preset, accumulators[preset].dailyTokens])), + ...(estimation ? { estimation } : {}), + ...(options.scopeSupported != null ? { scopeSupported: options.scopeSupported } : {}), + adeOriginatedTokensByPreset: Object.fromEntries( + ADE_USAGE_RANGE_PRESETS.map((preset) => [preset, accumulators[preset].adeOriginatedTokens]), + ), }; } +const PROVIDER_SCOPE_SUPPORT: Readonly> = { + claude: true, + codex: true, + cursor: false, + "cursor-agent": false, + openclaw: false, + opencode: false, + droid: false, + copilot: false, + gemini: false, +}; + +const PROVIDER_ESTIMATION: Readonly>> = { + cursor: "mixed", + "cursor-agent": "chars", + droid: "distribution", +}; + +type ProviderTokenEntries = Map; + +function canonicalProjectRoot(projectRoot: string): string { + const resolved = path.resolve(projectRoot); + const normalized = resolved.replace(/\\/g, "/"); + const worktreeMarker = "/.ade/worktrees/"; + const markerIndex = normalized.indexOf(worktreeMarker); + return markerIndex >= 0 ? path.resolve(normalized.slice(0, markerIndex)) : resolved; +} + +function tokenEntryMatchesProject(entry: TokenEntry, projectRoot: string | null | undefined): boolean { + if (!projectRoot) return false; + const root = canonicalProjectRoot(projectRoot); + if (entry.projectPath) { + const candidate = path.resolve(entry.projectPath); + if (candidate === root || candidate.startsWith(`${root}${path.sep}`)) return true; + } + if (entry.projectKey) { + const rootKey = sanitizeClaudeProjectPath(root); + if (entry.projectKey === rootKey || entry.projectKey.startsWith(`${rootKey}--ade-worktrees-`)) return true; + } + return false; +} + +function buildCostSnapshots( + entriesByProvider: ProviderTokenEntries, + scope: AdeUsageScope, + projectRoot: string | null | undefined, +): CostSnapshot[] { + const costs: CostSnapshot[] = []; + for (const [provider, machineEntries] of entriesByProvider) { + const scopeSupported = PROVIDER_SCOPE_SUPPORT[provider] === true; + const entries = scope === "project" + ? scopeSupported ? machineEntries.filter((entry) => tokenEntryMatchesProject(entry, projectRoot)) : [] + : machineEntries; + if (entries.length === 0) { + if (scope === "project" && !scopeSupported && machineEntries.length > 0) { + costs.push(aggregateCosts([], provider, { + estimation: PROVIDER_ESTIMATION[provider], + scopeSupported, + })); + } + continue; + } + costs.push(aggregateCosts(entries, provider, { + estimation: PROVIDER_ESTIMATION[provider], + scopeSupported, + })); + } + return costs; +} + // ── Stats Aggregation ──────────────────────────────────────────── type ResolvedAdeUsageRange = { @@ -954,22 +1346,21 @@ function toNonNegativeInt(value: unknown): number { } function startOfLocalDayIso(nowMs: number): string { - const date = new Date(nowMs); - date.setHours(0, 0, 0, 0); - return date.toISOString(); + return localDayOffset(nowMs, 0)?.toISOString() ?? new Date(nowMs).toISOString(); } function startOfLocalDayOffsetIso(nowMs: number, daysBack: number): string { - const date = new Date(nowMs); - date.setHours(0, 0, 0, 0); - date.setDate(date.getDate() - Math.max(0, daysBack)); - return date.toISOString(); + return localDayOffset(nowMs, -Math.max(0, daysBack))?.toISOString() ?? new Date(nowMs).toISOString(); } function normalizePreset(value: unknown): AdeUsageRangePreset { return isAdeUsageRangePreset(value) ? value : "7d"; } +function normalizeScope(value: unknown): AdeUsageScope { + return isAdeUsageScope(value) ? value : "machine"; +} + function validIsoOrNull(value: string | null | undefined): string | null { if (!value) return null; const timestamp = Date.parse(value); @@ -1153,6 +1544,28 @@ function addCostSnapshotsProviderUsage( : cost.costUsdByPreset?.[range.preset] ?? (range.preset === "today" ? cost.todayCostUsd : cost.last30dCostUsd), )); + let providerSummary = aggregation.providers.get(cost.provider); + if (!providerSummary) { + providerSummary = { + provider: cost.provider, + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + rangeCostUsd: 0, + todayCostUsd: 0, + last30dCostUsd: 0, + }; + aggregation.providers.set(cost.provider, providerSummary); + } + if (cost.estimation) providerSummary.estimation = cost.estimation; + if (cost.scopeSupported != null) providerSummary.scopeSupported = cost.scopeSupported; + const adeOriginatedTokens = Math.min( + providerTotalTokens, + toNonNegativeInt(cost.adeOriginatedTokensByPreset?.[range.preset]), + ); + providerSummary.adeOriginatedTokens = toNonNegativeInt(providerSummary.adeOriginatedTokens) + adeOriginatedTokens; + providerSummary.externalTokens = toNonNegativeInt(providerSummary.externalTokens) + providerTotalTokens - adeOriginatedTokens; for (const [model, tokens] of Object.entries(tokenBreakdown)) { const modelInput = toNonNegativeInt(tokens.input); const modelOutput = toNonNegativeInt(tokens.output); @@ -1196,13 +1609,43 @@ function sumTokenBreakdownCost(breakdown: Record): n } function dateIntersectsRange(date: string, range: ResolvedAdeUsageRange): boolean { - const dayStart = Date.parse(`${date}T00:00:00.000Z`); - if (!Number.isFinite(dayStart)) return false; - const dayEnd = dayStart + 86_400_000 - 1; + const start = localDayStart(date); + if (!start) return false; + const dayStart = start.getTime(); + const dayEnd = new Date(start.getFullYear(), start.getMonth(), start.getDate() + 1).getTime() - 1; if (range.since && dayEnd < Date.parse(range.since)) return false; return dayStart <= Date.parse(range.until); } +function emptyDailyPoint(date: string): AdeUsageDailyPoint { + return { + date, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + cachedTokens: 0, + commits: 0, + prs: 0, + insertions: 0, + deletions: 0, + filesChanged: 0, + sessions: 0, + githubCommits: 0, + githubPrs: 0, + githubAdditions: 0, + githubDeletions: 0, + }; +} + +function ensureDailyPoint(points: AdeUsageDailyPoint[], byDate: Map, date: string): AdeUsageDailyPoint { + const existing = byDate.get(date); + if (existing) return existing; + const point = emptyDailyPoint(date); + points.push(point); + byDate.set(date, point); + return point; +} + function makeDailySkeleton(range: ResolvedAdeUsageRange, nowMs: number): AdeUsageDailyPoint[] { const until = new Date(range.until); const untilMs = Number.isFinite(until.getTime()) ? until.getTime() : nowMs; @@ -1211,28 +1654,20 @@ function makeDailySkeleton(range: ResolvedAdeUsageRange, nowMs: number): AdeUsag range.preset === "7d" ? 7 : range.preset === "year" || range.preset === "all" ? 365 : 30; - const startMs = range.since - ? Math.max(Date.parse(range.since), untilMs - (maxDays - 1) * 86_400_000) - : untilMs - (maxDays - 1) * 86_400_000; - const start = new Date(startMs); - start.setHours(0, 0, 0, 0); + const untilDay = localDayOffset(untilMs, 0) ?? localDayOffset(nowMs, 0)!; + const windowStart = new Date( + untilDay.getFullYear(), + untilDay.getMonth(), + untilDay.getDate() - (maxDays - 1), + ); + const sinceDay = range.since ? localDayOffset(range.since, 0) : null; + const start = sinceDay && sinceDay.getTime() > windowStart.getTime() ? sinceDay : windowStart; const points: AdeUsageDailyPoint[] = []; for (let index = 0; index < maxDays; index += 1) { - const date = new Date(start.getTime() + index * 86_400_000); - if (date.getTime() > untilMs + 86_400_000) break; - points.push({ - date: date.toISOString().slice(0, 10), - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - commits: 0, - prs: 0, - insertions: 0, - deletions: 0, - filesChanged: 0, - sessions: 0, - }); + const date = new Date(start.getFullYear(), start.getMonth(), start.getDate() + index); + if (date.getTime() > untilDay.getTime()) break; + points.push(emptyDailyPoint(localDayKey(date))); } return points; } @@ -1245,18 +1680,37 @@ function mergeSnapshotDailyTokens( ): void { const byDate = new Map(points.map((point) => [point.date, point])); for (const cost of costs) { - const dailyTokens = exactRange + const dailyBreakdown = exactRange + ? cost.dailyTokenBreakdownByPreset?.all + : cost.dailyTokenBreakdownByPreset?.[range.preset]; + if (dailyBreakdown) { + for (const [date, models] of Object.entries(dailyBreakdown)) { + if (exactRange && !dateIntersectsRange(date, range)) continue; + const point = ensureDailyPoint(points, byDate, date); + for (const tokens of Object.values(models)) { + const input = toNonNegativeInt(tokens.input); + const output = toNonNegativeInt(tokens.output); + const cached = toNonNegativeInt(tokens.cached) + toNonNegativeInt(tokens.cacheWrite); + point.inputTokens += input; + point.outputTokens += output; + point.cachedTokens = toNonNegativeInt(point.cachedTokens) + cached; + point.totalTokens += input + output + cached; + } + } + continue; + } + + const legacyDailyTokens = exactRange ? cost.dailyTokensByPreset?.all ?? {} : cost.dailyTokensByPreset?.[range.preset] ?? {}; - for (const [date, value] of Object.entries(dailyTokens)) { + for (const [date, value] of Object.entries(legacyDailyTokens)) { if (exactRange && !dateIntersectsRange(date, range)) continue; - const point = byDate.get(date); - if (!point) continue; + const point = ensureDailyPoint(points, byDate, date); const tokens = toNonNegativeInt(value); - point.inputTokens += tokens; point.totalTokens += tokens; } } + points.sort((a, b) => a.date.localeCompare(b.date)); } function summarizeObservedProviderUsage(providers: AdeUsageProviderSummary[]): { @@ -1311,6 +1765,7 @@ type GitHubPullRequestRow = { number?: number; state?: string | null; createdAt?: string | null; + updatedAt?: string | null; closedAt?: string | null; mergedAt?: string | null; additions?: number | null; @@ -1410,16 +1865,6 @@ function parseGithubViewerLogin(raw: string): string | null { return parsed.login.trim(); } -function parseGithubPullRequestRows(raw: string, viewer: string): GitHubPullRequestRow[] { - return raw - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => safeJsonParse(line, null)) - .filter(isRecord) - .filter((row) => isRecord(row.author) && row.author.login === viewer) as GitHubPullRequestRow[]; -} - function parseGithubCommitDates(raw: string): string[] { return raw .split(/\r?\n/) @@ -1454,10 +1899,10 @@ function githubPullRequestGraphqlQuery(): string { return [ "query($owner: String!, $name: String!, $endCursor: String) {", " repository(owner: $owner, name: $name) {", - " pullRequests(first: 100, after: $endCursor, orderBy: { field: CREATED_AT, direction: DESC }) {", + " pullRequests(first: 100, after: $endCursor, orderBy: { field: UPDATED_AT, direction: DESC }) {", " pageInfo { hasNextPage endCursor }", " nodes {", - " number state createdAt closedAt mergedAt additions deletions changedFiles", + " number state createdAt updatedAt closedAt mergedAt additions deletions changedFiles", " author { login }", " }", " }", @@ -1470,7 +1915,64 @@ function dateKeyFromIso(value: string | null | undefined): string | null { if (!value) return null; const timestamp = Date.parse(value); if (!Number.isFinite(timestamp)) return null; - return new Date(timestamp).toISOString().slice(0, 10); + return localDayKey(timestamp) || null; +} + +type GithubCommandRunner = typeof runBufferedCommand; + +async function scanGithubPullRequestPages({ + projectRoot, + repoParts, + viewer, + range, + runCommand = runBufferedCommand, +}: { + projectRoot: string; + repoParts: { owner: string; name: string }; + viewer: string; + range: ResolvedAdeUsageRange; + runCommand?: GithubCommandRunner; +}): Promise { + const rows: GitHubPullRequestRow[] = []; + let endCursor: string | null = null; + do { + const raw = await runCommand( + "gh", + [ + "api", + "graphql", + "-F", + `owner=${repoParts.owner}`, + "-F", + `name=${repoParts.name}`, + ...(endCursor ? ["-F", `endCursor=${endCursor}`] : []), + "-f", + `query=${githubPullRequestGraphqlQuery()}`, + "--jq", + ".data.repository.pullRequests", + ], + { cwd: projectRoot }, + ); + const page = safeJsonParse(raw.trim(), null); + if (!isRecord(page)) break; + const nodes = Array.isArray(page.nodes) + ? page.nodes.filter(isRecord) as GitHubPullRequestRow[] + : []; + rows.push(...nodes.filter((row) => isRecord(row.author) && row.author.login === viewer)); + + const oldestUpdatedAt = nodes + .map((row) => typeof row.updatedAt === "string" ? Date.parse(row.updatedAt) : Number.NaN) + .filter(Number.isFinite) + .sort((a, b) => a - b)[0]; + if (range.since && oldestUpdatedAt != null && oldestUpdatedAt < Date.parse(range.since)) break; + + const pageInfo = isRecord(page.pageInfo) ? page.pageInfo : null; + const hasNextPage = pageInfo?.hasNextPage === true; + endCursor = hasNextPage && typeof pageInfo?.endCursor === "string" && pageInfo.endCursor + ? pageInfo.endCursor + : null; + } while (endCursor); + return rows; } function addGithubDaily( @@ -1505,58 +2007,41 @@ async function scanGithubActivityStats(projectRoot: string | null | undefined, r const repo = parseGithubRepoJson(repoRaw); if (!repo) return makeEmptyGithubStats("Unable to resolve the GitHub repository.", null); - const viewerRaw = await runBufferedCommand("gh", ["api", "user", "--cache", "10m"], { - cwd: projectRoot, - timeoutMs: 10_000, - }); - const viewer = parseGithubViewerLogin(viewerRaw); - if (!viewer) return makeEmptyGithubStats("Unable to resolve the GitHub user.", repo); - const repoParts = githubRepoParts(repo); - if (!repoParts) return makeEmptyGithubStats("Unable to resolve the GitHub repository.", repo); - - const [prRaw, commitRaw] = await Promise.all([ - runBufferedCommand( - "gh", - [ - "api", - "graphql", - "--paginate", - "-F", - `owner=${repoParts.owner}`, - "-F", - `name=${repoParts.name}`, - "-f", - `query=${githubPullRequestGraphqlQuery()}`, - "--jq", - ".data.repository.pullRequests.nodes[] | @json", - ], - { cwd: projectRoot }, - ), - runBufferedCommand( - "gh", - [ - "api", - `repos/${repo}/commits`, - "--method", - "GET", - "--cache", - "10m", - "-F", - `author=${viewer}`, - ...githubCommitDateArgs(range), - "--paginate", - "--jq", - ".[] | [.sha, .commit.author.date] | @tsv", - ], - { cwd: projectRoot }, - ), - ]); - - const dailyByDate = new Map(); - const prs = parseGithubPullRequestRows(prRaw, viewer); - const mergedPrs = prs.filter((pr) => timestampInRange(pr.mergedAt, range)); - const closedPrs = prs.filter((pr) => timestampInRange(pr.closedAt, range)); - const prsCreatedInRange = prs.filter((pr) => timestampInRange(pr.createdAt, range)); + const viewerRaw = await runBufferedCommand("gh", ["api", "user", "--cache", "10m"], { + cwd: projectRoot, + timeoutMs: 10_000, + }); + const viewer = parseGithubViewerLogin(viewerRaw); + if (!viewer) return makeEmptyGithubStats("Unable to resolve the GitHub user.", repo); + const repoParts = githubRepoParts(repo); + if (!repoParts) return makeEmptyGithubStats("Unable to resolve the GitHub repository.", repo); + + const [prs, commitRaw] = await Promise.all([ + scanGithubPullRequestPages({ projectRoot, repoParts, viewer, range }), + runBufferedCommand( + "gh", + [ + "api", + `repos/${repo}/commits`, + "--method", + "GET", + "--cache", + "10m", + "-F", + `author=${viewer}`, + ...githubCommitDateArgs(range), + "--paginate", + "--jq", + ".[] | [.sha, .commit.author.date] | @tsv", + ], + { cwd: projectRoot }, + ), + ]); + + const dailyByDate = new Map(); + const mergedPrs = prs.filter((pr) => timestampInRange(pr.mergedAt, range)); + const closedPrs = prs.filter((pr) => timestampInRange(pr.closedAt, range)); + const prsCreatedInRange = prs.filter((pr) => timestampInRange(pr.createdAt, range)); const commitsInRange = parseGithubCommitDates(commitRaw).filter((date) => timestampInRange(date, range)); for (const date of commitsInRange) { addGithubDaily(dailyByDate, dateKeyFromIso(date), { commits: 1 }); @@ -1602,22 +2087,20 @@ function mergeGithubDaily(points: AdeUsageDailyPoint[], githubStats: GitHubActiv if (!githubStats) return; const byDate = new Map(points.map((point) => [point.date, point])); for (const row of githubStats.daily) { - const point = byDate.get(row.date); - if (!point) continue; - point.commits = Math.max(point.commits, toNonNegativeInt(row.commits)); - point.prs = Math.max(point.prs, toNonNegativeInt(row.prs)); - point.insertions = Math.max(point.insertions, toNonNegativeInt(row.insertions)); - point.deletions = Math.max(point.deletions, toNonNegativeInt(row.deletions)); - point.filesChanged = Math.max(point.filesChanged, toNonNegativeInt(row.filesChanged)); + const point = ensureDailyPoint(points, byDate, row.date); + point.githubCommits = toNonNegativeInt(point.githubCommits) + toNonNegativeInt(row.commits); + point.githubPrs = toNonNegativeInt(point.githubPrs) + toNonNegativeInt(row.prs); + point.githubAdditions = toNonNegativeInt(point.githubAdditions) + toNonNegativeInt(row.insertions); + point.githubDeletions = toNonNegativeInt(point.githubDeletions) + toNonNegativeInt(row.deletions); } + points.sort((a, b) => a.date.localeCompare(b.date)); } function mergeDatabaseDaily(points: AdeUsageDailyPoint[], databaseStats: AdeDatabaseUsageStats | null | undefined): void { if (!databaseStats) return; const byDate = new Map(points.map((point) => [point.date, point])); for (const row of databaseStats.daily) { - const point = byDate.get(row.date); - if (!point) continue; + const point = ensureDailyPoint(points, byDate, row.date); // Provider ledgers are the authoritative token source when present. The // ADE DB log is a subset of those calls, so use it only as a gap-filler. if (point.totalTokens === 0 && toNonNegativeInt(row.totalTokens) > 0) { @@ -1629,12 +2112,13 @@ function mergeDatabaseDaily(points: AdeUsageDailyPoint[], databaseStats: AdeData point.durationMs = toNonNegativeInt(point.durationMs) + toNonNegativeInt(row.durationMs); point.interactions = toNonNegativeInt(point.interactions) + toNonNegativeInt(row.interactions); point.clients = { ...(point.clients ?? {}), ...(row.clients ?? {}) }; - point.commits = Math.max(point.commits, toNonNegativeInt(row.commits)); - point.prs = Math.max(point.prs, toNonNegativeInt(row.prs)); - point.insertions = Math.max(point.insertions, toNonNegativeInt(row.insertions)); - point.deletions = Math.max(point.deletions, toNonNegativeInt(row.deletions)); - point.filesChanged = Math.max(point.filesChanged, toNonNegativeInt(row.filesChanged)); + point.commits += toNonNegativeInt(row.commits); + point.prs += toNonNegativeInt(row.prs); + point.insertions += toNonNegativeInt(row.insertions); + point.deletions += toNonNegativeInt(row.deletions); + point.filesChanged += toNonNegativeInt(row.filesChanged); } + points.sort((a, b) => a.date.localeCompare(b.date)); } function collectAdeUsageStats({ @@ -1651,6 +2135,7 @@ function collectAdeUsageStats({ nowMs?: number; }): AdeUsageStats { const range = resolveAdeUsageRange(args, nowMs); + const scope = normalizeScope(args?.scope); const exactProviderRange = Boolean(args?.since || args?.until); const providerModelAggregation = createProviderModelAggregation(); addSnapshotProviderUsage(providerModelAggregation, snapshot, range, exactProviderRange); @@ -1666,6 +2151,7 @@ function collectAdeUsageStats({ const totalTokens = fallbackObserved.totalTokens > 0 ? fallbackObserved.totalTokens : trackedAdeTokens; const fallback: AdeUsageStats = { generatedAt: new Date(nowMs).toISOString(), + scope, range, summary: { totalTokens, @@ -1699,18 +2185,20 @@ function collectAdeUsageStats({ lanesCreated: toNonNegativeInt(dbSummary?.lanesCreated), lanesArchived: toNonNegativeInt(dbSummary?.lanesArchived), lanesDeleted: 0, - commitsCreated: Math.max(resolvedGithubStats.commitsCreated, toNonNegativeInt(dbSummary?.commitsCreated)), + // Legacy code-movement fields are local ADE DB values only. GitHub + // activity is exposed through githubActivity and the github* daily fields. + commitsCreated: toNonNegativeInt(dbSummary?.commitsCreated), pushOperations: toNonNegativeInt(dbSummary?.pushOperations), - prLandings: Math.max(resolvedGithubStats.prsMerged, toNonNegativeInt(dbSummary?.prLandings)), - prsTracked: Math.max(resolvedGithubStats.prsTracked, toNonNegativeInt(dbSummary?.prLandings)), + prLandings: toNonNegativeInt(dbSummary?.prLandings), + prsTracked: resolvedGithubStats.prsTracked, prsOpen: resolvedGithubStats.prsOpen, - prsMerged: Math.max(resolvedGithubStats.prsMerged, toNonNegativeInt(dbSummary?.prLandings)), + prsMerged: resolvedGithubStats.prsMerged, prsClosed: resolvedGithubStats.prsClosed, prAdditions: resolvedGithubStats.prAdditions, prDeletions: resolvedGithubStats.prDeletions, - filesChanged: Math.max(resolvedGithubStats.filesChanged, toNonNegativeInt(dbSummary?.filesChanged)), - insertions: Math.max(resolvedGithubStats.prAdditions, toNonNegativeInt(dbSummary?.insertions)), - deletions: Math.max(resolvedGithubStats.prDeletions, toNonNegativeInt(dbSummary?.deletions)), + filesChanged: toNonNegativeInt(dbSummary?.filesChanged), + insertions: toNonNegativeInt(dbSummary?.insertions), + deletions: toNonNegativeInt(dbSummary?.deletions), artifactsCaptured: toNonNegativeInt(dbSummary?.artifactsCaptured), automationRuns: toNonNegativeInt(dbSummary?.automationRuns), workerRuns: toNonNegativeInt(dbSummary?.workerRuns), @@ -1720,6 +2208,23 @@ function collectAdeUsageStats({ longestStreakDays: toNonNegativeInt(dbSummary?.longestStreakDays), longestSessionMs: toNonNegativeInt(dbSummary?.longestSessionMs), }, + githubActivity: { + commits: resolvedGithubStats.commitsCreated, + prsTracked: resolvedGithubStats.prsTracked, + prsOpen: resolvedGithubStats.prsOpen, + prsMerged: resolvedGithubStats.prsMerged, + prsClosed: resolvedGithubStats.prsClosed, + prAdditions: resolvedGithubStats.prAdditions, + prDeletions: resolvedGithubStats.prDeletions, + }, + localActivity: { + commits: toNonNegativeInt(dbSummary?.commitsCreated), + pushOperations: toNonNegativeInt(dbSummary?.pushOperations), + prLandings: toNonNegativeInt(dbSummary?.prLandings), + filesChanged: toNonNegativeInt(dbSummary?.filesChanged), + insertions: toNonNegativeInt(dbSummary?.insertions), + deletions: toNonNegativeInt(dbSummary?.deletions), + }, providers, models, adeProviders: databaseStats?.providers ?? [], @@ -1900,42 +2405,66 @@ function buildProviderWindows( freshWindows: UsageWindow[], errors: string[], prevWindows: UsageWindow[], - prevLastSuccessAt: string | null, + prevStatus: UsageProviderStatus | string | null, polledAt: string, + source?: UsageProviderSource, + errorKind?: UsageProviderErrorKind, + nextRetryAt?: string | null, ): { windows: UsageWindow[]; status: UsageProviderStatus; lastSuccessAt: string | null } { + const normalizedPrevStatus: UsageProviderStatus | null = typeof prevStatus === "string" + ? { state: "stale", lastSuccessAt: prevStatus, updatedAt: prevStatus } + : prevStatus; + const prevLastSuccessAt = normalizedPrevStatus?.lastSuccessAt ?? null; + const resolvedSource = source ?? normalizedPrevStatus?.source; if (freshWindows.length > 0) { return { windows: freshWindows, - status: { state: "ok", lastSuccessAt: polledAt }, + status: { + state: "ok", + lastSuccessAt: polledAt, + updatedAt: polledAt, + lastAttemptAt: polledAt, + ...(resolvedSource ? { source: resolvedSource } : {}), + }, lastSuccessAt: polledAt, }; } const name = PROVIDER_DISPLAY_NAME[provider] ?? provider; - const unauthed = errors.some((entry) => /no credentials/i.test(entry)); + const unauthed = errorKind === "auth" || errors.some((entry) => /no .*credentials/i.test(entry)); // A 401/403 means we reached the API but auth was rejected (expired token, // failed refresh) — that's "reconnect", not "couldn't reach". const authExpired = errors.some((entry) => /\b(401|403)\b|authentication|invalid.*credential/i.test(entry)); + const carriedWindows = filterUnexpiredCarriedWindows(prevWindows, polledAt); if (unauthed || authExpired) { return { - windows: [], + windows: carriedWindows, status: { - state: unauthed ? "unauthed" : "error", + state: "unauthed", lastSuccessAt: prevLastSuccessAt, - message: unauthed ? undefined : `${name} sign-in expired — reconnect`, + updatedAt: normalizedPrevStatus?.updatedAt ?? prevLastSuccessAt, + lastAttemptAt: polledAt, + errorKind: errorKind ?? "auth", + ...(resolvedSource ? { source: resolvedSource } : {}), + ...(nextRetryAt ? { nextRetryAt } : {}), + message: `${name} sign-in required — reconnect to refresh`, }, lastSuccessAt: prevLastSuccessAt, }; } - const carriedWindows = filterUnexpiredCarriedWindows(prevWindows, polledAt); if (carriedWindows.length > 0) { return { windows: carriedWindows, status: { state: "stale", lastSuccessAt: prevLastSuccessAt, + updatedAt: normalizedPrevStatus?.updatedAt ?? prevLastSuccessAt, + lastAttemptAt: polledAt, + ...(resolvedSource ? { source: resolvedSource } : {}), + ...(errorKind ? { errorKind } : {}), + ...(nextRetryAt ? { nextRetryAt } : {}), message: `Couldn't refresh ${name} — showing last reading`, }, lastSuccessAt: prevLastSuccessAt, @@ -1947,6 +2476,11 @@ function buildProviderWindows( status: { state: "error", lastSuccessAt: prevLastSuccessAt, + updatedAt: normalizedPrevStatus?.updatedAt ?? prevLastSuccessAt, + lastAttemptAt: polledAt, + ...(resolvedSource ? { source: resolvedSource } : {}), + ...(errorKind ? { errorKind } : {}), + ...(nextRetryAt ? { nextRetryAt } : {}), message: prevWindows.length > 0 ? `Couldn't refresh ${name} — last reading expired` : `Couldn't reach ${name} — retrying`, @@ -1960,8 +2494,8 @@ function buildProviderWindows( export type UsageTrackingService = ReturnType; type UsageTrackingDependencies = { - pollClaudeUsage?: () => Promise<{ windows: UsageWindow[]; extraUsage: ExtraUsage | null; errors: string[] }>; - pollCodexUsage?: () => Promise<{ windows: UsageWindow[]; errors: string[]; dailyUsage7d?: number[]; providerMessages?: UsageProviderMessage[] }>; + pollClaudeUsage?: (context?: UsageProviderPollContext) => Promise; + pollCodexUsage?: (context?: UsageProviderPollContext) => Promise; scanClaudeLogs?: () => Promise; scanCodexLogs?: () => Promise; scanCursorLogs?: () => Promise; @@ -1976,9 +2510,19 @@ type UsageTrackingDependencies = { }; type PollOptions = { - includeCosts?: boolean; + reason?: UsageRefreshReason; }; +function providerBackoffMs(result: UsageProviderPollResult, failureCount: number): number { + const exponential = Math.min( + MAX_POLL_INTERVAL_MS, + 60_000 * 2 ** Math.min(4, failureCount - 1), + ); + if (result.errorKind === "rate_limited") return Math.max(result.retryAfterMs ?? 0, exponential); + if (result.errorKind === "forbidden") return MAX_POLL_INTERVAL_MS; + return exponential; +} + export function createUsageTrackingService({ logger, pollIntervalMs: configuredInterval, @@ -2002,6 +2546,8 @@ export function createUsageTrackingService({ let lastSnapshot: UsageSnapshot | null = readCachedUsageSnapshot(logger); let cachedCosts: CostSnapshot[] = lastSnapshot?.costs ?? []; let cachedAdeCosts: CostSnapshot[] = lastSnapshot?.adeCosts ?? []; + let cachedProjectCosts: CostSnapshot[] = []; + let projectCostsReady = false; const cachedCostTimestampIso = lastSnapshot?.costsLastPolledAt ?? (cachedCosts.length > 0 || cachedAdeCosts.length > 0 ? lastSnapshot?.lastPolledAt : null); const cachedCostTimestampMs = cachedCostTimestampIso ? Date.parse(cachedCostTimestampIso) : Number.NaN; @@ -2021,11 +2567,21 @@ export function createUsageTrackingService({ const githubStatsCache = new Map(); const githubStatsInFlight = new Map>(); const statsRefreshInFlight = new Map>(); - let pollTimer: ReturnType | null = null; + let pollTimer: ReturnType | null = null; + let pollingEnabled = false; let inFlightPoll: Promise | null = null; - let inFlightPollIncludesCosts = false; - const runClaudeUsagePoll = dependencies?.pollClaudeUsage ?? (() => pollClaudeUsage(logger)); - const runCodexUsagePoll = dependencies?.pollCodexUsage ?? (() => pollCodexUsage(logger)); + let inFlightPollReason: UsageRefreshReason | null = null; + let inFlightHistoryRefresh: Promise | null = null; + let demandLeaseUntilMs = 0; + let lastDemandAtMs = Date.now(); + const providerFailureCount: Partial> = {}; + const providerNextRetryAtMs: Partial> = {}; + const runClaudeUsagePoll = dependencies?.pollClaudeUsage ?? ((context) => pollClaudeUsage(logger, context)); + const runCodexUsagePoll = dependencies?.pollCodexUsage ?? ((context) => pollCodexUsage(logger, context)); + const providerStrategies: UsageProviderStrategy[] = [ + { provider: "claude", poll: (context) => runClaudeUsagePoll(context) }, + { provider: "codex", poll: (context) => runCodexUsagePoll(context) }, + ]; const scanClaudeCostLogs = dependencies?.scanClaudeLogs ?? scanClaudeLogs; const scanCodexCostLogs = dependencies?.scanCodexLogs ?? scanCodexLogs; const scanCursorCostLogs = dependencies?.scanCursorLogs ?? scanCursorLogs; @@ -2038,7 +2594,7 @@ export function createUsageTrackingService({ const scanGitHubStatsForRange = dependencies?.scanGitHubStats ?? ((range: ResolvedAdeUsageRange) => scanGithubActivityStats(projectRoot, range)); const collectDatabaseStatsForRange = dependencies?.collectDatabaseStats - ?? ((range: ResolvedAdeUsageRange) => collectAdeDatabaseUsageStats(db, range)); + ?? ((range: ResolvedAdeUsageRange) => collectAdeDatabaseUsageStats(db, range, logger)); const emptySnapshot = (): UsageSnapshot => ({ windows: [], @@ -2052,13 +2608,21 @@ export function createUsageTrackingService({ errors: [], }); + function emitUpdate(snapshot: UsageSnapshot): void { + try { + onUpdate?.(snapshot); + } catch { + // Never crash on callback error + } + } + function cachedCostResult(): { costs: CostSnapshot[]; adeCosts: CostSnapshot[] } { return { costs: cachedCosts, adeCosts: cachedAdeCosts }; } async function pollCosts(): Promise<{ costs: CostSnapshot[]; adeCosts: CostSnapshot[] }> { const now = Date.now(); - if (costCacheTimestamp > 0 && now - costCacheTimestamp < COST_CACHE_TTL_MS) { + if (costCacheTimestamp > 0 && now - costCacheTimestamp < COST_CACHE_TTL_MS && projectCostsReady) { return cachedCostResult(); } @@ -2119,16 +2683,19 @@ export function createUsageTrackingService({ }), ]); - const costs: CostSnapshot[] = []; - if (claudeEntries.length > 0) costs.push(aggregateCosts(claudeEntries, "claude")); - if (codexEntries.length > 0) costs.push(aggregateCosts(codexEntries, "codex")); - if (cursorEntries.length > 0) costs.push(aggregateCosts(cursorEntries, "cursor")); - if (cursorAgentEntries.length > 0) costs.push(aggregateCosts(cursorAgentEntries, "cursor-agent")); - if (openClawEntries.length > 0) costs.push(aggregateCosts(openClawEntries, "openclaw")); - if (openCodeEntries.length > 0) costs.push(aggregateCosts(openCodeEntries, "opencode")); - if (droidEntries.length > 0) costs.push(aggregateCosts(droidEntries, "droid")); - if (copilotEntries.length > 0) costs.push(aggregateCosts(copilotEntries, "copilot")); - if (geminiEntries.length > 0) costs.push(aggregateCosts(geminiEntries, "gemini")); + const providerEntries: ProviderTokenEntries = new Map([ + ["claude", claudeEntries], + ["codex", codexEntries], + ["cursor", cursorEntries], + ["cursor-agent", cursorAgentEntries], + ["openclaw", openClawEntries], + ["opencode", openCodeEntries], + ["droid", droidEntries], + ["copilot", copilotEntries], + ["gemini", geminiEntries], + ]); + const costs = buildCostSnapshots(providerEntries, "machine", projectRoot); + const projectCosts = buildCostSnapshots(providerEntries, "project", projectRoot); const daily7d: Partial> = {}; if (claudeEntries.length > 0) daily7d.claude = bucketDaily7d(claudeEntries, now); @@ -2136,6 +2703,8 @@ export function createUsageTrackingService({ cachedCosts = costs; cachedAdeCosts = []; + cachedProjectCosts = projectCosts; + projectCostsReady = true; cachedDaily7d = daily7d; costCacheTimestamp = now; const durationMs = Date.now() - startedAt; @@ -2158,41 +2727,73 @@ export function createUsageTrackingService({ } async function poll(options: PollOptions = {}): Promise { - const includeCosts = options.includeCosts !== false; + const reason = options.reason ?? "automatic"; while (inFlightPoll) { - if (!includeCosts || inFlightPollIncludesCosts) { + if (reason === "automatic" || inFlightPollReason === "user") { return await inFlightPoll; } await inFlightPoll.catch(() => null); } let currentPoll!: Promise; - inFlightPollIncludesCosts = includeCosts; + inFlightPollReason = reason; currentPoll = Promise.resolve().then(async () => { const errors: string[] = []; let allWindows: UsageWindow[] = []; + const refreshStartedAt = Date.now(); try { - const [claudeResult, codexResult, costResult] = await Promise.all([ - runClaudeUsagePoll().catch((err) => { - const msg = `claude: poll failed: ${getErrorMessage(err)}`; - logger.warn("usage.poll.claude_failed", { error: msg }); - return { windows: [] as UsageWindow[], extraUsage: null as ExtraUsage | null, errors: [msg] }; - }), - runCodexUsagePoll().catch((err) => { - const msg = `codex: poll failed: ${getErrorMessage(err)}`; - logger.warn("usage.poll.codex_failed", { error: msg }); + const providerTasks = providerStrategies.map(async (strategy) => { + const previousStatus = lastSnapshot?.providerStatus?.[strategy.provider] ?? null; + const nextRetryMs = providerNextRetryAtMs[strategy.provider] ?? 0; + const shouldHonorBackoff = reason === "automatic" || previousStatus?.errorKind === "rate_limited"; + if (shouldHonorBackoff && nextRetryMs > Date.now()) { return { - windows: [] as UsageWindow[], - errors: [msg], - dailyUsage7d: undefined as number[] | undefined, - providerMessages: undefined as UsageProviderMessage[] | undefined, + provider: strategy.provider, + skipped: true as const, + result: { + windows: [], + source: previousStatus?.source, + errors: [], + errorKind: previousStatus?.errorKind, + } satisfies UsageProviderPollResult, }; - }), - includeCosts ? pollCosts() : Promise.resolve(cachedCostResult()), - ]); - - errors.push(...claudeResult.errors, ...codexResult.errors); + } + try { + const result = await strategy.poll({ reason }); + if (result.windows.length > 0) { + providerFailureCount[strategy.provider] = 0; + providerNextRetryAtMs[strategy.provider] = 0; + } else { + const failureCount = (providerFailureCount[strategy.provider] ?? 0) + 1; + providerFailureCount[strategy.provider] = failureCount; + providerNextRetryAtMs[strategy.provider] = Date.now() + providerBackoffMs(result, failureCount); + } + return { provider: strategy.provider, skipped: false as const, result }; + } catch (error) { + const message = `${strategy.provider}: poll failed: ${getErrorMessage(error)}`; + logger.warn(`usage.poll.${strategy.provider}_failed`, { error: message }); + const failureCount = (providerFailureCount[strategy.provider] ?? 0) + 1; + providerFailureCount[strategy.provider] = failureCount; + providerNextRetryAtMs[strategy.provider] = Date.now() + + Math.min(MAX_POLL_INTERVAL_MS, 60_000 * 2 ** Math.min(4, failureCount - 1)); + return { + provider: strategy.provider, + skipped: false as const, + result: { + windows: [], + errors: [message], + errorKind: errorKindForThrown(error), + } satisfies UsageProviderPollResult, + }; + } + }); + const providerResults = await Promise.all(providerTasks); + const resultsByProvider = new Map(providerResults.map((entry) => [entry.provider, entry])); + const emptyPollResult: UsageProviderPollResult = { windows: [], errors: [] }; + const claudeResult: UsageProviderPollResult = resultsByProvider.get("claude")?.result ?? emptyPollResult; + const codexResult: UsageProviderPollResult = resultsByProvider.get("codex")?.result ?? emptyPollResult; + for (const entry of providerResults) errors.push(...entry.result.errors); // Reconcile each provider against the last snapshot so a transient // failure (409/timeout) carries forward good data instead of wiping it. @@ -2200,17 +2801,29 @@ export function createUsageTrackingService({ const prevWindows = lastSnapshot?.windows ?? []; const providerStatus: UsageProviderStatusMap = {}; const mergedRaw: UsageWindow[] = []; - for (const [provider, result] of [ - ["claude", claudeResult], - ["codex", codexResult], - ] as const) { + for (const { provider, result, skipped } of providerResults) { + const previousStatus = lastSnapshot?.providerStatus?.[provider] ?? null; + if (skipped && previousStatus) { + providerStatus[provider] = previousStatus; + mergedRaw.push(...filterUnexpiredCarriedWindows( + prevWindows.filter((window) => window.provider === provider), + polledAt, + )); + continue; + } + const nextRetryMs = providerNextRetryAtMs[provider] ?? 0; const merged = buildProviderWindows( provider, result.windows, result.errors, prevWindows.filter((w) => w.provider === provider), - providerLastSuccess[provider] ?? null, + previousStatus ?? (providerLastSuccess[provider] + ? { state: "stale", lastSuccessAt: providerLastSuccess[provider]! } + : null), polledAt, + result.source, + result.errorKind, + nextRetryMs > Date.now() ? new Date(nextRetryMs).toISOString() : null, ); if (merged.lastSuccessAt) providerLastSuccess[provider] = merged.lastSuccessAt; providerStatus[provider] = merged.status; @@ -2228,11 +2841,11 @@ export function createUsageTrackingService({ const pacingByProvider = calculatePacingByProvider(allWindows); const extraUsage: ExtraUsage[] = []; if (claudeResult.extraUsage) extraUsage.push(claudeResult.extraUsage); - else if (providerStatus.claude?.state === "stale") { + else if (providerStatus.claude?.state === "stale" || providerStatus.claude?.state === "unauthed") { const previousClaudeExtra = lastSnapshot?.extraUsage.find((extra) => extra.provider === "claude"); if (previousClaudeExtra) extraUsage.push(previousClaudeExtra); } - const costsLastPolledAt = includeCosts ? polledAt : lastSnapshot?.costsLastPolledAt; + const costsLastPolledAt = lastSnapshot?.costsLastPolledAt; const dailyUsage7d: Partial> = { ...cachedDaily7d }; if (codexResult.dailyUsage7d?.some((value) => value > 0)) { dailyUsage7d.codex = codexResult.dailyUsage7d; @@ -2242,6 +2855,7 @@ export function createUsageTrackingService({ ...(lastSnapshot?.providerMessages ?? []).filter((message) => message.provider !== "codex"), ...(codexResult.providerMessages ?? []), ]; + const costResult = cachedCostResult(); const snapshot: UsageSnapshot = { windows: allWindows, @@ -2261,13 +2875,11 @@ export function createUsageTrackingService({ lastSnapshot = snapshot; void writeCachedUsageSnapshot(snapshot, logger); - try { - onUpdate?.(snapshot); - } catch { - // Never crash on callback error - } + emitUpdate(snapshot); logger.debug("usage.poll.complete", { + reason, + durationMs: Date.now() - refreshStartedAt, windowCount: allWindows.length, errorCount: errors.length, pacing: pacing.status, @@ -2287,7 +2899,7 @@ export function createUsageTrackingService({ } finally { if (inFlightPoll === currentPoll) { inFlightPoll = null; - inFlightPollIncludesCosts = false; + inFlightPollReason = null; } } }); @@ -2296,20 +2908,41 @@ export function createUsageTrackingService({ return await currentPoll; } + function nextPollDelayMs(nowMs = Date.now()): number { + if (demandLeaseUntilMs > nowMs) return ACTIVE_POLL_INTERVAL_MS; + if (nowMs - lastDemandAtMs >= IDLE_AFTER_MS) return IDLE_POLL_INTERVAL_MS; + return pollIntervalMs; + } + + function scheduleNextPoll(): void { + if (!pollingEnabled) return; + if (pollTimer) clearTimeout(pollTimer); + pollTimer = setTimeout(() => { + pollTimer = null; + void poll({ reason: "automatic" }) + .catch(() => {}) + .finally(() => { + if (pollingEnabled) scheduleNextPoll(); + }); + }, nextPollDelayMs()); + pollTimer.unref?.(); + } + function start() { - if (pollTimer) return; - // Automatic provider polling should not walk local agent ledgers. On - // machines with multi-GB Codex/Claude logs that scan can block project open - // and the runtime action queue; explicit refresh still performs it. - void poll({ includeCosts: false }).catch(() => {}); - pollTimer = setInterval(() => { - void poll({ includeCosts: false }).catch(() => {}); - }, pollIntervalMs); + if (pollingEnabled) return; + pollingEnabled = true; + scheduleNextPoll(); + void poll({ reason: "automatic" }) + .catch(() => {}) + .finally(() => { + if (pollingEnabled) scheduleNextPoll(); + }); } function stop() { + pollingEnabled = false; if (pollTimer) { - clearInterval(pollTimer); + clearTimeout(pollTimer); pollTimer = null; } } @@ -2318,34 +2951,96 @@ export function createUsageTrackingService({ return lastSnapshot ?? emptySnapshot(); } - async function forceRefresh(): Promise { - costCacheTimestamp = 0; // Invalidate cost cache - githubStatsCache.clear(); - githubStatsInFlight.clear(); + function noteQuotaDemand(): UsageSnapshot { + const nowMs = Date.now(); + lastDemandAtMs = nowMs; + demandLeaseUntilMs = nowMs + QUOTA_DEMAND_LEASE_MS; + if (pollTimer) scheduleNextPoll(); + const snapshot = getUsageSnapshot(); + if (nowMs - Date.parse(snapshot.lastPolledAt) > ACTIVE_POLL_INTERVAL_MS) { + void poll({ reason: "automatic" }).catch(() => {}); + } + return snapshot; + } + + async function forceRefresh( + options: { allowInteractiveAuth?: boolean } = {}, + ): Promise { + lastDemandAtMs = Date.now(); + const reason: UsageRefreshReason = options.allowInteractiveAuth === false ? "remote" : "user"; let timeout: ReturnType | null = null; const timeoutSnapshot = new Promise((resolve) => { timeout = setTimeout(() => { logger.warn("usage.force_refresh_returning_cached_snapshot", { - timeoutMs: FORCE_REFRESH_RESPONSE_TIMEOUT_MS, + timeoutMs: QUOTA_REFRESH_RESPONSE_TIMEOUT_MS, }); resolve(lastSnapshot ?? emptySnapshot()); - }, FORCE_REFRESH_RESPONSE_TIMEOUT_MS).unref?.(); + }, QUOTA_REFRESH_RESPONSE_TIMEOUT_MS).unref?.(); }); try { - return await Promise.race([poll({ includeCosts: true }), timeoutSnapshot]); + return await Promise.race([poll({ reason }), timeoutSnapshot]); } finally { if (timeout) clearTimeout(timeout); } } + async function refreshHistory( + options: { reason?: UsageRefreshReason } = {}, + ): Promise { + if (inFlightHistoryRefresh) return await inFlightHistoryRefresh; + costCacheTimestamp = 0; + githubStatsCache.clear(); + githubStatsInFlight.clear(); + const startedAt = Date.now(); + let current!: Promise; + current = measureUsagePhase( + logger, + { phase: "history", reason: options.reason ?? "user" }, + pollCosts, + ) + .then((costResult) => { + const refreshedAt = nowIso(); + const snapshot: UsageSnapshot = { + ...(lastSnapshot ?? emptySnapshot()), + costs: costResult.costs, + adeCosts: costResult.adeCosts, + dailyUsage7d: { ...cachedDaily7d }, + costsLastPolledAt: refreshedAt, + }; + lastSnapshot = snapshot; + void writeCachedUsageSnapshot(snapshot, logger); + try { + onUpdate?.(snapshot); + } catch { + // Never crash on callback error. + } + logger.debug("usage.refresh.history_complete", { + durationMs: Date.now() - startedAt, + providerCount: costResult.costs.length, + }); + return snapshot; + }) + .finally(() => { + if (inFlightHistoryRefresh === current) inFlightHistoryRefresh = null; + }); + inFlightHistoryRefresh = current; + return await current; + } + async function getAdeUsageStats(args: GetAdeUsageStatsArgs = {}): Promise { const nowMs = Date.now(); + const scope = normalizeScope(args.scope); const range = resolveAdeUsageRange(args, nowMs); const exactRange = Boolean(args.since || args.until); const cacheKey = githubStatsCacheKey(range, exactRange); const githubCached = githubStatsCache.get(cacheKey)?.stats ?? null; - const snapshot = lastSnapshot ?? emptySnapshot(); - const staleCosts = costCacheTimestamp === 0 || nowMs - costCacheTimestamp > COST_CACHE_TTL_MS; + const machineSnapshot = lastSnapshot ?? emptySnapshot(); + const snapshot = scope === "project" + ? { ...machineSnapshot, costs: cachedProjectCosts } + : machineSnapshot; + const staleCosts = costCacheTimestamp === 0 + || nowMs - costCacheTimestamp > COST_CACHE_TTL_MS + || (scope === "project" && !projectCostsReady); const providerNeedsRefresh = staleCosts; const githubNeedsRefresh = !githubCached || nowMs - (githubStatsCache.get(cacheKey)?.fetchedAtMs ?? 0) > GITHUB_STATS_CACHE_TTL_MS; if (providerNeedsRefresh || githubNeedsRefresh) { @@ -2360,7 +3055,7 @@ export function createUsageTrackingService({ }); stats.freshness = { state: providerNeedsRefresh || githubNeedsRefresh ? "refreshing" : "fresh", - providerUpdatedAt: snapshot.costsLastPolledAt ?? null, + providerUpdatedAt: machineSnapshot.costsLastPolledAt ?? null, githubUpdatedAt: githubCached?.fetchedAt ?? null, }; return stats; @@ -2371,13 +3066,16 @@ export function createUsageTrackingService({ requested: { provider: boolean; github: boolean }, exactRange = false, ): void { - const key = githubStatsCacheKey(range, exactRange); + const key = `${githubStatsCacheKey(range, exactRange)}:${requested.provider ? "provider" : ""}:${requested.github ? "github" : ""}`; if (statsRefreshInFlight.has(key)) return; const task = (async () => { const work: Promise[] = []; - if (requested.provider) work.push(poll({ includeCosts: true })); - if (requested.github) work.push(getGithubStatsForRange(range, exactRange)); + if (requested.provider) work.push(refreshHistory({ reason: "automatic" })); + if (requested.github) work.push(getGithubStatsForRange(range, exactRange, true)); await Promise.allSettled(work); + // History refresh emits when its ledger scan settles. GitHub can finish + // later, so always emit again after its cache is populated. + if (requested.github) emitUpdate(lastSnapshot ?? emptySnapshot()); })().finally(() => { statsRefreshInFlight.delete(key); }); @@ -2392,11 +3090,16 @@ export function createUsageTrackingService({ if (exactRange) { return `${range.preset}:${range.since ?? "all"}:${range.until}`; } - const untilDay = range.until.slice(0, 10); - return `${range.preset}:${range.since?.slice(0, 10) ?? "all"}:${untilDay}`; + const untilDay = localDayKey(range.until); + const sinceDay = range.since ? localDayKey(range.since) : "all"; + return `${range.preset}:${sinceDay || "all"}:${untilDay}`; } - async function getGithubStatsForRange(range: ResolvedAdeUsageRange, exactRange = false): Promise { + async function getGithubStatsForRange( + range: ResolvedAdeUsageRange, + exactRange = false, + waitForComplete = false, + ): Promise { const cacheKey = githubStatsCacheKey(range, exactRange); const cached = githubStatsCache.get(cacheKey); if (cached && Date.now() - cached.fetchedAtMs < GITHUB_STATS_CACHE_TTL_MS) { @@ -2416,6 +3119,8 @@ export function createUsageTrackingService({ githubStatsInFlight.set(cacheKey, inFlight); } + if (waitForComplete) return await inFlight; + let timeout: ReturnType | null = null; const loadingFallback = new Promise((resolve) => { timeout = setTimeout(() => { @@ -2434,7 +3139,9 @@ export function createUsageTrackingService({ start, stop, getUsageSnapshot, + noteQuotaDemand, forceRefresh, + refreshHistory, getAdeUsageStats, poll, dispose: stop, @@ -2452,6 +3159,7 @@ export const _testing = { isClaudeTokenExpiredOrExpiring, refreshClaudeCredentials, parseClaudeWindows, + parseClaudeCliUsage, parseCodexRateLimitWindows, pollClaudeUsage, pollCodexUsage, @@ -2469,6 +3177,11 @@ export const _testing = { parseGeminiEntries, aggregateCosts, bucketDaily7d, + localDayKey, + makeDailySkeleton, + dateIntersectsRange, + buildCostSnapshots, + scanGithubPullRequestPages, collectAdeUsageStats, calculatePacing, calculatePacingByProvider, diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 64abf74ab..5e41f5ec4 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -1045,6 +1045,8 @@ declare global { getAdeStats: (args?: GetAdeUsageStatsArgs) => Promise; getSnapshot: () => Promise; refresh: () => Promise; + refreshHistory: () => Promise; + noteDemand: () => Promise; checkBudget: (args: BudgetCheckArgs) => Promise; getCumulativeUsage: (args: { scope: BudgetCapScope; diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index db7435135..bbff3ae72 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -2027,7 +2027,12 @@ describe("preload OAuth bridge", () => { statusHints: {}, }; } - if (channel === IPC.usageGetSnapshot || channel === IPC.usageRefresh) { + if ( + channel === IPC.usageGetSnapshot + || channel === IPC.usageRefresh + || channel === IPC.usageRefreshHistory + || channel === IPC.usageNoteDemand + ) { throw new Error("local bound usage should not fall back to desktop usage IPC"); } return undefined; @@ -2054,6 +2059,8 @@ describe("preload OAuth bridge", () => { const bridge = (globalThis as any).__adeBridge; await expect(bridge.usage.getSnapshot()).resolves.toEqual(snapshot); await expect(bridge.usage.refresh()).resolves.toEqual(refreshed); + await expect(bridge.usage.refreshHistory()).resolves.toEqual(snapshot); + await expect(bridge.usage.noteDemand()).resolves.toEqual(snapshot); expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { rootPath: "/repo", @@ -2063,8 +2070,18 @@ describe("preload OAuth bridge", () => { rootPath: "/repo", request: { domain: "usage", action: "forceRefresh" }, }); + expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + rootPath: "/repo", + request: { domain: "usage", action: "refreshHistory" }, + }); + expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + rootPath: "/repo", + request: { domain: "usage", action: "noteQuotaDemand" }, + }); expect(invoke).not.toHaveBeenCalledWith(IPC.usageGetSnapshot); expect(invoke).not.toHaveBeenCalledWith(IPC.usageRefresh); + expect(invoke).not.toHaveBeenCalledWith(IPC.usageRefreshHistory); + expect(invoke).not.toHaveBeenCalledWith(IPC.usageNoteDemand); }); it("routes usage reads through a remote project runtime when bound", async () => { @@ -2104,7 +2121,12 @@ describe("preload OAuth bridge", () => { const request = (payload as { request?: { domain?: string; action?: string } } | undefined)?.request; return { ok: true, domain: request?.domain, action: request?.action, result: snapshot, statusHints: {} }; } - if (channel === IPC.usageGetSnapshot || channel === IPC.usageRefresh) { + if ( + channel === IPC.usageGetSnapshot + || channel === IPC.usageRefresh + || channel === IPC.usageRefreshHistory + || channel === IPC.usageNoteDemand + ) { throw new Error("remote usage should not call desktop usage IPC"); } return undefined; @@ -2131,6 +2153,8 @@ describe("preload OAuth bridge", () => { const bridge = (globalThis as any).__adeBridge; await expect(bridge.usage.getSnapshot()).resolves.toEqual(snapshot); await expect(bridge.usage.refresh()).resolves.toEqual(snapshot); + await expect(bridge.usage.refreshHistory()).resolves.toEqual(snapshot); + await expect(bridge.usage.noteDemand()).resolves.toEqual(snapshot); expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { id: "target-1", @@ -2142,8 +2166,20 @@ describe("preload OAuth bridge", () => { projectId: "project-1", request: { domain: "usage", action: "forceRefresh" }, }); + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { domain: "usage", action: "refreshHistory" }, + }); + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { domain: "usage", action: "noteQuotaDemand" }, + }); expect(invoke).not.toHaveBeenCalledWith(IPC.usageGetSnapshot); expect(invoke).not.toHaveBeenCalledWith(IPC.usageRefresh); + expect(invoke).not.toHaveBeenCalledWith(IPC.usageRefreshHistory); + expect(invoke).not.toHaveBeenCalledWith(IPC.usageNoteDemand); }); it("routes project local-data cleanup through a remote project runtime when bound", async () => { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 6f63e57c0..418426b0c 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -4299,10 +4299,12 @@ contextBridge.exposeInMainWorld("ade", { }, }, usage: { - getAdeStats: async (args: GetAdeUsageStatsArgs = {}): Promise => - callProjectRuntimeActionOr("usage", "getAdeUsageStats", { args }, () => - ipcRenderer.invoke(IPC.usageGetAdeStats, args), - ), + getAdeStats: async (args: GetAdeUsageStatsArgs = {}): Promise => { + const normalizedArgs: GetAdeUsageStatsArgs = { ...args, scope: args.scope ?? "machine" }; + return callProjectRuntimeActionOr("usage", "getAdeUsageStats", { args: normalizedArgs }, () => + ipcRenderer.invoke(IPC.usageGetAdeStats, normalizedArgs), + ); + }, getSnapshot: async (): Promise => callProjectRuntimeActionOr("usage", "getUsageSnapshot", {}, () => ipcRenderer.invoke(IPC.usageGetSnapshot), @@ -4311,6 +4313,14 @@ contextBridge.exposeInMainWorld("ade", { callProjectRuntimeActionOr("usage", "forceRefresh", {}, () => ipcRenderer.invoke(IPC.usageRefresh), ), + refreshHistory: async (): Promise => + callProjectRuntimeActionOr("usage", "refreshHistory", {}, () => + ipcRenderer.invoke(IPC.usageRefreshHistory), + ), + noteDemand: async (): Promise => + callProjectRuntimeActionOr("usage", "noteQuotaDemand", {}, () => + ipcRenderer.invoke(IPC.usageNoteDemand), + ), checkBudget: async (args: BudgetCheckArgs): Promise => callProjectRuntimeActionOr("budget", "checkBudget", { args }, () => ipcRenderer.invoke(IPC.usageCheckBudget, args), diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 8082bb549..89cd1e9de 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -3557,6 +3557,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { getAdeStats: getBrowserAdeUsageStats, getSnapshot: resolved(BROWSER_USAGE_SNAPSHOT), refresh: resolved(BROWSER_USAGE_SNAPSHOT), + refreshHistory: resolved(BROWSER_USAGE_SNAPSHOT), + noteDemand: resolved(BROWSER_USAGE_SNAPSHOT), checkBudget: resolvedArg({ allowed: true, warnings: [] as string[], diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index de987a983..a48c11fba 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -158,7 +158,7 @@ import { type WorkPtyLaunchResult, } from "../terminals/cliLaunch"; import { WorkSurfaceHeader } from "../work/WorkSurfaceHeader"; -import { WorkUsageActivityCarousel } from "../usage/UsageActivityCarousel"; +import { WorkActivityModule } from "../usage/ActivityModule"; import { branchNameFromRef } from "../prs/shared/laneBranchTargets"; import { shouldShowClaudeCacheTtl } from "../../lib/claudeCacheTtl"; import { @@ -11144,26 +11144,21 @@ export function AgentChatPane({ appPanelOpen ? "px-3" : "px-6 pb-24", )}>
-
ADE @@ -11268,7 +11263,7 @@ export function AgentChatPane({ exit={{ opacity: 0, y: 6 }} transition={{ duration: 0.28, ease: "easeOut" }} > - + ) : null}
diff --git a/apps/desktop/src/renderer/components/settings/AdeUsageSection.tsx b/apps/desktop/src/renderer/components/settings/AdeUsageSection.tsx index 51d902da7..9b421f9ff 100644 --- a/apps/desktop/src/renderer/components/settings/AdeUsageSection.tsx +++ b/apps/desktop/src/renderer/components/settings/AdeUsageSection.tsx @@ -5,56 +5,76 @@ import { GitBranch, GitPullRequest, Robot, - WarningCircle, } from "@phosphor-icons/react"; import type { AdeUsageModelSummary, AdeUsageProviderSummary, AdeUsageRangePreset, + AdeUsageScope, AdeUsageStats, } from "../../../shared/types"; import { formatCost, formatTokens, relativeTimeCompact } from "../../lib/format"; import { COLORS, LABEL_STYLE, - MONO_FONT, SANS_FONT, outlineButton, } from "../lanes/laneDesignTokens"; -import { UsageActivityCarousel } from "../usage/UsageActivityCarousel"; - -const ACCENT = { - tokens: "#60A5FA", - code: "#63D471", - pr: "#A78BFA", - cost: "#FBBF24", -} as const; - -const RANGE_OPTIONS: Array<{ preset: AdeUsageRangePreset; label: string }> = [ - { preset: "today", label: "Today" }, - { preset: "7d", label: "7 days" }, - { preset: "30d", label: "30 days" }, - { preset: "year", label: "Year" }, - { preset: "all", label: "All time" }, -]; +import { useAppStore } from "../../state/appStore"; +import { ActivityModule, RANGE_OPTIONS } from "../usage/ActivityModule"; +import { providerColor } from "../usage/providerColors"; +import { UsageQuotaPanel } from "../usage/UsageQuotaPanel"; + +const SCOPE_STORAGE_KEY = "ade.stats.scope.v1"; +const RANGE_STORAGE_KEY = "ade.stats.range.v1"; const PANEL_STYLE: React.CSSProperties = { - background: "color-mix(in srgb, var(--color-card) 88%, var(--color-bg) 12%)", - border: "1px solid color-mix(in srgb, var(--color-border) 82%, transparent)", - borderRadius: 8, + background: "color-mix(in srgb, var(--color-card) 90%, var(--color-bg) 10%)", + border: "1px solid color-mix(in srgb, var(--color-border) 78%, transparent)", + borderRadius: 10, padding: 16, }; const usageStatsCache = new Map(); -function statsCacheKey(scope: string, preset: AdeUsageRangePreset): string { - return `${scope}:${preset}`; +function statsCacheKey(scope: string, sourceScope: AdeUsageScope, preset: AdeUsageRangePreset): string { + return `${scope}:${sourceScope}:${preset}`; } function cacheScopeFromProject(project: { rootPath?: string | null } | null | undefined): string { return project?.rootPath?.trim() || "browser-preview"; } +function readScope(): AdeUsageScope { + if (typeof localStorage === "undefined") return "project"; + const value = localStorage.getItem(SCOPE_STORAGE_KEY); + return value === "machine" ? "machine" : "project"; +} + +function persistScope(scope: AdeUsageScope): void { + try { + localStorage.setItem(SCOPE_STORAGE_KEY, scope); + } catch { + // best-effort + } +} + +function readPreset(): AdeUsageRangePreset { + if (typeof localStorage === "undefined") return "all"; + const value = localStorage.getItem(RANGE_STORAGE_KEY); + return RANGE_OPTIONS.some((option) => option.preset === value) + ? value as AdeUsageRangePreset + : "all"; +} + +function persistPreset(preset: AdeUsageRangePreset): void { + try { + localStorage.setItem(RANGE_STORAGE_KEY, preset); + } catch { + // best-effort + } +} + function formatWhole(value: number): string { return Math.max(0, Math.floor(value || 0)).toLocaleString(); } @@ -92,16 +112,24 @@ function humanizeProvider(provider: string): string { .join(" "); } -function compactRange(stats: AdeUsageStats | null): string { - if (!stats) return ""; - const since = stats.range.since ? new Date(stats.range.since) : null; - const until = new Date(stats.range.until); - if (!since || Number.isNaN(since.getTime())) return "All time"; - if (Number.isNaN(until.getTime())) return ""; - return `${since.toLocaleDateString([], { month: "short", day: "numeric" })} - ${until.toLocaleDateString([], { month: "short", day: "numeric" })}`; +function estimationNote(kind: AdeUsageProviderSummary["estimation"]): string | null { + switch (kind) { + case "chars": + return "estimated from text length"; + case "distribution": + return "daily split estimated"; + case "mixed": + return "partly estimated"; + default: + return null; + } } -function StatCard({ +// --------------------------------------------------------------------------- +// Building blocks +// --------------------------------------------------------------------------- + +function StatTile({ label, value, detail, @@ -115,16 +143,16 @@ function StatCard({ icon: React.ReactNode; }) { return ( -
+
-
{label}
+
{label}
{icon}
-
+
{value}
-
+
{detail}
@@ -132,27 +160,11 @@ function StatCard({ ); } -function SectionHeader({ label, detail }: { label: string; detail?: string }) { +function SectionHeader({ label }: { label: string }) { return ( -
-

- {label} -

- {detail ? ( - - {detail} - - ) : null} -
- ); -} - -function EmptyState({ text }: { text: string }) { - return ( -
- - {text} -
+

+ {label} +

); } @@ -165,62 +177,85 @@ function Bar({ value, max, color }: { value: number; max: number; color: string ); } -function ProviderBreakdown({ +function AiUsage({ providers, models, + theme, }: { providers: AdeUsageProviderSummary[]; models: AdeUsageModelSummary[]; + theme: "dark" | "light"; }) { const topModels = models.slice(0, 8); const maxProvider = Math.max(1, ...providers.map((provider) => provider.totalTokens)); const maxModel = Math.max(1, ...topModels.map((model) => model.totalTokens)); + const adeTokens = providers.reduce((sum, provider) => sum + (provider.adeOriginatedTokens ?? 0), 0); + const externalTokens = providers.reduce((sum, provider) => sum + (provider.externalTokens ?? 0), 0); + const hasSplit = providers.some( + (provider) => provider.adeOriginatedTokens != null || provider.externalTokens != null, + ) && adeTokens + externalTokens > 0; + return (
- -
+ + {hasSplit ? ( +
+
+
+
+
+
+ {formatTokens(adeTokens)} in ADE + {formatTokens(externalTokens)} external +
+
+ ) : null} + +
{providers.length === 0 ? ( - - ) : providers.map((provider) => ( -
-
-
- {humanizeProvider(provider.provider)} +
No AI usage found for this range.
+ ) : providers.map((provider) => { + const note = estimationNote(provider.estimation); + return ( +
+
+
+ {humanizeProvider(provider.provider)} +
+
+ {formatUsd(provider.rangeCostUsd)}{note ? ` · ${note}` : ""} +
-
- {formatUsd(provider.rangeCostUsd)} + +
+ {formatTokens(provider.totalTokens)}
- -
- {formatTokens(provider.totalTokens)} -
-
- ))} + ); + })}
{topModels.length > 0 ? (
-
Top models
+
Top models
{topModels.map((model) => ( -
+
- + {model.model} - + {formatTokens(model.totalTokens)}
- +
-
- {formatUsd(model.costUsd)}
- {formatTokens(model.inputTokens)} in / {formatTokens(model.outputTokens)} out +
+ {formatUsd(model.costUsd)} · {formatTokens(model.inputTokens)} in / {formatTokens(model.outputTokens)} out
))} @@ -230,73 +265,132 @@ function ProviderBreakdown({ ); } -function GithubBreakdown({ stats }: { stats: AdeUsageStats }) { - const summary = stats.summary; - const totalCode = summary.insertions + summary.deletions; - const maxLine = Math.max(1, summary.insertions, summary.deletions, summary.filesChanged); - const repoLabel = stats.github.repo ?? "GitHub"; - const githubLoading = stats.github.error === "GitHub activity is still loading."; - +function CodeColumn({ + title, + subtitle, + rows, +}: { + title: string; + subtitle: string; + rows: Array<{ label: string; value: string }>; +}) { return ( -
- - <> -
- - - -
- -
- - - -
- -
- - +
+
+
{title}
+
{subtitle}
+
+
+ {rows.map((row) => ( +
+
{row.value}
+
{row.label}
- {!stats.github.available ? ( -
- {githubLoading ? "GitHub activity is still loading; local ADE history is shown now." : "GitHub is unavailable; local ADE history is shown."} -
- ) : null} - -
- ); -} - -function MiniMetric({ label, value, accent }: { label: string; value: string; accent: string }) { - return ( -
-
{label}
-
{value}
+ ))} +
); } -function Fact({ label, value }: { label: string; value: string }) { +function CodeAndPrs({ stats }: { stats: AdeUsageStats }) { + const github = stats.githubActivity; + const local = stats.localActivity; + const summary = stats.summary; + + // Prefer the labeled github/local groups; fall back to legacy summary fields + // (which max-merge sources) as a single column so a merged number is never + // shown twice. + const githubColumn = github + ? { + title: "GitHub", + subtitle: stats.github.repo ?? "Remote repository", + rows: [ + { label: "Commits", value: formatWhole(github.commits) }, + { label: "PRs merged", value: formatWhole(github.prsMerged) }, + { label: "PRs open", value: formatWhole(github.prsOpen) }, + { label: "Lines +/-", value: `${formatWhole(github.prAdditions)} / ${formatWhole(github.prDeletions)}` }, + ], + } + : null; + + const localColumn = local + ? { + title: "Local lanes", + subtitle: "This project's git history", + rows: [ + { label: "Commits", value: formatWhole(local.commits) }, + { label: "PR landings", value: formatWhole(local.prLandings) }, + { label: "Files changed", value: formatWhole(local.filesChanged) }, + { label: "Lines +/-", value: `${formatWhole(local.insertions)} / ${formatWhole(local.deletions)}` }, + ], + } + : null; + + const legacyColumn = + !github && !local + ? { + title: "Code activity", + subtitle: stats.github.available ? (stats.github.repo ?? "GitHub + local history") : "ADE local history", + rows: [ + { label: "Commits", value: formatWhole(summary.commitsCreated) }, + { label: "PRs merged", value: formatWhole(summary.prsMerged) }, + { label: "Files changed", value: formatWhole(summary.filesChanged) }, + { label: "Lines +/-", value: `${formatWhole(summary.insertions)} / ${formatWhole(summary.deletions)}` }, + ], + } + : null; + + const columns = [githubColumn, localColumn, legacyColumn].filter(Boolean) as Array<{ + title: string; + subtitle: string; + rows: Array<{ label: string; value: string }>; + }>; + return ( -
-
{value}
-
{label}
-
+
+ +
1 ? "1fr 1fr" : "1fr", gap: 20 }}> + {columns.map((column) => ( + + ))} +
+
); } -function ChangeRow({ label, value, max, color }: { label: string; value: number; max: number; color: string }) { - return ( -
- {label} - - {formatWhole(value)} -
- ); +function metaLine(stats: AdeUsageStats | null, scope: AdeUsageScope): string { + if (!stats) return ""; + const parts: string[] = []; + const updated = relativeTimeCompact(stats.freshness?.providerUpdatedAt ?? stats.generatedAt); + if (updated) parts.push(`Updated ${updated} ago`); + if (stats.freshness?.state === "refreshing") parts.push("refreshing"); + + const estimated = stats.providers + .filter((provider) => provider.estimation && provider.estimation !== "exact") + .map((provider) => humanizeProvider(provider.provider)); + if (estimated.length === 1) parts.push(`${estimated[0]} tokens estimated`); + else if (estimated.length > 1) { + const last = estimated.at(-1)!; + parts.push(`${estimated.slice(0, -1).join(", ")} and ${last} tokens estimated`); + } + + parts.push(scope === "machine" ? "Provider totals cover this machine" : "Provider totals scoped to this project"); + + for (const note of stats.sourceNotes ?? []) { + if (note.trim()) parts.push(note.trim()); + } + return parts.join(" · "); } +// --------------------------------------------------------------------------- +// Section +// --------------------------------------------------------------------------- + export function AdeUsageSection() { - const [preset, setPreset] = React.useState("7d"); + const theme = useAppStore((state) => state.theme); + const [activeTab, setActiveTab] = React.useState<"limits" | "activity">("limits"); + const [preset, setPreset] = React.useState(() => readPreset()); + const [scope, setScope] = React.useState(() => readScope()); const [cacheScope, setCacheScope] = React.useState(null); const [stats, setStats] = React.useState(null); const [loading, setLoading] = React.useState(true); @@ -304,51 +398,52 @@ export function AdeUsageSection() { const [error, setError] = React.useState(null); const loadSeqRef = React.useRef(0); - const loadStats = React.useCallback(async (nextPreset: AdeUsageRangePreset, scope: string, force = false) => { - const loadSeq = loadSeqRef.current + 1; - loadSeqRef.current = loadSeq; - const key = statsCacheKey(scope, nextPreset); - const cached = usageStatsCache.get(key); - if (cached) { - setStats(cached); - setLoading(false); - } else { - setStats(null); - setLoading(true); - } - - try { - setError(null); - if (force) { - setRefreshing(true); - await window.ade?.usage?.refresh?.(); - } - const result = await window.ade?.usage?.getAdeStats?.({ preset: nextPreset }); - if (!result) { - throw new Error("Stats are unavailable."); - } - if (loadSeqRef.current === loadSeq) { - usageStatsCache.set(key, result); - setStats(result); + const loadStats = React.useCallback( + async (nextPreset: AdeUsageRangePreset, sourceScope: AdeUsageScope, scopeKey: string, force = false) => { + const loadSeq = loadSeqRef.current + 1; + loadSeqRef.current = loadSeq; + const key = statsCacheKey(scopeKey, sourceScope, nextPreset); + const cached = usageStatsCache.get(key); + if (cached) { + setStats(cached); + setLoading(false); + } else { + setStats(null); + setLoading(true); } - } catch (err) { - if (loadSeqRef.current === loadSeq) { - if (cached && !force) { - setError(null); - setStats(cached); - } else { - usageStatsCache.delete(key); - setError(err instanceof Error ? err.message : String(err)); - setStats(null); + + try { + setError(null); + if (force) { + setRefreshing(true); + await window.ade?.usage?.refreshHistory?.(); + } + const result = await window.ade?.usage?.getAdeStats?.({ preset: nextPreset, scope: sourceScope }); + if (!result) throw new Error("Stats are unavailable."); + if (loadSeqRef.current === loadSeq) { + usageStatsCache.set(key, result); + setStats(result); + } + } catch (err) { + if (loadSeqRef.current === loadSeq) { + if (cached && !force) { + setError(null); + setStats(cached); + } else { + usageStatsCache.delete(key); + setError(err instanceof Error ? err.message : String(err)); + setStats(null); + } + } + } finally { + if (loadSeqRef.current === loadSeq) { + setLoading(false); + setRefreshing(false); } } - } finally { - if (loadSeqRef.current === loadSeq) { - setLoading(false); - setRefreshing(false); - } - } - }, []); + }, + [], + ); React.useEffect(() => { let mounted = true; @@ -368,9 +463,7 @@ export function AdeUsageSection() { }; } - void window.ade.app.getProject() - .then(applyProject) - .catch(() => applyProject(null)); + void window.ade.app.getProject().then(applyProject).catch(() => applyProject(null)); const unsubscribe = window.ade.app.onProjectChanged?.(applyProject); return () => { mounted = false; @@ -379,149 +472,203 @@ export function AdeUsageSection() { }, []); React.useEffect(() => { - if (!cacheScope) return; - void loadStats(preset, cacheScope, false); - }, [cacheScope, loadStats, preset]); + if (!cacheScope || activeTab !== "activity") return; + void loadStats(preset, scope, cacheScope, false); + }, [activeTab, cacheScope, loadStats, preset, scope]); + // Re-fetch when a background ledger refresh completes, instead of a capped + // freshness-driven retry. React.useEffect(() => { - if (!cacheScope) return; - if (stats?.freshness?.state !== "refreshing") return; - const timeout = window.setTimeout(() => { - void loadStats(preset, cacheScope, false); - }, 3_500); - return () => window.clearTimeout(timeout); - }, [cacheScope, loadStats, preset, stats?.freshness?.state, stats?.generatedAt]); + if (!cacheScope || activeTab !== "activity") return; + const unsubscribe = window.ade?.usage?.onUpdate?.(() => { + void loadStats(preset, scope, cacheScope, false); + }); + return () => unsubscribe?.(); + }, [activeTab, cacheScope, loadStats, preset, scope]); + + const changeScope = React.useCallback((next: AdeUsageScope) => { + setScope(next); + persistScope(next); + }, []); + + const changePreset = React.useCallback((next: AdeUsageRangePreset) => { + setPreset(next); + persistPreset(next); + }, []); const summary = stats?.summary; - const providerCount = stats?.providers.length ?? 0; - const modelCount = stats?.models.length ?? 0; - const topProvider = stats?.providers[0] ? humanizeProvider(stats.providers[0].provider) : "No providers"; - const updatedLabel = stats?.generatedAt ? relativeTimeCompact(stats.generatedAt) : ""; - const rangeLabel = compactRange(stats); const loadingEmpty = loading && !stats; - const headerDetail = [ - "ADE activity across desktop, mobile, terminal, and web", - rangeLabel, - updatedLabel ? `updated ${updatedLabel}` : "", - ].filter(Boolean).join(" / "); + const meta = metaLine(stats, scope); return ( -
+
Settings
-

- Stats -

+

Usage

- {headerDetail} + {activeTab === "limits" + ? "Live Claude and Codex quota from the connected host." + : "Your ADE activity across desktop, mobile, terminal, and web."}
-
-
- {RANGE_OPTIONS.map((option) => { - const active = preset === option.preset; - return ( - - ); - })} -
- - +
+ setActiveTab(value as "limits" | "activity")} + /> + {activeTab === "activity" ? ( + <> + changeScope(value as AdeUsageScope)} + /> + ({ value: option.preset, label: option.label }))} + value={preset} + onChange={(value) => changePreset(value as AdeUsageRangePreset)} + /> + + + ) : null}
- {error ? : null} - - - -
- } - /> - } - /> - } - /> - } + {activeTab === "limits" ? ( + + ) : ( + <> + {error ? ( +
{error}
+ ) : null} + +
+ +
+ } + /> + } + /> + } + /> + } + /> +
+
+ +
+ + -
+
-
+
{loadingEmpty ? ( -
+
Loading AI usage.
+ ) : ( + + )} + {stats ? ( + ) : ( - +
Loading code activity.
)} - {stats ? :
}
-
- } - /> - } - /> -
+ {meta ? ( +
{meta}
+ ) : null} + + )} +
+ ); +} + +function Segmented({ + options, + value, + onChange, +}: { + options: Array<{ value: string; label: string }>; + value: string; + onChange: (value: string) => void; +}) { + return ( +
+ {options.map((option, index) => { + const active = value === option.value; + return ( + + ); + })}
); } diff --git a/apps/desktop/src/renderer/components/usage/ActivityModule.tsx b/apps/desktop/src/renderer/components/usage/ActivityModule.tsx new file mode 100644 index 000000000..7ae9b35db --- /dev/null +++ b/apps/desktop/src/renderer/components/usage/ActivityModule.tsx @@ -0,0 +1,917 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + DeviceMobile, + Fire, + Globe, + Monitor, + TerminalWindow, + Trophy, +} from "@phosphor-icons/react"; +import type { + AdeUsageClientSurface, + AdeUsageDailyPoint, + AdeUsageRangePreset, + AdeUsageStats, +} from "../../../shared/types"; +import { formatTokens } from "../../lib/format"; +import { useAppStore } from "../../state/appStore"; + +// --------------------------------------------------------------------------- +// Persistence +// --------------------------------------------------------------------------- + +const STORAGE_KEY = "ade.activity.module.v1"; +const LEGACY_STORAGE_KEY = "ade.stats.carousel.v1"; +const DEFAULT_PRESET: AdeUsageRangePreset = "all"; + +const TABS = ["activity", "tokens", "code", "clients"] as const; +type ActivityTab = (typeof TABS)[number]; + +const TAB_LABELS: Record = { + activity: "Activity", + tokens: "Tokens", + code: "Code", + clients: "Clients", +}; + +export const RANGE_OPTIONS: Array<{ preset: AdeUsageRangePreset; label: string }> = [ + { preset: "today", label: "Today" }, + { preset: "7d", label: "7d" }, + { preset: "30d", label: "30d" }, + { preset: "year", label: "Year" }, + { preset: "all", label: "All" }, +]; + +type PersistedState = { tab: ActivityTab; preset: AdeUsageRangePreset }; + +function isTab(value: unknown): value is ActivityTab { + return typeof value === "string" && (TABS as readonly string[]).includes(value); +} + +function isPreset(value: unknown): value is AdeUsageRangePreset { + return RANGE_OPTIONS.some((option) => option.preset === value); +} + +export function readActivityPersisted(): PersistedState { + const fallback: PersistedState = { tab: "activity", preset: DEFAULT_PRESET }; + if (typeof localStorage === "undefined") return fallback; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as Partial; + return { + tab: isTab(parsed.tab) ? parsed.tab : "activity", + preset: isPreset(parsed.preset) ? parsed.preset : DEFAULT_PRESET, + }; + } + // Migrate the retired carousel key ({ slide, preset }) once, gracefully. + const legacy = localStorage.getItem(LEGACY_STORAGE_KEY); + if (legacy) { + const parsed = JSON.parse(legacy) as { slide?: unknown; preset?: unknown }; + return { + tab: isTab(parsed.slide) ? parsed.slide : "activity", + preset: isPreset(parsed.preset) ? parsed.preset : DEFAULT_PRESET, + }; + } + } catch { + // Preferences are best-effort in hardened/private browser contexts. + } + return fallback; +} + +function persistActivityPatch(patch: Partial): void { + if (typeof localStorage === "undefined") return; + try { + const current = readActivityPersisted(); + localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...current, ...patch })); + } catch { + // ignore + } +} + +// --------------------------------------------------------------------------- +// Reduced-motion +// --------------------------------------------------------------------------- + +function usePrefersReducedMotion(): boolean { + const [reduced, setReduced] = useState(() => { + if (typeof window === "undefined" || !window.matchMedia) return false; + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; + }); + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const query = window.matchMedia("(prefers-reduced-motion: reduce)"); + const onChange = () => setReduced(query.matches); + query.addEventListener?.("change", onChange); + return () => query.removeEventListener?.("change", onChange); + }, []); + return reduced; +} + +// --------------------------------------------------------------------------- +// Formatting helpers +// --------------------------------------------------------------------------- + +function compact(value: number): string { + if (!Number.isFinite(value) || value <= 0) return "0"; + if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(value >= 10_000_000_000 ? 0 : 1)}B`; + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}K`; + return Math.floor(value).toLocaleString(); +} + +function formatDay(date: string): string { + const parsed = new Date(`${date}T00:00:00`); + if (Number.isNaN(parsed.getTime())) return date; + return parsed.toLocaleDateString([], { month: "short", day: "numeric" }); +} + +function dayValue(point: AdeUsageDailyPoint): number { + return ( + point.totalTokens + + point.sessions * 4_000 + + (point.interactions ?? 0) * 1_500 + + point.commits * 4_000 + + point.prs * 8_000 + + point.filesChanged * 500 + + point.insertions + + point.deletions + + (point.githubCommits ?? 0) * 4_000 + + (point.githubPrs ?? 0) * 8_000 + + (point.githubAdditions ?? 0) + + (point.githubDeletions ?? 0) + ); +} + +function dayHasActivity(point: AdeUsageDailyPoint): boolean { + return ( + point.totalTokens > 0 || + point.sessions > 0 || + point.commits > 0 || + point.prs > 0 || + point.filesChanged > 0 || + point.insertions > 0 || + point.deletions > 0 || + (point.githubCommits ?? 0) > 0 || + (point.githubPrs ?? 0) > 0 || + (point.githubAdditions ?? 0) > 0 || + (point.githubDeletions ?? 0) > 0 || + (point.interactions ?? 0) > 0 + ); +} + +function sessionsTotal(stats: AdeUsageStats): number { + return (stats.summary.chatSessions ?? 0) + (stats.summary.terminalSessions ?? 0); +} + +// --------------------------------------------------------------------------- +// Colors +// --------------------------------------------------------------------------- + +const TOKEN_COLORS = { input: "#5B93F5", output: "#E0A82E", cache: "#8892A6" } as const; +const CODE_COLORS = { insertions: "#3FB950", deletions: "#E5595C", github: "#8892A6" } as const; +const HEATMAP_HUE = "#5B93F5"; + +const CLIENT_COLORS: Record = { + desktop: "#5B93F5", + mobile: "#E0729B", + tui: "#3FB950", + web: "#2DD4BF", + api: "#E0A82E", +}; +const CLIENT_LABELS: Record = { + desktop: "Desktop", + mobile: "Mobile", + tui: "ADE Code", + web: "Web", + api: "API", +}; + +// --------------------------------------------------------------------------- +// Day tooltip +// --------------------------------------------------------------------------- + +type TooltipState = { point: AdeUsageDailyPoint; left: number; top: number }; + +function useDayTooltip(containerRef: React.RefObject) { + const [tip, setTip] = useState(null); + + const locate = useCallback((point: AdeUsageDailyPoint, target: HTMLElement): TooltipState | null => { + const container = containerRef.current; + if (!container) return null; + const c = container.getBoundingClientRect(); + const t = target.getBoundingClientRect(); + const half = 92; + const rawLeft = t.left - c.left + t.width / 2; + return { + point, + left: Math.min(Math.max(rawLeft, half), Math.max(half, c.width - half)), + top: t.top - c.top, + }; + }, [containerRef]); + + const show = useCallback((point: AdeUsageDailyPoint, target: HTMLElement) => { + const next = locate(point, target); + if (next) setTip(next); + }, [locate]); + + const toggle = useCallback((point: AdeUsageDailyPoint, target: HTMLElement) => { + setTip((prev) => (prev?.point.date === point.date ? null : locate(point, target))); + }, [locate]); + + const hide = useCallback(() => setTip(null), []); + + return { tip, show, toggle, hide }; +} + +function DayTooltip({ tip }: { tip: TooltipState }) { + const { point } = tip; + const code = point.insertions + point.deletions; + return ( +
+
{formatDay(point.date)}
+
+ {formatTokens(point.totalTokens)} tokens + + {formatTokens(point.inputTokens)} in · {formatTokens(point.outputTokens)} out + {point.cachedTokens != null && point.cachedTokens > 0 ? ` · ${formatTokens(point.cachedTokens)} cache` : ""} + + {point.sessions} {point.sessions === 1 ? "session" : "sessions"} + {code > 0 ? ( + + +{compact(point.insertions)} + {" "} + -{compact(point.deletions)} + {" lines"} + + ) : null} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Chart primitives +// --------------------------------------------------------------------------- + +/** Downsamples a daily series so wide ranges stay legible as fixed-count bars. */ +function useChartPoints(points: AdeUsageDailyPoint[], maxBars: number): AdeUsageDailyPoint[] { + return useMemo(() => { + const source = [...points].sort((a, b) => a.date.localeCompare(b.date)); + if (source.length <= maxBars) return source; + const size = Math.ceil(source.length / maxBars); + const merged: AdeUsageDailyPoint[] = []; + for (let index = 0; index < source.length; index += size) { + const chunk = source.slice(index, index + size); + const last = chunk.at(-1); + if (!last) continue; + const sum = (pick: (p: AdeUsageDailyPoint) => number) => chunk.reduce((acc, p) => acc + pick(p), 0); + merged.push({ + date: last.date, + inputTokens: sum((p) => p.inputTokens), + outputTokens: sum((p) => p.outputTokens), + totalTokens: sum((p) => p.totalTokens), + cachedTokens: sum((p) => p.cachedTokens ?? 0), + commits: sum((p) => p.commits), + prs: sum((p) => p.prs), + insertions: sum((p) => p.insertions), + deletions: sum((p) => p.deletions), + filesChanged: sum((p) => p.filesChanged), + sessions: sum((p) => p.sessions), + durationMs: sum((p) => p.durationMs ?? 0), + interactions: sum((p) => p.interactions ?? 0), + githubAdditions: sum((p) => p.githubAdditions ?? 0), + githubDeletions: sum((p) => p.githubDeletions ?? 0), + }); + } + return merged; + }, [points, maxBars]); +} + +function ChartFrame({ + height, + children, + ariaLabel, +}: { + height: number; + children: React.ReactNode; + ariaLabel: string; +}) { + return ( +
+ {children} +
+ ); +} + +function ActivityHeatmap({ + points, + height, + reduced, + tooltip, +}: { + points: AdeUsageDailyPoint[]; + height: number; + reduced: boolean; + tooltip: ReturnType; +}) { + const cells = useMemo(() => { + const ordered = [...points].sort((a, b) => a.date.localeCompare(b.date)); + const max = Math.max(1, ...ordered.map(dayValue)); + return ordered.map((point) => ({ point, intensity: Math.max(0, Math.min(1, dayValue(point) / max)) })); + }, [points]); + + // Fill the chart box instead of leaving it mostly blank: a short range lays out + // as one tall row of large cells; longer ranges use a 7-row calendar-style grid + // (columns = weeks) whose cells stretch to fill the available width and height. + const rows = cells.length <= 7 ? 1 : 7; + const cols = Math.max(1, Math.ceil(cells.length / rows)); + + return ( +
+ {cells.map(({ point, intensity }) => ( + tooltip.show(point, event.currentTarget)} + onPointerLeave={tooltip.hide} + onClick={(event) => tooltip.toggle(point, event.currentTarget)} + /> + ))} +
+ ); +} + +/** Muted, centered hint for a tab whose own series is empty while the module + * has data on other tabs (so the global warm-empty state does not apply). */ +function TabEmptyHint({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +} + +function TokenBars({ + points, + height, + reduced, + tooltip, +}: { + points: AdeUsageDailyPoint[]; + height: number; + reduced: boolean; + tooltip: ReturnType; +}) { + const max = Math.max(1, ...points.map((p) => p.totalTokens)); + const anyCache = points.some((p) => (p.cachedTokens ?? 0) > 0); + const anyTokens = points.some((p) => p.totalTokens > 0); + return ( +
+ {!anyTokens ? ( + + ) : ( + + {points.map((point) => { + const total = Math.max(0, point.inputTokens + point.outputTokens + (point.cachedTokens ?? 0)); + const barHeight = total > 0 ? Math.max(3, (total / max) * height) : 0; + const seg = (value: number) => (total > 0 ? `${(value / total) * 100}%` : "0%"); + return ( +
tooltip.show(point, event.currentTarget)} + onPointerLeave={tooltip.hide} + onClick={(event) => tooltip.toggle(point, event.currentTarget)} + > + + + {anyCache ? : null} +
+ ); + })} +
+ )} + +
+ ); +} + +function CodeBars({ + points, + height, + reduced, + tooltip, +}: { + points: AdeUsageDailyPoint[]; + height: number; + reduced: boolean; + tooltip: ReturnType; +}) { + const anyGithub = points.some((p) => (p.githubAdditions ?? 0) + (p.githubDeletions ?? 0) > 0); + const localMax = Math.max(1, ...points.map((p) => p.insertions + p.deletions)); + const githubMax = anyGithub + ? Math.max(1, ...points.map((p) => (p.githubAdditions ?? 0) + (p.githubDeletions ?? 0))) + : 1; + const max = Math.max(localMax, githubMax); + const anyCode = points.some((p) => p.insertions + p.deletions > 0) || anyGithub; + return ( +
+ {!anyCode ? ( + + ) : ( + + {points.map((point) => { + const total = Math.max(0, point.insertions + point.deletions); + const barHeight = total > 0 ? Math.max(3, (total / max) * height) : 0; + const seg = (value: number) => (total > 0 ? `${(value / total) * 100}%` : "0%"); + const githubTotal = (point.githubAdditions ?? 0) + (point.githubDeletions ?? 0); + const githubHeight = anyGithub && githubTotal > 0 ? Math.max(2, (githubTotal / max) * height) : 0; + return ( +
tooltip.show(point, event.currentTarget)} + onPointerLeave={tooltip.hide} + onClick={(event) => tooltip.toggle(point, event.currentTarget)} + > + {githubHeight > 0 ? ( + + ) : null} + + + + +
+ ); + })} +
+ )} + +
+ ); +} + +function ClientMix({ stats }: { stats: AdeUsageStats }) { + const clients = (stats.clients ?? []).filter((client) => client.interactions > 0); + const total = clients.reduce((sum, client) => sum + client.interactions, 0); + const Icon = ({ client }: { client: AdeUsageClientSurface }) => { + if (client === "mobile") return ; + if (client === "tui") return ; + if (client === "web") return ; + return ; + }; + if (clients.length === 0) { + return ; + } + return ( +
+
+ {clients.map((client) => ( + + ))} +
+
+ {clients.slice(0, 4).map((client) => ( +
+ + + + {CLIENT_LABELS[client.client]} + {Math.round((client.interactions / total) * 100)}% +
+ ))} +
+
+ ); +} + +function Legend({ items }: { items: Array<{ color: string; label: string }> }) { + return ( +
+ {items.map((item) => ( + + + {item.label} + + ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Empty + loading states +// --------------------------------------------------------------------------- + +function SkeletonChart({ height, bars }: { height: number; bars: number }) { + const heights = useMemo( + () => Array.from({ length: bars }, (_, index) => 0.3 + ((index * 37) % 60) / 100), + [bars], + ); + return ( +
+ {heights.map((fraction, index) => ( + + ))} +
+ ); +} + +function WarmEmpty({ height }: { height: number }) { + const heights = [0.35, 0.55, 0.4, 0.7, 0.5, 0.62, 0.44, 0.58, 0.48, 0.66, 0.4, 0.54]; + return ( +
+ +

+ Your activity will appear here after your first chat. +

+
+ ); +} + +// --------------------------------------------------------------------------- +// Controls +// --------------------------------------------------------------------------- + +function TabRow({ tab, onTabChange }: { tab: ActivityTab; onTabChange: (tab: ActivityTab) => void }) { + return ( +
+ {TABS.map((value) => { + const active = value === tab; + return ( + + ); + })} +
+ ); +} + +function RangeControl({ + preset, + onPresetChange, + variant, +}: { + preset: AdeUsageRangePreset; + onPresetChange: (preset: AdeUsageRangePreset) => void; + variant: "compact" | "full"; +}) { + if (variant === "compact") { + return ( + + ); + } + return ( +
+ {RANGE_OPTIONS.map((option) => { + const active = preset === option.preset; + return ( + + ); + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// Lifetime total / streak chip +// --------------------------------------------------------------------------- + +/** + * Shows the measured all-provider lifetime total on the all-time range. Other + * ranges retain the streak chip because their token total is range-scoped. + */ +function useFooterChip(stats: AdeUsageStats | null): { icon: "trophy" | "fire"; label: string; fresh: boolean } | null { + return useMemo(() => { + if (!stats) return null; + if (stats.range.preset === "all" && stats.summary.totalTokens > 0) { + return { + icon: "trophy", + label: `${formatTokens(stats.summary.totalTokens)} lifetime tokens`, + fresh: false, + }; + } + const streak = stats.summary.currentStreakDays ?? 0; + if (streak >= 3) return { icon: "fire", label: `${streak}-day streak`, fresh: false }; + return null; + }, [stats]); +} + +function FooterChip({ + chip, + reduced, +}: { + chip: { icon: "trophy" | "fire"; label: string; fresh: boolean }; + reduced: boolean; +}) { + const ref = useRef(null); + useEffect(() => { + if (!chip.fresh || reduced || !ref.current) return; + ref.current.animate?.( + [ + { opacity: 0.4, transform: "scale(0.94)" }, + { opacity: 1, transform: "scale(1.04)" }, + { opacity: 1, transform: "scale(1)" }, + ], + { duration: 620, easing: "ease-out" }, + ); + }, [chip.fresh, reduced]); + return ( + + {chip.icon === "trophy" ? : } + {chip.label} + + ); +} + +// --------------------------------------------------------------------------- +// Module +// --------------------------------------------------------------------------- + +function ariaSummary(stats: AdeUsageStats | null, preset: AdeUsageRangePreset): string { + const rangeLabel = RANGE_OPTIONS.find((option) => option.preset === preset)?.label ?? preset; + if (!stats) return `Activity summary for ${rangeLabel}. Loading.`; + return `Activity for ${rangeLabel}: ${formatTokens(stats.summary.totalTokens)} tokens, ${sessionsTotal(stats)} sessions, ${stats.summary.activeDays ?? 0} active days.`; +} + +export function ActivityModule({ + stats, + loading = false, + variant = "full", + preset, + onPresetChange, + showRangeControl = true, + className = "", +}: { + stats: AdeUsageStats | null; + loading?: boolean; + variant?: "compact" | "full"; + preset: AdeUsageRangePreset; + onPresetChange?: (preset: AdeUsageRangePreset) => void; + showRangeControl?: boolean; + className?: string; +}) { + const reduced = usePrefersReducedMotion(); + const [tab, setTab] = useState(() => readActivityPersisted().tab); + const cardRef = useRef(null); + const tooltip = useDayTooltip(cardRef); + const chip = useFooterChip(stats); + + const compactMode = variant === "compact"; + const chartHeight = compactMode ? 84 : 132; + const maxBars = compactMode ? 40 : 64; + const chartPoints = useChartPoints(stats?.daily ?? [], maxBars); + const hasActivity = (stats?.daily ?? []).some(dayHasActivity); + + const changeTab = useCallback((next: ActivityTab) => { + setTab(next); + tooltip.hide(); + persistActivityPatch({ tab: next }); + }, [tooltip]); + + const summary = stats?.summary; + const activeDays = summary?.activeDays; + + let chart: React.ReactNode; + if (loading && !stats) { + chart = ; + } else if (!stats) { + chart = ; + } else if (!hasActivity) { + chart = ; + } else if (tab === "activity") { + chart = ; + } else if (tab === "tokens") { + chart = ; + } else if (tab === "code") { + chart = ; + } else { + chart = ; + } + + return ( +
+
+ + {showRangeControl && onPresetChange ? ( + + ) : null} +
+ +
+ {chart} + {tooltip.tip ? : null} +
+ +
+ + {stats ? ( + <> + {formatTokens(stats.summary.totalTokens)} tokens + {" · "} + {compact(sessionsTotal(stats))} sessions + {activeDays != null ? ( + <> + {" · "} + {activeDays} active {activeDays === 1 ? "day" : "days"} + + ) : null} + + ) : loading ? ( + "Loading activity…" + ) : ( + "No activity yet" + )} + + {chip ? : null} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Self-fetching wrapper for the new-chat surface (compact variant) +// --------------------------------------------------------------------------- + +const workStatsCache = new Map(); + +export function WorkActivityModule() { + const projectRoot = useAppStore( + (state) => state.project?.rootPath ?? state.projectBinding?.rootPath ?? "project", + ); + const [preset, setPreset] = useState(() => readActivityPersisted().preset); + const cacheKey = `${projectRoot}:${preset}`; + const [stats, setStats] = useState(() => workStatsCache.get(cacheKey) ?? null); + const [loading, setLoading] = useState(!stats); + const requestRef = useRef(0); + + const load = useCallback(async (key: string, nextPreset: AdeUsageRangePreset) => { + const request = requestRef.current + 1; + requestRef.current = request; + try { + const result = await window.ade?.usage?.getAdeStats?.({ preset: nextPreset }); + if (!result || requestRef.current !== request) return; + workStatsCache.set(key, result); + setStats(result); + } catch { + // The composer stays available when stats are temporarily unavailable; + // keep the last successful snapshot rather than surfacing an error here. + } finally { + if (requestRef.current === request) setLoading(false); + } + }, []); + + useEffect(() => { + const cached = workStatsCache.get(cacheKey) ?? null; + setStats(cached); + setLoading(!cached); + void load(cacheKey, preset); + }, [cacheKey, load, preset]); + + // Pick up background ledger-refresh completions whenever they land, instead of + // a capped retry loop. + useEffect(() => { + const unsubscribe = window.ade?.usage?.onUpdate?.(() => { + void load(cacheKey, preset); + }); + return () => unsubscribe?.(); + }, [cacheKey, load, preset]); + + const changePreset = useCallback((next: AdeUsageRangePreset) => { + setPreset(next); + persistActivityPatch({ preset: next }); + }, []); + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx index 691cc6410..31dbc07b2 100644 --- a/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx +++ b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx @@ -113,13 +113,13 @@ function formatUpdatedAgo(snapshot: UsageSnapshot | null, nowMs: number): string } // A quiet, non-destructive warning: data is being shown, but the last refresh -// for one or more providers failed (stale) or has nothing yet (error). +// failed, needs re-authentication, or has nothing yet. function usageWarning(snapshot: UsageSnapshot | null): { warn: boolean; detail: string | null } { const statuses = snapshot?.providerStatus; if (!statuses) return { warn: false, detail: null }; const issues: string[] = []; for (const [provider, status] of Object.entries(statuses)) { - if (status && (status.state === "stale" || status.state === "error")) { + if (status && status.state !== "ok") { issues.push(status.message ?? `${PROVIDER_LABEL[provider as UsageProvider] ?? provider} unavailable`); } } diff --git a/apps/desktop/src/renderer/components/usage/UsageActivityCarousel.tsx b/apps/desktop/src/renderer/components/usage/UsageActivityCarousel.tsx deleted file mode 100644 index 67ec89c75..000000000 --- a/apps/desktop/src/renderer/components/usage/UsageActivityCarousel.tsx +++ /dev/null @@ -1,414 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - CaretLeft, - CaretRight, - DeviceMobile, - Globe, - Monitor, - TerminalWindow, -} from "@phosphor-icons/react"; -import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; -import type { - AdeUsageClientSurface, - AdeUsageDailyPoint, - AdeUsageRangePreset, - AdeUsageStats, -} from "../../../shared/types"; -import { formatTokens } from "../../lib/format"; -import { useAppStore } from "../../state/appStore"; - -const STORAGE_KEY = "ade.stats.carousel.v1"; -const CLIENT_COLORS: Record = { - desktop: "#8B7CFF", - mobile: "#F472B6", - tui: "#4ADE80", - web: "#38BDF8", - api: "#FBBF24", -}; -const CLIENT_LABELS: Record = { - desktop: "Desktop", - mobile: "Mobile", - tui: "ADE Code", - web: "Web", - api: "API", -}; -const SLIDES = ["activity", "tokens", "code", "clients"] as const; -type Slide = (typeof SLIDES)[number]; - -const RANGE_OPTIONS: Array<{ preset: AdeUsageRangePreset; label: string }> = [ - { preset: "today", label: "Day" }, - { preset: "7d", label: "Week" }, - { preset: "30d", label: "Month" }, - { preset: "year", label: "Year" }, -]; - -type PersistedState = { slide: Slide; preset: AdeUsageRangePreset }; - -function readPersistedState(): PersistedState { - if (typeof localStorage === "undefined") return { slide: "activity", preset: "7d" }; - try { - const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") as Partial; - return { - slide: SLIDES.includes(parsed.slide as Slide) ? parsed.slide as Slide : "activity", - preset: RANGE_OPTIONS.some((option) => option.preset === parsed.preset) ? parsed.preset! : "7d", - }; - } catch { - return { slide: "activity", preset: "7d" }; - } -} - -function persistState(value: PersistedState): void { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(value)); - } catch { - // Preferences are best-effort in hardened/private browser contexts. - } -} - -function compact(value: number): string { - if (!Number.isFinite(value) || value <= 0) return "0"; - if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(value >= 10_000_000_000 ? 0 : 1)}B`; - if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`; - if (value >= 1_000) return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}K`; - return Math.floor(value).toLocaleString(); -} - -function duration(value: number): string { - const minutes = Math.max(0, Math.round(value / 60_000)); - if (minutes < 60) return `${minutes}m`; - const hours = Math.floor(minutes / 60); - const remainder = minutes % 60; - return remainder ? `${hours}h ${remainder}m` : `${hours}h`; -} - -function dayValue(point: AdeUsageDailyPoint): number { - return point.totalTokens + point.sessions * 4_000 + (point.interactions ?? 0) * 1_500; -} - -function ChartEmpty({ loading, label }: { loading?: boolean; label: string }) { - return ( -
- {loading ? ( -
- {[0, 1, 2].map((index) => ( - - ))} -
- ) : label} -
- ); -} - -function ActivityHeatmap({ points, compactMode }: { points: AdeUsageDailyPoint[]; compactMode: boolean }) { - const cells = useMemo(() => { - const ordered = [...points].sort((a, b) => a.date.localeCompare(b.date)); - const max = Math.max(1, ...ordered.map(dayValue)); - return ordered.map((point) => ({ - point, - intensity: Math.max(0, Math.min(1, dayValue(point) / max)), - })); - }, [points]); - if (!cells.some(({ point }) => dayValue(point) > 0)) return ; - - return ( -
-
- {cells.map(({ point, intensity }, index) => ( - - ))} -
-
- {cells[0]?.point.date.slice(5)} - {compact(points.reduce((sum, point) => sum + point.sessions, 0))} sessions - {cells.at(-1)?.point.date.slice(5)} -
-
- ); -} - -function BarChart({ - points, - mode, -}: { - points: AdeUsageDailyPoint[]; - mode: "tokens" | "code"; -}) { - const chartPoints = useMemo(() => { - const source = [...points].sort((a, b) => a.date.localeCompare(b.date)); - const maxBars = 48; - if (source.length <= maxBars) return source; - const size = Math.ceil(source.length / maxBars); - const compacted: AdeUsageDailyPoint[] = []; - for (let index = 0; index < source.length; index += size) { - const chunk = source.slice(index, index + size); - const last = chunk.at(-1); - if (!last) continue; - compacted.push({ - date: last.date, - inputTokens: chunk.reduce((sum, point) => sum + point.inputTokens, 0), - outputTokens: chunk.reduce((sum, point) => sum + point.outputTokens, 0), - totalTokens: chunk.reduce((sum, point) => sum + point.totalTokens, 0), - commits: chunk.reduce((sum, point) => sum + point.commits, 0), - prs: chunk.reduce((sum, point) => sum + point.prs, 0), - insertions: chunk.reduce((sum, point) => sum + point.insertions, 0), - deletions: chunk.reduce((sum, point) => sum + point.deletions, 0), - filesChanged: chunk.reduce((sum, point) => sum + point.filesChanged, 0), - sessions: chunk.reduce((sum, point) => sum + point.sessions, 0), - durationMs: chunk.reduce((sum, point) => sum + (point.durationMs ?? 0), 0), - interactions: chunk.reduce((sum, point) => sum + (point.interactions ?? 0), 0), - }); - } - return compacted; - }, [points]); - const values = chartPoints.map((point) => mode === "tokens" ? point.totalTokens : point.insertions + point.deletions); - const max = Math.max(1, ...values); - if (!values.some((value) => value > 0)) return ; - - return ( -
- {chartPoints.map((point, index) => { - const primary = mode === "tokens" ? point.inputTokens : point.insertions; - const secondary = mode === "tokens" ? point.outputTokens : point.deletions; - const total = Math.max(0, primary + secondary); - const height = Math.max(total > 0 ? 3 : 0, (total / max) * 92); - const secondaryPct = total > 0 ? (secondary / total) * 100 : 0; - return ( - - - - ); - })} -
- ); -} - -function ClientMix({ stats }: { stats: AdeUsageStats }) { - const clients = (stats.clients ?? []).filter((client) => client.interactions > 0); - const total = clients.reduce((sum, client) => sum + client.interactions, 0); - if (!total) return ; - const Icon = ({ client }: { client: AdeUsageClientSurface }) => { - if (client === "mobile") return ; - if (client === "tui") return ; - if (client === "web") return ; - return ; - }; - return ( -
-
- {clients.map((client) => ( - - ))} -
-
- {clients.slice(0, 4).map((client) => ( -
- - {CLIENT_LABELS[client.client]} - {Math.round((client.interactions / total) * 100)}% -
- ))} -
-
- ); -} - -function slideTitle(slide: Slide): { title: string; detail: string } { - switch (slide) { - case "tokens": return { title: "AI token flow", detail: "Input and output by day" }; - case "code": return { title: "Code movement", detail: "Additions and deletions" }; - case "clients": return { title: "Where you use ADE", detail: "Desktop, mobile, terminal, and web" }; - default: return { title: "ADE activity", detail: "Your daily rhythm" }; - } -} - -export function UsageActivityCarousel({ - stats, - loading = false, - compactMode = false, - preset: controlledPreset, - onPresetChange, - className = "", -}: { - stats: AdeUsageStats | null; - loading?: boolean; - compactMode?: boolean; - preset?: AdeUsageRangePreset; - onPresetChange?: (preset: AdeUsageRangePreset) => void; - className?: string; -}) { - const reduceMotion = useReducedMotion(); - const initial = useMemo(readPersistedState, []); - const [slide, setSlide] = useState(initial.slide); - const [internalPreset, setInternalPreset] = useState(initial.preset); - const [direction, setDirection] = useState(1); - const preset = controlledPreset ?? internalPreset; - const title = slideTitle(slide); - const summary = stats?.summary; - - const changeSlide = useCallback((delta: number) => { - const current = SLIDES.indexOf(slide); - const next = SLIDES[(current + delta + SLIDES.length) % SLIDES.length]!; - setDirection(delta >= 0 ? 1 : -1); - setSlide(next); - persistState({ slide: next, preset }); - }, [preset, slide]); - - const changePreset = useCallback((next: AdeUsageRangePreset) => { - setInternalPreset(next); - persistState({ slide, preset: next }); - onPresetChange?.(next); - }, [onPresetChange, slide]); - - return ( -
-
-
-
-
{title.title}
-
{title.detail}
-
-
- {RANGE_OPTIONS.map((option) => ( - - ))} -
-
- -
- - - {!stats ? : null} - {stats && slide === "activity" ? : null} - {stats && slide === "tokens" ? : null} - {stats && slide === "code" ? : null} - {stats && slide === "clients" ? : null} - - -
- -
- -
- {formatTokens(summary?.totalTokens ?? 0)} tokens - {compact((summary?.chatSessions ?? 0) + (summary?.terminalSessions ?? 0))} sessions - {!compactMode ? {duration(summary?.trackedAdeDurationMs ?? 0)} active : null} -
- -
-
- ); -} - -const workStatsCache = new Map(); - -export function WorkUsageActivityCarousel() { - const projectRoot = useAppStore((state) => state.project?.rootPath ?? state.projectBinding?.rootPath ?? "project"); - const initial = useMemo(readPersistedState, []); - const [preset, setPreset] = useState(initial.preset); - const cacheKey = `${projectRoot}:${preset}`; - const [stats, setStats] = useState(() => workStatsCache.get(cacheKey) ?? null); - const [loading, setLoading] = useState(!stats); - const requestRef = useRef(0); - - useEffect(() => { - const request = requestRef.current + 1; - requestRef.current = request; - const cached = workStatsCache.get(cacheKey) ?? null; - setStats(cached); - setLoading(!cached); - let retry: number | null = null; - let retryCount = 0; - - const load = async () => { - try { - const result = await window.ade?.usage?.getAdeStats?.({ preset }); - if (!result || requestRef.current !== request) return; - workStatsCache.set(cacheKey, result); - setStats(result); - if (result.freshness?.state === "refreshing" && retryCount < 2) { - retryCount += 1; - retry = window.setTimeout(() => void load(), 2_800); - } - } catch { - // The composer stays available when remote stats are temporarily - // unavailable; retain the last successful snapshot if there is one. - } finally { - if (requestRef.current === request) setLoading(false); - } - }; - void load(); - return () => { - if (retry != null) window.clearTimeout(retry); - }; - }, [cacheKey, preset]); - - return ( - - ); -} diff --git a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx index d50d40243..dc82af159 100644 --- a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx +++ b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Gauge } from "@phosphor-icons/react"; +import { ArrowClockwise, Gauge } from "@phosphor-icons/react"; import type { AiProviderConnectionStatus, AiProviderConnections, @@ -15,10 +15,6 @@ import { providerChatAccent } from "../chat/chatSurfaceTheme"; import { ClaudeLogo, CodexLogo } from "../terminals/ToolLogos"; import { cn } from "../ui/cn"; -// How long cached numbers may sit before opening the popup quietly refreshes -// them in the background. A failed refresh never clears what's already shown. -const STALE_REFRESH_THRESHOLD_MS = 60_000; - const PROVIDER_ORDER: UsageProvider[] = ["claude", "codex"]; const PROVIDER_META: Record = { @@ -168,13 +164,21 @@ function ageMsFromIso(iso: string | undefined, nowMs: number): number { return nowMs - parsed; } -let lastPanelCostRefreshAttemptAtMs = Number.NEGATIVE_INFINITY; +function providerSourceLabel(status: UsageProviderStatus | null): string { + if (status?.source === "oauth") return "OAuth"; + if (status?.source === "http") return "HTTP"; + if (status?.source === "cli") return "CLI"; + return "Waiting"; +} -function shouldRefreshPanelSnapshot(snapshot: UsageSnapshot | null, nowMs: number): boolean { - if (!snapshot) return true; - if (ageMsFromIso(snapshot.lastPolledAt, nowMs) > STALE_REFRESH_THRESHOLD_MS) return true; - if (ageMsFromIso(snapshot.costsLastPolledAt, nowMs) <= STALE_REFRESH_THRESHOLD_MS) return false; - return nowMs - lastPanelCostRefreshAttemptAtMs > STALE_REFRESH_THRESHOLD_MS; +function providerUpdatedLabel(status: UsageProviderStatus | null, nowMs: number): string { + const updatedAt = status?.updatedAt ?? status?.lastSuccessAt; + const ageMs = ageMsFromIso(updatedAt ?? undefined, nowMs); + if (!Number.isFinite(ageMs)) return "not updated"; + if (ageMs < 60_000) return "just now"; + if (ageMs < 3_600_000) return `${Math.max(1, Math.floor(ageMs / 60_000))}m ago`; + if (ageMs < 86_400_000) return `${Math.floor(ageMs / 3_600_000)}h ago`; + return `${Math.floor(ageMs / 86_400_000)}d ago`; } function usePrefersReducedMotion(): boolean { @@ -192,14 +196,17 @@ function usePrefersReducedMotion(): boolean { export function UsageQuotaPanel({ className, onSnapshotChange, + showRefreshControl = false, }: { className?: string; onSnapshotChange?: (snapshot: UsageSnapshot | null) => void; + showRefreshControl?: boolean; }) { const [snapshot, setSnapshot] = useState(null); const [providerConnections, setProviderConnections] = useState(null); const [bridgeMissing, setBridgeMissing] = useState(false); const [nowMs, setNowMs] = useState(() => Date.now()); + const [refreshing, setRefreshing] = useState(false); const reducedMotion = usePrefersReducedMotion(); const onSnapshotChangeRef = useRef(onSnapshotChange); @@ -212,8 +219,9 @@ export function UsageQuotaPanel({ onSnapshotChangeRef.current?.(nextSnapshot); }, []); - // Render cached numbers instantly, subscribe to live updates, and only kick a - // background refresh if the data is stale. A failed refresh changes nothing. + // Render cached numbers instantly, subscribe to live updates, and tell the + // adaptive scheduler that this surface is active. A failed refresh changes + // nothing already shown. useEffect(() => { if (!window.ade?.usage) { setBridgeMissing(true); @@ -235,14 +243,11 @@ export function UsageQuotaPanel({ if (cancelled) return; if (current) applySnapshot(current); - if (shouldRefreshPanelSnapshot(current, Date.now())) { - try { - lastPanelCostRefreshAttemptAtMs = Date.now(); - const fresh = await bridge.refresh(); - if (!cancelled && fresh) applySnapshot(fresh); - } catch { - // Quiet — the service preserves last-good data and marks it stale. - } + try { + const demanded = await bridge.noteDemand?.(); + if (!cancelled && demanded) applySnapshot(demanded); + } catch { + // Quiet — demand only schedules a non-interactive provider refresh. } })(); @@ -252,6 +257,19 @@ export function UsageQuotaPanel({ }; }, [applySnapshot]); + const refreshNow = useCallback(async () => { + if (!window.ade?.usage?.refresh || refreshing) return; + setRefreshing(true); + try { + const next = await window.ade.usage.refresh(); + if (next) applySnapshot(next); + } catch { + // The service preserves last-good data and records the provider state. + } finally { + setRefreshing(false); + } + }, [applySnapshot, refreshing]); + useEffect(() => { let cancelled = false; if (!window.ade?.ai?.getStatus) return; @@ -299,6 +317,23 @@ export function UsageQuotaPanel({ return (
+ {showRefreshControl ? ( +
+
+
Live limits
+
Quota refresh never scans local history.
+
+ +
+ ) : null} {visibleProviders.length === 0 ? (
))}
@@ -494,6 +531,8 @@ function ProviderUsageCard({ dailyUsage7d, nowMs, reducedMotion, + refreshing, + onRefresh, }: { provider: UsageProvider; windows: UsageWindow[]; @@ -503,17 +542,32 @@ function ProviderUsageCard({ dailyUsage7d: number[] | null; nowMs: number; reducedMotion: boolean; + refreshing: boolean; + onRefresh: () => Promise; }) { const meta = PROVIDER_META[provider]; const isAuthed = connection?.authAvailable !== false; const isUsageUnauthed = status?.state === "unauthed"; - if (!isAuthed || isUsageUnauthed) { + if (windows.length === 0 && (!isAuthed || isUsageUnauthed)) { return ( -
-
+
+
- {status?.message ?? "Not signed in"} + + {providerSourceLabel(status)} · {providerUpdatedLabel(status, nowMs)} + +
+
+ {status?.message ?? "Not signed in"} +
); @@ -532,15 +586,29 @@ function ProviderUsageCard({ return (
-
+
- {status?.state === "stale" ? ( - - stale - - ) : null} + + {providerSourceLabel(status)} · {providerUpdatedLabel(status, nowMs)} +
+ {status && status.state !== "ok" ? ( +
+ + {status.message ?? (status.state === "stale" ? "Showing last known quota" : "Quota is unavailable")} + + +
+ ) : null} + {windows.length > 0 ? (
{messages.slice(0, 1).map((message) => ( diff --git a/apps/desktop/src/renderer/components/usage/providerColors.ts b/apps/desktop/src/renderer/components/usage/providerColors.ts new file mode 100644 index 000000000..2540cbce2 --- /dev/null +++ b/apps/desktop/src/renderer/components/usage/providerColors.ts @@ -0,0 +1,65 @@ +import type { ThemeId } from "../../state/appStore"; + +/** + * Brand-anchored provider colors for usage bars and legends in the Settings + * dashboard. Each provider carries a light-theme and dark-theme value tuned to + * sit legibly on a card background. + * + * Anthropic/Claude keeps its rust family; the rest are picked to stay distinct + * from one another without leaning on the generic blue/purple defaults. + */ +export type ProviderColorPair = { light: string; dark: string }; + +const PROVIDER_COLORS: Record = { + claude: { light: "#C15F3C", dark: "#D97757" }, + anthropic: { light: "#C15F3C", dark: "#D97757" }, + codex: { light: "#0F9E8E", dark: "#2DD4BF" }, + openai: { light: "#0F9E8E", dark: "#2DD4BF" }, + cursor: { light: "#52627A", dark: "#93A6C4" }, + "cursor-agent": { light: "#52627A", dark: "#93A6C4" }, + copilot: { light: "#2DA44E", dark: "#3FB950" }, + gemini: { light: "#2C6FE0", dark: "#5B93F5" }, + google: { light: "#2C6FE0", dark: "#5B93F5" }, + droid: { light: "#B45309", dark: "#E0A82E" }, + opencode: { light: "#7C5CE0", dark: "#A78BFA" }, + deepseek: { light: "#3A54D6", dark: "#6C86FF" }, + mistral: { light: "#E05A00", dark: "#FF7A1A" }, + ollama: { light: "#6B7280", dark: "#A1A1AA" }, + lmstudio: { light: "#6D28D9", dark: "#9F7BEA" }, + openrouter: { light: "#0284C7", dark: "#38BDF8" }, + openclaw: { light: "#B45309", dark: "#E0A82E" }, + xai: { light: "#3F3F46", dark: "#B4B4BD" }, +}; + +/** + * Deterministic fallback palette for providers without a brand token, so an + * unknown provider still gets a stable, distinct color instead of the default + * accent. Theme-aware pairs, indexed by a hash of the provider name. + */ +const FALLBACK_PALETTE: ProviderColorPair[] = [ + { light: "#2563EB", dark: "#60A5FA" }, + { light: "#0F766E", dark: "#2DD4BF" }, + { light: "#B45309", dark: "#E0A82E" }, + { light: "#7C5CE0", dark: "#A78BFA" }, + { light: "#BE185D", dark: "#F472B6" }, + { light: "#4D7C0F", dark: "#A3E635" }, +]; + +function hashIndex(value: string, modulo: number): number { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0; + } + return hash % modulo; +} + +function normalizeProvider(provider: string): string { + return provider.trim().toLowerCase(); +} + +/** Returns the theme-appropriate brand color for a provider. */ +export function providerColor(provider: string, theme: ThemeId = "dark"): string { + const key = normalizeProvider(provider); + const pair = PROVIDER_COLORS[key] ?? FALLBACK_PALETTE[hashIndex(key, FALLBACK_PALETTE.length)]!; + return theme === "light" ? pair.light : pair.dark; +} diff --git a/apps/desktop/src/renderer/components/usage/usage.test.tsx b/apps/desktop/src/renderer/components/usage/usage.test.tsx index 93a2c7145..99183b3bc 100644 --- a/apps/desktop/src/renderer/components/usage/usage.test.tsx +++ b/apps/desktop/src/renderer/components/usage/usage.test.tsx @@ -12,7 +12,8 @@ import type { UsageSnapshot, } from "../../../shared/types"; import { HeaderUsageControl } from "./HeaderUsageControl"; -import { UsageActivityCarousel } from "./UsageActivityCarousel"; +import { ActivityModule, WorkActivityModule, readActivityPersisted } from "./ActivityModule"; +import { AdeUsageSection } from "../settings/AdeUsageSection"; import { UsageQuotaPanel } from "./UsageQuotaPanel"; import { ADE_BROWSER_VIEW_OCCLUSION_END_EVENT, @@ -22,7 +23,7 @@ import { type UsageComponentTestBridge = { usage: Pick< Window["ade"]["usage"], - "getSnapshot" | "refresh" | "getBudgetConfig" | "saveBudgetConfig" | "onUpdate" + "getSnapshot" | "refresh" | "refreshHistory" | "noteDemand" | "getBudgetConfig" | "saveBudgetConfig" | "onUpdate" >; ai: Pick; }; @@ -88,6 +89,20 @@ function makeQuotaPanelSnapshot(): UsageSnapshot { resetsInHours: 24, }, }, + providerStatus: { + claude: { + state: "ok", + source: "oauth", + lastSuccessAt: "2026-05-08T07:00:00.000Z", + updatedAt: "2026-05-08T07:00:00.000Z", + }, + codex: { + state: "ok", + source: "http", + lastSuccessAt: "2026-05-08T07:00:00.000Z", + updatedAt: "2026-05-08T07:00:00.000Z", + }, + }, costs: [], extraUsage: [], lastPolledAt: "2026-05-08T07:00:00.000Z", @@ -95,15 +110,30 @@ function makeQuotaPanelSnapshot(): UsageSnapshot { }; } -function makeActivityStats(): AdeUsageStats { - return { +function makeActivityStats(overrides: Partial = {}): AdeUsageStats { + const base = { generatedAt: "2026-07-09T12:00:00.000Z", range: { preset: "7d", since: "2026-07-03T00:00:00.000Z", until: "2026-07-09T12:00:00.000Z" }, summary: { totalTokens: 3_000, + observedProviderInputTokens: 2_000, + observedProviderOutputTokens: 900, + observedProviderCachedTokens: 100, + observedProviderCostRangeUsd: 0.42, + observedProviderCostTodayUsd: 0.12, + observedProviderCost30dUsd: 1.4, chatSessions: 2, terminalSessions: 1, trackedAdeDurationMs: 3_600_000, + commitsCreated: 2, + prsTracked: 1, + prsMerged: 1, + prsOpen: 0, + insertions: 200, + deletions: 30, + filesChanged: 6, + activeDays: 2, + currentStreakDays: 0, }, providers: [], models: [], @@ -119,12 +149,29 @@ function makeActivityStats(): AdeUsageStats { { client: "mobile", interactions: 2, activeDays: 1, sessions: 1, lastActiveAt: "2026-07-09T11:00:00.000Z" }, ], daily: [ - { date: "2026-07-08", inputTokens: 1_000, outputTokens: 500, totalTokens: 1_500, commits: 1, prs: 0, insertions: 80, deletions: 10, filesChanged: 2, sessions: 1, interactions: 4 }, - { date: "2026-07-09", inputTokens: 1_000, outputTokens: 500, totalTokens: 1_500, commits: 1, prs: 1, insertions: 120, deletions: 20, filesChanged: 4, sessions: 2, interactions: 6 }, + { date: "2026-07-08", inputTokens: 1_000, outputTokens: 500, totalTokens: 1_500, cachedTokens: 200, commits: 1, prs: 0, insertions: 80, deletions: 10, filesChanged: 2, sessions: 1, interactions: 4 }, + { date: "2026-07-09", inputTokens: 1_000, outputTokens: 500, totalTokens: 1_500, cachedTokens: 300, commits: 1, prs: 1, insertions: 120, deletions: 20, filesChanged: 4, sessions: 2, interactions: 6 }, ], github: { repo: "ade/ADE", available: true, lastFetchedAt: "2026-07-09T12:00:00.000Z", error: null }, sourceNotes: [], - } as unknown as AdeUsageStats; + }; + return { ...base, ...overrides } as unknown as AdeUsageStats; +} + +function makeEmptyActivityStats(): AdeUsageStats { + return makeActivityStats({ + summary: { + totalTokens: 0, + chatSessions: 0, + terminalSessions: 0, + activeDays: 0, + currentStreakDays: 0, + }, + clients: [], + daily: [ + { date: "2026-07-08", inputTokens: 0, outputTokens: 0, totalTokens: 0, commits: 0, prs: 0, insertions: 0, deletions: 0, filesChanged: 0, sessions: 0, interactions: 0 }, + ], + } as unknown as Partial); } function makeHeaderUsageSnapshot(): UsageSnapshot { @@ -217,6 +264,8 @@ describe("usage components", () => { usage: { getSnapshot: vi.fn<[], Promise>(async () => snapshot), refresh: vi.fn<[], Promise>(async () => snapshot), + refreshHistory: vi.fn<[], Promise>(async () => snapshot), + noteDemand: vi.fn<[], Promise>(async () => snapshot), getBudgetConfig: vi.fn<[], Promise>(async () => ({})), saveBudgetConfig: vi.fn<[BudgetCapConfig], Promise>(async (config) => config), onUpdate: vi.fn<[(snapshot: UsageSnapshot) => void], () => void>(() => () => {}), @@ -257,6 +306,7 @@ describe("usage components", () => { ]; vi.mocked(window.ade.usage.getSnapshot).mockResolvedValue(snapshot); vi.mocked(window.ade.usage.refresh).mockResolvedValue(snapshot); + vi.mocked(window.ade.usage.noteDemand).mockResolvedValue(snapshot); render(); @@ -265,12 +315,13 @@ describe("usage components", () => { expect(await screen.findByText("44.0% used")).toBeTruthy(); }); - it("auto-refreshes once on mount so the drawer never shows stale data", async () => { + it("registers non-interactive quota demand on mount without forcing user auth", async () => { render(); await waitFor(() => { - expect(window.ade.usage.refresh).toHaveBeenCalledTimes(1); + expect(window.ade.usage.noteDemand).toHaveBeenCalledTimes(1); }); + expect(window.ade.usage.refresh).not.toHaveBeenCalled(); }); it("hides providers whose CLI is not detected on this machine", async () => { @@ -311,7 +362,21 @@ describe("usage components", () => { expect(screen.queryByText("No provider CLIs detected")).toBeNull(); }); - it("dims the provider card when the CLI is installed but not signed in", async () => { + it("shows an explicit reconnect state when the CLI is installed but not signed in", async () => { + const snapshot = makeQuotaPanelSnapshot(); + snapshot.windows = snapshot.windows.filter((window) => window.provider !== "claude"); + snapshot.providerStatus = { + ...snapshot.providerStatus, + claude: { + state: "unauthed", + source: "oauth", + lastSuccessAt: null, + updatedAt: null, + message: "Claude sign-in required — reconnect to refresh", + }, + }; + vi.mocked(window.ade.usage.getSnapshot).mockResolvedValue(snapshot); + vi.mocked(window.ade.usage.noteDemand).mockResolvedValue(snapshot); vi.mocked(window.ade.ai.getStatus).mockResolvedValue( makeAiStatus({ claude: makeProviderConnection("claude", { runtimeDetected: true, authAvailable: false }), @@ -320,7 +385,8 @@ describe("usage components", () => { render(); - expect(await screen.findByText("Not signed in")).toBeTruthy(); + expect(await screen.findByText(/Claude sign-in required/)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Reconnect" })).toBeTruthy(); expect(screen.queryByText("20.0% used")).toBeNull(); }); @@ -328,7 +394,7 @@ describe("usage components", () => { render(); await waitFor(() => { - expect(window.ade.usage.refresh).toHaveBeenCalled(); + expect(window.ade.usage.noteDemand).toHaveBeenCalled(); }); expect(screen.queryByText("Cursor")).toBeNull(); expect(screen.queryByText(/Cursor not detected/i)).toBeNull(); @@ -501,30 +567,257 @@ describe("usage components", () => { }); }); - describe("UsageActivityCarousel", () => { + describe("ActivityModule", () => { beforeEach(() => localStorage.clear()); - it("cycles through charts and persists the selected chart", async () => { - render(); - expect(screen.getByText("ADE activity")).toBeTruthy(); + it("defaults new activity views to the all-time range", () => { + expect(readActivityPersisted()).toEqual({ tab: "activity", preset: "all" }); + }); - fireEvent.click(screen.getByRole("button", { name: "Next activity chart" })); - await waitFor(() => expect(screen.getByText("AI token flow")).toBeTruthy()); - expect(JSON.parse(localStorage.getItem("ade.stats.carousel.v1") ?? "{}")).toMatchObject({ - slide: "tokens", - preset: "7d", - }); + it("switches tabs and persists the selection", async () => { + render(); + + expect(screen.getByRole("tab", { name: "Activity", selected: true })).toBeTruthy(); + fireEvent.click(screen.getByRole("tab", { name: "Tokens" })); + + await waitFor(() => expect(screen.getByRole("tab", { name: "Tokens", selected: true })).toBeTruthy()); + // The tokens chart shows its legend. + expect(screen.getByText("Input")).toBeTruthy(); + expect(screen.getByText("Output")).toBeTruthy(); + expect(JSON.parse(localStorage.getItem("ade.activity.module.v1") ?? "{}")).toMatchObject({ tab: "tokens" }); }); - it("switches among the day, week, month, and year contracts", () => { + it("uses the unified range vocabulary and reports changes", () => { const onPresetChange = vi.fn(); - render(); + render(); + + for (const label of ["Today", "7d", "30d", "Year", "All"]) { + expect(screen.getByRole("button", { name: label })).toBeTruthy(); + } + // Legacy Day/Week/Month labels are gone. + expect(screen.queryByRole("button", { name: "Week" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Month" })).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Year" })); expect(onPresetChange).toHaveBeenCalledWith("year"); - expect(screen.getByRole("button", { name: "Day" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Week" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Month" })).toBeTruthy(); + }); + + it("shows a day breakdown tooltip when a cell is tapped", () => { + const { container } = render(); + + const grid = container.querySelector('[aria-label="Daily activity heatmap"]'); + const cell = grid?.querySelector("span"); + expect(cell).toBeTruthy(); + fireEvent.click(cell as Element); + + const tooltip = screen.getByRole("tooltip"); + expect(tooltip.textContent).toContain("Jul 8"); + expect(tooltip.textContent).toContain("1.5K tokens"); + }); + + it("renders a warm empty state only when local and GitHub activity are empty", () => { + const { rerender } = render(); + expect(screen.getByText("Your activity will appear here after your first chat.")).toBeTruthy(); + + const githubOnly = makeEmptyActivityStats(); + githubOnly.daily = [{ + ...githubOnly.daily[0]!, + githubCommits: 1, + githubPrs: 1, + githubAdditions: 20, + githubDeletions: 3, + }]; + rerender(); + + expect(screen.queryByText("Your activity will appear here after your first chat.")).toBeNull(); + const activityCell = screen.getByRole("img", { name: "Daily activity heatmap" }).querySelector("span"); + expect((activityCell as HTMLElement).style.background).not.toContain("var(--color-fg) 7%"); + fireEvent.click(screen.getByRole("tab", { name: "Code" })); + expect(screen.getByRole("img", { name: "Code changes by day, additions and deletions" })).toBeTruthy(); + expect(screen.getByText("GitHub")).toBeTruthy(); + }); + + it("shows a streak chip once the streak reaches three days", () => { + render(); + expect(screen.getByText("5-day streak")).toBeTruthy(); + }); + + it("shows the measured all-provider lifetime total on the all-time range", () => { + const stats = makeActivityStats({ + range: { preset: "all", since: null, until: "2026-07-09T12:00:00.000Z" }, + summary: { ...makeActivityStats().summary, totalTokens: 101_500_000_000, currentStreakDays: 0 }, + }); + render(); + expect(screen.getByText("101.5B lifetime tokens")).toBeTruthy(); + }); + + it("renders bars without motion transitions when reduced motion is preferred", () => { + const matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query.includes("reduce"), + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + onchange: null, + dispatchEvent: vi.fn(), + })); + Object.defineProperty(window, "matchMedia", { writable: true, configurable: true, value: matchMedia }); + + const { container } = render(); + fireEvent.click(screen.getByRole("tab", { name: "Tokens" })); + + const bars = container.querySelectorAll('[role="img"] > div'); + expect(bars.length).toBeGreaterThan(0); + bars.forEach((bar) => expect((bar as HTMLElement).style.transition).toBe("")); + + Reflect.deleteProperty(window, "matchMedia"); + }); + + it("renders a compact range select and a full segmented control", () => { + const { unmount } = render(); + // Compact uses a narrow select rather than five buttons. + expect(screen.getByLabelText("Time range")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "30d" })).toBeNull(); + unmount(); + + render(); + expect(screen.getByRole("button", { name: "30d" })).toBeTruthy(); + }); + + it("scales the heatmap to fill the box: one row for short ranges, seven for long", () => { + const short = makeActivityStats(); + const { rerender, container } = render(); + const grid1 = container.querySelector('[aria-label="Daily activity heatmap"]')!; + expect(grid1.getAttribute("data-heatmap-rows")).toBe("1"); + expect(grid1.children.length).toBe(short.daily.length); + + const long = makeActivityStats({ + daily: Array.from({ length: 30 }, (_, i) => ({ + date: `2026-06-${String(i + 1).padStart(2, "0")}`, + inputTokens: 100 * i, outputTokens: 50 * i, totalTokens: 150 * i, cachedTokens: 0, + commits: 0, prs: 0, insertions: 0, deletions: 0, filesChanged: 0, sessions: 1, interactions: 1, + })), + } as unknown as Partial); + rerender(); + const grid2 = container.querySelector('[aria-label="Daily activity heatmap"]')!; + expect(grid2.getAttribute("data-heatmap-rows")).toBe("7"); + expect(grid2.children.length).toBe(30); + }); + + it("shows a per-tab hint when the active tab is empty but the module has data", () => { + const stats = makeActivityStats({ + daily: [ + { date: "2026-07-08", inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cachedTokens: 0, commits: 0, prs: 0, insertions: 0, deletions: 0, filesChanged: 0, sessions: 1, interactions: 2 }, + { date: "2026-07-09", inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cachedTokens: 0, commits: 0, prs: 0, insertions: 0, deletions: 0, filesChanged: 0, sessions: 1, interactions: 2 }, + ], + clients: [], + } as unknown as Partial); + render(); + + // Global warm-empty must NOT show — the module has token activity. + expect(screen.queryByText("Your activity will appear here after your first chat.")).toBeNull(); + + // Code tab: zeroed code series → hint, but the legend stays. + fireEvent.click(screen.getByRole("tab", { name: "Code" })); + expect(screen.getByText("No code changes in this range.")).toBeTruthy(); + expect(screen.getByText("Added")).toBeTruthy(); + expect(screen.getByText("Removed")).toBeTruthy(); + + // Clients tab: no client interactions → hint. + fireEvent.click(screen.getByRole("tab", { name: "Clients" })); + expect(screen.getByText("No client activity in this range.")).toBeTruthy(); + }); + }); + + describe("WorkActivityModule", () => { + let onUpdate: ((snapshot: UsageSnapshot) => void) | null; + + beforeEach(() => { + localStorage.clear(); + onUpdate = null; + Object.assign(window.ade, { + usage: { + ...window.ade.usage, + getAdeStats: vi.fn(async () => makeActivityStats()), + onUpdate: vi.fn((cb: (snapshot: UsageSnapshot) => void) => { + onUpdate = cb; + return () => {}; + }), + }, + }); + }); + + it("re-fetches stats when a background usage update lands", async () => { + const getAdeStats = vi.mocked(window.ade.usage.getAdeStats); + getAdeStats.mockResolvedValueOnce(makeActivityStats({ summary: { ...makeActivityStats().summary, totalTokens: 3_000 } })); + + render(); + expect(await screen.findByText(/3\.0K/)).toBeTruthy(); + + getAdeStats.mockResolvedValue(makeActivityStats({ summary: { ...makeActivityStats().summary, totalTokens: 9_000_000 } })); + await act(async () => { + onUpdate?.(makeEmptySnapshot()); + }); + + expect(await screen.findByText(/9\.0M/)).toBeTruthy(); + }); + + it("requests all-time stats by default", async () => { + render(); + + await waitFor(() => { + expect(window.ade.usage.getAdeStats).toHaveBeenCalledWith({ preset: "all" }); + }); + }); + }); + + describe("AdeUsageSection", () => { + let getAdeStats: ReturnType; + + beforeEach(() => { + localStorage.clear(); + getAdeStats = vi.fn(async () => makeActivityStats()); + Object.assign(window.ade, { + usage: { + ...window.ade.usage, + getAdeStats, + refresh: vi.fn(async () => makeEmptySnapshot()), + onUpdate: vi.fn(() => () => {}), + }, + app: { + getProject: vi.fn(async () => ({ rootPath: "/tmp/project" })), + onProjectChanged: vi.fn(() => () => {}), + }, + }); + }); + + it("round-trips the scope toggle through getAdeStats and persists it", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Activity" })); + + await waitFor(() => expect(getAdeStats).toHaveBeenCalledWith({ preset: "all", scope: "project" })); + + fireEvent.click(screen.getByRole("button", { name: "This machine" })); + + await waitFor(() => expect(getAdeStats).toHaveBeenCalledWith({ preset: "all", scope: "machine" })); + expect(localStorage.getItem("ade.stats.scope.v1")).toBe("machine"); + }); + + it("composes a meta footer from freshness, estimation, and scope", async () => { + getAdeStats.mockResolvedValue(makeActivityStats({ + freshness: { state: "fresh", providerUpdatedAt: new Date(Date.now() - 120_000).toISOString(), githubUpdatedAt: null }, + providers: [ + { provider: "cursor", inputTokens: 10, outputTokens: 5, cachedTokens: 0, totalTokens: 15, rangeCostUsd: 0, todayCostUsd: 0, last30dCostUsd: 0, estimation: "chars" }, + ], + })); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Activity" })); + + expect(await screen.findByText(/Cursor tokens estimated/)).toBeTruthy(); + expect(screen.getByText(/Provider totals scoped to this project/)).toBeTruthy(); + expect(screen.getByText(/Updated .* ago/)).toBeTruthy(); }); }); }); diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index d40b26246..d4cd0f3f5 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -709,6 +709,8 @@ export const IPC = { usageGetAdeStats: "ade.usage.getAdeStats", usageGetSnapshot: "ade.usage.getSnapshot", usageRefresh: "ade.usage.refresh", + usageRefreshHistory: "ade.usage.refreshHistory", + usageNoteDemand: "ade.usage.noteDemand", usageCheckBudget: "ade.usage.checkBudget", usageGetCumulativeUsage: "ade.usage.getCumulativeUsage", usageGetBudgetConfig: "ade.usage.getBudgetConfig", diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index fb1a1010f..8a1505b42 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -948,6 +948,8 @@ export type SyncSendToSessionResult = PtySendToSessionResult; export type SyncRemoteCommandAction = | "usage.getAdeStats" + | "usage.getQuotaSnapshot" + | "usage.refreshQuota" | PersonalChatRemoteCommandAction | "lanes.list" | "lanes.listDeleteProgress" diff --git a/apps/desktop/src/shared/types/usage.ts b/apps/desktop/src/shared/types/usage.ts index 8143e683f..fa1ee144e 100644 --- a/apps/desktop/src/shared/types/usage.ts +++ b/apps/desktop/src/shared/types/usage.ts @@ -63,10 +63,35 @@ export function isAdeUsageRangePreset(value: unknown): value is AdeUsageRangePre export type AdeUsageClientSurface = "desktop" | "mobile" | "tui" | "web" | "api"; +/** + * Scope of provider-ledger metrics. + * - `machine` — every session found in the provider's local ledgers (codeburn-comparable). + * - `project` — only sessions attributable to the current project root (cwd match). + * GitHub and ADE-DB metrics are always project/repo scoped regardless of this value. + */ +export const ADE_USAGE_SCOPES = ["machine", "project"] as const; + +export type AdeUsageScope = (typeof ADE_USAGE_SCOPES)[number]; + +export function isAdeUsageScope(value: unknown): value is AdeUsageScope { + return typeof value === "string" && (ADE_USAGE_SCOPES as readonly string[]).includes(value); +} + +/** + * How a provider's token counts were obtained. + * - `exact` — provider-recorded token counts. + * - `chars` — estimated from character length (chars/4). + * - `distribution` — real session totals spread across calls/days (per-day values synthetic). + * - `mixed` — some entries exact, some estimated. + */ +export type AdeUsageEstimationKind = "exact" | "chars" | "distribution" | "mixed"; + export type GetAdeUsageStatsArgs = { preset?: AdeUsageRangePreset; since?: string | null; until?: string | null; + /** Defaults to "machine" (legacy behavior). */ + scope?: AdeUsageScope; }; export type AdeUsageProviderSummary = { @@ -78,6 +103,14 @@ export type AdeUsageProviderSummary = { rangeCostUsd: number; todayCostUsd: number; last30dCostUsd: number; + /** Omitted/`exact` when counts are provider-recorded. */ + estimation?: AdeUsageEstimationKind; + /** False when this provider's ledger cannot be filtered to a project (machine-only). */ + scopeSupported?: boolean; + /** Tokens attributed to ADE-originated sessions (subset of totalTokens). */ + adeOriginatedTokens?: number; + /** Tokens from sessions launched outside ADE (subset of totalTokens). */ + externalTokens?: number; }; export type AdeUsageModelSummary = { @@ -138,10 +171,14 @@ export type AdeUsageClientSummary = { }; export type AdeUsageDailyPoint = { + /** Local calendar day (YYYY-MM-DD in the machine's timezone). */ date: string; inputTokens: number; outputTokens: number; totalTokens: number; + /** Cache-read tokens for the day (provider ledgers). */ + cachedTokens?: number; + /** Local (ADE DB / git operations) measures — never merged with GitHub values. */ commits: number; prs: number; insertions: number; @@ -151,10 +188,38 @@ export type AdeUsageDailyPoint = { durationMs?: number; interactions?: number; clients?: Partial>; + /** GitHub-reported measures for the same day, kept separate from local ones. */ + githubCommits?: number; + githubPrs?: number; + githubAdditions?: number; + githubDeletions?: number; +}; + +/** GitHub-scoped activity, reported separately from local activity (never max-merged). */ +export type AdeUsageGithubActivity = { + commits: number; + prsTracked: number; + prsOpen: number; + prsMerged: number; + prsClosed: number; + prAdditions: number; + prDeletions: number; +}; + +/** Current-project ADE DB / git-operation activity, reported separately from GitHub. */ +export type AdeUsageLocalActivity = { + commits: number; + pushOperations: number; + prLandings: number; + filesChanged: number; + insertions: number; + deletions: number; }; export type AdeUsageStats = { generatedAt: string; + /** Scope the provider-ledger metrics were computed at. Absent = machine (legacy hosts). */ + scope?: AdeUsageScope; range: { preset: AdeUsageRangePreset; since: string | null; @@ -213,6 +278,12 @@ export type AdeUsageStats = { longestStreakDays?: number; longestSessionMs?: number; }; + /** + * GitHub vs local activity as separate labeled groups. When present, the UI + * must prefer these over the legacy max-merged summary fields. + */ + githubActivity?: AdeUsageGithubActivity; + localActivity?: AdeUsageLocalActivity; providers: AdeUsageProviderSummary[]; models: AdeUsageModelSummary[]; adeProviders?: AdeUsageProviderSummary[]; @@ -299,10 +370,33 @@ export type UsagePacingByProvider = Partial>; */ export type UsageProviderState = "ok" | "stale" | "unauthed" | "error"; +export type UsageProviderSource = "oauth" | "http" | "cli"; + +export type UsageProviderErrorKind = + | "auth" + | "forbidden" + | "conflict" + | "rate_limited" + | "timeout" + | "network" + | "invalid_response" + | "unavailable" + | "unknown"; + export type UsageProviderStatus = { state: UsageProviderState; /** ISO timestamp of the last poll that returned real windows, if any. */ lastSuccessAt: string | null; + /** Provider source that produced the displayed windows. */ + source?: UsageProviderSource; + /** ISO timestamp of the data currently being displayed. */ + updatedAt?: string | null; + /** ISO timestamp of the most recent attempted refresh. */ + lastAttemptAt?: string | null; + /** Typed failure classification for stale/error/unauthed states. */ + errorKind?: UsageProviderErrorKind; + /** ISO timestamp before which automatic refresh should remain backed off. */ + nextRetryAt?: string | null; /** Friendly, log-free reason for a non-ok state (e.g. "Couldn't reach Claude"). */ message?: string; }; @@ -326,6 +420,12 @@ export type CostSnapshot = { tokenBreakdownByPreset?: Partial>>; dailyTokenBreakdownByPreset?: Partial>>>; dailyTokensByPreset?: Partial>>; + /** How this provider's token counts were obtained. Omitted = exact. */ + estimation?: AdeUsageEstimationKind; + /** False when this ledger cannot be filtered to a project scope. */ + scopeSupported?: boolean; + /** Tokens attributed to ADE-originated sessions, by preset. */ + adeOriginatedTokensByPreset?: Partial>; }; export type ExtraUsage = { diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index edcc0af8b..66102762c 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ 1A0D93D06509796A389A4CAA /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 31EC445F22FD38F90C16343E /* Foundation.framework */; }; 26C8992F8D661707E9360503 /* Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51942BAC0965C7D0CCE6E8B8 /* Database.swift */; }; 28CFE3D489EA1B208D231519 /* SyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66B5024B0A05F3D9754101F1 /* SyncService.swift */; }; + A11700000000000000000002 /* MobileUsageQuotaStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11700000000000000000001 /* MobileUsageQuotaStore.swift */; }; 4DC17C7C8E82D387043B01FD /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73B17472FAC08845853BC6B4 /* ContentView.swift */; }; B2B52EED21B04E9700000002 /* RemoteProjectAddSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B52EED21B04E9700000001 /* RemoteProjectAddSheet.swift */; }; 56223CC3AF5A01B710CDC4CF /* WorkTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6ECFA9D57E70E57A60E8AB /* WorkTabView.swift */; }; @@ -402,6 +403,7 @@ 51942BAC0965C7D0CCE6E8B8 /* Database.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Database.swift; path = ADE/Services/Database.swift; sourceTree = ""; }; 5EE4D463D21266B62B422D11 /* PRsTabView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PRsTabView.swift; path = ADE/Views/PRsTabView.swift; sourceTree = ""; }; 66B5024B0A05F3D9754101F1 /* SyncService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncService.swift; path = ADE/Services/SyncService.swift; sourceTree = ""; }; + A11700000000000000000001 /* MobileUsageQuotaStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MobileUsageQuotaStore.swift; path = ADE/Services/MobileUsageQuotaStore.swift; sourceTree = ""; }; 73B17472FAC08845853BC6B4 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ContentView.swift; path = ADE/App/ContentView.swift; sourceTree = ""; }; B2B52EED21B04E9700000001 /* RemoteProjectAddSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RemoteProjectAddSheet.swift; path = ADE/App/RemoteProjectAddSheet.swift; sourceTree = ""; }; 8943C47805A871A4E4A4BF68 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = ADE/Assets.xcassets; sourceTree = ""; }; @@ -786,6 +788,7 @@ 51942BAC0965C7D0CCE6E8B8 /* Database.swift */, B5D5B5B87564C73F2FF34B0D /* KeychainService.swift */, 66B5024B0A05F3D9754101F1 /* SyncService.swift */, + A11700000000000000000001 /* MobileUsageQuotaStore.swift */, AE00000000000000000000A2 /* PushNotificationService.swift */, AE00000000000000000000A3 /* LiveActivityService.swift */, AF00000000000000000000A1 /* DpopKeyService.swift */, @@ -1203,6 +1206,7 @@ 26C8992F8D661707E9360503 /* Database.swift in Sources */, 0375D32BA5870617FA1758C6 /* KeychainService.swift in Sources */, 28CFE3D489EA1B208D231519 /* SyncService.swift in Sources */, + A11700000000000000000002 /* MobileUsageQuotaStore.swift in Sources */, 6BDC22C6450AF0B3CBDB2650 /* FilesTabView.swift in Sources */, E20000000000000000000042 /* FilesModels.swift in Sources */, E20000000000000000000043 /* FilesHelpers.swift in Sources */, diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 6e3753b7b..6cdfb33cf 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -4403,6 +4403,8 @@ struct MobileAdeUsageDailyPoint: Codable, Equatable, Identifiable { var inputTokens: Int? var outputTokens: Int? var totalTokens: Int? + /// Cache-read tokens for the day (provider ledgers). Absent on older hosts. + var cachedTokens: Int? var insertions: Int? var deletions: Int? var filesChanged: Int? @@ -4410,6 +4412,11 @@ struct MobileAdeUsageDailyPoint: Codable, Equatable, Identifiable { var durationMs: Int? var interactions: Int? var clients: [String: Int]? + /// GitHub-reported measures for the same day, kept separate from local ones. + var githubCommits: Int? + var githubPrs: Int? + var githubAdditions: Int? + var githubDeletions: Int? } struct MobileAdeUsageClientSummary: Codable, Equatable, Identifiable { @@ -4427,10 +4434,109 @@ struct MobileAdeUsageFreshness: Codable, Equatable { var githubUpdatedAt: String? } -struct MobileAdeUsageStats: Codable, Equatable { +/// GitHub-scoped activity, reported separately from local activity (never +/// max-merged). All optional so older hosts still decode. +struct MobileAdeUsageGithubActivity: Codable, Equatable { + var commits: Int? + var prsTracked: Int? + var prsOpen: Int? + var prsMerged: Int? + var prsClosed: Int? + var prAdditions: Int? + var prDeletions: Int? +} + +/// Current-project ADE DB / git-operation activity, reported separately from +/// GitHub. All optional so older hosts still decode. +struct MobileAdeUsageLocalActivity: Codable, Equatable { + var commits: Int? + var pushOperations: Int? + var prLandings: Int? + var filesChanged: Int? + var insertions: Int? + var deletions: Int? +} + +/// Per-provider ledger summary. Rendered on richer surfaces; decoded here so the +/// mobile model stays a faithful, forward-compatible view of the shared contract. +struct MobileAdeUsageProviderSummary: Codable, Equatable, Identifiable { + var id: String { provider } + var provider: String + var inputTokens: Int? + var outputTokens: Int? + var cachedTokens: Int? + var totalTokens: Int? + var rangeCostUsd: Double? + /// "exact" | "chars" | "distribution" | "mixed" — omitted means exact. + var estimation: String? + /// False when this provider's ledger cannot be filtered to a project. + var scopeSupported: Bool? + var adeOriginatedTokens: Int? + var externalTokens: Int? +} + +struct MobileAdeUsageStats: Decodable, Equatable { var generatedAt: String + /// Scope the provider metrics were computed at ("machine" | "project"). Absent + /// on legacy hosts. + var scope: String? var summary: MobileAdeUsageSummary var clients: [MobileAdeUsageClientSummary]? var daily: [MobileAdeUsageDailyPoint] var freshness: MobileAdeUsageFreshness? + var githubActivity: MobileAdeUsageGithubActivity? + var localActivity: MobileAdeUsageLocalActivity? + var providers: [MobileAdeUsageProviderSummary]? +} + +extension MobileAdeUsageStats { + private enum CodingKeys: String, CodingKey { + case generatedAt, scope, summary, clients, daily, freshness + case githubActivity, localActivity, providers + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + generatedAt = try container.decode(String.self, forKey: .generatedAt) + scope = try container.decodeIfPresent(String.self, forKey: .scope) + summary = try container.decode(MobileAdeUsageSummary.self, forKey: .summary) + clients = try container.decodeIfPresent([MobileAdeUsageClientSummary].self, forKey: .clients) + daily = try container.decodeIfPresent([MobileAdeUsageDailyPoint].self, forKey: .daily) ?? [] + freshness = try container.decodeIfPresent(MobileAdeUsageFreshness.self, forKey: .freshness) + githubActivity = try container.decodeIfPresent(MobileAdeUsageGithubActivity.self, forKey: .githubActivity) + localActivity = try container.decodeIfPresent(MobileAdeUsageLocalActivity.self, forKey: .localActivity) + // Lossy-decode the providers array so one malformed provider entry can't drop + // the whole stats payload (mirrors ExternalSessionSummary's sessions decode). + providers = (try? container.decode(ADELossyArray.self, forKey: .providers))?.wrappedValue + } +} + +// MARK: - Live provider quota + +struct MobileUsageQuotaWindow: Codable, Equatable, Identifiable { + var id: String { "\(provider):\(windowType):\(resetsAt):\(percentUsed)" } + var provider: String + var windowType: String + var percentUsed: Double + var resetsAt: String + var resetsInMs: Double + var windowDurationMs: Double? +} + +struct MobileUsageProviderStatus: Codable, Equatable { + var state: String + var lastSuccessAt: String? + var source: String? + var updatedAt: String? + var lastAttemptAt: String? + var errorKind: String? + var nextRetryAt: String? + var message: String? +} + +struct MobileUsageQuotaSnapshot: Codable, Equatable { + var windows: [MobileUsageQuotaWindow] + var providerStatus: [String: MobileUsageProviderStatus]? + var lastPolledAt: String + var errors: [String] } diff --git a/apps/ios/ADE/Services/MobileUsageQuotaStore.swift b/apps/ios/ADE/Services/MobileUsageQuotaStore.swift new file mode 100644 index 000000000..da976f0c0 --- /dev/null +++ b/apps/ios/ADE/Services/MobileUsageQuotaStore.swift @@ -0,0 +1,86 @@ +import Combine +import Foundation + +@MainActor +final class MobileUsageQuotaStore: ObservableObject { + static let shared = MobileUsageQuotaStore() + + @Published private(set) var snapshot: MobileUsageQuotaSnapshot? + @Published private(set) var refreshing = false + @Published private(set) var errorMessage: String? + + private let cacheKeyPrefix = "ade.mobile.usageQuota.v2" + private var loadedHostIdentity: String? + private var loadGeneration = 0 + + private init() {} + + func load(using syncService: SyncService, refresh: Bool = false) async { + guard let hostIdentity = currentHostIdentity(syncService) else { + loadGeneration += 1 + bind(to: nil) + refreshing = false + return + } + let hostChanged = loadedHostIdentity != hostIdentity + if hostChanged { + bind(to: hostIdentity) + } + let action = refresh ? "usage.refreshQuota" : "usage.getQuotaSnapshot" + guard syncService.supportsRemoteAction(action) else { + loadGeneration += 1 + snapshot = nil + errorMessage = "Update ADE on this machine to view live usage limits." + refreshing = false + return + } + if !refresh && refreshing && !hostChanged { return } + loadGeneration += 1 + let generation = loadGeneration + if refresh { refreshing = true } + defer { + if generation == loadGeneration { refreshing = false } + } + do { + let next = try await syncService.fetchUsageQuotaSnapshot(refresh: refresh) + guard generation == loadGeneration, + currentHostIdentity(syncService) == hostIdentity else { return } + snapshot = next + errorMessage = nil + if let data = try? JSONEncoder().encode(next) { + UserDefaults.standard.set(data, forKey: cacheKey(for: hostIdentity)) + } + } catch { + guard generation == loadGeneration, + currentHostIdentity(syncService) == hostIdentity else { return } + errorMessage = error.localizedDescription + } + } + + private func bind(to hostIdentity: String?) { + loadedHostIdentity = hostIdentity + errorMessage = nil + guard let hostIdentity, + let data = UserDefaults.standard.data(forKey: cacheKey(for: hostIdentity)), + let decoded = try? JSONDecoder().decode(MobileUsageQuotaSnapshot.self, from: data) else { + snapshot = nil + return + } + snapshot = decoded + } + + private func currentHostIdentity(_ syncService: SyncService) -> String? { + for value in [ + syncService.activeHostProfile?.hostIdentity, + syncService.activeHostProfile?.lastHostDeviceId, + ] { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmed.isEmpty { return trimmed } + } + return nil + } + + private func cacheKey(for hostIdentity: String) -> String { + "\(cacheKeyPrefix).\(hostIdentity)" + } +} diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 0159fdafa..d7844a80b 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -10560,6 +10560,26 @@ final class SyncService: ObservableObject { ) } + func fetchUsageQuotaSnapshot(refresh: Bool = false) async throws -> MobileUsageQuotaSnapshot { + let action = refresh ? "usage.refreshQuota" : "usage.getQuotaSnapshot" + guard supportsRemoteAction(action) else { + throw NSError( + domain: "ADE", + code: 17, + userInfo: [ + NSLocalizedDescriptionKey: "Live usage limits are not available on this machine version. Update ADE on the machine and reconnect.", + "ADEErrorCode": "unsupported_action", + ] + ) + } + return try await sendDecodableCommand( + action: action, + disconnectOnTimeout: false, + timeoutNanoseconds: refresh ? 22_000_000_000 : 8_000_000_000, + as: MobileUsageQuotaSnapshot.self + ) + } + private func sendDecodableCommand( action: String, args: [String: Any] = [:], diff --git a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift index 808f68c4d..f648b71ab 100644 --- a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift +++ b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift @@ -55,6 +55,9 @@ struct ConnectionSettingsView: View { SettingsAppearanceSection() .padding(.horizontal, 16) + SettingsUsageQuotaSection(syncService: syncService) + .padding(.horizontal, 16) + SettingsVoiceInputSection() .padding(.horizontal, 16) @@ -382,6 +385,144 @@ private final class SettingsConnectionPresentationModel: ObservableObject { } } +private struct SettingsUsageQuotaSection: View { + let syncService: SyncService + + @ObservedObject private var store = MobileUsageQuotaStore.shared + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline) { + SettingsSectionHeader(label: "AI USAGE", hint: "Live limits from your machine") + Spacer(minLength: 8) + Button { + Task { await store.load(using: syncService, refresh: true) } + } label: { + if store.refreshing { + ProgressView().controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + } + } + .frame(width: 44, height: 44) + .buttonStyle(.plain) + .accessibilityLabel("Refresh AI usage limits") + .disabled(!syncService.supportsRemoteAction("usage.refreshQuota") || store.refreshing) + } + + VStack(spacing: 0) { + if let snapshot = store.snapshot { + ForEach(["claude", "codex"], id: \.self) { provider in + SettingsUsageProviderCard( + provider: provider, + windows: snapshot.windows.filter { $0.provider == provider }, + status: snapshot.providerStatus?[provider] + ) + if provider == "claude" { Divider().opacity(0.14) } + } + } else { + Text("Pair with an updated ADE machine to load Claude and Codex limits.") + .font(.footnote) + .foregroundStyle(ADEColor.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + } + } + .background(ADEColor.surfaceBackground.opacity(0.82), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay { RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(ADEColor.glassBorder, lineWidth: 0.6) } + + if let error = store.errorMessage { + Text(error) + .font(.caption2) + .foregroundStyle(ADEColor.warning) + } + } + .task(id: syncService.connectionState.rawValue) { + await store.load(using: syncService) + } + } +} + +private struct SettingsUsageProviderCard: View { + let provider: String + let windows: [MobileUsageQuotaWindow] + let status: MobileUsageProviderStatus? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(provider == "claude" ? "Claude" : "Codex") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Spacer() + Text("\((status?.source ?? "waiting").uppercased()) · \(mobileUsageSettingsRelativeTime(status?.updatedAt ?? status?.lastSuccessAt))") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(ADEColor.textMuted) + } + + ForEach(windows) { window in + VStack(alignment: .leading, spacing: 5) { + HStack { + Text(mobileUsageWindowLabel(window.windowType)) + Spacer() + Text("\(Int(max(0, 100 - window.percentUsed)))% left") + .fontWeight(.semibold) + } + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + + ProgressView(value: max(0, min(100, 100 - window.percentUsed)), total: 100) + .tint(provider == "claude" ? ADEColor.warning : ADEColor.purpleAccent) + + Text("Resets \(mobileUsageSettingsResetLabel(window.resetsAt))") + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + } + } + + if let message = status?.message, status?.state != "ok" { + Text(message) + .font(.caption2) + .foregroundStyle(ADEColor.warning) + } + } + .padding(14) + .accessibilityElement(children: .contain) + } +} + +private func mobileUsageWindowLabel(_ value: String) -> String { + switch value { + case "five_hour": return "5-hour" + case "weekly": return "Weekly" + case "monthly": return "Monthly" + case "weekly_oauth_apps": return "OAuth apps" + case "weekly_cowork": return "Cowork" + default: return value.replacingOccurrences(of: "_", with: " ").capitalized + } +} + +private func mobileUsageSettingsDate(_ iso: String?) -> Date? { + guard let iso else { return nil } + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return fractional.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) +} + +private func mobileUsageSettingsRelativeTime(_ iso: String?) -> String { + guard let date = mobileUsageSettingsDate(iso) else { return "not updated" } + let seconds = max(0, Int(Date().timeIntervalSince(date))) + if seconds < 60 { return "now" } + if seconds < 3_600 { return "\(seconds / 60)m ago" } + if seconds < 86_400 { return "\(seconds / 3_600)h ago" } + return "\(seconds / 86_400)d ago" +} + +private func mobileUsageSettingsResetLabel(_ iso: String) -> String { + guard let date = mobileUsageSettingsDate(iso) else { return "soon" } + return date.formatted(date: .abbreviated, time: .shortened) +} + private struct SettingsAuroraBackground: View { var body: some View { ZStack { diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index 33b69de8c..dc75ad3fd 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -661,10 +661,10 @@ struct WorkNewChatScreen: View { var body: some View { VStack(spacing: 0) { - Spacer(minLength: 24) + Spacer(minLength: 8) ScrollView { - VStack(spacing: 18) { + VStack(spacing: 14) { brandMark VStack(spacing: 6) { Text("Start a new conversation") @@ -685,6 +685,9 @@ struct WorkNewChatScreen: View { } .scrollBounceBehavior(.basedOnSize) .scrollDismissesKeyboard(.interactively) + .refreshable { + await MobileUsageQuotaStore.shared.load(using: syncService, refresh: true) + } // Kept outside the scroll view so lane selection can scroll away while // the activity card stays pinned immediately above the composer. The @@ -814,9 +817,8 @@ struct WorkNewChatScreen: View { .renderingMode(.original) .interpolation(.high) .aspectRatio(contentMode: .fit) - .frame(maxWidth: 260) - .shadow(color: ADEColor.purpleAccent.opacity(0.45), radius: 22) - .padding(.top, 8) + .frame(maxWidth: 240) + .padding(.top, 0) .accessibilityLabel("ADE") } diff --git a/apps/ios/ADE/Views/Work/WorkUsageActivityCarousel.swift b/apps/ios/ADE/Views/Work/WorkUsageActivityCarousel.swift index 110e58478..3be6e6711 100644 --- a/apps/ios/ADE/Views/Work/WorkUsageActivityCarousel.swift +++ b/apps/ios/ADE/Views/Work/WorkUsageActivityCarousel.swift @@ -1,62 +1,99 @@ import Foundation import SwiftUI +// The struct name is kept for source stability (referenced from WorkNewChatScreen +// and the Xcode project), but this is the tabbed activity module that matches the +// desktop redesign — named tabs, a unified range control, split token/code bars, +// tap tooltips, a warm empty state, exact lifetime totals, and live limits. + private enum WorkUsageRange: String, CaseIterable, Identifiable { - case day = "today" + case today case week = "7d" case month = "30d" case year + case all var id: String { rawValue } var title: String { switch self { - case .day: return "Day" - case .week: return "Week" - case .month: return "Month" + case .today: return "Today" + case .week: return "7d" + case .month: return "30d" case .year: return "Year" + case .all: return "All" } } } -private enum WorkUsageChart: Int, CaseIterable { +private enum WorkUsageTab: String, CaseIterable, Identifiable { case activity case tokens case code case clients + case limits + var id: String { rawValue } var title: String { switch self { - case .activity: return "ADE activity" - case .tokens: return "AI token flow" - case .code: return "Code movement" - case .clients: return "Where you use ADE" + case .activity: return "Activity" + case .tokens: return "Tokens" + case .code: return "Code" + case .clients: return "Clients" + case .limits: return "Limits" } } +} - var detail: String { - switch self { - case .activity: return "Your daily rhythm" - case .tokens: return "Input and output" - case .code: return "Additions and deletions" - case .clients: return "Desktop, mobile, terminal, and web" - } - } +private enum WorkUsageColors { + static let tokenInput = ADEColor.info + static let tokenOutput = ADEColor.warning + static let tokenCache = ADEColor.textMuted + static let insertions = ADEColor.success + static let deletions = ADEColor.danger + static let github = ADEColor.textMuted + static let heatmap = ADEColor.info + static let client: [String: Color] = [ + "desktop": ADEColor.info, + "mobile": Color.pink, + "tui": ADEColor.success, + "web": Color.teal, + "api": ADEColor.warning, + ] } private struct WorkUsageVisualBucket: Identifiable { var id: String + var date: String var inputTokens: Int var outputTokens: Int + var cachedTokens: Int var insertions: Int var deletions: Int + var githubAdditions: Int + var githubDeletions: Int var sessions: Int var interactions: Int - var tokens: Int { inputTokens + outputTokens } + var tokens: Int { inputTokens + outputTokens + cachedTokens } var code: Int { insertions + deletions } + var githubCode: Int { githubAdditions + githubDeletions } var activity: Int { tokens + sessions * 4_000 + interactions * 1_500 } } +/// Compact day detail surfaced by a tap on a bar or heatmap cell. +private struct WorkUsageBucketDetail: Equatable { + var date: String + var inputTokens: Int + var outputTokens: Int + var cachedTokens: Int + var insertions: Int + var deletions: Int + var sessions: Int + + var totalTokens: Int { inputTokens + outputTokens + cachedTokens } + var code: Int { insertions + deletions } +} + private func workUsageBuckets(_ points: [MobileAdeUsageDailyPoint], maxCount: Int) -> [WorkUsageVisualBucket] { let ordered = points.sorted { $0.date < $1.date } guard !ordered.isEmpty else { return [] } @@ -65,10 +102,14 @@ private func workUsageBuckets(_ points: [MobileAdeUsageDailyPoint], maxCount: In let chunk = ordered[start.. String { return value.formatted() } +private let workUsageDayParser: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter +}() + +private let workUsageDayDisplay: DateFormatter = { + let formatter = DateFormatter() + formatter.setLocalizedDateFormatFromTemplate("MMMd") + return formatter +}() + +private func workUsageFormatDay(_ date: String) -> String { + guard let parsed = workUsageDayParser.date(from: date) else { return date } + return workUsageDayDisplay.string(from: parsed) +} + struct WorkUsageActivityCarousel: View { @EnvironmentObject private var syncService: SyncService - @AppStorage("ade.work.usageChart.v1") private var chartRaw = WorkUsageChart.activity.rawValue - @AppStorage("ade.work.usageRange.v1") private var rangeRaw = WorkUsageRange.week.rawValue + @AppStorage("ade.work.activityTab.v1") private var tabRaw = WorkUsageTab.activity.rawValue + @AppStorage("ade.work.usageRange.v1") private var rangeRaw = WorkUsageRange.all.rawValue @Environment(\.accessibilityReduceMotion) private var reduceMotion + @ObservedObject private var quotaStore = MobileUsageQuotaStore.shared @State private var stats: MobileAdeUsageStats? @State private var loadedRange: String? @State private var loading = false - @State private var direction: CGFloat = 1 + @State private var selection: WorkUsageBucketDetail? - private var chart: WorkUsageChart { WorkUsageChart(rawValue: chartRaw) ?? .activity } - private var range: WorkUsageRange { WorkUsageRange(rawValue: rangeRaw) ?? .week } - private var loadKey: String { "\(rangeRaw):\(syncService.connectionState.rawValue)" } + private var tab: WorkUsageTab { WorkUsageTab(rawValue: tabRaw) ?? .activity } + private var range: WorkUsageRange { WorkUsageRange(rawValue: rangeRaw) ?? .all } + private var loadKey: String { "\(tabRaw):\(rangeRaw):\(syncService.connectionState.rawValue)" } - private var chartHeading: some View { - VStack(alignment: .leading, spacing: 2) { - Text(chart.title) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Text(chart.detail) - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) + private var summary: MobileAdeUsageSummary? { + loadedRange == rangeRaw ? stats?.summary : nil + } + + private var hasActivity: Bool { + guard let daily = stats?.daily else { return false } + return daily.contains { point in + (point.totalTokens ?? 0) > 0 || (point.sessions ?? 0) > 0 + || (point.insertions ?? 0) > 0 || (point.deletions ?? 0) > 0 + || (point.githubCommits ?? 0) > 0 || (point.githubPrs ?? 0) > 0 + || (point.githubAdditions ?? 0) > 0 || (point.githubDeletions ?? 0) > 0 + || (point.interactions ?? 0) > 0 } - .accessibilityElement(children: .combine) } - private var rangeSelector: some View { - HStack(spacing: 2) { - ForEach(WorkUsageRange.allCases) { option in - Button(option.title) { rangeRaw = option.rawValue } - .font(.caption2.weight(range == option ? .semibold : .medium)) - .foregroundStyle(range == option ? ADEColor.textPrimary : ADEColor.textMuted) - .frame(minWidth: 44, minHeight: 44) - .background( - range == option ? ADEColor.surfaceBackground.opacity(0.92) : .clear, - in: RoundedRectangle(cornerRadius: 6, style: .continuous) - ) - .contentShape(Rectangle()) - .buttonStyle(.plain) - .accessibilityLabel("\(option.title) activity range") - .accessibilityAddTraits(range == option ? .isSelected : []) - } + private var footerChip: (system: String, label: String)? { + guard let summary else { return nil } + if range == .all, let totalTokens = summary.totalTokens, totalTokens > 0 { + return ("trophy.fill", "\(workUsageCompact(totalTokens)) lifetime tokens") + } + if let streak = summary.currentStreakDays, streak >= 3 { + return ("flame.fill", "\(streak)-day streak") } - .padding(2) - .background(ADEColor.recessedBackground.opacity(0.75), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + return nil } var body: some View { - VStack(spacing: 8) { - ViewThatFits(in: .horizontal) { - HStack(alignment: .top, spacing: 10) { - chartHeading - Spacer(minLength: 4) - rangeSelector + VStack(spacing: 10) { + header + + ZStack(alignment: .top) { + chartArea + .frame(height: 84) + if let selection { + WorkUsageTooltip(detail: selection) + .padding(.top, -6) + .transition(.opacity) } + } - VStack(alignment: .leading, spacing: 4) { - chartHeading - rangeSelector - .frame(maxWidth: .infinity, alignment: .trailing) + footer + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(ADEColor.surfaceBackground.opacity(0.9)) + } + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(ADEColor.glassBorder, lineWidth: 0.6) + } + .accessibilityElement(children: .contain) + .accessibilityLabel(accessibilitySummary) + .task(id: loadKey) { await loadStats() } + .task(id: syncService.connectionState.rawValue) { + await quotaStore.load(using: syncService) + } + .onChange(of: rangeRaw) { _, _ in selection = nil } + .onChange(of: tabRaw) { _, _ in selection = nil } + } + + private var accessibilitySummary: String { + if tab == .limits { return "Live Claude and Codex limits from the connected machine." } + guard let summary else { return "Activity for \(range.title). Loading." } + let sessions = (summary.chatSessions ?? 0) + (summary.terminalSessions ?? 0) + return "Activity for \(range.title): \(workUsageCompact(summary.totalTokens ?? 0)) tokens, \(sessions) sessions, \(summary.activeDays ?? 0) active days." + } + + private var header: some View { + HStack(spacing: 8) { + tabRow + Spacer(minLength: 6) + if tab != .limits { rangeControl } + } + } + + private var tabRow: some View { + HStack(spacing: 2) { + ForEach(WorkUsageTab.allCases) { option in + Button(option.title) { + withAnimation(reduceMotion ? nil : .snappy(duration: 0.18)) { tabRaw = option.rawValue } } + .font(.caption.weight(tab == option ? .semibold : .medium)) + .foregroundStyle(tab == option ? ADEColor.textPrimary : ADEColor.textMuted) + .padding(.horizontal, 8) + .frame(minHeight: 34) + .background( + tab == option ? ADEColor.recessedBackground.opacity(0.9) : .clear, + in: RoundedRectangle(cornerRadius: 6, style: .continuous) + ) + .contentShape(Rectangle()) + .buttonStyle(.plain) + .accessibilityLabel("\(option.title) chart") + .accessibilityAddTraits(tab == option ? .isSelected : []) } + } + } - ZStack { - Group { - if let stats, loadedRange == rangeRaw { - switch chart { - case .activity: WorkUsageHeatmap(points: stats.daily) - case .tokens: WorkUsageBars(points: stats.daily, mode: .tokens) - case .code: WorkUsageBars(points: stats.daily, mode: .code) - case .clients: WorkUsageClientMix(clients: stats.clients ?? []) - } - } else if loading { - ProgressView().controlSize(.small).tint(ADEColor.purpleAccent) - } else { - Text("Activity appears after your first ADE session.") - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) - } + private var rangeControl: some View { + Menu { + Picker("Time range", selection: $rangeRaw) { + ForEach(WorkUsageRange.allCases) { option in + Text(option.title).tag(option.rawValue) } - .id(chart.rawValue) - .transition(reduceMotion ? .opacity : .asymmetric( - insertion: .move(edge: direction > 0 ? .trailing : .leading).combined(with: .opacity), - removal: .move(edge: direction > 0 ? .leading : .trailing).combined(with: .opacity) - )) } - .frame(height: 76) - .clipped() - - HStack(spacing: 8) { - Button { changeChart(by: -1) } label: { - Image(systemName: "chevron.left") - .font(.caption.weight(.semibold)) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) + } label: { + HStack(spacing: 4) { + Text(range.title) + .font(.caption.weight(.medium)) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .semibold)) + } + .foregroundStyle(ADEColor.textSecondary) + .padding(.horizontal, 10) + .frame(minHeight: 34) + .background(ADEColor.recessedBackground.opacity(0.7), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .accessibilityLabel("Time range, \(range.title)") + } + + @ViewBuilder + private var chartArea: some View { + if tab == .limits { + WorkUsageQuotaCompact(snapshot: quotaStore.snapshot) + } else if let stats, loadedRange == rangeRaw { + if !hasActivity { + WorkUsageWarmEmpty() + } else { + switch tab { + case .activity: + WorkUsageHeatmap(points: stats.daily, selectedId: selection?.date, onSelect: select) + case .tokens: + WorkUsageBars(points: stats.daily, mode: .tokens, selectedId: selection?.date, onSelect: select) + case .code: + WorkUsageBars(points: stats.daily, mode: .code, selectedId: selection?.date, onSelect: select) + case .clients: + WorkUsageClientMix(clients: stats.clients ?? []) + case .limits: + EmptyView() } - .buttonStyle(.plain) - .accessibilityLabel("Previous activity chart") - Spacer(minLength: 0) - if loadedRange == rangeRaw, let summary = stats?.summary { + } + } else if loading { + WorkUsageSkeleton() + } else { + WorkUsageWarmEmpty() + } + } + + private var footer: some View { + HStack(spacing: 6) { + Group { + if tab == .limits, let snapshot = quotaStore.snapshot { + Text("Checked \(mobileUsageRelativeTime(snapshot.lastPolledAt))") + if quotaStore.refreshing { ProgressView().controlSize(.mini) } + } else if let summary { + let sessions = (summary.chatSessions ?? 0) + (summary.terminalSessions ?? 0) Text("\(workUsageCompact(summary.totalTokens ?? 0)) tokens") Text("·") - Text("\(workUsageCompact((summary.chatSessions ?? 0) + (summary.terminalSessions ?? 0))) sessions") + Text("\(workUsageCompact(sessions)) sessions") if let activeDays = summary.activeDays, activeDays > 0 { Text("·") - Text("\(activeDays)d active") + Text("\(activeDays) active \(activeDays == 1 ? "day" : "days")") } + } else if loading { + Text("Loading activity…") } else { - Text("Cross-client activity") - } - Spacer(minLength: 0) - Button { changeChart(by: 1) } label: { - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) + Text("No activity yet") } - .buttonStyle(.plain) - .accessibilityLabel("Next activity chart") } - .font(.system(.caption2, design: .monospaced)) + .font(.caption2) .foregroundStyle(ADEColor.textMuted) - .overlay(alignment: .top) { Divider().opacity(0.12) } - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background { - RoundedRectangle(cornerRadius: 15, style: .continuous) - .fill(ADEColor.surfaceBackground.opacity(0.82)) - .overlay(alignment: .topTrailing) { - Circle() - .fill(ADEColor.purpleAccent.opacity(0.13)) - .frame(width: 120, height: 120) - .blur(radius: 30) - .offset(x: 26, y: -60) - } + .lineLimit(1) + + Spacer(minLength: 0) + + if tab != .limits, let chip = footerChip { + Label(chip.label, systemImage: chip.system) + .labelStyle(.titleAndIcon) + .font(.caption2.weight(.medium)) + .foregroundStyle(ADEColor.accent) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(ADEColor.accent.opacity(0.14), in: Capsule(style: .continuous)) + .overlay(Capsule(style: .continuous).stroke(ADEColor.accent.opacity(0.28), lineWidth: 0.6)) + .accessibilityLabel(chip.label) + } } - .clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous)) - .overlay { RoundedRectangle(cornerRadius: 15, style: .continuous).stroke(ADEColor.glassBorder, lineWidth: 0.6) } - .task(id: loadKey) { await loadStats() } + .overlay(alignment: .top) { Divider().opacity(0.12) } + .padding(.top, 2) } - private func changeChart(by delta: Int) { - let cases = WorkUsageChart.allCases - let next = (chart.rawValue + delta + cases.count) % cases.count - direction = delta >= 0 ? 1 : -1 - withAnimation(reduceMotion ? .linear(duration: 0.08) : .snappy(duration: 0.28)) { - chartRaw = next + private func select(_ detail: WorkUsageBucketDetail) { + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.14)) { + selection = (selection?.date == detail.date) ? nil : detail } } @MainActor private func loadStats() async { + guard tab != .limits else { + loading = false + return + } let requestedRange = range.rawValue guard syncService.supportsRemoteAction("usage.getAdeStats") else { stats = nil @@ -247,11 +372,12 @@ struct WorkUsageActivityCarousel: View { do { let first = try await syncService.fetchAdeUsageStats(preset: requestedRange) guard !Task.isCancelled, range.rawValue == requestedRange else { return } - withAnimation(.easeOut(duration: 0.2)) { + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.2)) { stats = first loadedRange = requestedRange } loading = false + // Pick up a background ledger refresh the host signals as still warming. if first.freshness?.state == "refreshing" { try? await Task.sleep(nanoseconds: 2_800_000_000) guard !Task.isCancelled, range.rawValue == requestedRange else { return } @@ -269,23 +395,150 @@ struct WorkUsageActivityCarousel: View { } } +private struct WorkUsageTooltip: View { + let detail: WorkUsageBucketDetail + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(workUsageFormatDay(detail.date)) + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text("\(workUsageCompact(detail.totalTokens)) tokens · \(workUsageCompact(detail.inputTokens)) in / \(workUsageCompact(detail.outputTokens)) out") + .font(.system(size: 11)) + .foregroundStyle(ADEColor.textMuted) + if detail.cachedTokens > 0 || detail.sessions > 0 { + Text("\(workUsageCompact(detail.cachedTokens)) cache · \(detail.sessions) \(detail.sessions == 1 ? "session" : "sessions")") + .font(.system(size: 11)) + .foregroundStyle(ADEColor.textMuted) + } + if detail.code > 0 { + Text("+\(workUsageCompact(detail.insertions)) / -\(workUsageCompact(detail.deletions)) lines") + .font(.system(size: 11)) + .foregroundStyle(ADEColor.textMuted) + } + } + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background(ADEColor.cardBackground.opacity(0.98), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous).stroke(ADEColor.glassBorder, lineWidth: 0.6)) + .shadow(color: Color.black.opacity(0.2), radius: 6, y: 2) + .accessibilityElement(children: .combine) + } +} + +private func mobileUsageRelativeTime(_ iso: String) -> String { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + guard let date = fractional.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) else { + return "recently" + } + let seconds = max(0, Int(Date().timeIntervalSince(date))) + if seconds < 60 { return "now" } + if seconds < 3_600 { return "\(seconds / 60)m ago" } + if seconds < 86_400 { return "\(seconds / 3_600)h ago" } + return "\(seconds / 86_400)d ago" +} + +private struct WorkUsageQuotaCompact: View { + let snapshot: MobileUsageQuotaSnapshot? + + var body: some View { + VStack(spacing: 8) { + if let snapshot { + ForEach(["claude", "codex"], id: \.self) { provider in + let windows = snapshot.windows.filter { $0.provider == provider } + let fiveHour = windows.first { $0.windowType == "five_hour" } + let weekly = windows.first { $0.windowType == "weekly" || $0.windowType == "monthly" } + let status = snapshot.providerStatus?[provider] + HStack(spacing: 8) { + Text(provider == "claude" ? "Claude" : "Codex") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .frame(width: 54, alignment: .leading) + Text(fiveHour.map { "5h \(Int(max(0, 100 - $0.percentUsed)))%" } ?? "5h —") + Text(weekly.map { "week \(Int(max(0, 100 - $0.percentUsed)))%" } ?? "week —") + Spacer(minLength: 0) + Text(mobileUsageStatusLabel(status)) + .foregroundStyle(ADEColor.textMuted) + } + .font(.system(.caption2, design: .monospaced)) + } + } else { + Text("Connect to a machine to load live limits.") + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } +} + +private func mobileUsageStatusLabel(_ status: MobileUsageProviderStatus?) -> String { + guard let status else { return "WAITING" } + let source = status.source?.uppercased() ?? "UNKNOWN" + return status.state == "ok" ? source : "\(status.state.uppercased()) · \(source)" +} + private struct WorkUsageHeatmap: View { let points: [MobileAdeUsageDailyPoint] + let selectedId: String? + let onSelect: (WorkUsageBucketDetail) -> Void var body: some View { let buckets = workUsageBuckets(points, maxCount: 70) let maximum = max(1, buckets.map(\.activity).max() ?? 1) - LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 3), count: 14), spacing: 3) { - ForEach(buckets) { bucket in - RoundedRectangle(cornerRadius: 2, style: .continuous) - .fill(bucket.activity == 0 - ? ADEColor.textMuted.opacity(0.10) - : Color.blue.opacity(0.25 + 0.75 * Double(bucket.activity) / Double(maximum))) - .frame(height: 11) - .accessibilityLabel("\(bucket.id), \(workUsageCompact(bucket.tokens)) tokens") + // Fill the box: a short range is one tall row of large cells; longer ranges + // use a 7-row calendar grid (columns = weeks). Cells stretch to fill both + // dimensions instead of leaving the chart area mostly blank. + let rows = buckets.count <= 7 ? 1 : 7 + let columns = stride(from: 0, to: buckets.count, by: rows).map { start in + Array(buckets[start.. some View { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(bucket.activity == 0 + ? ADEColor.textMuted.opacity(0.10) + : WorkUsageColors.heatmap.opacity(0.25 + 0.72 * Double(bucket.activity) / Double(maximum))) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay { + if selectedId == bucket.date { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .stroke(ADEColor.textPrimary.opacity(0.6), lineWidth: 1) + } + } + .contentShape(Rectangle()) + .onTapGesture { onSelect(detail(bucket)) } + .accessibilityLabel("\(workUsageFormatDay(bucket.date)), \(workUsageCompact(bucket.tokens)) tokens") + } + + private func detail(_ bucket: WorkUsageVisualBucket) -> WorkUsageBucketDetail { + WorkUsageBucketDetail( + date: bucket.date, + inputTokens: bucket.inputTokens, + outputTokens: bucket.outputTokens, + cachedTokens: bucket.cachedTokens, + insertions: bucket.insertions, + deletions: bucket.deletions, + sessions: bucket.sessions + ) } } @@ -295,36 +548,34 @@ private struct WorkUsageBars: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion let points: [MobileAdeUsageDailyPoint] let mode: WorkUsageBarMode + let selectedId: String? + let onSelect: (WorkUsageBucketDetail) -> Void var body: some View { let buckets = workUsageBuckets(points, maxCount: 36) - let values = buckets.map { mode == .tokens ? $0.tokens : $0.code } - let maximum = max(1, values.max() ?? 1) - GeometryReader { proxy in - HStack(alignment: .bottom, spacing: 2) { - ForEach(Array(buckets.enumerated()), id: \.element.id) { index, bucket in - let primary = mode == .tokens ? bucket.inputTokens : bucket.insertions - let secondary = mode == .tokens ? bucket.outputTokens : bucket.deletions - let total = primary + secondary - let height = total == 0 ? 2 : max(3, proxy.size.height * CGFloat(total) / CGFloat(maximum)) - let split = total == 0 ? 0 : CGFloat(secondary) / CGFloat(total) - VStack(spacing: 0) { - RoundedRectangle(cornerRadius: 2, style: .continuous) - .fill(mode == .tokens ? Color.blue : Color.green) - .overlay(alignment: .bottom) { - Rectangle() - .fill(mode == .tokens ? ADEColor.purpleAccent : Color.pink) - .frame(height: height * split) - } + let anyGithub = mode == .code && buckets.contains { $0.githubCode > 0 } + let localMax = buckets.map { mode == .tokens ? $0.tokens : $0.code }.max() ?? 1 + let githubMax = anyGithub ? (buckets.map(\.githubCode).max() ?? 1) : 1 + let maximum = max(1, localMax, githubMax) + let anyCache = mode == .tokens && buckets.contains { $0.cachedTokens > 0 } + let hasData = buckets.contains { (mode == .tokens ? $0.tokens : $0.code + $0.githubCode) > 0 } + + VStack(spacing: 6) { + if hasData { + GeometryReader { proxy in + HStack(alignment: .bottom, spacing: 2) { + ForEach(Array(buckets.enumerated()), id: \.element.id) { index, bucket in + bar(bucket: bucket, index: index, height: proxy.size.height, maximum: maximum, anyGithub: anyGithub) + } } - .frame(maxWidth: .infinity) - .frame(height: height) - .animation( - reduceMotion ? nil : .snappy(duration: 0.45).delay(Double(min(index, 24)) * 0.01), - value: total - ) } + } else { + Text(mode == .tokens ? "No token usage in this range." : "No code changes in this range.") + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + .frame(maxWidth: .infinity, maxHeight: .infinity) } + legend(anyCache: anyCache, anyGithub: anyGithub) } .accessibilityElement(children: .ignore) .accessibilityLabel(mode == .tokens ? "AI token flow" : "Code movement") @@ -332,17 +583,101 @@ private struct WorkUsageBars: View { ? "\(workUsageCompact(buckets.reduce(0) { $0 + $1.inputTokens })) input and \(workUsageCompact(buckets.reduce(0) { $0 + $1.outputTokens })) output tokens" : "\(workUsageCompact(buckets.reduce(0) { $0 + $1.insertions })) additions and \(workUsageCompact(buckets.reduce(0) { $0 + $1.deletions })) deletions") } + + @ViewBuilder + private func bar(bucket: WorkUsageVisualBucket, index: Int, height: CGFloat, maximum: Int, anyGithub: Bool) -> some View { + let total = mode == .tokens ? bucket.tokens : bucket.code + let barHeight = total == 0 ? 2 : max(3, height * CGFloat(total) / CGFloat(maximum)) + let githubHeight = anyGithub && bucket.githubCode > 0 + ? max(2, height * CGFloat(bucket.githubCode) / CGFloat(maximum)) + : 0 + ZStack(alignment: .bottom) { + if githubHeight > 0 { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(WorkUsageColors.github.opacity(0.3)) + .frame(height: githubHeight) + } + stackedSegments(bucket: bucket, barHeight: barHeight) + .frame(height: barHeight) + .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous)) + if selectedId == bucket.date { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .stroke(ADEColor.textPrimary.opacity(0.55), lineWidth: 1) + .frame(height: max(barHeight, githubHeight)) + } + } + .frame(maxWidth: .infinity) + .frame(height: max(barHeight, githubHeight), alignment: .bottom) + .contentShape(Rectangle()) + .onTapGesture { onSelect(detail(bucket)) } + .animation(reduceMotion ? nil : .snappy(duration: 0.4).delay(Double(min(index, 24)) * 0.008), value: total) + } + + /// Explicit proportional segment heights so a stacked bar reads input/output/ + /// cache (or additions/deletions) bottom-to-top without ambiguous flex layout. + @ViewBuilder + private func stackedSegments(bucket: WorkUsageVisualBucket, barHeight: CGFloat) -> some View { + if mode == .tokens { + let total = CGFloat(max(1, bucket.tokens)) + VStack(spacing: 0) { + Rectangle().fill(WorkUsageColors.tokenCache) + .frame(height: barHeight * CGFloat(bucket.cachedTokens) / total) + Rectangle().fill(WorkUsageColors.tokenOutput) + .frame(height: barHeight * CGFloat(bucket.outputTokens) / total) + Rectangle().fill(WorkUsageColors.tokenInput) + .frame(maxHeight: .infinity) + } + } else { + let total = CGFloat(max(1, bucket.code)) + VStack(spacing: 0) { + Rectangle().fill(WorkUsageColors.deletions) + .frame(height: barHeight * CGFloat(bucket.deletions) / total) + Rectangle().fill(WorkUsageColors.insertions) + .frame(maxHeight: .infinity) + } + } + } + + @ViewBuilder + private func legend(anyCache: Bool, anyGithub: Bool) -> some View { + HStack(spacing: 10) { + if mode == .tokens { + legendItem(WorkUsageColors.tokenInput, "Input") + legendItem(WorkUsageColors.tokenOutput, "Output") + if anyCache { legendItem(WorkUsageColors.tokenCache, "Cache") } + } else { + legendItem(WorkUsageColors.insertions, "Added") + legendItem(WorkUsageColors.deletions, "Removed") + if anyGithub { legendItem(WorkUsageColors.github.opacity(0.5), "GitHub") } + } + Spacer(minLength: 0) + } + .font(.system(size: 11)) + .foregroundStyle(ADEColor.textMuted) + } + + private func legendItem(_ color: Color, _ label: String) -> some View { + HStack(spacing: 4) { + RoundedRectangle(cornerRadius: 2, style: .continuous).fill(color).frame(width: 8, height: 8) + Text(label) + } + } + + private func detail(_ bucket: WorkUsageVisualBucket) -> WorkUsageBucketDetail { + WorkUsageBucketDetail( + date: bucket.date, + inputTokens: bucket.inputTokens, + outputTokens: bucket.outputTokens, + cachedTokens: bucket.cachedTokens, + insertions: bucket.insertions, + deletions: bucket.deletions, + sessions: bucket.sessions + ) + } } private struct WorkUsageClientMix: View { let clients: [MobileAdeUsageClientSummary] - private let colors: [String: Color] = [ - "desktop": ADEColor.purpleAccent, - "mobile": Color.pink, - "tui": Color.green, - "web": Color.cyan, - "api": Color.orange, - ] private func label(_ client: String) -> String { switch client { @@ -358,17 +693,14 @@ private struct WorkUsageClientMix: View { let visible = clients.filter { $0.interactions > 0 }.sorted { $0.interactions > $1.interactions } let total = max(1, visible.reduce(0) { $0 + $1.interactions }) if visible.isEmpty { - Text("Client mix appears as you use ADE across devices.") - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) - .frame(maxWidth: .infinity, maxHeight: .infinity) + WorkUsageTabEmptyHint(message: "No client activity in this range.") } else { VStack(spacing: 10) { GeometryReader { proxy in HStack(spacing: 0) { ForEach(visible) { client in Rectangle() - .fill(colors[client.client] ?? Color.orange) + .fill(WorkUsageColors.client[client.client] ?? ADEColor.warning) .frame(width: proxy.size.width * CGFloat(client.interactions) / CGFloat(total)) } } @@ -382,7 +714,7 @@ private struct WorkUsageClientMix: View { ) { ForEach(visible.prefix(4)) { client in HStack(spacing: 4) { - Circle().fill(colors[client.client] ?? Color.orange).frame(width: 6, height: 6) + Circle().fill(WorkUsageColors.client[client.client] ?? ADEColor.warning).frame(width: 6, height: 6) Text(label(client.client)) Text("\(Int(round(Double(client.interactions) / Double(total) * 100)))%") .foregroundStyle(ADEColor.textPrimary) @@ -399,3 +731,58 @@ private struct WorkUsageClientMix: View { } } } + +/// Muted, centered hint for a tab whose own series is empty while the module has +/// data on other tabs (so the global warm-empty state does not apply). +private struct WorkUsageTabEmptyHint: View { + let message: String + var body: some View { + Text(message) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityLabel(message) + } +} + +private struct WorkUsageWarmEmpty: View { + private let fractions: [CGFloat] = [0.35, 0.55, 0.4, 0.7, 0.5, 0.62, 0.44, 0.58, 0.48, 0.66, 0.4, 0.54] + + var body: some View { + VStack(spacing: 8) { + HStack(alignment: .bottom, spacing: 3) { + ForEach(Array(fractions.enumerated()), id: \.offset) { _, fraction in + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(ADEColor.textMuted.opacity(0.12)) + .frame(height: 34 * fraction) + .frame(maxWidth: .infinity) + } + } + .frame(height: 34) + Text("Your activity will appear here after your first chat.") + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .accessibilityLabel("Your activity will appear here after your first chat.") + } +} + +private struct WorkUsageSkeleton: View { + private let fractions: [CGFloat] = (0..<20).map { 0.3 + CGFloat(($0 * 37) % 60) / 100 } + + var body: some View { + HStack(alignment: .bottom, spacing: 3) { + ForEach(Array(fractions.enumerated()), id: \.offset) { _, fraction in + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(ADEColor.textMuted.opacity(0.1)) + .frame(height: max(4, 76 * fraction)) + .frame(maxWidth: .infinity) + } + } + .frame(maxHeight: .infinity, alignment: .bottom) + .accessibilityLabel("Loading activity") + } +} diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index e36943c61..2118f9017 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -4053,6 +4053,95 @@ final class ADETests: XCTestCase { XCTAssertNil(stats.summary.totalInteractions) XCTAssertNil(stats.clients) XCTAssertNil(stats.freshness) + XCTAssertNil(stats.daily.first?.cachedTokens) + XCTAssertNil(stats.scope) + XCTAssertNil(stats.githubActivity) + XCTAssertNil(stats.localActivity) + XCTAssertNil(stats.providers) + } + + func testMobileAdeUsageStatsDecodesNewOptionalBreakdowns() throws { + let json = """ + { + "generatedAt": "2026-07-10T12:00:00.000Z", + "scope": "project", + "summary": { "totalTokens": 100, "currentStreakDays": 6, "activeDays": 4 }, + "daily": [ + { + "date": "2026-07-10", + "inputTokens": 40, + "outputTokens": 20, + "cachedTokens": 15, + "insertions": 30, + "deletions": 5, + "sessions": 2, + "githubCommits": 3, + "githubPrs": 1, + "githubAdditions": 88, + "githubDeletions": 9 + } + ], + "githubActivity": { "commits": 12, "prsMerged": 4, "prAdditions": 500, "prDeletions": 60 }, + "localActivity": { "commits": 7, "prLandings": 2, "insertions": 300, "deletions": 40 }, + "providers": [ + { "provider": "claude", "totalTokens": 80, "estimation": "exact", "scopeSupported": true, "adeOriginatedTokens": 70, "externalTokens": 10 }, + { "provider": 42 }, + { "provider": "cursor", "totalTokens": 20, "estimation": "chars", "scopeSupported": false } + ] + } + """ + + let stats = try JSONDecoder().decode(MobileAdeUsageStats.self, from: Data(json.utf8)) + + XCTAssertEqual(stats.scope, "project") + XCTAssertEqual(stats.daily.first?.cachedTokens, 15) + XCTAssertEqual(stats.daily.first?.githubCommits, 3) + XCTAssertEqual(stats.daily.first?.githubAdditions, 88) + XCTAssertEqual(stats.githubActivity?.prsMerged, 4) + XCTAssertEqual(stats.localActivity?.prLandings, 2) + // The malformed middle provider entry is dropped, the valid ones survive. + XCTAssertEqual(stats.providers?.count, 2) + XCTAssertEqual(stats.providers?.first?.provider, "claude") + XCTAssertEqual(stats.providers?.first?.adeOriginatedTokens, 70) + XCTAssertEqual(stats.providers?.last?.estimation, "chars") + XCTAssertEqual(stats.providers?.last?.scopeSupported, false) + } + + func testMobileUsageQuotaSnapshotDecodesSourceFreshnessAndUnknownFields() throws { + let json = """ + { + "windows": [{ + "provider": "claude", + "windowType": "five_hour", + "percentUsed": 27.5, + "resetsAt": "2026-07-10T19:00:00.000Z", + "resetsInMs": 3600000, + "windowDurationMs": 18000000, + "futureField": true + }], + "providerStatus": { + "claude": { + "state": "stale", + "source": "oauth", + "updatedAt": "2026-07-10T17:00:00.000Z", + "lastAttemptAt": "2026-07-10T18:00:00.000Z", + "errorKind": "rate_limited", + "nextRetryAt": "2026-07-10T18:05:00.000Z", + "message": "Showing last reading" + } + }, + "lastPolledAt": "2026-07-10T18:00:00.000Z", + "errors": ["claude: API returned 429"], + "pacing": { "status": "on-track" } + } + """ + + let snapshot = try JSONDecoder().decode(MobileUsageQuotaSnapshot.self, from: Data(json.utf8)) + + XCTAssertEqual(snapshot.windows.first?.percentUsed, 27.5) + XCTAssertEqual(snapshot.providerStatus?["claude"]?.source, "oauth") + XCTAssertEqual(snapshot.providerStatus?["claude"]?.errorKind, "rate_limited") + XCTAssertEqual(snapshot.errors, ["claude: API returned 429"]) } @MainActor @@ -4079,6 +4168,66 @@ final class ADETests: XCTestCase { } } + @MainActor + func testFetchUsageQuotaRejectsLegacyHostBeforeTransport() async throws { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + try service.applyHelloPayloadForTesting([ + "brain": [ + "deviceId": "host-1", + "deviceName": "Mac Studio", + ], + "features": [ + "projectCatalog": false, + ], + ]) + + do { + _ = try await service.fetchUsageQuotaSnapshot(refresh: true) + XCTFail("A legacy host must reject usage quota before attempting transport") + } catch { + XCTAssertEqual((error as NSError).userInfo["ADEErrorCode"] as? String, "unsupported_action") + } + } + + @MainActor + func testFetchUsageQuotaRejectsAdvertisedHostWithoutUsageActionBeforeTransport() async throws { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + try service.applyHelloPayloadForTesting([ + "brain": [ + "deviceId": "host-1", + "deviceName": "Mac Studio", + ], + "features": [ + "projectCatalog": false, + "commandRouting": [ + "mode": "allowlisted", + "actions": [[ + "action": "chat.send", + "policy": ["viewerAllowed": true], + ]], + ], + "mobileCompatibility": [ + "contractVersion": 1, + "mode": "full", + "requiredActions": ["chat.send"], + "missingActions": [], + ], + ], + ]) + + XCTAssertEqual(service.hostCompatibilityMode, .full) + XCTAssertFalse(service.supportsRemoteAction("usage.refreshQuota")) + do { + _ = try await service.fetchUsageQuotaSnapshot(refresh: true) + XCTFail("A host without the usage action must reject quota refresh before transport") + } catch { + let nsError = error as NSError + XCTAssertEqual(nsError.domain, "ADE") + XCTAssertEqual(nsError.code, 17) + XCTAssertEqual(nsError.userInfo["ADEErrorCode"] as? String, "unsupported_action") + } + } + @MainActor func testPersonalChatsStayLocallyActionGatedOnPartialHost() throws { let remoteCommandDescriptorsKey = "ade.sync.remoteCommandDescriptors" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d6b35993a..000143cab 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -522,7 +522,9 @@ ade.processes.* / ade.tests.* # processes also expose group bulk ops: ade.config.* # project config get/save/trust ade.keybindings.* ade.sync.* # device registry, PIN pairing (getPin/setPin/clearPin), QR payload, lane presence announce (setActiveLanePresence), host transfer -ade.usage.* # live provider quotas/budgets plus retrospective ADE activity stats +ade.usage.* # cached live provider quota reads, explicit quota-only refresh, + # adaptive-demand registration, budgets, and a separate + # retrospective history/activity refresh path ade.layout.* / ade.graph.* ade.computerUse.* ade.iosSimulator.* # macOS-only iOS Simulator drawer + Preview Lab: getStatus/launch/shutdown/screenshot/getScreenSnapshot/getInspectorSnapshot/inspectPoint/getPreviewCapability/listPreviewTargets/resolvePreviewMatch/ensurePreviewWorkspace/renderCurrentPreview/renderPreview/openPreviewWorkspace/startStream/stopStream/getStreamStatus/getWindowState/listWindowSources/tap/typeText/drag/swipe/selectPoint, plus the ade.iosSimulator.event push channel diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index e8633fcdc..0e3fa5454 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -68,7 +68,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/components/chat/MosaicCard.tsx` | Interactive mosaic card renderer (text, select, multiselect, number/slider, input, approve/deny, key-value table). Hooked in at `MarkdownBlock`'s code-fence handler behind a Claude-gated `mosaic` context prop from `AgentChatPane`; answered state persists across virtualized unmounts via a session-lifetime latch that rolls back on send failure. Non-Claude sessions render the plain fence. | | `apps/desktop/src/shared/types/chat.ts` | All chat types: `AgentChatSession`, `AgentChatEvent` union, `AgentChatEventHistorySnapshot` (with optional `sessionFound` for stale-session detection), provider-neutral `AgentChatMcpToolSource` / app context metadata, Codex goal/token-usage/runtime-state DTOs (`CodexSafetyBufferingState`, moderation metadata, sleep/thread-deleted/stall events), typed Codex goal/recovery control args, image generation/view events with large-inline-payload omission metadata, permission modes, pending input (including app-server `autoResolutionMs`), completion reports, `AgentChatMessageSession*` peer-message routing DTOs (`auto` / `queue` / `wake` / `interrupt-replace`), `AgentChatSetScheduledWorkPaused*`, `PARALLEL_CHAT_MAX_ATTACHMENTS`, and parallel launch state DTOs. `AgentChatSubagentSnapshot.label` carries provider-assigned display labels such as Codex Agent #N. `AgentChatScheduledWakeMetadata` marks synthetic unattended user turns with schedule id, kind, fire time, reason, and late state. `scheduled_work_update` captures Claude wake/cron/background lifecycle including `paused`, `firedAt`, and `late`; `AgentChatSessionSummary.nextWakeAt` and `scheduledWorkPaused` project durable scheduler state into session lists. `transcript_retraction` removes provider-superseded assistant text from renderers without rewriting the persisted JSONL stream. `user_message` events may also carry metadata such as `hideFullPrompt` for internal handoff briefs, while `displayText` remains the user-facing transcript text. `AgentChatSessionSummary.linearIssueLinks?: SessionLinearIssueLink[]` carries the Linear issues attached to the session (chat or CLI), populated from `session_linear_issues` independent of any lane link. The `session_meta_updated` event additionally carries optional permission/interaction mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`, `codexSandbox`, `codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, `cursorModeSnapshot`) so a mode change made on one client patches every other client's composer state; a title-only emit carries none of them and stays backward-compatible. | | `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Top-level renderer surface: state derivation, IPC wiring, composer mount, message-list mount, End/Delete chat controls in the header, parallel multi-model lane launch orchestration, transient-lane cleanup, and multi-lane deep-link navigation. Renders the inline `InlineQuestionRequestCard` (in `AgentChatMessageList`) when the active pending input is a question/structured-question. Resolves the surface accent colour through `providerChatAccent(provider)` so Claude/Codex/Cursor stay visually consistent regardless of model variant; the question/plan cards inherit that same `--chat-accent`. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts when IPC misses an event, even when the tile is not focused. A visible session whose transcript is still empty but whose summary no longer looks active receives two bounded forced history reads (after 900 ms and 3 s), covering newly-created headless sessions whose first append/event raced the renderer without introducing idle polling. Event-history snapshots with `sessionFound: false` clear stale locked-pane state instead of rendering a dead transcript. Draft chats scope their last-launch config by project/lane/surface/draft-kind and mark local model/reasoning/permission edits as touched so late lane-session hydration cannot overwrite the user's draft selection; composer text is also keyed by the real session id or the lane draft key (`draft:`) so switching draft lanes does not leak text through a shared null session key. During project transitions the pane blocks send/model/permission mutations and shows a "Project is switching..." composer placeholder so chat calls do not hit the wrong runtime binding. On macOS, polls `ade.iosSimulator.getStatus` and renders the iOS Simulator drawer toggle in the header when the platform is supported (see [iOS Simulator feature](../ios-simulator/README.md)); selecting elements inside the drawer flows back through the pane as `IosElementContextItem` chips on the composer. Polls `ade.appControl.getStatus` and exposes the App Control drawer toggle when the platform is supported, mounting `ChatAppControlPanel`; selections become `AppControlContextItem` chips + attachments on the composer. See [App Control](../computer-use/app-control.md). When mounted as a Work tile (`SessionSurface` passes `hideLaneToolDrawers={true}`) the iOS, App Control, and chat terminal drawer toggles are suppressed because the Work right-edge sidebar owns those lane-scoped drawers; hidden lane-tool mode also skips App Control status polling and terminal listing. Remote-bound panes further defer local-only App Control / proof snapshot polling until the matching drawer is open, delay unfinished parallel-launch cleanup recovery briefly after mount, cache chat-session lists and slash-command catalogs by active project root, and avoid mount-time session-delta fetches until a remote turn completes. The pane still listens on `ade:agent-chat:add-attachment` / `add-ios-context` / `add-app-control-context` / `add-builtin-browser-context` / `insert-draft` window events so selections from the sidebar flow into the active chat composer; event handlers match on either `sessionId` (for active sessions) or `draftTargetId` (for unsaved draft composers when `draftContextTargetId` is set), enabling the Work sidebar to insert context into a draft composer before a chat session exists. Work-tab CLI launches pass the active lane worktree into the shared launcher so the spawned CLI sees lane-aware Agent Skill roots. Work CLI launches intentionally skip the direct-argv path: the pane drops `command` / `args` from the `onLaunchPtySession` payload and always sends `startupCommand` plus `workCliStartupDelayMs = 180` so the spawned shell can finish drawing its prompt before the CLI invocation is typed in (see [pty-and-processes.md](../terminals-and-sessions/pty-and-processes.md#create-flow-createargs) for how `ptyService.create` consumes the delay). The `onLaunchCliSession` prop is typed as `(args: WorkPtyLaunchArgs) => Promise` and passes `disposition` matching the draft launch mode so background CLI launches do not steal focus. Internal draft launch state is structured through `DraftLaunchMode`, `DraftLaunchKind`, `DraftLaunchLaneTarget`, `StartedDraftLaunch`, and `DraftLaunchJob`. Each draft launch creates a `DraftLaunchJob` that tracks multi-step progress through a state machine (`creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` | `failed`; auto-created lanes are named deterministically up front and the AI rename runs in the background via `startBackgroundLaneNaming` / `startBackgroundParallelLaneNaming`, surfaced through `laneNamingStore`, so there is no blocking `naming-lane` phase) and stores it in the **root** store's `draftLaunchJobsByScope` (read via `useRootAppStore` / `rootAppStoreApi.getState()`) keyed by project root, lane, surface profile, and Work draft kind so loading/error strips survive pane remounts — and a remote project switch that tears down the originating per-project store — without leaking into another lane pane. The detached launch chain captures the originating `OpenProjectBinding`, passes it as a `pin` to branch/lane/chat/orchestration/PTY calls so a mid-launch project switch keeps targeting the originating runtime, pins rollback (`lanes.delete` / `agentChat.delete` with a `pin`) to that binding, and caps each step with `withDraftLaunchTimeout` (90 s). The composer is cleared optimistically when the job starts rather than after it finishes; active jobs remain visible while terminal rows are pruned by scope. The pane renders status strips with Open/Restore for ready/failed jobs, Dismiss for terminal jobs, and a hide-status escape hatch for stale active jobs. Failed jobs offer a Restore button that merges the snapshot back into the composer (merging attachments and context items by identity rather than replacing). `clearDraftLaunchComposer` resets the draft, attachments, and context items after a successful launch. `DraftLaunchJob` carries `draftKind` so the dismissible job strip's "Open" action restores the correct Work draft kind (chat vs. CLI). Proof remains chat-scoped and stays on the chat header. | -| `apps/desktop/src/renderer/components/usage/UsageActivityCarousel.tsx` | Reusable activity, token, code-movement, and client-mix charts. `AgentChatPane` mounts the compact variant directly below an empty Work draft composer (desktop and web only); it reads `usage.getAdeStats` through the active `window.ade` adapter and persists the selected chart and day/week/month/year range locally. | +| `apps/desktop/src/renderer/components/usage/ActivityModule.tsx` | Reusable activity, token, code-movement, and client-mix module. `AgentChatPane` mounts `WorkActivityModule` directly below an empty Work draft composer (desktop and web only); it reads `usage.getAdeStats` through the active `window.ade` adapter, defaults to all-time activity, and preserves explicit tab/range choices locally. | | `apps/desktop/src/renderer/lib/agentChatSessionListCache.ts` | Short-lived renderer cache for `ade.agentChat.list`, keyed by active project root, lane, automation, and archive flags. Normal reads coalesce; forced reads bypass an older in-flight promise, and promise-identity checks prevent the superseded response from repopulating the cache. Mutations invalidate by project/lane so remote Work panes do not fan out repeated list calls while still refreshing immediately after create/archive/delete. | | `apps/desktop/src/renderer/lib/chatSessionEvents.ts` | Renderer-local chat-session lifecycle helpers. `announceWorkChatSessionCreated` invalidates both Work session-list caches and publishes the durable session; Work and Lanes subscribers seed an optimistic row before their background refresh. `shouldRefreshSessionListForChatEvent` separately gates list refreshes for streamed chat events. | | `apps/desktop/src/renderer/lib/agentChatSlashCommandsCache.ts` | Short-lived renderer cache for `ade.agentChat.slashCommands`, keyed by project root plus session id or lane/provider. System notices can force-refresh the selected session's commands. | diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 4d4bfa847..7a70b90d0 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -12,7 +12,7 @@ subagents, computer use). The pane derives all visible state from the | Path | Role | |---|---| | `AgentChatPane.tsx` | Top-level pane; IPC wiring, session state, presentation profile resolution, lane navigation, parallel launch orchestration, mounting of sub-panels and composer. It persists a per-session `ade.chat.lastViewed.v1:` timestamp in renderer `localStorage`; scheduled turns that fired since that timestamp produce a dismissible while-you-were-away strip above the composer, with the latest outcome preview and jump requests for up to three wake dividers. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts so inactive-but-visible tiles stay current. Draft chats preserve user-touched model/reasoning/permission controls across late lane-session hydration, and composer text is keyed by session id or lane draft key so switching draft lanes does not reuse another draft's text. Accepts an optional `draftContextTargetId` prop so the Work sidebar can target an unsaved draft composer for context insertions (attachments, iOS/App Control/browser selections, draft text) even before a chat session exists; window event handlers match on either `sessionId` or `draftTargetId`. When auto-creating a lane the draft resolves the primary lane for the `onLaneChange` callback so the sidebar lane context stays in sync. Composer draft state (text, model, reasoning, attachments, context items) is persisted to `localStorage` under the `ade.chat.composerDraft.v1` key family and restored on scope change through `ComposerDraftStorageSnapshot`. Draft launches are tracked through **root**-store-backed `DraftLaunchJob` state machines with multi-step progress (`creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` / `failed`; auto-create names the lane deterministically up front and renames to the AI name in the background, so there is no blocking `naming-lane` phase); jobs live in the root store (not the per-project store) so an in-flight launch survives a remote project switch that tears down the originating project surface. The detached launch chain captures the originating `OpenProjectBinding`, passes it as a `pin` to branch/lane/chat/orchestration/PTY calls so a mid-launch project switch keeps targeting the originating runtime, pins rollback to that binding, and caps each step at 90 s (`withDraftLaunchTimeout`). The composer is cleared optimistically at job start, stale active rows gain a hide-status escape hatch, failed jobs expose Restore in the job strip and matching error banner, and the `DraftLaunchSnapshot` captures the full control state so the async launch uses frozen settings. | -| `apps/desktop/src/renderer/components/usage/UsageActivityCarousel.tsx` | Cross-client activity, token, code-movement, and client-mix charts. `AgentChatPane` mounts `WorkUsageActivityCarousel` beneath the empty Work draft composer when no app panel is open; the component persists the chosen chart and day/week/month/year range. | +| `apps/desktop/src/renderer/components/usage/ActivityModule.tsx` | Tabbed cross-client activity/tokens/code/clients module. `AgentChatPane` mounts the self-fetching `WorkActivityModule` (compact variant) beneath the empty Work draft composer when no app panel is open; the component persists the chosen tab and day/week/month/year range under `ade.activity.module.v1`. | | `apps/desktop/src/renderer/lib/draftLaunchJobs.ts` | Pure helper for Work draft-launch job DTOs, terminal/stale-state detection, and pruning. The list keeps active rows ahead of terminal rows, fills remaining retained slots with terminal rows, and keeps at least one terminal row alongside active jobs. Also owns the durability constants/helpers: `DRAFT_LAUNCH_TIMEOUT_MS` (90 s) + `withDraftLaunchTimeout` (fails a step whose runtime call never settles; the underlying IPC is not cancellable, so it keeps running detached and the timeout only unwedges the renderer-side job) and `LAUNCH_PROJECT_CHANGED_MESSAGE` (the legacy/unpinned abort error used only when no originating project binding is available and the active project drifts mid-launch). | | `apps/desktop/src/renderer/lib/handoffLaunchJobs.ts` | Pure helper for handoff placeholder DTOs, scope keys, stable placeholder ids, status labels, and search matching. `AgentChatPane` writes these jobs into the root store while `TerminalsPage` passes matching jobs into the Work session sidebar. The handoff drawer can offer a brief summarized handoff or a full-history fork when source and target stay within Claude or within Codex; Codex forks use the app-server thread id returned by `thread/fork`. | | `AgentChatMessageList.tsx` | Virtualized message list (`@tanstack/react-virtual`). Renders transcript rows and turn dividers, including a `Woke on schedule` divider before every synthetic scheduled turn and inline `SubagentSpawnCard` / `SubagentResultCard` / `BackgroundFinishChip` rows (from `SubagentActivityCards.tsx`) for real subagents and backgrounded shell commands, and accepts stable row-key jump requests from the while-you-were-away strip and the spawn/result jump affordances. Keeps sticky-bottom sessions pinned across streamed row growth and late virtual-height measurements. The last text block of a multi-block assistant turn exposes Copy turn, which joins only that turn's assistant text blocks with blank lines; legacy rows without a turn id and single-block turns keep only the normal block copy. Plan-approval rows with non-empty body text render a scrollable markdown block (capped at `360px`) beneath the header so the user can review plan content inline. Codex goal lifecycle rows use user-facing text such as `Goal set`, `Goal paused`, and `Goal cleared`. A stalled Codex turn renders a clickable Wait / Nudge / Retry / Resume recovery card wired to `agentChat.recoverCodexTurn`. User messages marked `metadata.hideFullPrompt` render and copy only their `displayText`, keeping internal handoff briefs out of the visible transcript details. | diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 030d1934b..92a315582 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -244,21 +244,19 @@ Renderer — settings: weekly window is present, otherwise `mo`). Percent values are clamped to 0-100, color through the green/amber/red thresholds at 75% / 100%, and show an ellipsis while missing. On mount, the button reads - the cached `ade.usage.getSnapshot`, immediately forces - `ade.usage.refresh`, ignores a slower cached startup read when a - fresher forced refresh has already landed, refreshes every 120 s, and - refreshes on window focus when the latest poll is older than 60 s. + the cached `ade.usage.getSnapshot` and records quota demand; it does not + force a provider request. Explicit refresh calls `ade.usage.refresh`, while + adaptive polling tightens to 60 s under demand and backs off when idle. Provider detection comes from `ade.ai.getStatus` on mount and every 5 min; CLIs not detected on the machine are hidden from the header, while installed-but-unauthenticated providers stay visible in the - panel as "Not signed in". The panel auto-refreshes on open, subscribes - to usage `onUpdate`, and drills down into 5-hour, weekly, monthly, - and other reset windows, last-poll status, daily 7-day usage, and - per-provider messages/error chips. Codex polling keeps the legacy HTTP - rate-limit endpoint as the first source for windows, then also asks the - Codex app-server via CLI JSON-RPC for `account/usage/read` and - `account/workspaceMessages/read` so the panel can show native daily - usage and provider workspace messages even when HTTP windows succeed. + panel as "Not signed in". The panel subscribes to usage `onUpdate`, and + drills down into 5-hour, weekly, monthly, and other reset windows with + explicit source, updated time, stale state, and inline provider errors. + Claude background polling never prompts Keychain and explicit local refresh + can fall back from OAuth to a bounded CLI probe. Codex returns directly when + HTTP supplies complete windows and uses a bounded app-server RPC only for + auth recovery or a successful but unrecognized response schema. Cursor usage polling was removed (it required a team-admin API key that desktop users almost never have); only `claude` and `codex` are tracked in `TRACKED_PROVIDERS`. Budget @@ -266,33 +264,72 @@ Renderer — settings: `saveBudgetConfig`. Threshold crossings (25 / 50 / 75 / 100 %) emit `UsageThresholdEvent`s for local usage handling. - `apps/desktop/src/renderer/components/settings/AdeUsageSection.tsx` - — Settings > Stats. Reads `window.ade.usage.getAdeStats({ preset })` - for today / 7d / 30d / year / all-time stats and calls - `window.ade.usage.refresh()` for explicit refresh. The first render is - stale-while-revalidate: cached provider/GitHub data and live project-DB - aggregates return immediately while expensive provider-ledger and `gh` - scans refresh in the background. The dashboard combines deduplicated local - provider tokens, ADE AI calls, sessions, lane/code movement, artifacts, - automations, workers, GitHub activity, and client-surface activity. + — Settings > Usage, split into **Limits** and **Activity** tabs. Limits + renders the same live quota contract as the header without starting a local + ledger scan. Activity is a sectioned dashboard rather than a single + carousel. Its header carries two segmented controls — a **scope** toggle (This + project / This machine, persisted to `ade.stats.scope.v1`, default project) + and a **range** toggle (Today / 7d / 30d / year / all, default all) — plus + a Refresh button. Below the header: an **Overview** row of stat tiles (AI tokens, + estimated cost, code movement, pull requests), an **Activity** section that + mounts `ActivityModule` (`variant="full"`, `showRangeControl={false}`), and + a two-panel row of **AI usage** (deduplicated per-provider token totals and + per-model breakdown, with per-provider estimation notes) and **Code & PRs** + (GitHub activity and ADE-local activity as separate labeled columns, never + max-merged). A meta line at the bottom reports freshness ("refreshing"), + estimation caveats, and which scope the provider totals were computed at. + It reads `window.ade.usage.getAdeStats({ preset, scope })` and calls + `window.ade.usage.refreshHistory()` for explicit Activity refresh; the first render is + stale-while-revalidate (cached provider/GitHub data plus live project-DB + aggregates return immediately while expensive provider-ledger and `gh` scans + refresh in the background). Provider colors come from `providerColor`. - `apps/desktop/src/main/services/usage/usageTrackingService.ts` — owns the - live quota snapshot plus the retrospective `getAdeUsageStats` projection. + live quota snapshot plus the retrospective `getAdeUsageStats(args)` + projection. `args.scope` selects `machine` (every session in the provider's + local ledgers, codeburn-comparable) or `project` (only sessions attributable + to the current project root by cwd match); GitHub and ADE-DB metrics are + always project/repo scoped regardless of scope. Provider token totals are + deduplicated from the local provider ledgers, all daily buckets and range + boundaries key on machine-local calendar days (`localDay.ts`), and GitHub vs + local activity are reported as separate labeled groups (never max-merged). + Live quota polling is adaptive and coalesced, retains unexpired last-good + provider windows with source/freshness metadata, and stays independent from + the expensive provider-ledger and GitHub history scans. It returns cached provider/GitHub results and current DB aggregates without - awaiting expensive scans, exposes freshness metadata, and coalesces stale - provider/GitHub revalidation in the background. + awaiting expensive scans, exposes freshness metadata (`fresh` / `refreshing`), + and coalesces stale provider/GitHub revalidation in the background + (`refreshStatsInBackground`, single-flight per range + source). - `apps/desktop/src/main/services/usage/usageStatsStore.ts` — aggregates the project database and owns the low-volume `usage_events` ledger. Only successful, meaningful user mutations are recorded; read/poll IPC is - ignored. Desktop IPC, ADE Code/TUI RPC, paired mobile commands, and paired - web commands are attributed as `desktop`, `tui`, `mobile`, and `web`. - `usage_events` is local-only (excluded from CRR replication) because every - controller action is recorded once on the runtime that executes it. -- `apps/desktop/src/shared/types/usage.ts` — shared range, daily-point, - freshness, aggregate, and client-attribution contracts. Supported presets - are today, 7d, 30d, year, and all time. -- `apps/desktop/src/renderer/components/usage/UsageActivityCarousel.tsx` — - reusable animated activity/token/code/client-mix carousel. It persists the - selected chart and day/week/month/year range, appears in Settings > Stats, - and renders directly below the empty Work composer on desktop and web. + ignored. Desktop IPC, ADE Code/TUI RPC, paired mobile commands, paired web + commands, and API callers are attributed as `desktop`, `tui`, `mobile`, + `web`, and `api`. `usage_events` is local-only (excluded from CRR + replication) because every controller action is recorded once on the runtime + that executes it. +- `apps/desktop/src/main/services/usage/localDay.ts` — machine-local calendar + day helpers (`localDayKey`, `localDayStart`, `localDayOffset`, + `localDayOrdinal`) so daily points and range boundaries follow the user's + timezone instead of UTC. +- `apps/desktop/src/shared/types/usage.ts` — shared range, scope, daily-point, + freshness, estimation, GitHub-vs-local activity, aggregate, and + client-attribution contracts. Supported presets are today, 7d, 30d, year, + and all time; scopes are `machine` and `project`; `AdeUsageEstimationKind` + (`exact` / `chars` / `distribution` / `mixed`) records how a provider's token + counts were obtained. +- `apps/desktop/src/renderer/components/usage/ActivityModule.tsx` — the + tabbed activity/tokens/code/clients module that replaced the old carousel. + `ActivityModule` renders `full` and `compact` variants (optional range + control) and persists the selected tab and day/week/month/year range to + `ade.activity.module.v1` (migrating the retired `ade.stats.carousel.v1` + key). `WorkActivityModule` is the self-fetching compact wrapper that reads + `usage.getAdeStats` and renders directly below the empty Work composer on + desktop and web. +- `apps/desktop/src/renderer/components/usage/providerColors.ts` — theme-aware + brand color palette for usage bars and legends. `providerColor(provider, + theme)` returns a per-provider brand color (Claude's rust family, distinct + hues for the other providers) with a deterministic hashed fallback for + unknown providers. - `apps/desktop/src/renderer/components/settings/ProxyAndPreviewSection.tsx` — proxy/preview configuration UI. - `apps/desktop/src/renderer/components/settings/DiagnosticsDashboardSection.tsx` @@ -303,59 +340,57 @@ Auto-update (top-bar control, not a settings tab): - `apps/desktop/src/main/services/updates/autoUpdateService.ts` — electron-updater wrapper that owns the renderer-visible `AutoUpdateSnapshot` (`status: "idle" | "checking" | "downloading" - | "ready" | "installing" | "error"`, current/target versions, - progress, structured failure details, and recently installed notice). - It keeps `autoDownload` off, reads the release artifact size, preflights - free space on the updater-cache volume, and starts the download only after - the check passes. Before install it re-checks the staged version and - preflights `process.execPath` so staging/replacement capacity is measured on - the installed application's volume. Disk-full, quota, capacity, network, - signature, verification, permission, and installer failures retain their - phase in the snapshot. Verified downloads are preserved when safe; partial - or untrusted cache data is cleared. The install preparation and native - handoff are bounded by a watchdog, so `installing` cannot remain stuck - indefinitely. On the next launch, `reconcilePersistedUpdateState` + | "ready" | "installing" | "error"`, version, progress, recently + installed notice). Tracks superseded downloads against the current + ready version via `compareUpdateVersions` (a SemVer-aware + comparator that handles `v` prefixes, missing patch, and + prerelease ordering) so a same-or-older `update-available` while a + newer build is already staged is logged and ignored instead of + clobbering the staged installer; packaged builds schedule startup and + periodic update checks, while dev/source launches leave those timers off + to avoid surfacing missing-updater-config errors; if the new build is strictly + newer, the cached installer dir is wiped and the snapshot + transitions back through `downloading`. `quitAndInstall()` is + asynchronous: it gates on the current snapshot being `ready`, + re-runs `updater.checkForUpdates()` with `allowReady: true` to + confirm the staged installer is still the latest, and only then + flips the snapshot to `installing`, persists the + `pendingInstallUpdate` global-state row, and calls + `updater.quitAndInstall(false, true)`. If the refresh check fails, + it surfaces the error, drops the cache, and clears the pending + install. On the next launch, `reconcilePersistedUpdateState` matches the running version against `pendingInstallUpdate` using - a SemVer-aware comparator (so `>=` target counts as installed, even if the - running build is one ahead), populates + the same SemVer comparator (so `>=` target counts as installed, + even if the running build is one ahead), populates `recentlyInstalledUpdate` with the actual running version, and cleans up the updater cache directory. On packaged launches with a recently installed update, the desktop refreshes the per-user runtime service so `ade serve` re-execs the updated bundled CLI and clients do not fall back to an isolated build-mismatch runtime. -- `apps/desktop/src/main/services/updates/autoUpdateErrors.ts` — measures - available bytes with `statfs`, estimates conservative download/install - headroom, extracts artifact size from update metadata, and classifies - updater failures into the shared kind/phase contract. -- `apps/desktop/src/shared/types/core.ts` — `AutoUpdateSnapshot`, - `AutoUpdateErrorDetails`, error kinds, and update phases shared across main, - preload IPC, and renderer. - `apps/desktop/src/renderer/components/app/AutoUpdateControl.tsx` — - the app-shell top-bar badge. It shows checking, download progress, install, - and quit/reopen states. An error snapshot remains visible as an amber - **Not enough space to update** or **Update failed** button instead of - disappearing. -- `apps/desktop/src/renderer/components/app/AutoUpdateErrorDialog.tsx` — - accessible failure detail and recovery dialog. It shows current/target - versions, failed phase, classified cause, capacity estimate and affected - path when available, whether the verified download was retained, changelog, - and a concurrency-safe **Check again** action. - -The post-install dialog is a centered card titled -"Updated to vX.Y.Z" (the running version) with an X close button and -click-outside dismiss; it offers a "Changelog" button that opens -`recentlyInstalled.releaseNotesUrl` (the docs changelog) and a "View on GitHub" -button that opens `recentlyInstalled.githubReleaseUrl` (the GitHub release -page). Each button is shown only when its URL is present; opening either link -also dismisses the notice. + the small badge in the app shell top bar. Shows "Checking for + updates" / "Downloading vX.Y.Z (NN%)" / "Install update vX.Y.Z" / + "ADE will quit and reopen" depending on the snapshot. Clicking the + install affordance prompts the user, sets a local + `installRequested` flag, and calls + `window.ade.updateQuitAndInstall()`; if the IPC returns `false` + (refresh check failed, no longer ready, etc.) the flag is cleared + so the badge falls back to the underlying snapshot. While + `installing` (or after the user clicks install but before the main + process flips status), the badge animates in fuchsia and is + disabled. The post-install dialog is a centered card titled + "Updated to vX.Y.Z" (the running version) with an X close button + and click-outside dismiss; it offers a "Changelog" button that opens + `recentlyInstalled.releaseNotesUrl` (the docs changelog) and a "View + on GitHub" button that opens `recentlyInstalled.githubReleaseUrl` + (the GitHub release page). Each button is shown only when its URL is + present; opening either link also dismisses the notice. ## Detail docs - [configuration-schema.md](./configuration-schema.md) — shape of `.ade/ade.yaml` and `.ade/local.yaml` as consumed by `projectConfigService`; types in `shared/types/config.ts`. -- [desktop-auto-update.md](./desktop-auto-update.md) — macOS updater cache and - staging flow, disk-space estimate, failure timing, and cache policy. - [first-run.md](./first-run.md) — the first-run setup dashboard, stack detection, existing-lane import, and the UX contract that lets users skip optional integrations. @@ -459,9 +494,9 @@ changing rather than which service backs it: | AI Connections | `ProvidersSection.tsx` | Provider CLIs, models, API-key status, provider readiness, OpenCode runtime diagnostics. When Claude is installed but unauthenticated, the shared `Login to Claude` CTA opens a primary-lane terminal running `claude auth login` and navigates to Work. Legacy `?tab=providers` lands here. | | Background Jobs | `AiFeaturesSection.tsx` | AI-powered automations: summaries, PR descriptions, commit messages, auto-naming, plus the project-wide **Pause all scheduled work** control for Claude wakeups, cron tasks, and loops. Pausing keeps schedules armed and suppresses `nextWakeAt`; on resume each overdue schedule runs once before cron work returns to its normal cadence. Legacy `?tab=automations` lands here. Each feature row has an independent reasoning-effort override (`ReasoningEffortPicker` with `useFamilyDefaults={false}`). | | Lane Templates | `LaneTemplatesSection.tsx`, `LaneBehaviorSection.tsx` | Lane init recipes and lane lifecycle policy | -| Stats | `AdeUsageSection.tsx`, `UsageActivityCarousel.tsx` | Fast cached local-provider, project-DB, GitHub, and cross-client activity with animated day/week/month/year charts. Deep links from `?tab=usage` and `?tab=stats` land here. | +| Stats | `AdeUsageSection.tsx`, `ActivityModule.tsx`, `providerColors.ts` | Usage page with live Limits plus a sectioned Activity dashboard: overview stat tiles, an activity/tokens/code/clients module, and split AI-usage and GitHub-vs-local Code & PRs panels, with project/machine scope and day/week/month/year/all ranges. Fast cached local-provider, project-DB, GitHub, and cross-client activity. Deep links from `?tab=usage` and `?tab=stats` land here. | -> Live provider quota windows and automation guardrails live in the top-bar Usage popup (`HeaderUsageControl.tsx` → `UsageQuotaPanel.tsx` + collapsible `BudgetCapEditor`). Settings > Stats is the retrospective cross-client ADE activity dashboard. +> Live provider quota windows and automation guardrails live in the top-bar Usage popup (`HeaderUsageControl.tsx` → `UsageQuotaPanel.tsx` + collapsible `BudgetCapEditor`) and Settings > Usage > Limits. The Activity tab is the retrospective cross-client dashboard. The Settings page itself (`SettingsPage.tsx`) has a legacy alias diff --git a/docs/features/onboarding-and-settings/usage-tracking.md b/docs/features/onboarding-and-settings/usage-tracking.md new file mode 100644 index 000000000..892991a91 --- /dev/null +++ b/docs/features/onboarding-and-settings/usage-tracking.md @@ -0,0 +1,116 @@ +# Usage tracking strategy + +ADE treats live provider limits and retrospective token/cost history as two +different workloads. Live limits must remain fast enough for the desktop, +terminal, CLI, and paired mobile clients; history scans may walk large local +ledgers and therefore run only through the Activity path. + +This design was reviewed against CodexBar `main` at +[`8489002e19eed002016b29faa7de0f8c5371c65c`](https://github.com/steipete/CodexBar/tree/8489002e19eed002016b29faa7de0f8c5371c65c) +on 2026-07-10. The relevant upstream references are +[`claude.md`](https://github.com/steipete/CodexBar/blob/8489002e19eed002016b29faa7de0f8c5371c65c/docs/claude.md), +[`codex.md`](https://github.com/steipete/CodexBar/blob/8489002e19eed002016b29faa7de0f8c5371c65c/docs/codex.md), +[`providers.md`](https://github.com/steipete/CodexBar/blob/8489002e19eed002016b29faa7de0f8c5371c65c/docs/providers.md), and +[`refresh-loop.md`](https://github.com/steipete/CodexBar/blob/8489002e19eed002016b29faa7de0f8c5371c65c/docs/refresh-loop.md). + +## ADE versus CodexBar + +| Concern | ADE before ADE-117 | CodexBar reference | ADE after ADE-117 | +|---|---|---|---| +| Claude source | Anthropic OAuth usage endpoint only | App auto: OAuth → Claude PTY → web | OAuth first; explicit user refresh may use a bounded Claude PTY fallback. Automatic refresh never opens the interactive CLI. | +| Claude auth | Every macOS credential lookup could invoke `security`, including background work | Explicit Keychain prompt policy; background reads can be no-UI | Background reads skip Keychain and use the credentials file/cache. Keychain repair is reserved for explicit user refresh. | +| Codex source | HTTP quota endpoint, then redundant app-server work even when HTTP returned complete windows | OAuth HTTP or CLI RPC, with source-specific fallback and bounded subprocesses | HTTP success returns immediately. RPC runs only for 401 recovery or a successful but unrecognized HTTP schema. | +| Cost/history source | Provider JSONL/SQLite scans could run as part of explicit quota refresh | Cost scans have separate caches and scheduling | `refreshHistory()` owns ledger invalidation/scans. `forceRefresh()` is quota-only. | +| Refresh cadence | Fixed two-minute polling | Fixed or adaptive cadence with coalescing | Adaptive demand lease: 60 s while usage is visible/recently requested, 2 min normally, 5 min after 15 min idle. One provider batch is in flight at a time. | +| Cache | Last snapshot plus provider/GitHub scan caches | Separate usage, cookie, and cost caches | Separate in-flight quota and history work. Each provider retains its last-good windows and last-success timestamp. | +| Stale behavior | A failed refresh could leave source/freshness ambiguous | Stale/error state remains visible | Provider status carries `updatedAt`, `lastAttemptAt`, error kind, optional next retry, and the source when one is known. Unexpired last-good windows remain visible with their source and an explicit stale/re-auth state. | +| Latency | User refresh could wait on every provider ledger and GitHub scan | Expensive storage scans do not block normal usage refresh | Quota refresh performs only provider credential/quota work. Large history scans can remain pending while a quota refresh completes. | +| Errors | Mostly provider-prefixed strings | Provider-specific surfaced errors and bounded timeouts | Structured classification for auth, forbidden, conflict, rate limit, timeout, network, invalid response, and unavailable. `Retry-After` and exponential backoff prevent refresh storms. | + +## Why Claude appeared to take forever + +The slow path was not only Anthropic's endpoint. An explicit refresh invalidated +cost caches and could wait for broad Claude, Codex, Cursor, OpenCode, Droid, +Copilot, Gemini, and GitHub history scans. A macOS credential read could also +spend five seconds in `security`, and overlapping UI/startup requests were +coalesced behind whichever large operation started first. The result was a +correct quota response queued behind unrelated disk and subprocess work. + +ADE now records a structured `usage.refresh.phase` entry for credentials, +quota HTTP, CLI fallback, and history phases, including provider, trigger, +duration, outcome, and error kind. These entries identify network/auth latency +without logging credentials or quota payloads. + +## Reproducible baseline + +The pre-change baseline came from ADE structured logs for 2026-07-10. Measure +the same events with: + +```sh +rg 'usage\.(forceRefresh|getUsageSnapshot)' ~/.ade/logs -g '*.log' +``` + +Observed wall time: + +| Operation | Samples | min | p50 | p90 | max | mean | +|---|---:|---:|---:|---:|---:|---:| +| `forceRefresh` | 44 | 6.648 s | 19.138 s | 27.204 s | 30.002 s | 20.414 s | +| `getUsageSnapshot` | 71 | 0.501 s | 7.359 s | 37.091 s | 121.395 s | 14.676 s | + +For regression coverage, run: + +```sh +npm --prefix apps/desktop exec -- vitest run src/main/services/usage/usageTrackingService.test.ts +``` + +The suite fixes the behavioral baseline: quota-only refresh must not start any +ledger scanner, must complete while a deliberately pending large-ledger scan is +still unresolved, Codex HTTP success must not spawn the CLI, and 401/403/409/429, +timeout, schema drift, `Retry-After`, stale carry-forward, and Claude CLI parsing +must remain covered. + +## Provider strategy boundary + +`UsageProviderStrategy` defines the live-quota boundary, while the coordinator +keeps four concerns separate: + +- `poll(context)` obtains a provider-authoritative limit snapshot. +- local history scanners intentionally remain outside the strategy and never + run from the live refresh path. +- source and auth behavior are provider-owned. +- the coordinator owns coalescing, adaptive cadence, backoff, cache persistence, + stale carry-forward, phase timing, and publication to every client. + +Only provider-authoritative quota or billing data may create a live limit. Local +token estimates remain Activity/history data and must not be presented as a +personal subscription quota. + +## Additional provider ranking + +| Rank | Provider | What is actually available | ADE position | +|---|---|---|---| +| 1 | GitHub Copilot | GitHub's supported billing APIs expose personal AI-credit/premium-request usage for self-billed accounts with `Plan: read`; organization-managed plans require organization administration access. | Best official next candidate, but label it billing usage rather than live remaining quota and gate it on the correct GitHub permission/account ownership. | +| 2 | Gemini | Official Gemini documentation exposes project rate-limit dimensions and directs users to AI Studio for active limits. CodexBar additionally uses Gemini CLI OAuth and its quota RPC, but that is not a documented personal subscription quota contract. | Keep existing local history. Prototype only behind an experimental strategy until Google documents a stable usage/remaining endpoint and auth scope. | +| 3 | Cursor | Cursor's supported Admin API provides team member usage/spend; it is not a general personal-plan API. | Offer only for explicitly configured team admins; do not silently reuse personal credentials or call token estimates quota. | +| 4 | Factory Droid, OpenCode, local/open providers | ADE can scan local histories, but no supported personal remaining-quota API was verified for the normal user credentials ADE already holds. | Activity/history only. Reassess when a provider publishes a stable authenticated quota API. | + +Primary provider references: [GitHub billing usage](https://docs.github.com/en/rest/billing/usage), +[Gemini rate limits](https://ai.google.dev/gemini-api/docs/rate-limits), and +[Cursor Admin API](https://docs.cursor.com/en/account/teams/admin-api). + +## Desktop, CLI, remote, and mobile parity + +- Desktop Settings shows Limits and Activity tabs. Limits reads cached live + quota; Activity explicitly owns expensive history refresh. +- `ade usage refresh` refreshes quota only; `ade usage refresh --history` runs + the separate history path. `ade code` `/usage` reads the runtime snapshot for + every tracked quota provider and displays source metadata. +- Remote desktop/runtime calls use the same runtime actions as a local project. +- Paired iOS devices request `usage.getQuotaSnapshot` for the host-cached + snapshot. Pull-to-refresh and Settings refresh use `usage.refreshQuota` and + therefore run a bounded provider flow on the paired host without interactive + Keychain or bare-TUI prompts. The phone stores the last snapshot in a + host-scoped local cache, clears it when the active host changes to an + unsupported or unidentified machine, shows source/staleness, and never + receives provider credentials. Older hosts that do not advertise the two + quota actions remain connected in limited mode and show update guidance. diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 9dbdb429a..8a8d2bdad 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -378,7 +378,8 @@ Canonical files (`apps/ade-cli/src/services/sync/`): full frame. Protocol version is `1`. Default host port is `8787`. - `syncRemoteCommandService.ts` (~4,600 lines) — command registry (lanes, chat, git, PR, sessions, conflicts, files, - `usage.getAdeStats`, `prs.getMobileSnapshot`, `lanes.presence.*`, + `usage.getAdeStats`, `usage.getQuotaSnapshot`, `usage.refreshQuota`, + `prs.getMobileSnapshot`, `lanes.presence.*`, `work.runQuickCommand`, `work.startCliSession`, `work.listExternalSessions`, `work.importExternalSession`, `chat.recoverCodexTurn`, `modelPicker.*`, …). @@ -404,6 +405,10 @@ Canonical files (`apps/ade-cli/src/services/sync/`): `usage.getAdeStats` is a viewer-allowed project read backed by the runtime's usage tracker; it serves cached provider/GitHub data plus live DB aggregates to iOS and web without replicating the local-only raw interaction ledger. + `usage.getQuotaSnapshot` reads the cached live Claude/Codex limit snapshot, + while `usage.refreshQuota` runs the bounded quota-only provider path with + interactive auth disabled. Neither remote quota action starts local provider + history scans or sends provider credentials to the controller. Lane snapshot commands accept decoration flags so mobile can refresh runtime/session buckets without recomputing conflict status, rebase suggestions, or auto-rebase status on every light refresh; lane detail diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 0d3e9cbdc..5028f49f9 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -121,6 +121,8 @@ apps/ios/ │ │ │ # handling, prefs, push.getStatus │ │ ├── LiveActivityService.swift # ActivityKit start/update/end for the │ │ │ # aggregate "agent runs" activity +│ │ ├── MobileUsageQuotaStore.swift # host-scoped cached Claude/Codex +│ │ │ # quota snapshot + refresh state │ │ ├── Dictation/ # SpeechDictationService, │ │ │ # DictationController, deterministic │ │ │ # cleanup, VoiceGlossary loader @@ -184,8 +186,8 @@ apps/ios/ │ │ │ # Work*Helpers, WorkNewChatScreen (chat/CLI │ │ │ # launcher + per-project interface │ │ │ # preference shared with Hub), -│ │ │ # WorkUsageActivityCarousel (fixed -│ │ │ # cross-client new-chat charts), +│ │ │ # WorkUsageActivityCarousel (host quota +│ │ │ # limits + cross-client activity charts), │ │ │ # WorkImportSessionScreen + │ │ │ # WorkExternalSessionAffordances │ │ │ # (provider session browse/details, @@ -239,6 +241,7 @@ apps/ios/ │ │ │ # sync.getWebPairingInfo), │ │ │ # SettingsConnectionHeader, │ │ │ # host compatibility warning banner, +│ │ │ # full usage limits + refresh section, │ │ │ # SettingsPinSheet, SettingsPushDeliverySection │ │ │ # (push + Live Activity diagnostics/toggles), │ │ │ # SettingsVoiceInputSection @@ -1296,11 +1299,25 @@ against them instead of relying on hardcoded mobile assumptions. A runtime that disables a command via policy change is immediately reflected in the phone's UI on the next descriptor read. -`usage.getAdeStats` is a viewer-allowed project command shared by iOS and the -web client. It returns the same stale-while-revalidate snapshot used by desktop -Stats, including daily points and `desktop` / `mobile` / `tui` / `web` client -attribution. The phone uses it only for the fixed Work new-chat carousel; it -does not duplicate the full desktop Stats page. +The usage commands are viewer-allowed project actions: + +- `usage.getQuotaSnapshot` reads the host's cached Claude/Codex quota windows + without doing provider or ledger work. `usage.refreshQuota` runs a bounded + quota-only refresh with interactive host authentication disabled. Work shows + a compact Limits summary and Settings shows the full windows, source, + freshness, stale/error state, reset times, and explicit refresh control. +- `usage.getAdeStats` returns the same stale-while-revalidate activity snapshot + used by desktop Stats, including daily points and `desktop` / `mobile` / + `tui` / `web` client attribution. The phone uses it for the Activity mode of + the Work new-chat carousel rather than duplicating the full desktop Stats + page. + +`MobileUsageQuotaStore` persists snapshots by host identity, rebinds on machine +changes, ignores an older in-flight response after a host switch, and clears +the visible snapshot when the active host is unknown or does not advertise the +quota actions. Provider credentials remain on the host. A legacy host therefore +stays connected in limited mode and shows update guidance instead of leaking a +different machine's cached limits. ## Implementation status (phone specifics) diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index 7abf4e9e7..c296e8070 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -153,6 +153,14 @@ over the wire. A controller only invokes an action the host advertises in desktop Stats: provider tokens, project-DB activity, daily points, and `desktop` / `mobile` / `tui` / `web` / `api` client attribution. It does not replicate or return raw `usage_events` rows. +- `getQuotaSnapshot` — viewer-allowed project read of the runtime's cached + Claude/Codex quota snapshot. It returns provider windows plus source, + freshness, retry, and stale/error state without starting provider or ledger + work. +- `refreshQuota` — viewer-allowed project refresh of Claude/Codex quota only. + The host disables interactive auth for this remote path, so it never opens a + Keychain prompt or a bare CLI/TUI login flow on behalf of the controller. + Local history scanners remain behind the separate desktop/CLI Activity path. **Lanes** (`lanes.*`) - `list`, `listDeleteProgress`, `refreshSnapshots`, `getDetail`, diff --git a/docs/features/web-client/README.md b/docs/features/web-client/README.md index 7ca3e5cec..0d9cde9c1 100644 --- a/docs/features/web-client/README.md +++ b/docs/features/web-client/README.md @@ -70,7 +70,7 @@ Browser `window.ade` adapter: web implementations of desktop renderer namespaces, mixing remote commands, sync sub-protocols, and local browser-only state. `misc.ts` routes `window.ade.usage.getAdeStats` through the viewer-allowed - `usage.getAdeStats` command so the reused empty-Work activity carousel shows + `usage.getAdeStats` command so the reused empty-Work activity module shows the runtime's cached cross-client aggregate instead of an empty native stub. `personalChats.ts` invokes runtime-scoped `personalChats.*` actions with `requireProject: false`, adds