mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-17 03:56:45 +00:00
Merge pull request #188 from getagentseal/feat/menubar-hardening
Harden menubar: refresh loop, concurrency, data sync, edge cases
This commit is contained in:
commit
ffc0e486d3
10 changed files with 96 additions and 47 deletions
|
|
@ -25,7 +25,8 @@ final class AppStore {
|
|||
}
|
||||
var showingAccentPicker: Bool = false
|
||||
var currency: String = "USD"
|
||||
var isLoading: Bool = false
|
||||
var isLoading: Bool { loadingCount > 0 }
|
||||
private var loadingCount: Int = 0
|
||||
var lastError: String?
|
||||
var subscription: SubscriptionUsage?
|
||||
var subscriptionError: String?
|
||||
|
|
@ -33,6 +34,7 @@ final class AppStore {
|
|||
var capacityEstimates: [String: CapacityEstimate] = [:]
|
||||
|
||||
private var cache: [PayloadCacheKey: CachedPayload] = [:]
|
||||
private var switchTask: Task<Void, Never>?
|
||||
|
||||
private var currentKey: PayloadCacheKey {
|
||||
PayloadCacheKey(period: selectedPeriod, provider: selectedProvider)
|
||||
|
|
@ -62,16 +64,37 @@ final class AppStore {
|
|||
payload.optimize.findingCount
|
||||
}
|
||||
|
||||
/// Switch to a period. Always fetches fresh data so the user never sees stale numbers.
|
||||
func switchTo(period: Period) async {
|
||||
/// Switch to a period. Cancels any in-flight switch and fetches provider-specific +
|
||||
/// all-provider data in parallel so tab strip costs stay in sync with the hero.
|
||||
func switchTo(period: Period) {
|
||||
selectedPeriod = period
|
||||
await refresh(includeOptimize: true, force: true)
|
||||
switchTask?.cancel()
|
||||
switchTask = Task {
|
||||
if selectedProvider == .all {
|
||||
await refresh(includeOptimize: true, force: true)
|
||||
} else {
|
||||
async let main: Void = refresh(includeOptimize: true, force: true)
|
||||
async let all: Void = refreshQuietly(period: period)
|
||||
_ = await (main, all)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch to a provider filter. Always fetches fresh data so the user never sees stale numbers.
|
||||
func switchTo(provider: ProviderFilter) async {
|
||||
/// Switch to a provider filter. Cancels any in-flight switch so rapid tab tapping only
|
||||
/// runs the CLI for the final selection. Fetches provider-specific and all-provider data
|
||||
/// in parallel so the tab strip costs stay in sync with the hero.
|
||||
func switchTo(provider: ProviderFilter) {
|
||||
selectedProvider = provider
|
||||
await refresh(includeOptimize: true, force: true)
|
||||
switchTask?.cancel()
|
||||
switchTask = Task {
|
||||
if provider == .all {
|
||||
await refresh(includeOptimize: true, force: true)
|
||||
} else {
|
||||
async let main: Void = refresh(includeOptimize: true, force: true)
|
||||
async let all: Void = refreshQuietly(period: selectedPeriod)
|
||||
_ = await (main, all)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var inFlightKeys: Set<PayloadCacheKey> = []
|
||||
|
|
@ -85,18 +108,21 @@ final class AppStore {
|
|||
if !force, cache[key]?.isFresh == true { return }
|
||||
guard !inFlightKeys.contains(key) else { return }
|
||||
inFlightKeys.insert(key)
|
||||
if cache[key] == nil {
|
||||
isLoading = true
|
||||
let showedLoading = cache[key] == nil
|
||||
if showedLoading {
|
||||
loadingCount += 1
|
||||
}
|
||||
defer {
|
||||
inFlightKeys.remove(key)
|
||||
isLoading = false
|
||||
if showedLoading { loadingCount = max(loadingCount - 1, 0) }
|
||||
}
|
||||
do {
|
||||
let fresh = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: includeOptimize)
|
||||
guard !Task.isCancelled else { return }
|
||||
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
|
||||
lastError = nil
|
||||
} catch {
|
||||
if Task.isCancelled { return }
|
||||
lastError = String(describing: error)
|
||||
NSLog("CodeBurn: fetch failed for \(key.period.rawValue)/\(key.provider.rawValue): \(error)")
|
||||
}
|
||||
|
|
@ -336,7 +362,7 @@ private let thousandsFormatter: NumberFormatter = {
|
|||
return f
|
||||
}()
|
||||
|
||||
extension Double {
|
||||
@MainActor extension Double {
|
||||
func asCurrency() -> String {
|
||||
let state = CurrencyState.shared
|
||||
let converted = self * state.rate
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
ProcessInfo.processInfo.disableSuddenTermination()
|
||||
backgroundActivity = ProcessInfo.processInfo.beginActivity(
|
||||
options: [.userInitiated, .automaticTerminationDisabled, .suddenTerminationDisabled],
|
||||
reason: "CodeBurn menubar polls AI coding cost every 15 seconds while idle in the background."
|
||||
reason: "CodeBurn menubar polls AI coding cost every 30 seconds while idle in the background."
|
||||
)
|
||||
|
||||
restorePersistedCurrency()
|
||||
|
|
@ -174,7 +174,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
lastRefreshTime = now
|
||||
|
||||
Task {
|
||||
await store.refresh(includeOptimize: true, force: true)
|
||||
async let main: Void = store.refresh(includeOptimize: true, force: true)
|
||||
async let today: Void = store.refreshQuietly(period: .today)
|
||||
_ = await (main, today)
|
||||
refreshStatusButton()
|
||||
}
|
||||
}
|
||||
|
|
@ -202,9 +204,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
}
|
||||
|
||||
private func startRefreshLoop() {
|
||||
Task {
|
||||
await store.refresh(includeOptimize: true)
|
||||
refreshStatusButton()
|
||||
Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self else { return }
|
||||
if self.store.selectedPeriod != .today || self.store.selectedProvider != .all {
|
||||
await self.store.refreshQuietly(period: .today)
|
||||
}
|
||||
await self.store.refresh(includeOptimize: true, force: true)
|
||||
self.refreshStatusButton()
|
||||
try? await Task.sleep(nanoseconds: refreshIntervalNanos)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ private let minValidFXRate: Double = 0.0001
|
|||
private let maxValidFXRate: Double = 1_000_000
|
||||
private let fxFetchTimeoutSeconds: TimeInterval = 10
|
||||
|
||||
@Observable
|
||||
final class CurrencyState: @unchecked Sendable {
|
||||
@MainActor @Observable
|
||||
final class CurrencyState: Sendable {
|
||||
static let shared = CurrencyState()
|
||||
|
||||
var code: String = "USD"
|
||||
|
|
@ -31,7 +31,7 @@ final class CurrencyState: @unchecked Sendable {
|
|||
}
|
||||
}
|
||||
|
||||
static func symbolForCode(_ code: String) -> String {
|
||||
nonisolated static func symbolForCode(_ code: String) -> String {
|
||||
// Some locales return "US$" for USD or "CA$" for CAD via NumberFormatter. Prefer the
|
||||
// plain glyph form everyone recognises.
|
||||
if let override = symbolOverrides[code] { return override }
|
||||
|
|
@ -42,7 +42,7 @@ final class CurrencyState: @unchecked Sendable {
|
|||
return formatter.currencySymbol ?? code
|
||||
}
|
||||
|
||||
private static let symbolOverrides: [String: String] = [
|
||||
nonisolated private static let symbolOverrides: [String: String] = [
|
||||
"USD": "$",
|
||||
"CAD": "$",
|
||||
"AUD": "$",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import SwiftUI
|
||||
import Observation
|
||||
|
||||
enum AccentPreset: String, CaseIterable, Identifiable {
|
||||
case ember = "Ember"
|
||||
|
|
@ -72,6 +73,7 @@ enum AccentPreset: String, CaseIterable, Identifiable {
|
|||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ThemeState {
|
||||
static let shared = ThemeState()
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ struct AgentTabStrip: View {
|
|||
HStack(spacing: 5) {
|
||||
ForEach(visibleFilters) { filter in
|
||||
Button {
|
||||
Task { await store.switchTo(provider: filter) }
|
||||
store.switchTo(provider: filter)
|
||||
} label: {
|
||||
AgentTab(
|
||||
filter: filter,
|
||||
|
|
|
|||
|
|
@ -370,7 +370,7 @@ private struct MiniStat: View {
|
|||
}
|
||||
|
||||
private struct TrendBar: Identifiable {
|
||||
let id = UUID()
|
||||
var id: String { date }
|
||||
let date: String
|
||||
let cost: Double
|
||||
let inputTokens: Double
|
||||
|
|
@ -793,7 +793,7 @@ private struct AllStats {
|
|||
let historyDayCount: Int
|
||||
}
|
||||
|
||||
private func computeAllStats(payload: MenubarPayload) -> AllStats {
|
||||
@MainActor private func computeAllStats(payload: MenubarPayload) -> AllStats {
|
||||
let history = payload.history.daily
|
||||
let favoriteModel = payload.current.topModels.first?.name ?? "—"
|
||||
|
||||
|
|
@ -848,13 +848,21 @@ private func computeAllStats(payload: MenubarPayload) -> AllStats {
|
|||
|
||||
var longestStreak = 0
|
||||
var running = 0
|
||||
let sortedDates = history.map(\.date).sorted()
|
||||
for date in sortedDates {
|
||||
if (costByDate[date] ?? 0) > 0 {
|
||||
running += 1
|
||||
longestStreak = max(longestStreak, running)
|
||||
} else {
|
||||
running = 0
|
||||
if let firstDate = history.map(\.date).min(),
|
||||
let lastDate = history.map(\.date).max(),
|
||||
let start = formatter.date(from: firstDate),
|
||||
let end = formatter.date(from: lastDate) {
|
||||
var cursor = start
|
||||
while cursor <= end {
|
||||
let key = formatter.string(from: cursor)
|
||||
if (costByDate[key] ?? 0) > 0 {
|
||||
running += 1
|
||||
longestStreak = max(longestStreak, running)
|
||||
} else {
|
||||
running = 0
|
||||
}
|
||||
guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
|
||||
cursor = next
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ struct MenuBarContent: View {
|
|||
|
||||
StarBanner()
|
||||
}
|
||||
.id(store.accentPreset)
|
||||
}
|
||||
|
||||
/// True when a specific provider tab is selected and that provider has no spend in the
|
||||
|
|
@ -457,7 +456,7 @@ struct FooterBar: View {
|
|||
Task {
|
||||
let downloads = (NSHomeDirectory() as NSString).appendingPathComponent("Downloads")
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
formatter.dateFormat = "yyyy-MM-dd-HHmmss"
|
||||
let base = "codeburn-\(formatter.string(from: Date()))"
|
||||
let outputPath = (downloads as NSString).appendingPathComponent(base + format.suffix)
|
||||
|
||||
|
|
@ -466,13 +465,17 @@ struct FooterBar: View {
|
|||
])
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
if process.terminationStatus == 0 {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: outputPath)])
|
||||
} else {
|
||||
NSLog("CodeBurn: \(format.cliName.uppercased()) export exited with status \(process.terminationStatus)")
|
||||
let fmt = format
|
||||
process.terminationHandler = { proc in
|
||||
Task { @MainActor in
|
||||
if proc.terminationStatus == 0 {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: outputPath)])
|
||||
} else {
|
||||
NSLog("CodeBurn: \(fmt.cliName.uppercased()) export exited with status \(proc.terminationStatus)")
|
||||
}
|
||||
}
|
||||
}
|
||||
try process.run()
|
||||
} catch {
|
||||
NSLog("CodeBurn: \(format.cliName.uppercased()) export failed: \(error)")
|
||||
}
|
||||
|
|
@ -483,21 +486,18 @@ struct FooterBar: View {
|
|||
/// thread right away so the UI redraws the next frame, then fetches a fresh rate in the
|
||||
/// background. CLI config is persisted so other codeburn commands stay in sync.
|
||||
private func applyCurrency(code: String) {
|
||||
store.currency = code
|
||||
let symbol = CurrencyState.symbolForCode(code)
|
||||
|
||||
Task {
|
||||
let cached = await FXRateCache.shared.cachedRate(for: code)
|
||||
await MainActor.run {
|
||||
if let cached {
|
||||
store.currency = code
|
||||
CurrencyState.shared.apply(code: code, rate: cached, symbol: symbol)
|
||||
}
|
||||
|
||||
let fresh = await FXRateCache.shared.rate(for: code)
|
||||
if let fresh, fresh != cached {
|
||||
await MainActor.run {
|
||||
CurrencyState.shared.apply(code: code, rate: fresh, symbol: symbol)
|
||||
}
|
||||
}
|
||||
store.currency = code
|
||||
CurrencyState.shared.apply(code: code, rate: fresh ?? cached, symbol: symbol)
|
||||
}
|
||||
|
||||
CLICurrencyConfig.persist(code: code)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ struct PeriodSegmentedControl: View {
|
|||
HStack(spacing: 1) {
|
||||
ForEach(Period.allCases) { period in
|
||||
Button {
|
||||
Task { await store.switchTo(period: period) }
|
||||
store.switchTo(period: period)
|
||||
} label: {
|
||||
Text(period.rawValue)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -249,6 +249,7 @@ export function getShortModelName(model: string): string {
|
|||
'gpt-5.4-mini': 'GPT-5.4 Mini',
|
||||
'gpt-5.4': 'GPT-5.4',
|
||||
'gpt-5.3-codex': 'GPT-5.3 Codex',
|
||||
'gpt-5.3': 'GPT-5.3',
|
||||
'gpt-5.2-pro': 'GPT-5.2 Pro',
|
||||
'gpt-5.2-low': 'GPT-5.2 Low',
|
||||
'gpt-5.2': 'GPT-5.2',
|
||||
|
|
@ -263,6 +264,9 @@ export function getShortModelName(model: string): string {
|
|||
'gemini-3-flash-preview': 'Gemini 3 Flash',
|
||||
'gemini-2.5-pro': 'Gemini 2.5 Pro',
|
||||
'gemini-2.5-flash': 'Gemini 2.5 Flash',
|
||||
'deepseek-coder-max': 'DeepSeek Coder Max',
|
||||
'deepseek-coder': 'DeepSeek Coder',
|
||||
'deepseek-r1': 'DeepSeek R1',
|
||||
'o4-mini': 'o4-mini',
|
||||
'o3': 'o3',
|
||||
'MiniMax-M2.7-highspeed': 'MiniMax M2.7 Highspeed',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue