Resilience: LaunchAgent restarts BroV on crash; crashed Claude Code/Codex sessions get a resume button (optional auto); login and waiting status in the Claude/ChatGPT pills
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,38 @@ struct AppGridView: View {
|
||||
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 }
|
||||
@@ -56,8 +88,8 @@ struct AppGridView: View {
|
||||
EmptyPill()
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
AppPill(app: DesktopApps.claude, limit: claudeLimit)
|
||||
AppPill(app: DesktopApps.chatGPT, limit: chatGPTLimit)
|
||||
AppPill(app: DesktopApps.claude, limit: claudeLimit, alert: alert(for: .claude))
|
||||
AppPill(app: DesktopApps.chatGPT, limit: chatGPTLimit, alert: alert(for: .codex))
|
||||
}
|
||||
}
|
||||
.padding(.leading, 104)
|
||||
@@ -87,9 +119,17 @@ struct PillLimit {
|
||||
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
|
||||
@@ -100,8 +140,7 @@ struct AppPill: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: open) {
|
||||
HStack(spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
icon
|
||||
.frame(width: 26, height: 26)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
@@ -114,6 +153,9 @@ struct AppPill: View {
|
||||
.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"))
|
||||
@@ -133,6 +175,7 @@ struct AppPill: View {
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
@@ -146,8 +189,7 @@ struct AppPill: View {
|
||||
.stroke(Color(hex: app.color).opacity(isHovered ? 0.6 : 0.22), lineWidth: 1)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onTapGesture(perform: open)
|
||||
.scaleEffect(isHovered ? 1.02 : 1)
|
||||
.onHover { h in withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = h } }
|
||||
.onAppear(perform: refresh)
|
||||
@@ -155,6 +197,27 @@ struct AppPill: View {
|
||||
.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))
|
||||
|
||||
Reference in New Issue
Block a user