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.
This commit is contained in:
Tam Nhu Tran committed 2026-06-23 22:56:48 -04:00
1 parent ed86a089ba
commit e8adcb8999
7 files changed
+284 -8

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@ import PackageDescription
// the core is verified.
let package = Package(
name: "CCSBar",
platforms: [.macOS(.v13)],
platforms: [.macOS(.v14)],
products: [
.executable(name: "CCSBar", targets: ["CCSBarApp"]),
.executable(name: "ccs-bar-check", targets: ["CCSBarCheck"]),
+81 -2
View File
@@ -36,6 +36,11 @@ struct BarMenuView: View {
/// monotonically (ContentHeightKey.reduce takes the max), so the frame never
/// thrashes downward.
@State private var contentHeight: CGFloat = 0
/// Multi-profile carousel: which provider page is currently visible. Resets to
/// first provider on popover re-open (KISS — no UserDefaults persistence needed).
// Per-provider carousel position: provider -> selected profile row id. Each
// provider has its own profile carousel, so selection is tracked per provider.
@State private var selectedProfileByProvider: [String: String] = [:]
// MARK: - Screen cap
@@ -164,6 +169,9 @@ struct BarMenuView: View {
viewModel.onOpen()
// Disarm quit on every popover open so a stale armed state never persists.
quitArmed = false
// Reset each provider's carousel to its first profile on every open — KISS,
// no persistence needed.
selectedProfileByProvider = [:]
}
}
@@ -234,9 +242,67 @@ struct BarMenuView: View {
BarRowView(row: row, viewModel: viewModel)
}
} else {
// Per-provider profile carousels: group subscription rows by provider, and
// render each provider as its OWN horizontally-paged carousel of PROFILE
// cards — one profile visible at a time, swipe left/right between that
// provider's profiles, page dots indicate count. Provider sections stack
// vertically. A provider with a single profile shows just its card (no
// carousel, no dots).
let groups = Dictionary(
grouping: orderedSubscriptions(parts.subscriptions), by: { $0.provider })
let providers = groups.keys.sorted() // stable: "claude-code" < "codex"
let multiProvider = providers.count > 1
subscriptionsHeader(parts.subscriptions)
ForEach(orderedSubscriptions(parts.subscriptions)) { row in
BarSubscriptionCard(row: row, onRefresh: { viewModel.forceRefresh() })
ForEach(providers, id: \.self) { prov in
let rows = groups[prov] ?? []
VStack(alignment: .leading, spacing: 4) {
// Provider caption to delineate sections when more than one provider.
if multiProvider {
Text(BarFormatting.providerLabel(prov))
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.secondary)
}
if rows.count <= 1 {
// Single profile — render the card directly, no carousel.
if let row = rows.first {
BarSubscriptionCard(
row: row, isParked: row.paused,
onRefresh: { viewModel.forceRefresh() })
}
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
ForEach(rows) { row in
BarSubscriptionCard(
row: row, isParked: row.paused,
onRefresh: { viewModel.forceRefresh() })
// 340 leaves 10px side padding inside the 360-wide popover.
.frame(width: 340, alignment: .leading)
.padding(.horizontal, 2)
.id(row.id)
}
}
// scrollTargetLayout on the HStack (the paging container) snaps to
// each profile card on .paging behavior.
.scrollTargetLayout()
}
.scrollTargetBehavior(.paging)
.scrollPosition(id: Binding(
get: { selectedProfileByProvider[prov] ?? rows.first?.id },
set: { selectedProfileByProvider[prov] = $0 ?? rows.first?.id }))
.frame(height: carouselHeight(rows))
// Page dots — one per profile in this provider's carousel.
HStack(spacing: 6) {
ForEach(rows) { row in
let isCurrent = (selectedProfileByProvider[prov] ?? rows.first?.id) == row.id
Circle()
.fill(isCurrent ? theme.subscription : Color.secondary.opacity(0.3))
.frame(width: 6, height: 6)
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
}
}
}
}
@@ -295,6 +361,19 @@ struct BarMenuView: View {
}
}
/// Estimates the height of a single profile carousel so its frame does not
/// collapse. Only ONE card is visible at a time, so the height is the tallest
/// single card in the carousel: a title row plus one bar per quota window, or a
/// compact parked/empty card when there are no windows.
private func carouselHeight(_ rows: [BarSummaryRow]) -> CGFloat {
let maxWindows = rows.map { $0.quotaWindows?.count ?? 0 }.max() ?? 0
if maxWindows == 0 {
return 80 // parked / reauth card: title row + status line only.
}
// ~34pt title row + ~28pt per window bar + vertical padding.
return 44 + CGFloat(maxWindows) * 30
}
private var header: some View {
HStack(spacing: 8) {
Image(nsImage: MenuBarIcon.headerImage())
@@ -13,6 +13,9 @@ import CCSBarCore
struct BarSubscriptionCard: View {
@Environment(\.barTheme) private var theme
let row: BarSummaryRow
/// When true this profile is parked (not the active/default profile for the
/// surface). The card is dimmed to 50% opacity to signal it is not live.
var isParked: Bool = false
/// Injected clock — defaults to live Date() in production, pinned in previews
/// and tests so countdown math is deterministic.
var now: Date = Date()
@@ -44,20 +47,28 @@ struct BarSubscriptionCard: View {
.background(
theme.cardSurface,
in: RoundedRectangle(cornerRadius: 9))
// Dim parked (non-active) profiles so the active profile clearly dominates.
.opacity(isParked ? 0.5 : 1.0)
}
// MARK: Title row
/// Health dot + product name + reauth chip + tier chip. No pause toggle —
/// subscriptions are not routable pool accounts.
/// Health dot + profile name (or provider label for legacy rows) + surface chip
/// + reauth chip + tier chip. No pause toggle — subscriptions are not routable
/// pool accounts.
private var titleRow: some View {
HStack(spacing: 8) {
Circle()
.fill(healthColor)
.frame(width: 8, height: 8)
Text(BarFormatting.providerLabel(row.provider))
// Multi-profile: show profile name when available; fall back to provider label.
Text(row.profile ?? BarFormatting.providerLabel(row.provider))
.font(.system(.body, design: .default).weight(.semibold))
.lineLimit(1)
// Surface tag chip ("ccs · work", "ccsx · personal") for multi-profile rows.
if let tag = BarFormatting.surfaceProfileLabel(row) {
Chip(tag, tint: theme.subscription.opacity(isParked ? 0.5 : 1))
}
if row.needsReauth {
Chip("reauth", tint: theme.bandRed)
}
+150
View File
@@ -1686,6 +1686,156 @@ do {
check(!BarUpdateChecker.isNewer("1.7", than: "1.7.0"), "isNewer: fewer than 3 parts rejected")
}
// MARK: Multi-profile fields decode (GH-1595)
//
// Three new optional wire keys: surface, profile, is_subscription.
// Native rows carry all three; CLIProxy pool rows omit them — legacy decoders
// must not fail and must yield nil for the absent fields.
let multiProfileJSON = """
[
{
"account_id": "ccs:work",
"provider": "claude-code",
"surface": "ccs",
"profile": "work",
"is_subscription": true,
"displayName": "work",
"tier": "max",
"paused": false,
"quota_percentage": 62,
"quotaStatus": "ok",
"next_reset": "2026-06-24T01:00:00.000Z",
"is_default": true,
"last_activity_at": null,
"today_cost": null,
"health": "ok",
"cached": false,
"fetchedAt": "2026-06-23T20:40:00.000Z",
"needsReauth": false
},
{
"account_id": "ccsx:ck",
"provider": "codex",
"surface": "ccsx",
"profile": "ck",
"is_subscription": true,
"displayName": "ck",
"tier": "pro",
"paused": true,
"quota_percentage": 80,
"quotaStatus": "ok",
"next_reset": null,
"is_default": false,
"last_activity_at": null,
"today_cost": null,
"health": "ok",
"cached": true,
"fetchedAt": "2026-06-23T16:19:00.000Z",
"needsReauth": false,
"stale_as_of": "2026-06-23T16:19:00.000Z"
},
{
"account_id": "alice@example.com",
"provider": "agy",
"displayName": "Alice (Ultra)",
"tier": "ultra",
"paused": false,
"quota_percentage": 70,
"quotaStatus": "ok",
"next_reset": null,
"is_default": false,
"last_activity_at": null,
"today_cost": null,
"health": "ok",
"cached": true,
"fetchedAt": "2026-06-23T20:00:00.000Z",
"needsReauth": false
}
]
"""
do {
let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(multiProfileJSON.utf8))
check(rows.count == 3, "mp: decodes 3 rows (2 native + 1 legacy CLIProxy)")
// (MP1) Native Claude active row
let claudeRow = rows[0]
check(claudeRow.accountId == "ccs:work", "mp: native Claude account_id = 'ccs:work'")
check(claudeRow.surface == "ccs", "mp: native Claude surface = 'ccs'")
check(claudeRow.profile == "work", "mp: native Claude profile = 'work'")
check(claudeRow.isSubscription == true, "mp: native Claude is_subscription = true")
check(claudeRow.paused == false, "mp: active Claude profile is not paused")
// (MP2) Parked Codex row
let codexRow = rows[1]
check(codexRow.accountId == "ccsx:ck", "mp: parked Codex account_id = 'ccsx:ck'")
check(codexRow.surface == "ccsx", "mp: parked Codex surface = 'ccsx'")
check(codexRow.profile == "ck", "mp: parked Codex profile = 'ck'")
check(codexRow.isSubscription == true, "mp: parked Codex is_subscription = true")
check(codexRow.paused == true, "mp: parked Codex row has paused = true")
// (MP3) Legacy CLIProxy row — new fields decode to nil without failure
let legacyRow = rows[2]
check(legacyRow.surface == nil, "mp: CLIProxy row surface decodes to nil (backward compat)")
check(legacyRow.profile == nil, "mp: CLIProxy row profile decodes to nil (backward compat)")
check(legacyRow.isSubscription == nil, "mp: CLIProxy row is_subscription decodes to nil")
// (MP4) isNativeSubscription uses is_subscription flag when present
check(
BarFormatting.isNativeSubscription(claudeRow),
"mp: isNativeSubscription true when is_subscription=true (new flag path)")
check(
BarFormatting.isNativeSubscription(codexRow),
"mp: isNativeSubscription true for parked Codex row (is_subscription=true)")
check(
!BarFormatting.isNativeSubscription(legacyRow),
"mp: isNativeSubscription false for CLIProxy pool row (no is_subscription)")
// (MP5) Legacy rows without is_subscription use fallback heuristic
let legacyClaudeRow = BarSummaryRow(accountId: "claude-code", provider: "claude-code")
check(
BarFormatting.isNativeSubscription(legacyClaudeRow),
"mp: legacy claude-code row uses heuristic fallback -> is native")
let legacyCodexRow = BarSummaryRow(accountId: "codex", provider: "codex")
check(
BarFormatting.isNativeSubscription(legacyCodexRow),
"mp: legacy codex row uses heuristic fallback -> is native")
let poolCodexRow = BarSummaryRow(accountId: "pool-codex-oauth-1", provider: "codex")
check(
!BarFormatting.isNativeSubscription(poolCodexRow),
"mp: CLIProxy codex pool row with no is_subscription -> not native (heuristic)")
// (MP6) partitionSubscriptions correctly splits with new flag
let parts = BarFormatting.partitionSubscriptions(rows)
check(parts.subscriptions.count == 2, "mp: partition yields 2 native subscriptions")
check(parts.pool.count == 1, "mp: partition yields 1 CLIProxy pool row")
check(
parts.subscriptions.map { $0.accountId } == ["ccs:work", "ccsx:ck"],
"mp: subscriptions keep backend order")
// (MP7) surfaceProfileLabel helper
check(
BarFormatting.surfaceProfileLabel(claudeRow) == "ccs · work",
"mp: surfaceProfileLabel for ccs:work -> 'ccs · work'")
check(
BarFormatting.surfaceProfileLabel(codexRow) == "ccsx · ck",
"mp: surfaceProfileLabel for ccsx:ck -> 'ccsx · ck'")
check(
BarFormatting.surfaceProfileLabel(legacyRow) == nil,
"mp: surfaceProfileLabel for CLIProxy row (no profile) -> nil")
// (MP8) surfaceProfileLabel falls back to provider when surface is absent
let noSurfaceRow = BarSummaryRow(
accountId: "ccsx:personal", provider: "codex",
surface: nil, profile: "personal", isSubscription: true)
check(
BarFormatting.surfaceProfileLabel(noSurfaceRow) == "codex · personal",
"mp: surfaceProfileLabel falls back to provider when surface is nil")
} catch {
check(false, "mp: multi-profile JSON decode failed: \(error)")
}
// cleanup
try? FileManager.default.removeItem(atPath: tmp)
@@ -165,11 +165,25 @@ public enum BarFormatting {
/// Code or Codex plan) rather than a CLIProxy-managed OAuth pool account. Drives
/// the "Subscriptions" grouping + badge so a user reads "this is MY plan quota",
/// not one of the rotating pool credentials.
///
/// Prefers the explicit `is_subscription` flag from the server (multi-profile
/// feature). Falls back to the legacy heuristic (`accountId == provider`) so
/// old single-profile payloads still work without `is_subscription`.
public static func isNativeSubscription(_ row: BarSummaryRow) -> Bool {
(row.provider == "claude-code" && row.accountId == "claude-code")
if let s = row.isSubscription { return s }
// Legacy fallback for old payloads that omit is_subscription.
return (row.provider == "claude-code" && row.accountId == "claude-code")
|| (row.provider == "codex" && row.accountId == "codex")
}
/// Surface + profile chip label for multi-profile cards, e.g. "ccs · work" or
/// "ccsx · personal". Returns nil for CLIProxy pool rows that have no profile.
public static func surfaceProfileLabel(_ row: BarSummaryRow) -> String? {
guard let p = row.profile else { return nil }
let s = row.surface ?? row.provider
return "\(s) · \(p)"
}
/// Friendly product label for a provider key. Native subscription keys read as
/// products ("Claude Code", "Codex"); any other provider passes through verbatim
/// (so "agy"/"ghcp"/"kiro" keep their established short chip text).
@@ -65,6 +65,16 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
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).
@@ -93,6 +103,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
case cached
case fetchedAt
case needsReauth
case surface
case profile
case isSubscription = "is_subscription"
case quotaWindows = "quota_windows"
case staleAsOf = "stale_as_of"
}
@@ -113,6 +126,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
cached: Bool = false,
fetchedAt: String? = nil,
needsReauth: Bool = false,
surface: String? = nil,
profile: String? = nil,
isSubscription: Bool? = nil,
quotaWindows: [QuotaWindowDetail]? = nil,
staleAsOf: String? = nil
) {
@@ -131,6 +147,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
self.cached = cached
self.fetchedAt = fetchedAt
self.needsReauth = needsReauth
self.surface = surface
self.profile = profile
self.isSubscription = isSubscription
self.quotaWindows = quotaWindows
self.staleAsOf = staleAsOf
}
@@ -156,6 +175,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
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)
}
+1 -1
View File
@@ -1 +1 @@
1.8.1
1.9.0