Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 81 additions & 19 deletions apps/agentacct/Sources/agentacct/DashboardPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,51 @@ struct DashboardWorkItem: Identifiable {
}
}

/// Complete when the daemon supplies its all-store attention aggregate, or
/// when an older daemon explicitly says the fallback task list is untruncated.
/// A truncated legacy list can show known review items but can never prove the
/// absence of older ones.
struct DashboardAttentionPresentation {
let items: [DashboardWorkItem]
let totalCount: Int?
let isComplete: Bool
let isTruncated: Bool
let isUnavailable: Bool

init(
recentTasks: [ReceiptSummary],
recentTasksTruncated: Bool?,
attention: ReceiptAttentionPayload?,
fetchError: String? = nil
) {
if fetchError != nil {
items = []
totalCount = nil
isComplete = false
isTruncated = false
isUnavailable = true
return
}

if let attention {
items = attention.tasks.map(DashboardWorkItem.init)
totalCount = attention.total
isComplete = true
isTruncated = attention.truncated || attention.total > attention.tasks.count
isUnavailable = false
return
}

let recentItems = recentTasks.map(DashboardWorkItem.init)
items = recentItems.filter { $0.needsReview && $0.hasFinding }
+ recentItems.filter { $0.needsReview && !$0.hasFinding }
isComplete = recentTasksTruncated == false
totalCount = isComplete ? items.count : nil
isTruncated = !isComplete
isUnavailable = false
}
}

enum DashboardUsageSeries: String, CaseIterable, Identifiable {
case tokens = "Tokens"
case cost = "Cost"
Expand Down Expand Up @@ -214,12 +259,13 @@ struct DashboardPane: View {
}
}

