diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 0d393d64f..1d9a91b47 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -4311,15 +4311,6 @@ app.whenReady().then(async () => { 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); - }, - projectRoot, - }); const storageInsightsService = createStorageInsightsService({ projectRoot, adeHome: machineAdeLayout.adeDir, @@ -4330,7 +4321,6 @@ app.whenReady().then(async () => { // because activity cannot be known here. isPathActive: () => true, }); - usageTrackingService.start(); const diskPressureMonitor = createDiskPressureMonitor({ roots: [projectRoot, machineAdeLayout.adeDir], logger, @@ -4352,7 +4342,10 @@ app.whenReady().then(async () => { adeCliService: shellContext.adeCliService, builtInBrowserService, diskPressureMonitor, - usageTrackingService, + // The machine brain owns quota polling and streams its snapshots through + // the runtime event pump. A second renderer-side tracker races those + // events and can make the compact header disagree with the open panel. + usageTrackingService: null, storageInsightsService, }; }; diff --git a/apps/desktop/src/main/services/usage/usageProviderStrategies.ts b/apps/desktop/src/main/services/usage/usageProviderStrategies.ts index 08717d33b..f33dcc602 100644 --- a/apps/desktop/src/main/services/usage/usageProviderStrategies.ts +++ b/apps/desktop/src/main/services/usage/usageProviderStrategies.ts @@ -13,7 +13,8 @@ export type UsageProviderPollContext = { reason: UsageRefreshReason; }; -export type UsageProviderPollResult = { +export type FreshUsageProviderPollResult = { + disposition?: "fresh"; windows: UsageWindow[]; /** Provider-level Codex spend control state, not tied to an individual quota window. */ spendControlReached?: boolean; @@ -26,6 +27,24 @@ export type UsageProviderPollResult = { providerMessages?: UsageProviderMessage[]; }; +type PreserveUsageProviderPollResult = { + /** The non-interactive caller could not authoritatively check this provider. */ + disposition: "preserve_previous"; + windows: []; + errors: []; + source?: UsageProviderSource; + spendControlReached?: never; + errorKind?: never; + retryAfterMs?: never; + extraUsage?: never; + dailyUsage7d?: never; + providerMessages?: never; +}; + +export type UsageProviderPollResult = + | FreshUsageProviderPollResult + | PreserveUsageProviderPollResult; + /** * Boundary between the quota scheduler and provider-specific auth/fallback * behavior. Historical ledger scanners intentionally do not implement this diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts index 8c69f3ed1..9e050f905 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts @@ -3776,6 +3776,197 @@ describe("usage reliability: non-destructive merge", () => { service.dispose(); }); + it("keeps a valid Claude status when a mobile refresh cannot read interactive credentials", async () => { + const logger = createLogger(); + const weeklyMs = 3 * 24 * 60 * 60 * 1000; + const claudeWindow = { + provider: "claude" as const, + windowType: "weekly" as const, + percentUsed: 37, + resetsAt: futureIso(weeklyMs), + resetsInMs: weeklyMs, + }; + const claudeExtraUsage = { + provider: "claude" as const, + isEnabled: true, + usedCreditsUsd: 9, + monthlyLimitUsd: 100, + utilization: 9, + currency: "usd", + }; + const pollClaudeUsage = vi + .fn() + .mockResolvedValueOnce({ + windows: [claudeWindow], + errors: [], + source: "oauth" as const, + extraUsage: claudeExtraUsage, + }) + .mockResolvedValueOnce({ + disposition: "preserve_previous" as const, + windows: [], + errors: [], + source: "oauth" as const, + }); + const service = createUsageTrackingService({ + logger, + dependencies: { + pollClaudeUsage, + pollCodexUsage: vi.fn(async () => ({ windows: [], errors: [] })), + }, + }); + + const first = await service.poll({ reason: "user" }); + const refreshed = await service.poll({ reason: "remote" }); + + expect(first.providerStatus?.claude?.state).toBe("ok"); + expect(refreshed.providerStatus?.claude).toEqual(first.providerStatus?.claude); + expect(refreshed.windows.filter((window) => window.provider === "claude")).toHaveLength(1); + expect(refreshed.windows.find((window) => window.provider === "claude")).toMatchObject(claudeWindow); + expect(refreshed.extraUsage).toEqual([claudeExtraUsage]); + expect(refreshed.errors).toEqual([]); + + service.dispose(); + }); + + it("downgrades a preserved provider after its final carried window expires", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-17T12:00:00.000Z")); + const pollClaudeUsage = vi + .fn() + .mockResolvedValueOnce({ + windows: [{ + provider: "claude" as const, + windowType: "weekly" as const, + percentUsed: 37, + resetsAt: "2026-07-17T12:01:00.000Z", + resetsInMs: 60_000, + }], + errors: [], + source: "oauth" as const, + }) + .mockResolvedValueOnce({ + disposition: "preserve_previous" as const, + windows: [], + errors: [], + source: "oauth" as const, + }); + const service = createUsageTrackingService({ + logger: createLogger(), + dependencies: { + pollClaudeUsage, + pollCodexUsage: vi.fn(async () => ({ windows: [], errors: [] })), + }, + }); + + try { + const first = await service.poll({ reason: "user" }); + expect(first.providerStatus?.claude?.state).toBe("ok"); + + vi.advanceTimersByTime(60_000); + const expired = await service.poll({ reason: "remote" }); + + expect(expired.windows.filter((window) => window.provider === "claude")).toEqual([]); + expect(expired.providerStatus?.claude).toMatchObject({ + state: "stale", + lastSuccessAt: first.lastPolledAt, + }); + expect(expired.providerStatus?.claude?.message).toMatch(/expired/i); + } finally { + service.dispose(); + vi.useRealTimers(); + } + }); + + it("clears a sticky Claude auth error when a remote refresh cannot verify credentials", async () => { + const logger = createLogger(); + const weeklyMs = 3 * 24 * 60 * 60 * 1000; + const claudeWindow = { + provider: "claude" as const, + windowType: "weekly" as const, + percentUsed: 37, + resetsAt: futureIso(weeklyMs), + resetsInMs: weeklyMs, + }; + const pollClaudeUsage = vi + .fn() + .mockResolvedValueOnce({ windows: [claudeWindow], errors: [], source: "oauth" as const }) + .mockResolvedValueOnce({ + windows: [], + errors: ["claude: no non-interactive credentials found"], + errorKind: "auth" as const, + source: "oauth" as const, + }) + .mockResolvedValueOnce({ + disposition: "preserve_previous" as const, + windows: [], + errors: [], + source: "oauth" as const, + }); + const service = createUsageTrackingService({ + logger, + dependencies: { + pollClaudeUsage, + pollCodexUsage: vi.fn(async () => ({ windows: [], errors: [] })), + }, + }); + + await service.poll({ reason: "user" }); + const staleError = await service.poll({ reason: "remote" }); + const refreshed = await service.poll({ reason: "remote" }); + + expect(staleError.providerStatus?.claude?.state).toBe("unauthed"); + expect(refreshed.providerStatus?.claude).toMatchObject({ + state: "stale", + lastSuccessAt: expect.any(String), + }); + expect(refreshed.providerStatus?.claude?.message).toBeUndefined(); + expect(refreshed.windows.filter((window) => window.provider === "claude")).toHaveLength(1); + expect(refreshed.errors).toEqual([]); + + service.dispose(); + }); + + it("preserves a genuine Claude auth error while automatic polling is backed off", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-17T12:00:00.000Z")); + const logger = createLogger(); + const pollClaudeUsage = vi + .fn() + .mockResolvedValueOnce({ + windows: [], + errors: ["claude: API returned 401"], + errorKind: "auth" as const, + source: "oauth" as const, + }) + .mockResolvedValueOnce({ + disposition: "preserve_previous" as const, + windows: [], + errors: [], + source: "oauth" as const, + }); + const service = createUsageTrackingService({ + logger, + dependencies: { + pollClaudeUsage, + pollCodexUsage: vi.fn(async () => ({ windows: [], errors: [] })), + }, + }); + + const failed = await service.poll({ reason: "user" }); + const backedOff = await service.poll({ reason: "automatic" }); + vi.advanceTimersByTime(61_000); + const nonInteractive = await service.poll({ reason: "automatic" }); + + expect(failed.providerStatus?.claude?.state).toBe("unauthed"); + expect(backedOff.providerStatus?.claude).toEqual(failed.providerStatus?.claude); + expect(nonInteractive.providerStatus?.claude).toEqual(failed.providerStatus?.claude); + expect(pollClaudeUsage).toHaveBeenCalledTimes(2); + + service.dispose(); + vi.useRealTimers(); + }); + it("attaches per-window pacing for both the 5-hour and weekly windows", async () => { const logger = createLogger(); const fiveHourMs = 2 * 60 * 60 * 1000; // 60% of the 5h window elapsed diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.ts b/apps/desktop/src/main/services/usage/usageTrackingService.ts index 2a409b255..a2fde8c0b 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.ts @@ -87,6 +87,7 @@ import { type AdeDatabaseUsageStats, } from "./usageStatsStore"; import type { + FreshUsageProviderPollResult, UsageProviderPollContext, UsageProviderPollResult, UsageProviderStrategy, @@ -497,7 +498,7 @@ async function captureClaudeCliUsage(logger: Logger): Promise { }); } -async function pollClaudeViaCli(logger: Logger): Promise { +async function pollClaudeViaCli(logger: Logger): Promise { try { const output = await captureClaudeCliUsage(logger); const windows = parseClaudeCliUsage(output); @@ -544,11 +545,10 @@ async function pollClaudeUsage( ); } return { + disposition: "preserve_previous", windows: [], + errors: [], source: "oauth", - extraUsage: null, - errors: ["claude: no non-interactive credentials found"], - errorKind: "auth", }; } @@ -654,7 +654,7 @@ async function pollClaudeUsage( async function pollCodexUsage( logger: Logger, context: UsageProviderPollContext = { reason: "user" }, -): Promise { +): Promise { const creds = await measureUsagePhase( logger, { provider: "codex", phase: "credentials", reason: context.reason }, @@ -730,7 +730,7 @@ async function pollCodexUsage( } } -async function pollCodexViaCliRpc(logger: Logger): Promise { +async function pollCodexViaCliRpc(logger: Logger): Promise { const windows: UsageWindow[] = []; const errors: string[] = []; let spendControlReached: boolean | undefined; @@ -2612,6 +2612,13 @@ export function createUsageTrackingService({ } try { const result = await strategy.poll({ reason }); + if (result.disposition === "preserve_previous") { + return { + provider: strategy.provider, + skipped: true as const, + result, + }; + } if (result.windows.length > 0) { providerFailureCount[strategy.provider] = 0; providerNextRetryAtMs[strategy.provider] = 0; @@ -2641,10 +2648,18 @@ export function createUsageTrackingService({ }); 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); + const emptyPollResult: FreshUsageProviderPollResult = { windows: [], errors: [] }; + const claudePollEntry = resultsByProvider.get("claude"); + const codexPollEntry = resultsByProvider.get("codex"); + const claudeResult = claudePollEntry && !claudePollEntry.skipped + ? claudePollEntry.result + : emptyPollResult; + const codexResult = codexPollEntry && !codexPollEntry.skipped + ? codexPollEntry.result + : emptyPollResult; + for (const entry of providerResults) { + if (!entry.skipped) 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. @@ -2652,14 +2667,34 @@ export function createUsageTrackingService({ const prevWindows = lastSnapshot?.windows ?? []; const providerStatus: UsageProviderStatusMap = {}; const mergedRaw: UsageWindow[] = []; - for (const { provider, result, skipped } of providerResults) { + for (const entry of providerResults) { + const { provider, result, skipped } = entry; const previousStatus = lastSnapshot?.providerStatus?.[provider] ?? null; - if (skipped && previousStatus) { - providerStatus[provider] = previousStatus; - mergedRaw.push(...filterUnexpiredCarriedWindows( + if (skipped) { + const carriedWindows = filterUnexpiredCarriedWindows( prevWindows.filter((window) => window.provider === provider), polledAt, - )); + ); + mergedRaw.push(...carriedWindows); + const hasLegacyNonInteractiveCredentialError = provider === "claude" + && lastSnapshot?.errors.includes("claude: no non-interactive credentials found") === true; + if ( + previousStatus + && !hasLegacyNonInteractiveCredentialError + && (previousStatus.state !== "ok" || carriedWindows.length > 0) + ) { + providerStatus[provider] = previousStatus; + } else if (previousStatus?.lastSuccessAt) { + providerStatus[provider] = { + state: "stale", + lastSuccessAt: previousStatus.lastSuccessAt, + updatedAt: previousStatus.updatedAt ?? previousStatus.lastSuccessAt, + ...(previousStatus.source ? { source: previousStatus.source } : {}), + ...(carriedWindows.length === 0 + ? { message: `${PROVIDER_DISPLAY_NAME[provider]} usage window expired` } + : {}), + }; + } continue; } const nextRetryMs = providerNextRetryAtMs[provider] ?? 0; @@ -2692,7 +2727,7 @@ export function createUsageTrackingService({ const pacingByProvider = calculatePacingByProvider(allWindows); const extraUsage: ExtraUsage[] = []; if (claudeResult.extraUsage) extraUsage.push(claudeResult.extraUsage); - else if (providerStatus.claude?.state === "stale" || providerStatus.claude?.state === "unauthed") { + else if (claudePollEntry?.skipped || providerStatus.claude?.state === "stale" || providerStatus.claude?.state === "unauthed") { const previousClaudeExtra = lastSnapshot?.extraUsage.find((extra) => extra.provider === "claude"); if (previousClaudeExtra) extraUsage.push(previousClaudeExtra); } @@ -2706,7 +2741,6 @@ export function createUsageTrackingService({ ...(lastSnapshot?.providerMessages ?? []).filter((message) => message.provider !== "codex"), ...(codexResult.providerMessages ?? []), ]; - const codexPollEntry = resultsByProvider.get("codex"); let spendControlReached = codexResult.spendControlReached; if (typeof spendControlReached !== "boolean" && (codexPollEntry?.skipped || codexResult.windows.length === 0)) { // Codex wasn't polled this round — retain the last known spend-control state. diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index e11c3069e..2ef373ab0 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -4155,6 +4155,11 @@ describe("preload OAuth bridge", () => { expect(iosSimulator).toHaveBeenCalledWith(iosEvent); expect(appControl).toHaveBeenCalledWith(appControlEvent); + const localUsageListener = on.mock.calls.find(([channel]) => channel === IPC.usageEvent)?.[1]; + expect(typeof localUsageListener).toBe("function"); + localUsageListener({}, { ...usageSnapshot, lastPolledAt: "newer-local-snapshot" }); + expect(usageUpdate).toHaveBeenCalledTimes(1); + for (const unsubscribe of unsubscribers) unsubscribe(); emit(11, { type: "usage", snapshot: { ...usageSnapshot, lastPolledAt: "later" } }); expect(usageUpdate).toHaveBeenCalledTimes(1); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 37fb63652..7d2a38d4a 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2857,8 +2857,12 @@ function subscribePtyExitEvents( function subscribeUsageUpdateEvents( cb: (payload: UsageSnapshot) => void, ): () => void { - const removeLocal = subscribeLocalUsageUpdateEvents(cb); - const removeRemote = subscribeRemoteUsageUpdateEvents(cb); + const removeLocal = subscribeLocalUsageUpdateEvents((payload) => { + if (!currentProjectBinding) cb(payload); + }); + const removeRemote = subscribeRemoteUsageUpdateEvents((payload) => { + if (currentProjectBinding) cb(payload); + }); return () => { removeRemote(); removeLocal(); diff --git a/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx index 1486b5644..c9fea3d54 100644 --- a/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx +++ b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx @@ -13,6 +13,7 @@ import { ADE_BROWSER_VIEW_OCCLUSION_END_EVENT, ADE_BROWSER_VIEW_OCCLUSION_START_EVENT, } from "../../lib/workSidebarBrowserResize"; +import { shouldApplyUsageSnapshot } from "./usageSnapshotOrdering"; const TRACKED_PROVIDERS: UsageProvider[] = ["claude", "codex"]; @@ -82,23 +83,6 @@ function formatUsageTitle(usage: HeaderUsageWindowSummary): string { return `${usage.planLabel} ${percentLabel(usage.planPercent)}, 5h ${percentLabel(usage.fiveHourPercent)}`; } -function snapshotLastPolledMs(snapshot: UsageSnapshot | null): number | null { - if (!snapshot) return null; - const timestamp = Date.parse(snapshot.lastPolledAt); - return Number.isFinite(timestamp) ? timestamp : null; -} - -function shouldApplyCachedSnapshot( - nextSnapshot: UsageSnapshot | null, - currentSnapshot: UsageSnapshot | null, -): boolean { - if (!currentSnapshot) return true; - if (!nextSnapshot) return false; - const nextTimestamp = snapshotLastPolledMs(nextSnapshot); - const currentTimestamp = snapshotLastPolledMs(currentSnapshot); - return nextTimestamp == null || currentTimestamp == null || nextTimestamp >= currentTimestamp; -} - function formatUpdatedAgo(snapshot: UsageSnapshot | null, nowMs: number): string { const t = snapshot ? Date.parse(snapshot.lastPolledAt) : Number.NaN; if (!Number.isFinite(t)) return ""; @@ -164,8 +148,10 @@ export function HeaderUsageControl({ const [snapshot, setSnapshot] = useState(null); const [providerConnections, setProviderConnections] = useState(undefined); + const [bindingRevision, setBindingRevision] = useState(0); const panelRef = useRef(null); const snapshotRef = useRef(null); + const bindingGenerationRef = useRef(0); const [refreshing, setRefreshing] = useState(false); const [nowMs, setNowMs] = useState(() => Date.now()); @@ -179,16 +165,18 @@ export function HeaderUsageControl({ }, [open]); const applySnapshot = useCallback((nextSnapshot: UsageSnapshot | null) => { + if (!shouldApplyUsageSnapshot(nextSnapshot, snapshotRef.current)) return; snapshotRef.current = nextSnapshot; setSnapshot(nextSnapshot); }, []); const handleRefresh = useCallback(async () => { if (!window.ade?.usage?.refresh) return; + const requestedGeneration = bindingGenerationRef.current; setRefreshing(true); try { const next = await window.ade.usage.refresh(); - if (next) applySnapshot(next); + if (next && requestedGeneration === bindingGenerationRef.current) applySnapshot(next); } catch { // swallow } finally { @@ -213,14 +201,22 @@ export function HeaderUsageControl({ }); const readCachedSnapshot = () => { if (cancelled) return; + const requestedGeneration = bindingGenerationRef.current; void readSnapshot().then(({ failed, snapshot: nextSnapshot }) => { - if (cancelled) return; + if (cancelled || requestedGeneration !== bindingGenerationRef.current) return; const currentSnapshot = snapshotRef.current; if (failed && currentSnapshot) return; - if (!shouldApplyCachedSnapshot(nextSnapshot, currentSnapshot)) return; applySnapshot(nextSnapshot); }); }; + const unsubscribeBinding = window.ade.app.onProjectBindingChanged?.(() => { + bindingGenerationRef.current += 1; + snapshotRef.current = null; + setSnapshot(null); + setProviderConnections(undefined); + setBindingRevision((revision) => revision + 1); + readCachedSnapshot(); + }); if (!deferInitialRead || open) { readCachedSnapshot(); @@ -229,6 +225,7 @@ export function HeaderUsageControl({ return () => { cancelled = true; unsubscribe?.(); + unsubscribeBinding?.(); }; }, [applySnapshot, deferInitialRead, open]); @@ -257,7 +254,7 @@ export function HeaderUsageControl({ cancelled = true; window.clearInterval(interval); }; - }, [deferInitialRead, open]); + }, [bindingRevision, deferInitialRead, open]); const detectedProviders = useMemo(() => { const providersWithUsage = TRACKED_PROVIDERS.filter((provider) => diff --git a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx index bcf9dcd81..fef6c7115 100644 --- a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx +++ b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx @@ -15,6 +15,7 @@ import { openExternalUrl } from "../../lib/openExternal"; import { providerChatAccent } from "../chat/chatSurfaceTheme"; import { ClaudeLogo, CodexLogo } from "../terminals/ToolLogos"; import { cn } from "../ui/cn"; +import { shouldApplyUsageSnapshot } from "./usageSnapshotOrdering"; const PROVIDER_ORDER: UsageProvider[] = ["claude", "codex"]; @@ -213,10 +214,13 @@ export function UsageQuotaPanel({ }) { const [snapshot, setSnapshot] = useState(null); const [providerConnections, setProviderConnections] = useState(null); + const [bindingRevision, setBindingRevision] = useState(0); const [bridgeMissing, setBridgeMissing] = useState(false); const [nowMs, setNowMs] = useState(() => Date.now()); const [refreshing, setRefreshing] = useState(false); const reducedMotion = usePrefersReducedMotion(); + const snapshotRef = useRef(null); + const bindingGenerationRef = useRef(0); const onSnapshotChangeRef = useRef(onSnapshotChange); useEffect(() => { @@ -224,6 +228,8 @@ export function UsageQuotaPanel({ }, [onSnapshotChange]); const applySnapshot = useCallback((nextSnapshot: UsageSnapshot | null) => { + if (!shouldApplyUsageSnapshot(nextSnapshot, snapshotRef.current)) return; + snapshotRef.current = nextSnapshot; setSnapshot(nextSnapshot); onSnapshotChangeRef.current?.(nextSnapshot); }, []); @@ -242,36 +248,49 @@ export function UsageQuotaPanel({ if (!cancelled) applySnapshot(next); }); - void (async () => { + const loadSnapshot = async () => { + const requestedGeneration = bindingGenerationRef.current; let current: UsageSnapshot | null = null; try { current = await bridge.getSnapshot(); } catch { current = null; } - if (cancelled) return; + if (cancelled || requestedGeneration !== bindingGenerationRef.current) return; if (current) applySnapshot(current); try { const demanded = await bridge.noteDemand?.(); - if (!cancelled && demanded) applySnapshot(demanded); + if (!cancelled && requestedGeneration === bindingGenerationRef.current && demanded) applySnapshot(demanded); } catch { // Quiet — demand only schedules a non-interactive provider refresh. } - })(); + }; + void loadSnapshot(); + const unsubscribeBinding = window.ade.app.onProjectBindingChanged?.(() => { + bindingGenerationRef.current += 1; + snapshotRef.current = null; + setSnapshot(null); + setProviderConnections(null); + setBindingRevision((revision) => revision + 1); + onSnapshotChangeRef.current?.(null); + void loadSnapshot(); + }); return () => { cancelled = true; unsubscribe?.(); + unsubscribeBinding?.(); }; }, [applySnapshot]); const refreshNow = useCallback(async () => { if (!window.ade?.usage?.refresh || refreshing) return; + const requestedGeneration = bindingGenerationRef.current; setRefreshing(true); try { const next = await window.ade.usage.refresh(); - if (next) applySnapshot(next); + if (next && requestedGeneration === bindingGenerationRef.current) applySnapshot(next); } catch { // The service preserves last-good data and records the provider state. } finally { @@ -293,7 +312,7 @@ export function UsageQuotaPanel({ return () => { cancelled = true; }; - }, []); + }, [bindingRevision]); useEffect(() => { const timer = window.setInterval(() => setNowMs(Date.now()), 15_000); diff --git a/apps/desktop/src/renderer/components/usage/usage.test.tsx b/apps/desktop/src/renderer/components/usage/usage.test.tsx index 9626ac4fc..2b59b67ba 100644 --- a/apps/desktop/src/renderer/components/usage/usage.test.tsx +++ b/apps/desktop/src/renderer/components/usage/usage.test.tsx @@ -22,7 +22,7 @@ import { } from "../../lib/workSidebarBrowserResize"; type UsageComponentTestBridge = { - app: Pick; + app: Pick; usage: Pick< Window["ade"]["usage"], "getSnapshot" | "refresh" | "refreshHistory" | "noteDemand" | "getBudgetConfig" | "saveBudgetConfig" | "onUpdate" @@ -265,6 +265,7 @@ describe("usage components", () => { const bridge = { app: { openExternal: vi.fn<[url: string], Promise>(async () => {}), + onProjectBindingChanged: vi.fn(() => () => {}), }, usage: { getSnapshot: vi.fn<[], Promise>(async () => snapshot), @@ -485,6 +486,48 @@ describe("usage components", () => { await act(async () => { await Promise.resolve(); }); expect(window.ade.usage.refresh).not.toHaveBeenCalled(); }); + + it("discards an explicit refresh response after the project binding changes", async () => { + let onBindingChanged: Parameters[0] | null = null; + const refresh = deferred(); + let currentSnapshot = makeQuotaPanelSnapshot(); + const reboundSnapshot = makeQuotaPanelSnapshot(); + reboundSnapshot.lastPolledAt = "2026-05-08T06:00:00.000Z"; + reboundSnapshot.windows = reboundSnapshot.windows.map((window) => + window.provider === "codex" ? { ...window, percentUsed: 42 } : window, + ); + const oldBindingResponse = makeQuotaPanelSnapshot(); + oldBindingResponse.lastPolledAt = "2026-05-08T08:00:00.000Z"; + oldBindingResponse.windows = oldBindingResponse.windows.map((window) => + window.provider === "codex" ? { ...window, percentUsed: 91 } : window, + ); + vi.mocked(window.ade.app.onProjectBindingChanged).mockImplementation((callback) => { + onBindingChanged = callback; + return () => {}; + }); + vi.mocked(window.ade.usage.getSnapshot).mockImplementation(async () => currentSnapshot); + vi.mocked(window.ade.usage.noteDemand).mockResolvedValue(null); + vi.mocked(window.ade.usage.refresh).mockReturnValue(refresh.promise); + + render(); + expect(await screen.findByText("63.0% used")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Refresh limits" })); + await waitFor(() => expect(window.ade.usage.refresh).toHaveBeenCalledTimes(1)); + + currentSnapshot = reboundSnapshot; + await act(async () => { + onBindingChanged?.(null); + }); + expect(await screen.findByText("42.0% used")).toBeTruthy(); + + await act(async () => { + refresh.resolve(oldBindingResponse); + await refresh.promise; + }); + expect(screen.getByText("42.0% used")).toBeTruthy(); + expect(screen.queryByText("91.0% used")).toBeNull(); + }); }); describe("HeaderUsageControl", () => { @@ -606,6 +649,103 @@ describe("usage components", () => { expect(window.ade.usage.refresh).not.toHaveBeenCalled(); }); + it("ignores an older usage event after a newer machine snapshot", async () => { + let onUpdate: ((snapshot: UsageSnapshot) => void) | null = null; + vi.mocked(window.ade.usage.onUpdate).mockImplementation((cb) => { + onUpdate = cb; + return () => {}; + }); + const newer = makeHeaderUsageSnapshot(); + newer.lastPolledAt = "2026-05-21T19:22:00.000Z"; + const older = makeHeaderUsageSnapshot(); + older.lastPolledAt = "2026-05-21T19:21:00.000Z"; + older.windows = older.windows.map((window) => ({ ...window, percentUsed: 77 })); + + render(); + + await act(async () => { + onUpdate?.(newer); + onUpdate?.(older); + }); + + expect(window.ade.usage.onUpdate).toHaveBeenCalledTimes(1); + expect(screen.getByRole("button", { name: /Codex wk 19%, 5h 9%/ })).toBeTruthy(); + expect(screen.queryByText("77%")).toBeNull(); + }); + + it("accepts an older authoritative snapshot after the project binding changes", async () => { + let onBindingChanged: Parameters[0] | null = null; + vi.mocked(window.ade.app.onProjectBindingChanged).mockImplementation((cb) => { + onBindingChanged = cb; + return () => {}; + }); + const newer = makeHeaderUsageSnapshot(); + newer.lastPolledAt = "2026-05-21T19:22:00.000Z"; + const olderMachine = makeHeaderUsageSnapshot(); + olderMachine.lastPolledAt = "2026-05-21T18:00:00.000Z"; + olderMachine.windows = olderMachine.windows.map((window) => ({ ...window, percentUsed: 42 })); + vi.mocked(window.ade.ai.getStatus) + .mockResolvedValueOnce(makeAiStatus({ + claude: makeProviderConnection("claude", { runtimeDetected: true, authAvailable: true }), + codex: makeProviderConnection("codex"), + })) + .mockResolvedValueOnce(makeAiStatus({ + claude: makeProviderConnection("claude"), + codex: makeProviderConnection("codex", { runtimeDetected: true, authAvailable: true }), + })); + vi.mocked(window.ade.usage.getSnapshot) + .mockResolvedValueOnce(newer) + .mockResolvedValueOnce(olderMachine); + + render(); + expect(await screen.findByRole("button", { name: /Claude wk …, 5h …/ })).toBeTruthy(); + + await act(async () => { + onBindingChanged?.(null); + }); + + expect(await screen.findByText("42%")).toBeTruthy(); + expect(window.ade.ai.getStatus).toHaveBeenCalledTimes(2); + expect(screen.queryByRole("button", { name: /Claude wk/ })).toBeNull(); + }); + + it("discards a header refresh response after the project binding changes", async () => { + const bindingCallbacks: Array[0]> = []; + const refresh = deferred(); + let currentSnapshot = makeHeaderUsageSnapshot(); + const reboundSnapshot = makeHeaderUsageSnapshot(); + reboundSnapshot.lastPolledAt = "2026-05-21T18:00:00.000Z"; + reboundSnapshot.windows = reboundSnapshot.windows.map((window) => ({ ...window, percentUsed: 42 })); + const oldBindingResponse = makeHeaderUsageSnapshot(); + oldBindingResponse.lastPolledAt = "2026-05-21T20:00:00.000Z"; + oldBindingResponse.windows = oldBindingResponse.windows.map((window) => ({ ...window, percentUsed: 91 })); + vi.mocked(window.ade.app.onProjectBindingChanged).mockImplementation((callback) => { + bindingCallbacks.push(callback); + return () => {}; + }); + vi.mocked(window.ade.usage.getSnapshot).mockImplementation(async () => currentSnapshot); + vi.mocked(window.ade.usage.noteDemand).mockResolvedValue(null); + vi.mocked(window.ade.usage.refresh).mockReturnValue(refresh.promise); + + render(); + fireEvent.click(await screen.findByRole("button", { name: /Codex wk 19%, 5h 9%/ })); + fireEvent.click(screen.getByTitle("Refresh usage")); + await waitFor(() => expect(window.ade.usage.refresh).toHaveBeenCalledTimes(1)); + + currentSnapshot = reboundSnapshot; + await act(async () => { + for (const callback of bindingCallbacks) callback(null); + }); + expect(await screen.findByRole("button", { name: /Codex wk 42%, 5h 42%/ })).toBeTruthy(); + + await act(async () => { + refresh.resolve(oldBindingResponse); + await refresh.promise; + }); + expect(screen.getByRole("button", { name: /Codex wk 42%, 5h 42%/ })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Codex wk 91%, 5h 91%/ })).toBeNull(); + }); + it("does not poll usage while the drawer stays closed", async () => { vi.useFakeTimers(); render(); diff --git a/apps/desktop/src/renderer/components/usage/usageSnapshotOrdering.ts b/apps/desktop/src/renderer/components/usage/usageSnapshotOrdering.ts new file mode 100644 index 000000000..68943ec29 --- /dev/null +++ b/apps/desktop/src/renderer/components/usage/usageSnapshotOrdering.ts @@ -0,0 +1,19 @@ +import type { UsageSnapshot } from "../../../shared/types"; + +function snapshotLastPolledMs(snapshot: UsageSnapshot | null): number | null { + if (!snapshot) return null; + const timestamp = Date.parse(snapshot.lastPolledAt); + return Number.isFinite(timestamp) ? timestamp : null; +} + +export function shouldApplyUsageSnapshot( + nextSnapshot: UsageSnapshot | null, + currentSnapshot: UsageSnapshot | null, +): boolean { + if (!currentSnapshot) return true; + if (!nextSnapshot) return false; + const nextTimestamp = snapshotLastPolledMs(nextSnapshot); + const currentTimestamp = snapshotLastPolledMs(currentSnapshot); + if (currentTimestamp != null && nextTimestamp == null) return false; + return currentTimestamp == null || nextTimestamp == null || nextTimestamp >= currentTimestamp; +} diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index b75e08e4f..1a3057f88 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -4694,6 +4694,7 @@ extension MobileAdeUsageStats { struct MobileUsageQuotaWindow: Codable, Equatable, Identifiable { var id: String { "\(provider):\(windowType):\(resetsAt):\(percentUsed)" } + var clampedPercentUsed: Double { max(0, min(100, percentUsed)) } var provider: String var windowType: String var percentUsed: Double diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index ec7cbcb7a..7186c19e6 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -2411,6 +2411,23 @@ final class SyncService: ObservableObject { projectHomePresented = false } + /// A user-requested machine transition always returns the UI to the Hub + /// before the socket changes. The active project remains cached, but no + /// in-project screen can keep rendering state owned by the previous machine. + func prepareForUserConnectionChange() { + projectHomePresented = true + } + + func disconnectForUserConnectionChange() { + prepareForUserConnectionChange() + disconnect() + } + + func reconnectForUserConnectionChange() async { + prepareForUserConnectionChange() + await reconnectIfPossible(userInitiated: true) + } + func selectProject(_ project: MobileProjectSummary) { let selectionGeneration = beginProjectSelection() unhideProject(project) @@ -4064,6 +4081,7 @@ final class SyncService: ObservableObject { } func reconnect(toSavedHost host: DiscoveredSyncHost) async { + prepareForUserConnectionChange() ProductAnalytics.shared.captureQuickConnect(.pairedMachine) let profiles = loadSavedProfiles() let candidates = profiles.values.filter { profile in @@ -4109,6 +4127,7 @@ final class SyncService: ObservableObject { relayCandidates: [String] ) async -> Bool { guard var profile = savedProfileForPairingQr(hostIdentity: hostIdentity) else { return false } + prepareForUserConnectionChange() let directHosts = directCandidates.compactMap { syncEndpointHost($0) } let relayHosts = deduplicatedAddresses(relayCandidates.filter(syncIsFullWebSocketRoute)) profile.savedAddressCandidates = deduplicatedAddresses(profile.savedAddressCandidates + directHosts) @@ -4145,6 +4164,7 @@ final class SyncService: ObservableObject { accountToken: String, authorization: AccountPairingAuthorization ) async -> Bool { + prepareForUserConnectionChange() ProductAnalytics.shared.captureQuickConnect(.accountMachine) let token = accountToken.trimmingCharacters(in: .whitespacesAndNewlines) let owner = authorization.ownerId.trimmingCharacters(in: .whitespacesAndNewlines) @@ -4452,6 +4472,7 @@ final class SyncService: ObservableObject { .compactMap { syncEndpointHost($0) } ) guard let preferredAddress = directHosts.first else { return false } + prepareForUserConnectionChange() var profile = HostConnectionProfile( hostIdentity: handoff.hostIdentity, hostName: handoff.hostName, @@ -4496,6 +4517,7 @@ final class SyncService: ObservableObject { .compactMap(syncEndpointHost) ) guard let preferredAddress = directHosts.first else { return false } + prepareForUserConnectionChange() var profile = HostConnectionProfile( hostIdentity: response.machine.deviceId, hostName: response.machine.name, @@ -4856,6 +4878,7 @@ final class SyncService: ObservableObject { tailscaleAddress: String? = nil, relayCandidates: [String] = [] ) async { + prepareForUserConnectionChange() lastPairingErrorCode = nil lastPairingFailure = nil relayAuthorizationRequirement = nil diff --git a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift index 86bb93813..eb1ce9394 100644 --- a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift +++ b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift @@ -852,9 +852,11 @@ struct ADERootToolbarControls: View { /// this control (e.g. the active and incoming root tab during a transition) /// don't emit colliding element ids. Callers typically pass the screen title. let scopeKey: String? + let showsSettings: Bool - init(scopeKey: String? = nil) { + init(scopeKey: String? = nil, showsSettings: Bool = false) { self.scopeKey = scopeKey + self.showsSettings = showsSettings } private var hasUnread: Bool { drawer.unreadCount > 0 } @@ -902,9 +904,19 @@ struct ADERootToolbarControls: View { } private var toolbarBody: some View { - ZStack(alignment: .topTrailing) { - attentionButton - unreadBadge + HStack(spacing: 0) { + ZStack(alignment: .topTrailing) { + attentionButton + unreadBadge + } + + if showsSettings { + Rectangle() + .fill(Color.white.opacity(0.08)) + .frame(width: 1, height: 18) + + settingsButton + } } } @@ -918,6 +930,16 @@ struct ADERootToolbarControls: View { ) } + private var settingsButton: some View { + toolbarIconButton( + icon: "gearshape", + tint: PrsGlass.textSecondary, + isAlive: false, + accessibilityLabel: "Settings", + action: { syncService.settingsPresented = true } + ) + } + @ViewBuilder private var unreadBadge: some View { if hasUnread { @@ -961,7 +983,7 @@ struct ADERootToolbarControls: View { .shadow(color: isAlive ? tint.opacity(0.28) : .clear, radius: 2, x: 0, y: 0) } } - .frame(width: 38, height: 34) + .frame(width: 44, height: 36) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -996,17 +1018,20 @@ struct ADERootTopBar: View { let title: String let showsGlobalControls: Bool let showsHubBackButton: Bool + let showsSettings: Bool let actions: Actions init( title: String, showsGlobalControls: Bool = true, showsHubBackButton: Bool = true, + showsSettings: Bool = false, @ViewBuilder actions: () -> Actions ) { self.title = title self.showsGlobalControls = showsGlobalControls self.showsHubBackButton = showsHubBackButton + self.showsSettings = showsSettings self.actions = actions() } @@ -1030,7 +1055,7 @@ struct ADERootTopBar: View { Spacer(minLength: 8) actions if showsGlobalControls { - ADERootToolbarControls(scopeKey: title) + ADERootToolbarControls(scopeKey: title, showsSettings: showsSettings) } } .padding(.horizontal, 16) @@ -1055,10 +1080,16 @@ struct ADERootTopBar: View { @available(iOS 17.0, *) extension ADERootTopBar where Actions == EmptyView { - init(title: String, showsGlobalControls: Bool = true, showsHubBackButton: Bool = true) { + init( + title: String, + showsGlobalControls: Bool = true, + showsHubBackButton: Bool = true, + showsSettings: Bool = false + ) { self.title = title self.showsGlobalControls = showsGlobalControls self.showsHubBackButton = showsHubBackButton + self.showsSettings = showsSettings self.actions = EmptyView() } } diff --git a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift index 90d58db26..eef96e2b3 100644 --- a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift +++ b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift @@ -35,9 +35,9 @@ struct ConnectionSettingsView: View { SettingsConnectionHeader( snapshot: presentationModel.connectionSnapshot, - onDisconnect: { syncService.disconnect() }, + onDisconnect: { syncService.disconnectForUserConnectionChange() }, onReconnect: { - Task { await syncService.reconnectIfPossible(userInitiated: true) } + Task { await syncService.reconnectForUserConnectionChange() } } ) @@ -63,9 +63,9 @@ struct ConnectionSettingsView: View { SettingsConnectionHeader( snapshot: presentationModel.connectionSnapshot, - onDisconnect: { syncService.disconnect() }, + onDisconnect: { syncService.disconnectForUserConnectionChange() }, onReconnect: { - Task { await syncService.reconnectIfPossible(userInitiated: true) } + Task { await syncService.reconnectForUserConnectionChange() } } ) } @@ -455,7 +455,8 @@ private struct SettingsUsageQuotaSection: View { SettingsUsageProviderCard( provider: provider, windows: snapshot.windows.filter { $0.provider == provider }, - status: snapshot.providerStatus?[provider] + status: snapshot.providerStatus?[provider], + spendControlReached: provider == "codex" && snapshot.spendControlReached == true ) if provider == "claude" { Divider().opacity(0.14) } } @@ -486,41 +487,82 @@ private struct SettingsUsageProviderCard: View { let provider: String let windows: [MobileUsageQuotaWindow] let status: MobileUsageProviderStatus? + let spendControlReached: Bool + + private var displayName: String { providerLabel(provider) } + private var providerTint: Color { ADEColor.providerBrand(for: provider) } + private var usageURL: URL { + if provider == "claude" { + return URL(string: "https://claude.ai/new#settings/usage")! + } + return URL(string: "https://chatgpt.com/codex/cloud/settings/analytics#usage")! + } var body: some View { VStack(alignment: .leading, spacing: 10) { HStack { - Text(provider == "claude" ? "Claude" : "Codex") + if let assetName = providerAssetName(provider) { + Image(assetName) + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + .accessibilityHidden(true) + } + + Text(displayName) .font(.subheadline.weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) + + Link(destination: usageURL) { + Image(systemName: "arrow.up.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(ADEColor.textMuted) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .accessibilityLabel("Open \(displayName) usage in browser") + Spacer() - Text("\((status?.source ?? "waiting").uppercased()) · \(mobileUsageSettingsRelativeTime(status?.updatedAt ?? status?.lastSuccessAt))") - .font(.system(.caption2, design: .monospaced)) + Text("\(mobileUsageProviderSource(status)) · \(mobileUsageSettingsRelativeTime(status?.updatedAt ?? status?.lastSuccessAt))") + .font(.caption2) .foregroundStyle(ADEColor.textMuted) } + if spendControlReached { + Text("Spending cap reached") + .font(.caption.weight(.medium)) + .foregroundStyle(ADEColor.warning) + } + ForEach(windows) { window in + let percentUsed = window.clampedPercentUsed VStack(alignment: .leading, spacing: 5) { HStack { - Text(mobileUsageWindowLabel(window.windowType)) + Text(mobileUsageWindowLabel(window)) Spacer() - Text("\(Int(max(0, 100 - window.percentUsed)))% left") + Text(String(format: "%.1f%% used", percentUsed)) .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) + ProgressView(value: percentUsed, total: 100) + .tint(percentUsed > 90 ? ADEColor.danger : percentUsed > 70 ? ADEColor.warning : providerTint) - Text("Resets \(mobileUsageSettingsResetLabel(window.resetsAt))") + Text(mobileUsageSettingsResetLabel(window.resetsAt)) .font(.caption2) .foregroundStyle(ADEColor.textMuted) } } - if let message = status?.message, status?.state != "ok" { - Text(message) + if windows.isEmpty, status?.state == "ok" { + Text("Waiting for the next usage reading") + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + } + + if let statusMessage = mobileUsageStatusMessage(status) { + Text(statusMessage) .font(.caption2) .foregroundStyle(ADEColor.warning) } @@ -530,15 +572,41 @@ private struct SettingsUsageProviderCard: View { } } -private func mobileUsageWindowLabel(_ value: String) -> String { - switch value { +private func mobileUsageProviderSource(_ status: MobileUsageProviderStatus?) -> String { + switch status?.source { + case "oauth": return "OAuth" + case "http": return "HTTP" + case "cli": return "CLI" + default: return "Waiting" + } +} + +private func mobileUsageWindowLabel(_ window: MobileUsageQuotaWindow) -> String { + if window.windowType == "five_hour", + let durationMs = window.windowDurationMs, + durationMs > 0 { + let minutes = Int((durationMs / 60_000).rounded()) + if minutes < 60 { return "\(minutes)-min" } + let hours = Double(minutes) / 60 + if hours.rounded() == hours { return "\(Int(hours))-hour" } + return String(format: "%.1f-hour", hours) + } + switch window.windowType { 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 + default: return window.windowType.replacingOccurrences(of: "_", with: " ").capitalized + } +} + +private func mobileUsageStatusMessage(_ status: MobileUsageProviderStatus?) -> String? { + guard let status, status.state != "ok" else { return nil } + if let message = status.message?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty { + return message } + return status.state == "stale" ? "Showing last known quota" : "Quota is unavailable" } private func mobileUsageSettingsDate(_ iso: String?) -> Date? { @@ -558,8 +626,15 @@ private func mobileUsageSettingsRelativeTime(_ iso: String?) -> String { } private func mobileUsageSettingsResetLabel(_ iso: String) -> String { - guard let date = mobileUsageSettingsDate(iso) else { return "soon" } - return date.formatted(date: .abbreviated, time: .shortened) + guard let date = mobileUsageSettingsDate(iso) else { return "Resetting soon" } + let seconds = max(0, Int(date.timeIntervalSinceNow)) + if seconds == 0 { return "Resetting now" } + let days = seconds / 86_400 + let hours = (seconds % 86_400) / 3_600 + let minutes = (seconds % 3_600) / 60 + if days > 0 { return "Resets in \(days)d \(hours)h" } + if hours > 0 { return "Resets in \(hours)h \(minutes)m" } + return "Resets in \(minutes)m" } // MARK: - Machines section (M5 / M14) diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index 23bfd0671..450d36adf 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -566,6 +566,7 @@ struct WorkNewChatScreen: View { @State private var sessionMode: WorkNewSessionMode = .chat @State private var shellLaunchBusy: Bool = false @State private var queuedShellLaneIds = Set() + @State private var usageRefreshRevision = 0 /// Status banner shown above the composer while an auto-created lane is being /// minted before the chat/CLI session starts. @State private var autoCreateStatus: String? @@ -690,7 +691,7 @@ struct WorkNewChatScreen: View { // Keep activity in the scrollable content instead of pinning it // above the composer. When the keyboard appears, the composer can // expand into this space without lifting the activity card with it. - WorkUsageActivityCarousel() + WorkUsageActivityCarousel(refreshRevision: usageRefreshRevision) .environmentObject(syncService) .padding(.top, 2) .fixedSize(horizontal: false, vertical: true) @@ -702,6 +703,7 @@ struct WorkNewChatScreen: View { .scrollDismissesKeyboard(.interactively) .refreshable { await MobileUsageQuotaStore.shared.load(using: syncService, refresh: true) + usageRefreshRevision &+= 1 } if let autoCreateStatus, busy { diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index 9e213ce40..d8bdac99b 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -477,7 +477,10 @@ struct WorkRootScreen: View { .navigationBarTitleDisplayMode(.inline) .toolbar(.hidden, for: .navigationBar) .safeAreaInset(edge: .top, spacing: 0) { - ADERootTopBar(title: isSelecting ? "\(selectedSessionIds.count) selected" : "Work") { + ADERootTopBar( + title: isSelecting ? "\(selectedSessionIds.count) selected" : "Work", + showsSettings: !isSelecting + ) { if isSelecting { Button("Cancel") { exitSelectionMode() diff --git a/apps/ios/ADE/Views/Work/WorkUsageActivityCarousel.swift b/apps/ios/ADE/Views/Work/WorkUsageActivityCarousel.swift index 8e2e96941..f710e0000 100644 --- a/apps/ios/ADE/Views/Work/WorkUsageActivityCarousel.swift +++ b/apps/ios/ADE/Views/Work/WorkUsageActivityCarousel.swift @@ -212,6 +212,8 @@ private func workUsageFormatDay(_ date: String) -> String { } struct WorkUsageActivityCarousel: View { + let refreshRevision: Int + @EnvironmentObject private var syncService: SyncService @AppStorage("ade.work.activityTab.v1") private var tabRaw = WorkUsageTab.activity.rawValue @AppStorage("ade.work.usageRange.v1") private var rangeRaw = WorkUsageRange.all.rawValue @@ -224,7 +226,13 @@ struct WorkUsageActivityCarousel: View { 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 loadKey: String { + "\(tabRaw):\(rangeRaw):\(syncService.connectionState.rawValue):\(refreshRevision)" + } + + init(refreshRevision: Int = 0) { + self.refreshRevision = refreshRevision + } private var summary: MobileAdeUsageSummary? { loadedRange == rangeRaw ? stats?.summary : nil @@ -511,12 +519,22 @@ private struct WorkUsageQuotaCompact: View { let weekly = windows.first { $0.windowType == "weekly" || $0.windowType == "monthly" } let status = snapshot.providerStatus?[provider] HStack(spacing: 8) { - Text(provider == "claude" ? "Claude" : "Codex") + if let assetName = providerAssetName(provider) { + Image(assetName) + .resizable() + .scaledToFit() + .frame(width: 15, height: 15) + .accessibilityHidden(true) + } + Text(providerLabel(provider)) .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 —") + .frame(width: 48, alignment: .leading) + Text(fiveHour.map { "5h \(Int($0.clampedPercentUsed))%" } ?? "5h —") + Text(weekly.map { + let label = $0.windowType == "monthly" ? "month" : "week" + return "\(label) \(Int($0.clampedPercentUsed))%" + } ?? "week —") Spacer(minLength: 0) Text(mobileUsageStatusLabel(status)) .foregroundStyle(ADEColor.textMuted) diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index d3c037f31..c11fbfc20 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -1376,6 +1376,26 @@ final class ADETests: XCTestCase { ) } + @MainActor + func testUserDisconnectReturnsToHubBeforeTransportChanges() { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + service.projectHomePresented = false + + service.disconnectForUserConnectionChange() + + XCTAssertTrue(service.projectHomePresented) + } + + @MainActor + func testOrdinaryReconnectDoesNotOverrideProjectPresentation() async { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + service.projectHomePresented = false + + await service.reconnectIfPossible(userInitiated: true) + + XCTAssertFalse(service.projectHomePresented) + } + func testSyncReconnectStateUsesBackoffAndResetsAfterSuccess() { var state = SyncReconnectState() @@ -5161,6 +5181,22 @@ final class ADETests: XCTestCase { XCTAssertEqual(workUsageDayActivityScore(empty), 0) } + func testMobileUsagePercentUsesProviderPercentWithoutInvertingIt() { + var window = MobileUsageQuotaWindow( + provider: "claude", + windowType: "weekly", + percentUsed: 63, + resetsAt: "2026-07-20T12:00:00Z", + resetsInMs: 1_000, + windowDurationMs: nil + ) + XCTAssertEqual(window.clampedPercentUsed, 63) + window.percentUsed = -4 + XCTAssertEqual(window.clampedPercentUsed, 0) + window.percentUsed = 140 + XCTAssertEqual(window.clampedPercentUsed, 100) + } + func testMobileUsageQuotaSnapshotDecodesSourceFreshnessAndUnknownFields() throws { let json = """ { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5eb5ad8a9..ae5c51d76 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -178,7 +178,7 @@ The desktop app is a **client of the runtime**. It owns a trusted main process, | Directory | Role | |-----------|------| | `apps/desktop/src/main/` | Node process with full OS access. Hosts windows, registers IPC handlers, routes runtime-backed APIs through local/remote runtime pools, spawns the local ADE runtime when needed, and owns Electron-only services that cannot run inside the runtime. Entry: `main.ts`. | -| `apps/desktop/src/preload/` | Typed bridge. Entry: `preload.ts`. Uses `contextBridge.exposeInMainWorld("ade", { ... })`. Runtime-backed APIs route through `LocalRuntimeConnectionPool` (local) or `RemoteConnectionPool` (paired/SSH-bound window); file APIs are strict once a local/remote runtime is bound, while usage/budget reads only route to runtime for remote-bound windows. During project switches, mutating runtime/sync calls that target the ambiguous active binding are blocked, read-only calls avoid refreshing stale bindings, active remote opens can be awaited before retrying reads, and remote lane preview URLs are localized through desktop-owned TCP forwards. If a packaged local window is temporarily bound to an isolated runtime whose sync service is disabled, only the exact machine-level sync-unavailable/register-project failures retry through main-process sync IPC; remote-bound failures never fall back locally. Explicitly targeted work can pass an `OpenProjectBinding` pin through `callPinnedRuntimeAction` to route to the captured project during a switch, used by detached draft launches and rollback. | +| `apps/desktop/src/preload/` | Typed bridge. Entry: `preload.ts`. Uses `contextBridge.exposeInMainWorld("ade", { ... })`. Runtime-backed APIs route through `LocalRuntimeConnectionPool` (local) or `RemoteConnectionPool` (paired/SSH-bound window); file APIs are strict once a local/remote runtime is bound, while usage/budget reads only route to runtime for remote-bound windows. Usage push delivery follows the active binding too: unbound windows accept main-process usage events, while bound windows accept only the runtime event stream, so a dormant local tracker cannot overwrite the active project's snapshot. During project switches, mutating runtime/sync calls that target the ambiguous active binding are blocked, read-only calls avoid refreshing stale bindings, active remote opens can be awaited before retrying reads, and remote lane preview URLs are localized through desktop-owned TCP forwards. If a packaged local window is temporarily bound to an isolated runtime whose sync service is disabled, only the exact machine-level sync-unavailable/register-project failures retry through main-process sync IPC; remote-bound failures never fall back locally. Explicitly targeted work can pass an `OpenProjectBinding` pin through `callPinnedRuntimeAction` to route to the captured project during a switch, used by detached draft launches and rollback. | | `apps/desktop/src/renderer/` | React 18 SPA. No Node access, no filesystem access, no direct process/network. Everything goes through `window.ade`. Entry: `main.tsx`. | | `apps/desktop/src/shared/` | Types, IPC channel constants (`ipc.ts`), model registry (`modelRegistry.ts`), keybindings, and cross-client derivations such as `chatScheduledWork.ts` and `externalSessionAffordances.ts` (the desktop/ADE Code Continue/Copy policy for provider-native imports). Imported by desktop, `apps/ade-cli`, and mobile contract generation paths. New runtime-facing types live in `shared/types/remoteRuntime.ts` and `shared/types/core.ts`. | | `apps/desktop/src/generated/` | Build-time generated code (e.g., bootstrap SQL snapshots). | @@ -661,7 +661,7 @@ Most services described here live under `apps/desktop/src/main/services/ | `tests/` | `testService.ts` | Test-suite execution + run history. | | `updates/` | `autoUpdateService.ts` | Electron auto-update wrapper around `electron-updater`. Owns the renderer-visible `AutoUpdateSnapshot` (`idle \| checking \| downloading \| ready \| installing \| error`), uses `compareUpdateVersions` (SemVer-aware) to dedupe / supersede staged installers and to reconcile `pendingInstallUpdate` against the running version on next boot. Packaged builds schedule startup/periodic checks; source/dev launches construct the service without auto-check timers so missing `app-update.yml` never surfaces as a renderer error. ADE manually starts downloads after a cache-volume capacity preflight, checks the installed-app volume again before staging, classifies disk/quota/network/verification/permission/installer failures in the shared snapshot, preserves verified downloads when safe, and bounds the native installer handoff with a watchdog. `quitAndInstall()` re-checks the staged version before calling `updater.quitAndInstall(false, true)`. See [desktop auto-update disk-space behavior](./features/onboarding-and-settings/desktop-auto-update.md). | | `storage/` | `diskPressure.ts`, `volume.ts`, `storageInsightsService.ts`, `historyCompression.ts` | Disk-full/recovery hardening. `diskPressure` samples all ADE storage roots, classifies pressure with recovery hysteresis, and gates write-producing operation classes via `canPerform(kind)` (enforced at each start boundary in `agentChatService` / `ptyService` / `processService` and the compressor). `storageInsightsService` builds the categorized Settings > Storage snapshot and preview-confirmed, link-safe cleanup. `historyCompression` losslessly gzip-compresses inactive old transcripts/logs after byte-identity verification and exposes the transparent `.gz` read/reinflate helpers used by transcript, session, and search readers. Constructed in both `main.ts` and the `ade` runtime `bootstrap.ts`. See [features/storage-and-recovery/README.md](./features/storage-and-recovery/README.md). | -| `usage/` | `usageTrackingService.ts`, `providerQuotaParsers.ts`, `usageStatsStore.ts`, `budgetCapService.ts`, `ledgers/localUsageLedgers.ts` | Live provider quota/cost accounting, budget enforcement, and retrospective activity stats. `usageTrackingService.ts` owns polling, pacing, provider/GitHub cache orchestration, and `getAdeUsageStats`; `providerQuotaParsers.ts` normalizes Claude and Codex quota payload variants and classifies Codex windows by advertised duration rather than assuming the provider's primary/secondary positions. The stats read returns cached expensive sources plus live project-DB aggregates immediately, marks the result `refreshing` when stale, and revalidates provider ledgers / GitHub in the background. `usageStatsStore.ts` aggregates AI calls, sessions, lanes, code movement, artifacts, automations, workers, streaks, and the local-only cross-client `usage_events` ledger. Local provider scanners live under `usage/ledgers/`. Budget caps can match a rule scope while `usd-per-run` evaluates usage records keyed to the active run id. Threshold state is shared at module level across all `createUsageTrackingService` instances so multiple project contexts don't fire duplicate threshold events; `main.ts` adds a final IPC-level dedup gate with a 10-minute TTL per `provider:threshold:resetCycle` key. | +| `usage/` | `usageTrackingService.ts`, `providerQuotaParsers.ts`, `usageStatsStore.ts`, `budgetCapService.ts`, `ledgers/localUsageLedgers.ts` | Live provider quota/cost accounting, budget enforcement, and retrospective activity stats. `usageTrackingService.ts` owns polling, pacing, provider/GitHub cache orchestration, and `getAdeUsageStats`; `providerQuotaParsers.ts` normalizes Claude and Codex quota payload variants and classifies Codex windows by advertised duration rather than assuming the provider's primary/secondary positions. The stats read returns cached expensive sources plus live project-DB aggregates immediately, marks the result `refreshing` when stale, and revalidates provider ledgers / GitHub in the background. `usageStatsStore.ts` aggregates AI calls, sessions, lanes, code movement, artifacts, automations, workers, streaks, and the local-only cross-client `usage_events` ledger. Local provider scanners live under `usage/ledgers/`. Budget caps can match a rule scope while `usd-per-run` evaluates usage records keyed to the active run id. For runtime-backed projects, the machine brain is the sole quota poller and the renderer consumes its pushed snapshot; `main.ts` does not start a competing project-context tracker. Threshold state remains shared at module level for the unbound/local contexts, and `main.ts` adds a final IPC-level dedup gate with a 10-minute TTL per `provider:threshold:resetCycle` key. | | `perf/` | `perfLog.ts`, `perfIpc.ts`, `metricsSampler.ts`, `aggregator.ts` | Opt-in local performance harness. `ADE_PERF_RUN_ID` opens a JSONL event log, samples Electron process metrics, records IPC durations, accepts renderer perf marks/web-vitals, and aggregates each run into `summary.json`. | **Cross-cutting personal-chat paths.** Personal chat reuses `chat/agentChatService.ts` with `surface: "personal"`, a light session profile, a neutral general-assistant prompt, project/lane environment variables removed, and project slash-command/ADE-guidance injection disabled. The hidden runtime also disables project push publication. Desktop Browser calls from that surface pass `tabCollection: "personal"`; `builtInBrowserService.ts` uses it only to select an independent visible tab collection. The persistent authentication partition remains global. See [Personal chats](./features/personal-chats/README.md) for the complete source map and invariants. @@ -1204,7 +1204,7 @@ The normative privacy, consent, taxonomy, quota, configuration, and instrumentat - **IPC tracing** — every handler emits `ipc.invoke.begin` / `ipc.invoke.done` / `ipc.invoke.failed` with call ID, channel, window ID, duration, summarized args. Mandatory for new handlers. - **Renderer lifecycle** — `renderer.route_change`, `renderer.tab_change`, `renderer.window_error`, `renderer.unhandled_rejection`, `renderer.event_loop_stall`. Mandatory for new surfaces that introduce novel lifecycle transitions. - **Startup tasks** — `project.startup_task_enabled`, `project.startup_task_skipped`, `project.startup_task_begin`, `project.startup_task_done` with durations. -- **Usage tracking** — `usageTrackingService.ts` + `usageStatsStore.ts` + `usage/ledgers/*` + `budgetCapService.ts` account for provider quotas/cost and retrospective ADE activity. The top-bar Usage popup (`HeaderUsageControl` → `UsageQuotaPanel` + collapsible `BudgetCapEditor`) shows live quota windows; Settings > Stats and the empty Work composer use the cached cross-client activity projection. After a successful meaningful mutation, desktop IPC, ADE action RPC, and paired sync-command ingress record one local `usage_events` row with client attribution; reads, polling, background work, and failed calls do not count. `main.ts` keeps a dormant usage tracker alive while no project context is open so the main menu can show provider usage from machine-level Claude/Codex auth, then pauses it while project-scoped contexts own polling. +- **Usage tracking** — `usageTrackingService.ts` + `usageStatsStore.ts` + `usage/ledgers/*` + `budgetCapService.ts` account for provider quotas/cost and retrospective ADE activity. The top-bar Usage popup (`HeaderUsageControl` → `UsageQuotaPanel` + collapsible `BudgetCapEditor`) shows live quota windows; Settings > Stats and the empty Work composer use the cached cross-client activity projection. After a successful meaningful mutation, desktop IPC, ADE action RPC, and paired sync-command ingress record one local `usage_events` row with client attribution; reads, polling, background work, and failed calls do not count. `main.ts` keeps a dormant usage tracker available while no runtime project is bound so the main menu can show machine-level Claude/Codex usage. Once a project runtime is bound, the machine brain is the only poller; preload forwards only that binding's runtime events, and the compact header and open panel reject older same-binding snapshots and reset their snapshot/provider state when the binding changes. - **Local perf runs** — `scripts/perf-launch.mjs` / `scripts/run-perf-scenario.mjs` launch ADE with a run id, feed renderer scenarios, and collect JSONL events plus `summary.json` under `~/.ade/perf-runs//`. This is local-only diagnostics, not external telemetry. - **Anonymous product analytics** — configured builds manually capture a strict allowlist of PostHog events for app opens, normalized screen views, successful feature actions, truthful persisted work-session completions, coarse error categories, daily aggregate usage, and analytics-budget health. Canonical desktop/runtime/hosted-client events, direct native-mobile UI events (`ade_mobile_*`), and public-site events (`ade_marketing_*`) use separate namespaces so marketing visits and the phone's own installation identity cannot inflate product activation or retention. The shared service salts/hashes project and session identifiers, disables GeoIP and person profiles, and rejects arbitrary properties. It never sends prompts, code, file or terminal content, repository names or paths, command arguments, URLs, branch names, error messages, stack traces, or recordings. Session replay, autocapture, automatic pageviews, surveys, and feature flags are disabled or absent. - **Quota controls** — analytics is lazy and batched, with a hard 200-event installation-wide UTC-day budget, tighter per-event and per-minute caps, deduplication windows, bounded queues, and a summarized budget event. Persisted `usage_events` are exported only after successful user mutations; reads, render loops, polling, streams, terminal bytes, and retries do not generate product events. Native mobile and public-web clients apply still-lower local ceilings. Budget `sent_count` is the legacy wire name for attempts accepted/enqueued locally, not confirmed PostHog delivery. diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index feb2a4390..0d982d350 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -332,19 +332,30 @@ Renderer — settings: 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 subscribes to usage `onUpdate`, and - drills down into 5-hour, weekly, monthly, and other reset windows with + panel as "Not signed in". The header and panel subscribe to usage `onUpdate`, + reject an older snapshot within the same project binding, and clear then + reload both quota and provider-connection state when the binding changes. + This keeps the compact percentages and the open panel on the same live + machine-brain snapshot even across fast project or machine switches. The + panel 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. + can fall back from OAuth to a bounded CLI probe. When a non-interactive + caller cannot authoritatively read Claude credentials, the service preserves + the previous unexpired windows, provider state, and extra-usage values rather + than replacing them with a false authentication error. 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 caps round-trip through `ade.usage.getBudgetConfig` / `saveBudgetConfig`. Threshold crossings (25 / 50 / 75 / 100 %) emit `UsageThresholdEvent`s for local usage handling. +- `apps/desktop/src/renderer/components/usage/usageSnapshotOrdering.ts` — + shared ordering guard for the compact header and full quota panel. It accepts + the first snapshot for a binding and newer/equal poll timestamps, while each + component explicitly resets the guard when the project binding changes. - `apps/desktop/src/renderer/components/settings/AdeUsageSection.tsx` — Settings > Usage, split into **Limits** and **Activity** tabs. Limits renders the same live quota contract as the header without starting a local @@ -376,7 +387,10 @@ Renderer — settings: 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. + the expensive provider-ledger and GitHub history scans. Runtime-backed + projects use the machine brain as the single quota owner; the desktop does + not create a second project-context tracker that could race the runtime event + stream. It returns cached provider/GitHub results and current DB aggregates without awaiting expensive scans, exposes freshness metadata (`fresh` / `refreshing`), and coalesces stale provider/GitHub revalidation in the background diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 5518b9df2..6cd1168db 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -450,6 +450,10 @@ older `ADEConnectionPill` and the per-tab "connection notice" banner cards — controllers no longer ship duplicate offline / reconnect / hydrating cards inside each screen body. +The Work root top bar also places a Settings gear immediately to the right of +the notification bell. It opens the same `ConnectionSettingsView` sheet as the +Hub connection affordance and is hidden while Work is in multi-select mode. + The Hub is the exception: with the navigation bar hidden, its no-machine / connection-error state renders `HubNoMachineState` instead of project cards, using the same `SyncConnectionHealth` mapping as @@ -683,10 +687,14 @@ Sources: `apps/ios/ADE/Services/SyncService.swift` and later therefore reconnects without navigation or a user tap. A successful hello restores the active project, chat and terminal subscriptions, tracked lane presence, and pending safe operations without rebuilding the current - navigation stack. User-initiated disconnects from Settings (including the - connecting-state Cancel button) cancel scheduled reconnect work and leave - the phone in the disconnected state until the user reconnects or pairs - again. + navigation stack. A user-initiated machine transition from Settings first + presents the Hub, then disconnects, reconnects, pairs, or adopts the selected + machine. The cached active project can remain available for recovery, but no + in-project screen keeps rendering state owned by the previous machine. + Disconnects (including the connecting-state Cancel button) also cancel + scheduled reconnect work and leave the phone disconnected until the user + reconnects or pairs again. Ordinary transport recovery does not force Hub + navigation. 8. After pairing completes, the phone announces currently-open lanes via `lanes.presence.announce` so the runtime decorates `LaneSummary.devicesOpen` for other controllers; the phone calls @@ -1490,13 +1498,16 @@ 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. + a compact provider-icon summary using the host's percent-used values directly. + Settings mirrors the desktop cards with provider icons, usage-threshold + colors, reset countdowns, source/freshness/error state, explicit refresh, and + external links to the Claude and Codex usage pages. - `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. + page. Pull-to-refresh on the new-chat screen refreshes both quota and the + currently selected activity range. `MobileUsageQuotaStore` persists snapshots by host identity, rebinds on machine changes, ignores an older in-flight response after a host switch, and clears