228 lines
8.1 KiB
Swift
228 lines
8.1 KiB
Swift
|
|
// FloatingHUDView.swift — Content for the always-on-top focus HUD panel
|
|||
|
|
|
|||
|
|
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)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Header
|
|||
|
|
|
|||
|
|
private var header: some View {
|
|||
|
|
HStack(spacing: 8) {
|
|||
|
|
Image(systemName: "eye.fill")
|
|||
|
|
.foregroundStyle(.blue)
|
|||
|
|
.font(.caption)
|
|||
|
|
|
|||
|
|
Text(session.activeTask?.title ?? "Focus Session")
|
|||
|
|
.font(.caption.bold())
|
|||
|
|
.lineLimit(1)
|
|||
|
|
|
|||
|
|
Spacer()
|
|||
|
|
|
|||
|
|
// Pulse dot — green when capturing, orange when executing
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
@ViewBuilder
|
|||
|
|
private var content: some View {
|
|||
|
|
// 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))
|
|||
|
|
}
|
|||
|
|
// Executing spinner
|
|||
|
|
else if session.isExecuting {
|
|||
|
|
HStack(spacing: 10) {
|
|||
|
|
ProgressView()
|
|||
|
|
.controlSize(.small)
|
|||
|
|
Text("Executing action…")
|
|||
|
|
.font(.caption)
|
|||
|
|
.foregroundStyle(.orange)
|
|||
|
|
}
|
|||
|
|
.padding(14)
|
|||
|
|
.transition(.opacity)
|
|||
|
|
}
|
|||
|
|
// Proactive friction card
|
|||
|
|
else if let card = session.proactiveCard {
|
|||
|
|
HUDCardView(card: card)
|
|||
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
|||
|
|
}
|
|||
|
|
// Latest VLM summary (idle state)
|
|||
|
|
else {
|
|||
|
|
Text(session.latestVlmSummary ?? "Monitoring your screen…")
|
|||
|
|
.font(.caption)
|
|||
|
|
.foregroundStyle(.secondary)
|
|||
|
|
.fixedSize(horizontal: false, vertical: true)
|
|||
|
|
.padding(14)
|
|||
|
|
.transition(.opacity)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - HUD Card (friction + proposed 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)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Action buttons — only for VLM friction with proposed actions
|
|||
|
|
if case .vlmFriction(_, _, let actions) = card.source, !actions.isEmpty {
|
|||
|
|
VStack(alignment: .leading, spacing: 6) {
|
|||
|
|
ForEach(Array(actions.prefix(2).enumerated()), id: \.offset) { index, action in
|
|||
|
|
Button {
|
|||
|
|
session.approveProactiveCard(actionIndex: index)
|
|||
|
|
} label: {
|
|||
|
|
VStack(alignment: .leading, spacing: 2) {
|
|||
|
|
Text(action.label)
|
|||
|
|
.font(.caption.bold())
|
|||
|
|
.lineLimit(2)
|
|||
|
|
.multilineTextAlignment(.leading)
|
|||
|
|
if let details = action.details, !details.isEmpty {
|
|||
|
|
Text(details)
|
|||
|
|
.font(.caption2)
|
|||
|
|
.foregroundStyle(.purple.opacity(0.7))
|
|||
|
|
.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)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Button("Not now — I'm good") { session.dismissProactiveCard() }
|
|||
|
|
.font(.caption)
|
|||
|
|
.foregroundStyle(.secondary)
|
|||
|
|
.buttonStyle(.plain)
|
|||
|
|
.padding(.top, 2)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.padding(14)
|
|||
|
|
.background(Color.purple.opacity(0.07))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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?"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Executor Output Sticky Card
|
|||
|
|
|
|||
|
|
private struct ExecutorOutputCard: View {
|
|||
|
|
let title: String
|
|||
|
|
let content: String
|
|||
|
|
let onDismiss: () -> Void
|
|||
|
|
|
|||
|
|
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)
|
|||
|
|
.fixedSize(horizontal: false, vertical: true)
|
|||
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|||
|
|
}
|
|||
|
|
.frame(maxHeight: 120)
|
|||
|
|
|
|||
|
|
Button("Dismiss") { onDismiss() }
|
|||
|
|
.font(.caption)
|
|||
|
|
.foregroundStyle(.secondary)
|
|||
|
|
.buttonStyle(.plain)
|
|||
|
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
|||
|
|
}
|
|||
|
|
.padding(14)
|
|||
|
|
.background(Color.green.opacity(0.07))
|
|||
|
|
}
|
|||
|
|
}
|