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:
maksarsanjeev
2026-10-06 22:56:39 +03:00
parent 37c01f991a
commit 0d40b78b26
9 changed files with 453 additions and 7 deletions
+69 -6
View File
@@ -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))