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 {
|
||||
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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user