Files
LockInBroMacOS/FloatingHUDView.swift

534 lines
19 KiB
Swift
Raw Normal View History

2026-03-29 00:58:22 -04:00
// FloatingHUDView.swift Content for the always-on-top focus HUD panel
2026-04-01 16:10:30 -05:00
// All notifications (friction, nudges, resume) render here not in system notifications.
2026-03-29 00:58:22 -04:00
2026-04-01 16:10:30 -05:00
import AppKit
2026-03-29 00:58:22 -04:00
import SwiftUI
struct FloatingHUDView: View {
@Environment(SessionManager.self) private var session
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
content
}
.background(.ultraThinMaterial)
.clipShape(.rect(cornerRadius: 12))
.shadow(color: .black.opacity(0.25), radius: 12, x: 0, y: 4)
.frame(width: 320)
.animation(.spring(duration: 0.3), value: session.proactiveCard?.id)
.animation(.spring(duration: 0.3), value: session.isExecuting)
.animation(.spring(duration: 0.3), value: session.executorOutput?.title)
2026-03-29 06:29:18 -04:00
.animation(.spring(duration: 0.3), value: session.monitoringError)
2026-04-01 16:10:30 -05:00
.animation(.spring(duration: 0.3), value: session.nudgeMessage)
.animation(.spring(duration: 0.3), value: session.showingResumeCard)
2026-03-29 00:58:22 -04:00
}
// MARK: - Header
private var header: some View {
HStack(spacing: 8) {
2026-04-01 16:10:30 -05:00
Image(systemName: session.isSessionActive ? "eye.fill" : "eye")
.foregroundStyle(session.isSessionActive ? .blue : .secondary)
2026-03-29 00:58:22 -04:00
.font(.caption)
2026-04-01 16:10:30 -05:00
Text(session.activeTask?.title ?? (session.isSessionActive ? "Focus Session" : "Argus Monitoring"))
2026-03-29 00:58:22 -04:00
.font(.caption.bold())
.lineLimit(1)
Spacer()
if session.isExecuting {
Image(systemName: "bolt.fill")
.font(.caption2)
.foregroundStyle(.orange)
} else {
Circle()
.fill(session.isCapturing ? Color.green : Color.secondary.opacity(0.4))
.frame(width: 7, height: 7)
.overlay(
Circle()
.fill(session.isCapturing ? Color.green.opacity(0.3) : .clear)
.frame(width: 14, height: 14)
)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
}
2026-04-01 16:10:30 -05:00
// MARK: - Content (priority order)
2026-03-29 00:58:22 -04:00
@ViewBuilder
private var content: some View {
2026-04-01 16:10:30 -05:00
// 1. Error / warning banner
2026-03-29 06:29:18 -04:00
if let error = session.monitoringError {
MonitoringErrorBanner(message: error)
.transition(.move(edge: .top).combined(with: .opacity))
}
2026-04-01 16:10:30 -05:00
// 2. Executor output sticky card (highest priority persists until dismissed)
2026-03-29 00:58:22 -04:00
if let output = session.executorOutput {
ExecutorOutputCard(title: output.title, content: output.content) {
session.executorOutput = nil
}
.transition(.move(edge: .top).combined(with: .opacity))
}
2026-04-01 16:10:30 -05:00
// 3. Executing spinner
2026-03-29 00:58:22 -04:00
else if session.isExecuting {
HStack(spacing: 10) {
ProgressView()
.controlSize(.small)
Text("Executing action…")
.font(.caption)
.foregroundStyle(.orange)
}
.padding(14)
.transition(.opacity)
}
2026-04-01 16:10:30 -05:00
// 4. Resume card (shown in HUD, not as system overlay)
else if session.showingResumeCard, let card = session.resumeCard {
ResumeCardView(card: card)
.transition(.move(edge: .top).combined(with: .opacity))
}
// 5. Proactive friction / session action card
2026-03-29 00:58:22 -04:00
else if let card = session.proactiveCard {
HUDCardView(card: card)
.transition(.move(edge: .top).combined(with: .opacity))
}
2026-04-01 16:10:30 -05:00
// 6. Nudge card (amber, shown in HUD instead of system notification)
else if let nudge = session.nudgeMessage {
NudgeCardView(message: nudge)
.transition(.move(edge: .top).combined(with: .opacity))
}
// 7. Idle state latest VLM summary
2026-03-29 06:29:18 -04:00
else if session.monitoringError == nil {
2026-04-01 16:10:30 -05:00
IdleSummaryView()
.transition(.opacity)
}
}
}
// MARK: - Nudge Card (amber replaces UNUserNotificationCenter)
private struct NudgeCardView: View {
let message: String
@Environment(SessionManager.self) private var session
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "lightbulb.fill")
.foregroundStyle(.orange)
.font(.caption)
Text(message)
.font(.caption)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(4)
Spacer(minLength: 0)
Button { session.dismissNudge() } label: {
Image(systemName: "xmark")
.font(.caption2.bold())
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
.padding(12)
.background(Color.orange.opacity(0.08))
.overlay(
Rectangle()
.frame(width: 3)
.foregroundStyle(Color.orange),
alignment: .leading
)
}
}
// MARK: - Resume Card (warm welcome-back in HUD)
private struct ResumeCardView: View {
let card: ResumeCard
@Environment(SessionManager.self) private var session
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "arrow.counterclockwise.circle.fill")
.foregroundStyle(.blue)
.font(.caption)
Text(card.welcomeBack)
.font(.caption.bold())
.foregroundStyle(.blue)
Spacer()
Button { session.showingResumeCard = false } label: {
Image(systemName: "xmark")
.font(.caption2.bold())
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
Text(card.youWereDoing)
.font(.caption)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
Text(card.nextStep)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text(card.motivation)
.font(.caption.italic())
.foregroundStyle(.blue.opacity(0.8))
.fixedSize(horizontal: false, vertical: true)
Button("Got it — let's go") {
session.showingResumeCard = false
}
.font(.caption.bold())
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 5)
.background(Color.blue)
.clipShape(.rect(cornerRadius: 6))
.buttonStyle(.plain)
}
.padding(14)
.background(Color.blue.opacity(0.07))
}
}
// MARK: - Idle Summary View
private struct IdleSummaryView: View {
@Environment(SessionManager.self) private var session
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Step progress only when session is active with steps
if session.isSessionActive && session.totalSteps > 0 {
HStack(spacing: 6) {
Image(systemName: "checklist")
.font(.caption2)
.foregroundStyle(.blue)
Text("Step \(min(session.completedCount + 1, session.totalSteps))/\(session.totalSteps): \(session.currentStep?.title ?? "In progress")")
.font(.caption)
.foregroundStyle(.blue)
.lineLimit(1)
}
Divider()
}
// Inferred task
if let task = session.latestInferredTask, !task.isEmpty {
VStack(alignment: .leading, spacing: 2) {
Text("DOING NOW")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
.tracking(0.5)
2026-03-29 06:29:18 -04:00
Text(task)
.font(.caption.bold())
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(2)
}
2026-04-01 16:10:30 -05:00
}
// App badge + VLM summary
HStack(alignment: .top, spacing: 6) {
if let app = session.latestAppName, !app.isEmpty {
Text(app)
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.purple)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(Color.purple.opacity(0.1))
.clipShape(.capsule)
.lineLimit(1)
}
2026-03-29 06:29:18 -04:00
Text(session.latestVlmSummary ?? "Monitoring your screen…")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(3)
}
2026-04-01 16:10:30 -05:00
// Distraction count badge
if session.isSessionActive && session.distractionCount > 0 {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 9))
.foregroundStyle(.orange)
Text("\(session.distractionCount) distraction\(session.distractionCount == 1 ? "" : "s") this session")
.font(.system(size: 9))
.foregroundStyle(.orange)
}
}
2026-03-29 00:58:22 -04:00
}
2026-04-01 16:10:30 -05:00
.padding(14)
2026-03-29 00:58:22 -04:00
}
}
2026-04-01 16:10:30 -05:00
// MARK: - HUD Card (friction + proposed actions / session actions)
2026-03-29 00:58:22 -04:00
private struct HUDCardView: View {
let card: ProactiveCard
@Environment(SessionManager.self) private var session
var body: some View {
VStack(alignment: .leading, spacing: 10) {
// Title row
HStack(alignment: .top, spacing: 8) {
Image(systemName: card.icon)
.foregroundStyle(.purple)
VStack(alignment: .leading, spacing: 3) {
Text(card.title)
.font(.caption.bold())
.foregroundStyle(.purple)
Text(bodyText)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(3)
}
Spacer()
Button { session.dismissProactiveCard() } label: {
Image(systemName: "xmark")
.font(.caption2.bold())
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
2026-03-29 06:29:18 -04:00
actionButtons
}
.padding(14)
.background(Color.purple.opacity(0.07))
}
@ViewBuilder
private var actionButtons: some View {
switch card.source {
case .vlmFriction(_, _, let actions) where !actions.isEmpty:
VStack(alignment: .leading, spacing: 6) {
ForEach(Array(actions.prefix(2).enumerated()), id: \.offset) { index, action in
Button {
session.approveProactiveCard(actionIndex: index)
} label: {
2026-04-01 16:10:30 -05:00
Text(action.label)
.font(.caption.bold())
.lineLimit(2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.purple.opacity(0.12))
.clipShape(.rect(cornerRadius: 8))
}
.buttonStyle(.plain)
.foregroundStyle(.purple)
}
notNowButton
}
case .sessionAction(let type, _, _, _, _, _):
VStack(alignment: .leading, spacing: 6) {
Button {
session.approveProactiveCard(actionIndex: 0)
} label: {
Text(sessionActionButtonLabel(type))
.font(.caption.bold())
2026-03-29 06:29:18 -04:00
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.purple.opacity(0.12))
.clipShape(.rect(cornerRadius: 8))
}
2026-04-01 16:10:30 -05:00
.buttonStyle(.plain)
.foregroundStyle(.purple)
2026-03-29 06:29:18 -04:00
notNowButton
}
2026-03-29 00:58:22 -04:00
2026-04-01 16:10:30 -05:00
case .appSwitchLoop:
2026-03-29 06:29:18 -04:00
VStack(alignment: .leading, spacing: 6) {
Button {
session.approveProactiveCard(actionIndex: 0)
} label: {
2026-04-01 16:10:30 -05:00
Text("Help me with this")
2026-03-29 06:29:18 -04:00
.font(.caption.bold())
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.purple.opacity(0.12))
.clipShape(.rect(cornerRadius: 8))
2026-03-29 00:58:22 -04:00
}
2026-03-29 06:29:18 -04:00
.buttonStyle(.plain)
.foregroundStyle(.purple)
2026-04-01 16:10:30 -05:00
2026-03-29 06:29:18 -04:00
notNowButton
2026-03-29 00:58:22 -04:00
}
2026-03-29 06:29:18 -04:00
default:
EmptyView()
}
}
private var notNowButton: some View {
Button("Not now — I'm good") { session.dismissProactiveCard() }
.font(.caption)
.foregroundStyle(.secondary)
.buttonStyle(.plain)
.padding(.top, 2)
}
private func sessionActionButtonLabel(_ type: String) -> String {
switch type {
case "resume": return "Resume session"
case "switch": return "Switch to this task"
case "complete": return "Mark complete"
2026-04-01 16:10:30 -05:00
case "start_new": return "Create task + start focus session"
2026-03-29 06:29:18 -04:00
default: return "OK"
2026-03-29 00:58:22 -04:00
}
}
private var bodyText: String {
switch card.source {
case .vlmFriction(_, let description, _):
return description ?? "I noticed something that might be slowing you down."
case .appSwitchLoop(let apps, let count):
return "You've switched between \(apps.joined(separator: "")) \(count)× — are you stuck?"
2026-04-01 16:10:30 -05:00
case .sessionAction(_, _, let checkpoint, let reason, _, _):
2026-03-29 06:29:18 -04:00
if !checkpoint.isEmpty { return "Left off: \(checkpoint)" }
return reason.isEmpty ? "Argus noticed a session change." : reason
}
}
}
// MARK: - Monitoring Error Banner
private struct MonitoringErrorBanner: View {
let message: String
@Environment(SessionManager.self) private var session
private var isRestarting: Bool { message.contains("restarting") }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: isRestarting ? "arrow.clockwise" : "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(isRestarting ? .orange : .red)
.symbolEffect(.pulse, isActive: isRestarting)
Text(message)
.font(.caption)
.foregroundStyle(isRestarting ? .orange : .red)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
if !isRestarting {
Button("Retry") { session.retryMonitoring() }
.font(.caption.bold())
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 5)
.background(Color.red.opacity(0.8))
.clipShape(.rect(cornerRadius: 6))
.buttonStyle(.plain)
}
2026-03-29 00:58:22 -04:00
}
2026-03-29 06:29:18 -04:00
.padding(12)
.background(isRestarting ? Color.orange.opacity(0.08) : Color.red.opacity(0.08))
.overlay(
Rectangle()
.frame(width: 3)
.foregroundStyle(isRestarting ? Color.orange : Color.red),
alignment: .leading
)
2026-03-29 00:58:22 -04:00
}
}
// MARK: - Executor Output Sticky Card
private struct ExecutorOutputCard: View {
let title: String
let content: String
let onDismiss: () -> Void
2026-04-01 16:10:30 -05:00
@State private var copied = false
private var maxScrollHeight: CGFloat {
let screenHeight = NSScreen.main?.visibleFrame.height ?? 800
return max(120, screenHeight - 157)
}
2026-03-29 00:58:22 -04:00
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.caption)
Text(title)
.font(.caption.bold())
.foregroundStyle(.green)
.lineLimit(1)
Spacer()
Button { onDismiss() } label: {
Image(systemName: "xmark")
.font(.caption2.bold())
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
ScrollView {
Text(content)
.font(.caption)
.foregroundStyle(.primary)
2026-04-01 16:10:30 -05:00
.textSelection(.enabled)
2026-03-29 00:58:22 -04:00
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
2026-04-01 16:10:30 -05:00
.frame(maxHeight: maxScrollHeight)
2026-03-29 00:58:22 -04:00
2026-04-01 16:10:30 -05:00
HStack {
Button("Dismiss") { onDismiss() }
.font(.caption)
.foregroundStyle(.secondary)
.buttonStyle(.plain)
Spacer()
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(content, forType: .string)
copied = true
Task {
try? await Task.sleep(for: .seconds(2))
copied = false
}
} label: {
Label(copied ? "Copied!" : "Copy", systemImage: copied ? "checkmark" : "doc.on.doc")
.font(.caption.bold())
.foregroundStyle(copied ? AnyShapeStyle(.secondary) : AnyShapeStyle(Color.green))
}
2026-03-29 00:58:22 -04:00
.buttonStyle(.plain)
2026-04-01 16:10:30 -05:00
.animation(.easeInOut(duration: 0.15), value: copied)
}
2026-03-29 00:58:22 -04:00
}
.padding(14)
.background(Color.green.opacity(0.07))
}
}