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:
2026-08-07 13:31:39 +02:00
parent 91f67f891b
commit 1925c9c657
3 changed files with 256 additions and 64 deletions
+4 -3
View File
@@ -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"
+165 -41
View File
@@ -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,10 +634,8 @@ 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) {
@@ -658,39 +662,13 @@ private struct RunHistoryRow: View {
} }
} }
Spacer() 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 { if run.output != nil || run.error != nil {
Button { Image(systemName: "chevron.right")
withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
} label: {
Image(systemName: expanded ? "chevron.up" : "chevron.down")
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.tertiary)
}
.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) .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 {
+67
View File
@@ -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")
}
}