13699864d8
- Jarvis integration: manage oAI-Web agents and usage from inside the app (/jarvis command, Settings tab 11) - Model category filter: keyword-based categorisation with popover picker in model selector - Categories shown in ModelInfoView with coloured chips; dot indicators on model rows Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
867 lines
32 KiB
Swift
867 lines
32 KiB
Swift
//
|
|
// 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<String> = []
|
|
|
|
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<Content: View>(_ label: LocalizedStringKey, @ViewBuilder content: () -> Content) -> some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
|
Text(label)
|
|
.font(.system(size: 13))
|
|
.frame(width: 100, alignment: .trailing)
|
|
content()
|
|
}
|
|
}
|
|
}
|