Files
oai-swift/oAI/Views/Screens/JarvisView.swift
T
rune 1925c9c657 Add clickable run detail view to Jarvis run history; fix field mismatches
Each row in the Run History list now opens a Run Details sheet showing
the full, untruncated output/error (the old inline chevron-expand
capped output at 20 lines) plus duration, trigger, tokens, and cost.
Output/error use an explicit Copy button rather than
.textSelection(.enabled), to avoid the same Escape-beeps-instead-of-
dismissing bug just fixed in ModelInfoView.

While wiring this up, found the JarvisAgentRun model didn't match the
real oAI-Web API response shape: output decoded from a nonexistent
"output" key instead of "result" (always nil, hence "No output for
this run" even on successful runs with real content), finishedAt
decoded from "finished_at" instead of "ended_at" (Duration silently
never showed), and the status icon only recognized "completed"/
"failed" instead of the API's actual "success"/"error" values (plain
gray circle instead of a green checkmark). Fixed all three, verified
against a real API response, added 4 decoding tests.
2026-08-07 13:31:39 +02:00

991 lines
37 KiB
Swift

//
// JarvisView.swift
// Confab
//
// Main modal for managing Jarvis (oAI-Web) agents and usage.
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// 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 selectedRun: JarvisAgentRun? = 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)
})
}
.sheet(item: $selectedRun) { run in
JarvisRunDetailSheet(run: run)
}
}
// 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)
.contentShape(Rectangle())
.onTapGesture { selectedRun = 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
var body: some View {
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()
// Tapping the row (wired by the caller) opens JarvisRunDetailSheet with the full,
// untruncated output/error — this used to be an inline chevron-expand capped at
// lineLimit(20), which isn't "complete" for long output.
if run.output != nil || run.error != nil {
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(.vertical, 4)
}
@ViewBuilder
private var statusIcon: some View {
JarvisRunStatusIcon(status: run.status, size: 14)
}
}
/// Shared between RunHistoryRow and JarvisRunDetailSheet so the two views can't drift apart.
fileprivate struct JarvisRunStatusIcon: View {
let status: String
var size: CGFloat = 14
var body: some View {
switch status {
case "running":
ProgressView().scaleEffect(size / 24).frame(width: size, height: size)
case "completed", "success":
Image(systemName: "checkmark.circle.fill")
.font(.system(size: size))
.foregroundStyle(.green)
case "failed", "error":
Image(systemName: "xmark.circle.fill")
.font(.system(size: size))
.foregroundStyle(.red)
case "stopped":
Image(systemName: "stop.circle.fill")
.font(.system(size: size))
.foregroundStyle(.orange)
default:
Image(systemName: "circle")
.font(.system(size: size))
.foregroundStyle(.secondary)
}
}
}
// MARK: - Run Detail Sheet
/// Full, untruncated view of a single run — opened by tapping a row in the run history list.
/// Output/error use an explicit Copy button rather than .textSelection(.enabled): a multi-line
/// selectable Text can grab real AppKit first-responder status, and Escape then gets consumed by
/// its own cancelOperation: handling instead of dismissing the sheet (silent beep, no log) — see
/// the identical bug fixed in ModelInfoView.
struct JarvisRunDetailSheet: View {
let run: JarvisAgentRun
@Environment(\.dismiss) var dismiss
@State private var showOutputCopied = false
@State private var showErrorCopied = false
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Run Details")
.font(.system(size: 18, weight: .bold))
Spacer()
JarvisRunStatusIcon(status: run.status, size: 16)
Text(run.status.capitalized)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.padding(.leading, 8)
.keyboardShortcut(.escape, modifiers: [])
}
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 12)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
detailRow("Started", run.formattedStarted)
if let dur = run.formattedDuration {
detailRow("Duration", dur)
}
if let trigger = run.triggerType {
detailRow("Trigger", trigger.capitalized)
}
if run.totalTokens > 0 {
detailRow("Tokens", "\(run.totalTokens.formatted()) (\((run.inputTokens ?? 0).formatted()) in / \((run.outputTokens ?? 0).formatted()) out)")
}
if let cost = run.costUsd, cost > 0 {
detailRow("Cost", String(format: "$%.5f", cost))
}
}
if let err = run.error {
Divider()
outputBlock(title: "Error", text: err, color: .red, showCopied: $showErrorCopied)
}
if let out = run.output {
Divider()
outputBlock(title: "Output", text: out, color: .primary, showCopied: $showOutputCopied)
}
if run.output == nil && run.error == nil {
Text("No output for this run.")
.font(.callout)
.foregroundStyle(.secondary)
}
}
.padding(24)
}
}
.frame(minWidth: 600, idealWidth: 700, minHeight: 450, idealHeight: 620)
.onExitCommand { dismiss() }
}
private func detailRow(_ label: String, _ value: String) -> some View {
HStack {
Text(label)
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 90, alignment: .leading)
Text(value)
.font(.system(size: 13))
Spacer()
}
}
@ViewBuilder
private func outputBlock(title: String, text: String, color: Color, showCopied: Binding<Bool>) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Text(title)
.font(.subheadline.weight(.medium))
.foregroundColor(.secondary)
Spacer()
Button(action: { copy(text, showCopied: showCopied) }) {
HStack(spacing: 3) {
Image(systemName: showCopied.wrappedValue ? "checkmark" : "doc.on.doc")
.font(.system(size: 11))
if showCopied.wrappedValue {
Text("Copied!")
.font(.system(size: 11))
}
}
.foregroundColor(showCopied.wrappedValue ? .green : .secondary)
}
.buttonStyle(.plain)
}
Text(text)
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(color)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(color.opacity(0.06))
.cornerRadius(6)
}
}
private func copy(_ text: String, showCopied: Binding<Bool>) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
withAnimation {
showCopied.wrappedValue = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation {
showCopied.wrappedValue = false
}
}
}
}
// 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()
}
}
}