detailRow's label and outputBlock's title were typed String instead of LocalizedStringKey, so Text(label)/Text(title) inside them could never localize regardless of catalog content — the exact anti-pattern CLAUDE.md's i18n rules warn against for helper functions. Fixed the types, then translated the 6 genuinely-new strings (Run Details, No output for this run., Started, Duration, Trigger, Tokens) into nb/sv/da/de/fr. A few others (Cost, Error, Output, Copied!) were already covered from being reused elsewhere in the catalog.
991 lines
37 KiB
Swift
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: LocalizedStringKey, _ 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: LocalizedStringKey, 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()
|
|
}
|
|
}
|
|
}
|