Files
LockInBroMacOS/FloatingHUDView.swift
2026-04-01 16:10:30 -05:00

534 lines
19 KiB
Swift
Raw Permalink 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.
// FloatingHUDView.swift Content for the always-on-top focus HUD panel
// All notifications (friction, nudges, resume) render here not in system notifications.
import AppKit
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)
.animation(.spring(duration: 0.3), value: session.monitoringError)
.animation(.spring(duration: 0.3), value: session.nudgeMessage)
.animation(.spring(duration: 0.3), value: session.showingResumeCard)
}
// MARK: - Header
private var header: some View {
HStack(spacing: 8) {
Image(systemName: session.isSessionActive ? "eye.fill" : "eye")
.foregroundStyle(session.isSessionActive ? .blue : .secondary)
.font(.caption)
Text(session.activeTask?.title ?? (session.isSessionActive ? "Focus Session" : "Argus Monitoring"))
.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)
}
// MARK: - Content (priority order)
@ViewBuilder
private var content: some View {
// 1. Error / warning banner
if let error = session.monitoringError {
MonitoringErrorBanner(message: error)
.transition(.move(edge: .top).combined(with: .opacity))
}
// 2. Executor output sticky card (highest priority persists until dismissed)
if let output = session.executorOutput {
ExecutorOutputCard(title: output.title, content: output.content) {
session.executorOutput = nil
}
.transition(.move(edge: .top).combined(with: .opacity))
}
// 3. Executing spinner
else if session.isExecuting {
HStack(spacing: 10) {
ProgressView()
.controlSize(.small)
Text("Executing action…")
.font(.caption)
.foregroundStyle(.orange)
}
.padding(14)
.transition(.opacity)
}
// 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
else if let card = session.proactiveCard {
HUDCardView(card: card)
.transition(.move(edge: .top).combined(with: .opacity))
}
// 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
else if session.monitoringError == nil {
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)
Text(task)
.font(.caption.bold())
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(2)
}
}
// 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)
}
Text(session.latestVlmSummary ?? "Monitoring your screen…")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(3)
}
// 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)
}
}
}
.padding(14)
}
}
// MARK: - HUD Card (friction + proposed actions / session actions)
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)
}
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: {
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())
.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 .appSwitchLoop:
VStack(alignment: .leading, spacing: 6) {
Button {
session.approveProactiveCard(actionIndex: 0)
} label: {
Text("Help me with this")
.font(.caption.bold())
.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
}
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"
case "start_new": return "Create task + start focus session"
default: return "OK"
}
}
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?"
case .sessionAction(_, _, let checkpoint, let reason, _, _):
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)
}
}
.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
)
}
}
// MARK: - Executor Output Sticky Card
private struct ExecutorOutputCard: View {
let title: String
let content: String
let onDismiss: () -> Void
@State private var copied = false
private var maxScrollHeight: CGFloat {
let screenHeight = NSScreen.main?.visibleFrame.height ?? 800
return max(120, screenHeight - 157)
}
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)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: maxScrollHeight)
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))
}
.buttonStyle(.plain)
.animation(.easeInOut(duration: 0.15), value: copied)
}
}
.padding(14)
.background(Color.green.opacity(0.07))
}
}