import SwiftUI import AppKit import CCSBarCore // MARK: - Content height preference key /// Preference key used to bubble the measured content VStack height up to the /// ScrollView parent. The reduction takes the MAX only to combine multiple /// simultaneous reporters into one value (in practice a single background /// GeometryReader reports here, so the max is just that reader's height). The /// consumer (`onPreferenceChange`) then tracks the height in both directions so /// the popover frame can shrink back when content collapses. This is safe from /// the classic GeometryReader feedback loop because the reader measures the /// intrinsic content VStack, whose height does not depend on the consumer frame. private struct ContentHeightKey: PreferenceKey { static let defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } } /// Dropdown content for the menu bar: a CCS-branded header, usage analytics, /// per-account rows + actions, an offline state when CCS isn't running, and /// footer controls. struct BarMenuView: View { @ObservedObject var viewModel: BarViewModel /// Resolved theme injected by ThemedRoot — used to tint the armed Quit control /// with the themed red ramp so it matches the dropdown on both plates. @Environment(\.barTheme) private var theme /// Two-step inline quit confirm. First footer-Quit click arms it (icon swaps /// hollow->filled, tints red); second click terminates. Reset on every popover /// open via .onAppear so a stale armed state never carries across sessions — /// no modal, no .confirmationDialog (those steal focus and dismiss the popover, /// the exact fragility of BUG 1). @State private var quitArmed = false /// Measured height of the scroll content VStack, updated via preference key. /// Starts at 0; the preference fires on the first layout pass and grows /// 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] = [:] /// Live horizontal drag translation per provider while a swipe is in progress. /// Reset to 0 (and committed to a page change) on drag release. Keyed by /// provider so each carousel tracks its own in-flight swipe independently. @State private var dragByProvider: [String: CGFloat] = [:] /// Whether the Alerts section is expanded to show every alert. Collapsed by /// default: only the most-severe few render, with a "+N more" toggle, so a /// burst of conditions never buries the cockpit under a wall of rows. @State private var alertsExpanded = false // MARK: - Screen cap /// Maximum height the scroll area may occupy: screen visible height minus space /// for the macOS menu bar, the CCS Bar header, footer, and a small safety margin. /// Clamped to a floor of 240 so the popover is always usable even on tiny displays. private var screenCap: CGFloat { let visibleHeight = NSScreen.main?.visibleFrame.height ?? 800 return max(240, visibleHeight - 120) } /// The resolved scroll-area height: the smaller of measured content and the cap. /// Content shorter than the cap renders FULLY with no scroll bar. /// Content taller than the cap scrolls. private var scrollAreaHeight: CGFloat { let cap = screenCap // Before the first measurement, use a modest default so the popover is never // sized to the full screen (which macOS then centers). Once the content is // measured, use its height, capped. guard contentHeight > 0 else { return min(cap, 560) } return min(contentHeight, cap) } var body: some View { VStack(alignment: .leading, spacing: 0) { header Divider() if viewModel.offline || viewModel.isStarting { offlineState.padding(14) } else { // The scroll indicator is suppressed (.never, not just .hidden) AND the // enclosing NSScrollView's scroller is hard-disabled via ScrollerHider: // inside a MenuBarExtra popover the SwiftUI preference alone is sometimes // ignored and a scroller track steals width + misaligns content. With the // reorder + collapsed spend strip the important rows fit without scrolling // for the common 1-4 subscription setup; the scroll only engages for // genuine pool/model overflow. ScrollView { VStack(alignment: .leading, spacing: 12) { // (1) UPDATE BANNER — shown only when a newer version is available. // Placed at the very top so it is seen without scrolling. if viewModel.updateAvailable { updateBanner } // (2) ALERTS — urgent quota crossings surface above accounts. // Spend-cap alerts are opt-in OFF by default, so by default only // quota/reauth/cooldown conditions appear here. Deduped, severity- // ranked, compact, and collapsed past a few — see alertsSection. if !viewModel.activeAlerts.isEmpty { alertsSection } // (3) SUBSCRIPTIONS — the dominant section, opens here. accountsSection // (4) SPEND — demoted to a thin informational strip below the cockpit. // spendChartStyle and spendPeriod are threaded from the viewModel and // toggled/selected inline from the Spend header so changes are live. if let analytics = viewModel.analytics { Divider() BarAnalyticsView( analytics: analytics, section: .spend, spendChartStyle: viewModel.spendChartStyle, onToggleSpendStyle: { viewModel.spendChartStyle = viewModel.spendChartStyle == .bars ? .line : .bars }, spendPeriod: viewModel.spendPeriod, onSelectPeriod: { viewModel.spendPeriod = $0 }) } // (5) POOL ACCOUNTS — compact generic rows, subordinate. poolSection // (6) BY-SURFACE / TOP MODELS — tightened detail, below the pool. if let analytics = viewModel.analytics, BarAnalyticsView(analytics: analytics, section: .breakdown).hasBreakdown { BarAnalyticsView(analytics: analytics, section: .breakdown) } // Zero-size AppKit bridge that disables the popover's NSScrollView // scroller at runtime (belt-and-suspenders with .scrollIndicators). ScrollerHider().frame(width: 0, height: 0) } .padding(14) // Measure the content height via a background GeometryReader that // reports into ContentHeightKey. Using .background keeps the reader // out of the layout pass that sizes the VStack itself, avoiding the // classic GeometryReader-inside-ScrollView feedback loop. .background( GeometryReader { geo in Color.clear .preference(key: ContentHeightKey.self, value: geo.size.height) } ) } .scrollIndicators(.never) // Deterministic height: exactly the content size up to the screen cap. // Content shorter than the cap renders FULLY (no empty space, no clip). // Content taller scrolls. contentHeight starts at 0 so we show screenCap // until the first preference fires. .frame(height: scrollAreaHeight, alignment: .top) .onPreferenceChange(ContentHeightKey.self) { measured in // Track the measured content height in BOTH directions so the popover // collapses back when content shrinks (e.g. alert / reauth rows clear) // instead of staying stuck at a past peak and rendering blank space // below. The background GeometryReader measures the intrinsic content // VStack, whose height does not depend on this frame, so updating in // either direction cannot feed the classic GeometryReader layout loop. // The 0.5pt deadband avoids churn on sub-pixel remeasures. if abs(measured - contentHeight) > 0.5 { contentHeight = measured } } } Divider() footer } // 360 is narrower than the old 380, keeping the popover compact while still // fitting the bar-list fixed column widths (label 32 + bar 110 + pct 32 + chip 48). .frame(width: 360) .onAppear { 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. Clear any in-flight drag and re-collapse alerts. selectedProfileByProvider = [:] dragByProvider = [:] alertsExpanded = false } } /// "Update available" banner. Shown when `viewModel.updateAvailable` is true. /// Styled to match the existing CompactAlertRow / ErrorBanner patterns (tinted /// background card, section label, borderless button). @ViewBuilder private var updateBanner: some View { VStack(alignment: .leading, spacing: 8) { SectionLabel("Update") HStack(spacing: 10) { Image(systemName: "arrow.down.circle.fill") .foregroundStyle(theme.accent) .font(.title3) VStack(alignment: .leading, spacing: 2) { Text("Update available") .font(.system(.caption, design: .default).weight(.semibold)) if let v = viewModel.latestVersion { Text("CCS Bar \(v)") .font(.caption2) .foregroundStyle(.secondary) } } Spacer(minLength: 4) if viewModel.isInstallingUpdate { HStack(spacing: 4) { ProgressView().controlSize(.mini) Text("Updating...") .font(.caption2) .foregroundStyle(.secondary) } } else { Button("Update Now") { viewModel.installUpdate() } .buttonStyle(.borderless) .font(.caption.weight(.medium)) .foregroundStyle(theme.accent) } } .padding(.vertical, 6) .padding(.horizontal, 10) .frame(maxWidth: .infinity, alignment: .leading) .background(theme.accent.opacity(0.10), in: RoundedRectangle(cornerRadius: 8)) } } /// The cockpit. Native subscriptions (Claude Code / Codex) render as detailed /// `BarSubscriptionCard`s at the very top, ordered tightest-binding-first /// (closest to empty on top) so the window the user is about to run out of /// leads. CLIProxy pool accounts keep the compact generic `BarRowView` below, /// subordinate. The two-section split is suppressed when only one kind is /// present, preserving the single "Accounts" header for a CLIProxy-only setup. @ViewBuilder private var accountsSection: some View { let parts = BarFormatting.partitionSubscriptions(viewModel.rows) VStack(alignment: .leading, spacing: 6) { if let error = viewModel.lastError { ErrorBanner(message: error) } if viewModel.rows.isEmpty { SectionLabel("Accounts") Text("No accounts configured") .font(.caption) .foregroundStyle(.secondary) } else if parts.subscriptions.isEmpty { // CLIProxy-only setup: keep the single established header + generic rows. SectionLabel("Accounts") ForEach(parts.pool) { row in 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(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 { profileCarousel(prov: prov, rows: rows) // Page controls — clickable prev/next arrows + dots so the carousel // is also navigable by MOUSE click, not only by drag/swipe. carouselControls(prov: prov, rows: rows) } } } } } } /// CLIProxy pool accounts as compact generic rows — subordinate, rendered below /// the spend strip. Suppressed entirely when there are no pool accounts, or /// when there are no subscriptions (the CLIProxy-only path renders pool rows /// under the single "Accounts" header in `accountsSection` instead). @ViewBuilder private var poolSection: some View { let parts = BarFormatting.partitionSubscriptions(viewModel.rows) if !parts.subscriptions.isEmpty && !parts.pool.isEmpty { VStack(alignment: .leading, spacing: 8) { SectionLabel("Pool accounts") ForEach(parts.pool) { row in BarRowView(row: row, viewModel: viewModel) } } } } /// "SUBSCRIPTIONS" header, with a right-aligned cross-tool headroom hint /// ("most room: NN%") when there are >=2 subscriptions with quota data. /// Falls back to the bare label otherwise. @ViewBuilder private func subscriptionsHeader(_ subs: [BarSummaryRow]) -> some View { HStack(alignment: .firstTextBaseline) { SectionLabel("Subscriptions") Spacer() if let leader = BarQuotaGauge.headroomLeader(subs) { Text("most room: \(leader.label) \(Int(leader.remainingPercent.rounded()))%") .font(.system(size: 10, weight: .medium)) .foregroundStyle(.secondary) .lineLimit(1) } } } /// Order subscription cards so the default/base account leads its provider /// carousel (it is the account the user runs by default), then by tightest /// binding window ascending (closest to empty next). Rows with no binding /// window (error/reauth) sink to the bottom so actionable quota leads. private func orderedSubscriptions(_ subs: [BarSummaryRow]) -> [BarSummaryRow] { subs.sorted { a, b in // Default account first within its provider group. if a.isDefault != b.isDefault { return a.isDefault } let ra = BarQuotaGauge.selectBindingWindow(a.quotaWindows ?? [])?.remainingPercent let rb = BarQuotaGauge.selectBindingWindow(b.quotaWindows ?? [])?.remainingPercent switch (ra, rb) { case let (.some(x), .some(y)): if x != y { return x < y } return (a.displayName ?? a.provider) < (b.displayName ?? b.provider) case (.some, .none): return true // a has quota, b doesn't → a first case (.none, .some): return false case (.none, .none): return (a.displayName ?? a.provider) < (b.displayName ?? b.provider) } } } /// Swipeable profile pager: one full-width card visible at a time, an HStack of /// the provider's cards offset to the current page. Two complementary inputs /// move between pages so no device is left out: /// - a `DragGesture` for a mouse/trackpad press-drag (the previous ScrollView /// never paged on a plain mouse click-drag), committing on release past a /// 20% threshold, with the ends rubber-banding so an over-drag resists; /// - a `CarouselScrollPager` overlay that turns a non-clicking horizontal /// trackpad / Magic Mouse swipe (delivered as scroll-wheel events, which a /// DragGesture does not see) into a page step. /// No ScrollView, so there is no paging drift and the card is always centered. @ViewBuilder private func profileCarousel(prov: String, rows: [BarSummaryRow]) -> some View { let currentId = selectedProfileByProvider[prov] ?? rows.first?.id let curIdx = rows.firstIndex(where: { $0.id == currentId }) ?? 0 GeometryReader { geo in let pageWidth = geo.size.width HStack(spacing: 0) { ForEach(rows) { row in BarSubscriptionCard( row: row, isParked: row.paused, onRefresh: { viewModel.forceRefresh() }) .frame(width: pageWidth) } } .offset(x: -CGFloat(curIdx) * pageWidth + (dragByProvider[prov] ?? 0)) .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 8) .onChanged { value in // Rubber-band at the ends: an over-drag past the first/last card moves // at a third the rate so it springs back instead of revealing a gap. let raw = value.translation.width let atStart = curIdx == 0 && raw > 0 let atEnd = curIdx == rows.count - 1 && raw < 0 dragByProvider[prov] = (atStart || atEnd) ? raw / 3 : raw } .onEnded { value in // Commit a page change when the swipe passes 20% of the page width; // otherwise snap back to the current card. let threshold = pageWidth * 0.2 var newIdx = curIdx if value.translation.width <= -threshold { newIdx = min(curIdx + 1, rows.count - 1) } else if value.translation.width >= threshold { newIdx = max(curIdx - 1, 0) } withAnimation(.easeOut(duration: 0.2)) { dragByProvider[prov] = 0 selectedProfileByProvider[prov] = rows[newIdx].id } } ) // Trackpad / Magic Mouse horizontal swipe (scroll-wheel events) → page step. // Transparent to clicks and to the DragGesture; only observes scroll. .overlay( CarouselScrollPager { step in page(prov: prov, by: step, rows: rows) } ) } .frame(height: carouselHeight(rows)) .clipped() // hide the neighbouring cards that sit outside the page viewport } /// Step the given provider's carousel by ±1 page, clamped to the ends. Used by /// the horizontal scroll-swipe overlay; the arrows/dots call `selectPage` /// directly. private func page(prov: String, by step: Int, rows: [BarSummaryRow]) { let currentId = selectedProfileByProvider[prov] ?? rows.first?.id let curIdx = rows.firstIndex(where: { $0.id == currentId }) ?? 0 let newIdx = min(max(curIdx + step, 0), rows.count - 1) if newIdx != curIdx { selectPage(prov, rows[newIdx].id) } } /// Prev/next arrows + clickable dots for a provider's profile carousel, so it /// is navigable by MOUSE click, not only by drag/swipe. Selecting a page sets /// `selectedProfileByProvider`, which animates the pager offset to that card. @ViewBuilder private func carouselControls(prov: String, rows: [BarSummaryRow]) -> some View { let currentId = selectedProfileByProvider[prov] ?? rows.first?.id let curIdx = rows.firstIndex(where: { $0.id == currentId }) ?? 0 HStack(spacing: 7) { pageArrow(systemName: "chevron.left", enabled: curIdx > 0) { if curIdx > 0 { selectPage(prov, rows[curIdx - 1].id) } } ForEach(rows) { row in Circle() .fill(currentId == row.id ? theme.subscription : Color.secondary.opacity(0.3)) .frame(width: 6, height: 6) .padding(4) // larger mouse hit target than the 6pt dot .contentShape(Rectangle()) .onTapGesture { selectPage(prov, row.id) } } pageArrow(systemName: "chevron.right", enabled: curIdx < rows.count - 1) { if curIdx < rows.count - 1 { selectPage(prov, rows[curIdx + 1].id) } } } .frame(maxWidth: .infinity, alignment: .center) } /// Animate the carousel to the given profile card (mouse click or dot tap). private func selectPage(_ prov: String, _ id: String) { withAnimation(.easeInOut(duration: 0.2)) { selectedProfileByProvider[prov] = id } } @ViewBuilder private func pageArrow( systemName: String, enabled: Bool, action: @escaping () -> Void ) -> some View { Button(action: action) { Image(systemName: systemName) .font(.system(size: 9, weight: .bold)) .frame(width: 16, height: 16) .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(!enabled) .opacity(enabled ? 0.7 : 0.25) } /// Height of the tallest single card in a carousel — only one card is visible /// at a time, so the paged frame is sized to fit it WITHOUT reserving blank /// space beneath. Card = vertical padding (16) + title row (~22) + one bar per /// quota window (~20) + an optional stale footnote (~16); a parked/reauth card /// is just the title row plus a one-line status. private func carouselHeight(_ rows: [BarSummaryRow]) -> CGFloat { let maxWindows = rows.map { $0.quotaWindows?.count ?? 0 }.max() ?? 0 if maxWindows == 0 { return 60 } let hasFootnote = rows.contains { $0.staleAsOf != nil } return 40 + CGFloat(maxWindows) * 20 + (hasFootnote ? 16 : 0) } // MARK: Alerts /// One displayed alert after de-duplication: the representative notification /// plus how many identical conditions it stands for (e.g. the same "ck needs /// re-authentication" firing on two surfaces collapses to one row with ×2). private struct GroupedAlert: Identifiable { let id: String let alert: BarNotification let count: Int } /// De-duplicate alerts by their visible text and rank by severity so the most /// actionable condition leads. Two alerts that render identically (same title + /// body) collapse into one group with a count, killing the "ck reauth" / /// "ck reauth" / "ck paused" / "ck paused" repetition seen with multi-surface /// profiles. private func groupedAlerts(_ alerts: [BarNotification]) -> [GroupedAlert] { var order: [String] = [] var byKey: [String: (alert: BarNotification, count: Int)] = [:] for a in alerts { let key = a.title + "\u{1F}" + a.body if let hit = byKey[key] { byKey[key] = (hit.alert, hit.count + 1) } else { byKey[key] = (a, 1) order.append(key) } } return order .map { GroupedAlert(id: $0, alert: byKey[$0]!.alert, count: byKey[$0]!.count) } .sorted { alertSeverityRank($0.alert.kind) < alertSeverityRank($1.alert.kind) } } /// Severity order for alert ranking: reauth (account unusable) first, spend /// caps next, then quota, then the soft paused/cooldown note. private func alertSeverityRank(_ kind: BarAlertKind) -> Int { switch kind { case .reauthNeeded: return 0 case .dailySpendAbove, .monthSpendAbove: return 1 case .quotaRemainingBelow: return 2 case .accountCooldownOrPaused: return 3 } } /// Calm, compact alerts: a labelled header with a total count, then a few /// single-line rows (most-severe first). Collapsed past `collapsedCap` behind a /// "+N more" toggle so a burst of conditions never floods the popover. Replaces /// the previous stack of tall two-line cards. @ViewBuilder private var alertsSection: some View { let groups = groupedAlerts(viewModel.activeAlerts) let collapsedCap = 3 let overflow = groups.count - collapsedCap let visible = alertsExpanded ? groups : Array(groups.prefix(collapsedCap)) VStack(alignment: .leading, spacing: 5) { HStack(spacing: 6) { SectionLabel("Alerts") Text("\(groups.count)") .font(.system(size: 10, weight: .semibold)) .padding(.horizontal, 5) .padding(.vertical, 1) .background(Color.secondary.opacity(0.18), in: Capsule()) .foregroundStyle(.secondary) Spacer(minLength: 0) } ForEach(visible) { g in CompactAlertRow(alert: g.alert, count: g.count) } if overflow > 0 { Button { withAnimation(.easeInOut(duration: 0.15)) { alertsExpanded.toggle() } } label: { HStack(spacing: 4) { Image(systemName: alertsExpanded ? "chevron.up" : "chevron.down") .font(.system(size: 9, weight: .bold)) Text(alertsExpanded ? "Show less" : "\(overflow) more") .font(.caption2) } .foregroundStyle(.secondary) .padding(.vertical, 2) .contentShape(Rectangle()) } .buttonStyle(.plain) } } } private var header: some View { HStack(spacing: 8) { Image(nsImage: MenuBarIcon.headerImage()) VStack(alignment: .leading, spacing: 0) { Text("CCS").font(.headline) Text("usage & accounts").font(.caption2).foregroundStyle(.secondary) } Spacer() if viewModel.isRefreshing { ProgressView().controlSize(.small) } if let v = BarVersionDisplay.string() { Text(v) .font(.caption2) .foregroundStyle(.secondary) } } .padding(.horizontal, 14) .padding(.vertical, 10) } /// Offline / starting state view. Two sub-states: /// /// **Starting** (`isStarting == true`): server launch is in progress. Shows a /// spinner row so the user knows something is happening. No action needed. /// /// **Truly offline** (`offline == true`, `isStarting == false`): server is not /// running and no launch is in progress. Shows a primary "Start CCS" button /// that triggers the launcher + poll sequence, a secondary "Retry" that /// re-probes without launching, and a guidance caption. /// /// All controls remain inside the popover — NO sheets, modals, or /// .confirmationDialog (those steal focus and auto-dismiss the MenuBarExtra, /// the known constraint noted throughout BarMenuView). @ViewBuilder private var offlineState: some View { if viewModel.isStarting { // Starting state: spinner + label. Primary action is implicit (wait). HStack(spacing: 8) { ProgressView() .controlSize(.small) Text("Starting CCS…") .font(.body) .foregroundStyle(.secondary) } } else { // Truly offline state: actionable controls. VStack(alignment: .leading, spacing: 8) { Label("CCS is not running", systemImage: "bolt.slash.fill") .font(.body) Text("Start CCS, then the menu will connect automatically.") .font(.caption) .foregroundStyle(.secondary) HStack(spacing: 8) { // Primary: launches the server (reads launch.json or falls back to ccs on PATH). Button("Start CCS") { viewModel.startCCS() } .buttonStyle(.borderedProminent) .controlSize(.small) // Secondary: re-probe only (no launch attempt). Button("Retry") { viewModel.onOpen() } .controlSize(.small) } } } } private var footer: some View { HStack(spacing: 12) { Button { openDashboard() } label: { Label("Dashboard", systemImage: "chart.bar.xaxis") } Button { viewModel.toggleIconStyle() } label: { Label( "Icon", systemImage: viewModel.iconStyle == .color ? "paintpalette" : "circle.lefthalf.filled" ) } .help("Toggle the menu-bar icon between color and monochrome (does not change the bar theme)") Button { // Open Settings as a standalone AppKit NSWindow (NOT a .sheet on this // popover). A sheet hosted in a .window-style MenuBarExtra popover pulls // focus off the popover and auto-dismisses the whole bar (BUG 1). The // window opens beside the popover and leaves it untouched. SettingsWindowController.shared.show(viewModel: viewModel) } label: { Label("Settings", systemImage: "gearshape") } .help("Settings — appearance/theme, menu-bar glance, and alerts") Spacer() Button { viewModel.forceRefresh() } label: { Image(systemName: "arrow.clockwise") } .help("Refresh") // Quit confirms via a two-step INLINE arm/confirm — no modal, no sheet, no // .confirmationDialog. Those all steal focus and auto-dismiss the popover // (the exact fragility of BUG 1). A stray single click can no longer kill // the app: the first click only arms; the popover stays open and responsive. quitButton } .buttonStyle(.borderless) .font(.caption) .padding(.horizontal, 14) .padding(.vertical, 11) } /// Two visual states in one footer slot. Disarmed: hollow power icon that arms /// on click. Armed: filled power icon tinted themed red that terminates on /// click. Reopening the popover disarms it (.onAppear on the root VStack). @ViewBuilder private var quitButton: some View { if !quitArmed { Button { quitArmed = true } label: { Image(systemName: "power") } .help("Quit CCS Bar (click again to confirm)") } else { Button { NSApplication.shared.terminate(nil) } label: { Image(systemName: "power.circle.fill") } .help("Click to confirm quit") .foregroundStyle(theme.bandRed) } } private func openDashboard() { // Open the dashboard if the server is up; otherwise start it via `ccs config`. Task { await DashboardLauncher.openOrStart() } } } /// One account row — the strongest section of the glance. /// /// Top line: health dot, name, default/paused/reauth badges. Subline: provider + /// tier chips, the honest tri-state quota label (NN% / "no quota" / "quota ?"), /// and a per-account "Last active " caption. Trailing: today's cost (or a /// muted "no data" when unknown vs a real "$0.00"), a visible pause/resume /// toggle, and the overflow menu (set-default / solo / tier-lock). struct BarRowView: View { @Environment(\.barTheme) private var theme let row: BarSummaryRow @ObservedObject var viewModel: BarViewModel /// A native first-party subscription (Claude Code / Codex) — drives the /// distinct "subscription" badge + indigo provider chip. private var isNativeSubscription: Bool { BarFormatting.isNativeSubscription(row) } var body: some View { HStack(alignment: .top, spacing: 10) { Circle() .fill(healthColor) .frame(width: 8, height: 8) .padding(.top, 5) VStack(alignment: .leading, spacing: 5) { HStack(spacing: 7) { Text(row.displayName ?? row.accountId) .font(.system(.body, design: .default).weight(.medium)) .lineLimit(1) .truncationMode(.middle) if row.isDefault { Chip("default", tint: theme.accent) } if row.paused { Chip("paused", tint: .secondary) } if row.needsReauth { Chip("reauth", tint: theme.bandRed) } if isNativeSubscription { Chip("subscription", tint: theme.subscription) } } HStack(spacing: 6) { Chip( BarFormatting.providerLabel(row.provider), tint: isNativeSubscription ? theme.subscription : theme.accent) if let tier = row.tier { Chip(tier, tint: .secondary) } QuotaGaugeView( percentage: row.quotaPercentage, status: row.quotaStatus, nextReset: row.nextReset) } if let lastActive = BarFormatting.lastActiveLabel( iso: row.lastActivityAt, daysSince: nil) { Text(lastActive) .font(.caption2) .foregroundStyle(.secondary) } } Spacer(minLength: 4) VStack(alignment: .trailing, spacing: 3) { costView HStack(spacing: 2) { pauseToggle overflowMenu } } } .padding(.vertical, 8) .padding(.horizontal, 10) .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8)) } /// Today's cost: a real "$x.xx" when known (including a genuine $0.00), a muted /// "no data" when the value is null (no usage record on a possibly-stale snapshot). @ViewBuilder private var costView: some View { if let cost = row.todayCost { Text(BarFormatting.money(cost)) .font(.system(.caption, design: .monospaced)) .foregroundStyle(.secondary) } else { Text("no data") .font(.caption2) .foregroundStyle(.tertiary) } } /// Visible primary action: one tap to pause or resume the account. private var pauseToggle: some View { Button { if row.paused { viewModel.resume(row) } else { viewModel.pause(row) } } label: { Image(systemName: row.paused ? "play.circle" : "pause.circle") } .buttonStyle(.borderless) .help(row.paused ? "Resume account" : "Pause account") } private var overflowMenu: some View { Menu { Button("Set as default") { viewModel.setDefault(row) } Button("Solo (pause others)") { viewModel.solo(row) } Divider() if let tier = row.tier { Button("Lock to \(tier)") { viewModel.tierLock(row, tier: tier) } } Button("Clear tier lock") { viewModel.tierLock(row, tier: nil) } } label: { Image(systemName: "ellipsis.circle") } .menuStyle(.borderlessButton) .menuIndicator(.hidden) .frame(width: 24) } /// Health dot. With the corrected backend, "unsupported" providers (ghcp/kiro) /// arrive as health "ok" (green) — no permanent orange dot. Orange is reserved /// for genuine transient fetch failures, red for accounts needing reauth. private var healthColor: Color { // Use the themed band ramp (not raw system .red/.orange/.green) so the dot // matches the rest of the dropdown and stays legible on both plates. switch row.health { case "error": return theme.bandRed case "warning": return theme.bandAmber default: return theme.bandGreen } } } /// Per-account quota gauge. When the row has a live "ok" quota with a percentage, /// renders a thin colored bar (filled by the remaining fraction, tinted by the /// severity band) plus a "resets in …" caption. When there is no live quota it /// falls back to the honest text label ("no quota" / "quota ?"). All branch, /// color, and countdown logic lives in the pure Core `BarQuotaGauge`; this view /// is a thin render. struct QuotaGaugeView: View { @Environment(\.barTheme) private var theme let percentage: Double? let status: String let nextReset: String? var body: some View { let band = BarQuotaGauge.band(percentage: percentage, status: status) if band != .none, let fill = BarQuotaGauge.fillFraction(percentage: percentage, status: status) { HStack(spacing: 5) { gaugeBar(fill: fill, color: color(for: band)) Text(BarFormatting.quotaLabel(percentage: percentage, status: status)) .font(.system(.caption2, design: .monospaced)) .foregroundStyle(color(for: band)) if let countdown = BarQuotaGauge.resetCountdown(nextReset: nextReset, now: Date()) { Text(countdown) .font(.caption2) .foregroundStyle(.tertiary) .lineLimit(1) } } } else { // No live quota: keep the existing honest text ("no quota" / "quota ?"). Text(BarFormatting.quotaLabel(percentage: percentage, status: status)) .font(.caption2) .foregroundStyle(.secondary) } } private func gaugeBar(fill: Double, color: Color) -> some View { GeometryReader { geo in ZStack(alignment: .leading) { Capsule() .fill(Color.primary.opacity(0.12)) Capsule() .fill(color) .frame(width: max(2, geo.size.width * fill)) } } .frame(width: 54, height: 6) } private func color(for band: BarQuotaGauge.Band) -> Color { // Themed band ramp for whole-dropdown consistency. .orange maps to the coral // band (the warning step in the green→amber→coral→red ramp) so it stays // distinct from the brand accent orange on both plates. switch band { case .green: return theme.bandGreen case .yellow: return theme.bandAmber case .orange: return theme.bandCoral case .red: return theme.bandRed case .none: return .secondary } } } /// Inline banner surfacing the last failed action so it is visible rather than /// silently swallowed. Success is confirmed by the default/paused badge updating. struct ErrorBanner: View { @Environment(\.barTheme) private var theme let message: String var body: some View { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(theme.accent) Text(message) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(2) } .padding(.vertical, 5) .padding(.horizontal, 8) .frame(maxWidth: .infinity, alignment: .leading) .background(theme.accent.opacity(0.10), in: RoundedRectangle(cornerRadius: 7)) } } /// Compact, single-line alert row used by the condensed Alerts section. One /// glanceable line — kind icon + the self-describing body (the title is dropped /// as redundant with the icon) + an optional ×N when several identical /// conditions were merged. The tint is softer than the old `AlertRow` card so a /// list of them reads as informative, not alarming. struct CompactAlertRow: View { @Environment(\.barTheme) private var theme let alert: BarNotification let count: Int var body: some View { HStack(spacing: 6) { Image(systemName: icon) .foregroundStyle(tint) .font(.caption2) .frame(width: 12) Text(alert.body) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) if count > 1 { Text("×\(count)") .font(.system(size: 9, weight: .semibold)) .foregroundStyle(.secondary) } Spacer(minLength: 0) } .padding(.vertical, 4) .padding(.horizontal, 8) .frame(maxWidth: .infinity, alignment: .leading) .background(tint.opacity(0.08), in: RoundedRectangle(cornerRadius: 6)) } private var icon: String { switch alert.kind { case .quotaRemainingBelow: return "gauge.with.dots.needle.bottom.0percent" case .dailySpendAbove, .monthSpendAbove: return "dollarsign.circle" case .reauthNeeded: return "key.slash" case .accountCooldownOrPaused: return "pause.circle" } } private var tint: Color { switch alert.kind { case .quotaRemainingBelow: return theme.accent case .dailySpendAbove, .monthSpendAbove: return theme.accent case .reauthNeeded: return theme.bandRed case .accountCooldownOrPaused: return .secondary } } } /// Small pill label used in account sublines. struct Chip: View { @Environment(\.colorScheme) private var colorScheme let text: String let tint: Color init(_ text: String, tint: Color) { self.text = text self.tint = tint } /// Lift the small 9pt label toward the opposite of the surface so it stays /// legible: toward white on the dark plate (the raw indigo subscription tint /// was too dim to read), toward black on the light plate (lifting toward white /// there would wash the text out). The forced scheme is already in effect on /// this subtree, so `colorScheme` reflects exactly the plate being drawn. private var textColor: Color { if tint == .secondary { return .secondary } let target: NSColor = (colorScheme == .light) ? .black : .white let lifted = NSColor(tint).blended(withFraction: 0.5, of: target) ?? NSColor(tint) return Color(nsColor: lifted) } var body: some View { Text(text) .font(.system(size: 10, weight: .semibold)) .padding(.horizontal, 5) .padding(.vertical, 1.5) .background(tint.opacity(0.22), in: Capsule()) .foregroundStyle(textColor) } } /// Adds horizontal trackpad / Magic Mouse swipe paging to the profile carousel. /// SwiftUI's `DragGesture` handles a mouse or trackpad press-drag, but a /// non-clicking two-finger swipe arrives as scroll-wheel events it never sees. /// /// This hosts a transparent AppKit anchor view (click- and drag-transparent via /// a nil `hitTest`, so it never blocks the cards' buttons or the DragGesture) and /// a local scroll-wheel monitor scoped to that view's on-screen frame. A /// predominantly horizontal scroll pages once per gesture; a vertical scroll is /// passed straight through so the popover still scrolls. If the frame math ever /// fails to match, the worst case is that scroll-swipe simply does nothing — /// drag and the arrows/dots still work — so the failure mode is benign. struct CarouselScrollPager: NSViewRepresentable { /// Called with +1 (next) or -1 (previous) when a horizontal swipe commits. let onPage: (Int) -> Void func makeCoordinator() -> Coordinator { Coordinator(onPage: onPage) } func makeNSView(context: Context) -> NSView { let view = PassthroughView() context.coordinator.attach(to: view) return view } func updateNSView(_ nsView: NSView, context: Context) { context.coordinator.onPage = onPage } static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { coordinator.detach() } /// Anchor view that is transparent to all mouse hit-testing, so clicks and the /// SwiftUI DragGesture pass through to the cards beneath it. final class PassthroughView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { nil } } /// Owns the local scroll-wheel monitor and the per-gesture accumulator. final class Coordinator { var onPage: (Int) -> Void private weak var view: NSView? private var monitor: Any? private var accumulated: CGFloat = 0 private var firedThisGesture = false private let threshold: CGFloat = 40 init(onPage: @escaping (Int) -> Void) { self.onPage = onPage } func attach(to view: NSView) { self.view = view monitor = NSEvent.addLocalMonitorForEvents(matching: [.scrollWheel]) { [weak self] event in self?.handle(event) ?? event } } func detach() { if let monitor { NSEvent.removeMonitor(monitor) } monitor = nil } /// Return nil to consume a horizontal swipe inside the carousel; return the /// event unchanged otherwise so vertical popover scroll is never swallowed. private func handle(_ event: NSEvent) -> NSEvent? { guard let view, let window = view.window, event.window === window else { return event } let frameInWindow = view.convert(view.bounds, to: nil) guard frameInWindow.contains(event.locationInWindow) else { return event } let dx = event.scrollingDeltaX let dy = event.scrollingDeltaY guard abs(dx) > abs(dy) else { return event } // vertical → let the popover scroll if event.phase.contains(.began) || event.momentumPhase.contains(.began) { accumulated = 0 firedThisGesture = false } accumulated += dx if !firedThisGesture && abs(accumulated) >= threshold { firedThisGesture = true // Natural scrolling: content moving left (negative dx) advances to next. onPage(accumulated < 0 ? 1 : -1) } if event.phase.contains(.ended) || event.momentumPhase.contains(.ended) { accumulated = 0 firedThisGesture = false } return nil } } }