import SwiftUI import AppKit // MARK: - BroV home: two floors of flat app pills // // Top floor: two empty slots, to be filled later. // Bottom floor: the Claude and ChatGPT desktop apps (click = bring to front, or launch). The bot keeps its spot on the left. struct DesktopAppSlot: Identifiable { let id: String let name: String /// Tried in order: the first installed one wins. let bundleIds: [String] let color: String let downloadURL: String } enum DesktopApps { static let claude = DesktopAppSlot(id: "app_claude", name: "Claude", bundleIds: ["com.anthropic.claudefordesktop"], color: "#D97757", downloadURL: "https://claude.ai/download") static let chatGPT = DesktopAppSlot(id: "app_chatgpt", name: "ChatGPT", // New ChatGPT app first (its id is com.openai.codex), Classic as fallback. bundleIds: ["com.openai.codex", "com.openai.chat"], color: "#10A37F", downloadURL: "https://chatgpt.com/download") } struct AppGridView: View { @ObservedObject var state: AppState /// "9% · сброс через 1 д 18 ч" — the Claude window closest to its limit. private var claudeLimit: PillLimit? { guard state.planRelayInstalled, let usage = state.claudePlanUsage, let pct = ClaudePlanGauge.dominantPct(usage) else { return nil } var text = "\(Int(pct.rounded()))%" if let w = ClaudePlanGauge.dominantWindow(usage), w.resetsAt > Date() { text += " · сброс через \(PlanTime.untilReset(w.resetsAt))" } return PillLimit(text: text, color: ClaudePlanGauge.color(for: pct)) } /// What Claude Code / Codex need from us, most urgent first: /// a session fell over → login needed → waiting for an answer → working. private func alert(for kind: AgentKind) -> PillAlert? { if let crash = state.crashedSessions.last(where: { $0.kind == kind }) { return PillAlert(text: "упала · \(crash.project) · поднять", color: "#F4505E") { AgentWatch.shared.revive(crash) } } if kind == .claude ? state.claudeNeedsLogin : state.codexNeedsLogin { return PillAlert(text: "нужен вход · войти", color: "#F4505E") { AgentAuth.shared.login(kind) } } let pillIds: Set = kind == .claude ? ["integration_claude", "agent_cursor"] : ["agent_codex"] let tasks = state.tasks.filter { pillIds.contains($0.id) } if let t = tasks.first(where: { $0.state == .approval || $0.state == .question }) { let isApproval = t.state == .approval return PillAlert(text: isApproval ? "ждёт разрешения · открыть" : "задал вопрос · открыть", color: "#F5A524") { state.setFocus(t.id) state.view = isApproval ? .approval : .question } } if let t = tasks.first(where: { [.working, .thinking, .searching].contains($0.state) }) { return PillAlert(text: "работает · \(t.name)", color: "#3B9EFF") } if tasks.contains(where: { $0.state == .ratelimit }) { return PillAlert(text: "упёрся в лимит", color: "#FB923C") } return nil } /// "23% · сброс сб 15:22" — ChatGPT weekly limit from the Codex logs. private var chatGPTLimit: PillLimit? { guard let w = state.codexWeekly else { return nil } let pct = ClaudePlanGauge.effectivePct(w) var text = "\(Int(pct.rounded()))%" if w.resetsAt > Date() { text += " · сброс \(PlanTime.resetMoment(w.resetsAt))" } return PillLimit(text: text, color: ClaudePlanGauge.color(for: pct)) } var body: some View { ZStack { VStack(spacing: 8) { HStack(spacing: 8) { ShelfPill() EmptyPill() } HStack(spacing: 8) { AppPill(app: DesktopApps.claude, limit: claudeLimit, alert: alert(for: .claude)) AppPill(app: DesktopApps.chatGPT, limit: chatGPTLimit, alert: alert(for: .codex)) } } .padding(.leading, 104) .padding(.trailing, 20) .padding(.vertical, 6) #if !APPSTORE if state.showingPlanDetail { CardBackground(wash: nil) ClaudePlanCardView(usage: state.claudePlanUsage) .transition(.opacity) } #endif } #if !APPSTORE .onChange(of: state.mode) { _, m in if m != .expanded { state.showingPlanDetail = false } } #endif } } // MARK: - App pill struct PillLimit { let text: String let color: String } /// Something the agent needs from us, shown instead of the limit line. struct PillAlert { let text: String let color: String var action: (() -> Void)? = nil } struct AppPill: View { let app: DesktopAppSlot var limit: PillLimit? = nil var alert: PillAlert? = nil @State private var appURL: URL? @State private var isRunning = false @State private var isHovered = false private var status: String { if appURL == nil { return "не установлено" } return isRunning ? "запущено" : "не запущено" } var body: some View { HStack(spacing: 10) { icon .frame(width: 26, height: 26) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(app.name) .font(.system(size: 13, weight: .semibold)) .foregroundColor(Color(hex: "#F5F6F8")) Spacer(minLength: 4) Image(systemName: "arrow.up.right") .font(.system(size: 9, weight: .semibold)) .foregroundColor(isHovered ? Color(hex: app.color) : Color(hex: "#5F646D")) } if let alert { alertRow(alert) } else { HStack(spacing: 5) { Circle() .fill(isRunning ? Color(hex: "#34D399") : Color(hex: "#5F646D")) .frame(width: 5, height: 5) // With a limit shown, the green dot alone says "running". if limit == nil || appURL == nil { Text(status) .font(.system(size: 10.5)) .foregroundColor(Color(hex: "#8E939C")) .lineLimit(1) } if let limit, appURL != nil { Text(limit.text) .font(.system(size: 10.5, weight: .semibold)) .foregroundColor(Color(hex: limit.color)) .lineLimit(1) .minimumScaleFactor(0.8) } } } } } .padding(.horizontal, 12) .frame(maxWidth: .infinity, maxHeight: .infinity) .background( RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(isHovered ? Color(hex: app.color).opacity(0.16) : Color(hex: "#0E0F11")) ) .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) .stroke(Color(hex: app.color).opacity(isHovered ? 0.6 : 0.22), lineWidth: 1) ) .contentShape(Rectangle()) .onTapGesture(perform: open) .scaleEffect(isHovered ? 1.02 : 1) .onHover { h in withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = h } } .onAppear(perform: refresh) .onReceive(NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didLaunchApplicationNotification)) { _ in refresh() } .onReceive(NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didTerminateApplicationNotification)) { _ in refresh() } } /// Status that needs attention: pulsing dot + text; tappable when it has an action. @ViewBuilder private func alertRow(_ alert: PillAlert) -> some View { let row = HStack(spacing: 5) { Circle() .fill(Color(hex: alert.color)) .frame(width: 6, height: 6) .shadow(color: Color(hex: alert.color), radius: 3) Text(alert.text) .font(.system(size: 10.5, weight: .semibold)) .foregroundColor(Color(hex: alert.color)) .lineLimit(1) .minimumScaleFactor(0.8) } if let action = alert.action { Button(action: action) { row.contentShape(Rectangle()) } .buttonStyle(.plain) } else { row } } @ViewBuilder private var icon: some View { if let appURL { Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path)) .resizable() .interpolation(.high) } else { RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(Color(hex: app.color)) .overlay(Text(String(app.name.prefix(1))) .font(.system(size: 13, weight: .bold)) .foregroundColor(.white)) } } /// The first installed bundle id of the slot. private var bundleId: String? { app.bundleIds.first { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0) != nil } } private func refresh() { appURL = bundleId.flatMap { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0) } isRunning = NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == bundleId } } private func open() { SoundEngine.shared.play("blip") if let running = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == bundleId }) { running.activate(options: .activateIgnoringOtherApps) } else if let appURL { NSWorkspace.shared.openApplication(at: appURL, configuration: .init(), completionHandler: nil) } else if let url = URL(string: app.downloadURL) { NSWorkspace.shared.open(url) } } } // MARK: - Empty slot struct EmptyPill: View { var body: some View { RoundedRectangle(cornerRadius: 14, style: .continuous) .strokeBorder(Color.white.opacity(0.08), style: StrokeStyle(lineWidth: 1, dash: [4, 4])) .overlay( Text("пусто") .font(.system(size: 10.5)) .foregroundColor(Color(hex: "#4A4E55")) ) .frame(maxWidth: .infinity, maxHeight: .infinity) } }