From 1925c9c657b819e1643a2a128a8a4d573edb99e5 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Fri, 7 Aug 2026 13:31:39 +0200 Subject: [PATCH] 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. --- oAI/Models/JarvisModels.swift | 7 +- oAI/Views/Screens/JarvisView.swift | 246 ++++++++++++++++++++++------- oAITests/JarvisModelsTests.swift | 67 ++++++++ 3 files changed, 256 insertions(+), 64 deletions(-) create mode 100644 oAITests/JarvisModelsTests.swift diff --git a/oAI/Models/JarvisModels.swift b/oAI/Models/JarvisModels.swift index 33fe407..27ba1f4 100644 --- a/oAI/Models/JarvisModels.swift +++ b/oAI/Models/JarvisModels.swift @@ -69,7 +69,7 @@ struct JarvisAgentInput: Codable, Sendable { struct JarvisAgentRun: Identifiable, Codable, Sendable { let id: 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 finishedAt: String? let output: String? @@ -80,10 +80,11 @@ struct JarvisAgentRun: Identifiable, Codable, Sendable { let triggerType: String? enum CodingKeys: String, CodingKey { - case id, status, output, error + case id, status, error case agentId = "agent_id" case startedAt = "started_at" - case finishedAt = "finished_at" + case finishedAt = "ended_at" + case output = "result" case costUsd = "cost_usd" case inputTokens = "input_tokens" case outputTokens = "output_tokens" diff --git a/oAI/Views/Screens/JarvisView.swift b/oAI/Views/Screens/JarvisView.swift index 411cf2a..b2e34a6 100644 --- a/oAI/Views/Screens/JarvisView.swift +++ b/oAI/Views/Screens/JarvisView.swift @@ -30,6 +30,7 @@ struct JarvisView: View { @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 = [] @@ -91,6 +92,9 @@ struct JarvisView: View { await saveAgent(existing: ctx.agent, input: input) }) } + .sheet(item: $selectedRun) { run in + JarvisRunDetailSheet(run: run) + } } // MARK: - Agents Tab @@ -323,6 +327,8 @@ struct JarvisView: View { } else { List(agentRuns) { run in RunHistoryRow(run: run) + .contentShape(Rectangle()) + .onTapGesture { selectedRun = run } } .listStyle(.plain) } @@ -628,69 +634,41 @@ struct JarvisView: View { 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) + 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)) - 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) - } + .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") + HStack(spacing: 8) { + if run.totalTokens > 0 { + Text("^[\(run.totalTokens) token](inflect: true)") .font(.caption2) .foregroundStyle(.secondary) } - .buttonStyle(.plain) + if let cost = run.costUsd, cost > 0 { + Text(String(format: "$%.5f", cost)) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + } } } - - 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) - } + 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) @@ -698,29 +676,175 @@ private struct RunHistoryRow: View { @ViewBuilder 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": - ProgressView().scaleEffect(0.6).frame(width: 14, height: 14) - case "completed": + ProgressView().scaleEffect(size / 24).frame(width: size, height: size) + case "completed", "success": Image(systemName: "checkmark.circle.fill") - .font(.system(size: 14)) + .font(.system(size: size)) .foregroundStyle(.green) - case "failed": + case "failed", "error": Image(systemName: "xmark.circle.fill") - .font(.system(size: 14)) + .font(.system(size: size)) .foregroundStyle(.red) case "stopped": Image(systemName: "stop.circle.fill") - .font(.system(size: 14)) + .font(.system(size: size)) .foregroundStyle(.orange) default: Image(systemName: "circle") - .font(.system(size: 14)) + .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) -> 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) { + 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 { diff --git a/oAITests/JarvisModelsTests.swift b/oAITests/JarvisModelsTests.swift new file mode 100644 index 0000000..e3165d1 --- /dev/null +++ b/oAITests/JarvisModelsTests.swift @@ -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") + } +}