0d40b78b26
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
271 lines
11 KiB
Swift
271 lines
11 KiB
Swift
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)
|
||
}
|
||
}
|