diff --git a/apps/agentacct/Sources/agentacct/DashboardPane.swift b/apps/agentacct/Sources/agentacct/DashboardPane.swift index c6b59c8..c1a68f9 100644 --- a/apps/agentacct/Sources/agentacct/DashboardPane.swift +++ b/apps/agentacct/Sources/agentacct/DashboardPane.swift @@ -1,3 +1,5 @@ +import AppKit +import Foundation import SwiftUI // The dashboard is a shift brief: what deserves attention now, what recorded @@ -86,6 +88,7 @@ struct DashboardAttentionItem: Identifiable, Equatable { let nextStep: String? let observedAt: Double? let sourceLabel: String? + let handedOff: Bool? init?(task: ReceiptSummary) { guard let reason = task.attention else { return nil } @@ -101,10 +104,18 @@ struct DashboardAttentionItem: Identifiable, Equatable { summary = reason.summary nextStep = reason.nextStep observedAt = reason.observedAt + handedOff = task.handedOff switch reason.source { case "mcp": sourceLabel = "MCP record" - case "client_log": sourceLabel = "Client log" + case "client_log": sourceLabel = "Local client log" case "machine": sourceLabel = "Machine check" + case "hook": sourceLabel = "Client hook" + case "transcript_scan": sourceLabel = "Transcript import" + case "ci": sourceLabel = "External CI or provider" + case "git": sourceLabel = "Git repository" + case "human": sourceLabel = "Human record" + case "inferred": sourceLabel = "agentacct inference" + case "none": sourceLabel = "No source recorded" case .some(let source): sourceLabel = source.replacingOccurrences(of: "_", with: " ").capitalized case nil: sourceLabel = nil } @@ -122,6 +133,93 @@ struct DashboardAttentionItem: Identifiable, Equatable { var recency: String? { agoText(observedAt) } } +/// A paste-ready brief assembled only from fields the daemon recorded. It +/// never guesses a recovery step and never implies that copying changes agent +/// state. A handoff marker changes the framing, not the underlying facts. +struct DashboardActionBrief: Equatable { + enum Kind: Equatable { + case review + case continuation + } + + let kind: Kind + let text: String + + init(focus: DashboardAttentionItem) { + kind = focus.handedOff == true ? .continuation : .review + + var lines = [ + focus.handedOff == true ? "Continuation brief" : "Review brief", + "Task: \(focus.title)", + "Task ID: \(focus.id)", + ] + if let project = focus.project, !project.isEmpty { + lines.append("Project: \(project)") + } + if let client = focus.client, !client.isEmpty { + lines.append("Agent: \(client)") + } + lines.append("Recorded attention: \(focus.reasonLabel) — \(focus.summary)") + lines.append("Recorded next step: \(focus.nextStep ?? "None recorded")") + lines.append("Observed: \(Self.timestamp(focus.observedAt) ?? "Not recorded")") + lines.append("Provenance: \(focus.sourceLabel ?? "Not recorded")") + text = lines.joined(separator: "\n") + } + + var buttonTitle: String { + switch kind { + case .review: return "Copy review brief" + case .continuation: return "Copy continuation brief" + } + } + + var copiedAccessibilityLabel: String { + switch kind { + case .review: return "Review brief copied" + case .continuation: return "Continuation brief copied" + } + } + + var failedAccessibilityLabel: String { + switch kind { + case .review: return "Review brief copy failed" + case .continuation: return "Continuation brief copy failed" + } + } + + private static func timestamp(_ epoch: Double?) -> String? { + guard let epoch else { return nil } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.string(from: Date(timeIntervalSince1970: epoch)) + } +} + +@MainActor +enum DashboardClipboard { + static func copy( + _ text: String, + to pasteboard: NSPasteboard = .general + ) -> Bool { + pasteboard.clearContents() + return pasteboard.setString(text, forType: .string) + } +} + +enum DashboardCopyFeedback: Equatable { + case idle + case copied(String) + case failed(String) + + mutating func record(succeeded: Bool, text: String) { + self = succeeded ? .copied(text) : .failed(text) + } + + mutating func clear() { + self = .idle + } +} + enum DashboardAttentionPresentation: Equatable { case loading case unavailable(String) @@ -483,6 +581,8 @@ private struct DashboardAttentionBriefCard: View { let payload: V1AttentionPayload? let error: String? let open: (DashboardDestination) -> Void + @State private var copyFeedback = DashboardCopyFeedback.idle + @State private var copyFeedbackToken: UUID? private var presentation: DashboardAttentionPresentation { DashboardAttentionPresentation(payload: payload, error: error) @@ -546,7 +646,10 @@ private struct DashboardAttentionBriefCard: View { } private func focusContent(total: Int, focus: DashboardAttentionItem) -> some View { - VStack(alignment: .leading, spacing: Space.l) { + let brief = DashboardActionBrief(focus: focus) + let copySucceeded = copyFeedback == .copied(brief.text) + let copyFailed = copyFeedback == .failed(brief.text) + return VStack(alignment: .leading, spacing: Space.l) { HStack(spacing: Space.s) { Text("PRIMARY ATTENTION") .font(Type.labelCaps) @@ -597,16 +700,55 @@ private struct DashboardAttentionBriefCard: View { .frame(maxWidth: .infinity, alignment: .leading) .background(Theme.tintNeutral, in: RoundedRectangle(cornerRadius: Metrics.radius, style: .continuous)) - Button { - open(.attentionTask(focus.id)) - } label: { - Label("Review evidence", systemImage: "doc.text.magnifyingglass") + HStack(spacing: Space.s) { + Button { + open(.attentionTask(focus.id)) + } label: { + Label("Review evidence", systemImage: "doc.text.magnifyingglass") + .font(Type.captionSemibold) + } + .buttonStyle(.borderedProminent) + .tint(Theme.accent) + .accessibilityHint("Opens this task in Work") + .accessibilityIdentifier("dashboard.shift-brief.review-evidence") + + Button { + let feedbackToken = UUID() + copyFeedbackToken = feedbackToken + copyFeedback.record( + succeeded: DashboardClipboard.copy(brief.text), + text: brief.text + ) + Task { @MainActor in + try? await Task.sleep(for: .seconds(2)) + guard copyFeedbackToken == feedbackToken else { return } + copyFeedback.clear() + copyFeedbackToken = nil + } + } label: { + ZStack { + // Reserve the idle label's full width so copy feedback + // cannot shove the primary action sideways. + Label(brief.buttonTitle, systemImage: "doc.on.doc") + .hidden() + .accessibilityHidden(true) + Label( + copySucceeded ? "Copied" : (copyFailed ? "Copy failed" : brief.buttonTitle), + systemImage: copySucceeded ? "checkmark" : "doc.on.doc" + ) + } .font(Type.captionSemibold) + } + .buttonStyle(.bordered) + .tint(copyFailed ? Theme.coral : Theme.accent) + .accessibilityLabel( + copySucceeded + ? brief.copiedAccessibilityLabel + : (copyFailed ? brief.failedAccessibilityLabel : brief.buttonTitle) + ) + .accessibilityHint("Copies recorded facts only; it does not resume or rerun an agent") + .accessibilityIdentifier("dashboard.shift-brief.copy-action-brief") } - .buttonStyle(.borderedProminent) - .tint(Theme.accent) - .accessibilityHint("Opens this task in Work") - .accessibilityIdentifier("dashboard.shift-brief.review-evidence") } .accessibilityElement(children: .contain) } diff --git a/apps/agentacct/Tests/agentacctTests/DashboardInteractionTests.swift b/apps/agentacct/Tests/agentacctTests/DashboardInteractionTests.swift index 319d921..df2e979 100644 --- a/apps/agentacct/Tests/agentacctTests/DashboardInteractionTests.swift +++ b/apps/agentacct/Tests/agentacctTests/DashboardInteractionTests.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import XCTest @testable import agentacct @@ -85,6 +86,146 @@ final class DashboardInteractionTests: XCTestCase { XCTAssertEqual(focus.sourceLabel, "MCP record") } + func testReviewBriefContainsOnlyRecordedFactsAndNamesMissingNextStep() throws { + let task = try decode( + ReceiptSummary.self, + from: """ + { + "task_id": "task-finding", + "title": "Verify dashboard hierarchy", + "project": "agentacct-gui", + "decision_status": { "key": "finding" }, + "evidence_strength": { "key": "unchecked" }, + "cost": {}, + "primary_root": { "client": "codex", "client_session_id": "session-1" }, + "attention": { + "kind": "failed_check", + "summary": "The reference image changed unexpectedly", + "next_step": null, + "observed_at": 1787889600, + "source": "ci" + } + } + """ + ) + + let focus = try XCTUnwrap(DashboardAttentionItem(task: task)) + let brief = DashboardActionBrief(focus: focus) + + XCTAssertEqual(brief.kind, .review) + XCTAssertEqual(brief.buttonTitle, "Copy review brief") + XCTAssertEqual(brief.copiedAccessibilityLabel, "Review brief copied") + XCTAssertEqual( + brief.text, + """ + Review brief + Task: Verify dashboard hierarchy + Task ID: task-finding + Project: agentacct-gui + Agent: codex + Recorded attention: Failed check — The reference image changed unexpectedly + Recorded next step: None recorded + Observed: 2026-08-28T04:00:00Z + Provenance: External CI or provider + """ + ) + XCTAssertFalse(brief.text.localizedCaseInsensitiveContains("rerun")) + XCTAssertFalse(brief.text.localizedCaseInsensitiveContains("resume")) + } + + func testExpandedProvenanceLabelsPreserveMachineCheckSource() throws { + let task = try decode( + ReceiptSummary.self, + from: """ + { + "task_id": "task-machine-check", + "decision_status": { "key": "finding" }, + "evidence_strength": { "key": "unchecked" }, + "cost": {}, + "attention": { + "kind": "failed_check", + "summary": "Snapshot verification failed", + "source": "machine" + } + } + """ + ) + + let focus = try XCTUnwrap(DashboardAttentionItem(task: task)) + XCTAssertEqual(focus.sourceLabel, "Machine check") + XCTAssertTrue(DashboardActionBrief(focus: focus).text.contains("Provenance: Machine check")) + } + + func testUnknownHandoffStateDoesNotImplyRecoveryOrContinuation() throws { + let task = try decode( + ReceiptSummary.self, + from: """ + { + "task_id": "task-legacy", + "decision_status": { "key": "blocked" }, + "evidence_strength": { "key": "none" }, + "cost": {}, + "attention": { "kind": "blocker", "summary": "Approval is missing" } + } + """ + ) + + let focus = try XCTUnwrap(DashboardAttentionItem(task: task)) + XCTAssertNil(focus.handedOff) + XCTAssertEqual(DashboardActionBrief(focus: focus).kind, .review) + XCTAssertEqual(DashboardActionBrief(focus: focus).buttonTitle, "Copy review brief") + } + + func testHandedOffAttentionProducesContinuationBriefWithoutRewritingAgentGuidance() throws { + let task = try decode( + ReceiptSummary.self, + from: """ + { + "task_id": "task-handoff", + "title": "Handoff dashboard polish", + "decision_status": { "key": "finding" }, + "evidence_strength": { "key": "unchecked" }, + "cost": {}, + "handed_off": true, + "attention": { + "kind": "blocker", + "summary": "Canonical renderer is offline", + "next_step": "Retry when the renderer is available", + "source": "mcp" + } + } + """ + ) + + let focus = try XCTUnwrap(DashboardAttentionItem(task: task)) + let brief = DashboardActionBrief(focus: focus) + + XCTAssertEqual(brief.kind, .continuation) + XCTAssertEqual(brief.buttonTitle, "Copy continuation brief") + XCTAssertEqual(brief.copiedAccessibilityLabel, "Continuation brief copied") + XCTAssertTrue(brief.text.hasPrefix("Continuation brief\n")) + XCTAssertTrue(brief.text.contains("Recorded attention: Recorded blocker — Canonical renderer is offline")) + XCTAssertTrue(brief.text.contains("Recorded next step: Retry when the renderer is available")) + XCTAssertTrue(brief.text.contains("Observed: Not recorded")) + } + + @MainActor + func testActionBriefClipboardBoundaryAndFeedbackStates() { + let pasteboard = NSPasteboard.withUniqueName() + defer { pasteboard.releaseGlobally() } + + XCTAssertTrue(DashboardClipboard.copy("recorded brief", to: pasteboard)) + XCTAssertEqual(pasteboard.string(forType: .string), "recorded brief") + + var feedback = DashboardCopyFeedback.idle + feedback.record(succeeded: true, text: "recorded brief") + XCTAssertEqual(feedback, .copied("recorded brief")) + feedback.record(succeeded: false, text: "new brief") + XCTAssertEqual(feedback, .failed("new brief")) + feedback.clear() + XCTAssertEqual(feedback, .idle) + } + func testShiftBriefNeverTurnsLoadingUnavailableOrMalformedDataIntoAllClear() throws { XCTAssertEqual( DashboardAttentionPresentation(payload: nil, error: nil), diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-minimum-dark.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-minimum-dark.png index 0ae1b99..aff8166 100644 Binary files a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-minimum-dark.png and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-minimum-dark.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-minimum-light.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-minimum-light.png index 4260bcb..ac153e7 100644 Binary files a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-minimum-light.png and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-minimum-light.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-reference-dark.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-reference-dark.png index 7d84b88..6dd8c0c 100644 Binary files a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-reference-dark.png and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-reference-dark.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-reference-light.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-reference-light.png index bae5902..0b2b258 100644 Binary files a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-reference-light.png and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-reference-light.png differ