Files
brov-macbook/NotchBuddy/Sources/App/AppGridView.swift
T

271 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String> = 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)
}
}