// // JarvisView.swift // oAI // // Main modal for managing Jarvis (oAI-Web) agents and usage. // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen import SwiftUI // MARK: - Editor context (avoids .sheet timing bug) private struct AgentEditContext: Identifiable { let id = UUID() let agent: JarvisAgent? // nil → new } // MARK: - JarvisView struct JarvisView: View { @Environment(\.dismiss) var dismiss @State private var selectedTab = 0 @State private var agents: [JarvisAgent] = [] @State private var runs: [String: [JarvisAgentRun]] = [:] @State private var usageStats: [JarvisUsageStat] = [] @State private var credits: JarvisCreditsResponse? = nil @State private var queueStatus: JarvisQueueStatus? = nil @State private var isLoadingAgents = false @State private var isLoadingUsage = false @State private var selectedAgent: JarvisAgent? = nil @State private var editContext: AgentEditContext? = nil @State private var errorMessage: String? = nil @State private var actionInProgress: Set = [] private let service = JarvisService.shared var body: some View { VStack(spacing: 0) { // Header HStack(alignment: .center) { Label("Jarvis", systemImage: "server.rack") .font(.system(size: 18, weight: .bold)) Spacer() if let qs = queueStatus { queueStatusBadge(qs) } Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") .font(.title2).foregroundStyle(.secondary) } .buttonStyle(.plain) .keyboardShortcut(.escape, modifiers: []) } .padding(.horizontal, 24) .padding(.top, 20) .padding(.bottom, 10) // Tab picker Picker("", selection: $selectedTab) { Text("Agents").tag(0) Text("Usage").tag(1) } .pickerStyle(.segmented) .padding(.horizontal, 24) .padding(.bottom, 10) Divider() if let err = errorMessage { HStack { Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange) Text(err).font(.callout).foregroundStyle(.secondary) Spacer() Button("Dismiss") { errorMessage = nil }.buttonStyle(.borderless) } .padding(.horizontal, 24) .padding(.vertical, 8) Divider() } switch selectedTab { case 0: agentsTab default: usageTab } } .frame(minWidth: 840, idealWidth: 960, minHeight: 560, idealHeight: 720) .task { await loadAll() } .sheet(item: $editContext) { ctx in JarvisAgentEditorSheet(agent: ctx.agent, onSave: { input in await saveAgent(existing: ctx.agent, input: input) }) } } // MARK: - Agents Tab private var agentsTab: some View { NavigationSplitView { agentList } detail: { if let agent = selectedAgent { agentDetailView(agent) } else { ContentUnavailableView("Select an Agent", systemImage: "server.rack", description: Text("Choose an agent from the list to view details and run history")) } } .navigationSplitViewStyle(.balanced) } private var agentList: some View { VStack(spacing: 0) { // Toolbar HStack(spacing: 8) { Button { editContext = AgentEditContext(agent: nil) } label: { Label("New Agent", systemImage: "plus") } .buttonStyle(.bordered) .controlSize(.small) Spacer() if isLoadingAgents { ProgressView().scaleEffect(0.7).frame(width: 14, height: 14) } Button { Task { await loadAgents() } } label: { Image(systemName: "arrow.clockwise") } .buttonStyle(.borderless) .help("Refresh agents") // Queue control if let qs = queueStatus { Button { Task { do { if qs.paused == true { try await service.resumeAll() } else { try await service.pauseAll() } await loadQueueStatus() } catch { errorMessage = error.localizedDescription } } } label: { Image(systemName: qs.paused == true ? "play.fill" : "pause.fill") } .buttonStyle(.borderless) .help(qs.paused == true ? "Resume queue" : "Pause queue") } } .padding(.horizontal, 12) .padding(.vertical, 8) Divider() if agents.isEmpty && !isLoadingAgents { ContentUnavailableView("No Agents", systemImage: "server.rack") .frame(maxWidth: .infinity, maxHeight: .infinity) } else { List(agents, id: \.id, selection: $selectedAgent) { agent in agentRow(agent) .tag(agent) } .listStyle(.plain) } } } private func agentRow(_ agent: JarvisAgent) -> some View { HStack(spacing: 8) { // Status dot Circle() .fill(agent.isRunning == true ? Color.green : Color.secondary.opacity(0.4)) .frame(width: 8, height: 8) VStack(alignment: .leading, spacing: 2) { Text(agent.name) .font(.system(size: 13, weight: .medium)) .lineLimit(1) if let schedule = agent.schedule, !schedule.isEmpty { Text(schedule) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) } } .frame(maxWidth: .infinity, alignment: .leading) // Enable toggle Toggle("", isOn: Binding( get: { agent.enabled }, set: { _ in Task { await toggleAgent(agent) } } )) .toggleStyle(.switch) .controlSize(.mini) .labelsHidden() // Run / Stop button if agent.isRunning == true { Button { Task { await stopAgent(agent) } } label: { Image(systemName: "stop.fill") .font(.system(size: 11)) .foregroundStyle(.red) } .buttonStyle(.borderless) .disabled(actionInProgress.contains(agent.id)) .help("Stop agent") } else { Button { Task { await runAgent(agent) } } label: { Image(systemName: actionInProgress.contains(agent.id) ? "ellipsis" : "play.fill") .font(.system(size: 11)) .foregroundStyle(.green) } .buttonStyle(.borderless) .disabled(actionInProgress.contains(agent.id) || !agent.enabled) .help("Run agent now") } } .padding(.vertical, 2) .contentShape(Rectangle()) } @ViewBuilder private func agentDetailView(_ agent: JarvisAgent) -> some View { VStack(alignment: .leading, spacing: 0) { // Agent info header VStack(alignment: .leading, spacing: 12) { HStack { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { Circle() .fill(agent.isRunning == true ? Color.green : Color.secondary.opacity(0.4)) .frame(width: 10, height: 10) Text(agent.name) .font(.system(size: 16, weight: .semibold)) if !agent.enabled { Text("Disabled") .font(.caption) .padding(.horizontal, 6).padding(.vertical, 2) .background(Color.orange.opacity(0.15)) .foregroundStyle(.orange) .cornerRadius(4) } } Text(agent.model) .font(.caption) .foregroundStyle(.secondary) } Spacer() Button { editContext = AgentEditContext(agent: agent) } label: { Label("Edit", systemImage: "pencil") } .buttonStyle(.bordered) .controlSize(.small) Button(role: .destructive) { Task { await deleteAgent(agent) } } label: { Image(systemName: "trash") } .buttonStyle(.bordered) .controlSize(.small) } if !agent.description.isEmpty { Text(agent.description) .font(.callout) .foregroundStyle(.secondary) } if let schedule = agent.schedule, !schedule.isEmpty { Label(schedule, systemImage: "clock") .font(.caption) .foregroundStyle(.secondary) } } .padding(16) Divider() // Prompt preview DisclosureGroup("Prompt") { ScrollView { Text(agent.prompt) .font(.system(size: 12, design: .monospaced)) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) .padding(8) } .frame(maxHeight: 120) .background(Color.gray.opacity(0.06)) .cornerRadius(6) .padding(.horizontal, 16) .padding(.bottom, 8) } .padding(.horizontal, 16) .padding(.top, 8) Divider() // Run history Text("Run History") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.secondary) .padding(.horizontal, 16) .padding(.top, 10) .padding(.bottom, 4) let agentRuns = runs[agent.id] ?? [] if agentRuns.isEmpty { Text("No runs yet") .font(.callout) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } else { List(agentRuns) { run in RunHistoryRow(run: run) } .listStyle(.plain) } } .task(id: agent.id) { await loadRuns(for: agent) } } // MARK: - Usage Tab private var usageTab: some View { VStack(spacing: 0) { // Credits card if let creds = credits, let balance = creds.remainingBalance { HStack(spacing: 16) { VStack(alignment: .leading, spacing: 4) { Text("OpenRouter Balance") .font(.caption) .foregroundStyle(.secondary) Text(String(format: "$%.4f", balance)) .font(.system(size: 22, weight: .semibold, design: .monospaced)) .foregroundStyle(balance < 1.0 ? .orange : .primary) } Spacer() if let total = creds.totalCredits { VStack(alignment: .trailing, spacing: 4) { Text("Total Credits") .font(.caption).foregroundStyle(.secondary) Text(String(format: "$%.2f", total)) .font(.callout).foregroundStyle(.secondary) } } if let used = creds.totalUsage { VStack(alignment: .trailing, spacing: 4) { Text("Total Used") .font(.caption).foregroundStyle(.secondary) Text(String(format: "$%.4f", used)) .font(.callout).foregroundStyle(.secondary) } } } .padding(16) .background(Color.blue.opacity(0.06)) .cornerRadius(10) .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 8) } if isLoadingUsage { ProgressView("Loading usage…") .frame(maxWidth: .infinity, maxHeight: .infinity) } else if usageStats.isEmpty { ContentUnavailableView("No Usage Data", systemImage: "chart.bar", description: Text("Run some agents to see usage statistics")) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { // Header row HStack { Text("Agent") .frame(maxWidth: .infinity, alignment: .leading) Text("Runs") .frame(width: 50, alignment: .trailing) Text("Input") .frame(width: 80, alignment: .trailing) Text("Output") .frame(width: 80, alignment: .trailing) Text("Cost") .frame(width: 80, alignment: .trailing) } .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) .padding(.horizontal, 16) .padding(.vertical, 6) Divider() ScrollView { VStack(spacing: 0) { ForEach(usageStats) { stat in usageRow(stat) Divider().padding(.leading, 16) } // Totals row let totalRuns = usageStats.compactMap(\.runCount).reduce(0, +) let totalInput = usageStats.compactMap(\.totalInputTokens).reduce(0, +) let totalOutput = usageStats.compactMap(\.totalOutputTokens).reduce(0, +) let totalCost = usageStats.compactMap(\.totalCostUsd).reduce(0, +) HStack { Text("Total") .font(.system(size: 13, weight: .semibold)) .frame(maxWidth: .infinity, alignment: .leading) Text("\(totalRuns)") .frame(width: 50, alignment: .trailing) .font(.system(size: 13, weight: .semibold)) Text(formatTokens(totalInput)) .frame(width: 80, alignment: .trailing) .font(.system(size: 13, weight: .semibold)) Text(formatTokens(totalOutput)) .frame(width: 80, alignment: .trailing) .font(.system(size: 13, weight: .semibold)) Text(String(format: "$%.4f", totalCost)) .frame(width: 80, alignment: .trailing) .font(.system(size: 13, weight: .semibold, design: .monospaced)) } .padding(.horizontal, 16) .padding(.vertical, 10) .background(Color.gray.opacity(0.07)) } } HStack { Spacer() Button { Task { await loadUsage() } } label: { Label("Refresh", systemImage: "arrow.clockwise") } .buttonStyle(.borderless) .font(.caption) .foregroundStyle(.secondary) } .padding(.horizontal, 16) .padding(.vertical, 6) } } } private func usageRow(_ stat: JarvisUsageStat) -> some View { HStack { Text(stat.displayName) .frame(maxWidth: .infinity, alignment: .leading) .lineLimit(1) Text("\(stat.runCount ?? 0)") .frame(width: 50, alignment: .trailing) .foregroundStyle(.secondary) Text(formatTokens(stat.totalInputTokens ?? 0)) .frame(width: 80, alignment: .trailing) .foregroundStyle(.secondary) Text(formatTokens(stat.totalOutputTokens ?? 0)) .frame(width: 80, alignment: .trailing) .foregroundStyle(.secondary) Text(stat.totalCostUsd.map { String(format: "$%.4f", $0) } ?? "—") .frame(width: 80, alignment: .trailing) .font(.system(size: 13, design: .monospaced)) } .font(.system(size: 13)) .padding(.horizontal, 16) .padding(.vertical, 8) } // MARK: - Queue Status Badge private func queueStatusBadge(_ qs: JarvisQueueStatus) -> some View { HStack(spacing: 6) { if qs.paused == true { Label("Paused", systemImage: "pause.fill") .foregroundStyle(.orange) } else if let running = qs.runningCount, running > 0 { Label("^[\(running) running](inflect: true)", systemImage: "circle.fill") .foregroundStyle(.green) } else { Label("Idle", systemImage: "checkmark.circle") .foregroundStyle(.secondary) } } .font(.caption) .padding(.horizontal, 10) .padding(.vertical, 4) .background(Color.gray.opacity(0.1)) .cornerRadius(6) } // MARK: - Data Loading private func loadAll() async { await withTaskGroup(of: Void.self) { group in group.addTask { await self.loadAgents() } group.addTask { await self.loadUsage() } group.addTask { await self.loadQueueStatus() } } } private func loadAgents() async { isLoadingAgents = true do { let fetched = try await service.listAgents() agents = fetched if let current = selectedAgent { selectedAgent = fetched.first(where: { $0.id == current.id }) } } catch { errorMessage = error.localizedDescription } isLoadingAgents = false } private func loadRuns(for agent: JarvisAgent) async { do { let fetched = try await service.agentRuns(id: agent.id) runs[agent.id] = fetched } catch { errorMessage = error.localizedDescription } } private func loadUsage() async { isLoadingUsage = true do { async let statsTask = service.usage() async let creditsTask = service.credits() usageStats = try await statsTask credits = try? await creditsTask } catch { errorMessage = error.localizedDescription } isLoadingUsage = false } private func loadQueueStatus() async { queueStatus = try? await service.queueStatus() } // MARK: - Agent Actions private func toggleAgent(_ agent: JarvisAgent) async { do { let updated = try await service.toggleAgent(id: agent.id) updateAgent(updated) } catch { errorMessage = error.localizedDescription } } private func runAgent(_ agent: JarvisAgent) async { actionInProgress.insert(agent.id) do { try await service.runAgent(id: agent.id) try? await Task.sleep(for: .seconds(1)) await loadAgents() } catch { errorMessage = error.localizedDescription } actionInProgress.remove(agent.id) } private func stopAgent(_ agent: JarvisAgent) async { do { try await service.stopAgent(id: agent.id) try? await Task.sleep(for: .seconds(1)) await loadAgents() } catch { errorMessage = error.localizedDescription } } private func deleteAgent(_ agent: JarvisAgent) async { do { try await service.deleteAgent(id: agent.id) agents.removeAll { $0.id == agent.id } if selectedAgent?.id == agent.id { selectedAgent = nil } } catch { errorMessage = error.localizedDescription } } private func saveAgent(existing: JarvisAgent?, input: JarvisAgentInput) async { do { let result: JarvisAgent if let existing { result = try await service.updateAgent(id: existing.id, input) } else { result = try await service.createAgent(input) } updateAgent(result) selectedAgent = result } catch { errorMessage = error.localizedDescription } } private func updateAgent(_ updated: JarvisAgent) { if let idx = agents.firstIndex(where: { $0.id == updated.id }) { agents[idx] = updated } else { agents.append(updated) } if selectedAgent?.id == updated.id { selectedAgent = updated } } // MARK: - Formatting private func formatTokens(_ n: Int) -> String { if n >= 1_000_000 { return String(format: "%.1fM", Double(n) / 1_000_000) } if n >= 1_000 { return String(format: "%.1fK", Double(n) / 1_000) } return "\(n)" } } // MARK: - Run History Row private struct RunHistoryRow: View { let run: JarvisAgentRun @State private var expanded = false var body: some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { statusIcon VStack(alignment: .leading, spacing: 1) { HStack(spacing: 6) { Text(run.formattedStarted) .font(.system(size: 12)) if let dur = run.formattedDuration { Text("· \(dur)") .font(.system(size: 12)) .foregroundStyle(.secondary) } } HStack(spacing: 8) { if run.totalTokens > 0 { Text("^[\(run.totalTokens) token](inflect: true)") .font(.caption2) .foregroundStyle(.secondary) } if let cost = run.costUsd, cost > 0 { Text(String(format: "$%.5f", cost)) .font(.caption2.monospaced()) .foregroundStyle(.secondary) } } } Spacer() if run.output != nil || run.error != nil { Button { withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() } } label: { Image(systemName: expanded ? "chevron.up" : "chevron.down") .font(.caption2) .foregroundStyle(.secondary) } .buttonStyle(.plain) } } if expanded { if let err = run.error { Text(err) .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.red) .padding(8) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.red.opacity(0.06)) .cornerRadius(6) .textSelection(.enabled) } else if let out = run.output { Text(out) .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.primary) .padding(8) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.gray.opacity(0.07)) .cornerRadius(6) .lineLimit(20) .textSelection(.enabled) } } } .padding(.vertical, 4) } @ViewBuilder private var statusIcon: some View { switch run.status { case "running": ProgressView().scaleEffect(0.6).frame(width: 14, height: 14) case "completed": Image(systemName: "checkmark.circle.fill") .font(.system(size: 14)) .foregroundStyle(.green) case "failed": Image(systemName: "xmark.circle.fill") .font(.system(size: 14)) .foregroundStyle(.red) case "stopped": Image(systemName: "stop.circle.fill") .font(.system(size: 14)) .foregroundStyle(.orange) default: Image(systemName: "circle") .font(.system(size: 14)) .foregroundStyle(.secondary) } } } // MARK: - Agent Editor Sheet struct JarvisAgentEditorSheet: View { let agent: JarvisAgent? let onSave: (JarvisAgentInput) async -> Void @Environment(\.dismiss) var dismiss @State private var name: String @State private var description: String @State private var model: String @State private var prompt: String @State private var schedule: String @State private var enabled: Bool @State private var maxToolCalls: String @State private var isSaving = false init(agent: JarvisAgent?, onSave: @escaping (JarvisAgentInput) async -> Void) { self.agent = agent self.onSave = onSave _name = State(initialValue: agent?.name ?? "") _description = State(initialValue: agent?.description ?? "") _model = State(initialValue: agent?.model ?? "") _prompt = State(initialValue: agent?.prompt ?? "") _schedule = State(initialValue: agent?.schedule ?? "") _enabled = State(initialValue: agent?.enabled ?? true) _maxToolCalls = State(initialValue: agent.flatMap(\.maxToolCalls).map(String.init) ?? "") } var isValid: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty && !model.trimmingCharacters(in: .whitespaces).isEmpty } var body: some View { VStack(spacing: 0) { // Header HStack { Text(agent == nil ? "New Agent" : "Edit Agent") .font(.system(size: 16, weight: .semibold)) Spacer() Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") .font(.title3).foregroundStyle(.secondary) } .buttonStyle(.plain) } .padding(.horizontal, 20) .padding(.top, 16) .padding(.bottom, 12) Divider() ScrollView { VStack(alignment: .leading, spacing: 16) { Group { editorField("Name") { TextField("Agent name", text: $name) .textFieldStyle(.roundedBorder) } editorField("Description") { TextField("Optional description", text: $description) .textFieldStyle(.roundedBorder) } editorField("Model") { TextField("e.g. anthropic/claude-sonnet-4-5", text: $model) .textFieldStyle(.roundedBorder) } editorField("Schedule") { TextField("Cron expression (e.g. 0 * * * *)", text: $schedule) .textFieldStyle(.roundedBorder) } editorField("Max Tool Calls") { TextField("Leave blank for default", text: $maxToolCalls) .textFieldStyle(.roundedBorder) .frame(maxWidth: 160) } editorField("Enabled") { Toggle("", isOn: $enabled) .toggleStyle(.switch) .labelsHidden() } } VStack(alignment: .leading, spacing: 6) { Text("Prompt") .font(.system(size: 13, weight: .medium)) TextEditor(text: $prompt) .font(.system(size: 13, design: .monospaced)) .frame(minHeight: 160) .overlay( RoundedRectangle(cornerRadius: 6) .strokeBorder(Color.gray.opacity(0.3)) ) } } .padding(20) } Divider() HStack(spacing: 10) { Spacer() Button("Cancel") { dismiss() } .keyboardShortcut(.escape, modifiers: []) Button { Task { isSaving = true let input = JarvisAgentInput( name: name.trimmingCharacters(in: .whitespaces), prompt: prompt, model: model.trimmingCharacters(in: .whitespaces), description: description, enabled: enabled, schedule: schedule.isEmpty ? nil : schedule, maxToolCalls: Int(maxToolCalls) ) await onSave(input) isSaving = false dismiss() } } label: { if isSaving { ProgressView().scaleEffect(0.8).frame(width: 16, height: 16) } else { Text(agent == nil ? "Create" : "Save") } } .buttonStyle(.borderedProminent) .disabled(!isValid || isSaving) .keyboardShortcut(.return, modifiers: .command) } .padding(.horizontal, 20) .padding(.vertical, 12) } .frame(minWidth: 560, idealWidth: 620, minHeight: 560, idealHeight: 700) } private func editorField(_ label: LocalizedStringKey, @ViewBuilder content: () -> Content) -> some View { HStack(alignment: .firstTextBaseline, spacing: 12) { Text(label) .font(.system(size: 13)) .frame(width: 100, alignment: .trailing) content() } } }