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
+185 -61
View File
@@ -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<String> = []
@@ -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<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 {