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.
This commit is contained in:
@@ -69,7 +69,7 @@ struct JarvisAgentInput: Codable, Sendable {
|
|||||||
struct JarvisAgentRun: Identifiable, Codable, Sendable {
|
struct JarvisAgentRun: Identifiable, Codable, Sendable {
|
||||||
let id: String
|
let id: String
|
||||||
let agentId: String?
|
let agentId: String?
|
||||||
let status: String // "running" | "completed" | "failed" | "stopped"
|
let status: String // "running" | "success" | "failed"/"error" | "stopped" (server-observed; not formally documented)
|
||||||
let startedAt: String?
|
let startedAt: String?
|
||||||
let finishedAt: String?
|
let finishedAt: String?
|
||||||
let output: String?
|
let output: String?
|
||||||
@@ -80,10 +80,11 @@ struct JarvisAgentRun: Identifiable, Codable, Sendable {
|
|||||||
let triggerType: String?
|
let triggerType: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, status, output, error
|
case id, status, error
|
||||||
case agentId = "agent_id"
|
case agentId = "agent_id"
|
||||||
case startedAt = "started_at"
|
case startedAt = "started_at"
|
||||||
case finishedAt = "finished_at"
|
case finishedAt = "ended_at"
|
||||||
|
case output = "result"
|
||||||
case costUsd = "cost_usd"
|
case costUsd = "cost_usd"
|
||||||
case inputTokens = "input_tokens"
|
case inputTokens = "input_tokens"
|
||||||
case outputTokens = "output_tokens"
|
case outputTokens = "output_tokens"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ struct JarvisView: View {
|
|||||||
@State private var isLoadingUsage = false
|
@State private var isLoadingUsage = false
|
||||||
@State private var selectedAgent: JarvisAgent? = nil
|
@State private var selectedAgent: JarvisAgent? = nil
|
||||||
@State private var editContext: AgentEditContext? = nil
|
@State private var editContext: AgentEditContext? = nil
|
||||||
|
@State private var selectedRun: JarvisAgentRun? = nil
|
||||||
@State private var errorMessage: String? = nil
|
@State private var errorMessage: String? = nil
|
||||||
@State private var actionInProgress: Set<String> = []
|
@State private var actionInProgress: Set<String> = []
|
||||||
|
|
||||||
@@ -91,6 +92,9 @@ struct JarvisView: View {
|
|||||||
await saveAgent(existing: ctx.agent, input: input)
|
await saveAgent(existing: ctx.agent, input: input)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
.sheet(item: $selectedRun) { run in
|
||||||
|
JarvisRunDetailSheet(run: run)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Agents Tab
|
// MARK: - Agents Tab
|
||||||
@@ -323,6 +327,8 @@ struct JarvisView: View {
|
|||||||
} else {
|
} else {
|
||||||
List(agentRuns) { run in
|
List(agentRuns) { run in
|
||||||
RunHistoryRow(run: run)
|
RunHistoryRow(run: run)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { selectedRun = run }
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
}
|
}
|
||||||
@@ -628,69 +634,41 @@ struct JarvisView: View {
|
|||||||
|
|
||||||
private struct RunHistoryRow: View {
|
private struct RunHistoryRow: View {
|
||||||
let run: JarvisAgentRun
|
let run: JarvisAgentRun
|
||||||
@State private var expanded = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
HStack(spacing: 8) {
|
||||||
HStack(spacing: 8) {
|
statusIcon
|
||||||
statusIcon
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
HStack(spacing: 6) {
|
||||||
HStack(spacing: 6) {
|
Text(run.formattedStarted)
|
||||||
Text(run.formattedStarted)
|
.font(.system(size: 12))
|
||||||
|
if let dur = run.formattedDuration {
|
||||||
|
Text("· \(dur)")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
if let dur = run.formattedDuration {
|
.foregroundStyle(.secondary)
|
||||||
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()
|
HStack(spacing: 8) {
|
||||||
if run.output != nil || run.error != nil {
|
if run.totalTokens > 0 {
|
||||||
Button {
|
Text("^[\(run.totalTokens) token](inflect: true)")
|
||||||
withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
|
|
||||||
} label: {
|
|
||||||
Image(systemName: expanded ? "chevron.up" : "chevron.down")
|
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
if let cost = run.costUsd, cost > 0 {
|
||||||
|
Text(String(format: "$%.5f", cost))
|
||||||
|
.font(.caption2.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer()
|
||||||
if expanded {
|
// Tapping the row (wired by the caller) opens JarvisRunDetailSheet with the full,
|
||||||
if let err = run.error {
|
// untruncated output/error — this used to be an inline chevron-expand capped at
|
||||||
Text(err)
|
// lineLimit(20), which isn't "complete" for long output.
|
||||||
.font(.system(size: 11, design: .monospaced))
|
if run.output != nil || run.error != nil {
|
||||||
.foregroundStyle(.red)
|
Image(systemName: "chevron.right")
|
||||||
.padding(8)
|
.font(.caption2)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.foregroundStyle(.tertiary)
|
||||||
.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)
|
.padding(.vertical, 4)
|
||||||
@@ -698,29 +676,175 @@ private struct RunHistoryRow: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var statusIcon: some View {
|
private var statusIcon: some View {
|
||||||
switch run.status {
|
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":
|
case "running":
|
||||||
ProgressView().scaleEffect(0.6).frame(width: 14, height: 14)
|
ProgressView().scaleEffect(size / 24).frame(width: size, height: size)
|
||||||
case "completed":
|
case "completed", "success":
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.font(.system(size: 14))
|
.font(.system(size: size))
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
case "failed":
|
case "failed", "error":
|
||||||
Image(systemName: "xmark.circle.fill")
|
Image(systemName: "xmark.circle.fill")
|
||||||
.font(.system(size: 14))
|
.font(.system(size: size))
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
case "stopped":
|
case "stopped":
|
||||||
Image(systemName: "stop.circle.fill")
|
Image(systemName: "stop.circle.fill")
|
||||||
.font(.system(size: 14))
|
.font(.system(size: size))
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
default:
|
default:
|
||||||
Image(systemName: "circle")
|
Image(systemName: "circle")
|
||||||
.font(.system(size: 14))
|
.font(.system(size: size))
|
||||||
.foregroundStyle(.secondary)
|
.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
|
// MARK: - Agent Editor Sheet
|
||||||
|
|
||||||
struct JarvisAgentEditorSheet: View {
|
struct JarvisAgentEditorSheet: View {
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
//
|
||||||
|
// JarvisModelsTests.swift
|
||||||
|
// oAITests
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import Confab
|
||||||
|
|
||||||
|
@Suite("JarvisAgentRun decoding")
|
||||||
|
struct JarvisModelsTests {
|
||||||
|
|
||||||
|
// Real shape returned by the oAI-Web /api/agents/{id}/runs endpoint — confirmed by Rune
|
||||||
|
// against a live run. Guards against the "result"/"output" and "ended_at"/"finished_at"
|
||||||
|
// field-name mismatch that shipped in the initial Run Details modal (output always showed
|
||||||
|
// "No output for this run" even when the API had real content).
|
||||||
|
private static let sampleJSON = """
|
||||||
|
{
|
||||||
|
"id": "8628fa26-8ff1-4053-b106-8ba84a0e10e0",
|
||||||
|
"agent_id": "531c884b-a401-4985-8717-bfa6bcc1d148",
|
||||||
|
"started_at": "2026-08-07T10:00:00.135999+00:00",
|
||||||
|
"ended_at": "2026-08-07T10:00:13.815956+00:00",
|
||||||
|
"status": "success",
|
||||||
|
"input_tokens": 25615,
|
||||||
|
"output_tokens": 1233,
|
||||||
|
"cost_usd": 0.031780000776052475,
|
||||||
|
"result": "Infrastructure Status: HEALTHY",
|
||||||
|
"error": null,
|
||||||
|
"model": "anthropic:claude-haiku-4-5-20251001"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
@Test("Decodes the API's 'result' field into .output")
|
||||||
|
func decodesResultIntoOutput() throws {
|
||||||
|
let run = try JSONDecoder().decode(JarvisAgentRun.self, from: Data(Self.sampleJSON.utf8))
|
||||||
|
#expect(run.output == "Infrastructure Status: HEALTHY")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Decodes the API's 'ended_at' field into .finishedAt")
|
||||||
|
func decodesEndedAtIntoFinishedAt() throws {
|
||||||
|
let run = try JSONDecoder().decode(JarvisAgentRun.self, from: Data(Self.sampleJSON.utf8))
|
||||||
|
#expect(run.finishedAt == "2026-08-07T10:00:13.815956+00:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Decodes a full real run without losing any field")
|
||||||
|
func decodesAllFields() throws {
|
||||||
|
let run = try JSONDecoder().decode(JarvisAgentRun.self, from: Data(Self.sampleJSON.utf8))
|
||||||
|
#expect(run.id == "8628fa26-8ff1-4053-b106-8ba84a0e10e0")
|
||||||
|
#expect(run.agentId == "531c884b-a401-4985-8717-bfa6bcc1d148")
|
||||||
|
#expect(run.status == "success")
|
||||||
|
#expect(run.startedAt == "2026-08-07T10:00:00.135999+00:00")
|
||||||
|
#expect(run.inputTokens == 25615)
|
||||||
|
#expect(run.outputTokens == 1233)
|
||||||
|
#expect(run.totalTokens == 26848)
|
||||||
|
#expect(run.costUsd == 0.031780000776052475)
|
||||||
|
#expect(run.error == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A finished run with both started_at and ended_at produces a non-nil duration")
|
||||||
|
func computesDurationFromRealFieldNames() throws {
|
||||||
|
let run = try JSONDecoder().decode(JarvisAgentRun.self, from: Data(Self.sampleJSON.utf8))
|
||||||
|
#expect(run.formattedDuration != nil)
|
||||||
|
#expect(run.formattedDuration == "13s")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user