From 9b25fee186cdeaa1ca8267e7d33e8f6527e51d61 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:15:58 -0400 Subject: [PATCH 1/2] fix: harden usage meters and iOS composer --- .../services/usage/providerQuotaParsers.ts | 221 ++++++++++++++++++ .../usage/usageTrackingService.test.ts | 36 ++- .../services/usage/usageTrackingService.ts | 220 +---------------- .../renderer/components/usage/usage.test.tsx | 53 +++++ .../Work/WorkComposerTypedTriggers.swift | 69 ++++-- .../WorkComposerTriggerDetectorTests.swift | 51 +++- docs/ARCHITECTURE.md | 2 +- .../onboarding-and-settings/README.md | 5 + .../onboarding-and-settings/usage-tracking.md | 13 +- .../sync-and-multi-device/ios-companion.md | 19 +- 10 files changed, 439 insertions(+), 250 deletions(-) create mode 100644 apps/desktop/src/main/services/usage/providerQuotaParsers.ts diff --git a/apps/desktop/src/main/services/usage/providerQuotaParsers.ts b/apps/desktop/src/main/services/usage/providerQuotaParsers.ts new file mode 100644 index 000000000..9973fac57 --- /dev/null +++ b/apps/desktop/src/main/services/usage/providerQuotaParsers.ts @@ -0,0 +1,221 @@ +import type { ExtraUsage, UsageWindow } from "../../../shared/types"; +import { isRecord } from "../shared/utils"; + +export interface ClaudeUsageResponse { + five_hour?: ClaudeUsageBucket; + fiveHour?: ClaudeUsageBucket; + seven_day?: ClaudeUsageBucket; + sevenDay?: ClaudeUsageBucket; + seven_day_sonnet?: ClaudeUsageBucket; + sevenDaySonnet?: ClaudeUsageBucket; + seven_day_opus?: ClaudeUsageBucket | null; + sevenDayOpus?: ClaudeUsageBucket | null; + seven_day_oauth_apps?: ClaudeUsageBucket | null; + sevenDayOAuthApps?: ClaudeUsageBucket | null; + seven_day_cowork?: ClaudeUsageBucket | null; + sevenDayCowork?: ClaudeUsageBucket | null; + extra_usage?: ClaudeExtraUsage | null; + extraUsage?: ClaudeExtraUsage | null; + rate_limit_tier?: string; +} + +type ClaudeUsageBucket = { + percent_used?: number; + used_percent?: number; + percentUsed?: number; + usedPercent?: number; + utilization?: number; + resets_at?: string; + resetsAt?: string; +}; + +type ClaudeExtraUsage = { + is_enabled?: boolean; + isEnabled?: boolean; + monthly_limit?: number; + monthlyLimit?: number; + used_credits?: number; + usedCredits?: number; + utilization?: number | null; + currency?: string; +}; + +export function computeResetsInMs(resetsAt: string): number { + if (!resetsAt) return 0; + return Math.max(0, new Date(resetsAt).getTime() - Date.now()); +} + +function usagePercent(bucket: Record | null | undefined): number { + if (!bucket) return 0; + if (typeof bucket.percent_used === "number") return bucket.percent_used; + if (typeof bucket.used_percent === "number") return bucket.used_percent; + if (typeof bucket.percentUsed === "number") return bucket.percentUsed; + if (typeof bucket.usedPercent === "number") return bucket.usedPercent; + if (typeof bucket.utilization === "number") return bucket.utilization; + return 0; +} + +function codexResetAt(value: unknown): string { + if (typeof value === "string" && value.trim()) return value; + if (typeof value === "number" && Number.isFinite(value)) { + const ms = value > 1_000_000_000_000 ? value : value * 1_000; + return new Date(ms).toISOString(); + } + return ""; +} + +export function parseClaudeWindows(data: ClaudeUsageResponse): { windows: UsageWindow[]; extraUsage: ExtraUsage | null } { + const windows: UsageWindow[] = []; + const fiveHour = data.five_hour ?? data.fiveHour; + const sevenDay = data.seven_day ?? data.sevenDay; + const sevenDaySonnet = data.seven_day_sonnet ?? data.sevenDaySonnet; + const sevenDayOpus = data.seven_day_opus ?? data.sevenDayOpus; + const sevenDayOAuthApps = data.seven_day_oauth_apps ?? data.sevenDayOAuthApps; + const sevenDayCowork = data.seven_day_cowork ?? data.sevenDayCowork; + + if (fiveHour) { + const resetsAt = fiveHour.resets_at ?? fiveHour.resetsAt ?? ""; + windows.push({ + provider: "claude", + windowType: "five_hour", + percentUsed: usagePercent(fiveHour), + resetsAt, + resetsInMs: computeResetsInMs(resetsAt), + }); + } + + if (sevenDay) { + const resetsAt = sevenDay.resets_at ?? sevenDay.resetsAt ?? ""; + const modelBreakdown: Record = {}; + if (sevenDaySonnet) modelBreakdown.sonnet = usagePercent(sevenDaySonnet); + if (sevenDayOpus) modelBreakdown.opus = usagePercent(sevenDayOpus); + windows.push({ + provider: "claude", + windowType: "weekly", + percentUsed: usagePercent(sevenDay), + resetsAt, + resetsInMs: computeResetsInMs(resetsAt), + modelBreakdown: Object.keys(modelBreakdown).length > 0 ? modelBreakdown : undefined, + }); + } + + if (sevenDayOAuthApps) { + const resetsAt = sevenDayOAuthApps.resets_at ?? sevenDayOAuthApps.resetsAt ?? ""; + windows.push({ + provider: "claude", + windowType: "weekly_oauth_apps", + percentUsed: usagePercent(sevenDayOAuthApps), + resetsAt, + resetsInMs: computeResetsInMs(resetsAt), + }); + } + + if (sevenDayCowork) { + const resetsAt = sevenDayCowork.resets_at ?? sevenDayCowork.resetsAt ?? ""; + windows.push({ + provider: "claude", + windowType: "weekly_cowork", + percentUsed: usagePercent(sevenDayCowork), + resetsAt, + resetsInMs: computeResetsInMs(resetsAt), + }); + } + + const extra = data.extra_usage ?? data.extraUsage; + let extraUsage: ExtraUsage | null = null; + if (extra) { + const isEnabled = extra.is_enabled ?? extra.isEnabled ?? false; + const usedCents = extra.used_credits ?? extra.usedCredits ?? 0; + const limitCents = extra.monthly_limit ?? extra.monthlyLimit ?? 0; + extraUsage = { + provider: "claude", + isEnabled, + usedCreditsUsd: usedCents / 100, + monthlyLimitUsd: limitCents / 100, + utilization: typeof extra.utilization === "number" ? extra.utilization : null, + currency: extra.currency ?? "usd", + }; + } + + return { windows, extraUsage }; +} + +export function parseCodexRateLimitWindows(data: Record): UsageWindow[] { + const windows: UsageWindow[] = []; + const snakeRateLimit = isRecord(data.rate_limit) ? data.rate_limit : null; + const camelRateLimits = isRecord(data.rateLimits) ? data.rateLimits : null; + const seen = new Set(); + + const addWindow = ( + bucket: Record | null, + fallbackWindowType: UsageWindow["windowType"], + limitId?: string | null, + ): void => { + if (!bucket) return; + const resetsAt = codexResetAt(bucket.reset_at ?? bucket.resets_at ?? bucket.resetsAt); + const windowDurationMins = codexWindowDurationMins(bucket); + const windowDurationMs = codexWindowDurationMs(windowDurationMins); + const windowType = codexWindowTypeFromDuration(windowDurationMins) ?? fallbackWindowType; + const key = `${windowType}:${resetsAt}:${limitId ?? ""}`; + if (seen.has(key)) return; + seen.add(key); + windows.push({ + provider: "codex", + windowType, + percentUsed: usagePercent(bucket), + resetsAt, + resetsInMs: computeResetsInMs(resetsAt), + ...(windowDurationMs ? { windowDurationMs } : {}), + }); + }; + + for (const [key, windowType] of [["primary", "five_hour"], ["secondary", "weekly"]] as const) { + const snakeKey = key === "primary" ? "primary_window" : "secondary_window"; + const snakeBucket = snakeRateLimit && isRecord(snakeRateLimit[snakeKey]) ? snakeRateLimit[snakeKey] : null; + const camelBucket = camelRateLimits && isRecord(camelRateLimits[key]) ? camelRateLimits[key] : null; + const directBucket = isRecord(data[snakeKey]) ? data[snakeKey] : isRecord(data[key]) ? data[key] : null; + addWindow(snakeBucket ?? camelBucket ?? directBucket, windowType); + } + + const limitSnapshots = [ + isRecord(data.rateLimits) ? data.rateLimits : null, + isRecord(data.rate_limits) ? data.rate_limits : null, + isRecord(data.rateLimitsByLimitId) ? data.rateLimitsByLimitId : null, + isRecord(data.rate_limits_by_limit_id) ? data.rate_limits_by_limit_id : null, + ].filter((entry): entry is Record => entry != null); + + for (const snapshots of limitSnapshots) { + for (const [limitId, rawSnapshot] of Object.entries(snapshots)) { + if (!isRecord(rawSnapshot)) continue; + for (const [field, fallbackType] of [["primary", "five_hour"], ["secondary", "weekly"]] as const) { + const bucket = isRecord(rawSnapshot[field]) ? rawSnapshot[field] : null; + if (bucket) addWindow(bucket, fallbackType, limitId); + } + } + } + + return windows; +} + +function codexWindowTypeFromDuration(value: number | null): UsageWindow["windowType"] | null { + if (value == null) return null; + if (value <= 360) return "five_hour"; + if (value <= 10_080) return "weekly"; + return "monthly"; +} + +function codexWindowDurationMins(bucket: Record): number | null { + const minutes = bucket.windowDurationMins ?? bucket.window_duration_mins; + if (typeof minutes === "number" && Number.isFinite(minutes) && minutes > 0) return minutes; + + const seconds = bucket.limitWindowSeconds + ?? bucket.limit_window_seconds + ?? bucket.windowDurationSeconds + ?? bucket.window_duration_seconds; + if (typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0) return seconds / 60; + return null; +} + +function codexWindowDurationMs(value: number | null): number | null { + return value == null ? null : Math.round(value * 60_000); +} diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts index 670f2a7fc..c19541384 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts @@ -1107,11 +1107,41 @@ Resets Jul 12 at 3pm // ../ai/providerCredentialSources.test.ts, colocated with the module. describe("parseCodexRateLimitWindows", () => { - it("accepts the wham HTTP response shape", () => { + it("classifies current weekly-only wham responses by their duration", () => { const result = parseCodexRateLimitWindows({ rate_limit: { - primary_window: { used_percent: 15, reset_at: 1773446952 }, - secondary_window: { used_percent: 63, reset_at: 1773853354 }, + primary_window: { + used_percent: 63, + reset_at: 1773853354, + limit_window_seconds: 7 * 24 * 60 * 60, + }, + secondary_window: null, + }, + }); + + expect(result).toEqual([ + expect.objectContaining({ + provider: "codex", + windowType: "weekly", + percentUsed: 63, + windowDurationMs: 7 * 24 * 60 * 60_000, + }), + ]); + }); + + it("detects five-hour and weekly wham windows without relying on their positions", () => { + const result = parseCodexRateLimitWindows({ + rate_limit: { + primary_window: { + used_percent: 63, + reset_at: 1773853354, + limit_window_seconds: 7 * 24 * 60 * 60, + }, + secondary_window: { + used_percent: 15, + reset_at: 1773446952, + limit_window_seconds: 5 * 60 * 60, + }, }, }); diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.ts b/apps/desktop/src/main/services/usage/usageTrackingService.ts index 63c37022d..b44131dff 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.ts @@ -93,6 +93,12 @@ import type { UsageRefreshReason, } from "./usageProviderStrategies"; import { localDayKey, localDayOffset, localDayStart } from "./localDay"; +import { + computeResetsInMs, + parseClaudeWindows, + parseCodexRateLimitWindows, + type ClaudeUsageResponse, +} from "./providerQuotaParsers"; // ── Constants ──────────────────────────────────────────────────── @@ -273,13 +279,6 @@ async function fetchJsonWithRetry( return last ?? { ok: false, status: 0, data: null }; } -// ── Window Helpers ─────────────────────────────────────────────── - -function computeResetsInMs(resetsAt: string): number { - if (!resetsAt) return 0; - 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"; @@ -519,213 +518,6 @@ async function pollClaudeViaCli(logger: Logger): Promise | null | undefined): number { - if (!bucket) return 0; - if (typeof bucket.percent_used === "number") return bucket.percent_used; - if (typeof bucket.used_percent === "number") return bucket.used_percent; - if (typeof bucket.percentUsed === "number") return bucket.percentUsed; - if (typeof bucket.usedPercent === "number") return bucket.usedPercent; - if (typeof bucket.utilization === "number") return bucket.utilization; - return 0; -} - -function codexResetAt(value: unknown): string { - if (typeof value === "string" && value.trim()) return value; - if (typeof value === "number" && Number.isFinite(value)) { - const ms = value > 1_000_000_000_000 ? value : value * 1_000; - return new Date(ms).toISOString(); - } - return ""; -} - -function parseClaudeWindows(data: ClaudeUsageResponse): { windows: UsageWindow[]; extraUsage: ExtraUsage | null } { - const windows: UsageWindow[] = []; - const fiveHour = data.five_hour ?? data.fiveHour; - const sevenDay = data.seven_day ?? data.sevenDay; - const sevenDaySonnet = data.seven_day_sonnet ?? data.sevenDaySonnet; - const sevenDayOpus = data.seven_day_opus ?? data.sevenDayOpus; - const sevenDayOAuthApps = data.seven_day_oauth_apps ?? data.sevenDayOAuthApps; - const sevenDayCowork = data.seven_day_cowork ?? data.sevenDayCowork; - - if (fiveHour) { - const resetsAt = fiveHour.resets_at ?? fiveHour.resetsAt ?? ""; - windows.push({ - provider: "claude", - windowType: "five_hour", - percentUsed: usagePercent(fiveHour), - resetsAt, - resetsInMs: computeResetsInMs(resetsAt), - }); - } - - if (sevenDay) { - const resetsAt = sevenDay.resets_at ?? sevenDay.resetsAt ?? ""; - const modelBreakdown: Record = {}; - if (sevenDaySonnet) modelBreakdown.sonnet = usagePercent(sevenDaySonnet); - if (sevenDayOpus) modelBreakdown.opus = usagePercent(sevenDayOpus); - windows.push({ - provider: "claude", - windowType: "weekly", - percentUsed: usagePercent(sevenDay), - resetsAt, - resetsInMs: computeResetsInMs(resetsAt), - modelBreakdown: Object.keys(modelBreakdown).length > 0 ? modelBreakdown : undefined, - }); - } - - if (sevenDayOAuthApps) { - const resetsAt = sevenDayOAuthApps.resets_at ?? sevenDayOAuthApps.resetsAt ?? ""; - windows.push({ - provider: "claude", - windowType: "weekly_oauth_apps", - percentUsed: usagePercent(sevenDayOAuthApps), - resetsAt, - resetsInMs: computeResetsInMs(resetsAt), - }); - } - - if (sevenDayCowork) { - const resetsAt = sevenDayCowork.resets_at ?? sevenDayCowork.resetsAt ?? ""; - windows.push({ - provider: "claude", - windowType: "weekly_cowork", - percentUsed: usagePercent(sevenDayCowork), - resetsAt, - resetsInMs: computeResetsInMs(resetsAt), - }); - } - - // Parse extra usage (monthly spend vs limit) — values come in cents from the API - const extra = data.extra_usage ?? data.extraUsage; - let extraUsage: ExtraUsage | null = null; - if (extra) { - const isEnabled = extra.is_enabled ?? extra.isEnabled ?? false; - const usedCents = extra.used_credits ?? extra.usedCredits ?? 0; - const limitCents = extra.monthly_limit ?? extra.monthlyLimit ?? 0; - extraUsage = { - provider: "claude", - isEnabled, - usedCreditsUsd: usedCents / 100, - monthlyLimitUsd: limitCents / 100, - utilization: typeof extra.utilization === "number" ? extra.utilization : null, - currency: extra.currency ?? "usd", - }; - } - - return { windows, extraUsage }; -} - -function parseCodexRateLimitWindows(data: Record): UsageWindow[] { - const windows: UsageWindow[] = []; - const snakeRateLimit = isRecord(data.rate_limit) ? data.rate_limit : null; - const camelRateLimits = isRecord(data.rateLimits) ? data.rateLimits : null; - const seen = new Set(); - - const addWindow = ( - bucket: Record | null, - windowType: UsageWindow["windowType"], - limitId?: string | null, - ): void => { - if (!bucket) return; - const resetsAt = codexResetAt(bucket.reset_at ?? bucket.resets_at ?? bucket.resetsAt); - const windowDurationMs = codexWindowDurationMs(bucket.windowDurationMins ?? bucket.window_duration_mins); - const key = `${windowType}:${resetsAt}:${limitId ?? ""}`; - if (seen.has(key)) return; - seen.add(key); - windows.push({ - provider: "codex", - windowType, - percentUsed: usagePercent(bucket), - resetsAt, - resetsInMs: computeResetsInMs(resetsAt), - ...(windowDurationMs ? { windowDurationMs } : {}), - }); - }; - - for (const [key, windowType] of [["primary", "five_hour"], ["secondary", "weekly"]] as const) { - const snakeKey = key === "primary" ? "primary_window" : "secondary_window"; - const snakeBucket = snakeRateLimit && isRecord(snakeRateLimit[snakeKey]) ? snakeRateLimit[snakeKey] : null; - const camelBucket = camelRateLimits && isRecord(camelRateLimits[key]) ? camelRateLimits[key] : null; - const directBucket = isRecord(data[snakeKey]) ? data[snakeKey] : isRecord(data[key]) ? data[key] : null; - const bucket = snakeBucket ?? camelBucket ?? directBucket; - addWindow(bucket, windowType); - } - - const limitSnapshots = [ - isRecord(data.rateLimits) ? data.rateLimits : null, - isRecord(data.rate_limits) ? data.rate_limits : null, - isRecord(data.rateLimitsByLimitId) ? data.rateLimitsByLimitId : null, - isRecord(data.rate_limits_by_limit_id) ? data.rate_limits_by_limit_id : null, - ].filter((entry): entry is Record => entry != null); - - for (const snapshots of limitSnapshots) { - for (const [limitId, rawSnapshot] of Object.entries(snapshots)) { - const snapshot = isRecord(rawSnapshot) ? rawSnapshot : null; - if (!snapshot) continue; - for (const [field, fallbackType] of [["primary", "five_hour"], ["secondary", "weekly"]] as const) { - const bucket = isRecord(snapshot[field]) ? snapshot[field] : null; - if (!bucket) continue; - addWindow(bucket, codexWindowTypeFromDuration(bucket.windowDurationMins ?? bucket.window_duration_mins) ?? fallbackType, limitId); - } - } - } - - return windows; -} - -function codexWindowTypeFromDuration(value: unknown): UsageWindow["windowType"] | null { - if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null; - if (value <= 360) return "five_hour"; - if (value <= 10_080) return "weekly"; - return "monthly"; -} - -function codexWindowDurationMs(value: unknown): number | null { - if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null; - return Math.round(value * 60_000); -} - // 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 diff --git a/apps/desktop/src/renderer/components/usage/usage.test.tsx b/apps/desktop/src/renderer/components/usage/usage.test.tsx index 9ff66b459..88d73a77c 100644 --- a/apps/desktop/src/renderer/components/usage/usage.test.tsx +++ b/apps/desktop/src/renderer/components/usage/usage.test.tsx @@ -316,6 +316,59 @@ describe("usage components", () => { expect(await screen.findByText("44.0% used")).toBeTruthy(); }); + it("shows pacing for both Codex windows when both limits are reported", async () => { + const snapshot = makeQuotaPanelSnapshot(); + const resetsAt = "2099-05-15T07:00:00.000Z"; + snapshot.windows = [ + { + provider: "codex", + windowType: "five_hour", + percentUsed: 48, + resetsAt, + resetsInMs: 2.5 * 60 * 60_000, + windowDurationMs: 5 * 60 * 60_000, + pacing: { + status: "on-track", + projectedWeeklyPercent: 96, + weekElapsedPercent: 50, + expectedPercent: 50, + deltaPercent: -2, + etaHours: 2.7, + willLastToReset: true, + resetsInHours: 2.5, + }, + }, + { + provider: "codex", + windowType: "weekly", + percentUsed: 63, + resetsAt, + resetsInMs: 3.5 * 24 * 60 * 60_000, + windowDurationMs: 7 * 24 * 60 * 60_000, + pacing: { + status: "far-ahead", + projectedWeeklyPercent: 126, + weekElapsedPercent: 50, + expectedPercent: 50, + deltaPercent: 13, + etaHours: 49.3, + willLastToReset: false, + resetsInHours: 84, + }, + }, + ]; + vi.mocked(window.ade.usage.getSnapshot).mockResolvedValue(snapshot); + vi.mocked(window.ade.usage.noteDemand).mockResolvedValue(snapshot); + + render(); + + expect(await screen.findByRole("progressbar", { name: "5-hour: 48.0% used" })).toBeTruthy(); + expect(screen.getByRole("progressbar", { name: "Weekly: 63.0% used" })).toBeTruthy(); + expect(screen.getByText("on track")).toBeTruthy(); + expect(screen.getByText("13% ahead")).toBeTruthy(); + expect(screen.getByText(/trending to 126% by reset/)).toBeTruthy(); + }); + it("registers non-interactive quota demand on mount without forcing user auth", async () => { render(); diff --git a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift index 8ebcdb043..10ea0b4e8 100644 --- a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift +++ b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift @@ -308,6 +308,51 @@ private final class WorkComposerPastingTextView: UITextView { } } +/// UIKit responder changes can synchronously re-enter SwiftUI's view graph. +/// Always apply the latest focus request after the current representable update +/// has yielded so send-time draft mutations cannot create an AttributeGraph +/// dependency cycle. +@MainActor +private final class WorkComposerFocusScheduler { + private var lastRequest: Bool? + private var pendingTask: Task? + + @discardableResult + func apply(_ isFocused: Bool, to textView: UITextView) -> Task? { + // SwiftUI may update the representable more than once for the same state. + // Keep the queued transition instead of canceling it without a replacement. + if lastRequest == isFocused, pendingTask != nil { return pendingTask } + + let previousRequest = lastRequest + lastRequest = isFocused + pendingTask?.cancel() + pendingTask = nil + + // Preserve the existing initial-false behavior: creating a composer with + // an unfocused binding must not dismiss a responder owned by another view. + guard isFocused || previousRequest == true else { return nil } + + pendingTask = Task { @MainActor [weak self, weak textView] in + await Task.yield() + guard !Task.isCancelled, + let self, + let textView, + self.lastRequest == isFocused + else { return } + + self.pendingTask = nil + if isFocused { + if !textView.isFirstResponder { + textView.becomeFirstResponder() + } + } else if textView.isFirstResponder { + textView.resignFirstResponder() + } + } + return pendingTask + } +} + /// Plain UITextView composer for start-chat surfaces that do not need typed /// trigger chips but still need multiline sizing and image-paste interception. struct WorkPlainComposerTextView: UIViewRepresentable { @@ -377,7 +422,7 @@ struct WorkPlainComposerTextView: UIViewRepresentable { var parent: WorkPlainComposerTextView weak var textView: UITextView? private var placeholderLabel: UILabel? - private var lastFocusRequest: Bool? + private let focusScheduler = WorkComposerFocusScheduler() init(_ parent: WorkPlainComposerTextView) { self.parent = parent @@ -399,13 +444,9 @@ struct WorkPlainComposerTextView: UIViewRepresentable { updateHeight() } - func applyFocusRequest(_ isFocused: Bool, to textView: UITextView) { - if isFocused, !textView.isFirstResponder { - textView.becomeFirstResponder() - } else if !isFocused, lastFocusRequest == true, textView.isFirstResponder { - textView.resignFirstResponder() - } - lastFocusRequest = isFocused + @discardableResult + func applyFocusRequest(_ isFocused: Bool, to textView: UITextView) -> Task? { + focusScheduler.apply(isFocused, to: textView) } func handlePasteImages(_ images: [UIImage]) -> Bool { @@ -562,7 +603,7 @@ struct WorkComposerTextView: UIViewRepresentable { private var chips: [(range: NSRange, text: String)] = [] private var placeholderLabel: UILabel? private var triggerInputTraitsActive = false - private var lastFocusRequest: Bool? + private let focusScheduler = WorkComposerFocusScheduler() init(_ parent: WorkComposerTextView) { self.parent = parent @@ -583,13 +624,9 @@ struct WorkComposerTextView: UIViewRepresentable { if parent.draftState.isFocused { parent.draftState.isFocused = false } } - func applyFocusRequest(_ isFocused: Bool, to textView: UITextView) { - if isFocused, !textView.isFirstResponder { - textView.becomeFirstResponder() - } else if !isFocused, lastFocusRequest == true, textView.isFirstResponder { - textView.resignFirstResponder() - } - lastFocusRequest = isFocused + @discardableResult + func applyFocusRequest(_ isFocused: Bool, to textView: UITextView) -> Task? { + focusScheduler.apply(isFocused, to: textView) } private func chipAttributes(kind: WorkComposerTriggerKind) -> [NSAttributedString.Key: Any] { diff --git a/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift b/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift index 5f9a72be1..d3019cc6b 100644 --- a/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift +++ b/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift @@ -130,7 +130,7 @@ final class WorkComposerTriggerDetectorTests: XCTestCase { } @MainActor - func testPlainComposerIgnoresInitialFalseFocusUpdate() { + func testPlainComposerDefersFocusTransitionsOutsideSwiftUIUpdate() async { var text = "" var isFocused = false var measuredHeight: CGFloat = 28 @@ -145,14 +145,22 @@ final class WorkComposerTriggerDetectorTests: XCTestCase { textView.resetRecording() textView.fakeIsFirstResponder = true - coordinator.applyFocusRequest(false, to: textView) + let initialTask = coordinator.applyFocusRequest(false, to: textView) + await initialTask?.value XCTAssertEqual(textView.resignCount, 0) XCTAssertTrue(textView.fakeIsFirstResponder) textView.fakeIsFirstResponder = false - coordinator.applyFocusRequest(true, to: textView) - coordinator.applyFocusRequest(false, to: textView) + let focusTask = coordinator.applyFocusRequest(true, to: textView) + XCTAssertEqual(textView.becomeCount, 0) + await focusTask?.value XCTAssertEqual(textView.becomeCount, 1) + XCTAssertTrue(textView.fakeIsFirstResponder) + + coordinator.applyFocusRequest(false, to: textView) + let dismissTask = coordinator.applyFocusRequest(false, to: textView) + XCTAssertEqual(textView.resignCount, 0) + await dismissTask?.value XCTAssertEqual(textView.resignCount, 1) XCTAssertFalse(textView.fakeIsFirstResponder) } @@ -173,7 +181,7 @@ final class WorkComposerTriggerDetectorTests: XCTestCase { } @MainActor - func testChatComposerAppliesRequestedFocusTransitions() { + func testChatComposerDefersAndCoalescesRequestedFocusTransitions() async { let draft = WorkChatComposerDraftState() let controller = WorkComposerSuggestionController() var measuredHeight: CGFloat = 24 @@ -188,13 +196,36 @@ final class WorkComposerTriggerDetectorTests: XCTestCase { let textView = FocusRecordingTextView() textView.resetRecording() - coordinator.applyFocusRequest(false, to: textView) - coordinator.applyFocusRequest(true, to: textView) - coordinator.applyFocusRequest(false, to: textView) + let initialTask = coordinator.applyFocusRequest(false, to: textView) + await initialTask?.value + XCTAssertEqual(textView.resignCount, 0) + let focusTask = coordinator.applyFocusRequest(true, to: textView) + XCTAssertEqual(textView.becomeCount, 0) + await focusTask?.value XCTAssertEqual(textView.becomeCount, 1) + XCTAssertTrue(textView.fakeIsFirstResponder) + + let dismissTask = coordinator.applyFocusRequest(false, to: textView) + XCTAssertEqual(textView.resignCount, 0) + await dismissTask?.value XCTAssertEqual(textView.resignCount, 1) XCTAssertFalse(textView.fakeIsFirstResponder) + + // A failed send can restore focus before the queued dismissal runs. Only + // the latest request should win, so the keyboard never flickers closed. + textView.fakeIsFirstResponder = true + let refocusTask = coordinator.applyFocusRequest(true, to: textView) + await refocusTask?.value + textView.resetRecording(firstResponder: true) + + coordinator.applyFocusRequest(false, to: textView) + let restoredFocusTask = coordinator.applyFocusRequest(true, to: textView) + await restoredFocusTask?.value + + XCTAssertEqual(textView.becomeCount, 0) + XCTAssertEqual(textView.resignCount, 0) + XCTAssertTrue(textView.fakeIsFirstResponder) } } @@ -219,9 +250,9 @@ private final class FocusRecordingTextView: UITextView { return true } - func resetRecording() { + func resetRecording(firstResponder: Bool = false) { becomeCount = 0 resignCount = 0 - fakeIsFirstResponder = false + fakeIsFirstResponder = firstResponder } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 268680bae..241a16061 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -641,7 +641,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`, `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`; that 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. 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. | | `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 `profileScope: "global"`; `builtInBrowserService.ts` treats it as an explicit storage partition choice instead of falling back to the source window's active project profile. See [Personal chats](./features/personal-chats/README.md) for the complete source map and invariants. diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 3061cc6d9..e81ae4d59 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -314,6 +314,11 @@ Renderer — settings: 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/providerQuotaParsers.ts` — normalizes + Claude and Codex live-quota response variants. Codex buckets use their + advertised duration (minute- or second-based fields) to determine whether a + window is five-hour, weekly, or monthly; primary/secondary position is only a + fallback for older payloads that omit duration metadata. - `apps/desktop/src/main/services/usage/ledgers/localUsageLedgers.ts` — read-only provider-history adapters. The Codex path selects recent JSONL files within per-file and aggregate byte budgets, discards oversized physical diff --git a/docs/features/onboarding-and-settings/usage-tracking.md b/docs/features/onboarding-and-settings/usage-tracking.md index 4423d96d4..f1a6d3fa2 100644 --- a/docs/features/onboarding-and-settings/usage-tracking.md +++ b/docs/features/onboarding-and-settings/usage-tracking.md @@ -27,6 +27,15 @@ on 2026-07-10. The relevant upstream references are | 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. | +Codex quota payloads are normalized in `providerQuotaParsers.ts`. Current HTTP +responses can report only one weekly bucket in `primary_window`, while older +responses and app-server snapshots may expose five-hour and weekly buckets in +primary/secondary positions. ADE therefore prefers the bucket's advertised +duration (`limit_window_seconds`, `window_duration_seconds`, or the equivalent +minute fields) and uses position only when duration metadata is absent. This +keeps the compact header and detailed Limits panel truthful when a provider +omits one window or changes its ordering. + ## Why Claude appeared to take forever The slow path was not only Anthropic's endpoint. An explicit refresh invalidated @@ -136,7 +145,9 @@ 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. +must remain covered. Codex parser coverage also pins duration-based window +classification so weekly-only and reordered five-hour/weekly responses cannot +be mislabeled by their primary/secondary positions. ## Provider strategy boundary diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 04d600c5b..24cea7a97 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -209,7 +209,8 @@ apps/ios/ │ │ │ # WorkComposerTypedTriggers (UITextView │ │ │ # composer + cursor-relative /command │ │ │ # & @file detection, inline suggestion -│ │ │ # strip, chip pills; replaced the +│ │ │ # strip, chip pills, deferred/coalesced +│ │ │ # UIKit focus transitions; replaced the │ │ │ # WorkMentionsPickerSheet / │ │ │ # WorkSlashCommandsSheet modals), │ │ │ # WorkChatAttachmentTray, @@ -1043,10 +1044,18 @@ composer: Work session chat clears the observable `UITextView` focus request, Work new-chat and personal new-chat clear their focus bindings, and the Hub inline composer collapses its full panel. The prompt field therefore returns to its compact resting state without an interactive keyboard swipe that can -conflict with chat navigation. On `WorkNewChatScreen`, the cross-client activity -carousel is part of the main scroll content rather than a pinned sibling above -the composer, so keyboard presentation gives an expanding multi-line prompt the -available space instead of lifting the activity panel with it. +conflict with chat navigation. `WorkComposerTextView` and +`WorkPlainComposerTextView` never call `becomeFirstResponder` or +`resignFirstResponder` synchronously from `UIViewRepresentable.updateUIView`. +Their shared focus scheduler yields past the active SwiftUI update, coalesces +rapid focus changes to the latest request, and ignores an initial unfocused +binding so it cannot dismiss a responder owned by another view. This prevents +send-time draft clearing from re-entering SwiftUI's view graph while preserving +keyboard dismissal and later focus restoration. On `WorkNewChatScreen`, the +cross-client activity carousel is part of the main scroll content rather than a +pinned sibling above the composer, so keyboard presentation gives an expanding +multi-line prompt the available space instead of lifting the activity panel +with it. Mobile chat image attachments use the same host-side temp attachment contract as desktop. Work and Hub chat composers expose an add-attachment control beside the From 5e5832e8802b01d43b1048815f9df3493cda5789 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:38:26 -0400 Subject: [PATCH 2/2] fix: address usage and focus review edge cases --- .../src/main/services/usage/providerQuotaParsers.ts | 4 +++- .../services/usage/usageTrackingService.test.ts | 13 +++++++++++++ .../ADE/Views/Work/WorkComposerTypedTriggers.swift | 5 +++++ .../ADETests/WorkComposerTriggerDetectorTests.swift | 8 ++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/services/usage/providerQuotaParsers.ts b/apps/desktop/src/main/services/usage/providerQuotaParsers.ts index 9973fac57..5cfecdcea 100644 --- a/apps/desktop/src/main/services/usage/providerQuotaParsers.ts +++ b/apps/desktop/src/main/services/usage/providerQuotaParsers.ts @@ -42,7 +42,9 @@ type ClaudeExtraUsage = { export function computeResetsInMs(resetsAt: string): number { if (!resetsAt) return 0; - return Math.max(0, new Date(resetsAt).getTime() - Date.now()); + const target = new Date(resetsAt).getTime(); + if (!Number.isFinite(target)) return 0; + return Math.max(0, target - Date.now()); } function usagePercent(bucket: Record | null | undefined): number { diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts index c19541384..94d04b91b 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts @@ -1057,6 +1057,19 @@ describe("parseClaudeWindows", () => { expect(result.windows.find((window) => window.windowType === "weekly")?.modelBreakdown?.opus).toBe(5); }); + it("uses a zero reset countdown for malformed provider timestamps", () => { + const result = parseClaudeWindows({ + five_hour: { utilization: 35, resets_at: "not-a-date" }, + }); + + expect(result.windows).toEqual([ + expect.objectContaining({ + resetsAt: "not-a-date", + resetsInMs: 0, + }), + ]); + }); + it("parses extra_usage when present", () => { const result = parseClaudeWindows({ five_hour: { utilization: 15, resets_at: "2026-03-14T21:00:00+00:00" }, diff --git a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift index 10ea0b4e8..4ea8cedfb 100644 --- a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift +++ b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift @@ -332,6 +332,11 @@ private final class WorkComposerFocusScheduler { // an unfocused binding must not dismiss a responder owned by another view. guard isFocused || previousRequest == true else { return nil } + // When UIKit already matches the latest binding and no transition remains + // queued, there is nothing to defer. This keeps routine SwiftUI updates + // from creating main-actor tasks after focus has settled. + guard textView.isFirstResponder != isFocused else { return nil } + pendingTask = Task { @MainActor [weak self, weak textView] in await Task.yield() guard !Task.isCancelled, diff --git a/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift b/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift index d3019cc6b..87104441d 100644 --- a/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift +++ b/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift @@ -157,6 +157,10 @@ final class WorkComposerTriggerDetectorTests: XCTestCase { XCTAssertEqual(textView.becomeCount, 1) XCTAssertTrue(textView.fakeIsFirstResponder) + let settledFocusTask = coordinator.applyFocusRequest(true, to: textView) + XCTAssertNil(settledFocusTask) + XCTAssertEqual(textView.becomeCount, 1) + coordinator.applyFocusRequest(false, to: textView) let dismissTask = coordinator.applyFocusRequest(false, to: textView) XCTAssertEqual(textView.resignCount, 0) @@ -206,6 +210,10 @@ final class WorkComposerTriggerDetectorTests: XCTestCase { XCTAssertEqual(textView.becomeCount, 1) XCTAssertTrue(textView.fakeIsFirstResponder) + let settledFocusTask = coordinator.applyFocusRequest(true, to: textView) + XCTAssertNil(settledFocusTask) + XCTAssertEqual(textView.becomeCount, 1) + let dismissTask = coordinator.applyFocusRequest(false, to: textView) XCTAssertEqual(textView.resignCount, 0) await dismissTask?.value