mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-06 08:13:49 +00:00
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:
1 parent
ed86a089ba
commit
e8adcb8999
7 files changed
+284
-8
No files matched your search
@@ -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"]),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
1.8.1
|
||||
1.9.0
|
||||
Reference in new issue
Block a user