Files
Tam Nhu Tran e8adcb8999 feat(bar): per-provider profile carousel with ccs/ccsx surface tags
Group native subscription rows by provider and render each provider as its own
horizontally paged carousel of profile cards (one profile per page, swipe
between a provider's profiles, page dots indicate count). Add a surface tag
chip (ccs/ccsx) and dim parked profiles. Switch the native-vs-pool split to the
is_subscription flag so pool codex accounts are not misclassified. Raise the
deployment target to macOS 14 for the native scroll paging APIs and bump the
Bar version to 1.9.0.
2026-06-23 22:56:48 -04:00

196 lines
7.4 KiB
Swift

import Foundation
/// One quota window for a native subscription row (Claude/Codex).
///
/// Carries BOTH `usedPercent` and `remainingPercent` verbatim from the backend
/// so the bar never re-derives one from the other (a single source of truth
/// avoids rounding drift between the collapsed glance value and the per-window
/// pace math). `windowMinutes` is the window length (300 = 5h, 10080 = 7d) used
/// by the burn-rate projection; nil when the backend could not determine it.
///
/// JSON: the parent field serializes to "quota_windows" (snake_case), but the
/// inner keys stay camelCase to match the live serializer.
public struct QuotaWindowDetail: Codable, Sendable, Equatable, Identifiable {
public let key: String
public let label: String
public let usedPercent: Double
public let remainingPercent: Double
public let resetAt: String?
public let windowMinutes: Int?
/// SwiftUI list identity: the window key is unique within a row.
public var id: String { key }
public init(
key: String,
label: String,
usedPercent: Double,
remainingPercent: Double,
resetAt: String? = nil,
windowMinutes: Int? = nil
) {
self.key = key
self.label = label
self.usedPercent = usedPercent
self.remainingPercent = remainingPercent
self.resetAt = resetAt
self.windowMinutes = windowMinutes
}
}
/// One account row in the menu-bar glance.
///
/// Mirrors the `GET /api/bar/summary` payload exactly (mixed snake_case and
/// camelCase keys, matching the CCS web-server response).
public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
public let accountId: String
public let provider: String
public let displayName: String?
public let tier: String?
public let paused: Bool
public let quotaPercentage: Double?
/// Tri-state quota availability: "ok" (provider has a quota API and the fetch
/// succeeded), "unsupported" (provider has no quota API at all, e.g. ghcp/kiro),
/// or "error" (should report quota but the fetch failed/timed out/needs reauth).
/// Drives "no quota" (unsupported) vs "quota ?" (error) so a bare "--" never
/// conflates the two.
public let quotaStatus: String
public let nextReset: String?
/// True when this is the provider's default account; drives the active/default badge.
public let isDefault: Bool
/// ISO timestamp this account was last used, null if never/unknown.
public let lastActivityAt: String?
public let todayCost: Double?
public let health: String
public let cached: Bool
public let fetchedAt: String?
public let needsReauth: Bool
/// Multi-profile: which CCS surface owns this profile ("ccs" = Claude via
/// `ccs auth`, "ccsx" = Codex via `ccsx auth`). nil on CLIProxy pool rows.
public let surface: String?
/// Multi-profile: the profile name (e.g. "work", "ck", "personal"). nil on
/// CLIProxy pool rows.
public let profile: String?
/// Explicit native-subscription flag. true on all native rows (Claude/Codex
/// own subscription), nil/false on CLIProxy pool rows. Replaces the brittle
/// `accountId == "claude-code"` heuristic.
public let isSubscription: Bool?
/// Native-only per-window quota breakdown (Claude: 5h/week/opus/sonnet,
/// Codex: 5h/week). nil for CLIProxy pool rows, which omit "quota_windows"
/// entirely — so legacy payloads decode unchanged (backward compatible).
public let quotaWindows: [QuotaWindowDetail]?
/// Native-only ISO mtime of the source session that supplied a STALE Codex
/// reading. Present only when the data is stale; nil otherwise. Drives the
/// "as of HH:mm (older session)" footnote without faking a "live" badge.
public let staleAsOf: String?
/// Stable identity for SwiftUI lists: provider-scoped account id.
public var id: String { "\(provider):\(accountId)" }
enum CodingKeys: String, CodingKey {
case accountId = "account_id"
case provider
case displayName
case tier
case paused
case quotaPercentage = "quota_percentage"
case quotaStatus
case nextReset = "next_reset"
case isDefault = "is_default"
case lastActivityAt = "last_activity_at"
case todayCost = "today_cost"
case health
case cached
case fetchedAt
case needsReauth
case surface
case profile
case isSubscription = "is_subscription"
case quotaWindows = "quota_windows"
case staleAsOf = "stale_as_of"
}
public init(
accountId: String,
provider: String,
displayName: String? = nil,
tier: String? = nil,
paused: Bool = false,
quotaPercentage: Double? = nil,
quotaStatus: String = "ok",
nextReset: String? = nil,
isDefault: Bool = false,
lastActivityAt: String? = nil,
todayCost: Double? = nil,
health: String = "ok",
cached: Bool = false,
fetchedAt: String? = nil,
needsReauth: Bool = false,
surface: String? = nil,
profile: String? = nil,
isSubscription: Bool? = nil,
quotaWindows: [QuotaWindowDetail]? = nil,
staleAsOf: String? = nil
) {
self.accountId = accountId
self.provider = provider
self.displayName = displayName
self.tier = tier
self.paused = paused
self.quotaPercentage = quotaPercentage
self.quotaStatus = quotaStatus
self.nextReset = nextReset
self.isDefault = isDefault
self.lastActivityAt = lastActivityAt
self.todayCost = todayCost
self.health = health
self.cached = cached
self.fetchedAt = fetchedAt
self.needsReauth = needsReauth
self.surface = surface
self.profile = profile
self.isSubscription = isSubscription
self.quotaWindows = quotaWindows
self.staleAsOf = staleAsOf
}
/// Resilient decode: the two native-only keys are decoded with
/// `decodeIfPresent` so a legacy payload (no "quota_windows"/"stale_as_of")
/// yields nil rather than a decode failure. All other keys keep synthesized
/// behavior.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
accountId = try c.decode(String.self, forKey: .accountId)
provider = try c.decode(String.self, forKey: .provider)
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
tier = try c.decodeIfPresent(String.self, forKey: .tier)
paused = try c.decode(Bool.self, forKey: .paused)
quotaPercentage = try c.decodeIfPresent(Double.self, forKey: .quotaPercentage)
quotaStatus = try c.decode(String.self, forKey: .quotaStatus)
nextReset = try c.decodeIfPresent(String.self, forKey: .nextReset)
isDefault = try c.decode(Bool.self, forKey: .isDefault)
lastActivityAt = try c.decodeIfPresent(String.self, forKey: .lastActivityAt)
todayCost = try c.decodeIfPresent(Double.self, forKey: .todayCost)
health = try c.decode(String.self, forKey: .health)
cached = try c.decode(Bool.self, forKey: .cached)
fetchedAt = try c.decodeIfPresent(String.self, forKey: .fetchedAt)
needsReauth = try c.decode(Bool.self, forKey: .needsReauth)
surface = try c.decodeIfPresent(String.self, forKey: .surface)
profile = try c.decodeIfPresent(String.self, forKey: .profile)
isSubscription = try c.decodeIfPresent(Bool.self, forKey: .isSubscription)
quotaWindows = try c.decodeIfPresent([QuotaWindowDetail].self, forKey: .quotaWindows)
staleAsOf = try c.decodeIfPresent(String.self, forKey: .staleAsOf)
}
}
extension BarSummaryRow {
/// Health rendered as an ASCII-safe dot for the dropdown.
public var healthDot: String {
switch health {
case "error": return "X"
case "warning": return "!"
default: return "OK"
}
}
}