private var attentionItems: [DashboardWorkItem] {
let items = dashboard.receiptTasks.map(DashboardWorkItem.init)
// Failed evidence is the more urgent review target. Preserve API order
// inside each group so equal-priority tasks remain stable.
return items.filter { $0.needsReview && $0.hasFinding }
+ items.filter { $0.needsReview && !$0.hasFinding }
private var attention: DashboardAttentionPresentation {
DashboardAttentionPresentation(
recentTasks: dashboard.receiptTasks,
recentTasksTruncated: dashboard.receiptTasksTruncated,
attention: dashboard.receiptAttention,
fetchError: dashboard.receiptListError
)
}

var body: some View {
Expand All @@ -233,7 +279,7 @@ struct DashboardPane: View {
selection.open(destination)
}
} right: {
NeedsReviewCard(items: attentionItems) { destination in
NeedsReviewCard(attention: attention) { destination in
selection.open(destination)
}
}
Expand Down Expand Up @@ -504,33 +550,49 @@ private struct RecentWorkRow: View {
}

private struct NeedsReviewCard: View {
let items: [DashboardWorkItem]
let attention: DashboardAttentionPresentation
let open: (DashboardDestination) -> Void

private var visibleItems: [DashboardWorkItem] { Array(items.prefix(2)) }
private var visibleItems: [DashboardWorkItem] { Array(attention.items.prefix(2)) }

var body: some View {
Card(padding: 0, fillsHeight: true) {
VStack(spacing: 0) {
DashboardCardHeader("Needs review", count: items.count) {
if items.count > visibleItems.count {
DashboardCardHeader("Needs review", count: attention.totalCount) {
if attention.isTruncated {
Button { open(.work) } label: {
Text("View all").font(Type.captionSemibold)
Text("Open Work").font(Type.captionSemibold)
}
.foregroundStyle(Theme.accent)
.buttonStyle(QuietButtonStyle())
.accessibilityIdentifier("dashboard.review.view-all")
.accessibilityIdentifier("dashboard.review.open-work")
}
}
Divider().overlay(Theme.hairline)

if visibleItems.isEmpty {
DashboardEmptyState(
icon: "checkmark.circle.fill",
title: "All clear",
message: "No blocked work or failed checks."
)
.frame(minHeight: 222)
if attention.isUnavailable {
DashboardEmptyState(
icon: "wifi.exclamationmark",
title: "Review status unavailable",
message: "The latest receipt refresh failed. Cached results aren't shown as current."
)
.frame(minHeight: 222)
} else if attention.isComplete {
DashboardEmptyState(
icon: "checkmark.circle.fill",
title: "All clear",
message: "No blocked work or failed checks."
)
.frame(minHeight: 222)
} else {
DashboardEmptyState(
icon: "exclamationmark.triangle.fill",
title: "Review status incomplete",
message: "Older work may be missing from this summary."
)
.frame(minHeight: 222)
}
} else {
ForEach(Array(visibleItems.enumerated()), id: \.element.id) { index, item in
DashboardAttentionRow(item: item) { open(.task(item.id)) }
Expand Down
23 changes: 14 additions & 9 deletions apps/agentacct/Sources/agentacct/DashboardSnapshotHarness.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,20 +124,23 @@ struct DashboardSnapshotConfiguration {
let width: CGFloat
let height: CGFloat
let colorScheme: ColorScheme
let workState: SnapshotWorkStoreState

var filename: String {
let appearance = colorScheme == .dark ? "dark" : "light"
return "dashboard-\(viewport)-\(appearance).png"
}

static let reviewConfigurations: [Self] = [
Self(viewport: "minimum", width: 960, height: 560, colorScheme: .light),
Self(viewport: "minimum", width: 960, height: 560, colorScheme: .dark),
Self(viewport: "minimum", width: 960, height: 560, colorScheme: .light, workState: .populated),
Self(viewport: "minimum", width: 960, height: 560, colorScheme: .dark, workState: .populated),
// The reference viewport must show the complete dashboard, including
// chart labels. The shorter minimum pair intentionally verifies the
// real top-of-scroll experience instead.
Self(viewport: "reference", width: 1120, height: 800, colorScheme: .light),
Self(viewport: "reference", width: 1120, height: 800, colorScheme: .dark),
Self(viewport: "reference", width: 1120, height: 800, colorScheme: .light, workState: .populated),
Self(viewport: "reference", width: 1120, height: 800, colorScheme: .dark, workState: .populated),
Self(viewport: "attention-unavailable", width: 1120, height: 800, colorScheme: .light, workState: .listErrorWithRetainedData),
Self(viewport: "attention-unavailable", width: 1120, height: 800, colorScheme: .dark, workState: .listErrorWithRetainedData),
]
}

Expand Down Expand Up @@ -178,13 +181,15 @@ enum DashboardSnapshotRenderer {
SnapshotScheme.override = nil
}

let glance = GlanceState(preloaded: fixture.glanceSnapshot)
let dashboard = DashboardStore(preloaded: fixture)
let selection = AppSelection()
selection.pane = .dashboard

return try configurations.map { configuration in
SnapshotScheme.override = configuration.colorScheme
let glance = GlanceState(preloaded: fixture.glanceSnapshot)
let dashboard = DashboardStore(
preloaded: fixture,
workState: configuration.workState
)
let selection = AppSelection()
selection.pane = .dashboard
// A packaged app consistently offers setup here. Injecting that
// state keeps SwiftPM and packaged-build snapshots identical.
let view = MainWindow(canSetUpOverride: true)
Expand Down
20 changes: 20 additions & 0 deletions apps/agentacct/Sources/agentacct/DashboardStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ enum SnapshotWorkStoreState {
case populated
case empty
case listError
case listErrorWithRetainedData
case receiptLoading
case receiptError
}
Expand All @@ -24,6 +25,8 @@ final class DashboardStore: ObservableObject {
@Published private(set) var usage: UsageSummary?
@Published private(set) var receiptTasks: [ReceiptSummary] = []
@Published private(set) var totalReceiptTasks: Int?
@Published private(set) var receiptTasksTruncated: Bool?
@Published private(set) var receiptAttention: ReceiptAttentionPayload?
@Published private(set) var receipt: Receipt?
@Published private(set) var receiptListError: String?
@Published private(set) var receiptError: String?
Expand Down Expand Up @@ -68,6 +71,8 @@ final class DashboardStore: ObservableObject {
case .populated:
receiptTasks = fixture.tasks.tasks
totalReceiptTasks = fixture.tasks.total
receiptTasksTruncated = fixture.tasks.truncated
receiptAttention = fixture.tasks.attention
receipt = fixture.work?.receipt
for session in fixture.work?.sessions ?? [] {
let key = "\(session.session.client)::\(session.session.clientSessionId)"
Expand All @@ -76,15 +81,26 @@ final class DashboardStore: ObservableObject {
case .empty:
receiptTasks = []
totalReceiptTasks = 0
receiptTasksTruncated = false
case .listError:
receiptTasks = []
receiptListError = "receipts fetch failed: synthetic review error"
case .listErrorWithRetainedData:
receiptTasks = fixture.tasks.tasks
totalReceiptTasks = fixture.tasks.total
receiptTasksTruncated = fixture.tasks.truncated
receiptAttention = fixture.tasks.attention
receiptListError = "receipts fetch failed: synthetic review error"
case .receiptLoading:
receiptTasks = fixture.tasks.tasks
totalReceiptTasks = fixture.tasks.total
receiptTasksTruncated = fixture.tasks.truncated
receiptAttention = fixture.tasks.attention
case .receiptError:
receiptTasks = fixture.tasks.tasks
totalReceiptTasks = fixture.tasks.total
receiptTasksTruncated = fixture.tasks.truncated
receiptAttention = fixture.tasks.attention
receiptError = "receipt fetch failed: synthetic review error"
}
let updated = fixture.glance.generatedAt.map(Date.init(timeIntervalSince1970:))
Expand All @@ -111,6 +127,8 @@ final class DashboardStore: ObservableObject {
let tasks = try await tasksRequest
receiptTasks = tasks.tasks
totalReceiptTasks = tasks.total
receiptTasksTruncated = tasks.truncated
receiptAttention = tasks.attention
receiptListError = nil
tasksSucceeded = true
} catch GlanceClientError.noDiscovery(_) {
Expand Down Expand Up @@ -156,6 +174,8 @@ final class DashboardStore: ObservableObject {
let payload: ReceiptTasksPayload = try await client.getAuthed("/v1/tasks?limit=200")
receiptTasks = payload.tasks
totalReceiptTasks = payload.total
receiptTasksTruncated = payload.truncated
receiptAttention = payload.attention
receiptListError = nil
} catch GlanceClientError.noDiscovery(_) {
receiptListError = "daemon not running (no discovery file) — start it with `agentacct start`"
Expand Down
10 changes: 10 additions & 0 deletions apps/agentacct/Sources/agentacct/V1Model.swift
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,16 @@ struct ReceiptTasksPayload: Decodable {
let tasks: [ReceiptSummary]
let total: Int?
let truncated: Bool?
/// Exact all-store attention count plus a bounded Dashboard preview.
/// Optional so the app can fail closed against an older daemon.
let attention: ReceiptAttentionPayload?
}

struct ReceiptAttentionPayload: Decodable {
let tasks: [ReceiptSummary]
let total: Int
let limit: Int?
let truncated: Bool
}

struct ReceiptSummary: Decodable, Identifiable {
Expand Down
2 changes: 2 additions & 0 deletions apps/agentacct/Tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,8 @@ The dashboard renderer owns this complete fixed matrix at 2x scale:
| `dashboard-minimum-dark.png` | 960 × 560 pt minimum window, dark; single-column viewport | 1920 × 1120 px |
| `dashboard-reference-light.png` | 1120 × 800 pt standard window, light; complete two-column dashboard | 2240 × 1600 px |
| `dashboard-reference-dark.png` | 1120 × 800 pt standard window, dark; complete two-column dashboard | 2240 × 1600 px |
| `dashboard-attention-unavailable-light.png` | 1120 × 800 pt failed receipt refresh with retained cache, light; proves cached attention is not presented as current | 2240 × 1600 px |
| `dashboard-attention-unavailable-dark.png` | 1120 × 800 pt failed receipt refresh with retained cache, dark; proves cached attention is not presented as current | 2240 × 1600 px |

References live under `Tests/agentacctTests/ReferenceImages/<platform-id>`.
They are read directly from the source checkout and excluded from SwiftPM's
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,89 @@ final class DashboardInteractionTests: XCTestCase {
XCTAssertFalse(items[4].hasFinding)
}

func testAttentionPresentationUsesCompleteAggregateAndFailsClosedForLegacyTruncation()
throws
{
let payload = try decode(
ReceiptTasksPayload.self,
from: """
{
"schema": "agentacct.receipt.v1",
"total": 203,
"truncated": true,
"tasks": [
{
"task_id": "recent-complete",
"decision_status": { "key": "verified" },
"evidence_strength": { "key": "self_checked" },
"cost": {}
}
],
"attention": {
"total": 3,
"limit": 2,
"truncated": true,
"tasks": [
{
"task_id": "older-finding",
"decision_status": { "key": "finding" },
"evidence_strength": { "key": "unchecked", "checks_failed": 1 },
"cost": {}
},
{
"task_id": "older-blocked",
"decision_status": { "key": "blocked" },
"evidence_strength": { "key": "not_gradeable" },
"cost": {}
}
]
}
}
"""
)
let complete = DashboardAttentionPresentation(
recentTasks: payload.tasks,
recentTasksTruncated: payload.truncated,
attention: payload.attention
)

XCTAssertEqual(complete.items.map(\.id), ["older-finding", "older-blocked"])
XCTAssertEqual(complete.totalCount, 3)
XCTAssertTrue(complete.isComplete)
XCTAssertTrue(complete.isTruncated)

let failedRefresh = DashboardAttentionPresentation(
recentTasks: payload.tasks,
recentTasksTruncated: payload.truncated,
attention: payload.attention,
fetchError: "receipts fetch failed: connection lost"
)
XCTAssertTrue(failedRefresh.items.isEmpty)
XCTAssertNil(failedRefresh.totalCount)
XCTAssertFalse(failedRefresh.isComplete)
XCTAssertFalse(failedRefresh.isTruncated)
XCTAssertTrue(failedRefresh.isUnavailable)

let legacy = DashboardAttentionPresentation(
recentTasks: [],
recentTasksTruncated: true,
attention: nil
)
XCTAssertTrue(legacy.items.isEmpty)
XCTAssertNil(legacy.totalCount)
XCTAssertFalse(legacy.isComplete)
XCTAssertTrue(legacy.isTruncated)

let exhaustiveLegacy = DashboardAttentionPresentation(
recentTasks: [],
recentTasksTruncated: false,
attention: nil
)
XCTAssertEqual(exhaustiveLegacy.totalCount, 0)
XCTAssertTrue(exhaustiveLegacy.isComplete)
XCTAssertFalse(exhaustiveLegacy.isTruncated)
}

@MainActor
func testLocalDataFreshnessUsesTheSnapshotClock() {
SnapshotMode.setFixtureDate(Date(timeIntervalSince1970: 1_000))
Expand Down
Loading
Loading