From 1aef1d6ad9f6ece4834a4b3d781396092ca97366 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:35:34 -0400 Subject: [PATCH 01/19] =?UTF-8?q?activity(p0):=20iOS=20contract=20decoder?= =?UTF-8?q?=20hardening=20=E2=80=94=20unknown-tolerant=20enums,=20lossy=20?= =?UTF-8?q?item=20decode,=20additive=20tier/statusSince=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- apps/ios/ADE.xcodeproj/project.pbxproj | 4 + apps/ios/ADE/Shared/ADESharedModels.swift | 318 ++++++++++++++++-- .../AttentionDrawerModel.swift | 3 + .../ActivityContractDecodingTests.swift | 236 +++++++++++++ apps/ios/ADEWidgets/ADELockScreenWidget.swift | 3 + 5 files changed, 537 insertions(+), 27 deletions(-) create mode 100644 apps/ios/ADETests/ActivityContractDecodingTests.swift diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 76fb21f92..b82c0173c 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -149,6 +149,7 @@ D30000000000000000000012 /* AttentionDrawerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000002 /* AttentionDrawerButton.swift */; }; D30000000000000000000013 /* AttentionDrawerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000003 /* AttentionDrawerSheet.swift */; }; D30000000000000000000015 /* AttentionDrawerModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000005 /* AttentionDrawerModelTests.swift */; }; + AC7200000000000000000001 /* ActivityContractDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7100000000000000000001 /* ActivityContractDecodingTests.swift */; }; D30000000000000000000016 /* SyncEnvelopeChunkAssemblerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000006 /* SyncEnvelopeChunkAssemblerTests.swift */; }; D30000000000000000000017 /* WorkMarkdownStreamingParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000007 /* WorkMarkdownStreamingParsingTests.swift */; }; D30000000000000000000018 /* PrMergeMergeStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000008 /* PrMergeMergeStateTests.swift */; }; @@ -424,6 +425,7 @@ D30000000000000000000002 /* AttentionDrawerButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttentionDrawerButton.swift; path = ADE/Views/AttentionDrawer/AttentionDrawerButton.swift; sourceTree = ""; }; D30000000000000000000003 /* AttentionDrawerSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttentionDrawerSheet.swift; path = ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift; sourceTree = ""; }; D30000000000000000000005 /* AttentionDrawerModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttentionDrawerModelTests.swift; path = ADETests/AttentionDrawerModelTests.swift; sourceTree = ""; }; + AC7100000000000000000001 /* ActivityContractDecodingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityContractDecodingTests.swift; path = ADETests/ActivityContractDecodingTests.swift; sourceTree = ""; }; D30000000000000000000006 /* SyncEnvelopeChunkAssemblerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncEnvelopeChunkAssemblerTests.swift; path = ADETests/SyncEnvelopeChunkAssemblerTests.swift; sourceTree = ""; }; D30000000000000000000007 /* WorkMarkdownStreamingParsingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkMarkdownStreamingParsingTests.swift; path = ADETests/WorkMarkdownStreamingParsingTests.swift; sourceTree = ""; }; D30000000000000000000008 /* PrMergeMergeStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrMergeMergeStateTests.swift; path = ADETests/PrMergeMergeStateTests.swift; sourceTree = ""; }; @@ -1059,6 +1061,7 @@ AF00000000000000000000A4 /* PairingAndDpopTests.swift */, AC1000000000000000000008 /* ClipPairingHandoffTests.swift */, D30000000000000000000005 /* AttentionDrawerModelTests.swift */, + AC7100000000000000000001 /* ActivityContractDecodingTests.swift */, D30000000000000000000006 /* SyncEnvelopeChunkAssemblerTests.swift */, D30000000000000000000007 /* WorkMarkdownStreamingParsingTests.swift */, D30000000000000000000008 /* PrMergeMergeStateTests.swift */, @@ -1560,6 +1563,7 @@ AF00000000000000000000C4 /* PairingAndDpopTests.swift in Sources */, AC1100000000000000000008 /* ClipPairingHandoffTests.swift in Sources */, D30000000000000000000015 /* AttentionDrawerModelTests.swift in Sources */, + AC7200000000000000000001 /* ActivityContractDecodingTests.swift in Sources */, D30000000000000000000016 /* SyncEnvelopeChunkAssemblerTests.swift in Sources */, D30000000000000000000017 /* WorkMarkdownStreamingParsingTests.swift in Sources */, D30000000000000000000018 /* PrMergeMergeStateTests.swift in Sources */, diff --git a/apps/ios/ADE/Shared/ADESharedModels.swift b/apps/ios/ADE/Shared/ADESharedModels.swift index d06ee95c8..7ffb25012 100644 --- a/apps/ios/ADE/Shared/ADESharedModels.swift +++ b/apps/ios/ADE/Shared/ADESharedModels.swift @@ -253,26 +253,111 @@ public struct WorkspaceSnapshot: Codable, Hashable, Sendable { /// model again. public let ADEAttentionContractVersion = 1 -public enum AccountAttentionItemKind: String, Codable, Hashable, Sendable { +public enum AccountAttentionItemKind: RawRepresentable, Codable, Hashable, Sendable { case agent - case pullRequest = "pull_request" + case pullRequest + case unrecognized(String) + + public init?(rawValue: String) { + switch rawValue { + case "agent": self = .agent + case "pull_request": self = .pullRequest + default: self = .unrecognized(rawValue) + } + } + + public var rawValue: String { + switch self { + case .agent: return "agent" + case .pullRequest: return "pull_request" + case .unrecognized(let rawValue): return rawValue + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = Self(rawValue: rawValue) ?? .unrecognized(rawValue) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } } -public enum AccountAttentionPhase: String, Codable, Hashable, Sendable { +public enum AccountAttentionPhase: RawRepresentable, Codable, Hashable, Sendable { case starting case running - case needsYou = "needs_you" + case needsYou case blocked case failed case completed case stale - case checksFailing = "checks_failing" - case reviewRequested = "review_requested" - case changesRequested = "changes_requested" - case mergeReady = "merge_ready" + case checksFailing + case reviewRequested + case changesRequested + case mergeReady case open case merged case closed + case unrecognized(String) + + public init?(rawValue: String) { + switch rawValue { + case "starting": self = .starting + case "running": self = .running + case "needs_you": self = .needsYou + case "blocked": self = .blocked + case "failed": self = .failed + case "completed": self = .completed + case "stale": self = .stale + case "checks_failing": self = .checksFailing + case "review_requested": self = .reviewRequested + case "changes_requested": self = .changesRequested + case "merge_ready": self = .mergeReady + case "open": self = .open + case "merged": self = .merged + case "closed": self = .closed + default: self = .unrecognized(rawValue) + } + } + + public var rawValue: String { + switch self { + case .starting: return "starting" + case .running: return "running" + case .needsYou: return "needs_you" + case .blocked: return "blocked" + case .failed: return "failed" + case .completed: return "completed" + case .stale: return "stale" + case .checksFailing: return "checks_failing" + case .reviewRequested: return "review_requested" + case .changesRequested: return "changes_requested" + case .mergeReady: return "merge_ready" + case .open: return "open" + case .merged: return "merged" + case .closed: return "closed" + case .unrecognized(let rawValue): return rawValue + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = Self(rawValue: rawValue) ?? .unrecognized(rawValue) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + fileprivate var isRecognized: Bool { + if case .unrecognized = self { return false } + return true + } /// Row copy for the Attention Drawer. Same words as `AgentRunPhase.label` /// and the desktop sidebar — "Working", not "Running"; "Done", not @@ -293,22 +378,69 @@ public enum AccountAttentionPhase: String, Codable, Hashable, Sendable { case .open: return "Open" case .merged: return "Merged" case .closed: return "Closed" + case .unrecognized: return "Unknown" } } } -public enum AccountAttentionEventKind: String, Codable, Hashable, Sendable { - case agentRunning = "agent_running" - case agentNeedsYou = "agent_needs_you" - case agentFailed = "agent_failed" - case agentCompleted = "agent_completed" - case prChecksFailing = "pr_checks_failing" - case prReviewRequested = "pr_review_requested" - case prChangesRequested = "pr_changes_requested" - case prMergeReady = "pr_merge_ready" - case prMerged = "pr_merged" - case prOpened = "pr_opened" - case prClosed = "pr_closed" +public enum AccountAttentionEventKind: RawRepresentable, Codable, Hashable, Sendable { + case agentRunning + case agentNeedsYou + case agentFailed + case agentCompleted + case prChecksFailing + case prReviewRequested + case prChangesRequested + case prMergeReady + case prMerged + case prOpened + case prClosed + case unrecognized(String) + + public init?(rawValue: String) { + switch rawValue { + case "agent_running": self = .agentRunning + case "agent_needs_you": self = .agentNeedsYou + case "agent_failed": self = .agentFailed + case "agent_completed": self = .agentCompleted + case "pr_checks_failing": self = .prChecksFailing + case "pr_review_requested": self = .prReviewRequested + case "pr_changes_requested": self = .prChangesRequested + case "pr_merge_ready": self = .prMergeReady + case "pr_merged": self = .prMerged + case "pr_opened": self = .prOpened + case "pr_closed": self = .prClosed + default: self = .unrecognized(rawValue) + } + } + + public var rawValue: String { + switch self { + case .agentRunning: return "agent_running" + case .agentNeedsYou: return "agent_needs_you" + case .agentFailed: return "agent_failed" + case .agentCompleted: return "agent_completed" + case .prChecksFailing: return "pr_checks_failing" + case .prReviewRequested: return "pr_review_requested" + case .prChangesRequested: return "pr_changes_requested" + case .prMergeReady: return "pr_merge_ready" + case .prMerged: return "pr_merged" + case .prOpened: return "pr_opened" + case .prClosed: return "pr_closed" + case .unrecognized(let rawValue): return rawValue + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = Self(rawValue: rawValue) ?? .unrecognized(rawValue) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } } public struct AccountAttentionMachine: Codable, Hashable, Sendable { @@ -464,15 +596,55 @@ extension AccountAttentionDestination: Codable { } } -public enum AccountAttentionActionKind: String, Codable, Hashable, Sendable { +public enum AccountAttentionActionKind: RawRepresentable, Codable, Hashable, Sendable { case approve case deny case answer case restart - case rerunChecks = "rerun_checks" - case markSeen = "mark_seen" + case rerunChecks + case markSeen case dismiss case open + case unrecognized(String) + + public init?(rawValue: String) { + switch rawValue { + case "approve": self = .approve + case "deny": self = .deny + case "answer": self = .answer + case "restart": self = .restart + case "rerun_checks": self = .rerunChecks + case "mark_seen": self = .markSeen + case "dismiss": self = .dismiss + case "open": self = .open + default: self = .unrecognized(rawValue) + } + } + + public var rawValue: String { + switch self { + case .approve: return "approve" + case .deny: return "deny" + case .answer: return "answer" + case .restart: return "restart" + case .rerunChecks: return "rerun_checks" + case .markSeen: return "mark_seen" + case .dismiss: return "dismiss" + case .open: return "open" + case .unrecognized(let rawValue): return rawValue + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = Self(rawValue: rawValue) ?? .unrecognized(rawValue) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } } public enum AccountAttentionPayloadValue: Codable, Hashable, Sendable { @@ -543,6 +715,12 @@ public struct AccountAttentionPlanProgress: Codable, Hashable, Sendable { } } +public enum AccountActivityTier: String, Codable, Hashable, Sendable { + case signal + case ambient + case idle +} + public struct AccountAttentionItem: Codable, Hashable, Identifiable, Sendable { public let contractVersion: Int public let id: String @@ -551,6 +729,9 @@ public struct AccountAttentionItem: Codable, Hashable, Identifiable, Sendable { public let kind: AccountAttentionItemKind public let eventKind: AccountAttentionEventKind public let phase: AccountAttentionPhase + /// Kept as an optional wire string so future tier values remain additive. + public let activityTier: String? + public let statusSince: Date? public private(set) var machine: AccountAttentionMachine public let project: AccountAttentionProject public let laneId: String? @@ -579,6 +760,8 @@ public struct AccountAttentionItem: Codable, Hashable, Identifiable, Sendable { kind: AccountAttentionItemKind, eventKind: AccountAttentionEventKind, phase: AccountAttentionPhase, + activityTier: String? = nil, + statusSince: Date? = nil, machine: AccountAttentionMachine, project: AccountAttentionProject, laneId: String? = nil, @@ -606,6 +789,8 @@ public struct AccountAttentionItem: Codable, Hashable, Identifiable, Sendable { self.kind = kind self.eventKind = eventKind self.phase = phase + self.activityTier = activityTier + self.statusSince = statusSince self.machine = machine self.project = project self.laneId = laneId @@ -651,11 +836,29 @@ public struct AccountAttentionItem: Codable, Hashable, Identifiable, Sendable { return true case .open, .completed, .merged, .closed: return false + case .unrecognized: + return false + } + } + + public var tier: AccountActivityTier { + if let activityTier { + return AccountActivityTier(rawValue: activityTier) ?? .idle + } + switch phase { + case .needsYou, .failed: + return .signal + case .starting, .running, .completed: + return .ambient + case .blocked, .stale, .checksFailing, .reviewRequested, + .changesRequested, .mergeReady, .open, .merged, .closed, + .unrecognized: + return .idle } } public var needsInbox: Bool { - guard dismissedAt == nil else { return false } + guard tier != .idle, dismissedAt == nil else { return false } switch phase { case .needsYou, .failed, .checksFailing, .changesRequested, .reviewRequested, .mergeReady: @@ -664,6 +867,8 @@ public struct AccountAttentionItem: Codable, Hashable, Identifiable, Sendable { return seenAt == nil case .starting, .running, .blocked, .open, .stale, .closed: return false + case .unrecognized: + return false } } @@ -678,6 +883,14 @@ public struct AccountAttentionTombstone: Codable, Hashable, Identifiable, Sendab public let deletedAt: Date } +private struct FailableDecodable: Decodable { + let value: Value? + + init(from decoder: Decoder) throws { + value = try? Value(from: decoder) + } +} + public struct AccountAttentionSnapshot: Codable, Hashable, Sendable { public let contractVersion: Int /// Opaque account stream identity assigned by Relay. Older relays and @@ -691,6 +904,18 @@ public struct AccountAttentionSnapshot: Codable, Hashable, Sendable { public let machines: [AccountAttentionMachine]? public let items: [AccountAttentionItem] public let tombstones: [AccountAttentionTombstone]? + public let itemsTruncated: Bool? + + private enum CodingKeys: String, CodingKey { + case contractVersion + case streamId + case revision + case generatedAt + case machines + case items + case tombstones + case itemsTruncated + } public init( contractVersion: Int = ADEAttentionContractVersion, @@ -699,7 +924,8 @@ public struct AccountAttentionSnapshot: Codable, Hashable, Sendable { generatedAt: Date, machines: [AccountAttentionMachine]? = nil, items: [AccountAttentionItem], - tombstones: [AccountAttentionTombstone]? = nil + tombstones: [AccountAttentionTombstone]? = nil, + itemsTruncated: Bool? = nil ) { self.contractVersion = contractVersion self.streamId = streamId @@ -708,6 +934,42 @@ public struct AccountAttentionSnapshot: Codable, Hashable, Sendable { self.machines = machines self.items = items self.tombstones = tombstones + self.itemsTruncated = itemsTruncated + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + contractVersion = try container.decode(Int.self, forKey: .contractVersion) + streamId = try container.decodeIfPresent(String.self, forKey: .streamId) + revision = try container.decode(Int.self, forKey: .revision) + generatedAt = try container.decode(Date.self, forKey: .generatedAt) + machines = try container.decodeIfPresent([AccountAttentionMachine].self, forKey: .machines) + items = try container.decode( + [FailableDecodable].self, + forKey: .items + ) + .compactMap(\.value) + // Unknown phases cannot be categorized safely by an installed UI. + // The raw enum value still decodes losslessly, while this one row is + // omitted instead of invalidating the entire account snapshot. + .filter { $0.phase.isRecognized } + tombstones = try container.decodeIfPresent( + [AccountAttentionTombstone].self, + forKey: .tombstones + ) + itemsTruncated = try container.decodeIfPresent(Bool.self, forKey: .itemsTruncated) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(contractVersion, forKey: .contractVersion) + try container.encodeIfPresent(streamId, forKey: .streamId) + try container.encode(revision, forKey: .revision) + try container.encode(generatedAt, forKey: .generatedAt) + try container.encodeIfPresent(machines, forKey: .machines) + try container.encode(items, forKey: .items) + try container.encodeIfPresent(tombstones, forKey: .tombstones) + try container.encodeIfPresent(itemsTruncated, forKey: .itemsTruncated) } /// Apply an incremental relay response to the last full snapshot. Relay @@ -748,7 +1010,8 @@ public struct AccountAttentionSnapshot: Codable, Hashable, Sendable { generatedAt: delta.generatedAt, machines: delta.machines ?? machines, items: Array(byId.values), - tombstones: delta.tombstones + tombstones: delta.tombstones, + itemsTruncated: delta.itemsTruncated ?? itemsTruncated )) } } @@ -775,7 +1038,8 @@ private func normalizedAccountAttentionSnapshot( generatedAt: snapshot.generatedAt, machines: snapshot.machines, items: Array(itemsById.values), - tombstones: snapshot.tombstones + tombstones: snapshot.tombstones, + itemsTruncated: snapshot.itemsTruncated ) } diff --git a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift index 11348d2b4..6e476f4e0 100644 --- a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift +++ b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift @@ -672,6 +672,9 @@ public final class AttentionDrawerModel: ObservableObject { case .merged: kind = .merged collection = .recent + case .unrecognized: + kind = .open + collection = .recent } let destination = source.destination diff --git a/apps/ios/ADETests/ActivityContractDecodingTests.swift b/apps/ios/ADETests/ActivityContractDecodingTests.swift new file mode 100644 index 000000000..b019c92e5 --- /dev/null +++ b/apps/ios/ADETests/ActivityContractDecodingTests.swift @@ -0,0 +1,236 @@ +import XCTest +@testable import ADE + +final class ActivityContractDecodingTests: XCTestCase { + func testUnknownPhaseDropsOnlyThatItemAndIgnoresUnknownTopLevelFields() throws { + let data = Data(#""" + { + "contractVersion": 1, + "revision": 42, + "generatedAt": "2026-08-01T12:00:00Z", + "itemsTruncated": true, + "futureTopLevel": { "enabled": true }, + "items": [ + { + "contractVersion": 1, + "id": "known-item", + "revision": 4, + "fingerprint": "known-fingerprint", + "kind": "agent", + "eventKind": "agent_running", + "phase": "running", + "machine": { + "machineKey": "machine:one", + "name": "Studio", + "online": true + }, + "project": { + "projectId": "project:one", + "name": "ADE" + }, + "title": "Known run", + "preview": "Working", + "privacyPreview": "Agent activity", + "destination": { + "kind": "session", + "sessionId": "session-known" + }, + "actions": [], + "occurredAt": "2026-08-01T11:59:00Z", + "updatedAt": "2026-08-01T12:00:00Z" + }, + { + "contractVersion": 1, + "id": "future-item", + "revision": 5, + "fingerprint": "future-fingerprint", + "kind": "agent", + "eventKind": "agent_running", + "phase": "future_phase", + "machine": { + "machineKey": "machine:one", + "name": "Studio", + "online": true + }, + "project": { + "projectId": "project:one", + "name": "ADE" + }, + "title": "Future run", + "preview": "Doing something new", + "privacyPreview": "Agent activity", + "destination": { + "kind": "session", + "sessionId": "session-future" + }, + "actions": [], + "occurredAt": "2026-08-01T11:59:30Z", + "updatedAt": "2026-08-01T12:00:00Z" + } + ] + } + """#.utf8) + + let snapshot = try XCTUnwrap(ADESharedContainer.decodeAttentionSnapshot(from: data)) + + XCTAssertEqual(snapshot.revision, 42) + XCTAssertEqual(snapshot.items.map(\.id), ["known-item"]) + XCTAssertEqual(snapshot.itemsTruncated, true) + } + + func testUnknownEnumValuesDecodeAndReencodeTheirRawValues() throws { + try assertUnknownRoundTrip( + AccountAttentionItemKind.self, + rawValue: "future_item", + expected: .unrecognized("future_item") + ) + try assertUnknownRoundTrip( + AccountAttentionPhase.self, + rawValue: "future_phase", + expected: .unrecognized("future_phase") + ) + try assertUnknownRoundTrip( + AccountAttentionEventKind.self, + rawValue: "future_event", + expected: .unrecognized("future_event") + ) + try assertUnknownRoundTrip( + AccountAttentionActionKind.self, + rawValue: "future_action", + expected: .unrecognized("future_action") + ) + } + + func testMalformedItemDropsWithoutInvalidatingSnapshot() throws { + let data = Data(#""" + { + "contractVersion": 1, + "revision": 2, + "generatedAt": "2026-08-01T12:00:00Z", + "items": [ + { + "contractVersion": 1, + "id": "survivor", + "revision": 1, + "fingerprint": "survivor-fingerprint", + "kind": "agent", + "eventKind": "agent_running", + "phase": "running", + "machine": { "machineKey": "machine:one", "name": "Studio", "online": true }, + "project": { "projectId": "project:one", "name": "ADE" }, + "title": "Survivor", + "preview": "Working", + "privacyPreview": "Agent activity", + "destination": { "kind": "session", "sessionId": "session-survivor" }, + "actions": [], + "occurredAt": "2026-08-01T11:59:00Z", + "updatedAt": "2026-08-01T12:00:00Z" + }, + { + "contractVersion": 1, + "revision": 2, + "kind": "agent" + } + ] + } + """#.utf8) + + let snapshot = try XCTUnwrap(ADESharedContainer.decodeAttentionSnapshot(from: data)) + + XCTAssertEqual(snapshot.items.map(\.id), ["survivor"]) + } + + func testActivityTierAndStatusSinceRoundTrip() throws { + let statusSince = Date(timeIntervalSince1970: 1_754_046_000) + let snapshot = AccountAttentionSnapshot( + revision: 7, + generatedAt: Date(timeIntervalSince1970: 1_754_046_100), + items: [ + makeItem( + id: "round-trip", + phase: .running, + activityTier: "ambient", + statusSince: statusSince + ) + ], + itemsTruncated: false + ) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + + let encoded = try encoder.encode(snapshot) + let decoded = try XCTUnwrap(ADESharedContainer.decodeAttentionSnapshot(from: encoded)) + let item = try XCTUnwrap(decoded.items.first) + + XCTAssertEqual(item.activityTier, "ambient") + XCTAssertEqual(item.tier, .ambient) + XCTAssertEqual(item.statusSince, statusSince) + XCTAssertEqual(decoded.itemsTruncated, false) + } + + func testTierDefaultsFromPhaseWhenActivityTierIsAbsent() { + XCTAssertEqual(makeItem(id: "needs-you", phase: .needsYou).tier, .signal) + XCTAssertEqual(makeItem(id: "failed", phase: .failed).tier, .signal) + XCTAssertEqual(makeItem(id: "starting", phase: .starting).tier, .ambient) + XCTAssertEqual(makeItem(id: "running", phase: .running).tier, .ambient) + XCTAssertEqual(makeItem(id: "completed", phase: .completed).tier, .ambient) + XCTAssertEqual(makeItem(id: "blocked", phase: .blocked).tier, .idle) + XCTAssertEqual(makeItem(id: "stale", phase: .stale).tier, .idle) + XCTAssertTrue(makeItem(id: "legacy-signal", phase: .needsYou).needsInbox) + XCTAssertFalse( + makeItem(id: "idle-needs-you", phase: .needsYou, activityTier: "idle").needsInbox + ) + } + + private func assertUnknownRoundTrip( + _ type: Value.Type, + rawValue: String, + expected: Value, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + let encodedRawValue = try JSONEncoder().encode(rawValue) + let decoded = try JSONDecoder().decode(type, from: encodedRawValue) + XCTAssertEqual(decoded, expected, file: file, line: line) + + let reencoded = try JSONEncoder().encode(decoded) + XCTAssertEqual( + try JSONDecoder().decode(String.self, from: reencoded), + rawValue, + file: file, + line: line + ) + } + + private func makeItem( + id: String, + phase: AccountAttentionPhase, + activityTier: String? = nil, + statusSince: Date? = nil + ) -> AccountAttentionItem { + let timestamp = Date(timeIntervalSince1970: 1_754_046_000) + return AccountAttentionItem( + id: id, + revision: 1, + fingerprint: "fingerprint-\(id)", + kind: .agent, + eventKind: .agentRunning, + phase: phase, + activityTier: activityTier, + statusSince: statusSince, + machine: AccountAttentionMachine( + machineKey: "machine:one", + name: "Studio", + online: true, + lastSeenAt: timestamp + ), + project: AccountAttentionProject(projectId: "project:one", name: "ADE"), + title: "Agent run", + preview: "Working", + privacyPreview: "Agent activity", + destination: .session(sessionId: "session-\(id)", itemId: nil, eventId: nil), + occurredAt: timestamp, + updatedAt: timestamp + ) + } +} diff --git a/apps/ios/ADEWidgets/ADELockScreenWidget.swift b/apps/ios/ADEWidgets/ADELockScreenWidget.swift index 0b73d7051..d412a31c1 100644 --- a/apps/ios/ADEWidgets/ADELockScreenWidget.swift +++ b/apps/ios/ADEWidgets/ADELockScreenWidget.swift @@ -514,6 +514,7 @@ private struct LockScreenPriorityStatus { case .open, .stale: return 4 case .completed, .merged: return 5 case .closed: return 6 + case .unrecognized: return 7 } } @@ -559,6 +560,8 @@ private struct LockScreenPriorityStatus { return (.idle, "arrow.triangle.merge", "MERGED", ADESharedTheme.statusSuccess) case .closed: return (.idle, "xmark.circle.fill", "CLOSED", ADESharedTheme.statusIdle) + case .unrecognized: + return (.idle, "questionmark.circle", "UNKNOWN", ADESharedTheme.statusIdle) } } } From 21d53abc3cedc3f0e1227c3014b9caeae79b9a66 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:35:34 -0400 Subject: [PATCH 02/19] =?UTF-8?q?activity(p2):=20shared=20foundation=20?= =?UTF-8?q?=E2=80=94=20SessionStatusLabel=20extraction,=20activity=20catal?= =?UTF-8?q?og=20+=20tiers,=20priority=20projection,=20pr=5Fopened/pr=5Fclo?= =?UTF-8?q?sed=20settings=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../attention/activityPriority.test.ts | 116 +++++++++++++ .../components/attention/activityPriority.ts | 99 +++++++++++ .../attention/attentionNotchLocalSettings.ts | 1 + .../attention/attentionPresentation.test.ts | 36 +++- .../attention/attentionPresentation.ts | 48 +++++- .../settings/NotificationsSection.test.tsx | 3 + .../settings/NotificationsSection.tsx | 21 ++- .../terminals/SessionStatusLabel.tsx | 148 +++++++++++++++++ .../terminals/SessionStatusSlot.tsx | 140 +--------------- .../renderer/webclient/adapter/attention.ts | 54 +++--- .../src/shared/activityCatalog.test.ts | 47 ++++++ apps/desktop/src/shared/activityCatalog.ts | 156 ++++++++++++++++++ .../src/shared/activityEventKinds.json | 13 ++ .../src/shared/types/attention.test.ts | 21 +++ apps/desktop/src/shared/types/attention.ts | 121 +++++++++----- 15 files changed, 802 insertions(+), 222 deletions(-) create mode 100644 apps/desktop/src/renderer/components/attention/activityPriority.test.ts create mode 100644 apps/desktop/src/renderer/components/attention/activityPriority.ts create mode 100644 apps/desktop/src/renderer/components/terminals/SessionStatusLabel.tsx create mode 100644 apps/desktop/src/shared/activityCatalog.test.ts create mode 100644 apps/desktop/src/shared/activityCatalog.ts create mode 100644 apps/desktop/src/shared/activityEventKinds.json diff --git a/apps/desktop/src/renderer/components/attention/activityPriority.test.ts b/apps/desktop/src/renderer/components/attention/activityPriority.test.ts new file mode 100644 index 000000000..bb2269c6d --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/activityPriority.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { + ATTENTION_CONTRACT_VERSION, + type AttentionItem, + type AttentionPhase, +} from "../../../shared/types/attention"; +import { + ACTIVITY_SECTION_DESCRIPTORS, + activityBadgeCount, + activityHeadline, + activitySections, +} from "./activityPriority"; + +const NOW = Date.parse("2026-08-01T12:00:00.000Z"); + +function activityItem( + id: string, + phase: AttentionPhase, + patch: Partial = {}, +): AttentionItem { + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + id, + revision: 1, + fingerprint: `fingerprint-${id}`, + kind: "agent", + eventKind: "agent_running", + phase, + machine: { machineKey: "studio", name: "Studio Mac", online: true, lastSeenAt: null }, + project: { projectId: "ade", name: "ADE" }, + title: id, + preview: "preview", + privacyPreview: "private preview", + destination: { kind: "session", sessionId: id }, + actions: [], + occurredAt: "2026-08-01T11:00:00.000Z", + updatedAt: "2026-08-01T11:00:00.000Z", + seenAt: null, + dismissedAt: null, + expiresAt: null, + ...patch, + }; +} + +describe("activity priority", () => { + it("always exposes the three reusable descriptors in priority order", () => { + expect(ACTIVITY_SECTION_DESCRIPTORS.map(({ id }) => id)).toEqual([ + "needs-you", + "working", + "done", + ]); + expect(activitySections([], NOW).map(({ id, items }) => [id, items])).toEqual([ + ["needs-you", []], + ["working", []], + ["done", []], + ]); + }); + + it("maps phases into needs-you, working, and done bands", () => { + const sections = activitySections([ + activityItem("done", "completed"), + activityItem("working", "running"), + activityItem("review", "review_requested"), + activityItem("needs", "needs_you"), + activityItem("open", "open"), + activityItem("closed", "closed"), + ], NOW); + + expect(sections.map((section) => [ + section.id, + section.items.map((item) => item.id), + ])).toEqual([ + ["needs-you", ["needs", "review"]], + ["working", ["working", "open"]], + ["done", ["done", "closed"]], + ]); + }); + + it("files explicit idle rows in the done ambient tail", () => { + const sections = activitySections([ + activityItem("idle-running", "running", { activityTier: "idle" }), + activityItem("fresh-done", "completed", { + updatedAt: "2026-08-01T10:00:00.000Z", + }), + activityItem("idle-stale", "stale", { + activityTier: "idle", + updatedAt: "2026-08-01T11:30:00.000Z", + }), + ], NOW); + + expect(sections[1]?.items).toEqual([]); + expect(sections[2]?.items.map((item) => item.id)).toEqual([ + "fresh-done", + "idle-running", + "idle-stale", + ]); + }); + + it("filters dismissed and expired rows before deriving badge and headline", () => { + const items = { + visible: activityItem("visible", "needs_you"), + dismissed: activityItem("dismissed", "failed", { + dismissedAt: "2026-08-01T11:30:00.000Z", + }), + expired: activityItem("expired", "needs_you", { + expiresAt: "2026-08-01T11:59:00.000Z", + }), + }; + + expect(activityBadgeCount(items, NOW)).toBe(1); + expect(activityHeadline(items, NOW)).toBe("1 needs you"); + expect(activityHeadline([activityItem("work", "running")], NOW)).toBe("1 working"); + expect(activityHeadline([activityItem("done", "completed")], NOW)).toBe("1 done"); + expect(activityHeadline([], NOW)).toBe("All clear"); + }); +}); diff --git a/apps/desktop/src/renderer/components/attention/activityPriority.ts b/apps/desktop/src/renderer/components/attention/activityPriority.ts new file mode 100644 index 000000000..1ed30c3be --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/activityPriority.ts @@ -0,0 +1,99 @@ +import { + ATTENTION_PHASE_PRIORITY, + activityItemTier, + sortAttentionItems, + type AttentionItem, +} from "../../../shared/types/attention"; + +export type ActivitySectionId = "needs-you" | "working" | "done"; + +export type ActivitySectionDescriptor = { + id: ActivitySectionId; + label: string; + order: number; +}; + +export const ACTIVITY_SECTION_DESCRIPTORS = [ + { id: "needs-you", label: "Needs you", order: 0 }, + { id: "working", label: "Working", order: 1 }, + { id: "done", label: "Done", order: 2 }, +] as const satisfies readonly ActivitySectionDescriptor[]; + +export type ActivitySection = ActivitySectionDescriptor & { + items: AttentionItem[]; +}; + +type ActivityItemsInput = + | readonly AttentionItem[] + | Readonly>; + +function activityInputItems(input: ActivityItemsInput): readonly AttentionItem[] { + return Array.isArray(input) + ? input + : Object.values(input as Readonly>); +} + +function activityItemIsExpired(item: AttentionItem, now: number): boolean { + if (!item.expiresAt) return false; + const expiresAt = Date.parse(item.expiresAt); + return Number.isFinite(expiresAt) && expiresAt <= now; +} + +export function activitySectionId(item: AttentionItem): ActivitySectionId { + // Disk-only roster rows are quiet history even when their preserved phase + // (for example stale) would otherwise fall inside the working band. + if (activityItemTier(item) === "idle") return "done"; + + const priority = ATTENTION_PHASE_PRIORITY[item.phase]; + if (priority <= ATTENTION_PHASE_PRIORITY.blocked) return "needs-you"; + if (priority <= ATTENTION_PHASE_PRIORITY.stale) return "working"; + return "done"; +} + +/** + * Priority-flat Activity projection. Every call returns the same three ordered + * descriptors, including empty sections, so popover, pane, and notch views can + * share headings without re-declaring their order. + */ +export function activitySections( + input: ActivityItemsInput, + now = Date.now(), +): ActivitySection[] { + const grouped: Record = { + "needs-you": [], + working: [], + done: [], + }; + + for (const item of activityInputItems(input)) { + if (item.dismissedAt || activityItemIsExpired(item, now)) continue; + grouped[activitySectionId(item)].push(item); + } + + return ACTIVITY_SECTION_DESCRIPTORS.map((descriptor) => { + const sorted = sortAttentionItems(grouped[descriptor.id]); + if (descriptor.id !== "done") return { ...descriptor, items: sorted }; + + // Idle roster history is the ambient tail even when its preserved phase + // has a numerically higher priority than a fresh completed outcome. + const live = sorted.filter((item) => activityItemTier(item) !== "idle"); + const idle = sorted.filter((item) => activityItemTier(item) === "idle"); + return { ...descriptor, items: [...live, ...idle] }; + }); +} + +/** The Activity badge is intentionally only the first, needs-you section. */ +export function activityBadgeCount(input: ActivityItemsInput, now = Date.now()): number { + return activitySections(input, now)[0]?.items.length ?? 0; +} + +export function activityHeadline(input: ActivityItemsInput, now = Date.now()): string { + const sections = activitySections(input, now); + const needsYou = sections[0]?.items.length ?? 0; + if (needsYou > 0) return `${needsYou} need${needsYou === 1 ? "s" : ""} you`; + const working = sections[1]?.items.length ?? 0; + if (working > 0) return `${working} working`; + const done = sections[2]?.items.length ?? 0; + if (done > 0) return `${done} done`; + return "All clear"; +} diff --git a/apps/desktop/src/renderer/components/attention/attentionNotchLocalSettings.ts b/apps/desktop/src/renderer/components/attention/attentionNotchLocalSettings.ts index 32bb87d8a..974d7aac3 100644 --- a/apps/desktop/src/renderer/components/attention/attentionNotchLocalSettings.ts +++ b/apps/desktop/src/renderer/components/attention/attentionNotchLocalSettings.ts @@ -126,6 +126,7 @@ export function normalizeAttentionPreferences( }, }, devices: preferences.devices ?? {}, + machines: preferences.machines ?? {}, projects: preferences.projects ?? {}, mutedSessionIds: preferences.mutedSessionIds ?? [], }; diff --git a/apps/desktop/src/renderer/components/attention/attentionPresentation.test.ts b/apps/desktop/src/renderer/components/attention/attentionPresentation.test.ts index a3af91fb3..6fb2a1149 100644 --- a/apps/desktop/src/renderer/components/attention/attentionPresentation.test.ts +++ b/apps/desktop/src/renderer/components/attention/attentionPresentation.test.ts @@ -1,12 +1,17 @@ import { describe, expect, it } from "vitest"; -import { attentionPhasePriority, type AttentionPhase } from "../../../shared/types"; +import { + attentionPhasePriority, + type AttentionItem, + type AttentionPhase, +} from "../../../shared/types"; import type { CanonicalSessionPhase } from "../../../shared/sessionCanonicalState"; import { sessionStatusPresentation } from "../../../shared/sessionStatusPresentation"; import { attentionPhaseIsSessionDerived, attentionPhasePresentation, attentionViewEmptyCopy, + activityItemPresentation, SESSION_DERIVED_ATTENTION_PHASES, type AttentionTone, } from "./attentionPresentation"; @@ -67,6 +72,35 @@ describe("attention phase presentation", () => { } }); + it("returns the complete canonical status presentation for every session-derived phase", () => { + const bridge: Record = { + starting: "starting", + running: "running", + needs_you: "needs_you", + completed: "ready", + failed: "failed", + stale: "stale", + }; + + for (const [attentionPhase, canonicalPhase] of Object.entries(bridge)) { + expect(activityItemPresentation({ phase: attentionPhase } as AttentionItem)) + .toEqual(sessionStatusPresentation(canonicalPhase)); + } + }); + + it("returns a complete status presentation for every PR-only phase", () => { + for (const phase of ALL_ATTENTION_PHASES.filter( + (candidate) => !attentionPhaseIsSessionDerived(candidate), + )) { + const presentation = activityItemPresentation({ phase } as AttentionItem); + expect(presentation).toMatchObject({ + label: attentionPhasePresentation(phase).label, + tone: attentionPhasePresentation(phase).tone, + showsElapsed: false, + }); + } + }); + it("labels work in motion 'Working' and clean outcomes 'Done'", () => { // The two renames the sidebar redesign turned on. Asserted literally as // well as via the bridge above, because these exact words appear in diff --git a/apps/desktop/src/renderer/components/attention/attentionPresentation.ts b/apps/desktop/src/renderer/components/attention/attentionPresentation.ts index 462503d51..cb35a3efa 100644 --- a/apps/desktop/src/renderer/components/attention/attentionPresentation.ts +++ b/apps/desktop/src/renderer/components/attention/attentionPresentation.ts @@ -1,8 +1,14 @@ -import type { AttentionActionKind, AttentionPhase } from "../../../shared/types"; +import type { + AttentionActionKind, + AttentionItem, + AttentionPhase, +} from "../../../shared/types"; import type { CanonicalSessionPhase } from "../../../shared/sessionCanonicalState"; import { sessionStatusPresentation, + type SessionStatusGlyph, type SessionStatusPresentation, + type SessionStatusTone, } from "../../../shared/sessionStatusPresentation"; /** @@ -146,7 +152,7 @@ const SESSION_DERIVED_PRESENTATION = Object.fromEntries( */ const NON_SESSION_PRESENTATION: Record< Exclude, - AttentionPhasePresentation + AttentionPhasePresentation & { tone: SessionStatusTone } > = { blocked: { label: "Blocked", tone: "neutral", active: false }, checks_failing: { label: "Checks failing", tone: "red", active: false }, @@ -167,6 +173,44 @@ export function attentionPhasePresentation(phase: AttentionPhase): AttentionPhas return PHASE_PRESENTATION[phase]; } +const NON_SESSION_STATUS_DETAILS: Record< + Exclude, + Pick +> = { + blocked: { glyph: null, showsElapsed: false, prominent: false }, + checks_failing: { glyph: "failed", showsElapsed: false, prominent: true }, + review_requested: { glyph: null, showsElapsed: false, prominent: true }, + changes_requested: { glyph: "failed", showsElapsed: false, prominent: true }, + merge_ready: { glyph: "done", showsElapsed: false, prominent: true }, + open: { glyph: null, showsElapsed: false, prominent: false }, + merged: { glyph: "done", showsElapsed: false, prominent: true }, + closed: { glyph: null, showsElapsed: false, prominent: false }, +}; + +/** + * Projects every Activity item into the same full status vocabulary used by a + * Work session row. Session phases delegate directly; PR-only phases add only + * the glyph/elapsed/prominence fields that the older phase presentation did + * not need. + */ +export function activityItemPresentation( + item: AttentionItem, +): SessionStatusPresentation | null { + if (attentionPhaseIsSessionDerived(item.phase)) { + return requireSessionPresentation(SESSION_PHASE_BY_ATTENTION_PHASE[item.phase]); + } + const presentation = NON_SESSION_PRESENTATION[item.phase]; + const details = NON_SESSION_STATUS_DETAILS[item.phase]; + const glyph: SessionStatusGlyph = details.glyph; + return { + label: presentation.label, + tone: presentation.tone, + glyph, + showsElapsed: details.showsElapsed, + prominent: details.prominent, + }; +} + export function attentionPhaseIsSessionDerived( phase: AttentionPhase, ): phase is SessionDerivedAttentionPhase { diff --git a/apps/desktop/src/renderer/components/settings/NotificationsSection.test.tsx b/apps/desktop/src/renderer/components/settings/NotificationsSection.test.tsx index 4ed3c756b..ad9c34910 100644 --- a/apps/desktop/src/renderer/components/settings/NotificationsSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/NotificationsSection.test.tsx @@ -71,6 +71,9 @@ describe("NotificationsSection", () => { // Other events must be untouched by a single-row edit. expect(saved.account.eventPolicies.agent_needs_you) .toBe(DEFAULT_ATTENTION_PREFERENCES.account.eventPolicies.agent_needs_you); + + expect(screen.getByRole("radiogroup", { name: "PR opened" })).toBeTruthy(); + expect(screen.getByRole("radiogroup", { name: "PR closed" })).toBeTruthy(); }); it("saves without a Save button", async () => { diff --git a/apps/desktop/src/renderer/components/settings/NotificationsSection.tsx b/apps/desktop/src/renderer/components/settings/NotificationsSection.tsx index 12321da9d..d2db048ac 100644 --- a/apps/desktop/src/renderer/components/settings/NotificationsSection.tsx +++ b/apps/desktop/src/renderer/components/settings/NotificationsSection.tsx @@ -5,6 +5,7 @@ import { type AttentionEventKind, type AttentionPreferences, } from "../../../shared/types/attention"; +import { ACTIVITY_EVENT_CATALOG } from "../../../shared/activityCatalog"; import { attentionNotchSettingsFromPreferences, normalizeAttentionPreferences, @@ -42,17 +43,15 @@ import { AgentCompletionSoundSection } from "./AgentCompletionSoundSection"; */ /** The events worth giving a user a dial for, in the order they'll scan them. */ -const EVENT_ROWS: { kind: AttentionEventKind; label: string; description: string }[] = [ - { kind: "agent_needs_you", label: "Agent asks a question", description: "A run is blocked waiting on your answer." }, - { kind: "agent_failed", label: "Agent fails", description: "A run stopped on an error." }, - { kind: "agent_completed", label: "Agent finishes", description: "A run reached the end of its turn." }, - { kind: "agent_running", label: "Agent starts working", description: "A run picked up your request." }, - { kind: "pr_checks_failing", label: "CI fails", description: "Checks went red on one of your PRs." }, - { kind: "pr_review_requested", label: "Review requested", description: "Someone asked you to review." }, - { kind: "pr_changes_requested", label: "Changes requested", description: "A reviewer asked for changes." }, - { kind: "pr_merge_ready", label: "PR ready to merge", description: "Checks passed and reviews are in." }, - { kind: "pr_merged", label: "PR merged", description: "One of your PRs landed." }, -]; +const EVENT_ROWS: readonly { + kind: AttentionEventKind; + label: string; + description: string; +}[] = ACTIVITY_EVENT_CATALOG.map(({ kind, label, description }) => ({ + kind, + label, + description, +})); const POLICY_OPTIONS: { value: AttentionDeliveryPolicy; label: string; hint: string }[] = [ { value: "off", label: "Off", hint: "Don't track" }, diff --git a/apps/desktop/src/renderer/components/terminals/SessionStatusLabel.tsx b/apps/desktop/src/renderer/components/terminals/SessionStatusLabel.tsx new file mode 100644 index 000000000..cb1a99bff --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/SessionStatusLabel.tsx @@ -0,0 +1,148 @@ +import React from "react"; +import { + Alarm, + CheckCircle, + Circle, + CircleDashed, + Clock, + NotePencil, + Moon, +} from "@phosphor-icons/react"; +import { + SESSION_TONE_TEXT_CLASS, + formatFutureDuration, + formatWorkingDuration, + type SessionStatusGlyph, + type SessionStatusPresentation, +} from "../../../shared/sessionStatusPresentation"; +import { cn } from "../ui/cn"; + +/** Renderer-only mapping from shared glyph identity to Phosphor icons. */ +function StatusGlyph({ glyph }: { glyph: SessionStatusGlyph }) { + switch (glyph) { + case "working": + return ; + case "planning": + return ; + case "waiting": + return ; + case "done": + return ; + // Filled, not outlined: "your move" is the one state allowed to shout. + case "needs-you": + return ; + case "stale": + return ; + case "woke": + return ; + case "snoozed": + return ; + // `failed` deliberately has no glyph — red plus the word is already the + // loudest thing on the row. + default: + return null; + } +} + +/** + * Live elapsed copy for the states where "how long" is the useful fact. + * The timestamp comes from the caller so this component stays independent of + * terminal-session models and renderer actions. + */ +function useElapsedLabel(sinceIso: string | null | undefined, enabled: boolean): string { + const sinceMs = React.useMemo(() => { + const parsed = sinceIso ? Date.parse(sinceIso) : Number.NaN; + return Number.isFinite(parsed) ? parsed : null; + }, [sinceIso]); + const [nowMs, setNowMs] = React.useState(() => Date.now()); + + React.useEffect(() => { + if (!enabled || sinceMs == null) return undefined; + setNowMs(Date.now()); + const intervalId = window.setInterval(() => setNowMs(Date.now()), 1_000); + return () => window.clearInterval(intervalId); + }, [enabled, sinceMs]); + + if (!enabled || sinceMs == null) return ""; + return formatWorkingDuration(nowMs - sinceMs); +} + +function useFutureLabel(atIso: string | null | undefined, enabled: boolean): string { + const atMs = React.useMemo(() => { + const parsed = atIso ? Date.parse(atIso) : Number.NaN; + return Number.isFinite(parsed) ? parsed : null; + }, [atIso]); + const [nowMs, setNowMs] = React.useState(() => Date.now()); + + React.useEffect(() => { + if (!enabled || atMs == null) return undefined; + setNowMs(Date.now()); + const intervalId = window.setInterval(() => setNowMs(Date.now()), 30_000); + return () => window.clearInterval(intervalId); + }, [atMs, enabled]); + + if (!enabled || atMs == null) return ""; + return formatFutureDuration(atMs, nowMs); +} + +export type SessionStatusLabelProps = { + presentation: SessionStatusPresentation | null; + elapsedSince?: string | null; + futureAt?: string | null; + timestampLabel: string; + compact: boolean; +}; + +/** + * Pure-props status vocabulary shared by session rows and upcoming Activity + * projections. It owns no session model, hover actions, or IPC. + */ +export function SessionStatusLabel({ + presentation, + elapsedSince, + futureAt, + timestampLabel, + compact, +}: SessionStatusLabelProps) { + const waiting = presentation?.glyph === "waiting"; + const elapsed = useElapsedLabel(elapsedSince, Boolean(presentation?.showsElapsed)); + const future = useFutureLabel(futureAt, waiting); + const exactWakeTitle = React.useMemo(() => { + if (!waiting || !futureAt) return undefined; + const wakeAt = Date.parse(futureAt); + return Number.isFinite(wakeAt) + ? `Next run ${new Date(wakeAt).toLocaleString()}` + : undefined; + }, [futureAt, waiting]); + + if (!presentation) { + return ( + + {timestampLabel} + + ); + } + + return ( + + + {/* Keep the ticker outside role=status so screen readers do not announce + the row again every second. */} + {presentation.label} + {elapsed ? {elapsed} : null} + {future ? {future} : null} + + ); +} diff --git a/apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx b/apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx index e519033bd..f2a127895 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx @@ -1,23 +1,10 @@ import React from "react"; import { - Alarm, ArrowUUpLeft, Check, - CheckCircle, - Circle, - CircleDashed, - Clock, - NotePencil, - Moon, } from "@phosphor-icons/react"; import type { OpenProjectBinding, TerminalSessionSummary } from "../../../shared/types"; -import { - SESSION_TONE_TEXT_CLASS, - formatFutureDuration, - formatWorkingDuration, - type SessionStatusGlyph, - type SessionStatusPresentation, -} from "../../../shared/sessionStatusPresentation"; +import type { SessionStatusPresentation } from "../../../shared/sessionStatusPresentation"; import { isChatToolType } from "../../lib/sessions"; import { canonicalInputFromSummary, @@ -25,6 +12,7 @@ import { } from "../../lib/terminalAttention"; import { cn } from "../ui/cn"; import { SessionSnoozeControl } from "./SessionSnoozeControl"; +import { SessionStatusLabel } from "./SessionStatusLabel"; import { settleSession, unsettleSession, @@ -47,79 +35,6 @@ import { * the attention center and iOS cannot drift into three different ambers. */ -/** - * Glyph identity → Phosphor. Kept here rather than in the shared presentation - * module: that module is imported by main-process code and must stay free of - * renderer dependencies. - */ -function StatusGlyph({ glyph }: { glyph: SessionStatusGlyph }) { - switch (glyph) { - case "working": - return ; - case "planning": - return ; - case "waiting": - return ; - case "done": - return ; - // Filled, not outlined: "your move" is the one state allowed to shout. - case "needs-you": - return ; - case "stale": - return ; - case "woke": - return ; - case "snoozed": - return ; - // `failed` deliberately has no glyph — red plus the word is already the - // loudest thing on the row. - default: - return null; - } -} - -/** - * Live elapsed copy for the states where "how long" is the question the row - * raises. Active chat turns count from their immutable turn-start timestamp; - * provider activity must not reset them. CLI and stale states retain the - * last-output clock because they do not have a provider turn boundary. - */ -function useElapsedLabel(sinceIso: string | null | undefined, enabled: boolean): string { - const sinceMs = React.useMemo(() => { - const parsed = sinceIso ? Date.parse(sinceIso) : Number.NaN; - return Number.isFinite(parsed) ? parsed : null; - }, [sinceIso]); - const [nowMs, setNowMs] = React.useState(() => Date.now()); - - React.useEffect(() => { - if (!enabled || sinceMs == null) return undefined; - setNowMs(Date.now()); - const intervalId = window.setInterval(() => setNowMs(Date.now()), 1_000); - return () => window.clearInterval(intervalId); - }, [enabled, sinceMs]); - - if (!enabled || sinceMs == null) return ""; - return formatWorkingDuration(nowMs - sinceMs); -} - -function useFutureLabel(atIso: string | null | undefined, enabled: boolean): string { - const atMs = React.useMemo(() => { - const parsed = atIso ? Date.parse(atIso) : Number.NaN; - return Number.isFinite(parsed) ? parsed : null; - }, [atIso]); - const [nowMs, setNowMs] = React.useState(() => Date.now()); - - React.useEffect(() => { - if (!enabled || atMs == null) return undefined; - setNowMs(Date.now()); - const intervalId = window.setInterval(() => setNowMs(Date.now()), 30_000); - return () => window.clearInterval(intervalId); - }, [atMs, enabled]); - - if (!enabled || atMs == null) return ""; - return formatFutureDuration(atMs, nowMs); -} - /** * The row action idiom, shared with `SessionSnoozeControl`'s trigger. * @@ -168,20 +83,10 @@ export function SessionStatusSlot({ if (!actionsEnabled) setSnoozeMenuOpen(false); }, [actionsEnabled]); - const waiting = presentation?.glyph === "waiting"; - const future = useFutureLabel(session.nextWakeAt, waiting); - const exactWakeTitle = React.useMemo(() => { - if (!waiting || !session.nextWakeAt) return undefined; - const wakeAt = Date.parse(session.nextWakeAt); - return Number.isFinite(wakeAt) - ? `Next run ${new Date(wakeAt).toLocaleString()}` - : undefined; - }, [session.nextWakeAt, waiting]); const canonicalPhase = sessionCanonicalUiState(canonicalInputFromSummary(session)).phase; const elapsedSince = canonicalPhase === "running" && isChatToolType(session.toolType) ? session.currentTurnStartedAt ?? session.lastActivityAt ?? session.startedAt : session.lastActivityAt ?? session.startedAt; - const elapsed = useElapsedLabel(elapsedSince, Boolean(presentation?.showsElapsed)); const isActivelyRunning = canonicalPhase === "starting" || canonicalPhase === "running" || canonicalPhase === "stale"; @@ -210,40 +115,13 @@ export function SessionStatusSlot({ compact ? "text-[10px]" : "text-[11px]", )} > - {presentation ? ( - - - {/* role="status" sits on the LABEL alone. Putting it on a wrapper - that also contains the ticking duration makes screen readers - announce the row once per second. */} - {presentation.label} - {elapsed ? ( - - {elapsed} - - ) : null} - {future ? ( - - {future} - - ) : null} - - ) : ( - {timestampLabel} - )} + {actionsEnabled ? ( diff --git a/apps/desktop/src/renderer/webclient/adapter/attention.ts b/apps/desktop/src/renderer/webclient/adapter/attention.ts index f5f21d3cb..52313c4b8 100644 --- a/apps/desktop/src/renderer/webclient/adapter/attention.ts +++ b/apps/desktop/src/renderer/webclient/adapter/attention.ts @@ -1,5 +1,7 @@ import { ATTENTION_CONTRACT_VERSION, + ATTENTION_EVENT_KINDS, + ATTENTION_PHASES, DEFAULT_ATTENTION_PREFERENCES, attentionDestinationDeepLink, type AttentionAction, @@ -32,37 +34,6 @@ type RelayResult = { body: unknown; }; -const ATTENTION_PHASES = new Set([ - "starting", - "running", - "needs_you", - "blocked", - "failed", - "completed", - "stale", - "checks_failing", - "review_requested", - "changes_requested", - "merge_ready", - "open", - "merged", - "closed", -]); - -const ATTENTION_EVENT_KINDS = new Set([ - "agent_running", - "agent_needs_you", - "agent_failed", - "agent_completed", - "pr_checks_failing", - "pr_review_requested", - "pr_changes_requested", - "pr_merge_ready", - "pr_merged", - "pr_opened", - "pr_closed", -]); - function record(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record @@ -179,8 +150,14 @@ function parseAttentionItem(value: unknown): AttentionItem | null { || !Number.isInteger(candidate.revision) || typeof candidate.fingerprint !== "string" || !["agent", "pull_request"].includes(String(candidate.kind)) - || !ATTENTION_EVENT_KINDS.has(candidate.eventKind as AttentionEventKind) - || !ATTENTION_PHASES.has(candidate.phase as AttentionPhase) + || !ATTENTION_EVENT_KINDS.includes(candidate.eventKind as AttentionEventKind) + || !ATTENTION_PHASES.includes(candidate.phase as AttentionPhase) + || ( + candidate.activityTier !== undefined + && !["signal", "ambient", "idle"].includes(String(candidate.activityTier)) + ) + || (candidate.contentFingerprint !== undefined && typeof candidate.contentFingerprint !== "string") + || (candidate.alertFingerprint !== undefined && typeof candidate.alertFingerprint !== "string") || !machine || !project || !destination @@ -198,6 +175,7 @@ function parseAttentionItem(value: unknown): AttentionItem | null { || !progressValid || typeof candidate.occurredAt !== "string" || typeof candidate.updatedAt !== "string" + || !optionalString(candidate.statusSince) || !optionalString(candidate.seenAt) || !optionalString(candidate.dismissedAt) || !optionalString(candidate.expiresAt) @@ -249,6 +227,7 @@ function parseAttentionSnapshot(value: unknown): AttentionSnapshot { || tombstones.some((item) => !item) || machines === null || machines?.some((machine) => !machine) + || (candidate.itemsTruncated !== undefined && typeof candidate.itemsTruncated !== "boolean") ) { throw new Error( "ADE Attention returned an incompatible response. Update ADE and retry.", @@ -262,6 +241,7 @@ function parseAttentionSnapshot(value: unknown): AttentionSnapshot { generatedAt: candidate.generatedAt, machines: machines as AttentionMachineRef[] | undefined, items: items as AttentionItem[], + itemsTruncated: candidate.itemsTruncated as boolean | undefined, tombstones: tombstones as AttentionTombstone[], }; } @@ -279,9 +259,9 @@ function isPreferenceScope(value: unknown, partial = false): value is AttentionP required("eventPolicies", (field) => { const policies = record(field); if (!policies) return false; - return (partial || [...ATTENTION_EVENT_KINDS].every((kind) => kind in policies)) + return (partial || ATTENTION_EVENT_KINDS.every((kind) => kind in policies)) && Object.entries(policies).every(([kind, policy]) => - ATTENTION_EVENT_KINDS.has(kind as AttentionEventKind) + ATTENTION_EVENT_KINDS.includes(kind as AttentionEventKind) && ["off", "ambient", "notify"].includes(String(policy))); }) && required("notificationsEnabled", (field) => typeof field === "boolean") @@ -292,6 +272,7 @@ function isPreferenceScope(value: unknown, partial = false): value is AttentionP && required("soundsEnabled", (field) => typeof field === "boolean") && required("celebrationsEnabled", (field) => typeof field === "boolean") && required("hideDetails", (field) => typeof field === "boolean") + && required("dockBadgeScope", (field) => field === "local" || field === "account") && required("quietHours", (field) => { const quietHours = record(field); return Boolean( @@ -308,12 +289,15 @@ function isPreferenceScope(value: unknown, partial = false): value is AttentionP function parseAttentionPreferences(value: unknown): AttentionPreferences { const candidate = record(value); const devices = record(candidate?.devices); + const machines = record(candidate?.machines); const projects = record(candidate?.projects); if ( !candidate || !isPreferenceScope(candidate.account) || !devices || !Object.values(devices).every((scope) => isPreferenceScope(scope, true)) + || !machines + || !Object.values(machines).every((scope) => isPreferenceScope(scope, true)) || !projects || !Object.values(projects).every((scope) => isPreferenceScope(scope, true)) || !Array.isArray(candidate.mutedSessionIds) diff --git a/apps/desktop/src/shared/activityCatalog.test.ts b/apps/desktop/src/shared/activityCatalog.test.ts new file mode 100644 index 000000000..34ea340d7 --- /dev/null +++ b/apps/desktop/src/shared/activityCatalog.test.ts @@ -0,0 +1,47 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + ACTIVITY_EVENT_BY_KIND, + ACTIVITY_EVENT_CATALOG, + type ActivityEventGroup, +} from "./activityCatalog"; +import { + ATTENTION_EVENT_KINDS, + BALANCED_ATTENTION_EVENT_POLICIES, + type AttentionDeliveryPolicy, + type AttentionEventKind, +} from "./types/attention"; + +type RawActivityEventDescriptor = { + kind: AttentionEventKind; + group: ActivityEventGroup; + defaultPolicy: AttentionDeliveryPolicy; +}; + +const RAW_ACTIVITY_EVENT_KINDS = JSON.parse( + readFileSync(new URL("./activityEventKinds.json", import.meta.url), "utf8"), +) as RawActivityEventDescriptor[]; + +describe("Activity event catalog", () => { + it("matches the ordered cross-platform JSON contract", () => { + expect(ACTIVITY_EVENT_CATALOG.map(({ kind, group, defaultPolicy }) => ({ + kind, + group, + defaultPolicy, + }))).toEqual(RAW_ACTIVITY_EVENT_KINDS); + }); + + it("covers every Attention event kind exactly once", () => { + const kinds = ACTIVITY_EVENT_CATALOG.map((descriptor) => descriptor.kind); + expect(kinds).toHaveLength(11); + expect(new Set(kinds).size).toBe(11); + expect(kinds.slice().sort()).toEqual([...ATTENTION_EVENT_KINDS].sort()); + expect(Object.keys(ACTIVITY_EVENT_BY_KIND).sort()).toEqual([...ATTENTION_EVENT_KINDS].sort()); + }); + + it("derives the balanced defaults from catalog policy", () => { + expect(BALANCED_ATTENTION_EVENT_POLICIES).toEqual(Object.fromEntries( + ACTIVITY_EVENT_CATALOG.map(({ kind, defaultPolicy }) => [kind, defaultPolicy]), + )); + }); +}); diff --git a/apps/desktop/src/shared/activityCatalog.ts b/apps/desktop/src/shared/activityCatalog.ts new file mode 100644 index 000000000..998828191 --- /dev/null +++ b/apps/desktop/src/shared/activityCatalog.ts @@ -0,0 +1,156 @@ +import type { + AttentionDeliveryPolicy, + AttentionEventKind, +} from "./types/attention"; + +export type ActivityEventGroup = "agents" | "pull_requests"; + +export type ActivityIconKey = + | "working" + | "needs-you" + | "failed" + | "done" + | "checks" + | "review" + | "changes" + | "merge-ready" + | "pull-request" + | "closed"; + +export type ActivityEventDescriptor = { + kind: AttentionEventKind; + group: ActivityEventGroup; + label: string; + description: string; + iconKey: ActivityIconKey; + defaultPolicy: AttentionDeliveryPolicy; + supportsAmbient: boolean; + order: number; +}; + +export const ACTIVITY_EVENT_GROUPS = [ + { id: "agents", label: "Agents" }, + { id: "pull_requests", label: "Pull requests" }, +] as const satisfies readonly { id: ActivityEventGroup; label: string }[]; + +/** + * One ordered source of truth for every event ADE can put in Activity. + * Existing Notifications copy stays verbatim so adopting the catalog is a + * structural refactor rather than a settings-page copy change. + */ +export const ACTIVITY_EVENT_CATALOG = [ + { + kind: "agent_needs_you", + group: "agents", + label: "Agent asks a question", + description: "A run is blocked waiting on your answer.", + iconKey: "needs-you", + defaultPolicy: "notify", + supportsAmbient: true, + order: 0, + }, + { + kind: "agent_failed", + group: "agents", + label: "Agent fails", + description: "A run stopped on an error.", + iconKey: "failed", + defaultPolicy: "notify", + supportsAmbient: true, + order: 1, + }, + { + kind: "agent_completed", + group: "agents", + label: "Agent finishes", + description: "A run reached the end of its turn.", + iconKey: "done", + defaultPolicy: "ambient", + supportsAmbient: true, + order: 2, + }, + { + kind: "agent_running", + group: "agents", + label: "Agent starts working", + description: "A run picked up your request.", + iconKey: "working", + defaultPolicy: "ambient", + supportsAmbient: true, + order: 3, + }, + { + kind: "pr_checks_failing", + group: "pull_requests", + label: "CI fails", + description: "Checks went red on one of your PRs.", + iconKey: "checks", + defaultPolicy: "notify", + supportsAmbient: true, + order: 4, + }, + { + kind: "pr_review_requested", + group: "pull_requests", + label: "Review requested", + description: "Someone asked you to review.", + iconKey: "review", + defaultPolicy: "notify", + supportsAmbient: true, + order: 5, + }, + { + kind: "pr_changes_requested", + group: "pull_requests", + label: "Changes requested", + description: "A reviewer asked for changes.", + iconKey: "changes", + defaultPolicy: "notify", + supportsAmbient: true, + order: 6, + }, + { + kind: "pr_merge_ready", + group: "pull_requests", + label: "PR ready to merge", + description: "Checks passed and reviews are in.", + iconKey: "merge-ready", + defaultPolicy: "notify", + supportsAmbient: true, + order: 7, + }, + { + kind: "pr_merged", + group: "pull_requests", + label: "PR merged", + description: "One of your PRs landed.", + iconKey: "done", + defaultPolicy: "ambient", + supportsAmbient: true, + order: 8, + }, + { + kind: "pr_opened", + group: "pull_requests", + label: "PR opened", + description: "One of your pull requests was opened.", + iconKey: "pull-request", + defaultPolicy: "ambient", + supportsAmbient: true, + order: 9, + }, + { + kind: "pr_closed", + group: "pull_requests", + label: "PR closed", + description: "One of your pull requests closed without merging.", + iconKey: "closed", + defaultPolicy: "ambient", + supportsAmbient: true, + order: 10, + }, +] as const satisfies readonly ActivityEventDescriptor[]; + +export const ACTIVITY_EVENT_BY_KIND = Object.fromEntries( + ACTIVITY_EVENT_CATALOG.map((descriptor) => [descriptor.kind, descriptor]), +) as Readonly>; diff --git a/apps/desktop/src/shared/activityEventKinds.json b/apps/desktop/src/shared/activityEventKinds.json new file mode 100644 index 000000000..411886de3 --- /dev/null +++ b/apps/desktop/src/shared/activityEventKinds.json @@ -0,0 +1,13 @@ +[ + { "kind": "agent_needs_you", "group": "agents", "defaultPolicy": "notify" }, + { "kind": "agent_failed", "group": "agents", "defaultPolicy": "notify" }, + { "kind": "agent_completed", "group": "agents", "defaultPolicy": "ambient" }, + { "kind": "agent_running", "group": "agents", "defaultPolicy": "ambient" }, + { "kind": "pr_checks_failing", "group": "pull_requests", "defaultPolicy": "notify" }, + { "kind": "pr_review_requested", "group": "pull_requests", "defaultPolicy": "notify" }, + { "kind": "pr_changes_requested", "group": "pull_requests", "defaultPolicy": "notify" }, + { "kind": "pr_merge_ready", "group": "pull_requests", "defaultPolicy": "notify" }, + { "kind": "pr_merged", "group": "pull_requests", "defaultPolicy": "ambient" }, + { "kind": "pr_opened", "group": "pull_requests", "defaultPolicy": "ambient" }, + { "kind": "pr_closed", "group": "pull_requests", "defaultPolicy": "ambient" } +] diff --git a/apps/desktop/src/shared/types/attention.test.ts b/apps/desktop/src/shared/types/attention.test.ts index 7dfb31d69..262fee373 100644 --- a/apps/desktop/src/shared/types/attention.test.ts +++ b/apps/desktop/src/shared/types/attention.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import { ATTENTION_CONTRACT_VERSION, + DEFAULT_ATTENTION_PREFERENCES, + activityItemIsAmbient, + activityItemTier, attentionDestinationDeepLink, attentionItemNeedsInbox, sanitizeAttentionPreview, @@ -55,6 +58,24 @@ describe("attention contract helpers", () => { expect(attentionItemNeedsInbox(item({ dismissedAt: "2026-07-28T10:05:00.000Z" }))).toBe(false); }); + it("keeps idle roster rows out of Inbox and derives legacy tiers by phase", () => { + const idleOutcome = item({ + phase: "completed", + eventKind: "agent_completed", + activityTier: "idle", + }); + expect(attentionItemNeedsInbox(idleOutcome)).toBe(false); + expect(activityItemTier(idleOutcome)).toBe("idle"); + expect(activityItemIsAmbient(idleOutcome)).toBe(true); + expect(activityItemTier(item({ phase: "needs_you" }))).toBe("signal"); + expect(activityItemTier(item({ phase: "running", eventKind: "agent_running" }))).toBe("ambient"); + }); + + it("defaults machine overrides empty and the dock badge to this Mac", () => { + expect(DEFAULT_ATTENTION_PREFERENCES.machines).toEqual({}); + expect(DEFAULT_ATTENTION_PREFERENCES.account.dockBadgeScope).toBe("local"); + }); + it("builds exact session and PR deep links", () => { expect(attentionDestinationDeepLink({ kind: "session", diff --git a/apps/desktop/src/shared/types/attention.ts b/apps/desktop/src/shared/types/attention.ts index 0d34048da..11bb2a8b3 100644 --- a/apps/desktop/src/shared/types/attention.ts +++ b/apps/desktop/src/shared/types/attention.ts @@ -1,35 +1,43 @@ +import { ACTIVITY_EVENT_CATALOG } from "../activityCatalog"; + export const ATTENTION_CONTRACT_VERSION = 1 as const; export type AttentionItemKind = "agent" | "pull_request"; -export type AttentionPhase = - | "starting" - | "running" - | "needs_you" - | "blocked" - | "failed" - | "completed" - | "stale" - | "checks_failing" - | "review_requested" - | "changes_requested" - | "merge_ready" - | "open" - | "merged" - | "closed"; - -export type AttentionEventKind = - | "agent_running" - | "agent_needs_you" - | "agent_failed" - | "agent_completed" - | "pr_checks_failing" - | "pr_review_requested" - | "pr_changes_requested" - | "pr_merge_ready" - | "pr_merged" - | "pr_opened" - | "pr_closed"; +export const ATTENTION_PHASES = [ + "starting", + "running", + "needs_you", + "blocked", + "failed", + "completed", + "stale", + "checks_failing", + "review_requested", + "changes_requested", + "merge_ready", + "open", + "merged", + "closed", +] as const; + +export type AttentionPhase = (typeof ATTENTION_PHASES)[number]; + +export const ATTENTION_EVENT_KINDS = [ + "agent_running", + "agent_needs_you", + "agent_failed", + "agent_completed", + "pr_checks_failing", + "pr_review_requested", + "pr_changes_requested", + "pr_merge_ready", + "pr_merged", + "pr_opened", + "pr_closed", +] as const; + +export type AttentionEventKind = (typeof ATTENTION_EVENT_KINDS)[number]; export type AttentionDeliveryPolicy = "off" | "ambient" | "notify"; @@ -95,6 +103,12 @@ export type AttentionItem = { id: string; revision: number; fingerprint: string; + /** Alert eligibility and Activity filing. Absent on legacy items. */ + activityTier?: "signal" | "ambient" | "idle"; + /** Stable identity for row-content changes. */ + contentFingerprint?: string; + /** Stable identity for alert deduplication. */ + alertFingerprint?: string; kind: AttentionItemKind; eventKind: AttentionEventKind; phase: AttentionPhase; @@ -118,6 +132,8 @@ export type AttentionItem = { actions: AttentionAction[]; occurredAt: string; updatedAt: string; + /** Immutable timestamp for the current phase, when the publisher has one. */ + statusSince?: string | null; seenAt: string | null; dismissedAt: string | null; expiresAt: string | null; @@ -160,6 +176,7 @@ export type AttentionSnapshot = { /** Current account-machine presence, returned even when no items changed. */ machines?: AttentionMachineRef[]; items: AttentionItem[]; + itemsTruncated?: boolean; tombstones?: AttentionTombstone[]; }; @@ -182,6 +199,7 @@ export type AttentionPreferenceScope = { soundsEnabled: boolean; celebrationsEnabled: boolean; hideDetails: boolean; + dockBadgeScope: "local" | "account"; quietHours: { enabled: boolean; startMinute: number; @@ -193,6 +211,7 @@ export type AttentionPreferenceScope = { export type AttentionPreferences = { account: AttentionPreferenceScope; devices: Record>; + machines: Record>; projects: Record>; mutedSessionIds: string[]; }; @@ -264,19 +283,9 @@ export type AttentionNotchAcknowledgeRequest = { export const BALANCED_ATTENTION_EVENT_POLICIES: Record< AttentionEventKind, AttentionDeliveryPolicy -> = { - agent_running: "ambient", - agent_needs_you: "notify", - agent_failed: "notify", - agent_completed: "ambient", - pr_checks_failing: "notify", - pr_review_requested: "notify", - pr_changes_requested: "notify", - pr_merge_ready: "notify", - pr_merged: "ambient", - pr_opened: "ambient", - pr_closed: "ambient", -}; +> = Object.fromEntries( + ACTIVITY_EVENT_CATALOG.map(({ kind, defaultPolicy }) => [kind, defaultPolicy]), +) as Record; export const DEFAULT_ATTENTION_PREFERENCES: AttentionPreferences = { account: { @@ -288,6 +297,7 @@ export const DEFAULT_ATTENTION_PREFERENCES: AttentionPreferences = { soundsEnabled: false, celebrationsEnabled: true, hideDetails: false, + dockBadgeScope: "local", quietHours: { enabled: false, startMinute: 22 * 60, @@ -296,11 +306,12 @@ export const DEFAULT_ATTENTION_PREFERENCES: AttentionPreferences = { }, }, devices: {}, + machines: {}, projects: {}, mutedSessionIds: [], }; -const ATTENTION_PHASE_PRIORITY: Record = { +export const ATTENTION_PHASE_PRIORITY: Readonly> = { needs_you: 0, failed: 1, checks_failing: 1, @@ -332,6 +343,7 @@ export function sortAttentionItems(items: readonly AttentionItem[]): AttentionIt } export function attentionItemNeedsInbox(item: AttentionItem): boolean { + if (activityItemTier(item) === "idle") return false; if (item.dismissedAt) return false; if ( item.phase === "needs_you" @@ -346,6 +358,31 @@ export function attentionItemNeedsInbox(item: AttentionItem): boolean { return (item.phase === "completed" || item.phase === "merged") && item.seenAt === null; } +/** + * Legacy snapshots predate the tier field. Derive the old signal/ambient split + * from the phase so a mixed-version fleet still files rows consistently. + */ +export function activityItemTier(item: AttentionItem): "signal" | "ambient" | "idle" { + if (item.activityTier) return item.activityTier; + switch (item.phase) { + case "needs_you": + case "blocked": + case "failed": + case "checks_failing": + case "review_requested": + case "changes_requested": + case "merge_ready": + return "signal"; + default: + return "ambient"; + } +} + +/** Idle rows are also ambient: neither tier is eligible to interrupt. */ +export function activityItemIsAmbient(item: AttentionItem): boolean { + return activityItemTier(item) !== "signal"; +} + export function attentionItemIsLive(item: AttentionItem): boolean { return ( item.phase === "starting" From 8dbbc85a63b6f7ea0de8513120f3fb3d1d753d07 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:41:54 -0400 Subject: [PATCH 03/19] =?UTF-8?q?activity(p0):=20relay=20bug-fix=20core=20?= =?UTF-8?q?=E2=80=94=20alert/content=20fingerprint=20split,=20durable=20al?= =?UTF-8?q?ert=20log,=20tier+staleness=20gates,=20machine=20mute=20scope,?= =?UTF-8?q?=20ack=20fencing,=20epoch=20reconcile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../migrations/0005_activity_feed.sql | 28 + apps/push-relay/src/attention.ts | 655 ++++++++++-- apps/push-relay/test/attention.test.ts | 932 +++++++++++++++++- 3 files changed, 1549 insertions(+), 66 deletions(-) create mode 100644 apps/push-relay/migrations/0005_activity_feed.sql diff --git a/apps/push-relay/migrations/0005_activity_feed.sql b/apps/push-relay/migrations/0005_activity_feed.sql new file mode 100644 index 000000000..65153c60a --- /dev/null +++ b/apps/push-relay/migrations/0005_activity_feed.sql @@ -0,0 +1,28 @@ +alter table attention_items add column content_fingerprint text; +alter table attention_items add column alert_fingerprint text; +alter table attention_items add column activity_tier text; +alter table attention_items add column roster_epoch integer not null default 0; + +update attention_items +set content_fingerprint = fingerprint, alert_fingerprint = fingerprint +where content_fingerprint is null; + +create index if not exists idx_attention_items_user_machine_epoch + on attention_items(user_id, machine_key, roster_epoch); + +create index if not exists idx_attention_items_user_alertable + on attention_items(user_id, activity_tier, seen_at, dismissed_at); + +create table if not exists attention_alert_log ( + user_id text not null, + alert_fingerprint text not null, + device_id text not null, + delivered_at text not null, + primary key(user_id, alert_fingerprint, device_id) +); + +create index if not exists idx_attention_alert_log_delivered + on attention_alert_log(delivered_at); + +create index if not exists idx_attention_delivery_receipts_delivered + on attention_delivery_receipts(delivered_at); diff --git a/apps/push-relay/src/attention.ts b/apps/push-relay/src/attention.ts index af5bab0de..c23071d43 100644 --- a/apps/push-relay/src/attention.ts +++ b/apps/push-relay/src/attention.ts @@ -74,6 +74,9 @@ type ParsedAttentionItem = Record & { id: string; revision: number; fingerprint: string; + contentFingerprint: string; + alertFingerprint: string; + activityTier?: "signal" | "ambient" | "idle"; kind: "agent" | "pull_request"; eventKind: string; phase: string; @@ -96,6 +99,8 @@ const MAX_BODY_BYTES = 256 * 1024; const MAX_ATTENTION_ITEMS = 64; const MAX_ATTENTION_TOMBSTONES = 64; const MAX_ATTENTION_DEVICES = 32; +const MAX_ATTENTION_MACHINE_PREFERENCES = 64; +const MAX_ACCOUNT_ATTENTION_ITEMS = 2_000; const ATTENTION_DEVICE_LEASE_MS = 30 * 24 * 60 * 60 * 1_000; const MAX_NOTIFICATION_ATTEMPTS_PER_PUBLISH = 64; const MAX_ID_LENGTH = 256; @@ -110,6 +115,9 @@ const TOMBSTONE_RETENTION_MS = 24 * 60 * 60 * 1_000; const MAX_OWNERSHIP_EPOCH_FUTURE_MS = 5 * 60 * 1_000; const LIVE_ACTIVITY_START_CLAIM_TTL_MS = 30_000; const NOTIFICATION_DELIVERY_CLAIM_TTL_MS = 60_000; +const MAX_ALERT_AGE_MS = 15 * 60_000; +const ATTENTION_DELIVERY_RECEIPT_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000; +const ATTENTION_ALERT_LOG_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000; const EVENT_KINDS = new Set([ "agent_running", @@ -478,14 +486,16 @@ function attentionItemUpsertStatement( userId: string, machineKey: string, item: ParsedAttentionItem, + rosterEpoch: number, ): D1PreparedStatement { return env.DB.prepare(` insert into attention_items( user_id, item_id, machine_key, source_revision, account_revision, - fingerprint, event_kind, phase, payload_json, seen_at, dismissed_at, + fingerprint, content_fingerprint, alert_fingerprint, activity_tier, + roster_epoch, event_kind, phase, payload_json, seen_at, dismissed_at, expires_at, updated_at ) - select ?, ?, ?, ?, revision, ?, ?, ?, ?, null, null, ?, ? + select ?, ?, ?, ?, revision, ?, ?, ?, ?, ?, ?, ?, ?, null, null, ?, ? from attention_revisions where user_id = ? and not exists ( @@ -500,15 +510,23 @@ function attentionItemUpsertStatement( source_revision = excluded.source_revision, account_revision = excluded.account_revision, fingerprint = excluded.fingerprint, + content_fingerprint = excluded.content_fingerprint, + alert_fingerprint = excluded.alert_fingerprint, + activity_tier = excluded.activity_tier, + roster_epoch = excluded.roster_epoch, event_kind = excluded.event_kind, phase = excluded.phase, payload_json = excluded.payload_json, seen_at = case - when attention_items.fingerprint = excluded.fingerprint then attention_items.seen_at + when coalesce(attention_items.alert_fingerprint, attention_items.fingerprint) + = excluded.alert_fingerprint + then attention_items.seen_at else null end, dismissed_at = case - when attention_items.fingerprint = excluded.fingerprint then attention_items.dismissed_at + when coalesce(attention_items.alert_fingerprint, attention_items.fingerprint) + = excluded.alert_fingerprint + then attention_items.dismissed_at else null end, expires_at = excluded.expires_at, @@ -519,7 +537,11 @@ function attentionItemUpsertStatement( item.id, machineKey, item.revision, - item.fingerprint, + item.contentFingerprint, + item.contentFingerprint, + item.alertFingerprint, + item.activityTier ?? null, + rosterEpoch, item.eventKind, item.phase, JSON.stringify(item), @@ -600,13 +622,20 @@ async function commitAttentionMachineChanges( items: ParsedAttentionItem[]; tombstones: IncomingAttentionTombstone[]; sealCapacityTombstones: boolean; + rosterEpoch?: number; now: string; }, ): Promise { const statements: D1PreparedStatement[] = []; for (const item of args.items) { statements.push( - attentionItemUpsertStatement(env, args.userId, args.machineKey, item), + attentionItemUpsertStatement( + env, + args.userId, + args.machineKey, + item, + args.rosterEpoch ?? 0, + ), attentionItemTombstoneDeleteStatement(env, args.userId, item), ); } @@ -636,12 +665,107 @@ async function commitAttentionMachineChanges( return commitAttentionRevision(env, args.userId, statements, args.now); } -function mergedDevicePreferences( +async function commitActivityReconcileFinal( + env: AttentionRelayEnv, + args: { + userId: string; + machineKey: string; + rosterEpoch: number; + now: string; + }, +): Promise { + return commitAttentionRevision(env, args.userId, [ + env.DB.prepare(` + insert into attention_tombstones( + user_id, item_id, source_revision, account_revision, revivable, deleted_at + ) + select user_id, item_id, source_revision, + (select revision from attention_revisions where user_id = ?), 0, ? + from attention_items + where user_id = ? and machine_key = ? and roster_epoch < ? + on conflict(user_id, item_id) do update set + source_revision = excluded.source_revision, + account_revision = excluded.account_revision, + revivable = 0, + deleted_at = excluded.deleted_at + where excluded.source_revision >= attention_tombstones.source_revision + `).bind( + args.userId, + args.now, + args.userId, + args.machineKey, + args.rosterEpoch, + ), + env.DB.prepare(` + delete from attention_items + where user_id = ? and machine_key = ? and roster_epoch < ? + `).bind(args.userId, args.machineKey, args.rosterEpoch), + ], args.now); +} + +async function enforceActivityAccountItemCap( + env: AttentionRelayEnv, + userId: string, + now: string, +): Promise<{ itemsTruncated: boolean; revision: number | null }> { + const countRow = await env.DB.prepare(` + select count(*) as count + from attention_items + where user_id = ? + `).bind(userId).first<{ count: number }>(); + const overflow = Math.max( + 0, + Number(countRow?.count ?? 0) - MAX_ACCOUNT_ATTENTION_ITEMS, + ); + if (overflow === 0) return { itemsTruncated: false, revision: null }; + + const idleCountRow = await env.DB.prepare(` + select count(*) as count + from attention_items + where user_id = ? and activity_tier = 'idle' + `).bind(userId).first<{ count: number }>(); + const rowsToRemove = Math.min(overflow, Number(idleCountRow?.count ?? 0)); + if (rowsToRemove === 0) return { itemsTruncated: true, revision: null }; + + const revision = await commitAttentionRevision(env, userId, [ + env.DB.prepare(` + insert into attention_tombstones( + user_id, item_id, source_revision, account_revision, revivable, deleted_at + ) + select user_id, item_id, source_revision, + (select revision from attention_revisions where user_id = ?), 0, ? + from attention_items + where user_id = ? and activity_tier = 'idle' + order by updated_at asc + limit ? + on conflict(user_id, item_id) do update set + source_revision = excluded.source_revision, + account_revision = excluded.account_revision, + revivable = 0, + deleted_at = excluded.deleted_at + where excluded.source_revision >= attention_tombstones.source_revision + `).bind(userId, now, userId, rowsToRemove), + env.DB.prepare(` + delete from attention_items + where user_id = ? and item_id in ( + select item_id + from attention_items + where user_id = ? and activity_tier = 'idle' + order by updated_at asc + limit ? + ) + `).bind(userId, userId, rowsToRemove), + ], now); + return { itemsTruncated: true, revision }; +} + +function resolveActivityDevicePreferences( device: AttentionDeviceRow, - accountPreferences: Record, - devicePreferences: Record, + preferences: Record, ): Record { const registered = readPreferences(device.preferences_json); + const accountPreferences = isRecord(preferences.account) ? preferences.account : {}; + const devicePreferences = isRecord(preferences.devices) ? preferences.devices : {}; const accountOverride = isRecord(devicePreferences[device.device_id]) ? devicePreferences[device.device_id] as Record : {}; @@ -653,6 +777,28 @@ function mergedDevicePreferences( return { ...registered, ...accountPreferences, ...accountOverride }; } +function resolveActivityDeliveryPreferences( + device: AttentionDeviceRow, + item: ParsedAttentionItem, + preferences: Record, +): Record { + const registered = readPreferences(device.preferences_json); + const account = isRecord(preferences.account) ? preferences.account : {}; + const projects = isRecord(preferences.projects) ? preferences.projects : {}; + const project = isRecord(projects[item.project.projectId]) + ? projects[item.project.projectId] as Record + : {}; + const machines = isRecord(preferences.machines) ? preferences.machines : {}; + const machine = isRecord(machines[item.machine.machineKey]) + ? machines[item.machine.machineKey] as Record + : {}; + const devices = isRecord(preferences.devices) ? preferences.devices : {}; + const explicitDevice = isRecord(devices[device.device_id]) + ? devices[device.device_id] as Record + : {}; + return { ...registered, ...account, ...project, ...machine, ...explicitDevice }; +} + function resolvedMutedSessionIds( device: AttentionDeviceRow, preferences: Record, @@ -843,9 +989,6 @@ async function deliverAttentionNotifications( const preferences = readPreferences(preferencesRow?.payload_json); const accountPreferences = isRecord(preferences.account) ? preferences.account : {}; - const eventPolicies = isRecord(accountPreferences.eventPolicies) - ? accountPreferences.eventPolicies - : {}; const devicePreferences = isRecord(preferences.devices) ? preferences.devices : {}; const nowMs = Date.now(); const desktopVisibleItemIds = await recentDesktopAttentionItemIds(env, userId, nowMs); @@ -859,15 +1002,13 @@ async function deliverAttentionNotifications( let notificationAttempts = 0; for (const item of items) { - const policy = typeof eventPolicies[item.eventKind] === "string" - ? eventPolicies[item.eventKind] - : DEFAULT_NOTIFY_EVENTS.has(item.eventKind) - ? "notify" - : "ambient"; - if (policy !== "notify") continue; + if (item.activityTier && item.activityTier !== "signal") continue; + if (nowMs - Date.parse(item.updatedAt) > MAX_ALERT_AGE_MS) continue; const current = await env.DB .prepare(` - select source_revision, fingerprint, seen_at, dismissed_at + select source_revision, fingerprint, + coalesce(alert_fingerprint, fingerprint) as alert_fingerprint, + seen_at, dismissed_at from attention_items where user_id = ? and item_id = ? limit 1 @@ -876,6 +1017,7 @@ async function deliverAttentionNotifications( .first<{ source_revision: number; fingerprint: string; + alert_fingerprint: string; seen_at: string | null; dismissed_at: string | null; }>(); @@ -886,7 +1028,8 @@ async function deliverAttentionNotifications( if ( !current || Number(current.source_revision) !== item.revision - || current.fingerprint !== item.fingerprint + || current.fingerprint !== item.contentFingerprint + || current.alert_fingerprint !== item.alertFingerprint || current.seen_at || current.dismissed_at ) { @@ -909,11 +1052,20 @@ async function deliverAttentionNotifications( for (const device of devicesResult.results) { if (notificationAttempts >= MAX_NOTIFICATION_ATTEMPTS_PER_PUBLISH) return; if (!device.apns_token) continue; - const override = mergedDevicePreferences( + const override = resolveActivityDeliveryPreferences( device, - accountPreferences, - devicePreferences, + item, + preferences, ); + const eventPolicies = isRecord(override.eventPolicies) + ? override.eventPolicies + : {}; + const policy = typeof eventPolicies[item.eventKind] === "string" + ? eventPolicies[item.eventKind] + : DEFAULT_NOTIFY_EVENTS.has(item.eventKind) + ? "notify" + : "ambient"; + if (policy !== "notify") continue; const notificationsEnabled = preferenceBoolean( override, {}, @@ -930,13 +1082,27 @@ async function deliverAttentionNotifications( ) { continue; } - const receiptState = `alert:${item.fingerprint.slice(0, 48)}`; + const receiptState = `alert:${item.alertFingerprint.slice(0, 48)}`; const existing = await env.DB.prepare(` - select 1 as found - from attention_delivery_receipts - where user_id = ? and item_id = ? and device_id = ? and state = ? + select 1 as found from ( + select 1 + from attention_delivery_receipts + where user_id = ? and item_id = ? and device_id = ? and state = ? + union all + select 1 + from attention_alert_log + where user_id = ? and alert_fingerprint = ? and device_id = ? + ) limit 1 - `).bind(userId, item.id, device.device_id, receiptState).first<{ found: number }>(); + `).bind( + userId, + item.id, + device.device_id, + receiptState, + userId, + item.alertFingerprint, + device.device_id, + ).first<{ found: number }>(); if (existing?.found) continue; const deliveryClaim = await claimAttentionNotificationDelivery(env, { userId, @@ -1004,6 +1170,17 @@ async function deliverAttentionNotifications( receiptState, new Date(nowMs).toISOString(), ), + env.DB.prepare(` + insert into attention_alert_log( + user_id, alert_fingerprint, device_id, delivered_at + ) values (?, ?, ?, ?) + on conflict(user_id, alert_fingerprint, device_id) do nothing + `).bind( + userId, + item.alertFingerprint, + device.device_id, + new Date(nowMs).toISOString(), + ), env.DB.prepare(` delete from attention_delivery_receipts where user_id = ? and item_id = ? and device_id = ? @@ -1311,7 +1488,6 @@ async function deliverAccountLiveActivity( ]); const preferences = readPreferences(preferencesRow?.payload_json); const accountPreferences = isRecord(preferences.account) ? preferences.account : {}; - const devicePreferences = isRecord(preferences.devices) ? preferences.devices : {}; const nowSeconds = Math.floor(Date.now() / 1_000); for (const device of devicesResult.results) { @@ -1319,11 +1495,7 @@ async function deliverAccountLiveActivity( // Account-wide ActivityKit delivery fails closed unless the current // registered device row is still owned by this exact account epoch. if (!Number.isSafeInteger(ownershipEpoch) || ownershipEpoch <= 0) continue; - const override = mergedDevicePreferences( - device, - accountPreferences, - devicePreferences, - ); + const override = resolveActivityDevicePreferences(device, preferences); const state = await env.DB.prepare(` select started, fingerprint from attention_activity_state @@ -1676,6 +1848,13 @@ function parseAttentionItem(value: unknown, machineKey: string): ParsedAttention const id = requiredString(value.id); const revision = Number(value.revision); const fingerprint = requiredString(value.fingerprint); + const contentFingerprint = value.contentFingerprint == null + ? fingerprint + : requiredString(value.contentFingerprint); + const alertFingerprint = value.alertFingerprint == null + ? fingerprint + : requiredString(value.alertFingerprint); + const activityTier = value.activityTier == null ? undefined : value.activityTier; const kind = value.kind; const eventKind = requiredString(value.eventKind, 64); const phase = requiredString(value.phase, 64); @@ -1690,6 +1869,14 @@ function parseAttentionItem(value: unknown, machineKey: string): ParsedAttention || !Number.isSafeInteger(revision) || revision < 0 || !fingerprint + || !contentFingerprint + || !alertFingerprint + || ( + activityTier !== undefined + && activityTier !== "signal" + && activityTier !== "ambient" + && activityTier !== "idle" + ) || (kind !== "agent" && kind !== "pull_request") || !eventKind || !EVENT_KINDS.has(eventKind) @@ -1886,7 +2073,10 @@ function parseAttentionItem(value: unknown, machineKey: string): ParsedAttention contractVersion: 1, id, revision, - fingerprint, + fingerprint: contentFingerprint, + contentFingerprint, + alertFingerprint, + ...(activityTier ? { activityTier } : {}), kind, eventKind, phase, @@ -1904,7 +2094,7 @@ function parseAttentionItem(value: unknown, machineKey: string): ParsedAttention actions: actions as Array>, updatedAt, occurredAt, - expiresAt, + expiresAt: activityTier === "idle" ? null : expiresAt, seenAt: null, dismissedAt: null, machine: { @@ -2308,6 +2498,127 @@ async function linkMachineToAccount( ]); } +async function refreshActivityMachinePresence( + env: AttentionRelayEnv, + args: { + userId: string; + machineKey: string; + machineName: string; + now: string; + }, +): Promise { + await env.DB.prepare(` + insert into attention_machine_links( + machine_key, user_id, machine_name, last_seen_at, linked_at, + legacy_devices_imported_at + ) values (?, ?, ?, ?, ?, null) + on conflict(machine_key) do update set + user_id = excluded.user_id, + machine_name = excluded.machine_name, + last_seen_at = excluded.last_seen_at + `).bind( + args.machineKey, + args.userId, + args.machineName, + args.now, + args.now, + ).run(); +} + +async function activityItemsForMachine( + env: AttentionRelayEnv, + userId: string, + machineKey: string, + now: string, +): Promise { + const rows = await env.DB.prepare(` + select payload_json + from attention_items + where user_id = ? and machine_key = ? + and seen_at is null and dismissed_at is null + and (expires_at is null or expires_at > ?) + order by updated_at desc + limit ? + `).bind( + userId, + machineKey, + now, + MAX_ACCOUNT_ATTENTION_ITEMS, + ).all<{ payload_json: string }>(); + return rows.results.flatMap((row) => { + try { + return [JSON.parse(row.payload_json) as ParsedAttentionItem]; + } catch { + return []; + } + }); +} + +type ActivityPublishAcknowledgment = { + itemId: string; + seenAt: string | null; + dismissedAt: string | null; + sourceRevision: number; +}; + +async function activityPublishAcknowledgments( + env: AttentionRelayEnv, + args: { + userId: string; + machineKey: string; + requestItems: ParsedAttentionItem[]; + }, +): Promise { + type AckRow = { + item_id: string; + seen_at: string | null; + dismissed_at: string | null; + source_revision: number; + account_revision: number; + }; + const requestRows = args.requestItems.length > 0 + ? await env.DB.prepare(` + select item_id, seen_at, dismissed_at, source_revision, account_revision + from attention_items + where user_id = ? and machine_key = ? + and item_id in (${args.requestItems.map(() => "?").join(", ")}) + `).bind( + args.userId, + args.machineKey, + ...args.requestItems.map((item) => item.id), + ).all() + : { results: [] as AckRow[] }; + const recentRows = await env.DB.prepare(` + select item_id, seen_at, dismissed_at, source_revision, account_revision + from attention_items + where user_id = ? and machine_key = ? + and (seen_at is not null or dismissed_at is not null) + order by account_revision desc + limit 64 + `).bind(args.userId, args.machineKey).all(); + const requestById = new Map(requestRows.results.map((row) => [row.item_id, row])); + const acknowledgments = args.requestItems.map((item) => { + const row = requestById.get(item.id); + return { + itemId: item.id, + seenAt: row?.seen_at ?? null, + dismissedAt: row?.dismissed_at ?? null, + sourceRevision: Number(row?.source_revision ?? item.revision), + }; + }); + const includedIds = new Set(acknowledgments.map((ack) => ack.itemId)); + for (const row of recentRows.results) { + if (includedIds.has(row.item_id)) continue; + acknowledgments.push({ + itemId: row.item_id, + seenAt: row.seen_at, + dismissedAt: row.dismissed_at, + sourceRevision: Number(row.source_revision), + }); + } + return acknowledgments; +} + export async function handleAttentionMachinePublish( request: Request, env: AttentionRelayEnv, @@ -2327,12 +2638,40 @@ export async function handleAttentionMachinePublish( } if (!isRecord(payload)) return json({ ok: false, error: "invalid payload" }, { status: 400 }); const machineName = boundedText(payload.machineName, 120) ?? "ADE machine"; + const mode = payload.mode === "delta" + || payload.mode === "reconcile" + || payload.mode === "presence" + ? payload.mode + : null; + if (payload.mode != null && !mode) { + return json({ ok: false, error: "invalid publish mode" }, { status: 400 }); + } const fullSnapshot = payload.fullSnapshot === true; + const rosterEpoch = mode ? Number(payload.rosterEpoch) : 0; + if (mode && (!Number.isSafeInteger(rosterEpoch) || rosterEpoch <= 0)) { + return json({ ok: false, error: "invalid roster epoch" }, { status: 400 }); + } + if (mode === "reconcile") { + if ( + payload.page != null + && (!Number.isSafeInteger(Number(payload.page)) || Number(payload.page) < 0) + ) { + return json({ ok: false, error: "invalid reconcile page" }, { status: 400 }); + } + if (payload.final != null && typeof payload.final !== "boolean") { + return json({ ok: false, error: "invalid reconcile final flag" }, { status: 400 }); + } + } else if (mode && (payload.page != null || payload.final != null)) { + return json({ ok: false, error: "page and final require reconcile mode" }, { status: 400 }); + } const rawItems = Array.isArray(payload.items) ? payload.items : []; const rawTombstones = Array.isArray(payload.tombstones) ? payload.tombstones : []; if (rawItems.length > MAX_ATTENTION_ITEMS || rawTombstones.length > MAX_ATTENTION_TOMBSTONES) { return json({ ok: false, error: "too many changes" }, { status: 400 }); } + if (mode === "presence" && (rawItems.length > 0 || rawTombstones.length > 0)) { + return json({ ok: false, error: "presence cannot write items" }, { status: 400 }); + } const items = rawItems.map((entry) => parseAttentionItem(entry, machineKey)); if (items.some((entry) => entry === null)) { return json({ ok: false, error: "invalid attention item" }, { status: 400 }); @@ -2369,7 +2708,7 @@ export async function handleAttentionMachinePublish( source_revision: number; fingerprint: string; }> = []; - if (fullSnapshot) { + if (!mode && fullSnapshot) { const existing = await env.DB.prepare(` select item_id, source_revision, fingerprint from attention_items @@ -2391,6 +2730,42 @@ export async function handleAttentionMachinePublish( } const tombstones = [...tombstonesById.values()]; const firstItem = items.find((entry): entry is ParsedAttentionItem => entry !== null); + const now = new Date().toISOString(); + if (mode === "presence") { + await refreshActivityMachinePresence(env, { + userId: account.userId, + machineKey, + machineName, + now, + }); + const storedItems = await activityItemsForMachine( + env, + account.userId, + machineKey, + now, + ); + await deliverAttentionNotifications(env, account.userId, storedItems); + const [current, acks] = await Promise.all([ + env.DB + .prepare("select revision from attention_revisions where user_id = ? limit 1") + .bind(account.userId) + .first<{ revision: number }>(), + activityPublishAcknowledgments(env, { + userId: account.userId, + machineKey, + requestItems: [], + }), + ]); + return json({ + ok: true, + protocol: 2, + revision: Number(current?.revision ?? 0), + acks, + upserted: 0, + removed: 0, + unchanged: true, + }); + } await linkMachineToAccount( env, account.userId, @@ -2398,7 +2773,8 @@ export async function handleAttentionMachinePublish( firstItem?.machine.name ?? machineName, ); if ( - fullSnapshot + !mode + && fullSnapshot && attentionFullSnapshotUnchanged( existingMachineItems, items as ParsedAttentionItem[], @@ -2414,40 +2790,67 @@ export async function handleAttentionMachinePublish( items as ParsedAttentionItem[], ); await deliverAccountLiveActivity(env, account.userId); - const current = await env.DB - .prepare("select revision from attention_revisions where user_id = ? limit 1") - .bind(account.userId) - .first<{ revision: number }>(); + const [current, acks] = await Promise.all([ + env.DB + .prepare("select revision from attention_revisions where user_id = ? limit 1") + .bind(account.userId) + .first<{ revision: number }>(), + activityPublishAcknowledgments(env, { + userId: account.userId, + machineKey, + requestItems: items as ParsedAttentionItem[], + }), + ]); return json({ ok: true, + protocol: 2, revision: Number(current?.revision ?? 0), + acks, upserted: 0, removed: 0, unchanged: true, }); } - const now = new Date().toISOString(); - const accountRevision = await commitAttentionMachineChanges(env, { + let accountRevision = await commitAttentionMachineChanges(env, { userId: account.userId, machineKey, items: items as ParsedAttentionItem[], tombstones, sealCapacityTombstones: - fullSnapshot && rawItems.length < MAX_ATTENTION_ITEMS, + !mode && fullSnapshot && rawItems.length < MAX_ATTENTION_ITEMS, + rosterEpoch, now, }); + if (mode === "reconcile" && payload.final === true) { + accountRevision = await commitActivityReconcileFinal(env, { + userId: account.userId, + machineKey, + rosterEpoch, + now, + }); + } + const cap = await enforceActivityAccountItemCap(env, account.userId, now); + if (cap.revision !== null) accountRevision = cap.revision; await deliverAttentionNotifications( env, account.userId, items as ParsedAttentionItem[], ); await deliverAccountLiveActivity(env, account.userId); + const acks = await activityPublishAcknowledgments(env, { + userId: account.userId, + machineKey, + requestItems: items as ParsedAttentionItem[], + }); return json({ ok: true, + protocol: 2, revision: accountRevision, + acks, upserted: items.length, removed: tombstones.length, + ...(cap.itemsTruncated ? { itemsTruncated: true } : {}), }); } @@ -2577,6 +2980,12 @@ async function handleAcknowledgment( if (!isRecord(payload) || !Array.isArray(payload.itemIds) || payload.itemIds.length > 64) { return json({ ok: false, error: "invalid acknowledgment" }, { status: 400 }); } + if ( + Object.prototype.hasOwnProperty.call(payload, "expectedAccountOwnerId") + && payload.expectedAccountOwnerId !== userId + ) { + return json({ ok: false, error: "account owner changed" }, { status: 409 }); + } const itemIds = payload.itemIds.map((value) => requiredString(value)); if (itemIds.some((value) => value === null)) { return json({ ok: false, error: "invalid item id" }, { status: 400 }); @@ -2586,6 +2995,31 @@ async function handleAcknowledgment( if (!seenAt || dismissedAt === undefined) { return json({ ok: false, error: "invalid timestamp" }, { status: 400 }); } + const hasSourceRevisions = Object.prototype.hasOwnProperty.call( + payload, + "sourceRevisions", + ); + if (hasSourceRevisions && !isRecord(payload.sourceRevisions)) { + return json({ ok: false, error: "invalid source revisions" }, { status: 400 }); + } + const sourceRevisions = hasSourceRevisions + ? payload.sourceRevisions as Record + : {}; + if ( + hasSourceRevisions + && ( + Object.keys(sourceRevisions).length > 64 + || Object.keys(sourceRevisions).some((itemId) => !(itemIds as string[]).includes(itemId)) + || (itemIds as string[]).some((itemId) => { + const revision = sourceRevisions[itemId]; + return typeof revision !== "number" + || !Number.isSafeInteger(revision) + || revision < 0; + }) + ) + ) { + return json({ ok: false, error: "invalid source revisions" }, { status: 400 }); + } if (itemIds.length === 0) { const current = await env.DB .prepare("select revision from attention_revisions where user_id = ? limit 1") @@ -2595,6 +3029,8 @@ async function handleAcknowledgment( ok: true, revision: Number(current?.revision ?? 0), itemIds, + applied: [], + stale: [], }); } const statements = (itemIds as string[]).map((itemId) => @@ -2615,6 +3051,8 @@ async function handleAcknowledgment( where user_id = ? ) where user_id = ? and item_id = ? + ${hasSourceRevisions ? "and source_revision <= ?" : ""} + returning item_id `).bind( seenAt, seenAt, @@ -2624,11 +3062,30 @@ async function handleAcknowledgment( userId, userId, itemId, + ...(hasSourceRevisions ? [Number(sourceRevisions[itemId])] : []), ), ); - const revision = await commitAttentionRevision(env, userId, statements); + const [revisionResult, ...mutationResults] = await env.DB.batch<{ + revision?: number; + item_id?: string; + }>([ + attentionRevisionBumpStatement(env, userId, new Date().toISOString()), + ...statements, + ]); + if (!revisionResult?.success || mutationResults.some((result) => !result.success)) { + throw new Error("attention acknowledgment transaction failed"); + } + const revision = Number(revisionResult.results[0]?.revision); + if (!Number.isSafeInteger(revision) || revision < 1) { + throw new Error("attention acknowledgment transaction did not return a revision"); + } + const applied = (itemIds as string[]).filter( + (_itemId, index) => mutationResults[index]?.results.length === 1, + ); + const appliedIds = new Set(applied); + const stale = (itemIds as string[]).filter((itemId) => !appliedIds.has(itemId)); await deliverAccountLiveActivity(env, userId); - return json({ ok: true, revision, itemIds }); + return json({ ok: true, revision, itemIds, applied, stale }); } async function handlePresence( @@ -2707,11 +3164,19 @@ async function handlePreferences( } if (!isRecord(payload)) return json({ ok: false, error: "invalid preferences" }, { status: 400 }); const preservesDevices = !Object.prototype.hasOwnProperty.call(payload, "devices"); + const preservesMachines = !Object.prototype.hasOwnProperty.call(payload, "machines"); + const preservesProjects = !Object.prototype.hasOwnProperty.call(payload, "projects"); const result = await mutateAttentionPreferences(env, userId, (current) => ({ ...payload, ...(preservesDevices && isRecord(current.devices) ? { devices: current.devices } : {}), + ...(preservesMachines && isRecord(current.machines) + ? { machines: current.machines } + : {}), + ...(preservesProjects && isRecord(current.projects) + ? { projects: current.projects } + : {}), })); if ("response" in result) return result.response; await deliverAccountLiveActivity(env, userId); @@ -2745,6 +3210,20 @@ async function mutateAttentionPreferences( } } const preferences = mutate(current); + if ( + preferences.machines != null + && ( + !isRecord(preferences.machines) + || Object.keys(preferences.machines).length > MAX_ATTENTION_MACHINE_PREFERENCES + ) + ) { + return { + response: json( + { ok: false, error: "invalid machine preferences" }, + { status: 400 }, + ), + }; + } const serialized = JSON.stringify(preferences); if (serialized.length > 32_000) { return { @@ -2821,6 +3300,47 @@ async function handleDevicePreferences( }); } +async function handleActivityMachinePreferences( + request: Request, + env: AttentionRelayEnv, + userId: string, + machineKey: string, +): Promise { + if (!requiredString(machineKey, 128)) { + return json({ ok: false, error: "invalid machine key" }, { status: 400 }); + } + let payload: unknown; + try { + payload = await request.json(); + } catch { + return json({ ok: false, error: "invalid json" }, { status: 400 }); + } + if (!isRecord(payload)) { + return json({ ok: false, error: "invalid machine preferences" }, { status: 400 }); + } + const result = await mutateAttentionPreferences(env, userId, (current) => { + const machines = isRecord(current.machines) ? current.machines : {}; + const machine = isRecord(machines[machineKey]) ? machines[machineKey] : {}; + return { + ...current, + machines: { + ...machines, + [machineKey]: { + ...machine, + ...payload, + }, + }, + }; + }); + if ("response" in result) return result.response; + await deliverAccountLiveActivity(env, userId); + return json({ + ok: true, + preferences: result.preferences, + updatedAt: result.updatedAt, + }); +} + async function deleteAttentionDeviceOwnership( env: AttentionRelayEnv, userId: string, @@ -3286,6 +3806,19 @@ async function handleAuthorizedAttentionAccountRequest( decodeURIComponent(route[2] ?? ""), ); } + if ( + route.length === 3 + && route[0] === "preferences" + && route[1] === "machines" + && request.method === "PATCH" + ) { + return await handleActivityMachinePreferences( + request, + env, + userId, + decodeURIComponent(route[2] ?? ""), + ); + } if ( route.length === 2 && route[0] === "devices" @@ -3336,6 +3869,12 @@ export async function pruneAttentionState(env: AttentionRelayEnv): Promise const now = new Date(); const tombstoneCutoff = new Date(now.getTime() - TOMBSTONE_RETENTION_MS).toISOString(); const presenceCutoff = new Date(now.getTime() - 10 * 60 * 1_000).toISOString(); + const receiptCutoff = new Date( + now.getTime() - ATTENTION_DELIVERY_RECEIPT_RETENTION_MS, + ).toISOString(); + const alertLogCutoff = new Date( + now.getTime() - ATTENTION_ALERT_LOG_RETENTION_MS, + ).toISOString(); const expiredDevices = await env.DB.prepare(` select user_id, device_id from attention_devices @@ -3348,17 +3887,12 @@ export async function pruneAttentionState(env: AttentionRelayEnv): Promise env.DB.batch([ env.DB.prepare(` delete from attention_delivery_receipts - where not exists ( - select 1 - from attention_items - where attention_items.user_id = attention_delivery_receipts.user_id - and attention_items.item_id = attention_delivery_receipts.item_id - and ( - attention_items.expires_at is null - or attention_items.expires_at > ? - ) - ) - `).bind(now.toISOString()), + where delivered_at <= ? + `).bind(receiptCutoff), + env.DB.prepare(` + delete from attention_alert_log + where delivered_at <= ? + `).bind(alertLogCutoff), env.DB.prepare("delete from attention_items where expires_at is not null and expires_at <= ?") .bind(now.toISOString()), ]), @@ -3374,11 +3908,13 @@ export async function pruneAttentionState(env: AttentionRelayEnv): Promise /** Pure contract helpers exposed only so relay tests can cover trust boundaries. */ export const attentionTestInternals = Object.freeze({ activityPullRequest, + activityPublishAcknowledgments, activityRun, attentionAlertRoutingPayload, attentionFullSnapshotUnchanged, attentionTombstoneBlocksItem, commitAttentionMachineChanges, + commitActivityReconcileFinal, deepLinkForItem, deliverAccountLiveActivity, deliverAttentionNotifications, @@ -3391,6 +3927,9 @@ export const attentionTestInternals = Object.freeze({ normalizedSnapshotCursor, parseAttentionItem, privacyPreservingActivityContentState, + refreshActivityMachinePresence, + resolveActivityDeliveryPreferences, sealCapacityTombstones, upsertAttentionTombstone, + MAX_ALERT_AGE_MS, }); diff --git a/apps/push-relay/test/attention.test.ts b/apps/push-relay/test/attention.test.ts index 9ecb2317d..d31820437 100644 --- a/apps/push-relay/test/attention.test.ts +++ b/apps/push-relay/test/attention.test.ts @@ -208,6 +208,7 @@ class SqliteD1Database { "../migrations/0002_rate_and_budget.sql", "../migrations/0003_account_attention.sql", "../migrations/0004_device_registration_generation.sql", + "../migrations/0005_activity_feed.sql", ]) { this.native.exec(readFileSync(new URL(migration, import.meta.url), "utf8")); } @@ -488,6 +489,56 @@ function validAgentItem(): Record { }; } +async function publishActivityForTest( + env: AttentionRelayEnv, + authorization: Awaited>, + payload: Record, +): Promise { + const body = new TextEncoder().encode(JSON.stringify(payload)).buffer as ArrayBuffer; + return await handleAttentionMachinePublish( + new Request("https://push.example/machines/activity/attention", { + method: "POST", + headers: { authorization: `Bearer ${authorization.token}` }, + }), + env, + MACHINE_KEY, + body, + ); +} + +function activityAgentItem( + args: { + sessionId: string; + itemId: string; + revision: number; + contentFingerprint: string; + alertFingerprint: string; + activityTier?: "signal" | "ambient" | "idle"; + updatedAt?: string; + expiresAt?: string | null; + preview?: string; + }, +): Record { + return { + ...validAgentItem(), + id: `agent:${MACHINE_KEY}:${args.sessionId}`, + revision: args.revision, + fingerprint: args.contentFingerprint, + contentFingerprint: args.contentFingerprint, + alertFingerprint: args.alertFingerprint, + ...(args.activityTier ? { activityTier: args.activityTier } : {}), + preview: args.preview ?? "The database migration is ready for review.", + updatedAt: args.updatedAt ?? "2026-07-28T08:00:05.000Z", + ...(args.expiresAt !== undefined ? { expiresAt: args.expiresAt } : {}), + destination: { + kind: "session", + sessionId: args.sessionId, + itemId: args.itemId, + eventId: `event-${args.itemId}`, + }, + }; +} + describe("account Attention contract", () => { it("resolves muted sessions device override then account then registration fallback", () => { const device = { @@ -624,6 +675,16 @@ describe("account Attention contract", () => { celebrationsEnabled: false, }, }, + projects: { + "project-a": { + hideDetails: true, + }, + }, + machines: { + [MACHINE_KEY]: { + notificationsEnabled: false, + }, + }, })); const [phoneAResponse, phoneBResponse, accountResponse] = await Promise.all([ @@ -656,11 +717,6 @@ describe("account Attention contract", () => { notificationsEnabled: true, hideDetails: true, }, - projects: { - "project-a": { - notificationsEnabled: false, - }, - }, }, ), ]); @@ -680,6 +736,11 @@ describe("account Attention contract", () => { }, projects: { "project-a": { + hideDetails: true, + }, + }, + machines: { + [MACHINE_KEY]: { notificationsEnabled: false, }, }, @@ -699,6 +760,850 @@ describe("account Attention contract", () => { } }); + it("preserves dismissal and suppresses re-alert when only content churns", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:01:00.000Z")); + const database = new SqliteD1Database(); + const authorization = await machinePublishAuthorization(); + let notificationSends = 0; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url === authorization.jwksUrl) return Response.json(authorization.jwks); + if (url.startsWith("https://api.sandbox.push.apple.com/")) { + notificationSends += 1; + return new Response(null, { + status: 200, + headers: { "apns-id": `content-churn-${notificationSends}` }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + })); + try { + insertAttentionDevice(database, { + userId: authorization.userId, + deviceId: "phone-content-churn", + apnsToken: "ab".repeat(32), + }); + const env = makeAttentionEnv(database, { + CLERK_JWKS_URL: authorization.jwksUrl, + CLERK_ISSUER: authorization.issuer, + CLERK_OAUTH_CLIENT_ID: "attention-test-client", + APNS_KEY: await generateTestP8(), + APNS_KEY_ID: "CHURNKEY12", + APNS_TEAM_ID: "CHURNTEAM1", + }); + const firstItem = activityAgentItem({ + sessionId: "session-churn", + itemId: "approval-stable", + revision: 7, + contentFingerprint: "content-before", + alertFingerprint: "alert-stable", + activityTier: "signal", + updatedAt: "2026-07-28T08:00:30.000Z", + }); + const first = await publishActivityForTest(env, authorization, { + machineName: "Studio", + mode: "delta", + rosterEpoch: 1, + items: [firstItem], + tombstones: [], + }); + expect(first.status).toBe(200); + expect(await first.json()).toMatchObject({ + protocol: 2, + acks: [{ + itemId: `agent:${MACHINE_KEY}:session-churn`, + seenAt: null, + dismissedAt: null, + sourceRevision: 7, + }], + }); + expect(notificationSends).toBe(1); + + const dismissedAt = "2026-07-28T08:00:40.000Z"; + const acknowledgment = await accountRoute( + database, + authorization.userId, + "POST", + "/attention/account/ack", + { + itemIds: [`agent:${MACHINE_KEY}:session-churn`], + sourceRevisions: { [`agent:${MACHINE_KEY}:session-churn`]: 7 }, + expectedAccountOwnerId: authorization.userId, + seenAt: dismissedAt, + dismissedAt, + }, + ); + expect(await acknowledgment.json()).toMatchObject({ + applied: [`agent:${MACHINE_KEY}:session-churn`], + stale: [], + }); + + const churned = activityAgentItem({ + sessionId: "session-churn", + itemId: "approval-stable", + revision: 8, + contentFingerprint: "content-after-preview-churn", + alertFingerprint: "alert-stable", + activityTier: "signal", + preview: "Elapsed 17.2s · processed 42 files.", + updatedAt: "2026-07-28T08:00:50.000Z", + }); + const second = await publishActivityForTest(env, authorization, { + machineName: "Studio", + mode: "delta", + rosterEpoch: 1, + items: [churned], + tombstones: [], + }); + expect(second.status).toBe(200); + expect(await second.json()).toMatchObject({ + protocol: 2, + acks: [{ + itemId: `agent:${MACHINE_KEY}:session-churn`, + dismissedAt, + sourceRevision: 8, + }], + }); + expect(row(database, ` + select content_fingerprint, alert_fingerprint, dismissed_at + from attention_items + where user_id = ? and item_id = ? + `, authorization.userId, `agent:${MACHINE_KEY}:session-churn`)).toEqual({ + content_fingerprint: "content-after-preview-churn", + alert_fingerprint: "alert-stable", + dismissed_at: dismissedAt, + }); + expect(notificationSends).toBe(1); + } finally { + database.close(); + } + }); + + it("resets dismissal and sends once for a new destination item identity", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:01:00.000Z")); + const database = new SqliteD1Database(); + const authorization = await machinePublishAuthorization(); + let notificationSends = 0; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url === authorization.jwksUrl) return Response.json(authorization.jwks); + if (url.startsWith("https://api.sandbox.push.apple.com/")) { + notificationSends += 1; + return new Response(null, { + status: 200, + headers: { "apns-id": `new-destination-${notificationSends}` }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + })); + try { + insertAttentionDevice(database, { + userId: authorization.userId, + deviceId: "phone-new-destination", + apnsToken: "cd".repeat(32), + }); + const env = makeAttentionEnv(database, { + CLERK_JWKS_URL: authorization.jwksUrl, + CLERK_ISSUER: authorization.issuer, + CLERK_OAUTH_CLIENT_ID: "attention-test-client", + APNS_KEY: await generateTestP8(), + APNS_KEY_ID: "DESTKEY123", + APNS_TEAM_ID: "DESTTEAM12", + }); + const itemId = `agent:${MACHINE_KEY}:session-new-destination`; + expect((await publishActivityForTest(env, authorization, { + machineName: "Studio", + mode: "delta", + rosterEpoch: 1, + items: [activityAgentItem({ + sessionId: "session-new-destination", + itemId: "question-1", + revision: 7, + contentFingerprint: "destination-content-1", + alertFingerprint: "destination-alert-1", + activityTier: "signal", + updatedAt: "2026-07-28T08:00:30.000Z", + })], + tombstones: [], + })).status).toBe(200); + expect(notificationSends).toBe(1); + expect((await accountRoute( + database, + authorization.userId, + "POST", + "/attention/account/ack", + { + itemIds: [itemId], + sourceRevisions: { [itemId]: 7 }, + expectedAccountOwnerId: authorization.userId, + seenAt: "2026-07-28T08:00:40.000Z", + dismissedAt: "2026-07-28T08:00:40.000Z", + }, + )).status).toBe(200); + + const response = await publishActivityForTest(env, authorization, { + machineName: "Studio", + mode: "delta", + rosterEpoch: 1, + items: [activityAgentItem({ + sessionId: "session-new-destination", + itemId: "question-2", + revision: 8, + contentFingerprint: "destination-content-2", + alertFingerprint: "destination-alert-2", + activityTier: "signal", + updatedAt: "2026-07-28T08:00:50.000Z", + })], + tombstones: [], + }); + expect(response.status).toBe(200); + expect(row(database, ` + select seen_at, dismissed_at, alert_fingerprint + from attention_items + where user_id = ? and item_id = ? + `, authorization.userId, itemId)).toEqual({ + seen_at: null, + dismissed_at: null, + alert_fingerprint: "destination-alert-2", + }); + expect(notificationSends).toBe(2); + } finally { + database.close(); + } + }); + + it("tombstones only rows absent from a completed paged reconcile epoch", async () => { + const database = new SqliteD1Database(); + const authorization = await machinePublishAuthorization(); + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url === authorization.jwksUrl) return Response.json(authorization.jwks); + throw new Error(`Unexpected fetch: ${url}`); + })); + const env = makeAttentionEnv(database, { + CLERK_JWKS_URL: authorization.jwksUrl, + CLERK_ISSUER: authorization.issuer, + CLERK_OAUTH_CLIENT_ID: "attention-test-client", + }); + const item = (sessionId: string) => activityAgentItem({ + sessionId, + itemId: `approval-${sessionId}`, + revision: 7, + contentFingerprint: `content-${sessionId}`, + alertFingerprint: `alert-${sessionId}`, + activityTier: "idle", + expiresAt: null, + }); + try { + expect((await publishActivityForTest(env, authorization, { + machineName: "Studio", + mode: "reconcile", + rosterEpoch: 10, + page: 0, + final: false, + items: [item("roster-1"), item("roster-2")], + tombstones: [], + })).status).toBe(200); + expect((await publishActivityForTest(env, authorization, { + machineName: "Studio", + mode: "reconcile", + rosterEpoch: 10, + page: 1, + final: true, + items: [item("roster-3")], + tombstones: [], + })).status).toBe(200); + expect(rows(database, ` + select item_id, roster_epoch + from attention_items + where user_id = ? + order by item_id + `, authorization.userId)).toEqual([ + { item_id: `agent:${MACHINE_KEY}:roster-1`, roster_epoch: 10 }, + { item_id: `agent:${MACHINE_KEY}:roster-2`, roster_epoch: 10 }, + { item_id: `agent:${MACHINE_KEY}:roster-3`, roster_epoch: 10 }, + ]); + + expect((await publishActivityForTest(env, authorization, { + machineName: "Studio", + mode: "reconcile", + rosterEpoch: 11, + page: 0, + final: false, + items: [item("roster-3")], + tombstones: [], + })).status).toBe(200); + expect((await publishActivityForTest(env, authorization, { + machineName: "Studio", + mode: "reconcile", + rosterEpoch: 11, + page: 1, + final: true, + items: [item("roster-1")], + tombstones: [], + })).status).toBe(200); + + expect(rows(database, ` + select item_id, roster_epoch + from attention_items + where user_id = ? + order by item_id + `, authorization.userId)).toEqual([ + { item_id: `agent:${MACHINE_KEY}:roster-1`, roster_epoch: 11 }, + { item_id: `agent:${MACHINE_KEY}:roster-3`, roster_epoch: 11 }, + ]); + expect(rows(database, ` + select item_id, revivable + from attention_tombstones + where user_id = ? + `, authorization.userId)).toEqual([{ + item_id: `agent:${MACHINE_KEY}:roster-2`, + revivable: 0, + }]); + } finally { + database.close(); + } + }); + + it("alerts only fresh signal-tier items", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:20:00.000Z")); + const database = new SqliteD1Database(); + const parse = (raw: Record) => { + const parsed = attentionTestInternals.parseAttentionItem(raw, MACHINE_KEY); + expect(parsed, "activity item must parse").not.toBeNull(); + if (!parsed) throw new Error("activity item did not parse"); + return parsed; + }; + const idle = parse(activityAgentItem({ + sessionId: "idle-tier", + itemId: "idle-tier", + revision: 1, + contentFingerprint: "idle-content", + alertFingerprint: "idle-alert", + activityTier: "idle", + updatedAt: "2026-07-28T08:19:00.000Z", + })); + const ambient = parse(activityAgentItem({ + sessionId: "ambient-tier", + itemId: "ambient-tier", + revision: 1, + contentFingerprint: "ambient-content", + alertFingerprint: "ambient-alert", + activityTier: "ambient", + updatedAt: "2026-07-28T08:19:00.000Z", + })); + const stale = parse(activityAgentItem({ + sessionId: "stale-signal", + itemId: "stale-signal", + revision: 1, + contentFingerprint: "stale-content", + alertFingerprint: "stale-alert", + activityTier: "signal", + updatedAt: "2026-07-28T08:04:59.999Z", + })); + const fresh = parse(activityAgentItem({ + sessionId: "fresh-signal", + itemId: "fresh-signal", + revision: 1, + contentFingerprint: "fresh-content", + alertFingerprint: "fresh-alert", + activityTier: "signal", + updatedAt: "2026-07-28T08:19:00.000Z", + })); + const sendPush = vi.fn(async () => ({ + ok: true, + status: 200, + apnsId: "fresh-signal-only", + reason: null, + tokenInvalid: false, + })); + try { + insertAttentionDevice(database, { + userId: "account-a", + deviceId: "phone-tier-gates", + apnsToken: "ab".repeat(32), + }); + await attentionTestInternals.commitAttentionMachineChanges( + makeAttentionEnv(database), + { + userId: "account-a", + machineKey: MACHINE_KEY, + items: [idle, ambient, stale, fresh], + tombstones: [], + sealCapacityTombstones: false, + rosterEpoch: 1, + now: "2026-07-28T08:20:00.000Z", + }, + ); + await attentionTestInternals.deliverAttentionNotifications( + makeAttentionEnv(database, { + APNS_KEY: "test-key", + APNS_KEY_ID: "TESTKEY123", + APNS_TEAM_ID: "TESTTEAM12", + }), + "account-a", + [idle, ambient, stale, fresh], + sendPush, + ); + expect(sendPush).toHaveBeenCalledTimes(1); + expect(rows(database, ` + select alert_fingerprint + from attention_alert_log + where user_id = 'account-a' + `)).toEqual([{ alert_fingerprint: "fresh-alert" }]); + } finally { + database.close(); + } + }); + + it("keeps machine-muted items in snapshots while device scope wins other fields", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:01:00.000Z")); + const database = new SqliteD1Database(); + const parsed = attentionTestInternals.parseAttentionItem(activityAgentItem({ + sessionId: "machine-muted", + itemId: "machine-muted", + revision: 1, + contentFingerprint: "machine-muted-content", + alertFingerprint: "machine-muted-alert", + activityTier: "signal", + updatedAt: "2026-07-28T08:00:30.000Z", + }), MACHINE_KEY); + expect(parsed, "machine-muted item must parse").not.toBeNull(); + if (!parsed) throw new Error("machine-muted item did not parse"); + const sendPush = vi.fn(async () => ({ + ok: true, + status: 200, + apnsId: "should-not-send", + reason: null, + tokenInvalid: false, + })); + try { + insertAttentionDevice(database, { + userId: "account-a", + deviceId: "phone-machine-muted", + apnsToken: "cd".repeat(32), + preferences: { soundsEnabled: false }, + }); + await attentionTestInternals.commitAttentionMachineChanges( + makeAttentionEnv(database), + { + userId: "account-a", + machineKey: MACHINE_KEY, + items: [parsed], + tombstones: [], + sealCapacityTombstones: false, + rosterEpoch: 1, + now: "2026-07-28T08:01:00.000Z", + }, + ); + expect((await accountRoute( + database, + "account-a", + "PATCH", + `/attention/account/preferences/machines/${MACHINE_KEY}`, + { notificationsEnabled: false, hideDetails: true }, + )).status).toBe(200); + expect((await accountRoute( + database, + "account-a", + "PATCH", + "/attention/account/preferences/devices/phone-machine-muted", + { soundsEnabled: true }, + )).status).toBe(200); + const storedPreferences = JSON.parse(row<{ payload_json: string }>(database, ` + select payload_json + from attention_preferences + where user_id = 'account-a' + `)?.payload_json ?? "{}") as Record; + expect(attentionTestInternals.resolveActivityDeliveryPreferences( + { + device_id: "phone-machine-muted", + apns_token: "cd".repeat(32), + push_to_start_token: null, + bundle_id: "com.ade.ios", + aps_environment: "sandbox", + preferences_json: JSON.stringify({ soundsEnabled: false }), + generation: "generation", + }, + parsed, + storedPreferences, + )).toMatchObject({ + notificationsEnabled: false, + hideDetails: true, + soundsEnabled: true, + }); + await attentionTestInternals.deliverAttentionNotifications( + makeAttentionEnv(database, { + APNS_KEY: "test-key", + APNS_KEY_ID: "TESTKEY123", + APNS_TEAM_ID: "TESTTEAM12", + }), + "account-a", + [parsed], + sendPush, + ); + const snapshot = await (await accountRoute( + database, + "account-a", + "GET", + "/attention/account/snapshot?since=0", + )).json() as { items: Array<{ id: string }> }; + expect(snapshot.items.map((item) => item.id)).toContain(parsed.id); + expect(sendPush).not.toHaveBeenCalled(); + + const tooManyMachines = Object.fromEntries( + Array.from({ length: 65 }, (_, index) => [ + `machine-${index}`, + { notificationsEnabled: false }, + ]), + ); + expect((await accountRoute( + database, + "account-a", + "PUT", + "/attention/account/preferences", + { machines: tooManyMachines }, + )).status).toBe(400); + } finally { + database.close(); + } + }); + + it("keeps durable alert history across prune and same-id device re-registration", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:01:00.000Z")); + const database = new SqliteD1Database(); + const authorization = await machinePublishAuthorization(); + let notificationSends = 0; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url === authorization.jwksUrl) return Response.json(authorization.jwks); + if (url.startsWith("https://api.sandbox.push.apple.com/")) { + notificationSends += 1; + return new Response(null, { + status: 200, + headers: { "apns-id": `durable-alert-${notificationSends}` }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + })); + const env = makeAttentionEnv(database, { + CLERK_JWKS_URL: authorization.jwksUrl, + CLERK_ISSUER: authorization.issuer, + CLERK_OAUTH_CLIENT_ID: "attention-test-client", + APNS_KEY: await generateTestP8(), + APNS_KEY_ID: "DURABLE123", + APNS_TEAM_ID: "DURABLE12", + }); + const publish = (revision: number, contentFingerprint: string) => + publishActivityForTest(env, authorization, { + machineName: "Studio", + mode: "delta", + rosterEpoch: 1, + items: [activityAgentItem({ + sessionId: "durable-alert", + itemId: "durable-alert", + revision, + contentFingerprint, + alertFingerprint: "durable-alert-identity", + activityTier: "signal", + updatedAt: "2026-07-28T08:00:30.000Z", + expiresAt: "2099-07-29T08:00:00.000Z", + })], + tombstones: [], + }); + try { + insertAttentionDevice(database, { + userId: authorization.userId, + deviceId: "phone-durable-alert", + apnsToken: "ef".repeat(32), + }); + expect((await publish(1, "durable-content-1")).status).toBe(200); + expect(notificationSends).toBe(1); + database.native.prepare(` + update attention_items + set expires_at = '2026-07-28T07:59:00.000Z' + where user_id = ? and item_id = ? + `).run(authorization.userId, `agent:${MACHINE_KEY}:durable-alert`); + database.native.prepare(` + update attention_delivery_receipts + set delivered_at = '2026-07-20T08:00:00.000Z' + where user_id = ? and device_id = 'phone-durable-alert' + `).run(authorization.userId); + database.native.prepare(` + update attention_alert_log + set delivered_at = '2026-07-08T08:00:00.000Z' + where user_id = ? and device_id = 'phone-durable-alert' + `).run(authorization.userId); + + await pruneAttentionState(env); + expect(rows(database, ` + select item_id from attention_items where user_id = ? + `, authorization.userId)).toEqual([]); + expect(rows(database, ` + select item_id from attention_delivery_receipts where user_id = ? + `, authorization.userId)).toEqual([]); + expect(rows(database, ` + select alert_fingerprint from attention_alert_log where user_id = ? + `, authorization.userId)).toEqual([{ + alert_fingerprint: "durable-alert-identity", + }]); + + expect((await publish(2, "durable-content-2")).status).toBe(200); + expect(notificationSends).toBe(1); + expect((await accountRoute( + database, + authorization.userId, + "DELETE", + "/attention/account/devices/phone-durable-alert", + { ownershipEpoch: 1, apnsToken: "ef".repeat(32) }, + )).status).toBe(200); + expect(rows(database, ` + select alert_fingerprint from attention_alert_log where user_id = ? + `, authorization.userId)).toHaveLength(1); + expect((await accountRoute( + database, + authorization.userId, + "PUT", + "/attention/account/devices/phone-durable-alert", + { + ownershipEpoch: 1, + apnsToken: "ef".repeat(32), + bundleId: "com.ade.ios", + apsEnvironment: "sandbox", + platform: "iOS", + }, + )).status).toBe(200); + expect((await publish(3, "durable-content-3")).status).toBe(200); + expect(notificationSends).toBe(1); + } finally { + database.close(); + } + }); + + it("fences stale acknowledgments and rejects account-owner mismatch", async () => { + const database = new SqliteD1Database(); + const parsed = attentionTestInternals.parseAttentionItem(activityAgentItem({ + sessionId: "ack-fence", + itemId: "ack-fence", + revision: 7, + contentFingerprint: "ack-content", + alertFingerprint: "ack-alert", + activityTier: "signal", + }), MACHINE_KEY); + expect(parsed, "ack-fence item must parse").not.toBeNull(); + if (!parsed) throw new Error("ack-fence item did not parse"); + try { + await attentionTestInternals.commitAttentionMachineChanges( + makeAttentionEnv(database), + { + userId: "account-a", + machineKey: MACHINE_KEY, + items: [parsed], + tombstones: [], + sealCapacityTombstones: false, + rosterEpoch: 1, + now: "2026-07-28T08:00:00.000Z", + }, + ); + const mismatch = await accountRoute( + database, + "account-a", + "POST", + "/attention/account/ack", + { + itemIds: [parsed.id], + sourceRevisions: { [parsed.id]: 7 }, + expectedAccountOwnerId: "account-b", + seenAt: "2026-07-28T08:01:00.000Z", + dismissedAt: null, + }, + ); + expect(mismatch.status).toBe(409); + expect(row(database, ` + select seen_at from attention_items where user_id = 'account-a' and item_id = ? + `, parsed.id)?.seen_at).toBeNull(); + + const stale = await accountRoute( + database, + "account-a", + "POST", + "/attention/account/ack", + { + itemIds: [parsed.id], + sourceRevisions: { [parsed.id]: 6 }, + expectedAccountOwnerId: "account-a", + seenAt: "2026-07-28T08:01:00.000Z", + dismissedAt: null, + }, + ); + expect(await stale.json()).toMatchObject({ + applied: [], + stale: [parsed.id], + }); + expect(row(database, ` + select seen_at from attention_items where user_id = 'account-a' and item_id = ? + `, parsed.id)?.seen_at).toBeNull(); + + const matching = await accountRoute( + database, + "account-a", + "POST", + "/attention/account/ack", + { + itemIds: [parsed.id], + sourceRevisions: { [parsed.id]: 7 }, + expectedAccountOwnerId: "account-a", + seenAt: "2026-07-28T08:02:00.000Z", + dismissedAt: null, + }, + ); + expect(await matching.json()).toMatchObject({ + applied: [parsed.id], + stale: [], + }); + expect(row(database, ` + select seen_at from attention_items where user_id = 'account-a' and item_id = ? + `, parsed.id)?.seen_at).toBe("2026-07-28T08:02:00.000Z"); + } finally { + database.close(); + } + }); + + it("handles presence with one link write and no item writes", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:01:00.000Z")); + const database = new SqliteD1Database(); + const authorization = await machinePublishAuthorization(); + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url === authorization.jwksUrl) return Response.json(authorization.jwks); + throw new Error(`Unexpected fetch: ${url}`); + })); + try { + database.native.prepare(` + insert into attention_machine_links( + machine_key, user_id, machine_name, last_seen_at, linked_at, + legacy_devices_imported_at + ) values (?, ?, 'Studio', '2026-07-28T08:00:00.000Z', + '2026-07-28T08:00:00.000Z', null) + `).run(MACHINE_KEY, authorization.userId); + const before = row<{ count: number }>(database, ` + select total_changes() as count + `)?.count ?? 0; + const response = await publishActivityForTest( + makeAttentionEnv(database, { + CLERK_JWKS_URL: authorization.jwksUrl, + CLERK_ISSUER: authorization.issuer, + CLERK_OAUTH_CLIENT_ID: "attention-test-client", + }), + authorization, + { + machineName: "Studio refreshed", + mode: "presence", + rosterEpoch: 12, + items: [], + tombstones: [], + }, + ); + const after = row<{ count: number }>(database, ` + select total_changes() as count + `)?.count ?? 0; + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + protocol: 2, + upserted: 0, + removed: 0, + acks: [], + }); + expect(after - before).toBe(1); + expect(rows(database, ` + select item_id from attention_items where user_id = ? + `, authorization.userId)).toEqual([]); + expect(row(database, ` + select machine_name, last_seen_at + from attention_machine_links + where machine_key = ? + `, MACHINE_KEY)).toEqual({ + machine_name: "Studio refreshed", + last_seen_at: "2026-07-28T08:01:00.000Z", + }); + } finally { + database.close(); + } + }); + + it("caps an account by tombstoning its oldest idle activity row", async () => { + const database = new SqliteD1Database(); + const authorization = await machinePublishAuthorization(); + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url === authorization.jwksUrl) return Response.json(authorization.jwks); + throw new Error(`Unexpected fetch: ${url}`); + })); + try { + const insertIdle = database.native.prepare(` + insert into attention_items( + user_id, item_id, machine_key, source_revision, account_revision, + fingerprint, content_fingerprint, alert_fingerprint, activity_tier, + roster_epoch, event_kind, phase, payload_json, seen_at, dismissed_at, + expires_at, updated_at + ) values (?, ?, ?, 1, 0, ?, ?, ?, 'idle', 1, 'agent_completed', + 'completed', '{}', null, null, null, ?) + `); + for (let index = 0; index < 2_000; index += 1) { + const fingerprint = `idle-fingerprint-${index}`; + insertIdle.run( + authorization.userId, + `idle-${index.toString().padStart(4, "0")}`, + MACHINE_KEY, + fingerprint, + fingerprint, + fingerprint, + new Date(Date.UTC(2026, 6, 1, 0, 0, index)).toISOString(), + ); + } + const response = await publishActivityForTest( + makeAttentionEnv(database, { + CLERK_JWKS_URL: authorization.jwksUrl, + CLERK_ISSUER: authorization.issuer, + CLERK_OAUTH_CLIENT_ID: "attention-test-client", + }), + authorization, + { + machineName: "Studio", + mode: "delta", + rosterEpoch: 2, + items: [activityAgentItem({ + sessionId: "cap-signal", + itemId: "cap-signal", + revision: 1, + contentFingerprint: "cap-signal-content", + alertFingerprint: "cap-signal-alert", + activityTier: "signal", + })], + tombstones: [], + }, + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ itemsTruncated: true }); + expect(row(database, ` + select count(*) as count from attention_items where user_id = ? + `, authorization.userId)?.count).toBe(2_000); + expect(row(database, ` + select revivable + from attention_tombstones + where user_id = ? and item_id = 'idle-0000' + `, authorization.userId)?.revivable).toBe(0); + } finally { + database.close(); + } + }); + it("retries a transient Live Activity start on an unchanged full-snapshot heartbeat", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-07-28T08:00:10.000Z")); @@ -1572,6 +2477,8 @@ describe("account Attention contract", () => { ok: true, revision: 4, itemIds: [], + applied: [], + stale: [], }); expect(row(database, ` select revision @@ -1875,6 +2782,10 @@ describe("account Attention contract", () => { delete from attention_delivery_receipts where user_id = 'account-a' and item_id = ? `).run(parsed.id); + database.native.prepare(` + delete from attention_alert_log + where user_id = 'account-a' and alert_fingerprint = ? + `).run(parsed.alertFingerprint); database.native.prepare(` update attention_presence set payload_json = ? @@ -3909,7 +4820,7 @@ describe("account Attention contract", () => { } }); - it("prunes delivery receipts without live Attention state for renewed devices", async () => { + it("prunes delivery receipts by age independently of Attention item state", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-07-28T12:00:00.000Z")); const database = new SqliteD1Database(); @@ -3942,7 +4853,8 @@ describe("account Attention contract", () => { ) values ('account-a', 'expired-item', 'active-phone', 'alert:expired', '2026-07-28T11:00:00.000Z'), ('account-a', 'active-item', 'active-phone', 'alert:active', '2026-07-28T11:00:00.000Z'), - ('account-a', 'removed-item', 'active-phone', 'alert:removed', '2026-07-28T11:00:00.000Z') + ('account-a', 'removed-item', 'active-phone', 'alert:removed', '2026-07-28T11:00:00.000Z'), + ('account-a', 'old-removed-item', 'active-phone', 'alert:old', '2026-07-20T11:00:00.000Z') `).run(); await pruneAttentionState(makeAttentionEnv(database)); @@ -3952,7 +4864,11 @@ describe("account Attention contract", () => { from attention_delivery_receipts where user_id = 'account-a' and device_id = 'active-phone' order by item_id - `)).toEqual([{ item_id: "active-item" }]); + `)).toEqual([ + { item_id: "active-item" }, + { item_id: "expired-item" }, + { item_id: "removed-item" }, + ]); expect(rows(database, ` select item_id from attention_items From 713b3f8ac72449b9e5b5c472d0fbaa3940469666 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:53:21 -0400 Subject: [PATCH 04/19] =?UTF-8?q?activity(p3):=20desktop=20popover=20?= =?UTF-8?q?=E2=80=94=20ActivityCard=20+=20HeaderActivityControl,=20priorit?= =?UTF-8?q?y-flat=20sections,=20dock=20badge=20scope,=20all-clear=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../renderer/components/app/TopBar.test.tsx | 6 +- .../src/renderer/components/app/TopBar.tsx | 14 +- .../attention/ActivityCard.test.tsx | 145 +++++ .../components/attention/ActivityCard.tsx | 230 ++++++++ .../attention/ActivityCardSkeleton.tsx | 35 ++ .../attention/AttentionSettingsPopover.tsx | 6 + .../attention/HeaderActivityControl.css | 480 ++++++++++++++++ .../attention/HeaderActivityControl.test.tsx | 427 ++++++++++++++ ...nControl.tsx => HeaderActivityControl.tsx} | 310 +++++----- .../attention/HeaderAttentionControl.css | 542 ------------------ .../attention/HeaderAttentionControl.test.tsx | 518 ----------------- .../attention/activityPriority.test.ts | 67 +++ .../components/attention/activityPriority.ts | 94 +++ .../attention/attentionHeaderSummary.ts | 195 ------- .../attention/attentionPresentation.ts | 7 +- .../components/attention/useAttentionSync.ts | 18 +- .../hooks/useAppWideSessionAttention.test.tsx | 218 +++++++ .../hooks/useAppWideSessionAttention.ts | 80 ++- .../src/renderer/state/attentionStore.ts | 38 ++ docs/features/web-client/README.md | 2 +- 20 files changed, 1973 insertions(+), 1459 deletions(-) create mode 100644 apps/desktop/src/renderer/components/attention/ActivityCard.test.tsx create mode 100644 apps/desktop/src/renderer/components/attention/ActivityCard.tsx create mode 100644 apps/desktop/src/renderer/components/attention/ActivityCardSkeleton.tsx create mode 100644 apps/desktop/src/renderer/components/attention/HeaderActivityControl.css create mode 100644 apps/desktop/src/renderer/components/attention/HeaderActivityControl.test.tsx rename apps/desktop/src/renderer/components/attention/{HeaderAttentionControl.tsx => HeaderActivityControl.tsx} (61%) delete mode 100644 apps/desktop/src/renderer/components/attention/HeaderAttentionControl.css delete mode 100644 apps/desktop/src/renderer/components/attention/HeaderAttentionControl.test.tsx delete mode 100644 apps/desktop/src/renderer/components/attention/attentionHeaderSummary.ts create mode 100644 apps/desktop/src/renderer/hooks/useAppWideSessionAttention.test.tsx diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index 59627ea5f..adb59b540 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -416,7 +416,7 @@ describe("TopBar", () => { publishAccountStatus(SIGNED_OUT_ACCOUNT); }); - it("carries account-wide Attention in the header and routes Open all to the center", () => { + it("carries account-wide Activity in the header and routes Open all to the center", () => { const needsYou = { contractVersion: ATTENTION_CONTRACT_VERSION, id: "needs-you", @@ -459,10 +459,10 @@ describe("TopBar", () => { render(); - const trigger = screen.getByTestId("header-attention-trigger"); + const trigger = screen.getByTestId("header-activity-trigger"); // The item belongs to another machine and project entirely — the header is // account-wide, not scoped to whatever project this window has open. - expect(trigger.getAttribute("aria-label")).toBe("Attention · 1 needs you"); + expect(trigger.getAttribute("aria-label")).toBe("Activity · 1 needs you"); fireEvent.click(trigger); fireEvent.click(screen.getByRole("button", { name: /Open all/ })); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index eb790692f..a62f62fc0 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -75,7 +75,7 @@ import { type ConnectionsPanelTab, } from "../../lib/connectionsPanel"; import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; -import { HeaderAttentionControl } from "../attention/HeaderAttentionControl"; +import { HeaderActivityControl } from "../attention/HeaderActivityControl"; import { HeaderUsageControl } from "../usage/HeaderUsageControl"; import { GlobalVoiceCaptureIndicator } from "../voice/GlobalVoiceCaptureIndicator"; import { appResourcePressureLevel, getAppResourceUsageCoalesced, resourcePressureDescription } from "../../lib/resourcePressure"; @@ -1586,8 +1586,10 @@ export function TopBar({ window.ade.app.newWindow().catch(() => {}); }, [isProjectBusy]); - // Attention is account-wide, so it never depends on a project being open. - const handleOpenAttentionCenter = useCallback(() => { + // Activity is account-wide, so it never depends on a project being open. + // P4: this becomes a shell-state flip that opens the Activity pane over the + // current tab; until that pane exists it still routes to the old center. + const handleOpenActivityPane = useCallback(() => { onNavigate?.("/attention"); }, [onNavigate]); @@ -2740,11 +2742,11 @@ export function TopBar({ ) : null} - {/* Trailing controls: attention · status · updates · utility cluster */} + {/* Trailing controls: activity · status · updates · utility cluster */}
- {/* Account-wide Attention — the one place every machine's work surfaces, + {/* Account-wide Activity — the one place every machine's work surfaces, reachable from every tab and project without a nav detour. */} - + {/* App-global voice capture — visible from any tab while recording. */} diff --git a/apps/desktop/src/renderer/components/attention/ActivityCard.test.tsx b/apps/desktop/src/renderer/components/attention/ActivityCard.test.tsx new file mode 100644 index 000000000..315a41ed3 --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/ActivityCard.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + ATTENTION_CONTRACT_VERSION, + type AttentionItem, + type AttentionPhase, +} from "../../../shared/types"; +import { ActivityCard } from "./ActivityCard"; + +afterEach(cleanup); + +function item(patch: Partial = {}): AttentionItem { + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + id: "item-a", + revision: 1, + fingerprint: "fingerprint-item-a", + kind: "agent", + eventKind: "agent_running", + phase: "running" as AttentionPhase, + machine: { + machineKey: "studio", + name: "Studio Mac", + online: true, + lastSeenAt: "2026-08-01T11:59:00.000Z", + }, + project: { projectId: "ade", name: "ADE", rootPath: "/repo/ade" }, + laneId: "lane-1", + laneName: "attention-revamp", + provider: "codex", + model: "gpt-5.6-sol", + title: "Rewrite the header popover", + preview: "Editing HeaderActivityControl.tsx", + privacyPreview: "Agent is working", + destination: { kind: "session", sessionId: "session-a" }, + actions: [], + occurredAt: "2026-08-01T11:58:00.000Z", + updatedAt: "2026-08-01T11:59:30.000Z", + seenAt: null, + dismissedAt: null, + expiresAt: null, + ...patch, + }; +} + +describe("ActivityCard", () => { + it("renders the whole row: lane, machine, status, title, note, and model", () => { + render(); + + const row = screen.getByRole("button"); + expect(row.getAttribute("data-activity-row")).toBe("item-a"); + expect(screen.getByText("attention-revamp")).toBeTruthy(); + expect(screen.getByText("Studio Mac")).toBeTruthy(); + expect(screen.getByText("Rewrite the header popover")).toBeTruthy(); + expect(screen.getByText("Editing HeaderActivityControl.tsx")).toBeTruthy(); + expect(screen.getByText("gpt-5.6-sol")).toBeTruthy(); + // The shared status vocabulary, not a second table: a running agent reads + // exactly as it does on a Work sidebar row, elapsed ticker included. + expect(screen.getByRole("status").textContent).toBe("Working"); + expect(row.getAttribute("data-activity-tone")).toBe("blue"); + }); + + it("anchors elapsed on statusSince so a cosmetic republish cannot reset it", () => { + const { container } = render( + , + ); + + expect(container.textContent).toContain("1m"); + }); + + it("shows only the redacted preview when hide-details is on", () => { + render(); + + expect(screen.getByText("Agent is working")).toBeTruthy(); + expect(screen.queryByText("Editing HeaderActivityControl.tsx")).toBeNull(); + }); + + it("dims an offline machine's row and says when it was last seen", () => { + render( + , + ); + + const chip = screen.getByText("MacBook Pro").parentElement; + expect(chip?.getAttribute("data-machine-online")).toBe("false"); + expect(chip?.getAttribute("title")).toContain("offline"); + expect(screen.getByRole("button").className).toContain("opacity-70"); + }); + + it("falls back to the project name when an item has no lane", () => { + render(); + + expect(screen.getByText("ADE")).toBeTruthy(); + }); + + it("hands the whole item back on open and does nothing else", () => { + const onOpen = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button")); + + expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ id: "item-a" })); + }); + + it("keeps the compact form to two lines without losing the status word", () => { + const { container } = render(); + + expect(container.querySelector(".h-\\[2\\.75rem\\]")).toBeTruthy(); + expect(container.querySelector(".h-\\[4\\.875rem\\]")).toBeNull(); + expect(screen.getByRole("status").textContent).toBe("Working"); + }); + + it("marks a pull request seen-state without pretending it has a provider", () => { + render( + , + ); + + expect(screen.getByRole("status").textContent).toBe("Ready to merge"); + expect(screen.queryByLabelText("Unseen")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/components/attention/ActivityCard.tsx b/apps/desktop/src/renderer/components/attention/ActivityCard.tsx new file mode 100644 index 000000000..eba83ffb5 --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/ActivityCard.tsx @@ -0,0 +1,230 @@ +import React from "react"; +import { DesktopTower, GitPullRequest, Laptop } from "@phosphor-icons/react"; + +import type { AttentionItem } from "../../../shared/types"; +import { relativeWhen } from "../../lib/format"; +import { ProviderLogo } from "../shared/ProviderLogos"; +import { SessionStatusLabel } from "../terminals/SessionStatusLabel"; +import { LaneIcon } from "../ui/vcsIcons"; +import { cn } from "../ui/cn"; +import { activityItemPresentation } from "./attentionPresentation"; + +/* ── Why this is not `terminals/SessionCard` ─────────────────────────────── + Three reasons, all of them load-bearing. Anyone tempted to "simplify" this + file by adapting an `AttentionItem` into a `TerminalSessionSummary` should + read them first — the shortcut is not cosmetic, it is a correctness bug. + + 1. WRONG-MACHINE MUTATIONS. `SessionCard` renders `SessionStatusSlot`, which + owns `SessionSnoozeControl` and calls `settleSession` / `unsettleSession` + against THIS Mac's local session service. An Activity row very often + belongs to another machine on the account. A fabricated summary would put + live settle/snooze buttons on it, and because session ids are not + globally unique per machine, the mutation could land on a same-id LOCAL + session. The status vocabulary is shared instead, through the pure + `SessionStatusLabel` extracted from that slot — words and hues cannot + drift, and no IPC comes along for the ride. + + 2. GEOMETRY IS A CONTRACT, NOT A STYLE. `SESSION_ROW_BLEED_CLASS` pays back a + measured 12px of `SessionListPane` ancestor inset plus a 6px webkit + scrollbar term, and it must sit on the element carrying + `content-visibility` because that implies paint containment. Inside a + popover with different padding the row would bleed under the panel + border. Activity rows are plain Tailwind and inset-neutral. + + 3. THE ADAPTER WOULD BE FICTION. `SessionCard` reads ~30 fields + `AttentionItem` does not have (`statusNote`, `lastOutputPreview`, `goal`, + `snoozedUntil`, `exitCode`, `runtimeState`, `orchestration*`, a whole + `LaneSummary`, …). It also pulls `useSessionDelta`, `useLaneNaming`, the + app store's project binding, and a work-grid drag source — every one of + them scoped to the open project, i.e. wrong for an account-wide feed. + + What IS shared: `shared/sessionStatusPresentation.ts` (the one-hue-one-meaning + table), `SessionStatusLabel`, and `activityItemPresentation()`. That is the + whole of the vocabulary and none of the machinery. + ────────────────────────────────────────────────────────────────────────── */ + +/** + * Machines only tell us their name, so the glyph is a read of that name rather + * than a hardware fact. It is decoration either way — the name beside it is the + * identity, and the chip is deliberately neutral because amber means "your + * move" everywhere in Activity. + */ +function MachineGlyph({ name, size }: { name: string; size: number }) { + const portable = /\b(?:macbook|laptop|air|book)\b/i.test(name); + const Glyph = portable ? Laptop : DesktopTower; + return ; +} + +function ActivityMachineChip({ item, size }: { item: AttentionItem; size: number }) { + const online = item.machine.online; + return ( + + + {item.machine.name} + + ); +} + +/** The provider mark, or the PR glyph for pull-request rows. */ +function ActivityAvatar({ item, size }: { item: AttentionItem; size: number }) { + if (item.kind === "pull_request") { + return ( + + ); + } + return ( + + ); +} + +/** + * The status note. `preview` is the agent's own words; `detail` is the + * publisher's fallback sentence. When the account has hide-details on, the + * publisher's already-redacted `privacyPreview` is the only line allowed out. + */ +export function activityCardPreview(item: AttentionItem, hideDetails: boolean): string { + if (hideDetails) return item.privacyPreview.trim(); + return (item.preview || item.detail || item.privacyPreview || "").trim(); +} + +export type ActivityCardProps = { + item: AttentionItem; + /** The row's only side effect. Navigation and acknowledgment live upstream. */ + onOpen: (item: AttentionItem) => void; + /** Mirrors the account's `hideDetails` preference. */ + hideDetails?: boolean; + /** Two-line form for dense mirrors (notch panel, mobile hub strip). */ + compact?: boolean; +}; + +/** + * One Activity row. `AttentionItem` in, `onOpen` out — no store reads, no IPC, + * no project scoping, so the same row renders in the header popover, the pane, + * and any surface that can hand it an item. + */ +export function ActivityCard({ + item, + onOpen, + hideDetails = false, + compact = false, +}: ActivityCardProps) { + const presentation = activityItemPresentation(item); + const tone = presentation?.tone ?? "neutral"; + const preview = activityCardPreview(item, hideDetails); + const laneLabel = item.laneName?.trim() || item.project.name; + const statusLabel = ( + + ); + + return ( + + ); +} + +export default ActivityCard; diff --git a/apps/desktop/src/renderer/components/attention/ActivityCardSkeleton.tsx b/apps/desktop/src/renderer/components/attention/ActivityCardSkeleton.tsx new file mode 100644 index 000000000..4a7d70e02 --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/ActivityCardSkeleton.tsx @@ -0,0 +1,35 @@ +import { cn } from "../ui/cn"; + +/** + * Same fixed heights as `ActivityCard`, so a popover that opens before the + * first snapshot lands does not resize under the pointer when it arrives. + */ +export function ActivityCardSkeleton({ compact = false }: { compact?: boolean }) { + const bar = "rounded-full bg-white/[0.07] motion-safe:animate-pulse"; + return ( +
+
+ + +
+
+ +
+ {compact ? null : ( +
+ + +
+ )} +
+ ); +} + +export default ActivityCardSkeleton; diff --git a/apps/desktop/src/renderer/components/attention/AttentionSettingsPopover.tsx b/apps/desktop/src/renderer/components/attention/AttentionSettingsPopover.tsx index 0b68982e7..96b01a8eb 100644 --- a/apps/desktop/src/renderer/components/attention/AttentionSettingsPopover.tsx +++ b/apps/desktop/src/renderer/components/attention/AttentionSettingsPopover.tsx @@ -33,6 +33,12 @@ import { } from "./attentionNotchLocalSettings"; import { useAccountStatus } from "../../lib/account"; import { navigateToAppTarget } from "../../lib/openExternal"; +// This component now has two mount points — the Attention center and the +// Activity header popover — so it carries its own `.attention-settings-*` +// styles instead of inheriting them from whichever parent happened to be +// mounted first. The import moves with the component when the center's +// stylesheet is retired. +import "./AttentionCenter.css"; const DESKTOP_FIRST_OPTIONS = [ { value: 0, label: "Immediately" }, diff --git a/apps/desktop/src/renderer/components/attention/HeaderActivityControl.css b/apps/desktop/src/renderer/components/attention/HeaderActivityControl.css new file mode 100644 index 000000000..2799e7d10 --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/HeaderActivityControl.css @@ -0,0 +1,480 @@ +/* The global-header Activity control. It borrows the shared status tone system + so a phase reads the same colour here as it does on a Work sidebar row, but + keeps its own compact type scale: this lives in a 28px header, not a page. + Every colour resolves through theme tokens so light mode is a token swap + rather than a second stylesheet. + + Rows are `ActivityCard`, i.e. Tailwind. Only the parts of a row that need a + per-tone colour — the hover rail, the focus ring, the unseen dot — live here, + driven by the `--tone-color` variable the tone class sets. */ + +.activity-hdr-trigger, +.activity-hdr-panel { + --tone-color: #a1a1aa; + --activity-hdr-fs-2xs: 9.5px; + --activity-hdr-fs-xs: 10.5px; + --activity-hdr-fs-sm: 11.5px; + --activity-hdr-fs-md: 12.5px; + --activity-hdr-surface: color-mix(in srgb, var(--color-card) 92%, var(--color-bg)); + --activity-hdr-hairline: color-mix(in srgb, var(--color-border) 70%, transparent); + --activity-hdr-shadow: 0 28px 70px -30px rgba(0, 0, 0, 0.8); +} + +.activity-hdr-trigger.activity-tone-amber, +.activity-hdr-panel .activity-tone-amber { --tone-color: #fbbf24; } +.activity-hdr-trigger.activity-tone-red, +.activity-hdr-panel .activity-tone-red { --tone-color: #f87171; } +.activity-hdr-trigger.activity-tone-violet, +.activity-hdr-panel .activity-tone-violet { --tone-color: #a78bfa; } +.activity-hdr-trigger.activity-tone-blue, +.activity-hdr-panel .activity-tone-blue { --tone-color: #60a5fa; } +.activity-hdr-trigger.activity-tone-emerald, +.activity-hdr-panel .activity-tone-emerald { --tone-color: #34d399; } +.activity-hdr-trigger.activity-tone-neutral, +.activity-hdr-panel .activity-tone-neutral { --tone-color: #a1a1aa; } + +/* 400-level tones sit near 1.7:1 on a white card; light mode uses the 600/700 + equivalents so pills and dots stay legible instead of washing out. */ +[data-theme="light"] .activity-hdr-trigger, +[data-theme="light"] .activity-hdr-panel { + --activity-hdr-shadow: 0 22px 55px -26px rgba(15, 23, 42, 0.3); +} +[data-theme="light"] .activity-hdr-trigger.activity-tone-amber, +[data-theme="light"] .activity-hdr-panel .activity-tone-amber { --tone-color: #b45309; } +[data-theme="light"] .activity-hdr-trigger.activity-tone-red, +[data-theme="light"] .activity-hdr-panel .activity-tone-red { --tone-color: #dc2626; } +[data-theme="light"] .activity-hdr-trigger.activity-tone-violet, +[data-theme="light"] .activity-hdr-panel .activity-tone-violet { --tone-color: #6d28d9; } +[data-theme="light"] .activity-hdr-trigger.activity-tone-blue, +[data-theme="light"] .activity-hdr-panel .activity-tone-blue { --tone-color: #1d4ed8; } +[data-theme="light"] .activity-hdr-trigger.activity-tone-emerald, +[data-theme="light"] .activity-hdr-panel .activity-tone-emerald { --tone-color: #047857; } +[data-theme="light"] .activity-hdr-trigger.activity-tone-neutral, +[data-theme="light"] .activity-hdr-panel .activity-tone-neutral { --tone-color: #52525b; } + +/* ---- trigger ---------------------------------------------------------- */ + +.activity-hdr-trigger { + height: 22px; + transition: + background-color 150ms ease, + border-color 150ms ease, + box-shadow 150ms ease, + color 150ms ease; +} + +.activity-hdr-trigger-icon { + color: var(--color-muted-fg); + transition: color 150ms ease; +} + +.activity-hdr-trigger[data-state="waiting"] { + border-color: color-mix(in srgb, var(--tone-color) 45%, transparent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--tone-color) 16%, transparent); +} + +/* The one amber in this file that is not a phase tone, and it earns it: a + degraded surface always carries an `availability.recovery` the user has to + perform (retry, sign in, update or restart the host). It is literally "your + move", which is the only meaning amber is allowed to carry — see the one-hue + rule in apps/desktop/src/shared/sessionStatusPresentation.ts. It cannot be + confused with a phase tone either: `data-state` is single-valued and + `degraded` outranks `waiting`. */ +.activity-hdr-trigger[data-state="degraded"] { + border-color: color-mix(in srgb, #f59e0b 42%, transparent); + box-shadow: 0 0 0 1px color-mix(in srgb, #f59e0b 13%, transparent); +} + +.activity-hdr-trigger[data-state="degraded"] .activity-hdr-trigger-icon { + color: #f59e0b; +} + +.activity-hdr-trigger[data-state="waiting"] .activity-hdr-trigger-icon, +.activity-hdr-trigger[data-state="live"] .activity-hdr-trigger-icon { + color: var(--tone-color); +} + +.activity-hdr-trigger[data-state="signed-out"] { + opacity: 0.75; +} + +.activity-hdr-trigger-count { + display: inline-flex; + min-width: 14px; + align-items: center; + justify-content: center; + padding: 0 3px; + border-radius: 999px; + background: var(--tone-color); + color: var(--color-bg); + font-family: var(--font-mono); + font-size: var(--activity-hdr-fs-2xs); + font-weight: 800; + line-height: 14px; + font-variant-numeric: tabular-nums; +} + +.activity-hdr-trigger-live { + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--tone-color); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--tone-color) 18%, transparent); + animation: activity-hdr-pulse 2.4s ease-in-out infinite; +} + +@keyframes activity-hdr-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.42; } +} + +/* ---- popover ---------------------------------------------------------- */ + +.activity-hdr-panel { + position: absolute; + right: 12px; + top: 40px; + display: flex; + width: min(420px, calc(100vw - 24px)); + max-height: min(600px, calc(100vh - 72px)); + flex-direction: column; + overflow: hidden; + border: 1px solid var(--activity-hdr-hairline); + border-radius: 14px; + background: var(--activity-hdr-surface); + box-shadow: var(--activity-hdr-shadow); + color: var(--color-fg); + animation: activity-hdr-enter 140ms ease-out; +} + +@keyframes activity-hdr-enter { + from { opacity: 0; transform: translateY(-6px) scale(0.985); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.activity-hdr-panel:focus-visible { + outline: none; +} + +.activity-hdr-panel-head { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 10px 10px 13px; + border-bottom: 1px solid var(--activity-hdr-hairline); +} + +.activity-hdr-panel-head h2 { + margin: 0; + min-width: 0; + flex: 1; + font-size: var(--activity-hdr-fs-md); + font-weight: 650; + letter-spacing: -0.01em; +} + +.activity-hdr-freshness { + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: 4px; + padding: 3px 7px; + border: 1px solid var(--activity-hdr-hairline); + border-radius: 999px; + background: color-mix(in srgb, var(--color-card) 60%, transparent); + color: var(--color-muted-fg); + font-size: var(--activity-hdr-fs-2xs); + font-weight: 600; +} + +button.activity-hdr-freshness { + cursor: pointer; +} + +.activity-hdr-freshness.is-error { + border-color: color-mix(in srgb, var(--color-error, #ef4444) 45%, transparent); + color: var(--color-error, #ef4444); +} + +.activity-hdr-spin { + animation: activity-hdr-spin 1.1s linear infinite; +} + +@keyframes activity-hdr-spin { + to { transform: rotate(360deg); } +} + +.activity-hdr-icon-button { + display: inline-flex; + height: 22px; + width: 22px; + flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: 7px; + color: var(--color-muted-fg); + transition: background-color 120ms ease, color 120ms ease; +} + +.activity-hdr-icon-button:hover { + background: color-mix(in srgb, var(--color-fg) 8%, transparent); + color: var(--color-fg); +} + +.activity-hdr-alert, +.activity-hdr-note { + display: flex; + align-items: flex-start; + gap: 7px; + padding: 8px 13px; + font-size: var(--activity-hdr-fs-xs); + line-height: 1.45; + border-bottom: 1px solid var(--activity-hdr-hairline); +} + +.activity-hdr-alert { + color: var(--color-error, #ef4444); + background: color-mix(in srgb, var(--color-error, #ef4444) 10%, transparent); +} + +.activity-hdr-note { + color: var(--color-muted-fg); + background: color-mix(in srgb, var(--color-fg) 4%, transparent); +} + +.activity-hdr-notch-health span { + flex: 1; +} + +.activity-hdr-notch-health button { + flex: 0 0 auto; + color: var(--color-accent); + font-weight: 650; +} + +.activity-hdr-notch-health button:hover, +.activity-hdr-notch-health button:focus-visible { + text-decoration: underline; + outline: none; +} + +.activity-hdr-body { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 2px; + overflow-y: auto; + padding: 6px; +} + +/* ---- sections --------------------------------------------------------- */ + +.activity-hdr-section { + display: flex; + flex-direction: column; + gap: 1px; +} + +.activity-hdr-section-heading { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + padding: 7px 7px 4px; + font-size: var(--activity-hdr-fs-2xs); + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--color-muted-fg); +} + +.activity-hdr-section-dot { + width: 6px; + height: 6px; + flex-shrink: 0; + border-radius: 999px; + background: var(--tone-color); +} + +.activity-hdr-section-count { + font-family: var(--font-mono); + font-size: var(--activity-hdr-fs-2xs); + font-variant-numeric: tabular-nums; + color: var(--tone-color); +} + +.activity-hdr-overflow { + display: inline-flex; + align-items: center; + gap: 4px; + align-self: flex-start; + margin: 2px 0 4px 8px; + padding: 2px 4px; + border-radius: 6px; + font-size: var(--activity-hdr-fs-xs); + font-weight: 600; + color: var(--color-muted-fg); + transition: color 120ms ease, background-color 120ms ease; +} + +.activity-hdr-overflow:hover, +.activity-hdr-overflow:focus-visible { + color: var(--color-fg); + background: color-mix(in srgb, var(--color-fg) 6%, transparent); + outline: none; +} + +/* ---- row chrome (the card itself is Tailwind) -------------------------- */ + +.activity-card { + transition: background-color 120ms ease, box-shadow 120ms ease; +} + +.activity-card::before { + content: ""; + position: absolute; + left: 0; + top: 8px; + bottom: 8px; + width: 2px; + border-radius: 999px; + background: var(--tone-color); + opacity: 0; + transition: opacity 120ms ease; +} + +.activity-card:hover, +.activity-card:focus-visible { + background: color-mix(in srgb, var(--color-fg) 6%, transparent); + outline: none; +} + +.activity-card:hover::before, +.activity-card:focus-visible::before { + opacity: 1; +} + +.activity-card:focus-visible { + box-shadow: 0 0 0 1px color-mix(in srgb, var(--tone-color) 55%, transparent); +} + +/* The lane is the row's identity line. Accent, not tone: a lane's colour must + not change because its agent's phase did. */ +.activity-card-lane { + color: color-mix(in srgb, var(--color-accent) 82%, var(--color-fg)); +} + +.activity-card-unseen { + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--tone-color); +} + +/* ---- empty states and footer ------------------------------------------ */ + +.activity-hdr-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 34px 26px 38px; + text-align: center; + color: var(--color-muted-fg); +} + +.activity-hdr-empty strong { + font-size: var(--activity-hdr-fs-md); + font-weight: 650; + color: var(--color-fg); +} + +.activity-hdr-empty p { + margin: 0; + max-width: 30ch; + font-size: var(--activity-hdr-fs-xs); + line-height: 1.5; +} + +/* All-clear is a state worth designing, not a gap to apologise for: one calm + emerald dot breathing at rest, no icon shouting an absence. */ +.activity-hdr-calm-dot { + position: relative; + width: 9px; + height: 9px; + margin-bottom: 4px; + border-radius: 999px; + background: color-mix(in srgb, #34d399 78%, transparent); +} + +.activity-hdr-calm-dot::after { + content: ""; + position: absolute; + inset: -6px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, #34d399 26%, transparent); + animation: activity-hdr-calm 3.6s ease-in-out infinite; +} + +@keyframes activity-hdr-calm { + 0%, 100% { opacity: 0.55; transform: scale(0.9); } + 50% { opacity: 0.15; transform: scale(1.12); } +} + +.activity-hdr-panel-foot { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 9px 8px 13px; + border-top: 1px solid var(--activity-hdr-hairline); + font-size: var(--activity-hdr-fs-xs); + color: var(--color-muted-fg); + font-variant-numeric: tabular-nums; +} + +.activity-hdr-panel-foot > span { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.activity-hdr-open-all { + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: 5px; + padding: 4px 9px; + border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--color-accent) 15%, transparent); + color: var(--color-accent); + font-size: var(--activity-hdr-fs-xs); + font-weight: 650; + transition: background-color 120ms ease, border-color 120ms ease; +} + +.activity-hdr-open-all:hover, +.activity-hdr-open-all:focus-visible { + background: color-mix(in srgb, var(--color-accent) 24%, transparent); + border-color: color-mix(in srgb, var(--color-accent) 55%, transparent); + outline: none; +} + +@media (prefers-reduced-motion: reduce) { + .activity-hdr-panel, + .activity-hdr-panel *, + .activity-hdr-trigger, + .activity-hdr-trigger * { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } + + .activity-hdr-trigger-live, + .activity-hdr-calm-dot::after { + animation: none; + } +} diff --git a/apps/desktop/src/renderer/components/attention/HeaderActivityControl.test.tsx b/apps/desktop/src/renderer/components/attention/HeaderActivityControl.test.tsx new file mode 100644 index 000000000..f32c64a84 --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/HeaderActivityControl.test.tsx @@ -0,0 +1,427 @@ +// @vitest-environment jsdom + +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ATTENTION_CONTRACT_VERSION, + DEFAULT_ATTENTION_PREFERENCES, + type AttentionItem, + type AttentionPhase, +} from "../../../shared/types"; +import { + attentionStore, + resetAttentionStoreForTests, +} from "../../state/attentionStore"; +import { publishAccountStatus, SIGNED_OUT_ACCOUNT } from "../../lib/account"; +import { HeaderActivityControl } from "./HeaderActivityControl"; + +const originalAde = window.ade; +const signedInAccount = { + signedIn: true as const, + userId: "account-a", + email: null, + name: null, + expiresAt: null, + provider: null, + imageUrl: null, +}; + +let openItem: ReturnType; +let acknowledge: ReturnType; +let getSnapshot: ReturnType; +let captureAnalytics: ReturnType; + +beforeEach(() => { + publishAccountStatus(signedInAccount); + openItem = vi.fn(async () => {}); + acknowledge = vi.fn(async () => {}); + captureAnalytics = vi.fn(async () => ({ accepted: true, reason: "accepted" })); + getSnapshot = vi.fn(async () => ({ + contractVersion: ATTENTION_CONTRACT_VERSION, + revision: attentionStore.getState().revision, + generatedAt: "2026-08-01T12:00:00.000Z", + items: Object.values(attentionStore.getState().itemsById), + })); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + account: { + ...(originalAde?.account ?? {}), + status: vi.fn(async () => signedInAccount), + }, + attention: { + openItem, + acknowledge, + getSnapshot, + getPreferences: vi.fn(async () => DEFAULT_ATTENTION_PREFERENCES), + }, + analytics: { capture: captureAnalytics }, + }, + }); +}); + +afterEach(() => { + cleanup(); + resetAttentionStoreForTests(); + publishAccountStatus(SIGNED_OUT_ACCOUNT); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: originalAde, + }); +}); + +function item( + id: string, + phase: AttentionPhase, + patch: Partial = {}, +): AttentionItem { + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + id, + revision: 1, + fingerprint: `fingerprint-${id}`, + kind: "agent", + eventKind: "agent_needs_you", + phase, + machine: { + machineKey: "studio", + name: "Studio Mac", + online: true, + lastSeenAt: "2026-08-01T11:59:00.000Z", + }, + project: { projectId: "ade", name: "ADE", rootPath: "/repo/ade" }, + laneName: `lane-${id}`, + provider: "codex", + model: "gpt-5.6-sol", + title: `Task ${id}`, + preview: "preview", + privacyPreview: "private preview", + destination: { kind: "session", sessionId: `session-${id}` }, + actions: [], + occurredAt: "2026-08-01T11:58:00.000Z", + updatedAt: "2026-08-01T11:58:00.000Z", + seenAt: null, + dismissedAt: null, + expiresAt: null, + ...patch, + }; +} + +function seedItems(items: AttentionItem[]): void { + attentionStore.setState({ + itemsById: Object.fromEntries(items.map((entry) => [entry.id, entry])), + generatedAt: "2026-08-01T12:00:00.000Z", + syncStatus: "ready", + }); +} + +function renderControl(onOpenPane = vi.fn()) { + render(); + return onOpenPane; +} + +function openPanel(): HTMLElement { + fireEvent.click(screen.getByTestId("header-activity-trigger")); + return screen.getByRole("dialog", { name: "Activity" }); +} + +describe("HeaderActivityControl", () => { + it("badges only work that needs you, and records a bounded header open", async () => { + seedItems([item("a", "needs_you"), item("b", "running"), item("c", "merge_ready")]); + renderControl(); + + const trigger = screen.getByTestId("header-activity-trigger"); + // merge_ready is a review, not a raised hand: it files under needs-you's + // priority band but the badge itself stays the needs-you count. + expect(trigger.textContent).toContain("2"); + expect(trigger.getAttribute("aria-label")).toBe("Activity · 2 need you · 1 working"); + expect(trigger.getAttribute("data-state")).toBe("waiting"); + + fireEvent.click(trigger); + await waitFor(() => { + // Analytics identity is deliberately unchanged by the rename. + expect(captureAnalytics).toHaveBeenCalledWith({ + event: "ade_feature_used", + properties: { + feature: "attention", + action: "header_opened", + outcome: "opened", + source: "renderer_route", + }, + dedupeKey: "attention_header_opened", + minimumIntervalMs: 60 * 60_000, + }); + }); + }); + + it("shows a live pulse without a count when nothing needs you", () => { + seedItems([item("b", "running")]); + renderControl(); + + const trigger = screen.getByTestId("header-activity-trigger"); + expect(trigger.getAttribute("data-state")).toBe("live"); + expect(trigger.textContent).toBe(""); + expect(trigger.getAttribute("aria-label")).toBe("Activity · 1 working"); + }); + + it("renders the three priority sections in order, and only those", () => { + seedItems([ + item("done", "completed"), + item("live", "running"), + item("asks", "needs_you"), + ]); + renderControl(); + const dialog = openPanel(); + + expect(attentionStore.getState().headerSurfaceVisible).toBe(true); + const sections = Array.from( + dialog.querySelectorAll("[data-activity-section]"), + ).map((section) => section.getAttribute("data-activity-section")); + expect(sections).toEqual(["needs-you", "working", "done"]); + expect( + Array.from(dialog.querySelectorAll("[data-activity-row]")).map((row) => + row.getAttribute("data-activity-row"), + ), + ).toEqual(["asks", "live", "done"]); + }); + + it("omits a section with nothing in it rather than showing an empty heading", () => { + seedItems([item("live", "running")]); + renderControl(); + const dialog = openPanel(); + + expect(dialog.querySelectorAll("[data-activity-section]").length).toBe(1); + expect(screen.queryByRole("heading", { name: /Needs you/ })).toBeNull(); + }); + + it("caps a section at six rows and offers the rest to the pane", () => { + seedItems( + Array.from({ length: 8 }, (_unused, index) => item(`n${index}`, "needs_you")), + ); + const onOpenPane = renderControl(); + const dialog = openPanel(); + + expect(dialog.querySelectorAll("[data-activity-row]").length).toBe(6); + const overflow = screen.getByRole("button", { name: /2 more/ }); + fireEvent.click(overflow); + expect(onOpenPane).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("designs the all-clear state instead of apologising for empty space", () => { + seedItems([]); + renderControl(); + const dialog = openPanel(); + + expect(screen.getByText("All agents idle")).toBeTruthy(); + expect(screen.getByText("Nothing needs you.")).toBeTruthy(); + expect(dialog.querySelector(".activity-hdr-calm-dot")).toBeTruthy(); + expect(screen.getByTestId("header-activity-trigger").getAttribute("aria-label")) + .toBe("Activity · all agents idle"); + }); + + it("never restates its own name in a filler caption", () => { + seedItems([item("a", "needs_you")]); + renderControl(); + const dialog = openPanel(); + + const head = dialog.querySelector(".activity-hdr-panel-head"); + expect(head?.querySelector("p")).toBeNull(); + expect(dialog.textContent).not.toContain("Attention is live"); + expect(dialog.textContent).not.toContain("Across every machine"); + }); + + it("counts sessions and machines in the footer and hands off to the pane", () => { + seedItems([ + item("a", "needs_you"), + item("b", "running", { + machine: { + machineKey: "laptop", + name: "MacBook Pro", + online: false, + lastSeenAt: "2026-08-01T10:00:00.000Z", + }, + }), + ]); + const onOpenPane = renderControl(); + const dialog = openPanel(); + + const footer = dialog.querySelector(".activity-hdr-panel-foot") as HTMLElement; + expect(within(footer).getByText("2 sessions · 1 of 2 machines online")).toBeTruthy(); + expect(dialog.textContent).toContain("last-known state from an offline machine"); + + fireEvent.click(within(footer).getByRole("button", { name: /Open all/ })); + expect(onOpenPane).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("opens the exact destination through the attention bridge, then marks it seen", async () => { + seedItems([item("a", "needs_you")]); + renderControl(); + openPanel(); + + fireEvent.click(screen.getByRole("button", { name: /Task a/ })); + + await waitFor(() => + expect(openItem).toHaveBeenCalledWith(expect.objectContaining({ id: "a" })), + ); + await waitFor(() => + expect(acknowledge).toHaveBeenCalledWith( + expect.objectContaining({ itemIds: ["a"] }), + ), + ); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + }); + + it("keeps the item unseen and explains a failed navigation", async () => { + seedItems([item("a", "needs_you")]); + openItem.mockRejectedValueOnce(new Error("Studio Mac is offline")); + renderControl(); + openPanel(); + + fireEvent.click(screen.getByRole("button", { name: /Task a/ })); + + await waitFor(() => + expect(screen.getByRole("alert").textContent).toContain("Studio Mac is offline"), + ); + expect(acknowledge).not.toHaveBeenCalled(); + expect(attentionStore.getState().itemsById.a?.seenAt).toBeNull(); + expect(screen.getByRole("dialog")).toBeTruthy(); + }); + + it("supports keyboard open, roving row navigation, and Escape returning focus", () => { + seedItems([item("a", "needs_you"), item("b", "needs_you")]); + renderControl(); + + const trigger = screen.getByTestId("header-activity-trigger"); + trigger.focus(); + fireEvent.keyDown(trigger, { key: "ArrowDown" }); + const dialog = screen.getByRole("dialog", { name: "Activity" }); + + fireEvent.keyDown(dialog, { key: "ArrowDown" }); + expect(document.activeElement?.getAttribute("data-activity-row")).toBe("a"); + fireEvent.keyDown(dialog, { key: "ArrowDown" }); + expect(document.activeElement?.getAttribute("data-activity-row")).toBe("b"); + fireEvent.keyDown(dialog, { key: "ArrowDown" }); + expect(document.activeElement?.getAttribute("data-activity-row")).toBe("a"); + fireEvent.keyDown(dialog, { key: "End" }); + expect(document.activeElement?.getAttribute("data-activity-row")).toBe("b"); + fireEvent.keyDown(dialog, { key: "Home" }); + expect(document.activeElement?.getAttribute("data-activity-row")).toBe("a"); + + fireEvent.keyDown(dialog, { key: "Escape" }); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it("hides agent text on every row when the account asks for hide-details", () => { + seedItems([item("a", "needs_you")]); + attentionStore.setState({ + preferences: { + ...DEFAULT_ATTENTION_PREFERENCES, + account: { ...DEFAULT_ATTENTION_PREFERENCES.account, hideDetails: true }, + }, + }); + renderControl(); + openPanel(); + + expect(screen.getByText("private preview")).toBeTruthy(); + expect(screen.queryByText("preview")).toBeNull(); + }); + + it("offers a retry instead of pretending a failed sync is current", async () => { + seedItems([item("a", "needs_you")]); + getSnapshot.mockRejectedValue(new Error("Relay unreachable")); + renderControl(); + + fireEvent.click(screen.getByTestId("header-activity-trigger")); + const retry = await screen.findByRole("button", { + name: /Attention is unavailable · Retry/, + }); + expect(getSnapshot).toHaveBeenCalledTimes(1); + + fireEvent.click(retry); + await waitFor(() => expect(getSnapshot).toHaveBeenCalledTimes(2)); + }); + + it("surfaces a missing native notch helper with recovery guidance", async () => { + seedItems([]); + const retry = vi.fn(async () => ({ + state: "missing" as const, + title: "ADE Notch needs reinstalling", + message: "Reinstall or update ADE, then restart the app.", + recovery: "reinstall_or_update" as const, + surface: null, + })); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(window.ade ?? {}), + attentionNotch: { + publishSnapshot: vi.fn(), + updateSettings: vi.fn(), + getHealth: retry, + retry, + onAcknowledgeRequested: vi.fn(() => () => {}), + }, + }, + }); + renderControl(); + + fireEvent.click(screen.getByTestId("header-activity-trigger")); + + expect(await screen.findByText("ADE Notch needs reinstalling")).toBeTruthy(); + expect(screen.getByText(/Reinstall or update ADE/)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Check again" })); + await waitFor(() => expect(retry).toHaveBeenCalledTimes(2)); + }); + + it("stays honest when signed out instead of showing an empty account", () => { + publishAccountStatus(SIGNED_OUT_ACCOUNT); + renderControl(); + + const trigger = screen.getByTestId("header-activity-trigger"); + expect(trigger.getAttribute("data-state")).toBe("signed-out"); + expect(trigger.getAttribute("aria-label")).toBe( + "Activity · sign in to sync across machines", + ); + + fireEvent.click(trigger); + expect( + screen.getByText(/Sign in to ADE to follow agents and pull requests/), + ).toBeTruthy(); + }); + + it("keeps machine-local work visible while signed out", () => { + publishAccountStatus(SIGNED_OUT_ACCOUNT); + seedItems([item("local", "needs_you")]); + attentionStore.setState({ + snapshotScope: "machine", + availability: { + state: "signed_out", + title: "Showing this Mac", + message: "Sign in to combine Activity across every ADE machine.", + recovery: "sign_in", + hostName: "This Mac", + }, + }); + renderControl(); + + const trigger = screen.getByTestId("header-activity-trigger"); + expect(trigger.getAttribute("data-state")).toBe("waiting"); + expect(trigger.textContent).toContain("1"); + expect(trigger.getAttribute("aria-label")).toContain("this machine only"); + + fireEvent.click(trigger); + expect(screen.getByRole("heading", { name: /Needs you/ })).toBeTruthy(); + expect( + screen.getByText(/Sign in to combine Activity across every ADE machine/), + ).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.tsx b/apps/desktop/src/renderer/components/attention/HeaderActivityControl.tsx similarity index 61% rename from apps/desktop/src/renderer/components/attention/HeaderAttentionControl.tsx rename to apps/desktop/src/renderer/components/attention/HeaderActivityControl.tsx index 7c386b084..0bf6520dd 100644 --- a/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.tsx +++ b/apps/desktop/src/renderer/components/attention/HeaderActivityControl.tsx @@ -5,7 +5,6 @@ import { ArrowRight, BellRinging, BellSimpleSlash, - GitPullRequest, WarningCircle, WifiHigh, WifiSlash, @@ -27,114 +26,74 @@ import { import { acknowledgeAttentionItem, attentionStore, + selectActivityHideDetails, useAttentionStore, } from "../../state/attentionStore"; import { useDialogFocusTrap } from "../app/HeaderSheet"; -import { ProviderLogo } from "../shared/ProviderLogos"; import { cn } from "../ui/cn"; +import { ActivityCard } from "./ActivityCard"; +import { AttentionSettingsPopover } from "./AttentionSettingsPopover"; import { - attentionPhasePresentation, - type AttentionTone, -} from "./attentionPresentation"; -import { - attentionHeaderTriggerLabel, - summarizeAttentionForHeader, - type AttentionHeaderBucket, -} from "./attentionHeaderSummary"; + ACTIVITY_SECTION_TONE, + activityTriggerLabel, + summarizeActivity, + type ActivitySection, +} from "./activityPriority"; import { refreshAttentionSnapshot } from "./useAttentionSync"; -import "./HeaderAttentionControl.css"; +import "./HeaderActivityControl.css"; -/** Rows shown per section before the overflow hands off to the full center. */ -const MAX_ROWS_PER_BUCKET = 4; +/** + * Rows shown per section before the overflow line hands off to the pane. Six, + * not four: the sections are now priority-flat, so a single "Working" section + * routinely carries what three buckets used to split. + */ +const MAX_ROWS_PER_SECTION = 6; const RELATIVE_TIME_TICK_MS = 30_000; const IDLE_TICK_MS = 120_000; -function toneClass(tone: AttentionTone): string { - return `attention-tone-${tone}`; -} - -function itemIcon(item: AttentionItem, size: number): React.ReactNode { - if (item.kind === "pull_request") return ; - return ; -} - function navigationErrorMessage(error: unknown): string { if (error instanceof Error && error.message.trim()) return error.message.trim(); return "ADE couldn’t open the exact machine and project for this item."; } -function machineLine(item: AttentionItem): string { - return item.machine.online - ? `${item.project.name} · ${item.machine.name}` - : `${item.project.name} · ${item.machine.name} (offline)`; -} - -function AttentionHeaderRow({ - item, - onOpen, -}: { - item: AttentionItem; - onOpen: () => void; -}) { - const phase = attentionPhasePresentation(item.phase); - return ( - - ); +function pluralize(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? "" : "s"}`; } -function AttentionHeaderSection({ - bucket, +function ActivityHeaderSection({ + section, + hideDetails, onOpenItem, - onOpenCenter, + onOpenPane, }: { - bucket: AttentionHeaderBucket; + section: ActivitySection; + hideDetails: boolean; onOpenItem: (item: AttentionItem) => void; - onOpenCenter: () => void; + onOpenPane: () => void; }) { - const shown = bucket.items.slice(0, MAX_ROWS_PER_BUCKET); - const overflow = bucket.items.length - shown.length; + const shown = section.items.slice(0, MAX_ROWS_PER_SECTION); + const overflow = section.items.length - shown.length; return ( -
-

- - {bucket.label} - {bucket.items.length} +
+

+ + {section.label} + {section.items.length}

{shown.map((item) => ( - onOpenItem(item)} /> + ))} {overflow > 0 ? ( - ) : null} @@ -143,22 +102,23 @@ function AttentionHeaderSection({ } /** - * Account-wide Attention, promoted into the global header so live work, things - * that need you, failures, and finished-but-unreviewed outcomes are one glance - * away from every tab and every project. The full Attention center stays the - * place to triage at length; this is the doorway to it. + * Account-wide Activity, promoted into the global header so live work, things + * that need you, and finished-but-unreviewed outcomes are one glance away from + * every tab and every project. Three priority-flat sections — needs you, + * working, done — and a handoff to the full pane for everything past the cap. */ -export function HeaderAttentionControl({ - onOpenCenter, +export function HeaderActivityControl({ + onOpenPane, }: { - /** Routes to the full Attention center — the shell owns navigation. */ - onOpenCenter: () => void; + /** Opens the full Activity surface — the shell owns how. */ + onOpenPane: () => void; }) { const itemsById = useAttentionStore((state) => state.itemsById); const syncStatus = useAttentionStore((state) => state.syncStatus); const syncError = useAttentionStore((state) => state.syncError); const generatedAt = useAttentionStore((state) => state.generatedAt); const availability = useAttentionStore((state) => state.availability); + const hideDetails = useAttentionStore(selectActivityHideDetails); const { status: accountStatus, loading: accountLoading } = useAccountStatus(); const signedIn = accountStatus.signedIn; @@ -169,10 +129,7 @@ export function HeaderAttentionControl({ const triggerRef = useRef(null); const panelRef = useRef(null); - const summary = useMemo( - () => summarizeAttentionForHeader(itemsById, now), - [itemsById, now], - ); + const summary = useMemo(() => summarizeActivity(itemsById, now), [itemsById, now]); const close = useCallback(() => { setOpen(false); @@ -181,6 +138,9 @@ export function HeaderAttentionControl({ const openPopover = useCallback(() => { setOpen(true); + // Event name, properties and dedupe key are deliberately unchanged through + // the Attention → Activity rename: forking them would fork the PostHog + // series and lose every comparison against the surface this replaces. void window.ade?.analytics?.capture({ event: "ade_feature_used", properties: { @@ -260,18 +220,22 @@ export function HeaderAttentionControl({ setOpen(false); }, []); - const openCenter = useCallback(() => { + const openPane = useCallback(() => { setOpen(false); - onOpenCenter(); - }, [onOpenCenter]); + onOpenPane(); + }, [onOpenPane]); const onPanelKeyDown = useCallback( (event: React.KeyboardEvent) => { + // The settings popover is a dialog of its own inside this one. While + // focus is in it, its keys are its business — otherwise Escape would + // close both at once and an arrow key would yank focus out to a row. + if ((event.target as HTMLElement | null)?.closest?.(".attention-settings-popover")) { + return; + } const delta = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0; const rows = Array.from( - event.currentTarget.querySelectorAll( - "[data-attention-header-row]", - ), + event.currentTarget.querySelectorAll("[data-activity-row]"), ); if (delta !== 0 && rows.length > 0) { event.preventDefault(); @@ -300,24 +264,25 @@ export function HeaderAttentionControl({ && availability.state !== "ready" && availability.state !== "signed_out"; const signedOutEmpty = signedOut && summary.trackedCount === 0; - const badgeCount = summary.waitingCount; - const hasLiveOnly = badgeCount === 0 && summary.liveCount > 0; + const badgeCount = summary.needsYouCount; + const hasLiveOnly = badgeCount === 0 && summary.workingCount > 0; + const baseLabel = activityTriggerLabel(summary).replace(/^Activity · /, ""); const triggerLabel = signedOut ? signedOutEmpty - ? "Attention · sign in to sync across machines" - : `Attention · this machine only · ${attentionHeaderTriggerLabel(summary).replace(/^Attention · /, "")} · sign in to sync` + ? "Activity · sign in to sync across machines" + : `Activity · this machine only · ${baseLabel} · sign in to sync` : degraded - ? `Attention · ${availability.title} · ${attentionHeaderTriggerLabel(summary).replace(/^Attention · /, "")}` - : attentionHeaderTriggerLabel(summary); + ? `Activity · ${availability.title} · ${baseLabel}` + : activityTriggerLabel(summary); const state = signedOutEmpty ? "signed-out" : degraded ? "degraded" - : badgeCount > 0 - ? "waiting" - : hasLiveOnly - ? "live" - : "clear"; + : badgeCount > 0 + ? "waiting" + : hasLiveOnly + ? "live" + : "clear"; const freshness = degraded ? { @@ -326,30 +291,37 @@ export function HeaderAttentionControl({ retry: availability.recovery === "retry", } : syncStatus === "error" - ? { tone: "error" as const, label: "Sync failed", retry: true } - : syncStatus === "syncing" - ? { tone: "syncing" as const, label: "Syncing", retry: false } - : generatedAt - ? { tone: "ready" as const, label: `Synced ${relativeWhen(generatedAt)}`, retry: false } - : null; + ? { tone: "error" as const, label: "Sync failed", retry: true } + : syncStatus === "syncing" + ? { tone: "syncing" as const, label: "Syncing", retry: false } + : generatedAt + ? { tone: "ready" as const, label: `Synced ${relativeWhen(generatedAt)}`, retry: false } + : null; const notchNeedsAttention = notchHealth != null && notchHealth.state !== "disabled" && notchHealth.state !== "starting" && notchHealth.state !== "running" && notchHealth.state !== "unsupported"; + const populatedSections = summary.sections.filter((section) => section.items.length > 0); + const machineLine = summary.machinesTotal === 0 + ? null + : summary.machinesOnline === summary.machinesTotal + ? pluralize(summary.machinesTotal, "machine") + : `${summary.machinesOnline} of ${pluralize(summary.machinesTotal, "machine")} online`; + return ( <> {open && typeof document !== "undefined" ? createPortal(
setOpen(false)} >
event.stopPropagation()} onKeyDown={onPanelKeyDown} > -
-
-

Attention

-

- {signedOut - ? availability?.title ?? "This machine only" - : availability?.title - ? availability.title - : summary.machinesTotal > 0 - ? `${summary.machinesOnline} of ${summary.machinesTotal} machine${summary.machinesTotal === 1 ? "" : "s"} online` - : "Across every machine on your account"} -

-
+
+ {/* No sub-caption. The line that used to sit here only ever + restated the surface's own name ("Account Attention is + live"), and a header that describes itself is a header + that has nothing to say. */} +

Activity

{freshness ? ( freshness.tone === "error" && freshness.retry ? ( ) : ( - + {freshness.tone === "syncing" ? ( - + ) : ( )} @@ -434,33 +399,34 @@ export function HeaderAttentionControl({ ) ) : null} +
{navigationError ? ( -
+
{navigationError}
) : null} {degraded ? ( -
+
{availability.message}
) : null} {signedOut && !signedOutEmpty ? ( -
+
{availability?.message @@ -470,7 +436,7 @@ export function HeaderAttentionControl({ ) : null} {notchNeedsAttention ? ( -
+
{notchHealth.title} @@ -484,7 +450,7 @@ export function HeaderAttentionControl({ ) : null} {summary.staleMachineCount > 0 ? ( -
+
{summary.staleMachineCount} item @@ -494,9 +460,9 @@ export function HeaderAttentionControl({
) : null} -
+
{signedOutEmpty ? ( -
+
Signed out

@@ -504,37 +470,35 @@ export function HeaderAttentionControl({ machine on your account.

- ) : summary.buckets.length === 0 ? ( -
- - All clear + ) : populatedSections.length === 0 ? ( +
+ + All agents idle

- {availability?.message - ?? (syncStatus === "error" - ? syncError ?? "Attention couldn’t sync, so this may be stale." - : "Nothing is running, waiting on you, or newly finished.")} + {syncStatus === "error" + ? syncError ?? "Activity couldn’t sync, so this may be stale." + : "Nothing needs you."}

) : ( - summary.buckets.map((bucket) => ( - ( + void openItem(item)} - onOpenCenter={openCenter} + onOpenPane={openPane} /> )) )}
-
+
- {summary.trackedCount} tracked - {summary.liveCount > 0 && badgeCount > 0 - ? ` · ${summary.liveCount} live` - : ""} + {pluralize(summary.trackedCount, "session")} + {machineLine ? ` · ${machineLine}` : ""} - @@ -548,4 +512,4 @@ export function HeaderAttentionControl({ ); } -export default HeaderAttentionControl; +export default HeaderActivityControl; diff --git a/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.css b/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.css deleted file mode 100644 index 019f9ab77..000000000 --- a/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.css +++ /dev/null @@ -1,542 +0,0 @@ -/* The global-header Attention control. It borrows the Attention center's tone - system so a phase reads the same colour in the header as it does in the full - surface, but keeps its own compact type scale: this lives in a 28px header, - not a page. Every colour resolves through theme tokens so light mode is a - token swap rather than a second stylesheet. */ - -.attn-hdr-trigger, -.attn-hdr-panel { - --tone-color: #a1a1aa; - --attn-hdr-fs-2xs: 9.5px; - --attn-hdr-fs-xs: 10.5px; - --attn-hdr-fs-sm: 11.5px; - --attn-hdr-fs-md: 12.5px; - --attn-hdr-surface: color-mix(in srgb, var(--color-card) 92%, var(--color-bg)); - --attn-hdr-hairline: color-mix(in srgb, var(--color-border) 70%, transparent); - --attn-hdr-shadow: 0 28px 70px -30px rgba(0, 0, 0, 0.8); -} - -.attn-hdr-trigger.attention-tone-amber, -.attn-hdr-panel .attention-tone-amber { --tone-color: #fbbf24; } -.attn-hdr-trigger.attention-tone-red, -.attn-hdr-panel .attention-tone-red { --tone-color: #f87171; } -.attn-hdr-trigger.attention-tone-violet, -.attn-hdr-panel .attention-tone-violet { --tone-color: #a78bfa; } -.attn-hdr-trigger.attention-tone-blue, -.attn-hdr-panel .attention-tone-blue { --tone-color: #60a5fa; } -.attn-hdr-trigger.attention-tone-cyan, -.attn-hdr-panel .attention-tone-cyan { --tone-color: #22d3ee; } -.attn-hdr-trigger.attention-tone-emerald, -.attn-hdr-panel .attention-tone-emerald { --tone-color: #34d399; } -.attn-hdr-trigger.attention-tone-neutral, -.attn-hdr-panel .attention-tone-neutral { --tone-color: #a1a1aa; } - -/* 400-level tones sit near 1.7:1 on a white card; light mode uses the 600/700 - equivalents so pills and dots stay legible instead of washing out. */ -[data-theme="light"] .attn-hdr-trigger, -[data-theme="light"] .attn-hdr-panel { - --attn-hdr-shadow: 0 22px 55px -26px rgba(15, 23, 42, 0.3); -} -[data-theme="light"] .attn-hdr-trigger.attention-tone-amber, -[data-theme="light"] .attn-hdr-panel .attention-tone-amber { --tone-color: #b45309; } -[data-theme="light"] .attn-hdr-trigger.attention-tone-red, -[data-theme="light"] .attn-hdr-panel .attention-tone-red { --tone-color: #dc2626; } -[data-theme="light"] .attn-hdr-trigger.attention-tone-violet, -[data-theme="light"] .attn-hdr-panel .attention-tone-violet { --tone-color: #6d28d9; } -[data-theme="light"] .attn-hdr-trigger.attention-tone-blue, -[data-theme="light"] .attn-hdr-panel .attention-tone-blue { --tone-color: #1d4ed8; } -[data-theme="light"] .attn-hdr-trigger.attention-tone-cyan, -[data-theme="light"] .attn-hdr-panel .attention-tone-cyan { --tone-color: #0e7490; } -[data-theme="light"] .attn-hdr-trigger.attention-tone-emerald, -[data-theme="light"] .attn-hdr-panel .attention-tone-emerald { --tone-color: #047857; } -[data-theme="light"] .attn-hdr-trigger.attention-tone-neutral, -[data-theme="light"] .attn-hdr-panel .attention-tone-neutral { --tone-color: #52525b; } - -/* ---- trigger ---------------------------------------------------------- */ - -.attn-hdr-trigger { - height: 22px; - transition: - background-color 150ms ease, - border-color 150ms ease, - box-shadow 150ms ease, - color 150ms ease; -} - -.attn-hdr-trigger-icon { - color: var(--color-muted-fg); - transition: color 150ms ease; -} - -.attn-hdr-trigger[data-state="waiting"] { - border-color: color-mix(in srgb, var(--tone-color) 45%, transparent); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--tone-color) 16%, transparent); -} - -/* The one amber in this file that is not a phase tone, and it earns it: a - degraded surface always carries an `availability.recovery` the user has to - perform (retry, sign in, update or restart the host). It is literally "your - move", which is the only meaning amber is allowed to carry — see the one-hue - rule in apps/desktop/src/shared/sessionStatusPresentation.ts. It cannot be - confused with a phase tone either: `data-state` is single-valued and - `degraded` outranks `waiting`, so the trigger paints this amber instead of — - never alongside — the leading bucket's colour. */ -.attn-hdr-trigger[data-state="degraded"] { - border-color: color-mix(in srgb, #f59e0b 42%, transparent); - box-shadow: 0 0 0 1px color-mix(in srgb, #f59e0b 13%, transparent); -} - -.attn-hdr-trigger[data-state="degraded"] .attn-hdr-trigger-icon { - color: #f59e0b; -} - -.attn-hdr-trigger[data-state="waiting"] .attn-hdr-trigger-icon, -.attn-hdr-trigger[data-state="live"] .attn-hdr-trigger-icon { - color: var(--tone-color); -} - -.attn-hdr-trigger[data-state="signed-out"] { - opacity: 0.75; -} - -.attn-hdr-trigger-count { - display: inline-flex; - min-width: 14px; - align-items: center; - justify-content: center; - padding: 0 3px; - border-radius: 999px; - background: var(--tone-color); - color: var(--color-bg); - font-family: var(--font-mono); - font-size: var(--attn-hdr-fs-2xs); - font-weight: 800; - line-height: 14px; - font-variant-numeric: tabular-nums; -} - -.attn-hdr-trigger-live { - width: 6px; - height: 6px; - border-radius: 999px; - background: var(--tone-color); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--tone-color) 18%, transparent); - animation: attn-hdr-pulse 2.4s ease-in-out infinite; -} - -@keyframes attn-hdr-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.42; } -} - -/* ---- popover ---------------------------------------------------------- */ - -.attn-hdr-panel { - position: absolute; - right: 12px; - top: 40px; - display: flex; - width: min(400px, calc(100vw - 24px)); - max-height: min(560px, calc(100vh - 72px)); - flex-direction: column; - overflow: hidden; - border: 1px solid var(--attn-hdr-hairline); - border-radius: 14px; - background: var(--attn-hdr-surface); - box-shadow: var(--attn-hdr-shadow); - color: var(--color-fg); - animation: attn-hdr-enter 140ms ease-out; -} - -@keyframes attn-hdr-enter { - from { opacity: 0; transform: translateY(-6px) scale(0.985); } - to { opacity: 1; transform: translateY(0) scale(1); } -} - -.attn-hdr-panel:focus-visible { - outline: none; -} - -.attn-hdr-panel-head { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 10px 10px 13px; - border-bottom: 1px solid var(--attn-hdr-hairline); -} - -.attn-hdr-panel-head h2 { - margin: 0; - font-size: var(--attn-hdr-fs-md); - font-weight: 650; - letter-spacing: -0.01em; -} - -.attn-hdr-panel-head p { - margin: 1px 0 0; - font-size: var(--attn-hdr-fs-xs); - color: var(--color-muted-fg); -} - -.attn-hdr-freshness { - display: inline-flex; - flex-shrink: 0; - align-items: center; - gap: 4px; - padding: 3px 7px; - border: 1px solid var(--attn-hdr-hairline); - border-radius: 999px; - background: color-mix(in srgb, var(--color-card) 60%, transparent); - color: var(--color-muted-fg); - font-size: var(--attn-hdr-fs-2xs); - font-weight: 600; -} - -button.attn-hdr-freshness { - cursor: pointer; -} - -.attn-hdr-freshness.is-error { - border-color: color-mix(in srgb, var(--color-error, #ef4444) 45%, transparent); - color: var(--color-error, #ef4444); -} - -.attn-hdr-spin { - animation: attn-hdr-spin 1.1s linear infinite; -} - -@keyframes attn-hdr-spin { - to { transform: rotate(360deg); } -} - -.attn-hdr-icon-button { - display: inline-flex; - height: 22px; - width: 22px; - flex-shrink: 0; - align-items: center; - justify-content: center; - border-radius: 7px; - color: var(--color-muted-fg); - transition: background-color 120ms ease, color 120ms ease; -} - -.attn-hdr-icon-button:hover { - background: color-mix(in srgb, var(--color-fg) 8%, transparent); - color: var(--color-fg); -} - -.attn-hdr-alert, -.attn-hdr-note { - display: flex; - align-items: flex-start; - gap: 7px; - padding: 8px 13px; - font-size: var(--attn-hdr-fs-xs); - line-height: 1.45; - border-bottom: 1px solid var(--attn-hdr-hairline); -} - -.attn-hdr-alert { - color: var(--color-error, #ef4444); - background: color-mix(in srgb, var(--color-error, #ef4444) 10%, transparent); -} - -.attn-hdr-note { - color: var(--color-muted-fg); - background: color-mix(in srgb, var(--color-fg) 4%, transparent); -} - -.attn-hdr-notch-health span { - flex: 1; -} - -.attn-hdr-notch-health button { - flex: 0 0 auto; - color: var(--color-accent); - font-weight: 650; -} - -.attn-hdr-notch-health button:hover, -.attn-hdr-notch-health button:focus-visible { - text-decoration: underline; - outline: none; -} - -.attn-hdr-body { - display: flex; - min-height: 0; - flex: 1; - flex-direction: column; - gap: 2px; - overflow-y: auto; - padding: 6px; -} - -/* ---- sections and rows ------------------------------------------------ */ - -.attn-hdr-section { - display: flex; - flex-direction: column; - gap: 1px; -} - -.attn-hdr-section-heading { - display: flex; - align-items: center; - gap: 6px; - margin: 0; - padding: 7px 7px 4px; - font-size: var(--attn-hdr-fs-2xs); - font-weight: 700; - letter-spacing: 0.07em; - text-transform: uppercase; - color: var(--color-muted-fg); -} - -.attn-hdr-section-dot { - width: 6px; - height: 6px; - flex-shrink: 0; - border-radius: 999px; - background: var(--tone-color); -} - -.attn-hdr-section-count { - font-family: var(--font-mono); - font-size: var(--attn-hdr-fs-2xs); - font-variant-numeric: tabular-nums; - color: var(--tone-color); -} - -.attn-hdr-row { - position: relative; - display: flex; - width: 100%; - align-items: flex-start; - gap: 8px; - padding: 7px 8px; - border-radius: 9px; - text-align: left; - transition: background-color 120ms ease, box-shadow 120ms ease; -} - -.attn-hdr-row::before { - content: ""; - position: absolute; - left: 0; - top: 8px; - bottom: 8px; - width: 2px; - border-radius: 999px; - background: var(--tone-color); - opacity: 0; - transition: opacity 120ms ease; -} - -.attn-hdr-row:hover, -.attn-hdr-row:focus-visible { - background: color-mix(in srgb, var(--color-fg) 6%, transparent); - outline: none; -} - -.attn-hdr-row:hover::before, -.attn-hdr-row:focus-visible::before { - opacity: 1; -} - -.attn-hdr-row:focus-visible { - box-shadow: 0 0 0 1px color-mix(in srgb, var(--tone-color) 55%, transparent); -} - -.attn-hdr-row-icon { - display: inline-flex; - height: 20px; - width: 20px; - flex-shrink: 0; - align-items: center; - justify-content: center; - border-radius: 6px; - background: color-mix(in srgb, var(--tone-color) 13%, transparent); - color: var(--tone-color); -} - -.attn-hdr-row-copy { - display: flex; - min-width: 0; - flex: 1; - flex-direction: column; - gap: 2px; -} - -.attn-hdr-row-title { - display: flex; - align-items: baseline; - gap: 8px; -} - -.attn-hdr-row-title strong { - min-width: 0; - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: var(--attn-hdr-fs-sm); - font-weight: 600; - color: var(--color-fg); -} - -.attn-hdr-row-title time { - flex-shrink: 0; - font-family: var(--font-mono); - font-size: var(--attn-hdr-fs-2xs); - color: color-mix(in srgb, var(--color-muted-fg) 85%, transparent); -} - -.attn-hdr-row-meta { - display: flex; - min-width: 0; - align-items: center; - gap: 6px; - font-size: var(--attn-hdr-fs-xs); - color: var(--color-muted-fg); -} - -.attn-hdr-phase { - display: inline-flex; - flex-shrink: 0; - align-items: center; - gap: 4px; - color: var(--tone-color); - font-weight: 600; -} - -.attn-hdr-phase-dot { - width: 5px; - height: 5px; - border-radius: 999px; - background: currentColor; -} - -.attn-hdr-phase-dot.is-active { - animation: attn-hdr-pulse 2.4s ease-in-out infinite; -} - -.attn-hdr-row-where { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.attn-hdr-unseen { - margin-top: 6px; - width: 6px; - height: 6px; - flex-shrink: 0; - border-radius: 999px; - background: var(--tone-color); -} - -.attn-hdr-overflow { - display: inline-flex; - align-items: center; - gap: 4px; - align-self: flex-start; - margin: 2px 0 4px 36px; - padding: 2px 4px; - border-radius: 6px; - font-size: var(--attn-hdr-fs-xs); - font-weight: 600; - color: var(--color-muted-fg); - transition: color 120ms ease, background-color 120ms ease; -} - -.attn-hdr-overflow:hover, -.attn-hdr-overflow:focus-visible { - color: var(--color-fg); - background: color-mix(in srgb, var(--color-fg) 6%, transparent); - outline: none; -} - -/* ---- empty and footer ------------------------------------------------- */ - -.attn-hdr-empty { - display: flex; - flex-direction: column; - align-items: center; - gap: 5px; - padding: 30px 26px 34px; - text-align: center; - color: var(--color-muted-fg); -} - -.attn-hdr-empty strong { - font-size: var(--attn-hdr-fs-md); - font-weight: 650; - color: var(--color-fg); -} - -.attn-hdr-empty p { - margin: 0; - max-width: 30ch; - font-size: var(--attn-hdr-fs-xs); - line-height: 1.5; -} - -.attn-hdr-panel-foot { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 9px 8px 13px; - border-top: 1px solid var(--attn-hdr-hairline); - font-size: var(--attn-hdr-fs-xs); - color: var(--color-muted-fg); - font-variant-numeric: tabular-nums; -} - -.attn-hdr-panel-foot > span { - min-width: 0; - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.attn-hdr-open-all { - display: inline-flex; - flex-shrink: 0; - align-items: center; - gap: 5px; - padding: 4px 9px; - border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent); - border-radius: 8px; - background: color-mix(in srgb, var(--color-accent) 15%, transparent); - color: var(--color-accent); - font-size: var(--attn-hdr-fs-xs); - font-weight: 650; - transition: background-color 120ms ease, border-color 120ms ease; -} - -.attn-hdr-open-all:hover, -.attn-hdr-open-all:focus-visible { - background: color-mix(in srgb, var(--color-accent) 24%, transparent); - border-color: color-mix(in srgb, var(--color-accent) 55%, transparent); - outline: none; -} - -@media (prefers-reduced-motion: reduce) { - .attn-hdr-panel, - .attn-hdr-panel *, - .attn-hdr-trigger, - .attn-hdr-trigger * { - animation-duration: 0.001ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.001ms !important; - } - - .attn-hdr-trigger-live, - .attn-hdr-phase-dot.is-active { - animation: none; - } -} diff --git a/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.test.tsx b/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.test.tsx deleted file mode 100644 index ce44e6064..000000000 --- a/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.test.tsx +++ /dev/null @@ -1,518 +0,0 @@ -// @vitest-environment jsdom - -import React from "react"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { - ATTENTION_CONTRACT_VERSION, - type AttentionItem, - type AttentionPhase, -} from "../../../shared/types"; -import { - attentionStore, - resetAttentionStoreForTests, -} from "../../state/attentionStore"; -import { publishAccountStatus, SIGNED_OUT_ACCOUNT } from "../../lib/account"; -import { HeaderAttentionControl } from "./HeaderAttentionControl"; -import { - attentionHeaderTriggerLabel, - summarizeAttentionForHeader, -} from "./attentionHeaderSummary"; - -const originalAde = window.ade; -const NOW = Date.parse("2026-07-29T12:00:00.000Z"); -const signedInAccount = { - signedIn: true as const, - userId: "account-a", - email: null, - name: null, - expiresAt: null, - provider: null, - imageUrl: null, -}; - -let openItem: ReturnType; -let acknowledge: ReturnType; -let getSnapshot: ReturnType; -let captureAnalytics: ReturnType; - -beforeEach(() => { - publishAccountStatus(signedInAccount); - openItem = vi.fn(async () => {}); - acknowledge = vi.fn(async () => {}); - captureAnalytics = vi.fn(async () => ({ accepted: true, reason: "accepted" })); - getSnapshot = vi.fn(async () => ({ - contractVersion: ATTENTION_CONTRACT_VERSION, - revision: attentionStore.getState().revision, - generatedAt: "2026-07-29T12:00:00.000Z", - items: Object.values(attentionStore.getState().itemsById), - })); - Object.defineProperty(window, "ade", { - configurable: true, - writable: true, - value: { - ...(originalAde ?? {}), - account: { - ...(originalAde?.account ?? {}), - status: vi.fn(async () => signedInAccount), - }, - attention: { openItem, acknowledge, getSnapshot }, - analytics: { - capture: captureAnalytics, - }, - }, - }); -}); - -afterEach(() => { - cleanup(); - resetAttentionStoreForTests(); - publishAccountStatus(SIGNED_OUT_ACCOUNT); - Object.defineProperty(window, "ade", { - configurable: true, - writable: true, - value: originalAde, - }); -}); - -function item( - id: string, - phase: AttentionPhase, - patch: Partial = {}, -): AttentionItem { - return { - contractVersion: ATTENTION_CONTRACT_VERSION, - id, - revision: 1, - fingerprint: `fingerprint-${id}`, - kind: "agent", - eventKind: "agent_needs_you", - phase, - machine: { - machineKey: "studio", - name: "Studio Mac", - online: true, - lastSeenAt: "2026-07-29T11:59:00.000Z", - }, - project: { projectId: "ade", name: "ADE", rootPath: "/repo/ade" }, - provider: "codex", - model: "GPT-5", - title: `Task ${id}`, - preview: "preview", - privacyPreview: "private preview", - destination: { kind: "session", sessionId: `session-${id}` }, - actions: [], - occurredAt: "2026-07-29T11:58:00.000Z", - updatedAt: "2026-07-29T11:58:00.000Z", - seenAt: null, - dismissedAt: null, - expiresAt: null, - ...patch, - }; -} - -function seedItems(items: AttentionItem[]): void { - attentionStore.setState({ - itemsById: Object.fromEntries(items.map((entry) => [entry.id, entry])), - generatedAt: "2026-07-29T12:00:00.000Z", - syncStatus: "ready", - }); -} - -function byId(items: AttentionItem[]): Record { - return Object.fromEntries(items.map((entry) => [entry.id, entry])); -} - -function renderControl(onOpenCenter = vi.fn()) { - render(); - return onOpenCenter; -} - -describe("HeaderAttentionControl", () => { - it("badges only work waiting on you and records a bounded header open", async () => { - seedItems([item("a", "needs_you"), item("b", "running"), item("c", "merge_ready")]); - renderControl(); - - const trigger = screen.getByTestId("header-attention-trigger"); - expect(trigger.textContent).toContain("2"); - expect(trigger.getAttribute("aria-label")).toBe( - "Attention · 1 needs you · 1 to review · 1 live", - ); - expect(trigger.getAttribute("data-state")).toBe("waiting"); - fireEvent.click(trigger); - await waitFor(() => { - expect(captureAnalytics).toHaveBeenCalledWith({ - event: "ade_feature_used", - properties: { - feature: "attention", - action: "header_opened", - outcome: "opened", - source: "renderer_route", - }, - dedupeKey: "attention_header_opened", - minimumIntervalMs: 60 * 60_000, - }); - }); - }); - - it("shows a live pulse without a count when nothing is waiting", () => { - seedItems([item("b", "running")]); - renderControl(); - - const trigger = screen.getByTestId("header-attention-trigger"); - expect(trigger.getAttribute("data-state")).toBe("live"); - expect(trigger.textContent).toBe(""); - expect(trigger.getAttribute("aria-label")).toBe("Attention · 1 live"); - }); - - it("groups the popover across machines and projects", () => { - seedItems([ - item("a", "needs_you"), - item("b", "failed", { - machine: { - machineKey: "laptop", - name: "Laptop", - online: false, - lastSeenAt: "2026-07-29T10:00:00.000Z", - }, - project: { projectId: "web", name: "Web", rootPath: "/repo/web" }, - }), - item("c", "running"), - item("d", "completed"), - ]); - renderControl(); - - fireEvent.click(screen.getByTestId("header-attention-trigger")); - - const dialog = screen.getByRole("dialog", { name: "Attention" }); - expect(attentionStore.getState().headerSurfaceVisible).toBe(true); - expect(dialog).toBeTruthy(); - expect(screen.getByRole("heading", { name: /Needs you/ })).toBeTruthy(); - expect(screen.getByRole("heading", { name: /Failing or blocked/ })).toBeTruthy(); - expect(screen.getByRole("heading", { name: /Done, unreviewed/ })).toBeTruthy(); - expect(screen.getByRole("heading", { name: /Live now/ })).toBeTruthy(); - expect(screen.getByText("1 of 2 machines online")).toBeTruthy(); - expect(screen.getByText("Web · Laptop (offline)")).toBeTruthy(); - expect(dialog.textContent).toContain("last-known state from an offline machine"); - }); - - it("opens the exact destination through the attention bridge, then marks it seen", async () => { - seedItems([item("a", "needs_you")]); - renderControl(); - - fireEvent.click(screen.getByTestId("header-attention-trigger")); - fireEvent.click(screen.getByRole("button", { name: /Task a/ })); - - await waitFor(() => - expect(openItem).toHaveBeenCalledWith(expect.objectContaining({ id: "a" })), - ); - await waitFor(() => - expect(acknowledge).toHaveBeenCalledWith( - expect.objectContaining({ itemIds: ["a"] }), - ), - ); - await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - }); - - it("keeps the item unseen and explains a failed navigation", async () => { - seedItems([item("a", "needs_you")]); - openItem.mockRejectedValueOnce(new Error("Studio Mac is offline")); - renderControl(); - - fireEvent.click(screen.getByTestId("header-attention-trigger")); - fireEvent.click(screen.getByRole("button", { name: /Task a/ })); - - await waitFor(() => expect(screen.getByRole("alert").textContent).toContain( - "Studio Mac is offline", - )); - expect(acknowledge).not.toHaveBeenCalled(); - expect(attentionStore.getState().itemsById.a?.seenAt).toBeNull(); - expect(screen.getByRole("dialog")).toBeTruthy(); - }); - - it("hands off to the full center from Open all and from a truncated section", () => { - seedItems([ - item("a", "needs_you"), - item("b", "needs_you"), - item("c", "needs_you"), - item("d", "needs_you"), - item("e", "needs_you"), - ]); - const onOpenCenter = renderControl(); - - fireEvent.click(screen.getByTestId("header-attention-trigger")); - expect(screen.getByText("1 more in Attention")).toBeTruthy(); - - fireEvent.click(screen.getByText("1 more in Attention")); - expect(onOpenCenter).toHaveBeenCalledTimes(1); - expect(screen.queryByRole("dialog")).toBeNull(); - - fireEvent.click(screen.getByTestId("header-attention-trigger")); - fireEvent.click(screen.getByRole("button", { name: /Open all/ })); - expect(onOpenCenter).toHaveBeenCalledTimes(2); - expect(screen.queryByRole("dialog")).toBeNull(); - }); - - it("supports keyboard open, arrow navigation, and Escape returning focus", () => { - seedItems([item("a", "needs_you"), item("b", "needs_you")]); - renderControl(); - - const trigger = screen.getByTestId("header-attention-trigger"); - trigger.focus(); - fireEvent.keyDown(trigger, { key: "ArrowDown" }); - const dialog = screen.getByRole("dialog", { name: "Attention" }); - - fireEvent.keyDown(dialog, { key: "ArrowDown" }); - expect(document.activeElement?.getAttribute("data-attention-header-row")).toBe("a"); - fireEvent.keyDown(dialog, { key: "ArrowDown" }); - expect(document.activeElement?.getAttribute("data-attention-header-row")).toBe("b"); - fireEvent.keyDown(dialog, { key: "ArrowDown" }); - expect(document.activeElement?.getAttribute("data-attention-header-row")).toBe("a"); - fireEvent.keyDown(dialog, { key: "End" }); - expect(document.activeElement?.getAttribute("data-attention-header-row")).toBe("b"); - - fireEvent.keyDown(dialog, { key: "Escape" }); - expect(screen.queryByRole("dialog")).toBeNull(); - expect(document.activeElement).toBe(trigger); - }); - - it("offers a retry instead of pretending a failed sync is current", async () => { - seedItems([item("a", "needs_you")]); - getSnapshot.mockRejectedValue(new Error("Relay unreachable")); - renderControl(); - - fireEvent.click(screen.getByTestId("header-attention-trigger")); - const retry = await screen.findByRole("button", { - name: /Attention is unavailable · Retry/, - }); - expect(getSnapshot).toHaveBeenCalledTimes(1); - - fireEvent.click(retry); - await waitFor(() => expect(getSnapshot).toHaveBeenCalledTimes(2)); - }); - - it("surfaces a missing native notch helper with recovery guidance", async () => { - seedItems([]); - const retry = vi.fn(async () => ({ - state: "missing" as const, - title: "ADE Notch needs reinstalling", - message: "Reinstall or update ADE, then restart the app.", - recovery: "reinstall_or_update" as const, - surface: null, - })); - Object.defineProperty(window, "ade", { - configurable: true, - writable: true, - value: { - ...(window.ade ?? {}), - attentionNotch: { - publishSnapshot: vi.fn(), - updateSettings: vi.fn(), - getHealth: retry, - retry, - onAcknowledgeRequested: vi.fn(() => () => {}), - }, - }, - }); - renderControl(); - - fireEvent.click(screen.getByTestId("header-attention-trigger")); - - expect(await screen.findByText("ADE Notch needs reinstalling")).toBeTruthy(); - expect(screen.getByText(/Reinstall or update ADE/)).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "Check again" })); - await waitFor(() => expect(retry).toHaveBeenCalledTimes(2)); - }); - - it("stays honest when signed out instead of showing an empty account", () => { - publishAccountStatus(SIGNED_OUT_ACCOUNT); - renderControl(); - - const trigger = screen.getByTestId("header-attention-trigger"); - expect(trigger.getAttribute("data-state")).toBe("signed-out"); - expect(trigger.getAttribute("aria-label")).toBe( - "Attention · sign in to sync across machines", - ); - - fireEvent.click(trigger); - expect( - screen.getByText(/Sign in to ADE to follow agents and pull requests/), - ).toBeTruthy(); - }); - - it("keeps machine-local work visible while signed out", () => { - publishAccountStatus(SIGNED_OUT_ACCOUNT); - seedItems([item("local", "needs_you")]); - attentionStore.setState({ - snapshotScope: "machine", - availability: { - state: "signed_out", - title: "Showing this Mac", - message: "Sign in to combine Attention across every ADE machine.", - recovery: "sign_in", - hostName: "This Mac", - }, - }); - renderControl(); - - const trigger = screen.getByTestId("header-attention-trigger"); - expect(trigger.getAttribute("data-state")).toBe("waiting"); - expect(trigger.textContent).toContain("1"); - expect(trigger.getAttribute("aria-label")).toContain("this machine only"); - - fireEvent.click(trigger); - expect(screen.getByText("Showing this Mac")).toBeTruthy(); - expect(screen.getByRole("heading", { name: /Needs you/ })).toBeTruthy(); - expect( - screen.getByText(/Sign in to combine Attention across every ADE machine/), - ).toBeTruthy(); - }); -}); - -describe("header Attention summary", () => { - it("separates waiting work from ambient live work and leads with urgency", () => { - const summary = summarizeAttentionForHeader( - byId([ - item("needs", "needs_you"), - item("failing", "checks_failing", { kind: "pull_request" }), - item("running", "running"), - item("starting", "starting"), - ]), - NOW, - ); - - expect(summary.waitingCount).toBe(2); - expect(summary.liveCount).toBe(2); - expect(summary.headline).toBe("1 needs you"); - expect(summary.tone).toBe("amber"); - expect(summary.buckets.map((bucket) => bucket.id)).toEqual([ - "needs_you", - "blocked", - "live", - ]); - }); - - it("stops counting reviewed, dismissed, and expired outcomes without losing tracked history", () => { - const summary = summarizeAttentionForHeader( - byId([ - item("reviewed", "completed", { seenAt: "2026-07-29T11:59:00.000Z" }), - item("dismissed", "needs_you", { dismissedAt: "2026-07-29T11:00:00.000Z" }), - item("expired", "needs_you", { expiresAt: "2026-07-29T11:00:00.000Z" }), - item("live", "running"), - ]), - NOW, - ); - - expect(summary.waitingCount).toBe(0); - expect(summary.trackedCount).toBe(2); - expect(summary.buckets.map((bucket) => bucket.id)).toEqual(["live"]); - }); - - it("reports offline ownership and orders each bucket by shared priority", () => { - const summary = summarizeAttentionForHeader( - byId([ - item("older", "failed", { - updatedAt: "2026-07-29T10:00:00.000Z", - machine: { - machineKey: "laptop", - name: "Laptop", - online: false, - lastSeenAt: "2026-07-29T10:00:00.000Z", - }, - }), - item("newer", "failed", { updatedAt: "2026-07-29T11:59:00.000Z" }), - ]), - NOW, - ); - - expect(summary.machinesTotal).toBe(2); - expect(summary.machinesOnline).toBe(1); - expect(summary.staleMachineCount).toBe(1); - expect(summary.buckets[0]?.items.map((entry) => entry.id)).toEqual([ - "newer", - "older", - ]); - }); - - it("enumerates each bucket in the trigger label and stays calm when clear", () => { - const summary = summarizeAttentionForHeader( - byId([ - item("needs", "needs_you"), - item("review", "merge_ready"), - item("live", "running"), - ]), - NOW, - ); - - expect(attentionHeaderTriggerLabel(summary)).toBe( - "Attention · 1 needs you · 1 to review · 1 live", - ); - expect(attentionHeaderTriggerLabel(summarizeAttentionForHeader({}, NOW))) - .toBe("Attention · nothing waiting"); - expect(summarizeAttentionForHeader({}, NOW).tone).toBe("neutral"); - }); - - /** - * The header is the loudest surface ADE has, so amber there has to keep - * meaning exactly one thing. A run that merely finished is an outcome, not a - * request: it may collect in the badge, but it must not colour the bell. - */ - it("lights amber only for work that needs the user, and emerald for finished work", () => { - const doneOnly = summarizeAttentionForHeader( - byId([item("done", "completed"), item("merged", "merged", { kind: "pull_request" })]), - NOW, - ); - - expect(doneOnly.buckets.map((bucket) => bucket.id)).toEqual(["done"]); - expect(doneOnly.tone).toBe("emerald"); - expect(doneOnly.headline).toBe("2 done"); - - const raisedHand = summarizeAttentionForHeader( - byId([item("done", "completed"), item("asks", "needs_you")]), - NOW, - ); - - expect(raisedHand.tone).toBe("amber"); - expect(raisedHand.buckets.map((bucket) => bucket.id)).toEqual(["needs_you", "done"]); - expect(raisedHand.buckets.map((bucket) => bucket.tone)).toEqual(["amber", "emerald"]); - }); - - it("separates an outstanding PR review from work that is simply finished", () => { - const summary = summarizeAttentionForHeader( - byId([ - item("review", "review_requested", { kind: "pull_request" }), - item("done", "completed"), - ]), - NOW, - ); - - expect(summary.buckets.map((bucket) => bucket.id)).toEqual(["review", "done"]); - expect(summary.buckets.map((bucket) => bucket.tone)).toEqual(["violet", "emerald"]); - expect(attentionHeaderTriggerLabel(summary)).toBe("Attention · 1 to review · 1 done"); - }); - - /** - * Stale is a silence, not a signal. It used to share amber with a raised - * hand; it must now neither colour the header nor inflate the badge. - */ - it("keeps stale and already-open work out of every count", () => { - const summary = summarizeAttentionForHeader( - byId([ - item("quiet", "stale"), - item("open", "open", { kind: "pull_request" }), - item("closed", "closed", { kind: "pull_request" }), - ]), - NOW, - ); - - expect(summary.buckets).toEqual([]); - expect(summary.waitingCount).toBe(0); - expect(summary.liveCount).toBe(0); - expect(summary.tone).toBe("neutral"); - expect(summary.headline).toBe("All clear"); - // Still tracked — the full Attention center can show them; the header just - // stays quiet about them. - expect(summary.trackedCount).toBe(3); - }); -}); diff --git a/apps/desktop/src/renderer/components/attention/activityPriority.test.ts b/apps/desktop/src/renderer/components/attention/activityPriority.test.ts index bb2269c6d..c78c24e38 100644 --- a/apps/desktop/src/renderer/components/attention/activityPriority.test.ts +++ b/apps/desktop/src/renderer/components/attention/activityPriority.test.ts @@ -7,8 +7,11 @@ import { import { ACTIVITY_SECTION_DESCRIPTORS, activityBadgeCount, + ACTIVITY_SECTION_TONE, activityHeadline, activitySections, + activityTriggerLabel, + summarizeActivity, } from "./activityPriority"; const NOW = Date.parse("2026-08-01T12:00:00.000Z"); @@ -114,3 +117,67 @@ describe("activity priority", () => { expect(activityHeadline([], NOW)).toBe("All clear"); }); }); + +describe("activity header summary", () => { + it("derives counts, machine presence, and the trigger label from one pass", () => { + const summary = summarizeActivity( + [ + activityItem("needs", "needs_you"), + activityItem("work", "running"), + activityItem("done", "completed"), + activityItem("offline", "running", { + machine: { + machineKey: "laptop", + name: "MacBook Pro", + online: false, + lastSeenAt: "2026-08-01T10:00:00.000Z", + }, + }), + ], + NOW, + ); + + expect(summary.needsYouCount).toBe(1); + expect(summary.workingCount).toBe(2); + expect(summary.doneCount).toBe(1); + expect(summary.trackedCount).toBe(4); + expect(summary.machinesOnline).toBe(1); + expect(summary.machinesTotal).toBe(2); + expect(summary.staleMachineCount).toBe(1); + expect(summary.tone).toBe("amber"); + expect(activityTriggerLabel(summary)).toBe( + "Activity · 1 needs you · 2 working · 1 done", + ); + }); + + /** + * Amber is the badge's only colour, and it may only mean "your move". Work in + * motion is blue and a finished run is emerald — neither may borrow it. + */ + it("reserves amber for needs-you and falls back through working then done", () => { + expect(summarizeActivity([activityItem("work", "running")], NOW).tone).toBe("blue"); + expect(summarizeActivity([activityItem("done", "completed")], NOW).tone).toBe("emerald"); + expect(summarizeActivity([], NOW).tone).toBe("neutral"); + expect(ACTIVITY_SECTION_TONE["needs-you"]).toBe("amber"); + }); + + it("says all agents are idle rather than enumerating zeroes", () => { + expect(activityTriggerLabel(summarizeActivity([], NOW))).toBe( + "Activity · all agents idle", + ); + }); + + it("counts a dismissed row out of tracked while still knowing its machine", () => { + const summary = summarizeActivity( + [ + activityItem("visible", "needs_you"), + activityItem("dismissed", "failed", { dismissedAt: "2026-08-01T11:30:00.000Z" }), + ], + NOW, + ); + + expect(summary.trackedCount).toBe(1); + expect(summary.needsYouCount).toBe(1); + expect(summary.machinesTotal).toBe(1); + }); +}); diff --git a/apps/desktop/src/renderer/components/attention/activityPriority.ts b/apps/desktop/src/renderer/components/attention/activityPriority.ts index 1ed30c3be..544cce95f 100644 --- a/apps/desktop/src/renderer/components/attention/activityPriority.ts +++ b/apps/desktop/src/renderer/components/attention/activityPriority.ts @@ -97,3 +97,97 @@ export function activityHeadline(input: ActivityItemsInput, now = Date.now()): s if (done > 0) return `${done} done`; return "All clear"; } + +/** + * The one hue per section, and the reason the badge can only ever be amber: + * amber means "your move" and nothing else, blue means work is happening, + * emerald means it finished cleanly. Same table as + * `shared/sessionStatusPresentation.ts` — see the one-hue-one-meaning rule there. + */ +export const ACTIVITY_SECTION_TONE = { + "needs-you": "amber", + working: "blue", + done: "emerald", +} as const satisfies Record; + +export type ActivitySummary = { + /** All three sections, always, in priority order. */ + sections: ActivitySection[]; + needsYouCount: number; + workingCount: number; + doneCount: number; + /** Every non-expired, non-dismissed item — what "Open all" leads to. */ + trackedCount: number; + /** Filed items whose machine is offline, i.e. last-known state only. */ + staleMachineCount: number; + machinesOnline: number; + machinesTotal: number; + tone: "amber" | "blue" | "emerald" | "neutral"; + headline: string; +}; + +/** + * Everything the Activity header claims, derived once so the trigger, its + * accessible label, the sections, and the footer can never disagree. + */ +export function summarizeActivity( + input: ActivityItemsInput, + now = Date.now(), +): ActivitySummary { + const sections = activitySections(input, now); + const machinesOnline = new Set(); + const machinesTotal = new Set(); + let trackedCount = 0; + + for (const item of activityInputItems(input)) { + if (activityItemIsExpired(item, now)) continue; + machinesTotal.add(item.machine.machineKey); + if (item.machine.online) machinesOnline.add(item.machine.machineKey); + if (!item.dismissedAt) trackedCount += 1; + } + + const needsYouCount = sections[0]?.items.length ?? 0; + const workingCount = sections[1]?.items.length ?? 0; + const doneCount = sections[2]?.items.length ?? 0; + // "Working" rows on an offline machine are the normal shape of a machine that + // went away mid-turn, so they count too: the whole point of the note is that + // the state on screen is remembered rather than observed. + const staleMachineCount = sections.reduce( + (total, section) => + total + section.items.filter((item) => !item.machine.online).length, + 0, + ); + + const tone = needsYouCount > 0 + ? "amber" + : workingCount > 0 + ? "blue" + : doneCount > 0 + ? "emerald" + : "neutral"; + + return { + sections, + needsYouCount, + workingCount, + doneCount, + trackedCount, + staleMachineCount, + machinesOnline: machinesOnline.size, + machinesTotal: machinesTotal.size, + tone, + headline: activityHeadline(input, now), + }; +} + +/** Tooltip and accessible name for the Activity header trigger. */ +export function activityTriggerLabel(summary: ActivitySummary): string { + const parts: string[] = []; + if (summary.needsYouCount > 0) { + parts.push(`${summary.needsYouCount} need${summary.needsYouCount === 1 ? "s" : ""} you`); + } + if (summary.workingCount > 0) parts.push(`${summary.workingCount} working`); + if (summary.doneCount > 0) parts.push(`${summary.doneCount} done`); + if (parts.length === 0) return "Activity · all agents idle"; + return `Activity · ${parts.join(" · ")}`; +} diff --git a/apps/desktop/src/renderer/components/attention/attentionHeaderSummary.ts b/apps/desktop/src/renderer/components/attention/attentionHeaderSummary.ts deleted file mode 100644 index db6e2d606..000000000 --- a/apps/desktop/src/renderer/components/attention/attentionHeaderSummary.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { - sortAttentionItems, - type AttentionItem, -} from "../../../shared/types"; -import type { AttentionTone } from "./attentionPresentation"; - -/** - * The global header carries one number, so that number has to mean exactly one - * thing: work that is waiting on the person reading it. Live work is real but - * it is not a request, so it rides alongside as an ambient pulse instead of - * inflating the count. Everything the header claims is derived here, once, so - * the trigger, its label, and the popover can never disagree. - */ -export type AttentionHeaderBucketId = - | "needs_you" - | "blocked" - | "review" - | "done" - | "live"; - -export type AttentionHeaderBucket = { - id: AttentionHeaderBucketId; - /** Section heading in the popover. */ - label: string; - /** Screen-reader/tooltip phrasing for one bucket, already pluralized. */ - summary: string; - tone: AttentionTone; - items: AttentionItem[]; -}; - -export type AttentionHeaderSummary = { - /** Non-empty buckets, most urgent first. */ - buckets: AttentionHeaderBucket[]; - /** needs_you + blocked/failing + done-but-unreviewed. Drives the badge. */ - waitingCount: number; - liveCount: number; - /** Every non-expired, non-dismissed item — what "Open all" leads to. */ - trackedCount: number; - /** Waiting items whose machine is offline, i.e. last-known state only. */ - staleMachineCount: number; - tone: AttentionTone; - /** Short truthful phrase: "2 need you", "3 live", "All clear". */ - headline: string; - machinesOnline: number; - machinesTotal: number; -}; - -const BUCKET_ORDER: AttentionHeaderBucketId[] = [ - "needs_you", - "blocked", - "review", - "done", - "live", -]; - -const BUCKET_LABEL: Record = { - needs_you: "Needs you", - blocked: "Failing or blocked", - review: "Waiting on review", - done: "Done, unreviewed", - live: "Live now", -}; - -/** - * Amber appears exactly once in this table, on `needs_you`, and that is the - * whole point: the header is the loudest surface ADE has, so the hue that means - * "your move" must not be shared with anything else. `done` is emerald because - * a finished run you have not looked at yet is an outcome, not a request — - * previously it rode in the same violet bucket as an outstanding PR review, - * which made "go look" and "go review" one indistinguishable colour. - */ -const BUCKET_TONE: Record = { - needs_you: "amber", - blocked: "red", - review: "violet", - done: "emerald", - live: "blue", -}; - -function bucketSummary(id: AttentionHeaderBucketId, count: number): string { - if (id === "needs_you") return `${count} need${count === 1 ? "s" : ""} you`; - if (id === "blocked") return `${count} failing or blocked`; - if (id === "review") return `${count} to review`; - if (id === "done") return `${count} done`; - return `${count} live`; -} - -function isExpired(item: AttentionItem, now: number): boolean { - if (!item.expiresAt) return false; - const expiresAt = Date.parse(item.expiresAt); - return Number.isFinite(expiresAt) && expiresAt <= now; -} - -/** - * Phases the header deliberately stays quiet about: `open`, `stale`, `closed`, - * and outcomes the user already acknowledged. They are neither in motion nor - * asking for anything, so they belong to the full Attention center. - * - * `stale` in particular is a silence, not a signal — it stays out of every - * bucket so it can never inflate the badge. The same holds for a session the - * user stopped: it never reaches the header at all, because a stopped run is an - * outcome the user chose. - * - * The loud tier is exactly `needs_you`. Nothing else may enter it — a resting - * or finished chat lands in `done`, never in the bucket that colours the header - * amber. - */ -export function attentionHeaderBucketFor( - item: AttentionItem, -): AttentionHeaderBucketId | null { - if (item.dismissedAt) return null; - switch (item.phase) { - case "needs_you": - return "needs_you"; - case "blocked": - case "failed": - case "checks_failing": - case "changes_requested": - return "blocked"; - case "review_requested": - case "merge_ready": - return "review"; - case "completed": - case "merged": - return item.seenAt ? null : "done"; - case "starting": - case "running": - return "live"; - default: - return null; - } -} - -export function summarizeAttentionForHeader( - itemsById: Record, - now = Date.now(), -): AttentionHeaderSummary { - const grouped = new Map(); - const machinesOnline = new Set(); - const machinesTotal = new Set(); - let trackedCount = 0; - let staleMachineCount = 0; - - for (const item of Object.values(itemsById)) { - if (isExpired(item, now)) continue; - machinesTotal.add(item.machine.machineKey); - if (item.machine.online) machinesOnline.add(item.machine.machineKey); - if (!item.dismissedAt) trackedCount += 1; - const bucket = attentionHeaderBucketFor(item); - if (!bucket) continue; - const existing = grouped.get(bucket); - if (existing) existing.push(item); - else grouped.set(bucket, [item]); - if (bucket !== "live" && !item.machine.online) staleMachineCount += 1; - } - - const buckets: AttentionHeaderBucket[] = []; - for (const id of BUCKET_ORDER) { - const items = grouped.get(id); - if (!items || items.length === 0) continue; - buckets.push({ - id, - label: BUCKET_LABEL[id], - summary: bucketSummary(id, items.length), - tone: BUCKET_TONE[id], - items: sortAttentionItems(items), - }); - } - - const liveCount = grouped.get("live")?.length ?? 0; - const waitingCount = buckets - .filter((bucket) => bucket.id !== "live") - .reduce((total, bucket) => total + bucket.items.length, 0); - const leading = buckets[0] ?? null; - - return { - buckets, - waitingCount, - liveCount, - trackedCount, - staleMachineCount, - tone: leading?.tone ?? "neutral", - headline: leading ? leading.summary : "All clear", - machinesOnline: machinesOnline.size, - machinesTotal: machinesTotal.size, - }; -} - -/** The tooltip and accessible name for the header trigger. */ -export function attentionHeaderTriggerLabel( - summary: AttentionHeaderSummary, -): string { - if (summary.buckets.length === 0) return "Attention · nothing waiting"; - return `Attention · ${summary.buckets.map((bucket) => bucket.summary).join(" · ")}`; -} diff --git a/apps/desktop/src/renderer/components/attention/attentionPresentation.ts b/apps/desktop/src/renderer/components/attention/attentionPresentation.ts index cb35a3efa..bafc04c17 100644 --- a/apps/desktop/src/renderer/components/attention/attentionPresentation.ts +++ b/apps/desktop/src/renderer/components/attention/attentionPresentation.ts @@ -145,10 +145,9 @@ const SESSION_DERIVED_PRESENTATION = Object.fromEntries( * branch policy, CI, or someone else's approval is frequently something the * reader cannot clear at all, so it makes no claim on them. * - * NOTE: `attentionHeaderSummary.ts` still files `blocked` into the red - * "Failing or blocked" bucket. That disagreement with the neutral tone here is - * known and deliberately left for now — reconciling a phase that has no - * producer would be two speculative changes instead of one documented one. + * `activityPriority.ts` files it in the needs-you band on phase priority alone, + * which is the closest thing to a decision anyone can make about a phase with + * no producer. Its tone stays neutral here, so it can never paint amber. */ const NON_SESSION_PRESENTATION: Record< Exclude, diff --git a/apps/desktop/src/renderer/components/attention/useAttentionSync.ts b/apps/desktop/src/renderer/components/attention/useAttentionSync.ts index c044818f1..bcb1f8d77 100644 --- a/apps/desktop/src/renderer/components/attention/useAttentionSync.ts +++ b/apps/desktop/src/renderer/components/attention/useAttentionSync.ts @@ -256,11 +256,21 @@ async function refreshAttentionNotchSettings( ) return; const attentionApi = typeof window !== "undefined" ? window.ade?.attention : null; const notchApi = typeof window !== "undefined" ? window.ade?.attentionNotch : null; - if (!attentionApi || !notchApi) return; - const promise = attentionApi - .getPreferences(scope.ownerId) + // The notch is optional — it does not exist on the web client at all — but the + // preferences behind it are not: hide-details and the dock-badge scope govern + // the Activity surfaces on every platform, so the fetch must not be gated on + // the native helper being present. + if (typeof attentionApi?.getPreferences !== "function") return; + const ownerId = scope.ownerId; + // `Promise.resolve().then(…)` rather than a bare call: a host that answers + // synchronously (or with nothing at all) must land in this chain's own catch + // instead of throwing past it as an unhandled rejection. + const promise = Promise.resolve() + .then(() => attentionApi.getPreferences(ownerId)) .then(async (preferences) => { - if (!isCurrentAccountScope(scope)) return; + if (!isCurrentAccountScope(scope) || !preferences) return; + attentionStore.getState().setPreferences(preferences); + if (!notchApi) return; await enqueueAttentionNotchSettingsUpdate( scope, attentionNotchSettingsFromPreferences(preferences), diff --git a/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.test.tsx b/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.test.tsx new file mode 100644 index 000000000..683042750 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.test.tsx @@ -0,0 +1,218 @@ +// @vitest-environment jsdom + +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ATTENTION_CONTRACT_VERSION, + DEFAULT_ATTENTION_PREFERENCES, + type AttentionItem, + type AttentionPhase, +} from "../../shared/types"; +import { attentionStore, resetAttentionStoreForTests } from "../state/attentionStore"; +import { useAppStore } from "../state/appStore"; + +const listSessionsCached = vi.fn(async () => [] as unknown[]); +const invalidateSessionListCache = vi.fn(); +const summarizeTerminalAttention = vi.fn(() => ({ + runningCount: 0, + activeCount: 0, + needsAttentionCount: 0, + indicator: "none" as const, + byLaneId: {}, +})); + +vi.mock("../lib/sessionListCache", () => ({ + listSessionsCached: (...args: unknown[]) => listSessionsCached(...(args as [])), + invalidateSessionListCache: (...args: unknown[]) => + invalidateSessionListCache(...(args as [])), +})); +vi.mock("../lib/terminalAttention", () => ({ + summarizeTerminalAttention: (...args: unknown[]) => + summarizeTerminalAttention(...(args as [])), +})); + +const { useAppWideSessionAttention } = await import("./useAppWideSessionAttention"); + +const originalAde = window.ade; +let setDockBadgeCount: ReturnType; +const noopUnsubscribe = () => {}; + +function needsYouItem(id: string, phase: AttentionPhase = "needs_you"): AttentionItem { + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + id, + revision: 1, + fingerprint: `fingerprint-${id}`, + kind: "agent", + eventKind: "agent_needs_you", + phase, + machine: { machineKey: "studio", name: "Studio Mac", online: true, lastSeenAt: null }, + project: { projectId: "ade", name: "ADE" }, + title: id, + preview: "preview", + privacyPreview: "private preview", + destination: { kind: "session", sessionId: id }, + actions: [], + occurredAt: "2026-08-01T11:00:00.000Z", + updatedAt: "2026-08-01T11:00:00.000Z", + seenAt: null, + dismissedAt: null, + expiresAt: null, + }; +} + +function readyAccountFeed(items: AttentionItem[]): void { + attentionStore.setState({ + itemsById: Object.fromEntries(items.map((item) => [item.id, item])), + availability: { + state: "ready", + title: "Account Activity", + message: "Live across your ADE account.", + recovery: null, + }, + }); +} + +function useAccountScope(): void { + attentionStore.setState({ + preferences: { + ...DEFAULT_ATTENTION_PREFERENCES, + account: { ...DEFAULT_ATTENTION_PREFERENCES.account, dockBadgeScope: "account" }, + }, + }); +} + +function Probe() { + useAppWideSessionAttention(); + return null; +} + +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + setDockBadgeCount = vi.fn(async () => {}); + summarizeTerminalAttention.mockReturnValue({ + runningCount: 0, + activeCount: 0, + needsAttentionCount: 2, + indicator: "none" as const, + byLaneId: {}, + }); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + app: { setDockBadgeCount }, + pty: { onData: () => noopUnsubscribe, onExit: () => noopUnsubscribe }, + agentChat: { onEvent: () => noopUnsubscribe }, + sessions: { onChanged: () => noopUnsubscribe }, + }, + }); + useAppStore.setState({ + showWelcome: false, + project: { rootPath: "/repo/ade" } as never, + }); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + resetAttentionStoreForTests(); + useAppStore.setState({ showWelcome: true, project: null }); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: originalAde, + }); +}); + +async function flushInitialRefresh(): Promise { + await act(async () => { + vi.advanceTimersByTime(3_000); + await Promise.resolve(); + }); +} + +describe("useAppWideSessionAttention dock badge scope", () => { + it("badges this machine's sessions by default", async () => { + readyAccountFeed([needsYouItem("a"), needsYouItem("b"), needsYouItem("c")]); + render(); + await flushInitialRefresh(); + + await waitFor(() => expect(setDockBadgeCount).toHaveBeenCalledWith(2)); + }); + + it("badges the whole account's needs-you tier once the setting is flipped", async () => { + useAccountScope(); + readyAccountFeed([needsYouItem("a"), needsYouItem("b"), needsYouItem("c")]); + render(); + await flushInitialRefresh(); + + await waitFor(() => expect(setDockBadgeCount).toHaveBeenCalledWith(3)); + }); + + it("counts the hidden CTO thread on top of the account tier", async () => { + useAccountScope(); + readyAccountFeed([needsYouItem("a")]); + useAppStore.setState({ ctoAttention: { awaitingInput: true } as never }); + render(); + await flushInitialRefresh(); + + await waitFor(() => expect(setDockBadgeCount).toHaveBeenCalledWith(2)); + useAppStore.setState({ ctoAttention: { awaitingInput: false } as never }); + }); + + /** + * A snapshot that has not landed knows nothing about the other machines, so + * "0 account-wide" would be a claim the data cannot support. Degrade to the + * local count instead of blanking the badge. + */ + it("falls back to the local count while the account feed is not ready", async () => { + useAccountScope(); + attentionStore.setState({ + itemsById: { a: needsYouItem("a"), b: needsYouItem("b"), c: needsYouItem("c") }, + availability: { + state: "degraded", + title: "Account Activity is reconnecting", + message: "Retry to restore live updates.", + recovery: "retry", + }, + }); + render(); + await flushInitialRefresh(); + + await waitFor(() => expect(setDockBadgeCount).toHaveBeenCalledWith(2)); + }); + + it("follows the account feed as it changes, without a session refresh", async () => { + useAccountScope(); + readyAccountFeed([needsYouItem("a")]); + render(); + await flushInitialRefresh(); + await waitFor(() => expect(setDockBadgeCount).toHaveBeenCalledWith(1)); + + act(() => { + readyAccountFeed([needsYouItem("a"), needsYouItem("b")]); + }); + + await waitFor(() => expect(setDockBadgeCount).toHaveBeenCalledWith(2)); + }); + + it("keeps badging the account when no project is open", async () => { + useAccountScope(); + readyAccountFeed([needsYouItem("a"), needsYouItem("b")]); + useAppStore.setState({ showWelcome: true }); + render(); + + await waitFor(() => expect(setDockBadgeCount).toHaveBeenCalledWith(2)); + }); + + it("clears the badge when no project is open and the scope is local", async () => { + readyAccountFeed([needsYouItem("a"), needsYouItem("b")]); + useAppStore.setState({ showWelcome: true }); + render(); + + await waitFor(() => expect(setDockBadgeCount).toHaveBeenCalledWith(0)); + }); +}); diff --git a/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.ts b/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.ts index f711d3637..3de2e9254 100644 --- a/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.ts +++ b/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.ts @@ -1,11 +1,17 @@ import { useEffect, useRef } from "react"; import type { TerminalSessionSummary } from "../../shared/types"; +import { activityBadgeCount } from "../components/attention/activityPriority"; import { shouldRefreshSessionListForChatEvent } from "../lib/chatSessionEvents"; import { invalidateSessionListCache, listSessionsCached, } from "../lib/sessionListCache"; import { summarizeTerminalAttention } from "../lib/terminalAttention"; +import { + attentionStore, + selectDockBadgeScope, + useAttentionStore, +} from "../state/attentionStore"; import { selectActiveProjectRoot, useAppStore } from "../state/appStore"; const EMPTY_TERMINAL_ATTENTION = { @@ -16,6 +22,20 @@ const EMPTY_TERMINAL_ATTENTION = { byLaneId: {}, }; +/** + * Account-scoped dock badge, or `null` when the account feed cannot answer. + * + * Null is not zero. A snapshot that has not landed, has gone degraded, or + * belongs to a signed-out window knows nothing about the other machines, and a + * badge of 0 in that state is a claim the data does not support — so the caller + * falls back to the local count instead. + */ +function accountDockBadgeCount(): number | null { + const state = attentionStore.getState(); + if (state.availability?.state !== "ready") return null; + return activityBadgeCount(state.itemsById); +} + export function useAppWideSessionAttention(): void { const currentProjectRoot = useAppStore(selectActiveProjectRoot); const showWelcome = useAppStore((state) => state.showWelcome); @@ -25,6 +45,9 @@ export function useAppWideSessionAttention(): void { // reach a minimized window. Kept in this hook so `setDockBadgeCount` keeps a // single writer. const ctoAwaitingInput = useAppStore((state) => state.ctoAttention.awaitingInput); + // Account scope is a synced preference, so it can flip from another device + // mid-session; the badge has to follow without a reload. + const dockBadgeScope = useAttentionStore(selectDockBadgeScope); const lastDockBadgeCountRef = useRef(null); const trackedProjectRoot = showWelcome ? null : currentProjectRoot; @@ -33,12 +56,26 @@ export function useAppWideSessionAttention(): void { setTerminalAttention(EMPTY_TERMINAL_ATTENTION); // The hook is app-wide, so route changes keep a project root and do not // enter this branch. Reaching it means the project was closed; clear the - // application-wide badge instead of leaking the previous project's count. - if (lastDockBadgeCountRef.current !== 0) { - lastDockBadgeCountRef.current = 0; - void window.ade?.app?.setDockBadgeCount?.(0)?.catch?.(() => {}); + // application-wide badge instead of leaking the previous project's count + // — unless the badge is account-scoped, in which case the open project is + // irrelevant to what it counts. + const accountOnly = dockBadgeScope === "account" ? accountDockBadgeCount() : null; + const projectlessBadge = accountOnly == null + ? 0 + : accountOnly + (ctoAwaitingInput ? 1 : 0); + if (lastDockBadgeCountRef.current !== projectlessBadge) { + lastDockBadgeCountRef.current = projectlessBadge; + void window.ade?.app?.setDockBadgeCount?.(projectlessBadge)?.catch?.(() => {}); } - return; + if (dockBadgeScope !== "account") return; + // Keep following the account feed while no project is open. + return attentionStore.subscribe(() => { + const count = accountDockBadgeCount(); + const next = count == null ? 0 : count + (ctoAwaitingInput ? 1 : 0); + if (lastDockBadgeCountRef.current === next) return; + lastDockBadgeCountRef.current = next; + void window.ade?.app?.setDockBadgeCount?.(next)?.catch?.(() => {}); + }); } let refreshTimer: number | null = null; @@ -46,6 +83,26 @@ export function useAppWideSessionAttention(): void { let refreshInFlight = false; let refreshQueued = false; let cancelled = false; + let localNeedsAttention = 0; + + /** + * The single dock-badge write. Account scope counts the whole account's + * needs-you tier; local scope counts this Mac's sessions. Either way the + * CTO thread is added on top, because it is hidden from both feeds. + */ + const pushDockBadge = () => { + const account = dockBadgeScope === "account" ? accountDockBadgeCount() : null; + const badgeCount = (account ?? localNeedsAttention) + (ctoAwaitingInput ? 1 : 0); + // Push on change so a blocked agent reaches the user even with the + // window minimized. + if (lastDockBadgeCountRef.current === badgeCount) return; + lastDockBadgeCountRef.current = badgeCount; + void window.ade?.app?.setDockBadgeCount?.(badgeCount)?.catch?.(() => {}); + }; + + const unsubscribeAttention = dockBadgeScope === "account" + ? attentionStore.subscribe(pushDockBadge) + : () => {}; const refreshTerminalAttention = async () => { if (cancelled) return; @@ -64,13 +121,9 @@ export function useAppWideSessionAttention(): void { if (cancelled) return; const attention = summarizeTerminalAttention(sessions); setTerminalAttention(attention); - const badgeCount = attention.needsAttentionCount + (ctoAwaitingInput ? 1 : 0); - // Dock badge mirrors the loud tier only; push on change so a blocked - // agent reaches the user even with the window minimized. - if (lastDockBadgeCountRef.current !== badgeCount) { - lastDockBadgeCountRef.current = badgeCount; - void window.ade?.app?.setDockBadgeCount?.(badgeCount)?.catch?.(() => {}); - } + // Dock badge mirrors the loud tier only. + localNeedsAttention = attention.needsAttentionCount; + pushDockBadge(); } catch { // best effort } finally { @@ -129,6 +182,7 @@ export function useAppWideSessionAttention(): void { return () => { cancelled = true; + unsubscribeAttention(); try { unsubscribeData(); unsubscribeExit(); @@ -142,5 +196,5 @@ export function useAppWideSessionAttention(): void { window.removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onVisibilityChange); }; - }, [trackedProjectRoot, setTerminalAttention, ctoAwaitingInput]); + }, [trackedProjectRoot, setTerminalAttention, ctoAwaitingInput, dockBadgeScope]); } diff --git a/apps/desktop/src/renderer/state/attentionStore.ts b/apps/desktop/src/renderer/state/attentionStore.ts index b3bd80e3a..ac24a0f7c 100644 --- a/apps/desktop/src/renderer/state/attentionStore.ts +++ b/apps/desktop/src/renderer/state/attentionStore.ts @@ -6,6 +6,7 @@ import { attentionItemNeedsInbox, sortAttentionItems, type AttentionItem, + type AttentionPreferences, type AttentionSnapshot, type AttentionTombstone, } from "../../shared/types"; @@ -38,11 +39,18 @@ export type AttentionStoreState = { scope: AttentionScope; selectedItemId: string | null; headerSurfaceVisible: boolean; + /** + * Last account preferences this window loaded. Null means "not loaded yet", + * which every reader must treat as the conservative default rather than as + * "the user turned it off" — see `selectActivityHideDetails`. + */ + preferences: AttentionPreferences | null; syncStatus: AttentionSyncStatus; syncError: string | null; pendingAcknowledgements: Record; acknowledgementErrors: Record; resetStream: () => void; + setPreferences: (preferences: AttentionPreferences | null) => void; applySnapshot: (snapshot: AttentionSnapshot) => void; upsertItem: (item: AttentionItem) => void; removeItem: (tombstone: AttentionTombstone) => void; @@ -115,6 +123,31 @@ export function selectAttentionCounts( }; } +/** + * Whether Activity surfaces may show agent-authored text. Unloaded preferences + * resolve to `false` rather than `true`: hide-details is off by default, and + * defaulting a *display* choice to "hidden" would make every surface look + * broken for the seconds before the account load lands. The native notch takes + * the opposite default because it paints over the menu bar of a locked-away + * Mac — see `failClosedAttentionNotchSettings` in `useAttentionSync.ts`. + */ +export function selectActivityHideDetails( + state: Pick, +): boolean { + return state.preferences?.account?.hideDetails === true; +} + +/** + * Dock-badge scope. Local by default so a fresh install badges only the work + * on the Mac in front of the user; flipping it to `account` is an explicit, + * synced choice. + */ +export function selectDockBadgeScope( + state: Pick, +): "local" | "account" { + return state.preferences?.account?.dockBadgeScope === "account" ? "account" : "local"; +} + export function selectAttentionUnseenCount( state: Pick, ): number { @@ -138,6 +171,7 @@ function createInitialState(): Pick< | "scope" | "selectedItemId" | "headerSurfaceVisible" + | "preferences" | "syncStatus" | "syncError" | "pendingAcknowledgements" @@ -156,6 +190,7 @@ function createInitialState(): Pick< scope: { kind: "all" }, selectedItemId: null, headerSurfaceVisible: false, + preferences: null, syncStatus: "idle", syncError: null, pendingAcknowledgements: {}, @@ -170,6 +205,9 @@ export const attentionStore = createStore((set) => ({ ...createInitialState(), headerSurfaceVisible: state.headerSurfaceVisible, })), + // Preferences are account-scoped, so a stream reset deliberately drops them + // rather than letting the previous account's privacy choice govern this one. + setPreferences: (preferences) => set({ preferences }), applySnapshot: (snapshot) => set((state) => { // Account revisions are monotonic only inside one verified account. diff --git a/docs/features/web-client/README.md b/docs/features/web-client/README.md index 0dcdc6b59..7d2e633f3 100644 --- a/docs/features/web-client/README.md +++ b/docs/features/web-client/README.md @@ -365,7 +365,7 @@ Reused desktop renderer (web-mode adaptation): `WelcomeVideoGate.tsx`) reads this flag to hide native window controls, the updater, the onboarding tour, and tabs with no sync-protocol backing instead of rendering broken affordances. -- `apps/desktop/src/renderer/components/attention/HeaderAttentionControl.tsx` +- `apps/desktop/src/renderer/components/attention/HeaderActivityControl.tsx` and `AttentionCenter.tsx` - the project-independent header drawer and its secondary Open all/history route. Attention is a global utility route, not another selected-machine tab, so it is intentionally separate from From 3de2aac0da983360b1f5761b6fe7ef234deb9238 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:56:24 -0400 Subject: [PATCH 05/19] =?UTF-8?q?activity(p1b):=20coordinator=20ack=20fenc?= =?UTF-8?q?ing=20+=20machine-mute=20IPC=20=E2=80=94=20putMachinePreference?= =?UTF-8?q?s=20channel,=20ready-state=20copy=20removed,=20host-only=20pres?= =?UTF-8?q?ence=20stamping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../attentionAccountCoordinator.test.ts | 181 +++++++++++++++++- .../attention/attentionAccountCoordinator.ts | 113 +++++++++-- .../attention/attentionNotchRouter.test.ts | 19 +- .../attention/attentionNotchRouter.ts | 10 + .../src/main/services/ipc/registerIpc.ts | 19 ++ .../main/services/ipc/runtimeBridge.test.ts | 25 ++- apps/desktop/src/preload/global.d.ts | 5 + apps/desktop/src/preload/preload.ts | 11 ++ apps/desktop/src/shared/ipc.ts | 1 + 9 files changed, 368 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main/services/attention/attentionAccountCoordinator.test.ts b/apps/desktop/src/main/services/attention/attentionAccountCoordinator.test.ts index 8c551034e..8ed816ba7 100644 --- a/apps/desktop/src/main/services/attention/attentionAccountCoordinator.test.ts +++ b/apps/desktop/src/main/services/attention/attentionAccountCoordinator.test.ts @@ -6,7 +6,10 @@ import { DEFAULT_ATTENTION_PREFERENCES, type AttentionSnapshot, } from "../../../shared/types/attention"; -import { AttentionAccountCoordinator } from "./attentionAccountCoordinator"; +import { + ActivityAcknowledgmentStaleError, + AttentionAccountCoordinator, +} from "./attentionAccountCoordinator"; function snapshot( overrides: Partial = {}, @@ -62,6 +65,8 @@ describe("AttentionAccountCoordinator", () => { streamId: "account:owner-a", availability: { state: "ready", + title: "", + message: "", recovery: null, }, }); @@ -69,6 +74,180 @@ describe("AttentionAccountCoordinator", () => { expect(callAttention).not.toHaveBeenCalled(); }); + it("marks only the responding host online in a machine fallback", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-29T12:30:00.000Z")); + const callAttention = vi.fn(async () => snapshot({ + streamId: "machine:machine-local", + machines: [ + { + machineKey: "machine-local", + name: "This MacBook", + online: false, + lastSeenAt: "2026-07-29T11:00:00.000Z", + }, + { + machineKey: "machine-remote", + name: "Studio Mac", + online: true, + lastSeenAt: "2026-07-29T10:00:00.000Z", + }, + ], + items: [ + { + id: "local-item", + revision: 1, + machine: { + machineKey: "machine-local", + online: false, + lastSeenAt: "2026-07-29T11:00:00.000Z", + }, + } as never, + { + id: "remote-item", + revision: 1, + machine: { + machineKey: "machine-remote", + online: false, + lastSeenAt: "2026-07-29T10:00:00.000Z", + }, + } as never, + ], + })); + const coordinator = new AttentionAccountCoordinator({ + getLogger: logger, + getCurrentAccountOwnerId: () => null, + localRuntimeConnectionPool: { callAttention } as any, + }); + + const result = await coordinator.getSnapshot({}); + + expect(result.machines?.[0]).toMatchObject({ + machineKey: "machine-local", + online: true, + lastSeenAt: "2026-07-29T12:30:00.000Z", + }); + expect(result.machines?.[1]).toEqual({ + machineKey: "machine-remote", + name: "Studio Mac", + online: true, + lastSeenAt: "2026-07-29T10:00:00.000Z", + }); + expect(result.availability?.hostName).toBe("This MacBook"); + expect(result.items[0]?.machine).toMatchObject({ + machineKey: "machine-local", + online: true, + lastSeenAt: "2026-07-29T12:30:00.000Z", + }); + expect(result.items[1]?.machine).toEqual({ + machineKey: "machine-remote", + online: false, + lastSeenAt: "2026-07-29T10:00:00.000Z", + }); + vi.useRealTimers(); + }); + + it("fences account acknowledgments with cached revisions and the loaded owner", async () => { + const acknowledgeAttention = vi.fn(async () => ({ + applied: ["attention-1"], + stale: [], + })); + const coordinator = new AttentionAccountCoordinator({ + getLogger: logger, + getCurrentAccountOwnerId: () => "owner-a", + accountAttentionClient: { + getAttentionSnapshot: vi.fn(async () => snapshot({ + scope: "account", + items: [{ id: "attention-1", revision: 8 } as never], + })), + acknowledgeAttention, + reportAttentionPresence: vi.fn(), + getAttentionPreferences: vi.fn(), + putAttentionPreferences: vi.fn(), + }, + }); + + await coordinator.getSnapshot({}); + await coordinator.acknowledge({ + itemIds: ["attention-1"], + sourceRevisions: { "attention-1": 999 }, + expectedAccountOwnerId: "owner-a", + seenAt: "2026-07-29T12:01:00.000Z", + }); + + expect(acknowledgeAttention).toHaveBeenCalledWith({ + itemIds: ["attention-1"], + sourceRevisions: { "attention-1": 8 }, + expectedAccountOwnerId: "owner-a", + seenAt: "2026-07-29T12:01:00.000Z", + }); + expect(acknowledgeAttention).toHaveBeenCalledTimes(1); + expect(coordinator).toBeInstanceOf(AttentionAccountCoordinator); + }); + + it("surfaces relay-stale account acknowledgments as a typed refresh error", async () => { + const coordinator = new AttentionAccountCoordinator({ + getLogger: logger, + getCurrentAccountOwnerId: () => "owner-a", + accountAttentionClient: { + getAttentionSnapshot: vi.fn(async () => snapshot({ + scope: "account", + items: [{ id: "attention-1", revision: 8 } as never], + })), + acknowledgeAttention: vi.fn(async () => ({ + applied: [], + stale: ["attention-1"], + })), + reportAttentionPresence: vi.fn(), + getAttentionPreferences: vi.fn(), + putAttentionPreferences: vi.fn(), + }, + }); + + await coordinator.getSnapshot({}); + const error = await coordinator.acknowledge({ + itemIds: ["attention-1"], + expectedAccountOwnerId: "owner-a", + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ActivityAcknowledgmentStaleError); + expect(error).toMatchObject({ + code: "activity_acknowledgment_stale", + staleItemIds: ["attention-1"], + }); + expect((error as Error).message).toMatch(/refresh Activity/i); + }); + + it("writes one machine preference scope through the account relay", async () => { + const putActivityMachinePreferences = vi.fn(async () => undefined); + const coordinator = new AttentionAccountCoordinator({ + getLogger: logger, + getCurrentAccountOwnerId: () => "owner-a", + accountAttentionClient: { + getAttentionSnapshot: vi.fn(), + acknowledgeAttention: vi.fn(), + reportAttentionPresence: vi.fn(), + getAttentionPreferences: vi.fn(), + putAttentionPreferences: vi.fn(), + putActivityMachinePreferences, + }, + }); + + await coordinator.putActivityMachinePreferences( + " machine-1 ", + { notificationsEnabled: false }, + "owner-a", + ); + + expect(putActivityMachinePreferences).toHaveBeenCalledWith( + "owner-a", + "machine-1", + { notificationsEnabled: false }, + ); + expect(putActivityMachinePreferences).toHaveBeenCalledTimes(1); + expect(coordinator).toBeInstanceOf(AttentionAccountCoordinator); + }); + it("sanitizes account auth failures and falls back to the local machine", async () => { const testLogger = logger(); const getAttentionSnapshot = vi.fn(async () => { diff --git a/apps/desktop/src/main/services/attention/attentionAccountCoordinator.ts b/apps/desktop/src/main/services/attention/attentionAccountCoordinator.ts index 1b625cf3f..6aff0ebca 100644 --- a/apps/desktop/src/main/services/attention/attentionAccountCoordinator.ts +++ b/apps/desktop/src/main/services/attention/attentionAccountCoordinator.ts @@ -2,6 +2,7 @@ import type { PushRelayClient } from "../../../../../ade-cli/src/services/push/p import { PushRelayRequestError } from "../../../../../ade-cli/src/services/push/pushRelayClient"; import { DEFAULT_ATTENTION_PREFERENCES, + type AttentionPreferenceScope, type AttentionPreferences, type AttentionPresence, type AttentionSnapshot, @@ -16,7 +17,7 @@ type AccountAttentionClient = Pick< | "reportAttentionPresence" | "getAttentionPreferences" | "putAttentionPreferences" ->; +> & Partial>; type AttentionAccountCoordinatorOptions = { getLogger: () => Pick; @@ -46,6 +47,17 @@ type AttentionPreferenceUpdateRequest = AttentionPreferenceRequest & { preferences?: unknown; }; +export class ActivityAcknowledgmentStaleError extends Error { + readonly code = "activity_acknowledgment_stale" as const; + + constructor(readonly staleItemIds: string[]) { + super( + "One or more Activity items changed after they loaded. Refresh Activity, then try again.", + ); + this.name = "ActivityAcknowledgmentStaleError"; + } +} + function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } @@ -54,7 +66,7 @@ export class AttentionAccountCoordinator { private loggedRuntimeCompatibilityFailure = false; private lastSnapshotScope: AttentionSnapshot["scope"] | null = null; private lastSnapshotAccountOwnerId: string | null = null; - private readonly lastMachineItemRevisions = new Map(); + private readonly lastSnapshotItemRevisions = new Map(); constructor(private readonly options: AttentionAccountCoordinatorOptions) {} @@ -83,8 +95,8 @@ export class AttentionAccountCoordinator { accountOwnerId, availability: { state: "ready", - title: "Account Attention is live", - message: "Work from every signed-in ADE machine is available.", + title: "", + message: "", recovery: null, }, }; @@ -107,12 +119,30 @@ export class AttentionAccountCoordinator { "getMachineSnapshot", {}, ); - const machineName = snapshot.machines?.[0]?.name?.trim() || "this Mac"; + const generatedAt = new Date().toISOString(); + const hostMachineKey = snapshot.streamId?.startsWith("machine:") + ? snapshot.streamId.slice("machine:".length) + : null; + const keyedHostMachine = hostMachineKey + ? snapshot.machines?.find((machine) => machine.machineKey === hostMachineKey) + : null; + const hostMachine = keyedHostMachine ?? snapshot.machines?.[0]; + const resolvedHostMachineKey = hostMachine?.machineKey ?? hostMachineKey; + const stampHostMachine = (machine: T): T => + machine.machineKey === resolvedHostMachineKey + ? { ...machine, online: true, lastSeenAt: generatedAt } + : machine; + const machineName = hostMachine?.name?.trim() || "this Mac"; const accountAvailability = accountFailure ? this.describeAccountFailure(accountFailure) : null; const machineSnapshot: AttentionSnapshot = { ...snapshot, + machines: snapshot.machines?.map(stampHostMachine), + items: snapshot.items.map((item) => + item.machine && typeof item.machine.machineKey === "string" + ? { ...item, machine: stampHostMachine(item.machine) } + : item), scope: "machine", accountOwnerId, availability: accountOwnerId @@ -199,7 +229,7 @@ export class AttentionAccountCoordinator { ) : {}; const staleItemIds = itemIds.filter((itemId) => - sourceRevisions[itemId] !== this.lastMachineItemRevisions.get(itemId)); + sourceRevisions[itemId] !== this.lastSnapshotItemRevisions.get(itemId)); if (staleItemIds.length > 0) { throw new Error( "This machine can only acknowledge the exact item revision that was loaded. Refresh and try again.", @@ -236,7 +266,40 @@ export class AttentionAccountCoordinator { throw new Error("Refresh Attention before acknowledging this item."); } if (currentAccountOwnerId && this.options.accountAttentionClient) { - await this.options.accountAttentionClient.acknowledgeAttention(acknowledgment); + const requestedAccountOwnerId = + request.expectedAccountOwnerId === null + || typeof request.expectedAccountOwnerId === "string" + ? request.expectedAccountOwnerId?.trim() || null + : undefined; + if ( + requestedAccountOwnerId === undefined + || requestedAccountOwnerId !== this.lastSnapshotAccountOwnerId + ) { + throw new Error( + "The account Activity scope changed after this item loaded. Refresh and try again.", + ); + } + const sourceRevisions = Object.fromEntries( + itemIds.flatMap((itemId) => { + const revision = this.lastSnapshotItemRevisions.get(itemId); + return revision === undefined ? [] : [[itemId, revision]]; + }), + ); + const staleItemIds = itemIds.filter((itemId) => sourceRevisions[itemId] === undefined); + if (staleItemIds.length > 0) { + throw new ActivityAcknowledgmentStaleError(staleItemIds); + } + const result = await this.options.accountAttentionClient.acknowledgeAttention({ + ...acknowledgment, + sourceRevisions, + expectedAccountOwnerId: requestedAccountOwnerId, + }); + if (!result) { + throw new Error("Sign in again, refresh Activity, then try to acknowledge this item."); + } + if (result.stale.length > 0) { + throw new ActivityAcknowledgmentStaleError(result.stale); + } return; } throw new Error("Sign in again, refresh Attention, then try to acknowledge this item."); @@ -300,6 +363,34 @@ export class AttentionAccountCoordinator { ); } + async putActivityMachinePreferences( + machineKey: unknown, + partial: unknown, + expectedAccountOwnerId?: unknown, + ): Promise { + const normalizedMachineKey = typeof machineKey === "string" ? machineKey.trim() : ""; + if (!normalizedMachineKey || !isRecord(partial)) { + throw new Error("A valid Activity machine preference update is required."); + } + const accountOwnerId = expectedAccountOwnerId === undefined + ? this.currentAccountOwnerId() + : this.requireCurrentAccountOwner(expectedAccountOwnerId); + if (!accountOwnerId) { + throw new Error("Sign in before changing Activity machine preferences."); + } + if ( + !this.options.accountAttentionClient + || !this.options.accountAttentionClient.putActivityMachinePreferences + ) { + throw new Error("Account Activity is unavailable until this Mac's ADE brain is ready."); + } + await this.options.accountAttentionClient.putActivityMachinePreferences( + accountOwnerId, + normalizedMachineKey, + partial as Partial, + ); + } + private currentAccountOwnerId(): string | null { return this.options.getCurrentAccountOwnerId()?.trim() || null; } @@ -310,11 +401,9 @@ export class AttentionAccountCoordinator { ): void { this.lastSnapshotScope = snapshot.scope ?? null; this.lastSnapshotAccountOwnerId = accountOwnerId; - this.lastMachineItemRevisions.clear(); - if (snapshot.scope === "machine") { - for (const item of snapshot.items) { - this.lastMachineItemRevisions.set(item.id, item.revision); - } + this.lastSnapshotItemRevisions.clear(); + for (const item of snapshot.items) { + this.lastSnapshotItemRevisions.set(item.id, item.revision); } } diff --git a/apps/desktop/src/main/services/attention/attentionNotchRouter.test.ts b/apps/desktop/src/main/services/attention/attentionNotchRouter.test.ts index ce144b07c..8905e2ee8 100644 --- a/apps/desktop/src/main/services/attention/attentionNotchRouter.test.ts +++ b/apps/desktop/src/main/services/attention/attentionNotchRouter.test.ts @@ -101,7 +101,24 @@ describe("Attention Notch routing", () => { }); it("accepts bounded canonical snapshots and settings", () => { - expect(parseAttentionNotchSnapshot(snapshot())).toEqual(snapshot()); + const activitySnapshot = { + ...snapshot(item({ + activityTier: "signal", + contentFingerprint: "content-v2", + alertFingerprint: "alert-v2", + statusSince: "2026-07-28T12:00:01.000Z", + })), + itemsTruncated: true, + } satisfies AttentionSnapshot; + expect(parseAttentionNotchSnapshot(activitySnapshot)).toEqual(activitySnapshot); + expect(parseAttentionNotchSnapshot({ + ...activitySnapshot, + itemsTruncated: "yes", + })).toBeNull(); + expect(parseAttentionNotchSnapshot({ + ...activitySnapshot, + items: [{ ...activitySnapshot.items[0], activityTier: "urgent" }], + })).toBeNull(); expect(parseAttentionNotchSettings({ enabled: true, revealMode: "click", diff --git a/apps/desktop/src/main/services/attention/attentionNotchRouter.ts b/apps/desktop/src/main/services/attention/attentionNotchRouter.ts index 43f37e663..663e561cd 100644 --- a/apps/desktop/src/main/services/attention/attentionNotchRouter.ts +++ b/apps/desktop/src/main/services/attention/attentionNotchRouter.ts @@ -159,6 +159,14 @@ function isAttentionItem(value: unknown): value is AttentionItem { || !Number.isSafeInteger(value.revision) || Number(value.revision) < 0 || !isNonEmptyString(value.fingerprint, 1_024) + || ( + value.activityTier !== undefined + && value.activityTier !== "signal" + && value.activityTier !== "ambient" + && value.activityTier !== "idle" + ) + || (value.contentFingerprint !== undefined && !isNonEmptyString(value.contentFingerprint, 1_024)) + || (value.alertFingerprint !== undefined && !isNonEmptyString(value.alertFingerprint, 1_024)) || (value.kind !== "agent" && value.kind !== "pull_request") || typeof value.eventKind !== "string" || !ATTENTION_EVENTS.has(value.eventKind) @@ -220,6 +228,7 @@ function isAttentionItem(value: unknown): value is AttentionItem { || !value.actions.every(isAttentionAction) || !isNonEmptyString(value.occurredAt, 128) || !isNonEmptyString(value.updatedAt, 128) + || !isNullableString(value.statusSince, 128) || !isNullableString(value.seenAt, 128) || !isNullableString(value.dismissedAt, 128) || !isNullableString(value.expiresAt, 128) @@ -245,6 +254,7 @@ export function parseAttentionNotchSnapshot(input: unknown): AttentionSnapshot | || !Array.isArray(input.items) || input.items.length > MAX_NOTCH_ITEMS || !input.items.every(isAttentionItem) + || (input.itemsTruncated !== undefined && typeof input.itemsTruncated !== "boolean") ) { return null; } diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 66baa1f5f..72782b987 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -1628,6 +1628,7 @@ export function registerIpc({ | "reportAttentionPresence" | "getAttentionPreferences" | "putAttentionPreferences" + | "putActivityMachinePreferences" > | null; }) { // Process-scoped by design: renderer reloads and additional windows in the @@ -3257,6 +3258,24 @@ export function registerIpc({ async (_event, input: unknown) => attentionAccountCoordinator.putPreferences(input), ); + ipcMain.handle( + IPC.attentionPutMachinePreferences, + async (_event, input: unknown) => { + const request = input && typeof input === "object" && !Array.isArray(input) + ? input as { + accountOwnerId?: unknown; + machineKey?: unknown; + preferences?: unknown; + } + : {}; + return attentionAccountCoordinator.putActivityMachinePreferences( + request.machineKey, + request.preferences, + request.accountOwnerId, + ); + }, + ); + ipcMain.handle(IPC.attentionOpenItem, async (_event, input: unknown) => { const snapshot = parseAttentionNotchSnapshot({ contractVersion: ATTENTION_CONTRACT_VERSION, diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index 49f3c583a..0ceb8d1d8 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -1519,16 +1519,20 @@ describe("registerIpc sync bridge", () => { streamId: "account-stream", revision: 5, generatedAt: "2026-07-28T12:00:00.000Z", - items: [], + items: [{ id: "attention-1", revision: 5 } as never], tombstones: [], }; const callAttention = vi.fn(); const accountAttentionClient = { getAttentionSnapshot: vi.fn(async () => snapshot), - acknowledgeAttention: vi.fn(async () => ({})), + acknowledgeAttention: vi.fn(async () => ({ + applied: ["attention-1"], + stale: [], + })), reportAttentionPresence: vi.fn(async () => undefined), getAttentionPreferences: vi.fn(async () => ({ account: { hideDetails: true } })), putAttentionPreferences: vi.fn(async () => undefined), + putActivityMachinePreferences: vi.fn(async () => undefined), }; const openAttentionItem = vi.fn(async () => undefined); registerIpc({ @@ -1570,6 +1574,8 @@ describe("registerIpc sync bridge", () => { }); await ipcHandlers.get(IPC.attentionAcknowledge)?.(eventForSender(), { itemIds: ["attention-1"], + sourceRevisions: { "attention-1": 5 }, + expectedAccountOwnerId: "account-a", seenAt: "2026-07-28T12:01:00.000Z", }); await ipcHandlers.get(IPC.attentionReportPresence)?.(eventForSender(), { @@ -1588,6 +1594,14 @@ describe("registerIpc sync bridge", () => { preferences: { account: { hideDetails: false } }, }, ); + await ipcHandlers.get(IPC.attentionPutMachinePreferences)?.( + eventForSender(), + { + accountOwnerId: "account-a", + machineKey: "machine-a", + preferences: { notificationsEnabled: false }, + }, + ); const attentionItem: AttentionItem = { contractVersion: ATTENTION_CONTRACT_VERSION, id: "attention-1", @@ -1637,6 +1651,8 @@ describe("registerIpc sync bridge", () => { ); expect(accountAttentionClient.acknowledgeAttention).toHaveBeenCalledWith({ itemIds: ["attention-1"], + sourceRevisions: { "attention-1": 5 }, + expectedAccountOwnerId: "account-a", seenAt: "2026-07-28T12:01:00.000Z", }); expect(accountAttentionClient.getAttentionPreferences).toHaveBeenCalledWith("account-a"); @@ -1644,6 +1660,11 @@ describe("registerIpc sync bridge", () => { "account-a", { account: { hideDetails: false } }, ); + expect(accountAttentionClient.putActivityMachinePreferences).toHaveBeenCalledWith( + "account-a", + "machine-a", + { notificationsEnabled: false }, + ); expect(openAttentionItem).toHaveBeenCalledWith(attentionItem); openAttentionItem.mockRejectedValueOnce( diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 3b5a48248..15e08c806 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -1203,6 +1203,11 @@ declare global { accountOwnerId: string, preferences: import("../shared/types").AttentionPreferences, ) => Promise; + putMachinePreferences?: ( + accountOwnerId: string, + machineKey: string, + preferences: Partial, + ) => Promise; openItem: ( item: import("../shared/types").AttentionItem, ) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index fe9006dd0..c12f5c3d7 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -6,6 +6,7 @@ import { type AttentionItem, type AttentionNotchAcknowledgeRequest, type AttentionNotchSettings, + type AttentionPreferenceScope, type AttentionPreferences, type AttentionPresence, type AttentionSnapshot, @@ -4875,6 +4876,16 @@ contextBridge.exposeInMainWorld("ade", { accountOwnerId, preferences, }), + putMachinePreferences: async ( + accountOwnerId: string, + machineKey: string, + preferences: Partial, + ): Promise => + ipcRenderer.invoke(IPC.attentionPutMachinePreferences, { + accountOwnerId, + machineKey, + preferences, + }), openItem: async (item: AttentionItem): Promise => { await ipcRenderer.invoke(IPC.attentionOpenItem, item); }, diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index aa3cb5153..987a8a6cb 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -50,6 +50,7 @@ export const IPC = { attentionReportPresence: "ade.attention.reportPresence", attentionGetPreferences: "ade.attention.getPreferences", attentionPutPreferences: "ade.attention.putPreferences", + attentionPutMachinePreferences: "ade.attention.putMachinePreferences", attentionOpenItem: "ade.attention.openItem", analyticsCapture: "ade.analytics.capture", analyticsGetStatus: "ade.analytics.getStatus", From c992270d5d13ba95992095289818de0e8a458788 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:56:56 -0400 Subject: [PATCH 06/19] activity(p0): relay alert pushes carry content-available for iOS background refresh Co-Authored-By: Claude Fable 5 --- apps/push-relay/src/attention.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/push-relay/src/attention.ts b/apps/push-relay/src/attention.ts index c23071d43..ef8bbd2e9 100644 --- a/apps/push-relay/src/attention.ts +++ b/apps/push-relay/src/attention.ts @@ -1134,6 +1134,9 @@ async function deliverAttentionNotifications( ...(body ? { body } : {}), }, ...(soundsEnabled ? { sound: "default" } : {}), + // Wakes the app for a background snapshot refresh alongside the + // visible alert; foreground polling remains the guaranteed path. + "content-available": 1, "thread-id": item.id, "interruption-level": item.eventKind === "agent_needs_you" ? "time-sensitive" From d28e5148263c627d350d573dc141808c64cd1652 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:05:02 -0400 Subject: [PATCH 07/19] =?UTF-8?q?activity(p1):=20publisher=20protocol=202?= =?UTF-8?q?=20=E2=80=94=20fingerprint=20split,=20delta/reconcile/presence?= =?UTF-8?q?=20modes,=20roster=20expansion=20to=20all=20non-archived=20sess?= =?UTF-8?q?ions,=20remote-ack=20persistence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- apps/ade-cli/src/bootstrap.ts | 7 +- apps/ade-cli/src/cli.ts | 22 +- .../services/push/activityFingerprint.test.ts | 76 ++ .../src/services/push/activityFingerprint.ts | 86 +++ .../push/pushPublisherService.test.ts | 528 ++++++++++++- .../src/services/push/pushPublisherService.ts | 708 +++++++++++++++--- .../services/push/pushRegistrationStore.ts | 119 +++ .../src/services/push/pushRelayClient.ts | 71 +- .../src/services/sync/rosterBuilder.test.ts | 10 + 9 files changed, 1511 insertions(+), 116 deletions(-) create mode 100644 apps/ade-cli/src/services/push/activityFingerprint.test.ts create mode 100644 apps/ade-cli/src/services/push/activityFingerprint.ts diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 5febf3db3..986c1dd94 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -138,7 +138,7 @@ import type { BuiltInBrowserDesktopBridgeClient } from "./services/builtInBrowse import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; import { createPushRegistrationStore } from "./services/push/pushRegistrationStore"; import { createPushRelayClient } from "./services/push/pushRelayClient"; -import { getSharedPushPublisherService, resolvePushRelayStateFile, type PushPrNotification, type PushPublisherService } from "./services/push/pushPublisherService"; +import { getSharedPushPublisherService, resolvePushRelayStateFile, type PushPrNotification, type PushPublisherDeps, type PushPublisherService } from "./services/push/pushPublisherService"; import type { createFileService } from "../../desktop/src/main/services/files/fileService"; import type { AppNavigationRequest, AppNavigationResult, PortLease } from "../../desktop/src/shared/types"; import type { PrEventPayload } from "../../desktop/src/shared/types/prs"; @@ -235,6 +235,7 @@ export type AdeRuntimeSyncOptions = { phonePairingStateDir?: string; projectCatalogProvider?: Parameters[0]["projectCatalogProvider"]; rosterProvider?: Parameters[0]["rosterProvider"]; + activityRosterProvider?: PushPublisherDeps["activityRosterProvider"]; foreignChatProvider?: Parameters[0]["foreignChatProvider"]; personalChatScope?: Parameters[0]["personalChatScope"]; remoteCommandExecutor?: Parameters[0]["remoteCommandExecutor"]; @@ -1530,8 +1531,12 @@ export async function createAdeRuntime(args: { } return { machineKey, deviceId }; }, + activityRosterProvider: resolvedArgs.syncRuntime?.activityRosterProvider, }; }); + pushPublisherService.setActivityRosterProvider( + resolvedArgs.syncRuntime?.activityRosterProvider ?? null, + ); const detachPushSources = publishPushEvents ? pushPublisherService.attachSources(projectId, { // The lightweight no-agent headless chat stub intentionally exposes diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index c88e5a793..6400130a8 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -15989,6 +15989,17 @@ async function runServe( personalChatScope, }), ); + // Shared by mobile roster delivery and protocol-2 Activity publishing. The + // closure is evaluated only after `scopeRegistry` has been assigned. + const activityRosterProvider = { + buildSnapshot: () => + buildRosterSnapshot({ + projectRegistry, + scopeRegistry, + hostProjectId: preferredSyncProjectId, + logger: headlessProjectLogger, + }), + }; scopeRegistry = new ProjectScopeRegistry(projectRegistry, { syncRuntime: { enabled: syncEnabled, @@ -16008,15 +16019,8 @@ async function runServe( // which is assigned by this very `new ProjectScopeRegistry(...)` call — // safe because `buildSnapshot` only runs later (on `roster_subscribe`), // by which point the binding is set (mirrors machineProjectCatalogProvider). - rosterProvider: { - buildSnapshot: () => - buildRosterSnapshot({ - projectRegistry, - scopeRegistry, - hostProjectId: preferredSyncProjectId, - logger: headlessProjectLogger, - }), - }, + rosterProvider: activityRosterProvider, + activityRosterProvider, // Cross-project chat "quick look": lets the phone stream a foreign // project's chat transcript read-only without a project switch. Reads // straight off that project's `.ade` transcripts dir (registry-validated, diff --git a/apps/ade-cli/src/services/push/activityFingerprint.test.ts b/apps/ade-cli/src/services/push/activityFingerprint.test.ts new file mode 100644 index 000000000..821b7516d --- /dev/null +++ b/apps/ade-cli/src/services/push/activityFingerprint.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + ATTENTION_CONTRACT_VERSION, + type AttentionItem, +} from "../../../../desktop/src/shared/types/attention"; +import { + activityAlertFingerprint, + activityContentFingerprint, +} from "./activityFingerprint"; + +function item(overrides: Partial = {}): AttentionItem { + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + id: "agent:machine:session", + revision: 1, + fingerprint: "legacy", + activityTier: "ambient", + kind: "agent", + eventKind: "agent_running", + phase: "running", + machine: { + machineKey: "machine", + name: "MacBook", + online: true, + lastSeenAt: "2026-08-01T12:00:00.000Z", + }, + project: { projectId: "project", name: "ADE" }, + laneId: "lane", + laneName: "feature", + provider: "Codex", + model: "gpt-5", + title: "Codex is working", + preview: "Working for 1.2s · 120 tokens · 3 files", + privacyPreview: "An ADE agent is working.", + destination: { kind: "session", sessionId: "session", itemId: "approval-1" }, + actions: [{ id: "open", kind: "open", label: "Open" }], + occurredAt: "2026-08-01T12:00:00.000Z", + updatedAt: "2026-08-01T12:00:00.000Z", + seenAt: null, + dismissedAt: null, + expiresAt: "2026-08-01T14:00:00.000Z", + ...overrides, + }; +} + +describe("Activity fingerprints", () => { + it("keeps content and alert identity stable across elapsed/count preview churn", () => { + const first = item(); + const churned = item({ + revision: 99, + preview: "Working for 48.9s · 8,420 tokens · 27 files", + occurredAt: "2026-08-01T12:00:48.000Z", + updatedAt: "2026-08-01T12:00:48.000Z", + expiresAt: "2026-08-01T14:00:48.000Z", + machine: { + ...first.machine, + online: false, + lastSeenAt: "2026-08-01T12:00:48.000Z", + }, + detail: "different noisy detail", + recentActivity: ["tool event"], + }); + + expect(activityContentFingerprint(churned)).toBe(activityContentFingerprint(first)); + expect(activityAlertFingerprint(churned)).toBe(activityAlertFingerprint(first)); + }); + + it("changes alert identity when the session destination item changes", () => { + const first = item(); + const nextApproval = item({ + destination: { kind: "session", sessionId: "session", itemId: "approval-2" }, + }); + + expect(activityAlertFingerprint(nextApproval)).not.toBe(activityAlertFingerprint(first)); + }); +}); diff --git a/apps/ade-cli/src/services/push/activityFingerprint.ts b/apps/ade-cli/src/services/push/activityFingerprint.ts new file mode 100644 index 000000000..e897d2eb0 --- /dev/null +++ b/apps/ade-cli/src/services/push/activityFingerprint.ts @@ -0,0 +1,86 @@ +import { createHash } from "node:crypto"; +import { + sanitizeAttentionPreview, + type AttentionItem, +} from "../../../../desktop/src/shared/types/attention"; + +function sha256(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex"); +} + +/** + * Remove high-frequency progress copy that does not change what an Activity + * row means. The sanitized preview remains part of the content identity; only + * elapsed durations and token/file counters are normalized away. + */ +export function normalizeActivityPreview(value: string): string { + return sanitizeAttentionPreview(value) + .replace(/\b\d+(?:\.\d+)?\s?(?:ms|s|m|h)\b/gi, "") + .replace(/\b\d[\d,]*(?:\.\d+)?(?=\s+(?:tokens?|files?)\b)/gi, "#") + .replace(/\s+/g, " ") + .trim(); +} + +/** What the Activity row looks like, excluding timestamps and ack state. */ +export function activityContentFingerprint(item: AttentionItem): string { + return sha256({ + id: item.id, + kind: item.kind, + eventKind: item.eventKind, + phase: item.phase, + activityTier: item.activityTier ?? null, + laneId: item.laneId ?? null, + laneName: item.laneName ?? null, + provider: item.provider ?? null, + model: item.model ?? null, + title: item.title, + projectId: item.project.projectId, + projectName: item.project.name, + destination: item.destination, + actions: item.actions.map((action) => `${action.id}:${action.kind}`), + planProgress: item.planProgress ?? null, + normalizedPreview: normalizeActivityPreview(item.preview), + }); +} + +/** Stable identity of the alert, deliberately independent of copy and time. */ +export function activityAlertFingerprint(item: AttentionItem): string { + if (item.kind === "pull_request" && item.destination.kind === "pull_request") { + return sha256({ + id: item.id, + eventKind: item.eventKind, + phase: item.phase, + number: item.destination.number, + }); + } + return sha256({ + id: item.id, + eventKind: item.eventKind, + phase: item.phase, + itemId: item.destination.kind === "session" + ? item.destination.itemId ?? "" + : "", + }); +} + +/** Cheap publisher-side change detector, including acknowledgment changes. */ +export function activityPublishFingerprint(item: AttentionItem): string { + return [ + item.contentFingerprint ?? activityContentFingerprint(item), + item.alertFingerprint ?? activityAlertFingerprint(item), + item.seenAt ?? "", + item.dismissedAt ?? "", + ].join("\u0000"); +} + +/** Populate the split fingerprints while preserving the legacy field. */ +export function withActivityFingerprints(item: AttentionItem): AttentionItem { + const contentFingerprint = activityContentFingerprint(item); + const alertFingerprint = activityAlertFingerprint(item); + return { + ...item, + fingerprint: contentFingerprint, + contentFingerprint, + alertFingerprint, + }; +} diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index 7f0b9cf02..a68bb190c 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -5,6 +5,7 @@ import { createHash, createHmac } from "node:crypto"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_ATTENTION_PREFERENCES } from "../../../../desktop/src/shared/types/attention"; import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; +import type { SyncRosterProject } from "../../../../desktop/src/shared/types/sync"; import type { PushDeviceRegistration, PushQuietHours, @@ -13,6 +14,7 @@ import { createPushRegistrationStore, type PushRegistrationStore, type StoredAttentionAcknowledgment, + type StoredRemoteAttentionAcknowledgment, } from "./pushRegistrationStore"; import { createPushRelayClient } from "./pushRelayClient"; import { @@ -44,11 +46,39 @@ function run(overrides: Partial): AgentRunState { itemId: null, startedAt: 0, lastActiveAt: 0, + statusSinceAt: 0, metaResolved: true, ...overrides, }; } +function rosterProject(count: number, lastActivityAt = "2026-08-01T12:00:00.000Z"): SyncRosterProject { + return { + projectId: "roster-project", + rootPath: "/projects/roster", + displayName: "Roster project", + booted: false, + runningCount: 0, + attentionCount: 0, + lanes: [{ id: "lane-roster", name: "Roster lane" }], + chats: Array.from({ length: count }, (_, index) => ({ + id: `disk-session-${String(index).padStart(3, "0")}`, + laneId: "lane-roster", + title: `Disk session ${index}`, + provider: "codex", + model: "gpt-5", + toolType: "codex-chat", + status: "idle" as const, + lastActivityAt, + preview: `Processed ${index} files in 12s`, + })), + }; +} + +async function settleMicrotasks(): Promise { + for (let index = 0; index < 12; index += 1) await Promise.resolve(); +} + describe("quiet hours", () => { it("parses HH:MM and rejects malformed input", () => { expect(parseHhMm("22:00")).toBe(22 * 60); @@ -149,6 +179,10 @@ describe("createPushPublisherService flush", () => { function makeHarness( deviceOverride: typeof device | Array = device, now?: () => number, + options: { + activityProtocol?: number | null; + activityRosterProvider?: { buildSnapshot(): Promise } | null; + } = {}, ) { const publish = vi.fn().mockResolvedValue({ ok: true }); const publishAttention = vi.fn().mockResolvedValue(null); @@ -156,6 +190,9 @@ describe("createPushPublisherService flush", () => { let accountOwnerId: string | null = "owner-a"; const devices = Array.isArray(deviceOverride) ? [...deviceOverride] : [deviceOverride]; const attentionAcknowledgments = new Map(); + const remoteAttentionAcknowledgments = new Map(); + let activityProtocol = options.activityProtocol ?? null; + let activityRosterEpoch = 0; const attentionAcknowledgmentKey = ( accountOwnerId: string | null, itemId: string, @@ -240,6 +277,36 @@ describe("createPushPublisherService flush", () => { } } }, + recordRemoteAttentionAcknowledgments: (args: { + accountOwnerId: string | null; + acknowledgments: Array<{ + itemId: string; + sourceRevision: number; + seenAt: string | null; + dismissedAt: string | null; + }>; + updatedAt: string; + }) => { + for (const acknowledgment of args.acknowledgments) { + const key = attentionAcknowledgmentKey(args.accountOwnerId, acknowledgment.itemId); + remoteAttentionAcknowledgments.set(key, { + ...acknowledgment, + accountOwnerId: args.accountOwnerId, + updatedAt: args.updatedAt, + }); + } + }, + listRemoteAttentionAcknowledgments: (ownerId?: string | null) => + [...remoteAttentionAcknowledgments.values()].filter((acknowledgment) => + ownerId === undefined || acknowledgment.accountOwnerId === ownerId), + getActivityProtocol: () => activityProtocol, + setActivityProtocol: (protocol: number | null) => { + activityProtocol = protocol; + }, + nextActivityRosterEpoch: () => { + activityRosterEpoch += 1; + return activityRosterEpoch; + }, }; const relayClient = { publish, @@ -284,6 +351,7 @@ describe("createPushPublisherService flush", () => { now, flushDebounceMs: 2_000, promptFlushMs: 150, + activityRosterProvider: options.activityRosterProvider, }); const cliSessions = new Map { cliSessions, detach, attentionAcknowledgments, + remoteAttentionAcknowledgments, getAttentionAcknowledgment: ( itemId: string, ownerId: string | null = accountOwnerId, @@ -1149,7 +1218,7 @@ describe("createPushPublisherService flush", () => { publisher.dispose(); }); - it("advances the Attention revision without spamming a duplicate alert", async () => { + it("does not republish when only the source revision advances", async () => { const fixedNow = Date.parse("2026-07-05T12:00:00.000Z"); const { publisher, publish, publishAttention, emit } = makeHarness( device, @@ -1169,8 +1238,8 @@ describe("createPushPublisherService flush", () => { emit(approval); await vi.advanceTimersByTimeAsync(200); - expect(publishAttention).toHaveBeenCalledTimes(2); - expect(publish).not.toHaveBeenCalled(); + expect(publishAttention).toHaveBeenCalledTimes(1); + expect(publish).toHaveBeenCalledTimes(1); publisher.dispose(); }); @@ -1227,6 +1296,303 @@ describe("createPushPublisherService flush", () => { publisher.dispose(); }); + it("coalesces 50 running-agent events into exactly one protocol-2 publish", async () => { + const { publisher, publishAttention, emit } = makeHarness( + device, + undefined, + { activityProtocol: 2 }, + ); + publishAttention.mockResolvedValue({ ok: true, protocol: 2, revision: 1, acks: [] }); + + for (let index = 0; index < 50; index += 1) { + emit({ + sessionId: "s-running", + timestamp: new Date().toISOString(), + event: { type: "text", text: `stream chunk ${index}` }, + }); + } + await vi.advanceTimersByTimeAsync(2_500); + + expect(publishAttention).toHaveBeenCalledTimes(1); + expect(publishAttention.mock.calls[0][0]).toMatchObject({ + mode: "reconcile", + page: 0, + final: true, + items: [expect.objectContaining({ phase: "running", activityTier: "ambient" })], + }); + publisher.dispose(); + }); + + it("publishes changed items and dropped ids as an explicit delta", async () => { + const { publisher, publishAttention, emit } = makeHarness( + device, + undefined, + { activityProtocol: 2 }, + ); + publishAttention.mockResolvedValue({ ok: true, protocol: 2, revision: 1, acks: [] }); + emit({ + sessionId: "s-running", + timestamp: "", + event: { type: "text", text: "working" }, + }); + await vi.advanceTimersByTimeAsync(2_500); + publishAttention.mockClear(); + + publisher._debug.onPtyExit("scope-1", { + ptyId: "pty-s-running", + sessionId: "s-running", + laneId: "auth-lane", + exitCode: 130, + }); + await vi.advanceTimersByTimeAsync(2_500); + + expect(publishAttention).toHaveBeenCalledTimes(1); + expect(publishAttention.mock.calls[0][0]).toMatchObject({ + mode: "delta", + items: [], + tombstones: [expect.objectContaining({ + id: `agent:${"a".repeat(40)}:s-running`, + deletedAt: expect.any(String), + })], + }); + publisher.dispose(); + }); + + it("pages a 200-session roster reconcile under the item and body caps", async () => { + const buildSnapshot = vi.fn().mockResolvedValue([rosterProject(200)]); + const { publisher, publishAttention } = makeHarness( + device, + undefined, + { + activityProtocol: 2, + activityRosterProvider: { buildSnapshot }, + }, + ); + publishAttention.mockResolvedValue({ ok: true, protocol: 2, revision: 1, acks: [] }); + + await publisher.start(); + await vi.advanceTimersByTimeAsync(200); + + expect(buildSnapshot).toHaveBeenCalledTimes(1); + expect(publishAttention).toHaveBeenCalledTimes(5); + const payloads = publishAttention.mock.calls.map(([payload]) => payload); + expect(payloads.every((payload) => payload.mode === "reconcile")).toBe(true); + expect(payloads.every((payload) => payload.items.length <= 48)).toBe(true); + expect(payloads.every((payload) => payload.tombstones.length <= 48)).toBe(true); + expect(payloads.slice(0, -1).every((payload) => payload.final === false)).toBe(true); + expect(payloads.at(-1)?.final).toBe(true); + expect(payloads.flatMap((payload) => payload.items)).toHaveLength(200); + expect( + payloads.every((payload) => Buffer.byteLength(JSON.stringify(payload), "utf8") < 256 * 1024), + ).toBe(true); + publisher.dispose(); + }); + + it("shrinks the roster cap by ten percent when the relay truncates items", async () => { + const buildSnapshot = vi.fn().mockResolvedValue([rosterProject(300)]); + const { publisher, publishAttention } = makeHarness( + device, + undefined, + { + activityProtocol: 2, + activityRosterProvider: { buildSnapshot }, + }, + ); + publishAttention.mockImplementation(async () => ({ + ok: true, + protocol: 2, + revision: 1, + acks: [], + itemsTruncated: publishAttention.mock.calls.length === 1, + })); + + await publisher.start(); + await vi.advanceTimersByTimeAsync(200); + const snapshot = await publisher.getMachineAttentionSnapshot(); + + expect(snapshot.items).toHaveLength(270); + publisher.dispose(); + }); + + it("explicitly tombstones roster overflow", async () => { + const buildSnapshot = vi.fn().mockResolvedValue([rosterProject(301)]); + const { publisher, publishAttention } = makeHarness( + device, + undefined, + { + activityProtocol: 2, + activityRosterProvider: { buildSnapshot }, + }, + ); + publishAttention.mockResolvedValue({ ok: true, protocol: 2, revision: 1, acks: [] }); + + await publisher.start(); + await vi.advanceTimersByTimeAsync(200); + + const payloads = publishAttention.mock.calls.map(([payload]) => payload); + expect(payloads.flatMap((payload) => payload.items)).toHaveLength(300); + expect(payloads.flatMap((payload) => payload.tombstones)).toEqual([ + expect.objectContaining({ + id: `agent:${"a".repeat(40)}:disk-session-300`, + deletedAt: expect.any(String), + }), + ]); + publisher.dispose(); + }); + + it("publishes an empty presence heartbeat without rebuilding the roster", async () => { + const buildSnapshot = vi.fn().mockResolvedValue([rosterProject(1)]); + const { publisher, publishAttention, emit } = makeHarness( + device, + undefined, + { + activityProtocol: 2, + activityRosterProvider: { buildSnapshot }, + }, + ); + publishAttention.mockResolvedValue({ ok: true, protocol: 2, revision: 1, acks: [] }); + emit({ + sessionId: "s-running", + timestamp: "", + event: { type: "text", text: "working" }, + }); + await vi.advanceTimersByTimeAsync(2_500); + expect(buildSnapshot).toHaveBeenCalledTimes(1); + publishAttention.mockClear(); + + await vi.advanceTimersByTimeAsync(30_000); + + expect(publishAttention).toHaveBeenCalledTimes(1); + expect(publishAttention).toHaveBeenCalledWith({ + machineName: "MacBook", + mode: "presence", + rosterEpoch: 1, + items: [], + tombstones: [], + }); + expect(buildSnapshot).toHaveBeenCalledTimes(1); + publisher.dispose(); + }); + + it("persists remote dismissal acknowledgments and downgrades signal items", async () => { + const { publisher, publishAttention, emit } = makeHarness( + device, + undefined, + { activityProtocol: 2 }, + ); + publishAttention.mockImplementation(async (payload) => ({ + ok: true, + protocol: 2, + revision: 1, + acks: payload.items.map((item: { id: string; revision: number }) => ({ + itemId: item.id, + seenAt: "2026-07-05T12:00:01.000Z", + dismissedAt: "2026-07-05T12:00:02.000Z", + sourceRevision: item.revision, + })), + })); + + emit(approval); + await vi.advanceTimersByTimeAsync(200); + const item = (await publisher.getMachineAttentionSnapshot()).items[0]!; + + expect(item).toMatchObject({ + phase: "needs_you", + activityTier: "ambient", + seenAt: "2026-07-05T12:00:01.000Z", + dismissedAt: "2026-07-05T12:00:02.000Z", + }); + publishAttention.mockClear(); + publisher.poke(); + await vi.advanceTimersByTimeAsync(200); + expect(publishAttention.mock.calls[0][0]).toMatchObject({ + mode: "delta", + items: [expect.objectContaining({ + id: item.id, + activityTier: "ambient", + dismissedAt: "2026-07-05T12:00:02.000Z", + })], + }); + publisher.dispose(); + }); + + it("falls back to a live-only full snapshot when protocol is absent", async () => { + const buildSnapshot = vi.fn().mockResolvedValue([rosterProject(3)]); + const { publisher, publishAttention, emit } = makeHarness( + device, + undefined, + { activityRosterProvider: { buildSnapshot } }, + ); + publishAttention.mockResolvedValue({ ok: true, revision: 1 }); + emit(approval); + await vi.advanceTimersByTimeAsync(200); + + expect(publishAttention).toHaveBeenCalledTimes(1); + expect(publishAttention.mock.calls[0][0]).toMatchObject({ + machineName: "MacBook", + fullSnapshot: true, + items: [expect.objectContaining({ id: `agent:${"a".repeat(40)}:s-1` })], + }); + expect(publishAttention.mock.calls[0][0].mode).toBeUndefined(); + expect(buildSnapshot).not.toHaveBeenCalled(); + publisher.dispose(); + }); + + it("keeps idle roster revisions stable across uncached rebuilds", async () => { + let clock = Date.parse("2026-08-01T12:00:00.000Z"); + const buildSnapshot = vi.fn().mockResolvedValue([ + rosterProject(1, "2026-07-01T09:30:00.000Z"), + ]); + const { publisher } = makeHarness( + device, + () => clock, + { + activityProtocol: 2, + activityRosterProvider: { buildSnapshot }, + }, + ); + + const first = (await publisher.getMachineAttentionSnapshot()).items[0]!; + clock += 11_000; + const second = (await publisher.getMachineAttentionSnapshot()).items[0]!; + + expect(buildSnapshot).toHaveBeenCalledTimes(2); + expect(first).toMatchObject({ + activityTier: "idle", + phase: "stale", + expiresAt: null, + statusSince: "2026-07-01T09:30:00.000Z", + }); + expect(second.revision).toBe(first.revision); + expect(second.revision).toBe(Date.parse("2026-07-01T09:30:00.000Z")); + publisher.dispose(); + }); + + it("keeps live statusSince immutable while the phase is unchanged", async () => { + const { publisher, emit } = makeHarness( + device, + undefined, + { activityProtocol: 2 }, + ); + emit({ + sessionId: "s-running", + timestamp: "", + event: { type: "text", text: "first" }, + }); + const first = (await publisher.getMachineAttentionSnapshot()).items[0]!; + vi.setSystemTime(new Date("2026-07-05T12:00:05.000Z")); + emit({ + sessionId: "s-running", + timestamp: "", + event: { type: "text", text: "second" }, + }); + const second = (await publisher.getMachineAttentionSnapshot()).items[0]!; + + expect(second.revision).toBeGreaterThan(first.revision); + expect(second.statusSince).toBe(first.statusSince); + publisher.dispose(); + }); + it("alerts native structured questions with the unified needs-you copy immediately", async () => { const { publisher, publish, emit } = makeHarness(); await publisher.start(); @@ -2120,7 +2486,7 @@ describe("createPushPublisherService flush", () => { publishAttention.mockClear(); detach(); await vi.runAllTicks(); - await Promise.resolve(); + await settleMicrotasks(); expect(publishAttention).toHaveBeenCalledTimes(1); expect(publishAttention).toHaveBeenCalledWith({ @@ -2151,7 +2517,7 @@ describe("createPushPublisherService flush", () => { publishAttention.mockClear(); detach(); await vi.runAllTicks(); - await Promise.resolve(); + await settleMicrotasks(); expect(publishAttention).toHaveBeenCalledTimes(1); expect(publishAttention.mock.calls[0][0].items).toEqual([]); @@ -2357,6 +2723,37 @@ describe("createPushRegistrationStore", () => { }); expect(reopened.listPendingAttentionAcknowledgments()).toHaveLength(2); }); + + it("persists protocol, monotonic roster epochs, and remote acknowledgments", () => { + const store = createPushRegistrationStore({ filePath }); + store.getOrCreateIdentity(); + store.setActivityProtocol(2); + expect(store.nextActivityRosterEpoch()).toBe(1); + expect(store.nextActivityRosterEpoch()).toBe(2); + store.recordRemoteAttentionAcknowledgments({ + accountOwnerId: "owner-a", + acknowledgments: [{ + itemId: "agent:machine:session-1", + sourceRevision: 9, + seenAt: "2026-07-05T01:00:00.000Z", + dismissedAt: "2026-07-05T01:01:00.000Z", + }], + updatedAt: "2026-07-05T01:01:00.000Z", + }); + + const reopened = createPushRegistrationStore({ filePath }); + expect(reopened.getActivityProtocol()).toBe(2); + expect(reopened.nextActivityRosterEpoch()).toBe(3); + expect(reopened.listRemoteAttentionAcknowledgments("owner-a")).toEqual([ + expect.objectContaining({ + itemId: "agent:machine:session-1", + accountOwnerId: "owner-a", + sourceRevision: 9, + dismissedAt: "2026-07-05T01:01:00.000Z", + }), + ]); + expect(reopened.listRemoteAttentionAcknowledgments("owner-b")).toEqual([]); + }); }); const MACHINE_KEY = "0123456789abcdef0123456789abcdef"; // gitleaks:allow — test fixture @@ -2569,6 +2966,127 @@ describe("createPushRelayClient", () => { expect(init.headers.authorization).toBe("Bearer account-access-token"); }); + it("passes through the additive Activity snapshot fields", async () => { + const activityItem = { + id: "agent:machine-a:session-1", + revision: 17, + activityTier: "idle", + contentFingerprint: "content-17", + alertFingerprint: "alert-17", + statusSince: "2026-07-05T00:00:00.000Z", + }; + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + contractVersion: 1, + streamId: "account-a", + revision: 17, + generatedAt: "2026-07-05T00:00:00.000Z", + items: [activityItem], + itemsTruncated: true, + tombstones: [], + machines: [], + }), + }); + const client = createPushRelayClient({ + store: makeStore(), + logger, + baseUrl: "https://relay.test", + getAccountAccessToken: async () => "account-access-token", + getAccountUserId: () => "account-a", + }); + + const result = await client.getAttentionSnapshot(); + + expect(result?.itemsTruncated).toBe(true); + expect(result?.items[0]).toMatchObject(activityItem); + expect(result?.streamId).toBe("account-a"); + }); + + it("sends revision and owner fences and parses stale acknowledgments", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + revision: 19, + applied: ["item-applied"], + stale: ["item-stale"], + }), + }); + const client = createPushRelayClient({ + store: makeStore(), + logger, + baseUrl: "https://relay.test", + getAccountAccessToken: async () => "account-access-token", + getAccountUserId: () => "account-a", + }); + + await expect(client.acknowledgeAttention({ + itemIds: ["item-applied", "item-stale"], + sourceRevisions: { "item-applied": 4, "item-stale": 7 }, + expectedAccountOwnerId: "account-a", + seenAt: "2026-07-05T00:01:00.000Z", + })).resolves.toEqual({ + applied: ["item-applied"], + stale: ["item-stale"], + }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://relay.test/attention/account/ack"); + expect(JSON.parse(init.body)).toEqual({ + itemIds: ["item-applied", "item-stale"], + sourceRevisions: { "item-applied": 4, "item-stale": 7 }, + expectedAccountOwnerId: "account-a", + seenAt: "2026-07-05T00:01:00.000Z", + }); + }); + + it("keeps machine overrides in full preference writes while omitting devices", async () => { + const client = createPushRelayClient({ + store: makeStore(), + logger, + baseUrl: "https://relay.test", + getAccountAccessToken: async () => "account-access-token", + getAccountUserId: () => "account-a", + }); + + await client.putAttentionPreferences("account-a", { + ...DEFAULT_ATTENTION_PREFERENCES, + devices: { "phone-1": { hideDetails: true } }, + machines: { "machine-a": { notificationsEnabled: false } }, + }); + + const [url, init] = fetchMock.mock.calls[0]; + const body = JSON.parse(init.body) as Record; + expect(url).toBe("https://relay.test/attention/account/preferences"); + expect(body.devices).toBeUndefined(); + expect(body.machines).toEqual({ "machine-a": { notificationsEnabled: false } }); + }); + + it("patches one encoded Activity machine preference scope", async () => { + const client = createPushRelayClient({ + store: makeStore(), + logger, + baseUrl: "https://relay.test", + getAccountAccessToken: async () => "account-access-token", + getAccountUserId: () => "account-a", + }); + + await client.putActivityMachinePreferences( + "account-a", + "machine/a", + { notificationsEnabled: false }, + ); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe( + "https://relay.test/attention/account/preferences/machines/machine%2Fa", + ); + expect(init.method).toBe("PATCH"); + expect(JSON.parse(init.body)).toEqual({ notificationsEnabled: false }); + }); + it("retries one unauthorized account read with a forced fresh token", async () => { fetchMock .mockResolvedValueOnce({ diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index 63719b6c7..8a4620909 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -1,5 +1,4 @@ import path from "node:path"; -import { createHash } from "node:crypto"; import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; import { @@ -13,7 +12,12 @@ import { type AttentionPreferences, type AttentionPresence, type AttentionSnapshot, + type AttentionTombstone, } from "../../../../desktop/src/shared/types/attention"; +import type { + SyncRosterChatStatus, + SyncRosterProject, +} from "../../../../desktop/src/shared/types/sync"; import type { PtyExitEvent, TerminalSessionStatus } from "../../../../desktop/src/shared/types/sessions"; import { canonicalSessionState } from "../../../../desktop/src/shared/sessionCanonicalState"; import type { PrNotificationKind } from "../../../../desktop/src/shared/types/prs"; @@ -30,6 +34,11 @@ import type { PushRelayClient, PushRelayLiveActivityItem, } from "./pushRelayClient"; +import { PushRelayRequestError } from "./pushRelayClient"; +import { + activityPublishFingerprint, + withActivityFingerprints, +} from "./activityFingerprint"; export const AGENT_RUNS_ACTIVITY_ID = "agent-runs"; export const AGENT_RUNS_ATTRIBUTES_TYPE = "ADEAgentRunsAttributes"; @@ -79,8 +88,11 @@ const RUNNING_TTL_MS = 2 * 60 * 60 * 1000; // 2h for running/starting const WAITING_TTL_MS = 24 * 60 * 60 * 1000; // 24h for waiting_for_* const PR_LIVE_ACTIVITY_TTL_MS = 45 * 60 * 1000; // keep recent PR status visible, then age it out const ATTENTION_RECENT_TTL_MS = 24 * 60 * 60 * 1000; -/** The relay rejects an Attention publish containing more than 64 items. */ -const ATTENTION_PUBLISH_MAX_ITEMS = 64; +export const ACTIVITY_ROSTER_MAX_ITEMS_PER_MACHINE = 300; +export const ACTIVITY_PUBLISH_PAGE_ITEMS = 48; +export const ACTIVITY_RECONCILE_INTERVAL_MS = 30 * 60_000; +const ACTIVITY_ROSTER_CACHE_MS = 10_000; +const LEGACY_ATTENTION_PUBLISH_MAX_ITEMS = 64; const DEFAULT_FLUSH_DEBOUNCE_MS = 2_000; const DEFAULT_PROMPT_FLUSH_MS = 150; const PUBLISH_RETRY_MS = 30_000; @@ -112,6 +124,8 @@ export type AgentRunState = { itemId: string | null; startedAt: number; lastActiveAt: number; + /** Immutable while `phase` is unchanged. */ + statusSinceAt: number; metaResolved: boolean; }; @@ -151,6 +165,7 @@ export type PrLiveActivityState = { repoOwner: string | null; repoName: string | null; updatedAt: number; + statusSinceAt: number; }; type PendingAlert = { @@ -191,6 +206,9 @@ export type PushPublisherDeps = { deviceId?: string | null; } | null; getAccountOwnerId?: () => string | null; + activityRosterProvider?: { + buildSnapshot(): Promise; + } | null; /** Test seams. */ now?: () => number; flushDebounceMs?: number; @@ -472,15 +490,44 @@ function providerDisplayName(provider: string | null | undefined): string | null } } -function fingerprintAttentionItem(value: Omit): string { - return createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex"); -} - function agentAttentionPhase(phase: AgentRunPhase): AttentionPhase { if (phase === "waiting_for_approval" || phase === "waiting_for_input") return "needs_you"; return phase; } +function rosterAttentionPhase(status: SyncRosterChatStatus): AttentionPhase { + switch (status) { + case "awaiting": + return "needs_you"; + case "failed": + return "failed"; + case "running": + return "running"; + case "idle": + return "stale"; + case "ended": + return "completed"; + } +} + +function rosterActivityTier(status: SyncRosterChatStatus): "ambient" | "idle" { + return status === "idle" || status === "ended" ? "idle" : "ambient"; +} + +function prActivityTier(phase: AttentionPhase): "signal" | "ambient" { + return phase === "checks_failing" + || phase === "changes_requested" + || phase === "review_requested" + || phase === "merge_ready" + ? "signal" + : "ambient"; +} + +function validTimestampMs(value: string | null | undefined): number { + const parsed = typeof value === "string" ? Date.parse(value) : Number.NaN; + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; +} + function agentAttentionEventKind(phase: AgentRunPhase): AttentionEventKind { if (phase === "waiting_for_approval" || phase === "waiting_for_input") return "agent_needs_you"; if (phase === "failed") return "agent_failed"; @@ -524,7 +571,18 @@ export function createPushPublisherService(deps: PushPublisherDeps) { let lastMachineSnapshotAccountOwnerId: string | null | undefined; let pendingAlerts: PendingAlert[] = []; const lastAlertFingerprintByKey = new Map>(); - let lastAttentionFingerprint: string | null = null; + const lastPublishedFingerprintById = new Map(); + const lastPublishedRevisionById = new Map(); + const lastOverflowRevisionById = new Map(); + let activityProtocol = deps.store.getActivityProtocol?.() ?? null; + let activityRosterProvider = deps.activityRosterProvider ?? null; + let rosterCache: { at: number; projects: SyncRosterProject[] } | null = null; + let rosterEpoch = 0; + let reconcilePending = true; + let lastReconcileAt = 0; + let lastActivityAccountOwnerId: string | null | undefined; + let activityRosterCap = ACTIVITY_ROSTER_MAX_ITEMS_PER_MACHINE; + let lastLegacyAttentionFingerprint: string | null = null; let lastAttentionPublishedAt = 0; /** Last Live Activity content confirmed per phone. Absence means start. */ const liveActivityFingerprintByDevice = new Map(); @@ -547,6 +605,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { let attentionHeartbeatTimer: NodeJS.Timeout | null = null; let flushFireAt = 0; let flushing = false; + let scheduledFlushIncludesActivity = false; let finalAttentionSnapshotPending = false; let finalAttentionSnapshotQueued = false; let disposed = false; @@ -591,6 +650,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { itemId: null, startedAt: ts, lastActiveAt: ts, + statusSinceAt: ts, metaResolved: false, }; runs.set(sessionId, run); @@ -603,6 +663,12 @@ export function createPushPublisherService(deps: PushPublisherDeps) { run.lastActiveAt = Math.max(now(), run.lastActiveAt + 1); }; + const setRunPhase = (run: AgentRunState, phase: AgentRunPhase): void => { + if (run.phase === phase) return; + run.phase = phase; + run.statusSinceAt = run.lastActiveAt; + }; + const runSubject = (run: AgentRunState): string => run.agent?.trim() || run.title?.trim() || "Agent"; const laneTitleLine = (run: AgentRunState): string => { @@ -610,16 +676,37 @@ export function createPushPublisherService(deps: PushPublisherDeps) { return parts.length > 0 ? parts.join(" · ") : run.title?.trim() || "Agent run"; }; - const buildAttentionItems = (nowMs: number): AttentionItem[] => { + const loadActivityRoster = async (nowMs: number): Promise => { + if (!activityRosterProvider) return []; + if (rosterCache && nowMs - rosterCache.at < ACTIVITY_ROSTER_CACHE_MS) { + return rosterCache.projects; + } + try { + const projects = await activityRosterProvider.buildSnapshot(); + rosterCache = { at: nowMs, projects }; + return projects; + } catch (error) { + logWarn("attention.activity_roster_build_failed", error); + const projects = rosterCache?.projects ?? []; + rosterCache = { at: nowMs, projects }; + return projects; + } + }; + + const buildAttentionItems = async ( + nowMs: number, + includeRoster: boolean, + ): Promise => { const { machineKey } = deps.store.getOrCreateIdentity(); const accountMachineIdentity = deps.getAccountMachineIdentity?.() ?? null; + const nowIso = new Date(nowMs).toISOString(); const machine = { machineKey, accountMachineKey: accountMachineIdentity?.machineKey ?? null, deviceId: accountMachineIdentity?.deviceId ?? null, name: deps.machineName, online: true, - lastSeenAt: null, + lastSeenAt: nowIso, }; const attentionRuns = new Map([ ...recentRuns, @@ -668,10 +755,12 @@ export function createPushPublisherService(deps: PushPublisherDeps) { payload: { sessionId: run.sessionId }, }); } - const withoutFingerprint: Omit = { + const item: AttentionItem = { contractVersion: ATTENTION_CONTRACT_VERSION, id: `agent:${machineKey}:${run.sessionId}`, revision: run.lastActiveAt, + fingerprint: "", + activityTier: phase === "needs_you" || phase === "failed" ? "signal" : "ambient", kind: "agent", eventKind, phase, @@ -715,16 +804,109 @@ export function createPushPublisherService(deps: PushPublisherDeps) { actions, occurredAt: new Date(run.startedAt).toISOString(), updatedAt: new Date(run.lastActiveAt).toISOString(), + statusSince: new Date(run.statusSinceAt).toISOString(), seenAt: null, dismissedAt: null, expiresAt, }; - return { - ...withoutFingerprint, - fingerprint: fingerprintAttentionItem(withoutFingerprint), - }; + return withActivityFingerprints(item); }); + const rosterItems = includeRoster + ? (await loadActivityRoster(nowMs)).flatMap((project): AttentionItem[] => { + const laneNames = new Map(project.lanes.map((lane) => [lane.id, lane.name])); + return project.chats.map((chat): AttentionItem => { + const phase = rosterAttentionPhase(chat.status); + const activityTier = rosterActivityTier(chat.status); + const revision = validTimestampMs(chat.lastActivityAt); + const activityAt = new Date(revision).toISOString(); + const provider = providerDisplayName(chat.provider ?? chat.toolType); + const subject = provider ?? chat.title?.trim() ?? "Agent"; + const preview = sanitizeAttentionPreview( + chat.attentionMessage?.trim() + || chat.statusNote?.trim() + || chat.preview?.trim() + || chat.title?.trim() + || laneNames.get(chat.laneId) + || "ADE session", + ); + const actions: AttentionItem["actions"] = [ + { id: "open", kind: "open", label: "Open" }, + ]; + if (phase === "needs_you") { + actions.unshift({ + id: "answer", + kind: "answer", + label: "Answer", + payload: { sessionId: chat.id }, + }); + } + return withActivityFingerprints({ + contractVersion: ATTENTION_CONTRACT_VERSION, + id: `agent:${machineKey}:${chat.id}`, + revision, + fingerprint: "", + activityTier, + kind: "agent", + eventKind: phase === "needs_you" + ? "agent_needs_you" + : phase === "failed" + ? "agent_failed" + : phase === "completed" + ? "agent_completed" + : "agent_running", + phase, + machine, + project: { + projectId: project.projectId, + name: project.displayName, + rootPath: project.rootPath ?? null, + }, + laneId: chat.laneId, + laneName: laneNames.get(chat.laneId) ?? null, + provider, + model: chat.model ?? null, + title: phase === "needs_you" + ? `${subject} needs you` + : phase === "failed" + ? `${subject} failed` + : phase === "completed" + ? `${subject} is done` + : phase === "stale" + ? `${subject} is idle` + : `${subject} is working`, + preview, + privacyPreview: phase === "needs_you" + ? "An ADE agent needs your input." + : phase === "failed" + ? "An ADE agent run failed." + : phase === "completed" + ? "An ADE agent is done." + : phase === "stale" + ? "An ADE agent session is idle." + : "An ADE agent is working.", + detail: chat.statusNote ? sanitizeAttentionPreview(chat.statusNote, 1_000) : null, + recentActivity: [], + planProgress: null, + destination: { + kind: "session", + sessionId: chat.id, + itemId: null, + }, + actions, + occurredAt: activityAt, + updatedAt: activityAt, + statusSince: activityAt, + seenAt: null, + dismissedAt: null, + expiresAt: activityTier === "idle" + ? null + : new Date(revision + (phase === "running" ? RUNNING_TTL_MS : ATTENTION_RECENT_TTL_MS)).toISOString(), + }); + }); + }) + : []; + const prItems = [...prActivities.values()].map((pr): AttentionItem => { const scopeKey = pr.scopeKey; const scope = scopes.get(scopeKey); @@ -746,10 +928,12 @@ export function createPushPublisherService(deps: PushPublisherDeps) { payload: { prId: pr.prId, prNumber: pr.prNumber }, }); } - const withoutFingerprint: Omit = { + const item: AttentionItem = { contractVersion: ATTENTION_CONTRACT_VERSION, id: `pull-request:${machineKey}:${pr.id}`, revision: pr.updatedAt, + fingerprint: "", + activityTier: prActivityTier(mapped.phase), kind: "pull_request", eventKind: mapped.eventKind, phase: mapped.phase, @@ -780,36 +964,59 @@ export function createPushPublisherService(deps: PushPublisherDeps) { actions, occurredAt: new Date(pr.updatedAt).toISOString(), updatedAt: new Date(pr.updatedAt).toISOString(), + statusSince: new Date(pr.statusSinceAt).toISOString(), seenAt: null, dismissedAt: null, expiresAt: new Date(pr.updatedAt + ATTENTION_RECENT_TTL_MS).toISOString(), }; - return { - ...withoutFingerprint, - fingerprint: fingerprintAttentionItem(withoutFingerprint), - }; + return withActivityFingerprints(item); }); - return [...runItems, ...prItems]; + + const agentItems = new Map(); + for (const item of rosterItems) agentItems.set(item.id, item); + // Live state is authoritative on the shared terminal_sessions.id/sessionId + // namespace and therefore wins every collision with a roster row. + for (const item of runItems) agentItems.set(item.id, item); + return [...agentItems.values(), ...prItems]; }; - const mergeMachineAcknowledgments = (items: AttentionItem[]): AttentionItem[] => - items.map((item) => { - const accountOwnerId = deps.getAccountOwnerId?.()?.trim() || null; - const acknowledgment = deps.store.getAttentionAcknowledgment?.( - item.id, - accountOwnerId, - ); + const mergeMachineAcknowledgments = (items: AttentionItem[]): AttentionItem[] => { + const accountOwnerId = deps.getAccountOwnerId?.()?.trim() || null; + const remoteById = new Map( + (deps.store.listRemoteAttentionAcknowledgments?.(accountOwnerId) ?? []) + .map((acknowledgment) => [acknowledgment.itemId, acknowledgment]), + ); + return items.map((item) => { + const local = deps.store.getAttentionAcknowledgment?.(item.id, accountOwnerId); + const remote = remoteById.get(item.id); + let seenAt = item.seenAt; + let dismissedAt = item.dismissedAt; if ( - !acknowledgment - || acknowledgment.accountOwnerId !== accountOwnerId - || acknowledgment.sourceRevision < item.revision - ) return item; - return { + local + && local.accountOwnerId === accountOwnerId + && local.sourceRevision >= item.revision + ) { + seenAt = local.seenAt; + dismissedAt = local.dismissedAt; + } + // The relay is canonical for account acknowledgments. A revision-current + // remote value replaces the local projection, including explicit nulls. + if ( + remote + && remote.accountOwnerId === accountOwnerId + && remote.sourceRevision >= item.revision + ) { + seenAt = remote.seenAt; + dismissedAt = remote.dismissedAt; + } + return withActivityFingerprints({ ...item, - seenAt: acknowledgment.seenAt, - dismissedAt: acknowledgment.dismissedAt, - }; + seenAt, + dismissedAt, + ...(dismissedAt ? { activityTier: "ambient" as const } : {}), + }); }); + }; const reconcileMachineAcknowledgments = async ( currentItems: readonly AttentionItem[], @@ -876,8 +1083,9 @@ export function createPushPublisherService(deps: PushPublisherDeps) { lastAlertFingerprintByKey.delete(dedupeKey); }; - const scheduleFlush = (immediate: boolean): void => { + const scheduleFlush = (immediate: boolean, activityChanged = true): void => { if (disposed) return; + if (activityChanged) scheduledFlushIncludesActivity = true; const delay = immediate ? promptFlushMs : flushDebounceMs; const fireAt = now() + delay; // Keep the earliest scheduled flush — a prompt (immediate) is never pushed @@ -888,7 +1096,9 @@ export function createPushPublisherService(deps: PushPublisherDeps) { flushTimer = setTimeout(() => { flushTimer = null; flushFireAt = 0; - void runFlush(); + const presenceOnly = !scheduledFlushIncludesActivity; + scheduledFlushIncludesActivity = false; + void runFlush(presenceOnly); }, delay); }; @@ -972,7 +1182,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { || (record.settleOverride !== "active" && record.settledAt) ) ) { - run.phase = "completed"; + setRunPhase(run, "completed"); recentRuns.set(run.sessionId, { ...run }); runs.delete(run.sessionId); continue; @@ -1132,56 +1342,349 @@ export function createPushPublisherService(deps: PushPublisherDeps) { return { items, commit }; }; - const publishAttentionSnapshot = async ( + type ActivityPublishResponse = { + protocol: number; + result: Record; + capShrunk: boolean; + }; + + const recordActivityPublishResponse = ( + result: Record, nowMs: number, - ): Promise<"published" | "unchanged" | "unavailable"> => { - if (typeof deps.relayClient.publishAttention !== "function") return "unavailable"; - // Bound the full snapshot before fingerprinting it. The relay treats the - // published window as authoritative for this machine, so selection must be - // deterministic and use the same canonical priority order as every ADE - // Attention surface (needs-you/failures first, then recency and stable id). - const items = sortAttentionItems(buildAttentionItems(nowMs)) - .slice(0, ATTENTION_PUBLISH_MAX_ITEMS); - const fingerprint = JSON.stringify(items.map((item) => ({ - id: item.id, - revision: item.revision, - fingerprint: item.fingerprint, - }))); + allowCapShrink = true, + ): ActivityPublishResponse => { + const protocol = Number.isSafeInteger(result.protocol) && Number(result.protocol) >= 2 + ? Number(result.protocol) + : 1; + if (activityProtocol !== protocol) { + activityProtocol = protocol; + deps.store.setActivityProtocol?.(protocol); + } + const accountOwnerId = deps.getAccountOwnerId?.()?.trim() || null; + const acknowledgments = Array.isArray(result.acks) + ? result.acks.flatMap((value) => { + if (!value || typeof value !== "object") return []; + const acknowledgment = value as Record; + const itemId = typeof acknowledgment.itemId === "string" + ? acknowledgment.itemId.trim() + : ""; + const sourceRevision = Number(acknowledgment.sourceRevision); + const seenAt = acknowledgment.seenAt === null || typeof acknowledgment.seenAt === "string" + ? acknowledgment.seenAt + : null; + const dismissedAt = acknowledgment.dismissedAt === null || typeof acknowledgment.dismissedAt === "string" + ? acknowledgment.dismissedAt + : null; + return itemId && Number.isFinite(sourceRevision) + ? [{ itemId, sourceRevision, seenAt, dismissedAt }] + : []; + }) + : []; + if (acknowledgments.length > 0) { + deps.store.recordRemoteAttentionAcknowledgments?.({ + accountOwnerId, + acknowledgments, + updatedAt: new Date(nowMs).toISOString(), + }); + } + const previousCap = activityRosterCap; + if (allowCapShrink && protocol >= 2 && result.itemsTruncated === true) { + activityRosterCap = Math.max(1, Math.floor(activityRosterCap * 0.9)); + reconcilePending = true; + } + lastAttentionPublishedAt = nowMs; + return { + protocol, + result, + capShrunk: activityRosterCap < previousCap, + }; + }; + + const selectActivityRoster = (items: AttentionItem[]): { + selected: AttentionItem[]; + overflow: AttentionItem[]; + } => { + const ordered = sortAttentionItems(items); + const foreground = ordered.filter((item) => item.activityTier !== "idle"); + const idle = ordered + .filter((item) => item.activityTier === "idle") + .sort((left, right) => { + const time = Date.parse(right.updatedAt) - Date.parse(left.updatedAt); + return Number.isFinite(time) && time !== 0 ? time : left.id.localeCompare(right.id); + }); + const all = [...foreground, ...idle]; + return { + selected: all.slice(0, activityRosterCap), + overflow: all.slice(activityRosterCap), + }; + }; + + const activityTombstone = ( + id: string, + sourceRevision: number, + nowMs: number, + ): AttentionTombstone => ({ + id, + revision: Math.max(nowMs, sourceRevision + 1), + deletedAt: new Date(nowMs).toISOString(), + }); + + const publishLegacyAttention = async ( + nowMs: number, + force: boolean, + ): Promise<"published" | "unchanged" | "unavailable" | "protocol2"> => { + const items = mergeMachineAcknowledgments( + sortAttentionItems(await buildAttentionItems(nowMs, false)) + .slice(0, LEGACY_ATTENTION_PUBLISH_MAX_ITEMS), + ); + const fingerprint = JSON.stringify( + items.map((item) => [item.id, activityPublishFingerprint(item)]), + ); if ( - fingerprint === lastAttentionFingerprint + !force + && fingerprint === lastLegacyAttentionFingerprint && nowMs - lastAttentionPublishedAt < ATTENTION_HEARTBEAT_MS ) { await reconcileMachineAcknowledgments(items); return "unchanged"; } - try { - const result = await deps.relayClient.publishAttention({ + const result = await deps.relayClient.publishAttention?.({ + machineName: deps.machineName, + fullSnapshot: true, + items, + }); + if (!result) return "unavailable"; + const response = recordActivityPublishResponse(result, nowMs, false); + lastLegacyAttentionFingerprint = fingerprint; + await reconcileMachineAcknowledgments(items); + if (response.protocol >= 2) { + reconcilePending = true; + return "protocol2"; + } + reconcilePending = false; + return result.unchanged === true || result.suppressed === true + ? "unchanged" + : "published"; + }; + + const publishProtocol2Reconcile = async ( + nowMs: number, + items: AttentionItem[], + overflow: AttentionItem[], + ): Promise<"published" | "unchanged" | "legacy"> => { + rosterEpoch = deps.store.nextActivityRosterEpoch?.() ?? rosterEpoch + 1; + const tombstones = overflow.map((item) => + activityTombstone(item.id, item.revision, nowMs)); + const itemPages: AttentionItem[][] = []; + const tombstonePages: AttentionTombstone[][] = []; + for (let offset = 0; offset < items.length; offset += ACTIVITY_PUBLISH_PAGE_ITEMS) { + itemPages.push(items.slice(offset, offset + ACTIVITY_PUBLISH_PAGE_ITEMS)); + } + for (let offset = 0; offset < tombstones.length; offset += ACTIVITY_PUBLISH_PAGE_ITEMS) { + tombstonePages.push(tombstones.slice(offset, offset + ACTIVITY_PUBLISH_PAGE_ITEMS)); + } + const pageCount = Math.max(1, itemPages.length, tombstonePages.length); + let capShrunk = false; + let unchanged = true; + for (let page = 0; page < pageCount; page += 1) { + const result = await deps.relayClient.publishAttention!({ machineName: deps.machineName, - fullSnapshot: true, - items, + mode: "reconcile", + rosterEpoch, + page, + final: page === pageCount - 1, + items: itemPages[page] ?? [], + tombstones: tombstonePages[page] ?? [], }); - if (result) { - lastAttentionFingerprint = fingerprint; - lastAttentionPublishedAt = nowMs; + if (!result) throw new Error("Attention relay became unavailable during reconcile."); + const response = recordActivityPublishResponse(result, nowMs, page === 0); + if (response.protocol < 2) return "legacy"; + capShrunk = capShrunk || response.capShrunk; + unchanged = unchanged && (result.unchanged === true || result.suppressed === true); + } + lastPublishedFingerprintById.clear(); + lastPublishedRevisionById.clear(); + lastOverflowRevisionById.clear(); + for (const item of items) { + lastPublishedFingerprintById.set(item.id, activityPublishFingerprint(item)); + lastPublishedRevisionById.set(item.id, item.revision); + } + for (const item of overflow) { + lastOverflowRevisionById.set(item.id, item.revision); + } + lastReconcileAt = nowMs; + reconcilePending = capShrunk; + await reconcileMachineAcknowledgments(items); + return unchanged ? "unchanged" : "published"; + }; + + const publishProtocol2Delta = async ( + nowMs: number, + items: AttentionItem[], + overflow: AttentionItem[], + presenceOnly: boolean, + ): Promise<"published" | "unchanged" | "legacy"> => { + const selectedIds = new Set(items.map((item) => item.id)); + const overflowById = new Map(overflow.map((item) => [item.id, item])); + const changed = items.filter((item) => + lastPublishedFingerprintById.get(item.id) !== activityPublishFingerprint(item)); + const droppedTombstones = [...lastPublishedFingerprintById.keys()] + .filter((id) => !selectedIds.has(id)) + .map((id) => activityTombstone( + id, + lastPublishedRevisionById.get(id) ?? 0, + nowMs, + )); + const overflowTombstones = overflow + .filter((item) => lastOverflowRevisionById.get(item.id) !== item.revision) + .map((item) => activityTombstone( + item.id, + Math.max(item.revision, lastPublishedRevisionById.get(item.id) ?? 0), + nowMs, + )); + const tombstones = [...new Map( + [...droppedTombstones, ...overflowTombstones] + .map((tombstone) => [tombstone.id, tombstone]), + ).values()]; + if (changed.length === 0 && tombstones.length === 0) { + if (!presenceOnly && nowMs - lastAttentionPublishedAt < ATTENTION_HEARTBEAT_MS) { await reconcileMachineAcknowledgments(items); - return result.unchanged === true || result.suppressed === true - ? "unchanged" - : "published"; + return "unchanged"; } - return "unavailable"; + const result = await deps.relayClient.publishAttention!({ + machineName: deps.machineName, + mode: "presence", + rosterEpoch, + items: [], + tombstones: [], + }); + if (!result) throw new Error("Attention relay became unavailable during presence publish."); + const response = recordActivityPublishResponse(result, nowMs); + return response.protocol >= 2 ? "unchanged" : "legacy"; + } + + // A normal delta is bounded by the machine cap, but a burst can still + // exceed one wire page. Page explicit deltas too; unlike reconcile, no + // page/final fields are needed because every page is independently safe. + const pageCount = Math.max( + Math.ceil(changed.length / ACTIVITY_PUBLISH_PAGE_ITEMS), + Math.ceil(tombstones.length / ACTIVITY_PUBLISH_PAGE_ITEMS), + ); + let unchanged = true; + for (let page = 0; page < pageCount; page += 1) { + const pageItems = changed.slice( + page * ACTIVITY_PUBLISH_PAGE_ITEMS, + (page + 1) * ACTIVITY_PUBLISH_PAGE_ITEMS, + ); + const pageTombstones = tombstones.slice( + page * ACTIVITY_PUBLISH_PAGE_ITEMS, + (page + 1) * ACTIVITY_PUBLISH_PAGE_ITEMS, + ); + const result = await deps.relayClient.publishAttention!({ + machineName: deps.machineName, + mode: "delta", + rosterEpoch, + items: pageItems, + tombstones: pageTombstones, + }); + if (!result) throw new Error("Attention relay became unavailable during delta publish."); + const response = recordActivityPublishResponse(result, nowMs, page === 0); + if (response.protocol < 2) return "legacy"; + unchanged = unchanged && (result.unchanged === true || result.suppressed === true); + for (const item of pageItems) { + lastPublishedFingerprintById.set(item.id, activityPublishFingerprint(item)); + lastPublishedRevisionById.set(item.id, item.revision); + lastOverflowRevisionById.delete(item.id); + } + for (const tombstone of pageTombstones) { + lastPublishedFingerprintById.delete(tombstone.id); + lastPublishedRevisionById.delete(tombstone.id); + const overflowItem = overflowById.get(tombstone.id); + if (overflowItem) lastOverflowRevisionById.set(tombstone.id, overflowItem.revision); + else lastOverflowRevisionById.delete(tombstone.id); + } + } + await reconcileMachineAcknowledgments(items); + return unchanged ? "unchanged" : "published"; + }; + + const publishActivity = async ( + nowMs: number, + presenceOnly: boolean, + ): Promise<"published" | "unchanged" | "unavailable"> => { + if (typeof deps.relayClient.publishAttention !== "function") return "unavailable"; + const accountOwnerId = deps.getAccountOwnerId?.()?.trim() || null; + if (lastActivityAccountOwnerId !== accountOwnerId) { + if (lastActivityAccountOwnerId !== undefined) { + lastPublishedFingerprintById.clear(); + lastPublishedRevisionById.clear(); + lastOverflowRevisionById.clear(); + } + lastActivityAccountOwnerId = accountOwnerId; + reconcilePending = true; + } + if (lastReconcileAt > 0 && nowMs - lastReconcileAt >= ACTIVITY_RECONCILE_INTERVAL_MS) { + reconcilePending = true; + } + + try { + if (activityProtocol == null || activityProtocol < 2) { + const legacy = await publishLegacyAttention(nowMs, activityProtocol == null || reconcilePending); + if (legacy !== "protocol2") return legacy; + } + + // A pure presence heartbeat must never touch the all-project disk roster. + if (presenceOnly && !reconcilePending) { + const result = await deps.relayClient.publishAttention({ + machineName: deps.machineName, + mode: "presence", + rosterEpoch, + items: [], + tombstones: [], + }); + if (!result) return "unavailable"; + const response = recordActivityPublishResponse(result, nowMs); + if (response.protocol >= 2) return "unchanged"; + return await publishLegacyAttention(nowMs, true) === "published" + ? "published" + : "unchanged"; + } + + const built = mergeMachineAcknowledgments(await buildAttentionItems(nowMs, true)); + const { selected, overflow } = selectActivityRoster(built); + const protocolResult = reconcilePending + ? await publishProtocol2Reconcile(nowMs, selected, overflow) + : await publishProtocol2Delta(nowMs, selected, overflow, presenceOnly); + if (protocolResult !== "legacy") return protocolResult; + lastPublishedFingerprintById.clear(); + lastPublishedRevisionById.clear(); + lastOverflowRevisionById.clear(); + reconcilePending = true; + return await publishLegacyAttention(nowMs, true) === "published" + ? "published" + : "unchanged"; } catch (error) { + reconcilePending = true; + if ( + error instanceof PushRelayRequestError + && error.status >= 400 + && error.status < 500 + ) { + activityProtocol = null; + deps.store.setActivityProtocol?.(null); + } logWarn("attention.publish_failed", error); scheduleRetry(); return "unavailable"; } }; - const flush = async (): Promise => { + const flush = async (presenceOnly = false): Promise => { const nowMs = now(); pruneRuns(nowMs); prunePrActivities(nowMs); await resolveMissingMeta(); - const attentionPublishResult = await publishAttentionSnapshot(nowMs); + const attentionPublishResult = await publishActivity(nowMs, presenceOnly); const accountAttentionPublished = attentionPublishResult === "published"; const accountAttentionAvailable = attentionPublishResult !== "unavailable"; if (isGated()) { @@ -1450,19 +1953,19 @@ export function createPushPublisherService(deps: PushPublisherDeps) { flushTimer = setTimeout(() => { flushTimer = null; flushFireAt = 0; - void runFlush(); + void runFlush(false); }, PUBLISH_RETRY_MS); }; - const runFlush = async (): Promise => { + const runFlush = async (presenceOnly = false): Promise => { if (disposed) return; if (flushing) { - if (scopes.size > 0) scheduleFlush(false); + if (scopes.size > 0) scheduleFlush(false, !presenceOnly); return; } flushing = true; try { - await flush(); + await flush(presenceOnly); } catch (error) { logWarn("push.flush_failed", error); } finally { @@ -1482,7 +1985,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { // if another authoritative snapshot is still needed. if (flushing) return; finalAttentionSnapshotPending = false; - void runFlush(); + void runFlush(false); }); }; @@ -1503,7 +2006,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { switch (event.type) { case "approval_request": { - run.phase = "waiting_for_approval"; + setRunPhase(run, "waiting_for_approval"); run.detail = event.description ?? run.detail; run.itemId = event.itemId || null; enqueueAlert({ @@ -1521,7 +2024,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { break; } case "structured_question": { - run.phase = "waiting_for_input"; + setRunPhase(run, "waiting_for_input"); run.detail = event.question ?? run.detail; enqueueAlert({ sessionId, @@ -1537,7 +2040,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { } case "pending_input_resolved": { if (run.phase === "waiting_for_approval" || run.phase === "waiting_for_input") { - run.phase = "running"; + setRunPhase(run, "running"); } run.itemId = null; // Allow a later prompt in the same session to alert again. @@ -1548,28 +2051,28 @@ export function createPushPublisherService(deps: PushPublisherDeps) { case "status": { if (event.turnStatus !== "started") run.itemId = null; if (event.turnStatus === "started") { - if (isTerminalPhase(run.phase)) run.phase = "running"; + if (isTerminalPhase(run.phase)) setRunPhase(run, "running"); } else if (event.turnStatus === "failed") { - run.phase = "failed"; + setRunPhase(run, "failed"); enqueueFailedAlert(run); } else if (event.turnStatus === "completed" || event.turnStatus === "interrupted") { - run.phase = "completed"; + setRunPhase(run, "completed"); } break; } case "done": { if (event.status === "failed") { - run.phase = "failed"; + setRunPhase(run, "failed"); if (!run.model && event.model) run.model = event.model; enqueueFailedAlert(run); } else { - run.phase = "completed"; + setRunPhase(run, "completed"); } break; } default: { if (!isTerminalPhase(run.phase) && run.phase === "starting") { - run.phase = "running"; + setRunPhase(run, "running"); } break; } @@ -1612,9 +2115,9 @@ export function createPushPublisherService(deps: PushPublisherDeps) { ) ) { if (existing) { - existing.phase = "completed"; existing.itemId = null; markRunUpdated(existing); + setRunPhase(existing, "completed"); recentRuns.set(signal.sessionId, { ...existing }); runs.delete(signal.sessionId); pendingAlerts = pendingAlerts.filter( @@ -1655,7 +2158,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { if (existing && existing.kind === "chat") return; const run = ensureRun(signal.sessionId, scopeKey, "cli"); markRunUpdated(run); - run.phase = phase; + setRunPhase(run, phase); if (!run.lane) { run.lane = scopes.get(scopeKey)?.resolveLaneName?.(signal.laneId) ?? signal.laneId ?? null; } @@ -1676,8 +2179,8 @@ export function createPushPublisherService(deps: PushPublisherDeps) { // runtime signal stream never reports exit codes. const run = runs.get(event.sessionId); if (run && run.kind === "cli") { - run.phase = event.exitCode == null || event.exitCode === 0 ? "completed" : "failed"; markRunUpdated(run); + setRunPhase(run, event.exitCode == null || event.exitCode === 0 ? "completed" : "failed"); recentRuns.set(run.sessionId, { ...run }); scheduleFlush(false); } @@ -1761,9 +2264,10 @@ export function createPushPublisherService(deps: PushPublisherDeps) { ? (resolveLaneName?.(notification.laneId) ?? notification.laneId) : null; const activityId = prActivityId(scopeKey, notification); + const existingActivity = prActivities.get(activityId); const eventStamp = Math.max( now(), - (prActivities.get(activityId)?.updatedAt ?? -1) + 1, + (existingActivity?.updatedAt ?? -1) + 1, ); prActivities.set(activityId, { id: activityId, @@ -1776,6 +2280,9 @@ export function createPushPublisherService(deps: PushPublisherDeps) { repoOwner: notification.repoOwner?.trim() || null, repoName: notification.repoName?.trim() || null, updatedAt: eventStamp, + statusSinceAt: existingActivity?.phase === notification.kind + ? existingActivity.statusSinceAt + : eventStamp, }); schedulePrActivityExpiry(eventStamp); @@ -1893,6 +2400,18 @@ export function createPushPublisherService(deps: PushPublisherDeps) { if (disposed || warmed) return; warmed = true; void relayApnsConfigured().catch(() => {}); + reconcilePending = true; + scheduleFlush(true); + }, + + setActivityRosterProvider( + provider: PushPublisherDeps["activityRosterProvider"], + ): void { + if (activityRosterProvider === provider) return; + activityRosterProvider = provider ?? null; + rosterCache = null; + reconcilePending = true; + if (warmed && scopes.size > 0) scheduleFlush(true); }, /** @@ -1924,7 +2443,10 @@ export function createPushPublisherService(deps: PushPublisherDeps) { unsubscribes: scopeUnsubscribes, }); if (!attentionHeartbeatTimer) { - attentionHeartbeatTimer = setInterval(() => scheduleFlush(false), ATTENTION_HEARTBEAT_MS); + attentionHeartbeatTimer = setInterval( + () => scheduleFlush(false, false), + ATTENTION_HEARTBEAT_MS, + ); attentionHeartbeatTimer.unref?.(); } return () => detachScope(scopeKey); @@ -1948,9 +2470,9 @@ export function createPushPublisherService(deps: PushPublisherDeps) { run.lane = request.laneId ? scopes.get(scopeKey)?.resolveLaneName?.(request.laneId) ?? request.laneId : run.lane; - run.phase = "waiting_for_input"; run.detail = request.message; markRunUpdated(run); + setRunPhase(run, "waiting_for_input"); run.metaResolved = true; // An explicit ask supersedes any pending approval on the same session: // clear the stale approval item + its queued alert/dedupe so the next @@ -1978,9 +2500,9 @@ export function createPushPublisherService(deps: PushPublisherDeps) { const run = runs.get(sessionId); if (!run || (scopeKey != null && run.scopeKey !== scopeKey)) return; if (run.phase !== "waiting_for_input" && run.phase !== "waiting_for_approval") return; - run.phase = "running"; run.itemId = null; markRunUpdated(run); + setRunPhase(run, "running"); pendingAlerts = pendingAlerts.filter( (alert) => alert.dedupeKey !== `alert:${sessionId}:approval` @@ -1995,9 +2517,9 @@ export function createPushPublisherService(deps: PushPublisherDeps) { if (disposed || !sessionId) return; const run = runs.get(sessionId); if (!run || (scopeKey != null && run.scopeKey !== scopeKey)) return; - run.phase = "completed"; run.itemId = null; markRunUpdated(run); + setRunPhase(run, "completed"); recentRuns.set(sessionId, { ...run }); runs.delete(sessionId); pendingAlerts = pendingAlerts.filter( @@ -2090,10 +2612,10 @@ export function createPushPublisherService(deps: PushPublisherDeps) { async getMachineAttentionSnapshot(): Promise { const nowMs = now(); const { machineKey } = deps.store.getOrCreateIdentity(); - const items = mergeMachineAcknowledgments( - sortAttentionItems(buildAttentionItems(nowMs)) - .slice(0, ATTENTION_PUBLISH_MAX_ITEMS), + const built = mergeMachineAcknowledgments( + await buildAttentionItems(nowMs, activityProtocol != null && activityProtocol >= 2), ); + const items = selectActivityRoster(built).selected; const accountMachineIdentity = deps.getAccountMachineIdentity?.() ?? null; const accountOwnerId = deps.getAccountOwnerId?.()?.trim() || null; const machine = items[0]?.machine ?? { @@ -2102,7 +2624,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { deviceId: accountMachineIdentity?.deviceId ?? null, name: deps.machineName, online: true, - lastSeenAt: null, + lastSeenAt: new Date(nowMs).toISOString(), }; lastMachineSnapshotItems.clear(); for (const item of items) lastMachineSnapshotItems.set(item.id, item); diff --git a/apps/ade-cli/src/services/push/pushRegistrationStore.ts b/apps/ade-cli/src/services/push/pushRegistrationStore.ts index ff27eaf57..894f8356f 100644 --- a/apps/ade-cli/src/services/push/pushRegistrationStore.ts +++ b/apps/ade-cli/src/services/push/pushRegistrationStore.ts @@ -23,6 +23,15 @@ export type StoredAttentionAcknowledgment = { pendingRelaySync: boolean; }; +export type StoredRemoteAttentionAcknowledgment = { + itemId: string; + accountOwnerId: string | null; + sourceRevision: number; + seenAt: string | null; + dismissedAt: string | null; + updatedAt: string; +}; + type PushRegistrationFile = { version: 1; /** Unguessable machine key claimed on the relay (32 hex chars). */ @@ -36,6 +45,12 @@ type PushRegistrationFile = { devices: Record; /** Durable machine-fallback inbox state, revision-fenced per Attention item. */ attentionAcknowledgments: Record; + /** Relay-owned acknowledgments flowing back down with protocol-2 publishes. */ + remoteAttentionAcknowledgments: Record; + /** Last detected relay protocol. `1` means the response omitted protocol. */ + activityProtocol: number | null; + /** Durable monotonic reconcile epoch; incremented before every new sweep. */ + activityRosterEpoch: number; lastPublishAt: string | null; lastPublishError: string | null; lastRelayContactAt: string | null; @@ -97,6 +112,9 @@ function createEmptyFile(): PushRegistrationFile { enabled: true, devices: {}, attentionAcknowledgments: {}, + remoteAttentionAcknowledgments: {}, + activityProtocol: null, + activityRosterEpoch: 0, lastPublishAt: null, lastPublishError: null, lastRelayContactAt: null, @@ -193,6 +211,42 @@ export function createPushRegistrationStore(args: PushRegistrationStoreArgs) { ]; }), ), + remoteAttentionAcknowledgments: Object.fromEntries( + Object.entries(parsed.remoteAttentionAcknowledgments ?? {}) + .filter((entry): entry is [string, StoredRemoteAttentionAcknowledgment] => { + const acknowledgment = entry[1]; + return Boolean( + acknowledgment + && typeof acknowledgment === "object" + && typeof acknowledgment.itemId === "string" + && acknowledgment.itemId.trim().length > 0 + && ( + acknowledgment.accountOwnerId === undefined + || acknowledgment.accountOwnerId === null + || typeof acknowledgment.accountOwnerId === "string" + ) + && Number.isFinite(acknowledgment.sourceRevision) + && (acknowledgment.seenAt === null || typeof acknowledgment.seenAt === "string") + && (acknowledgment.dismissedAt === null || typeof acknowledgment.dismissedAt === "string") + && typeof acknowledgment.updatedAt === "string", + ); + }) + .map(([, acknowledgment]) => { + const accountOwnerId = acknowledgment.accountOwnerId?.trim() || null; + return [ + attentionAcknowledgmentKey(accountOwnerId, acknowledgment.itemId), + { ...acknowledgment, accountOwnerId }, + ]; + }), + ), + activityProtocol: Number.isSafeInteger(parsed.activityProtocol) + && Number(parsed.activityProtocol) > 0 + ? Number(parsed.activityProtocol) + : null, + activityRosterEpoch: Number.isSafeInteger(parsed.activityRosterEpoch) + && Number(parsed.activityRosterEpoch) >= 0 + ? Number(parsed.activityRosterEpoch) + : 0, lastPublishAt: parsed.lastPublishAt ?? null, lastPublishError: parsed.lastPublishError ?? null, lastRelayContactAt: parsed.lastRelayContactAt ?? null, @@ -381,6 +435,71 @@ export function createPushRegistrationStore(args: PushRegistrationStoreArgs) { if (changed) write({ ...file, attentionAcknowledgments: next }); }, + recordRemoteAttentionAcknowledgments(args: { + accountOwnerId: string | null; + acknowledgments: Array<{ + itemId: string; + sourceRevision: number; + seenAt: string | null; + dismissedAt: string | null; + }>; + updatedAt: string; + }): void { + const file = load(); + const next = { ...file.remoteAttentionAcknowledgments }; + for (const acknowledgment of args.acknowledgments) { + const itemId = acknowledgment.itemId.trim(); + if (!itemId || !Number.isFinite(acknowledgment.sourceRevision)) continue; + const key = attentionAcknowledgmentKey(args.accountOwnerId, itemId); + const existing = next[key]; + if (existing && existing.sourceRevision > acknowledgment.sourceRevision) continue; + next[key] = { + itemId, + accountOwnerId: args.accountOwnerId, + sourceRevision: acknowledgment.sourceRevision, + seenAt: acknowledgment.seenAt, + dismissedAt: acknowledgment.dismissedAt, + updatedAt: args.updatedAt, + }; + } + const bounded = Object.fromEntries( + Object.entries(next) + .sort((left, right) => + Date.parse(right[1].updatedAt) - Date.parse(left[1].updatedAt)) + .slice(0, ATTENTION_ACK_MAX), + ); + write({ ...file, remoteAttentionAcknowledgments: bounded }); + }, + + listRemoteAttentionAcknowledgments( + accountOwnerId?: string | null, + ): StoredRemoteAttentionAcknowledgment[] { + return Object.values(load().remoteAttentionAcknowledgments) + .filter((acknowledgment) => + accountOwnerId === undefined || acknowledgment.accountOwnerId === accountOwnerId) + .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt)); + }, + + getActivityProtocol(): number | null { + return load().activityProtocol; + }, + + setActivityProtocol(protocol: number | null): void { + const file = load(); + const normalized = Number.isSafeInteger(protocol) && Number(protocol) > 0 + ? Number(protocol) + : null; + if (file.activityProtocol === normalized) return; + write({ ...file, activityProtocol: normalized }); + }, + + nextActivityRosterEpoch(): number { + const file = load(); + const next = Math.max(0, file.activityRosterEpoch) + 1; + write({ ...file, activityRosterEpoch: next }); + return next; + }, + hasRegisteredDevices(): boolean { // A device only counts once it has at least one deliverable token. return Object.values(load().devices).some( diff --git a/apps/ade-cli/src/services/push/pushRelayClient.ts b/apps/ade-cli/src/services/push/pushRelayClient.ts index 84f47a9ae..57c6bf18c 100644 --- a/apps/ade-cli/src/services/push/pushRelayClient.ts +++ b/apps/ade-cli/src/services/push/pushRelayClient.ts @@ -2,9 +2,11 @@ import { createHash, createHmac } from "node:crypto"; import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; import type { AttentionItem, + AttentionPreferenceScope, AttentionPreferences, AttentionPresence, AttentionSnapshot, + AttentionTombstone, } from "../../../../desktop/src/shared/types/attention"; import type { PushDeviceRegistration } from "../../../../desktop/src/shared/types/push"; import type { PushRegistrationStore } from "./pushRegistrationStore"; @@ -62,13 +64,33 @@ export type PushRelayHealth = { apnsConfigured: boolean; }; -export type AttentionRelayPublishPayload = { +export type ActivityAcknowledgmentRelayResult = { + applied: string[]; + stale: string[]; +}; + +export type LegacyAttentionRelayPublishPayload = { machineName: string; fullSnapshot: true; items: AttentionItem[]; tombstones?: Array<{ id: string; revision: number }>; }; +export type ActivityPublishRequest = { + machineName: string; + mode: "delta" | "reconcile" | "presence"; + rosterEpoch: number; + page?: number; + final?: boolean; + items: AttentionItem[]; + tombstones: AttentionTombstone[]; + fullSnapshot?: never; +}; + +export type AttentionRelayPublishPayload = + | LegacyAttentionRelayPublishPayload + | ActivityPublishRequest; + /** * Canonical string the relay commits every signed call to. Binding method, * path and body hash prevents replaying a captured signature against another @@ -365,10 +387,11 @@ export function createPushRelayClient(args: { async acknowledgeAttention(acknowledgment: { itemIds: string[]; + sourceRevisions?: Record; seenAt?: string; dismissedAt?: string | null; expectedAccountOwnerId?: string | null; - }): Promise | null> { + }): Promise { if (!args.getAccountAccessToken) return null; const currentAccountUserId = args.getAccountUserId?.()?.trim() || null; const expectedAccountUserId = acknowledgment.expectedAccountOwnerId === undefined @@ -380,19 +403,31 @@ export function createPushRelayClient(args: { ); } if (!currentAccountUserId) return null; - const { - expectedAccountOwnerId: _expectedAccountOwnerId, - ...relayAcknowledgment - } = acknowledgment; const response = await request("POST", "/attention/account/ack", { - body: relayAcknowledgment, + body: acknowledgment, accountAuthorized: true, expectedAccountUserId: expectedAccountUserId ?? undefined, }); if (response.status === 401 && response.body?.error === "ADE account is not signed in") { return null; } - return requireOk("acknowledgeAttention", response); + const body = requireOk("acknowledgeAttention", response); + if ( + !Array.isArray(body.applied) + || !body.applied.every((itemId) => typeof itemId === "string") + || !Array.isArray(body.stale) + || !body.stale.every((itemId) => typeof itemId === "string") + ) { + throw new PushRelayRequestError( + "acknowledgeAttention", + 502, + "relay returned an invalid Activity acknowledgment", + ); + } + return { + applied: body.applied, + stale: body.stale, + }; }, async reportAttentionPresence(presence: AttentionPresence): Promise { @@ -437,6 +472,26 @@ export function createPushRelayClient(args: { requireOk("putAttentionPreferences", response); }, + async putActivityMachinePreferences( + expectedAccountUserId: string, + machineKey: string, + partial: Partial, + ): Promise { + const response = await request( + "PATCH", + `/attention/account/preferences/machines/${encodeURIComponent(machineKey)}`, + { + body: partial, + accountAuthorized: true, + expectedAccountUserId, + }, + ); + if (response.status === 401 && response.body?.error === "ADE account is not signed in") { + return; + } + requireOk("putActivityMachinePreferences", response); + }, + async health(): Promise { const response = await request("GET", "/health"); const body = response.body ?? {}; diff --git a/apps/ade-cli/src/services/sync/rosterBuilder.test.ts b/apps/ade-cli/src/services/sync/rosterBuilder.test.ts index cc4e48a21..cd736203e 100644 --- a/apps/ade-cli/src/services/sync/rosterBuilder.test.ts +++ b/apps/ade-cli/src/services/sync/rosterBuilder.test.ts @@ -229,6 +229,16 @@ describe("buildRosterSnapshot", () => { ]); }); + it("keeps terminal_sessions.id as the canonical publisher session id", async () => { + const projects = await buildRosterSnapshot({ projectRegistry, scopeRegistry: unbootedScopes }); + const row = projects[0]!.chats.find((chat) => chat.title === "Codex CLI"); + + // Activity publishes this row as agent::; using the + // parent chat_session_id here would prevent live/roster collision dedupe. + expect(row?.id).toBe("cli-codex"); + expect(row?.chatSessionId).toBeNull(); + }); + it("maps disk status truthfully (running→idle, awaiting, failed) when un-booted", async () => { const projects = await buildRosterSnapshot({ projectRegistry, scopeRegistry: unbootedScopes }); const byId = new Map(projects[0]!.chats.map((chat) => [chat.id, chat])); From c86caac4e47094b882dab9ecaf4adffac3b43e2f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:15:35 -0400 Subject: [PATCH 08/19] =?UTF-8?q?activity(p6a):=20iOS=20services=20?= =?UTF-8?q?=E2=80=94=2020s=20foreground=20poll,=20push-triggered=20refresh?= =?UTF-8?q?,=20pending-ack=20queue=20with=20surfaced=20failures=20+=20stal?= =?UTF-8?q?e=20retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../components/attention/AttentionCenter.css | 1761 ----------------- .../attention/AttentionCenter.test.tsx | 667 ------- .../components/attention/AttentionCenter.tsx | 1154 ----------- .../attention/AttentionSettingsPopover.tsx | 449 ----- apps/ios/ADE.xcodeproj/project.pbxproj | 8 + apps/ios/ADE/App/ADEApp.swift | 2 + apps/ios/ADE/App/ADEAppDelegate.swift | 17 +- apps/ios/ADE/Services/AccountDirectory.swift | 70 +- apps/ios/ADE/Services/AccountService.swift | 494 ++++- apps/ios/ADE/Shared/ADESharedContainer.swift | 1 + .../AttentionDrawerModel.swift | 8 +- apps/ios/ADETests/ActivityAckQueueTests.swift | 93 + apps/ios/ADETests/ActivityPollingTests.swift | 102 + apps/ios/ADETests/PairingAndDpopTests.swift | 21 +- 14 files changed, 776 insertions(+), 4071 deletions(-) delete mode 100644 apps/desktop/src/renderer/components/attention/AttentionCenter.css delete mode 100644 apps/desktop/src/renderer/components/attention/AttentionCenter.test.tsx delete mode 100644 apps/desktop/src/renderer/components/attention/AttentionCenter.tsx delete mode 100644 apps/desktop/src/renderer/components/attention/AttentionSettingsPopover.tsx create mode 100644 apps/ios/ADETests/ActivityAckQueueTests.swift create mode 100644 apps/ios/ADETests/ActivityPollingTests.swift diff --git a/apps/desktop/src/renderer/components/attention/AttentionCenter.css b/apps/desktop/src/renderer/components/attention/AttentionCenter.css deleted file mode 100644 index ac9fad76d..000000000 --- a/apps/desktop/src/renderer/components/attention/AttentionCenter.css +++ /dev/null @@ -1,1761 +0,0 @@ -/* Attention is a monitoring surface, not a dense debug console. Everything here - hangs off one type scale and one tone system so a new rule can't quietly - reintroduce 7px labels or a dark-only accent. Mono is reserved for counts and - timestamps, where fixed-width rhythm actually helps scanning. */ - -.attention-center { - position: relative; - display: flex; - height: 100%; - min-height: 0; - min-width: 0; - flex-direction: column; - overflow: hidden; - color: var(--color-fg); - background: - radial-gradient(circle at 20% -20%, color-mix(in srgb, var(--color-accent) 12%, transparent), transparent 35%), - linear-gradient(180deg, color-mix(in srgb, var(--color-bg) 92%, var(--color-card)), var(--color-bg)); - isolation: isolate; - - /* Type scale. Nothing in this file sets a raw font-size. */ - --attn-fs-2xs: 10px; /* counts, badges */ - --attn-fs-xs: 11px; /* meta, timestamps, eyebrows */ - --attn-fs-sm: 12px; /* labels, item titles, controls */ - --attn-fs-md: 13px; /* body copy, previews, descriptions */ - --attn-fs-lg: 15px; /* empty-state and placeholder headings */ - --attn-fs-xl: 18px; /* page title */ - - /* Rhythm */ - --attn-gutter: clamp(16px, 2.2vw, 28px); - --attn-radius-panel: 14px; - --attn-radius-card: 11px; - --attn-radius-control: 9px; - - /* Surfaces */ - --attention-surface: color-mix(in srgb, var(--color-card) 76%, transparent); - --attention-surface-raised: color-mix(in srgb, var(--color-card) 91%, transparent); - --attention-hairline: color-mix(in srgb, var(--color-border) 68%, transparent); - --attention-copy-dim: color-mix(in srgb, var(--color-muted-fg) 88%, transparent); - --attn-sticky-bg: color-mix(in srgb, var(--color-card) 88%, var(--color-bg)); - --attn-sheen: rgba(255, 255, 255, 0.055); - --attn-shadow-popover: 0 30px 80px -32px rgba(0, 0, 0, 0.86); - --attn-shadow-menu: 0 22px 55px -25px rgba(0, 0, 0, 0.75); - - /* Tones. --tone-color is the readable ink and is re-declared per tone class. - Anything derived from it must be mixed in the rule that consumes it: a - derived token declared here would compute once against this neutral - default and inherit that same value into every tone. --tone-on is a plain - literal, so it is safe to hold here. */ - --tone-color: #a1a1aa; - --tone-on: #0e1111; - /* No --attn-warn here on purpose. A free-floating amber token is how amber - leaked onto four unrelated things; the only amber left in this file is - .attention-tone-amber, which a phase must earn by meaning "your move". */ - --attn-danger: #f87171; - --attn-ok: #34d399; - --attn-idle: #71717a; -} - -.attention-tone-amber { --tone-color: #fbbf24; } -.attention-tone-red { --tone-color: #f87171; } -.attention-tone-violet { --tone-color: #a78bfa; } -.attention-tone-blue { --tone-color: #60a5fa; } -.attention-tone-cyan { --tone-color: #22d3ee; } -.attention-tone-emerald { --tone-color: #34d399; } -.attention-tone-neutral { --tone-color: #a1a1aa; } - -/* The 400-level tones above sit at ~1.7:1 on a white card. Light theme gets - 600/700-level equivalents so phase pills, the Deny action and status dots - stay readable instead of washing out. */ -[data-theme="light"] .attention-center { - --tone-on: #ffffff; - --attn-sheen: rgba(255, 255, 255, 0.6); - --attn-shadow-popover: 0 24px 60px -28px rgba(15, 23, 42, 0.28); - --attn-shadow-menu: 0 18px 44px -22px rgba(15, 23, 42, 0.22); - --attn-danger: #b91c1c; - --attn-ok: #047857; - --attn-idle: #71717a; - --attn-sticky-bg: color-mix(in srgb, var(--color-card) 94%, var(--color-bg)); -} - -[data-theme="light"] .attention-tone-amber { --tone-color: #b45309; } -[data-theme="light"] .attention-tone-red { --tone-color: #dc2626; } -[data-theme="light"] .attention-tone-violet { --tone-color: #6d28d9; } -[data-theme="light"] .attention-tone-blue { --tone-color: #1d4ed8; } -[data-theme="light"] .attention-tone-cyan { --tone-color: #0e7490; } -[data-theme="light"] .attention-tone-emerald { --tone-color: #047857; } -[data-theme="light"] .attention-tone-neutral { --tone-color: #52525b; } - -.attention-ambient { - position: absolute; - z-index: -1; - width: 360px; - height: 360px; - border-radius: 999px; - opacity: 0.09; - filter: blur(90px); - pointer-events: none; -} - -.attention-ambient-one { - top: -220px; - left: 20%; - background: var(--color-accent); -} - -.attention-ambient-two { - right: -200px; - bottom: -220px; - background: color-mix(in srgb, var(--color-accent) 40%, #22d3ee); -} - -[data-theme="light"] .attention-ambient { - opacity: 0.05; -} - -/* ── Header ─────────────────────────────────────────────────────────── */ - -.attention-header { - position: relative; - z-index: 12; - display: flex; - min-height: 70px; - flex: 0 0 auto; - align-items: center; - justify-content: space-between; - gap: 20px; - padding: 12px 20px; - border-bottom: 1px solid var(--attention-hairline); - background: color-mix(in srgb, var(--color-bg) 78%, transparent); - backdrop-filter: blur(22px) saturate(1.2); -} - -.attention-title-lockup, -.attention-header-controls, -.attention-detail-breadcrumb, -.attention-detail-tools, -.attention-detail-kicker, -.attention-section-heading { - display: flex; - align-items: center; -} - -.attention-title-lockup { - min-width: 0; - gap: 11px; -} - -.attention-title-icon { - position: relative; - display: inline-flex; - width: 36px; - height: 36px; - flex: 0 0 auto; - align-items: center; - justify-content: center; - color: var(--color-accent-bright, var(--color-accent)); - border: 1px solid color-mix(in srgb, var(--color-accent) 28%, transparent); - border-radius: 12px; - background: - linear-gradient(145deg, color-mix(in srgb, var(--color-accent) 18%, transparent), color-mix(in srgb, var(--color-card) 92%, transparent)); - box-shadow: inset 0 1px 0 var(--attn-sheen), 0 8px 22px -14px var(--color-accent); -} - -/* The bell badge counts the whole inbox — needs-you, failures, review requests - and unseen outcomes together — so it is an aggregate, not a request. It used - to be amber, which is exactly how amber came to mean four things at once; - neutral-strong keeps it legible and leaves the meaning to the per-row tones. */ -.attention-title-icon > span { - position: absolute; - top: -5px; - right: -6px; - display: inline-flex; - min-width: 18px; - height: 18px; - align-items: center; - justify-content: center; - padding: 0 4px; - color: var(--color-bg); - border: 2px solid var(--color-bg); - border-radius: 99px; - background: var(--color-fg); - font-family: var(--font-mono); - font-size: var(--attn-fs-2xs); - font-weight: 750; - line-height: 1; -} - -.attention-title-lockup h1 { - margin: 0; - font-size: var(--attn-fs-xl); - font-weight: 680; - letter-spacing: -0.025em; -} - -.attention-title-lockup p { - margin: 2px 0 0; - overflow: hidden; - color: var(--attention-copy-dim); - font-size: var(--attn-fs-xs); - text-overflow: ellipsis; - white-space: nowrap; -} - -.attention-header-controls { - position: relative; - gap: 9px; -} - -.attention-freshness { - display: inline-flex; - align-items: center; - gap: 5px; - color: color-mix(in srgb, var(--color-muted-fg) 82%, transparent); - font-size: var(--attn-fs-xs); - white-space: nowrap; -} - -.attention-freshness-error { - color: var(--attn-danger); -} - -/* ── Scope picker ───────────────────────────────────────────────────── */ - -.attention-scope-wrap { - position: relative; -} - -.attention-scope-button { - display: flex; - width: min(190px, 24vw); - height: 31px; - align-items: center; - gap: 7px; - padding: 0 9px; - color: var(--color-muted-fg); - border: 1px solid var(--attention-hairline); - border-radius: var(--attn-radius-control); - background: color-mix(in srgb, var(--color-card) 75%, transparent); - font-size: var(--attn-fs-sm); - font-weight: 590; - transition: color 140ms ease, border-color 140ms ease, background 140ms ease; -} - -.attention-scope-button:hover, -.attention-scope-button[data-scoped] { - color: var(--color-fg); - border-color: color-mix(in srgb, var(--color-accent) 34%, var(--color-border)); - background: color-mix(in srgb, var(--color-accent) 8%, var(--color-card)); -} - -.attention-scope-menu { - position: absolute; - top: calc(100% + 7px); - right: 0; - z-index: 50; - width: 275px; - max-height: min(500px, calc(100vh - 150px)); - overflow-y: auto; - padding: 6px; - border: 1px solid color-mix(in srgb, var(--color-border) 86%, transparent); - border-radius: 13px; - background: color-mix(in srgb, var(--color-card) 96%, var(--color-bg)); - box-shadow: var(--attn-shadow-menu), inset 0 1px 0 var(--attn-sheen); - backdrop-filter: blur(28px) saturate(1.25); -} - -.attention-scope-group { - margin-top: 4px; - padding-top: 4px; - border-top: 1px solid var(--attention-hairline); -} - -.attention-scope-option { - display: flex; - width: 100%; - min-height: 38px; - align-items: center; - gap: 9px; - padding: 6px 8px; - color: var(--color-muted-fg); - border-radius: 8px; - text-align: left; - transition: color 120ms ease, background 120ms ease; -} - -.attention-scope-option:hover, -.attention-scope-option[aria-checked="true"] { - color: var(--color-fg); - background: color-mix(in srgb, var(--color-accent) 9%, transparent); -} - -.attention-scope-option strong, -.attention-scope-option small { - display: block; -} - -.attention-scope-option strong { - font-size: var(--attn-fs-sm); - font-weight: 640; -} - -.attention-scope-option small { - margin-top: 1px; - color: var(--color-muted-fg); - font-size: var(--attn-fs-xs); -} - -.attention-scope-option-icon { - display: inline-flex; - width: 23px; - height: 23px; - flex: 0 0 auto; - align-items: center; - justify-content: center; - border: 1px solid var(--attention-hairline); - border-radius: 7px; - background: color-mix(in srgb, var(--color-fg) 3%, transparent); -} - -.attention-scope-project { - min-height: 32px; - padding-left: 20px; - font-size: var(--attn-fs-sm); -} - -/* ── Toolbar ────────────────────────────────────────────────────────── */ - -.attention-toolbar { - position: relative; - z-index: 8; - display: flex; - min-height: 46px; - flex: 0 0 auto; - align-items: center; - gap: 12px; - padding: 7px 20px; - border-bottom: 1px solid var(--attention-hairline); - background: color-mix(in srgb, var(--color-bg) 68%, transparent); -} - -.attention-tabs { - display: inline-flex; - gap: 3px; - padding: 3px; - border: 1px solid var(--attention-hairline); - border-radius: 10px; - background: color-mix(in srgb, var(--color-bg) 75%, var(--color-card)); -} - -.attention-tab { - display: inline-flex; - height: 28px; - align-items: center; - gap: 6px; - padding: 0 10px; - color: color-mix(in srgb, var(--color-muted-fg) 88%, transparent); - border-radius: 7px; - font-size: var(--attn-fs-sm); - font-weight: 600; - transition: color 140ms ease, background 140ms ease, box-shadow 140ms ease; -} - -.attention-tab:hover { - color: var(--color-fg); -} - -.attention-tab[data-active] { - color: color-mix(in srgb, var(--color-accent) 35%, var(--color-fg)); - background: color-mix(in srgb, var(--color-accent) 12%, var(--color-card)); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 17%, transparent), 0 4px 12px -10px var(--color-accent); -} - -.attention-tab-count { - display: inline-flex; - min-width: 17px; - height: 17px; - align-items: center; - justify-content: center; - padding: 0 4px; - color: var(--color-muted-fg); - border-radius: 5px; - background: color-mix(in srgb, var(--color-fg) 5%, transparent); - font-family: var(--font-mono); - font-size: var(--attn-fs-2xs); - line-height: 1; -} - -.attention-tab[data-active] .attention-tab-count { - color: var(--color-fg); - background: color-mix(in srgb, var(--color-accent) 14%, transparent); -} - -.attention-filter-chip, -.attention-toolbar-hint { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: var(--attn-fs-xs); -} - -.attention-filter-chip { - max-width: 190px; - height: 26px; - padding: 0 8px; - color: color-mix(in srgb, var(--color-accent) 44%, var(--color-fg)); - border: 1px solid color-mix(in srgb, var(--color-accent) 26%, transparent); - border-radius: 8px; - background: color-mix(in srgb, var(--color-accent) 8%, transparent); -} - -.attention-filter-chip span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.attention-toolbar-hint { - margin-left: auto; - color: color-mix(in srgb, var(--color-muted-fg) 68%, transparent); -} - -/* ── Layout ─────────────────────────────────────────────────────────── */ - -.attention-layout { - position: relative; - display: grid; - min-height: 0; - flex: 1 1 auto; - grid-template-columns: clamp(300px, 34%, 430px) minmax(0, 1fr); - gap: 10px; - padding: 10px; -} - -.attention-roster-panel, -.attention-detail-panel { - min-height: 0; - min-width: 0; - overflow: hidden; - border: 1px solid var(--attention-hairline); - border-radius: var(--attn-radius-panel); - background: var(--attention-surface); - box-shadow: inset 0 1px 0 var(--attn-sheen); - backdrop-filter: blur(20px); -} - -.attention-roster-panel { - display: flex; - flex-direction: column; -} - -.attention-panel-heading { - display: flex; - min-height: 43px; - flex: 0 0 auto; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 8px 12px; - border-bottom: 1px solid var(--attention-hairline); -} - -.attention-panel-heading > div { - display: flex; - min-width: 0; - align-items: baseline; - gap: 7px; -} - -.attention-panel-heading strong { - font-size: var(--attn-fs-sm); - font-weight: 650; - letter-spacing: -0.01em; - white-space: nowrap; -} - -.attention-panel-heading > div > span { - color: var(--color-muted-fg); - font-family: var(--font-mono); - font-size: var(--attn-fs-xs); - white-space: nowrap; -} - -/* Jump-to-inbox affordance. Accent, not amber: the number behind it is the - whole inbox (failures and review requests included), and amber is reserved - for states where the user personally has to move. */ -.attention-panel-heading button { - flex: 0 0 auto; - padding: 4px 7px; - color: var(--color-accent-bright, var(--color-accent)); - border-radius: 6px; - background: color-mix(in srgb, var(--color-accent) 14%, transparent); - font-size: var(--attn-fs-xs); - font-weight: 600; - white-space: nowrap; - transition: background 130ms ease; -} - -.attention-panel-heading button:hover { - background: color-mix(in srgb, var(--color-accent) 22%, transparent); -} - -.attention-roster-scroll { - min-height: 0; - flex: 1 1 auto; - overflow-y: auto; - padding: 7px; - scrollbar-gutter: stable; -} - -/* ── Roster grouping: machine → project → item ──────────────────────── */ - -.attention-machine-group + .attention-machine-group { - margin-top: 10px; -} - -/* Both group headings stick so the machine and project a row belongs to stay - on screen while scrolling a long roster. */ -.attention-machine-heading { - position: sticky; - top: -7px; - z-index: 3; - display: flex; - height: 38px; - align-items: center; - gap: 8px; - margin: 0 -7px; - padding: 0 13px; - border-bottom: 1px solid var(--attention-hairline); - background: var(--attn-sticky-bg); - backdrop-filter: blur(12px); -} - -.attention-machine-icon { - display: inline-flex; - width: 26px; - height: 26px; - flex: 0 0 auto; - align-items: center; - justify-content: center; - color: color-mix(in srgb, var(--color-accent) 34%, var(--color-fg)); - border: 1px solid var(--attention-hairline); - border-radius: 8px; - background: color-mix(in srgb, var(--color-accent) 5%, transparent); -} - -.attention-machine-heading strong, -.attention-machine-heading small { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.attention-machine-heading strong { - font-size: var(--attn-fs-sm); - font-weight: 650; -} - -.attention-machine-heading small { - margin-top: 1px; - color: var(--color-muted-fg); - font-size: var(--attn-fs-xs); -} - -.attention-online-dot { - width: 6px; - height: 6px; - flex: 0 0 auto; - border-radius: 99px; -} - -.attention-online-dot.is-online { - background: var(--attn-ok); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--attn-ok) 12%, transparent); -} - -.attention-online-dot.is-offline { - background: var(--attn-idle); -} - -.attention-machine-count { - display: inline-flex; - min-width: 19px; - height: 19px; - align-items: center; - justify-content: center; - padding: 0 5px; - color: var(--color-muted-fg); - border-radius: 6px; - background: color-mix(in srgb, var(--color-fg) 6%, transparent); - font-family: var(--font-mono); - font-size: var(--attn-fs-2xs); -} - -.attention-project-group { - margin-top: 2px; -} - -.attention-project-heading { - position: sticky; - top: 31px; - z-index: 2; - display: flex; - height: 28px; - align-items: center; - gap: 7px; - margin: 0 -7px; - padding: 0 14px 0 22px; - color: color-mix(in srgb, var(--color-muted-fg) 92%, transparent); - background: var(--attn-sticky-bg); - font-size: var(--attn-fs-xs); - font-weight: 600; - letter-spacing: 0.01em; -} - -.attention-project-heading > span:nth-child(2) { - flex: 1 1 auto; -} - -.attention-project-heading > span:last-child { - font-family: var(--font-mono); - font-size: var(--attn-fs-2xs); -} - -.attention-project-glyph { - display: inline-flex; - width: 18px; - height: 18px; - flex: 0 0 auto; - align-items: center; - justify-content: center; - color: color-mix(in srgb, var(--color-accent) 45%, var(--color-fg)); - border: 1px solid color-mix(in srgb, var(--color-accent) 18%, var(--color-border)); - border-radius: 5px; - background: color-mix(in srgb, var(--color-accent) 6%, transparent); - font-family: var(--font-sans); - font-size: var(--attn-fs-2xs); - font-weight: 700; - line-height: 1; -} - -.attention-project-items { - display: flex; - flex-direction: column; - gap: 3px; - padding-top: 3px; -} - -/* ── Item row ───────────────────────────────────────────────────────── */ - -.attention-item-row { - position: relative; - display: flex; - width: 100%; - min-width: 0; - align-items: flex-start; - gap: 9px; - overflow: hidden; - padding: 10px 10px 9px; - color: var(--color-fg); - border: 1px solid transparent; - border-radius: 10px; - text-align: left; - transition: border-color 140ms ease, background 140ms ease, box-shadow 140ms ease; -} - -.attention-item-row:hover { - border-color: color-mix(in srgb, var(--tone-color) 18%, var(--color-border)); - background: color-mix(in srgb, var(--tone-color) 5%, transparent); -} - -.attention-item-row[data-selected] { - border-color: color-mix(in srgb, var(--tone-color) 28%, var(--color-border)); - background: - linear-gradient(100deg, color-mix(in srgb, var(--tone-color) 10%, transparent), color-mix(in srgb, var(--color-fg) 2%, transparent)); - box-shadow: 0 9px 26px -22px var(--tone-color), inset 0 1px 0 var(--attn-sheen); -} - -.attention-selected-rail { - position: absolute; - top: 8px; - bottom: 8px; - left: 0; - width: 2px; - border-radius: 0 99px 99px 0; - background: var(--tone-color); - box-shadow: 0 0 10px color-mix(in srgb, var(--tone-color) 55%, transparent); -} - -.attention-item-icon, -.attention-detail-provider { - display: inline-flex; - flex: 0 0 auto; - align-items: center; - justify-content: center; - color: var(--tone-color); - border: 1px solid color-mix(in srgb, var(--tone-color) 22%, var(--color-border)); - background: color-mix(in srgb, var(--tone-color) 7%, var(--color-card)); -} - -.attention-item-icon { - width: 31px; - height: 31px; - border-radius: 9px; -} - -.attention-item-copy { - display: block; - min-width: 0; - flex: 1 1 auto; -} - -.attention-item-title-line { - display: flex; - min-width: 0; - align-items: baseline; - gap: 8px; -} - -/* Titles are frequently file paths and branch names, which have no break - opportunities. Without this they clip mid-word with no ellipsis. */ -.attention-item-title-line strong { - min-width: 0; - flex: 1 1 auto; - overflow: hidden; - font-size: var(--attn-fs-sm); - font-weight: 640; - letter-spacing: -0.01em; - text-overflow: ellipsis; - white-space: nowrap; -} - -.attention-item-title-line time { - flex: 0 0 auto; - color: color-mix(in srgb, var(--color-muted-fg) 78%, transparent); - font-family: var(--font-mono); - font-size: var(--attn-fs-2xs); -} - -.attention-item-preview { - display: -webkit-box; - overflow: hidden; - margin-top: 3px; - color: color-mix(in srgb, var(--color-muted-fg) 92%, transparent); - font-size: var(--attn-fs-md); - line-height: 1.4; - overflow-wrap: anywhere; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; -} - -.attention-item-meta { - display: flex; - min-width: 0; - align-items: center; - gap: 6px; - margin-top: 6px; - overflow: hidden; - color: color-mix(in srgb, var(--color-muted-fg) 78%, transparent); - font-size: var(--attn-fs-xs); - white-space: nowrap; -} - -.attention-item-meta > span:not(.attention-phase-pill) { - overflow: hidden; - text-overflow: ellipsis; -} - -.attention-item-meta > span:not(:last-child)::after { - margin-left: 6px; - color: color-mix(in srgb, var(--color-muted-fg) 35%, transparent); - content: "·"; -} - -.attention-phase-pill { - display: inline-flex; - flex: 0 0 auto; - align-items: center; - gap: 5px; - color: color-mix(in srgb, var(--tone-color) 78%, var(--color-fg)); - font-size: var(--attn-fs-xs); - font-weight: 650; -} - -.attention-phase-dot { - width: 5px; - height: 5px; - flex: 0 0 auto; - border-radius: 99px; - background: var(--tone-color); - box-shadow: 0 0 8px color-mix(in srgb, var(--tone-color) 40%, transparent); -} - -.attention-phase-dot-active { - animation: attention-status-pulse 2.4s ease-in-out infinite; -} - -/* Unseen is a separate axis from phase, so it gets its own mark rather than a - second dot in the phase colour. */ -.attention-unseen-dot { - width: 7px; - height: 7px; - flex: 0 0 auto; - margin-top: 4px; - border: 2px solid color-mix(in srgb, var(--color-accent) 78%, transparent); - border-radius: 99px; - background: transparent; -} - -.attention-item-row[data-selected] .attention-unseen-dot, -.attention-item-row:hover .attention-unseen-dot { - background: color-mix(in srgb, var(--color-accent) 78%, transparent); -} - -/* ── Detail ─────────────────────────────────────────────────────────── */ - -.attention-detail-panel { - overflow-y: auto; -} - -.attention-detail-card { - position: relative; - display: flex; - min-height: 100%; - flex-direction: column; - overflow: hidden; - background: - radial-gradient(circle at 86% 0%, color-mix(in srgb, var(--tone-color) 8%, transparent), transparent 28%), - color-mix(in srgb, var(--color-card) 71%, transparent); -} - -.attention-detail-accent { - position: absolute; - top: 0; - right: 0; - left: 0; - height: 2px; - background: linear-gradient(90deg, transparent, var(--tone-color) 18%, var(--tone-color) 82%, transparent); - opacity: 0.85; - box-shadow: 0 0 16px color-mix(in srgb, var(--tone-color) 35%, transparent); -} - -.attention-detail-header { - display: flex; - min-height: 43px; - flex: 0 0 auto; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 8px 13px; - border-bottom: 1px solid var(--attention-hairline); -} - -.attention-detail-breadcrumb { - min-width: 0; - gap: 6px; - overflow: hidden; - color: color-mix(in srgb, var(--color-muted-fg) 88%, transparent); - font-size: var(--attn-fs-xs); - white-space: nowrap; -} - -.attention-breadcrumb-status { - display: inline-flex; - color: var(--attn-idle); -} - -.attention-breadcrumb-status.is-online { - color: var(--attn-ok); -} - -.attention-breadcrumb-separator { - color: color-mix(in srgb, var(--color-muted-fg) 40%, transparent); -} - -.attention-detail-tools { - flex: 0 0 auto; - gap: 4px; -} - -.attention-seen-label { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 0 5px; - color: color-mix(in srgb, var(--color-muted-fg) 78%, transparent); - font-size: var(--attn-fs-xs); -} - -.attention-icon-button { - display: inline-flex; - width: 26px; - height: 26px; - align-items: center; - justify-content: center; - color: var(--color-muted-fg); - border: 1px solid transparent; - border-radius: 7px; - transition: color 120ms ease, border-color 120ms ease, background 120ms ease; -} - -.attention-icon-button:hover { - color: var(--color-fg); - border-color: var(--attention-hairline); - background: color-mix(in srgb, var(--color-fg) 5%, transparent); -} - -.attention-detail-hero { - display: flex; - gap: 13px; - padding: clamp(16px, 2.4vw, 24px) var(--attn-gutter) 16px; -} - -.attention-detail-provider { - width: 44px; - height: 44px; - border-radius: 13px; - box-shadow: 0 10px 28px -20px var(--tone-color), inset 0 1px 0 var(--attn-sheen); -} - -.attention-detail-kicker { - gap: 8px; -} - -.attention-detail-kicker time { - color: color-mix(in srgb, var(--color-muted-fg) 75%, transparent); - font-family: var(--font-mono); - font-size: var(--attn-fs-2xs); -} - -/* Sized for a path-shaped title: still clearly the hero, but it no longer - takes three lines and swamps the actions below it. */ -.attention-detail-hero h2 { - max-width: 62ch; - margin: 7px 0 0; - font-size: clamp(16px, 1.5vw, 20px); - font-weight: 660; - line-height: 1.28; - letter-spacing: -0.018em; - overflow-wrap: anywhere; -} - -.attention-detail-hero p { - max-width: 74ch; - margin: 7px 0 0; - color: color-mix(in srgb, var(--color-muted-fg) 92%, transparent); - font-size: var(--attn-fs-md); - line-height: 1.55; - overflow-wrap: anywhere; -} - -.attention-offline-banner, -.attention-ack-error { - display: flex; - align-items: flex-start; - gap: 8px; - margin: 0 var(--attn-gutter) 14px; - padding: 9px 11px; - border-radius: 10px; - font-size: var(--attn-fs-md); - line-height: 1.45; -} - -/* "This machine is offline, you're reading last-known state" is true but not - actionable — the user cannot reconnect it from here. It was one of the five - things amber used to mean; it is neutral now, per the one-hue rule in - apps/desktop/src/shared/sessionStatusPresentation.ts. */ -.attention-offline-banner { - color: var(--color-muted-fg); - border: 1px solid color-mix(in srgb, var(--attn-idle) 30%, transparent); - background: color-mix(in srgb, var(--attn-idle) 10%, transparent); -} - -.attention-ack-error { - color: var(--attn-danger); - border: 1px solid color-mix(in srgb, var(--attn-danger) 28%, transparent); - background: color-mix(in srgb, var(--attn-danger) 8%, transparent); -} - -.attention-offline-banner svg, -.attention-ack-error svg { - flex: 0 0 auto; - margin-top: 1px; -} - -.attention-offline-banner strong, -.attention-ack-error strong { - display: block; - margin-bottom: 1px; -} - -.attention-detail-actions { - display: flex; - flex-wrap: wrap; - gap: 7px; - padding: 0 var(--attn-gutter) 18px; - border-bottom: 1px solid var(--attention-hairline); -} - -.attention-action { - display: inline-flex; - height: 31px; - align-items: center; - justify-content: center; - gap: 6px; - padding: 0 12px; - color: var(--color-muted-fg); - border: 1px solid var(--attention-hairline); - border-radius: 8px; - background: color-mix(in srgb, var(--color-fg) 3%, transparent); - font-size: var(--attn-fs-sm); - font-weight: 620; - transition: color 130ms ease, border-color 130ms ease, background 130ms ease, filter 130ms ease; -} - -/* Dark theme lightens the tone so near-black text sits on it; light theme uses - the tone at full strength under white text. */ -.attention-action[data-tone="primary"] { - color: var(--tone-on); - border-color: color-mix(in srgb, var(--tone-color) 70%, white); - background: color-mix(in srgb, var(--tone-color) 82%, white); -} - -[data-theme="light"] .attention-action[data-tone="primary"] { - border-color: var(--tone-color); - background: var(--tone-color); -} - -.attention-action[data-tone="secondary"]:hover { - color: var(--color-fg); - border-color: color-mix(in srgb, var(--tone-color) 34%, var(--color-border)); - background: color-mix(in srgb, var(--tone-color) 8%, transparent); -} - -.attention-action[data-tone="danger"] { - color: var(--attn-danger); - border-color: color-mix(in srgb, var(--attn-danger) 30%, transparent); - background: color-mix(in srgb, var(--attn-danger) 8%, transparent); -} - -.attention-action[data-tone="ghost"] { - border-color: transparent; - background: transparent; -} - -.attention-action:hover:not(:disabled) { - filter: brightness(1.08); -} - -.attention-action:disabled { - cursor: not-allowed; - opacity: 0.42; -} - -.attention-detail-body { - display: grid; - min-height: 0; - flex: 1 1 auto; - align-content: start; - gap: 10px; - padding: 14px var(--attn-gutter) 20px; -} - -.attention-detail-section { - padding: 12px; - border: 1px solid color-mix(in srgb, var(--color-border) 62%, transparent); - border-radius: var(--attn-radius-card); - background: color-mix(in srgb, var(--color-bg) 48%, transparent); -} - -.attention-section-heading { - gap: 7px; - color: color-mix(in srgb, var(--tone-color) 68%, var(--color-fg)); -} - -.attention-section-heading h3 { - margin: 0; - color: var(--color-fg); - font-size: var(--attn-fs-sm); - font-weight: 650; -} - -.attention-section-heading > span { - margin-left: auto; - color: var(--color-muted-fg); - font-family: var(--font-mono); - font-size: var(--attn-fs-xs); -} - -.attention-detail-note p, -.attention-detail-calm p { - margin: 8px 0 0; - color: color-mix(in srgb, var(--color-muted-fg) 92%, transparent); - font-size: var(--attn-fs-md); - line-height: 1.58; - white-space: pre-wrap; - overflow-wrap: anywhere; -} - -.attention-progress-track { - height: 5px; - margin-top: 11px; - overflow: hidden; - border-radius: 99px; - background: color-mix(in srgb, var(--color-fg) 8%, transparent); -} - -.attention-progress-fill { - height: 100%; - border-radius: inherit; - background: linear-gradient(90deg, color-mix(in srgb, var(--tone-color) 72%, white), var(--tone-color)); - box-shadow: 0 0 12px color-mix(in srgb, var(--tone-color) 35%, transparent); -} - -.attention-plan-current { - display: flex; - align-items: center; - gap: 6px; - margin: 9px 0 0; - color: var(--color-muted-fg); - font-size: var(--attn-fs-md); - overflow-wrap: anywhere; -} - -.attention-plan-current svg { - flex: 0 0 auto; - color: var(--tone-color); -} - -.attention-activity-list { - position: relative; - display: flex; - flex-direction: column; - gap: 0; - margin: 9px 0 0; - padding: 0; - list-style: none; -} - -.attention-activity-list::before { - position: absolute; - top: 10px; - bottom: 10px; - left: 3px; - width: 1px; - background: color-mix(in srgb, var(--color-border) 85%, transparent); - content: ""; -} - -.attention-activity-list li { - position: relative; - display: flex; - gap: 9px; - padding: 5px 0; - color: color-mix(in srgb, var(--color-muted-fg) 92%, transparent); - font-size: var(--attn-fs-md); - line-height: 1.45; - overflow-wrap: anywhere; -} - -.attention-activity-node { - z-index: 1; - width: 7px; - height: 7px; - flex: 0 0 auto; - margin-top: 5px; - border: 2px solid color-mix(in srgb, var(--color-card) 82%, var(--color-bg)); - border-radius: 99px; - background: color-mix(in srgb, var(--tone-color) 68%, var(--color-muted-fg)); -} - -.attention-detail-calm { - display: flex; - align-items: flex-start; - gap: 10px; - color: var(--tone-color); -} - -.attention-detail-calm svg { - flex: 0 0 auto; -} - -.attention-detail-calm h3 { - margin: 0; - color: var(--color-fg); - font-size: var(--attn-fs-sm); - font-weight: 650; -} - -.attention-detail-calm p { - margin-top: 3px; -} - -.attention-detail-footer { - display: flex; - min-height: 34px; - align-items: center; - gap: 7px; - padding: 7px 14px; - color: color-mix(in srgb, var(--color-muted-fg) 72%, transparent); - border-top: 1px solid var(--attention-hairline); - font-size: var(--attn-fs-xs); -} - -.attention-detail-footer > span + span:not(.ml-auto)::before { - margin-right: 7px; - content: "·"; -} - -/* ── Empty and placeholder states ───────────────────────────────────── */ - -.attention-empty, -.attention-detail-placeholder { - display: flex; - height: 100%; - min-height: 250px; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 28px; - text-align: center; -} - -.attention-empty-icon, -.attention-detail-placeholder-icon { - display: inline-flex; - width: 49px; - height: 49px; - align-items: center; - justify-content: center; - color: color-mix(in srgb, var(--color-accent) 55%, var(--color-muted-fg)); - border: 1px solid color-mix(in srgb, var(--color-accent) 16%, var(--color-border)); - border-radius: 15px; - background: color-mix(in srgb, var(--color-accent) 6%, transparent); - box-shadow: 0 15px 35px -28px var(--color-accent); -} - -.attention-empty-icon-error { - color: var(--attn-danger); - border-color: color-mix(in srgb, var(--attn-danger) 26%, transparent); - background: color-mix(in srgb, var(--attn-danger) 7%, transparent); -} - -.attention-empty strong, -.attention-detail-placeholder strong { - margin-top: 14px; - font-size: var(--attn-fs-lg); - font-weight: 650; - letter-spacing: -0.015em; -} - -.attention-empty p, -.attention-detail-placeholder p { - max-width: 42ch; - margin: 6px 0 0; - color: var(--color-muted-fg); - font-size: var(--attn-fs-md); - line-height: 1.55; -} - -.attention-subtle-button { - display: inline-flex; - height: 28px; - align-items: center; - gap: 6px; - margin-top: 13px; - padding: 0 10px; - color: color-mix(in srgb, var(--color-accent) 44%, var(--color-fg)); - border: 1px solid color-mix(in srgb, var(--color-accent) 24%, transparent); - border-radius: 7px; - background: color-mix(in srgb, var(--color-accent) 7%, transparent); - font-size: var(--attn-fs-sm); - font-weight: 600; -} - -/* ── Settings popover ───────────────────────────────────────────────── */ - -.attention-settings-wrap { - position: relative; -} - -.attention-settings-trigger { - display: inline-flex; - width: 30px; - height: 30px; - align-items: center; - justify-content: center; - color: color-mix(in srgb, var(--color-muted-fg) 92%, transparent); - border: 1px solid transparent; - border-radius: var(--attn-radius-control); - background: transparent; - transition: color 140ms ease, border-color 140ms ease, background 140ms ease, transform 140ms ease; -} - -.attention-settings-trigger:hover, -.attention-settings-trigger[aria-expanded="true"] { - color: var(--color-fg); - border-color: color-mix(in srgb, var(--color-accent) 24%, var(--color-border)); - background: color-mix(in srgb, var(--color-accent) 8%, var(--color-card)); -} - -.attention-settings-trigger:active { - transform: scale(0.94); -} - -/* Anchored to the trigger's right edge and clamped to the viewport instead of - the old fixed -216px nudge, which could hang off the window. */ -.attention-settings-popover { - position: absolute; - top: calc(100% + 9px); - right: 0; - z-index: 80; - width: min(400px, calc(100vw - 32px)); - max-height: calc(100vh - 120px); - overflow-y: auto; - border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent); - border-radius: 16px; - background: - radial-gradient(circle at 14% -10%, color-mix(in srgb, var(--color-accent) 12%, transparent), transparent 35%), - color-mix(in srgb, var(--color-card) 97%, var(--color-bg)); - box-shadow: var(--attn-shadow-popover), inset 0 1px 0 var(--attn-sheen); - backdrop-filter: blur(30px) saturate(1.25); - transform-origin: top right; -} - -.attention-settings-popover:focus { - outline: none; -} - -.attention-settings-popover > header { - position: sticky; - top: 0; - z-index: 1; - display: flex; - min-height: 56px; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 11px 13px; - border-bottom: 1px solid var(--attention-hairline); - background: color-mix(in srgb, var(--color-card) 96%, var(--color-bg)); -} - -.attention-settings-popover > header > div, -.attention-settings-popover > header > div > span:last-child { - display: flex; -} - -.attention-settings-popover > header > div { - min-width: 0; - align-items: center; - gap: 9px; -} - -.attention-settings-popover > header > div > span:last-child { - min-width: 0; - flex-direction: column; -} - -.attention-settings-popover > header strong { - font-size: var(--attn-fs-sm); - font-weight: 660; - letter-spacing: -0.01em; -} - -.attention-settings-popover > header small { - margin-top: 2px; - color: var(--attention-copy-dim); - font-size: var(--attn-fs-xs); -} - -.attention-settings-heading-icon { - display: inline-flex; - width: 31px; - height: 31px; - flex: 0 0 auto; - align-items: center; - justify-content: center; - color: var(--color-accent-bright, var(--color-accent)); - border: 1px solid color-mix(in srgb, var(--color-accent) 24%, transparent); - border-radius: 9px; - background: color-mix(in srgb, var(--color-accent) 9%, transparent); -} - -.attention-settings-account-badge { - flex: 0 0 auto; - padding: 3px 7px; - color: color-mix(in srgb, var(--color-accent) 55%, var(--color-fg)); - border: 1px solid color-mix(in srgb, var(--color-accent) 22%, transparent); - border-radius: 99px; - background: color-mix(in srgb, var(--color-accent) 7%, transparent); - font-size: var(--attn-fs-2xs); - font-weight: 650; - letter-spacing: 0.02em; -} - -.attention-settings-popover section { - padding: 10px 10px 6px; -} - -.attention-settings-popover section + section { - padding-top: 9px; - border-top: 1px solid var(--attention-hairline); -} - -.attention-settings-popover section h3 { - margin: 0 0 5px 3px; - color: color-mix(in srgb, var(--color-muted-fg) 88%, transparent); - font-size: var(--attn-fs-2xs); - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.07em; -} - -.attention-settings-row, -.attention-settings-delay { - display: grid; - min-height: 48px; - grid-template-columns: 30px minmax(0, 1fr) auto; - align-items: center; - gap: 9px; - padding: 7px; - border-radius: 10px; - transition: background 130ms ease; -} - -.attention-settings-row:hover, -.attention-settings-delay:hover { - background: color-mix(in srgb, var(--color-fg) 4%, transparent); -} - -.attention-settings-row[data-disabled], -.attention-settings-delay[data-disabled] { - opacity: 0.5; -} - -.attention-settings-row-icon { - display: inline-flex; - width: 29px; - height: 29px; - align-items: center; - justify-content: center; - color: color-mix(in srgb, var(--color-accent) 43%, var(--color-muted-fg)); - border: 1px solid color-mix(in srgb, var(--color-border) 68%, transparent); - border-radius: 8px; - background: color-mix(in srgb, var(--color-bg) 46%, transparent); -} - -.attention-settings-row-copy, -.attention-settings-row-copy > span { - display: flex; - min-width: 0; -} - -.attention-settings-row-copy { - flex-direction: column; -} - -.attention-settings-row-copy > span { - align-items: center; - gap: 6px; -} - -.attention-settings-row-copy strong { - font-size: var(--attn-fs-sm); - font-weight: 630; -} - -.attention-settings-row-copy small { - flex: 0 0 auto; - padding: 2px 5px; - color: color-mix(in srgb, var(--color-accent) 50%, var(--color-fg)); - border-radius: 4px; - background: color-mix(in srgb, var(--color-accent) 10%, transparent); - font-size: var(--attn-fs-2xs); - font-weight: 650; - letter-spacing: 0.02em; -} - -.attention-settings-row-copy em { - margin-top: 3px; - color: color-mix(in srgb, var(--color-muted-fg) 88%, transparent); - font-size: var(--attn-fs-xs); - font-style: normal; - line-height: 1.35; -} - -.attention-settings-switch { - position: relative; - width: 32px; - height: 19px; - flex: 0 0 auto; - padding: 0; - border: 1px solid color-mix(in srgb, var(--color-border) 90%, transparent); - border-radius: 99px; - background: color-mix(in srgb, var(--color-muted) 80%, transparent); - box-shadow: var(--shadow-inset, inset 0 1px 2px rgba(0, 0, 0, 0.18)); - transition: border-color 150ms ease, background 150ms ease; -} - -.attention-settings-switch > span { - position: absolute; - top: 2px; - left: 2px; - width: 13px; - height: 13px; - border-radius: 99px; - background: color-mix(in srgb, var(--color-muted-fg) 82%, white); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); - transition: transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1), background 150ms ease; -} - -.attention-settings-switch[aria-checked="true"] { - border-color: color-mix(in srgb, var(--color-accent) 50%, transparent); - background: color-mix(in srgb, var(--color-accent) 62%, var(--color-accent-deep, var(--color-accent))); -} - -.attention-settings-switch[aria-checked="true"] > span { - background: #fff; - transform: translateX(13px); -} - -.attention-settings-delay select { - width: 132px; - height: 29px; - padding: 0 8px; - color: var(--color-fg); - border: 1px solid var(--attention-hairline); - border-radius: 7px; - background: color-mix(in srgb, var(--color-bg) 62%, var(--color-card)); - font-family: var(--font-sans); - font-size: var(--attn-fs-xs); -} - -.attention-settings-delay select:disabled { - cursor: not-allowed; - opacity: 0.5; -} - -.attention-settings-loading { - display: flex; - min-height: 230px; - align-items: center; - justify-content: center; - gap: 9px; - color: var(--attention-copy-dim); - font-size: var(--attn-fs-md); -} - -.attention-settings-loading > span { - width: 14px; - height: 14px; - border: 2px solid color-mix(in srgb, var(--color-accent) 18%, transparent); - border-top-color: var(--color-accent); - border-radius: 50%; - animation: attention-settings-spin 700ms linear infinite; -} - -.attention-settings-error { - display: flex; - align-items: flex-start; - gap: 7px; - margin: 5px 10px 8px; - padding: 8px 9px; - color: var(--attn-danger); - border: 1px solid color-mix(in srgb, var(--attn-danger) 26%, transparent); - border-radius: 8px; - background: color-mix(in srgb, var(--attn-danger) 7%, transparent); - font-size: var(--attn-fs-xs); - line-height: 1.4; -} - -.attention-settings-error svg { - flex: 0 0 auto; - margin-top: 1px; -} - -.attention-settings-popover > footer { - position: sticky; - bottom: 0; - display: flex; - min-height: 48px; - align-items: center; - gap: 7px; - padding: 9px 10px; - border-top: 1px solid var(--attention-hairline); - background: color-mix(in srgb, var(--color-card) 96%, var(--color-bg)); -} - -.attention-settings-popover > footer > span { - display: inline-flex; - min-width: 0; - flex: 1; - align-items: center; - gap: 5px; - color: color-mix(in srgb, var(--color-muted-fg) 82%, transparent); - font-size: var(--attn-fs-xs); -} - -.attention-settings-popover > footer > button { - height: 29px; - flex: 0 0 auto; - padding: 0 11px; - color: var(--color-muted-fg); - border: 1px solid transparent; - border-radius: 7px; - background: transparent; - font-size: var(--attn-fs-sm); - font-weight: 610; -} - -.attention-settings-popover > footer > button:hover { - color: var(--color-fg); - background: color-mix(in srgb, var(--color-fg) 5%, transparent); -} - -.attention-settings-popover > footer > .attention-settings-save { - color: #fff; - border-color: color-mix(in srgb, var(--color-accent) 55%, transparent); - background: color-mix(in srgb, var(--color-accent) 74%, var(--color-accent-deep, var(--color-accent))); - box-shadow: 0 7px 18px -10px var(--color-accent); -} - -.attention-settings-popover > footer > .attention-settings-save:hover { - color: #fff; - background: color-mix(in srgb, var(--color-accent) 88%, var(--color-accent-deep, var(--color-accent))); -} - -.attention-settings-popover > footer > button:disabled { - opacity: 0.45; - pointer-events: none; -} - -/* Link out to the canonical settings surface. The popover is three quick - toggles; the full delivery model lives in Settings > Notifications. */ -.attention-settings-open-full { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - width: 100%; - margin-top: 4px; - padding: 8px 10px; - font-size: 12px; - font-weight: 500; - color: var(--color-secondary-fg); - background: color-mix(in srgb, var(--color-fg) 4%, transparent); - border: 1px solid color-mix(in srgb, var(--color-border) 85%, transparent); - border-radius: 9px; - cursor: pointer; - transition: background 120ms ease, color 120ms ease; -} - -.attention-settings-open-full:hover { - color: var(--color-fg); - background: color-mix(in srgb, var(--color-fg) 8%, transparent); -} - -/* ── Focus and motion ───────────────────────────────────────────────── */ - -.attention-center button:focus-visible, -.attention-center select:focus-visible, -.attention-center [role="dialog"]:focus-visible { - outline: 2px solid color-mix(in srgb, var(--color-accent) 72%, var(--color-fg)); - outline-offset: 2px; -} - -.attention-item-row:focus-visible { - outline-offset: -2px; -} - -@keyframes attention-settings-spin { - to { transform: rotate(360deg); } -} - -/* Opacity only. A scaling dot reads as a throb on a surface that is meant to - sit in the corner of your eye all day. */ -@keyframes attention-status-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } -} - -/* ── Responsive ─────────────────────────────────────────────────────── */ - -@media (min-width: 1600px) { - .attention-layout { - gap: 12px; - padding: 12px; - } -} - -@media (max-width: 900px) { - .attention-layout { - grid-template-columns: minmax(270px, 40%) minmax(0, 1fr); - } - - .attention-freshness, - .attention-toolbar-hint { - display: none; - } -} - -/* Below this the detail card cannot hold a hero, an action row and three - sections in a ~400px column, so the panes stack and each scrolls on its own - instead of clipping path-shaped titles. */ -@media (max-width: 820px) { - .attention-layout { - grid-template-columns: minmax(0, 1fr); - grid-template-rows: minmax(170px, 42%) minmax(0, 1fr); - } - - .attention-detail-hero { - padding-top: 16px; - } -} - -@media (max-width: 700px) { - .attention-header { - min-height: 62px; - padding-right: 12px; - padding-left: 12px; - } - - .attention-title-lockup p { - display: none; - } - - .attention-scope-button { - width: 148px; - } - - .attention-toolbar { - padding-right: 12px; - padding-left: 12px; - } - - .attention-layout { - gap: 6px; - padding: 6px; - } - - .attention-roster-panel, - .attention-detail-panel { - border-radius: var(--attn-radius-card); - } -} - -@media (prefers-reduced-motion: reduce) { - .attention-center *, - .attention-center *::before, - .attention-center *::after { - scroll-behavior: auto !important; - animation-duration: 0.001ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.001ms !important; - } - - .attention-phase-dot-active { - animation: none; - } -} diff --git a/apps/desktop/src/renderer/components/attention/AttentionCenter.test.tsx b/apps/desktop/src/renderer/components/attention/AttentionCenter.test.tsx deleted file mode 100644 index 03249efe9..000000000 --- a/apps/desktop/src/renderer/components/attention/AttentionCenter.test.tsx +++ /dev/null @@ -1,667 +0,0 @@ -// @vitest-environment jsdom - -import React from "react"; -import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { - ATTENTION_CONTRACT_VERSION, - DEFAULT_ATTENTION_PREFERENCES, - type AttentionItem, -} from "../../../shared/types"; -import { - attentionStore, - resetAttentionStoreForTests, -} from "../../state/attentionStore"; -import { - publishAccountStatus, - SIGNED_OUT_ACCOUNT, -} from "../../lib/account"; -import { - AttentionCenter, -} from "./AttentionCenter"; -import { - attentionNotchSettingsFromPreferences, - onAttentionNotchSettingsChanged, - persistAttentionNotchSettings, - readAttentionNotchEnabled, - readAttentionNotchPresentation, - writeAttentionNotchEnabled, - writeAttentionNotchPresentation, -} from "./attentionNotchLocalSettings"; - -const originalAde = window.ade; -const signedInAccount = { - signedIn: true as const, - userId: "account-a", - email: null, - name: null, - expiresAt: null, - provider: null, - imageUrl: null, -}; - -beforeEach(() => { - publishAccountStatus(signedInAccount); - Object.defineProperty(window, "ade", { - configurable: true, - writable: true, - value: { - ...(originalAde ?? {}), - account: { - ...(originalAde?.account ?? {}), - status: vi.fn(async () => signedInAccount), - }, - }, - }); -}); - -function item( - id: string, - patch: Partial = {}, -): AttentionItem { - return { - contractVersion: ATTENTION_CONTRACT_VERSION, - id, - revision: 1, - fingerprint: `fingerprint-${id}`, - kind: "agent", - eventKind: "agent_needs_you", - phase: "needs_you", - machine: { - machineKey: "studio", - name: "Studio Mac", - online: true, - lastSeenAt: "2026-07-28T14:00:00.000Z", - }, - project: { projectId: "ade", name: "ADE", rootPath: "/repo/ade" }, - provider: "codex", - model: "GPT-5", - title: `Task ${id}`, - preview: "Waiting for a safe decision", - privacyPreview: "Agent needs your attention", - detail: "The agent reached an approval checkpoint.", - recentActivity: ["Edited AuthService.ts", "Ran focused tests"], - planProgress: { completed: 2, total: 4, current: "Verify the approval flow" }, - destination: { kind: "session", sessionId: `session-${id}` }, - actions: [ - { id: `approve-${id}`, kind: "approve", label: "Approve" }, - { id: `deny-${id}`, kind: "deny", label: "Deny" }, - ], - occurredAt: "2026-07-28T14:00:00.000Z", - updatedAt: "2026-07-28T14:00:00.000Z", - seenAt: null, - dismissedAt: null, - expiresAt: null, - ...patch, - }; -} - -afterEach(() => { - cleanup(); - resetAttentionStoreForTests(); - publishAccountStatus(SIGNED_OUT_ACCOUNT); - window.localStorage.removeItem("ade:attention:notch-enabled"); - window.localStorage.removeItem("ade:attention:notch-reveal-mode"); - window.localStorage.removeItem("ade:attention:notch-expanded-panel"); - Object.defineProperty(window, "ade", { - configurable: true, - writable: true, - value: originalAde, - }); -}); - -describe("AttentionCenter", () => { - // Notch presentation (reveal mode, expanded panel) moved to - // Settings > Notifications; its coverage lives in - // settings/NotificationsSection.test.tsx. - - it("opens exact context before acknowledging an unhandled action", async () => { - const online = item("approval"); - attentionStore.setState({ - itemsById: { [online.id]: online }, - generatedAt: "2026-07-28T14:00:00.000Z", - }); - let finishOpening: () => void = () => {}; - const openItem = vi.fn(() => new Promise((resolve) => { - finishOpening = resolve; - })); - - render(); - - expect(screen.getByRole("heading", { name: "Task approval" })).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "Open to approve" })); - await waitFor(() => expect(openItem).toHaveBeenCalledWith( - expect.objectContaining({ - id: online.id, - destination: online.destination, - }), - )); - expect(attentionStore.getState().itemsById.approval?.seenAt).toBeNull(); - - await act(async () => { - finishOpening(); - await Promise.resolve(); - }); - await waitFor(() => { - expect(attentionStore.getState().itemsById.approval?.seenAt).not.toBeNull(); - }); - }); - - it("keeps failed navigation unseen and explains how opening failed", async () => { - const remote = item("unreachable"); - attentionStore.setState({ itemsById: { [remote.id]: remote } }); - const openItem = vi.fn(async () => { - throw new Error("Studio Mac stopped responding."); - }); - - render(); - fireEvent.click(screen.getByRole("button", { name: "Open to approve" })); - - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain( - "Studio Mac stopped responding.", - ); - }); - expect(attentionStore.getState().itemsById.unreachable?.seenAt).toBeNull(); - }); - - it("keeps remote actions disabled for last-known offline work", () => { - const offline = item("offline", { - machine: { - machineKey: "cloud", - name: "Cloud Mac", - online: false, - lastSeenAt: "2026-07-28T13:00:00.000Z", - }, - }); - attentionStore.setState({ itemsById: { [offline.id]: offline } }); - - render(); - - expect(screen.getByText("Cloud Mac is offline.")).toBeTruthy(); - expect( - (screen.getByRole("button", { name: "Open to approve" }) as HTMLButtonElement).disabled, - ).toBe(true); - expect((screen.getByRole("button", { name: "Open" }) as HTMLButtonElement).disabled).toBe(true); - }); - - it("applies project lenses and offers a one-click clear affordance", () => { - const ade = item("ade"); - const versic = item("versic", { - project: { projectId: "versic", name: "Versic", rootPath: "/repo/versic" }, - title: "Task Versic", - }); - attentionStore.setState({ - itemsById: { [ade.id]: ade, [versic.id]: versic }, - }); - - render(); - fireEvent.click(screen.getByRole("button", { name: "All machines" })); - fireEvent.click(screen.getByRole("menuitemradio", { name: "Versic" })); - - return waitFor(() => { - expect(screen.getByRole("heading", { name: "Task Versic" })).toBeTruthy(); - }).then(() => { - expect(screen.queryByRole("heading", { name: "Task ade" })).toBeNull(); - fireEvent.click(screen.getByTitle("Clear scope")); - expect(attentionStore.getState().scope).toEqual({ kind: "all" }); - }); - }); - - it("rolls back a failed acknowledgement and explains the failure", async () => { - const approval = item("rollback"); - let rejectAcknowledgement: (error: Error) => void = () => {}; - const acknowledge = vi.fn(() => new Promise((_resolve, reject) => { - rejectAcknowledgement = reject; - })); - Object.defineProperty(window, "ade", { - configurable: true, - writable: true, - value: { - ...(window.ade ?? {}), - attention: { - acknowledge, - getSnapshot: vi.fn(), - reportPresence: vi.fn(), - getPreferences: vi.fn(), - putPreferences: vi.fn(), - }, - }, - }); - attentionStore.setState({ itemsById: { [approval.id]: approval } }); - render(); - - fireEvent.click(screen.getByRole("button", { name: /Task rollback/ })); - expect(attentionStore.getState().itemsById.rollback?.seenAt).not.toBeNull(); - - await act(async () => { - rejectAcknowledgement(new Error("Relay is temporarily unavailable.")); - await Promise.resolve(); - }); - - await waitFor(() => { - expect(attentionStore.getState().itemsById.rollback?.seenAt).toBeNull(); - expect(screen.getByRole("alert").textContent).toContain("Relay is temporarily unavailable."); - }); - }); - - it("contains a rejected detail acknowledgement after rolling it back", async () => { - const approval = item("detail-rollback"); - const acknowledge = vi.fn(async () => { - throw new Error("Relay rejected the acknowledgement."); - }); - Object.defineProperty(window, "ade", { - configurable: true, - writable: true, - value: { - ...(window.ade ?? {}), - attention: { - acknowledge, - getSnapshot: vi.fn(), - reportPresence: vi.fn(), - getPreferences: vi.fn(), - putPreferences: vi.fn(), - }, - }, - }); - attentionStore.setState({ itemsById: { [approval.id]: approval } }); - render(); - - fireEvent.click(screen.getByTitle("Mark as seen")); - - await waitFor(() => { - expect(acknowledge).toHaveBeenCalledWith({ - itemIds: [approval.id], - sourceRevisions: { [approval.id]: approval.revision }, - expectedAccountOwnerId: null, - seenAt: expect.any(String), - }); - expect(attentionStore.getState().itemsById[approval.id]?.seenAt).toBeNull(); - expect(screen.getByRole("alert").textContent).toContain( - "Relay rejected the acknowledgement.", - ); - }); - }); - - it("walks the roster with arrow keys and exposes it as a single tab stop", () => { - const first = item("first"); - const second = item("second", { updatedAt: "2026-07-28T13:59:00.000Z" }); - attentionStore.setState({ itemsById: { [first.id]: first, [second.id]: second } }); - - const { container } = render(); - const rows = Array.from( - container.querySelectorAll("[data-attention-item]"), - ); - - expect(rows).toHaveLength(2); - expect(rows.filter((row) => row.tabIndex === 0)).toHaveLength(1); - - rows[0].focus(); - fireEvent.keyDown(rows[0], { key: "ArrowDown" }); - expect(document.activeElement).toBe(rows[1]); - - fireEvent.keyDown(rows[1], { key: "ArrowUp" }); - expect(document.activeElement).toBe(rows[0]); - - fireEvent.keyDown(rows[0], { key: "End" }); - expect(document.activeElement).toBe(rows[1]); - }); - - it("moves focus into the scope menu and hands it back when dismissed", () => { - const only = item("scoped"); - attentionStore.setState({ itemsById: { [only.id]: only } }); - - render(); - const trigger = screen.getByRole("button", { name: "All machines" }); - fireEvent.click(trigger); - - const options = screen.getAllByRole("menuitemradio"); - expect(document.activeElement).toBe(options[0]); - - fireEvent.keyDown(options[0], { key: "ArrowDown" }); - expect(document.activeElement).toBe(options[1]); - - fireEvent.keyDown(options[1], { key: "Escape" }); - expect(trigger.getAttribute("aria-expanded")).toBe("false"); - expect(document.activeElement).toBe(trigger); - }); - - it("returns focus to the settings trigger when the popover is dismissed", async () => { - Object.defineProperty(window, "ade", { - configurable: true, - writable: true, - value: { - ...(window.ade ?? {}), - attention: { - getSnapshot: vi.fn(), - acknowledge: vi.fn(), - reportPresence: vi.fn(), - getPreferences: vi.fn(async () => DEFAULT_ATTENTION_PREFERENCES), - putPreferences: vi.fn(), - }, - }, - }); - - render(); - const trigger = screen.getByRole("button", { name: "Attention settings" }); - fireEvent.click(trigger); - - const dialog = await screen.findByRole("dialog", { name: "Attention settings" }); - expect(document.activeElement).toBe(dialog); - - fireEvent.keyDown(document, { key: "Escape" }); - await waitFor(() => { - expect(screen.queryByRole("dialog", { name: "Attention settings" })).toBeNull(); - }); - await waitFor(() => expect(document.activeElement).toBe(trigger)); - }); - - it("saves account delivery preferences and keeps ADE Notch device-local", async () => { - const putPreferences = vi.fn(async () => undefined); - const updateSettings = vi.fn(async () => undefined); - Object.defineProperty(window, "ade", { - configurable: true, - writable: true, - value: { - ...(window.ade ?? {}), - attention: { - getSnapshot: vi.fn(), - acknowledge: vi.fn(), - reportPresence: vi.fn(), - getPreferences: vi.fn(async () => DEFAULT_ATTENTION_PREFERENCES), - putPreferences, - }, - attentionNotch: { - publishSnapshot: vi.fn(), - updateSettings, - onAcknowledgeRequested: vi.fn(() => () => {}), - }, - }, - }); - render(); - - fireEvent.click(screen.getByRole("button", { name: "Attention settings" })); - await screen.findByRole("dialog", { name: "Attention settings" }); - await waitFor(() => { - expect(screen.getByRole("switch", { name: "Sounds" })).toBeTruthy(); - }); - - fireEvent.click(screen.getByRole("switch", { name: "ADE Notch" })); - fireEvent.click(screen.getByRole("switch", { name: "Sounds" })); - fireEvent.click(screen.getByRole("button", { name: "Save" })); - - // Escalation, quiet hours, and per-event policies moved to - // Settings > Notifications; the popover keeps three quick toggles. - expect(screen.queryByRole("combobox", { name: "Phone escalation" })).toBeNull(); - - await waitFor(() => { - expect(putPreferences).toHaveBeenCalledWith( - "account-a", - expect.objectContaining({ - account: expect.objectContaining({ - soundsEnabled: true, - }), - }), - ); - expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ - enabled: false, - soundsEnabled: true, - })); - expect(window.localStorage.getItem("ade:attention:notch-enabled")).toBe("false"); - }); - }); - - // Off, the three reveal modes, and the expanded-panel switch are the whole - // presentation contract; they only mean anything if they reach the helper. - - - it("clears a closed save without letting its stale result interrupt a replacement", async () => { - let resolveStaleSave: () => void = () => {}; - const staleSave = new Promise((resolve) => { - resolveStaleSave = resolve; - }); - let resolveReplacementSave: () => void = () => {}; - const replacementSave = new Promise((resolve) => { - resolveReplacementSave = resolve; - }); - const putPreferences = vi.fn() - .mockImplementationOnce(() => staleSave) - .mockImplementationOnce(() => replacementSave); - const updateSettings = vi.fn(async () => undefined); - Object.defineProperty(window, "ade", { - configurable: true, - writable: true, - value: { - ...(window.ade ?? {}), - attention: { - getSnapshot: vi.fn(), - acknowledge: vi.fn(), - reportPresence: vi.fn(), - getPreferences: vi.fn(async () => DEFAULT_ATTENTION_PREFERENCES), - putPreferences, - }, - attentionNotch: { - publishSnapshot: vi.fn(), - updateSettings, - onAcknowledgeRequested: vi.fn(() => () => {}), - }, - }, - }); - render(); - - const trigger = screen.getByRole("button", { name: "Attention settings" }); - fireEvent.click(trigger); - await waitFor(() => { - expect(screen.getByRole("button", { name: "Save" })).toBeTruthy(); - }); - fireEvent.click(screen.getByRole("button", { name: "Save" })); - await waitFor(() => { - expect(screen.getByRole("button", { name: "Saving…" })).toBeTruthy(); - }); - fireEvent.click(screen.getByRole("button", { name: "Cancel" })); - await waitFor(() => { - expect(screen.queryByRole("dialog", { name: "Attention settings" })).toBeNull(); - }); - - fireEvent.click(trigger); - await waitFor(() => { - expect( - (screen.getByRole("button", { name: "Save" }) as HTMLButtonElement).disabled, - ).toBe(false); - }); - fireEvent.click(screen.getByRole("button", { name: "Save" })); - await waitFor(() => { - expect(putPreferences).toHaveBeenCalledTimes(2); - expect(screen.getByRole("button", { name: "Saving…" })).toBeTruthy(); - }); - - await act(async () => { - resolveStaleSave(); - await staleSave; - }); - - expect(updateSettings).not.toHaveBeenCalled(); - expect(screen.getByRole("button", { name: "Saving…" })).toBeTruthy(); - - await act(async () => { - resolveReplacementSave(); - await replacementSave; - }); - await waitFor(() => { - expect(updateSettings).toHaveBeenCalledTimes(1); - expect(screen.getByText("Saved")).toBeTruthy(); - }); - }); - - it("does not apply an earlier account's delayed preferences after switching accounts", async () => { - let resolveAccountA: (preferences: typeof DEFAULT_ATTENTION_PREFERENCES) => void = - () => {}; - const accountAPreferences = new Promise((resolve) => { - resolveAccountA = resolve; - }); - const accountBPreferences = { - ...DEFAULT_ATTENTION_PREFERENCES, - account: { - ...DEFAULT_ATTENTION_PREFERENCES.account, - notificationsEnabled: false, - hideDetails: false, - }, - }; - const putPreferences = vi.fn(async () => undefined); - const getPreferences = vi - .fn() - .mockImplementationOnce(() => accountAPreferences) - .mockResolvedValueOnce(accountBPreferences); - Object.defineProperty(window, "ade", { - configurable: true, - writable: true, - value: { - ...(window.ade ?? {}), - attention: { - getSnapshot: vi.fn(), - acknowledge: vi.fn(), - reportPresence: vi.fn(), - getPreferences, - putPreferences, - }, - }, - }); - render(); - - fireEvent.click(screen.getByRole("button", { name: "Attention settings" })); - await waitFor(() => expect(getPreferences).toHaveBeenCalledTimes(1)); - - act(() => { - publishAccountStatus({ - signedIn: true, - userId: "account-b", - email: null, - name: null, - expiresAt: null, - provider: null, - imageUrl: null, - }); - }); - await waitFor(() => { - expect(screen.queryByRole("dialog", { name: "Attention settings" })).toBeNull(); - }); - - fireEvent.click(screen.getByRole("button", { name: "Attention settings" })); - await screen.findByRole("dialog", { name: "Attention settings" }); - await waitFor(() => { - expect(getPreferences).toHaveBeenCalledTimes(2); - expect( - screen.getByRole("switch", { name: "Phone notifications" }) - .getAttribute("aria-checked"), - ).toBe("false"); - }); - - await act(async () => { - resolveAccountA({ - ...DEFAULT_ATTENTION_PREFERENCES, - account: { - ...DEFAULT_ATTENTION_PREFERENCES.account, - notificationsEnabled: true, - hideDetails: true, - }, - }); - await accountAPreferences; - }); - - expect( - screen.getByRole("switch", { name: "Phone notifications" }) - .getAttribute("aria-checked"), - ).toBe("false"); - fireEvent.click(screen.getByRole("button", { name: "Save" })); - - await waitFor(() => { - expect(putPreferences).toHaveBeenCalledWith("account-b", accountBPreferences); - }); - }); -}); - -describe("attention notch local settings", () => { - beforeEach(() => { - window.localStorage.clear(); - }); - - it("defaults safely and falls back from an unreadable reveal mode", () => { - expect(readAttentionNotchEnabled()).toBe(true); - expect(readAttentionNotchPresentation()).toEqual({ - revealMode: "hover", - expandedPanelEnabled: true, - }); - - window.localStorage.setItem("ade:attention:notch-reveal-mode", "telepathy"); - expect(readAttentionNotchPresentation().revealMode).toBe("hover"); - }); - - it("round-trips every presentation mode independently from full disable", () => { - for (const revealMode of ["minimal", "hover", "click"] as const) { - writeAttentionNotchPresentation({ revealMode, expandedPanelEnabled: false }); - expect(readAttentionNotchPresentation()).toEqual({ - revealMode, - expandedPanelEnabled: false, - }); - } - writeAttentionNotchEnabled(false); - - expect(attentionNotchSettingsFromPreferences(DEFAULT_ATTENTION_PREFERENCES)) - .toMatchObject({ - enabled: false, - revealMode: "click", - expandedPanelEnabled: false, - }); - }); - - it("persists native context-menu changes and notifies the renderer", () => { - let observed: ReturnType | null = null; - const unsubscribe = onAttentionNotchSettingsChanged((settings) => { - observed = settings; - }); - persistAttentionNotchSettings({ - enabled: false, - revealMode: "minimal", - expandedPanelEnabled: false, - preferredDisplayId: null, - hideDetails: true, - celebrationsEnabled: true, - soundsEnabled: false, - }); - - expect(readAttentionNotchEnabled()).toBe(false); - expect(readAttentionNotchPresentation()).toEqual({ - revealMode: "minimal", - expandedPanelEnabled: false, - }); - expect(observed).toMatchObject({ - enabled: false, - revealMode: "minimal", - expandedPanelEnabled: false, - }); - unsubscribe(); - }); - it("links to Settings through the navigation bus, not the router", async () => { - // The attention subtree is mounted outside the router here (and in the - // notch), so the link must dispatch an app-navigation target rather than - // calling useNavigate — which would throw "may be used only in the context - // of a ". - const targets: unknown[] = []; - const onNavigate = (event: Event) => { - targets.push((event as CustomEvent).detail?.target); - }; - window.addEventListener("ade:navigate-target", onNavigate); - try { - render(); - fireEvent.click(screen.getByRole("button", { name: "Attention settings" })); - await screen.findByRole("dialog", { name: "Attention settings" }); - - fireEvent.click(await screen.findByRole("button", { name: /All notification settings/ })); - - expect(targets).toEqual([{ kind: "settings", tab: "notifications" }]); - } finally { - window.removeEventListener("ade:navigate-target", onNavigate); - } - }); -}); diff --git a/apps/desktop/src/renderer/components/attention/AttentionCenter.tsx b/apps/desktop/src/renderer/components/attention/AttentionCenter.tsx deleted file mode 100644 index 2a5f06212..000000000 --- a/apps/desktop/src/renderer/components/attention/AttentionCenter.tsx +++ /dev/null @@ -1,1154 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { - AnimatePresence, - LayoutGroup, - MotionConfig, - motion, - useReducedMotion, -} from "motion/react"; -import { - ArrowClockwise, - ArrowSquareOut, - BellRinging, - CaretDown, - Check, - CheckCircle, - ClockCounterClockwise, - DesktopTower, - FunnelSimple, - GitPullRequest, - Lightning, - ListChecks, - RadioButton, - Sparkle, - Tray, - WarningCircle, - WifiHigh, - WifiSlash, - X, - XCircle, -} from "@phosphor-icons/react"; - -import { - attentionDestinationDeepLink, - type AttentionAction, - type AttentionItem, - type AttentionMachineRef, - type AttentionProjectRef, -} from "../../../shared/types"; -import { relativeWhen } from "../../lib/format"; -import { openAdeDeeplink } from "../../lib/openExternal"; -import { - acknowledgeAttentionItem, - selectAttentionCounts, - selectAttentionItems, - useAttentionStore, - type AttentionScope, - type AttentionView, -} from "../../state/attentionStore"; -import { ProviderLogo } from "../shared/ProviderLogos"; -import { cn } from "../ui/cn"; -import { - attentionActionTone, - attentionPhasePresentation, - attentionViewEmptyCopy, - type AttentionTone, -} from "./attentionPresentation"; -import { AttentionSettingsPopover } from "./AttentionSettingsPopover"; -import { refreshAttentionSnapshot } from "./useAttentionSync"; -import "./AttentionCenter.css"; - -type AttentionCenterProps = { - onAction?: (item: AttentionItem, action: AttentionAction) => void | Promise; - onOpenItem?: (item: AttentionItem) => void | Promise; -}; - -type ProjectGroup = { - project: AttentionProjectRef; - items: AttentionItem[]; -}; - -type MachineGroup = { - machine: AttentionMachineRef; - projects: ProjectGroup[]; - itemCount: number; -}; - -const VIEW_CONFIG: Array<{ - id: AttentionView; - label: string; - icon: React.ElementType; -}> = [ - { id: "live", label: "Live", icon: RadioButton }, - { id: "inbox", label: "Inbox", icon: Tray }, - { id: "recent", label: "Recent", icon: ClockCounterClockwise }, -]; - -function groupItems(items: readonly AttentionItem[]): MachineGroup[] { - const machines = new Map(); - for (const item of items) { - let machine = machines.get(item.machine.machineKey); - if (!machine) { - machine = { machine: item.machine, projects: [], itemCount: 0 }; - machines.set(item.machine.machineKey, machine); - } - machine.itemCount += 1; - let project = machine.projects.find( - (entry) => entry.project.projectId === item.project.projectId, - ); - if (!project) { - project = { project: item.project, items: [] }; - machine.projects.push(project); - } - project.items.push(item); - } - return [...machines.values()].sort((left, right) => { - if (left.machine.online !== right.machine.online) return left.machine.online ? -1 : 1; - return left.machine.name.localeCompare(right.machine.name); - }); -} - -function toneClass(tone: AttentionTone): string { - return `attention-tone-${tone}`; -} - -function itemIcon(item: AttentionItem, size: number): React.ReactNode { - if (item.kind === "pull_request") { - return ; - } - return ; -} - -function actionIcon(action: AttentionAction): React.ElementType { - if (action.kind === "approve") return Check; - if (action.kind === "deny") return X; - if (action.kind === "restart" || action.kind === "rerun_checks") return ArrowClockwise; - if (action.kind === "open") return ArrowSquareOut; - if (action.kind === "dismiss") return XCircle; - if (action.kind === "mark_seen") return CheckCircle; - return Lightning; -} - -function itemSupportsActionOffline(action: AttentionAction): boolean { - return action.kind === "mark_seen" || action.kind === "dismiss"; -} - -function navigationErrorMessage(error: unknown): string { - if (error instanceof Error && error.message.trim()) return error.message.trim(); - return "ADE couldn’t open the exact machine and project for this item."; -} - -function scopeLabel(scope: AttentionScope): string { - return scope.kind === "all" ? "All machines" : scope.label; -} - -const ROSTER_PANEL_ID = "attention-roster-panel"; - -function tabDomId(view: AttentionView): string { - return `attention-tab-${view}`; -} - -/** Moves focus within a group of controls, wrapping at both ends. */ -function focusRelative(elements: HTMLElement[], from: Element | null, delta: number): void { - if (elements.length === 0) return; - const current = elements.indexOf(from as HTMLElement); - const next = current < 0 - ? 0 - : (current + delta + elements.length) % elements.length; - elements[next]?.focus(); -} - -function AttentionTabs({ - view, - counts, - onChange, -}: { - view: AttentionView; - counts: Record; - onChange: (view: AttentionView) => void; -}) { - // A tablist is a single tab stop: arrows move between tabs, Tab leaves the group. - const onKeyDown = (event: React.KeyboardEvent) => { - const delta = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0; - const tabs = Array.from( - event.currentTarget.querySelectorAll('[role="tab"]'), - ); - if (delta !== 0) { - event.preventDefault(); - focusRelative(tabs, document.activeElement, delta); - return; - } - if (event.key === "Home" || event.key === "End") { - event.preventDefault(); - (event.key === "Home" ? tabs[0] : tabs[tabs.length - 1])?.focus(); - } - }; - - return ( -
- {VIEW_CONFIG.map((entry) => { - const active = view === entry.id; - const count = counts[entry.id]; - return ( - - ); - })} -
- ); -} - -function ScopePicker({ - scope, - allItems, - onChange, -}: { - scope: AttentionScope; - allItems: AttentionItem[]; - onChange: (scope: AttentionScope) => void; -}) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - const triggerRef = useRef(null); - const options = useMemo(() => groupItems(allItems), [allItems]); - - useEffect(() => { - if (!open) return; - const close = (event: PointerEvent) => { - if (!ref.current?.contains(event.target as Node)) setOpen(false); - }; - window.addEventListener("pointerdown", close); - return () => window.removeEventListener("pointerdown", close); - }, [open]); - - // Opening a menu should land focus inside it, and closing it should hand - // focus back to the trigger rather than dropping the user at the document. - useEffect(() => { - if (!open) return; - const menu = ref.current?.querySelector('[role="menu"]'); - if (!menu) return; - const checked = menu.querySelector('[aria-checked="true"]'); - (checked ?? menu.querySelector('[role="menuitemradio"]'))?.focus(); - }, [open]); - - const closeMenu = (returnFocus: boolean) => { - setOpen(false); - if (returnFocus) triggerRef.current?.focus(); - }; - - const onMenuKeyDown = (event: React.KeyboardEvent) => { - if (event.key === "Escape") { - event.preventDefault(); - closeMenu(true); - return; - } - if (event.key === "Tab") { - setOpen(false); - return; - } - const delta = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0; - const items = Array.from( - event.currentTarget.querySelectorAll('[role="menuitemradio"]'), - ); - if (delta !== 0) { - event.preventDefault(); - focusRelative(items, document.activeElement, delta); - return; - } - if (event.key === "Home" || event.key === "End") { - event.preventDefault(); - (event.key === "Home" ? items[0] : items[items.length - 1])?.focus(); - } - }; - - return ( -
- - - {open ? ( - - - {options.map((machine) => ( -
- - {machine.projects.map((project) => ( - - ))} -
- ))} -
- ) : null} -
-
- ); -} - -function PhasePill({ item }: { item: AttentionItem }) { - const phase = attentionPhasePresentation(item.phase); - return ( - - - {phase.label} - - ); -} - -function AttentionItemRow({ - item, - selected, - tabbable, - reducedMotion, - onSelect, -}: { - item: AttentionItem; - selected: boolean; - tabbable: boolean; - reducedMotion: boolean; - onSelect: () => void; -}) { - const phase = attentionPhasePresentation(item.phase); - return ( - - {selected ? ( - - ) : null} - - {itemIcon(item, 17)} - - - - {item.title} - - - {item.preview} - - - {item.laneName ? {item.laneName} : null} - {item.model ? {item.model} : null} - - - {!item.seenAt ? : null} - - ); -} - -function AttentionRoster({ - items, - selectedId, - focusId, - reducedMotion, - onSelect, -}: { - items: AttentionItem[]; - selectedId: string | null; - focusId: string | null; - reducedMotion: boolean; - onSelect: (item: AttentionItem) => void; -}) { - const groups = useMemo(() => groupItems(items), [items]); - - // The roster is one tab stop; arrows walk the rows across machine and project - // groups, and Enter/Space (native button behaviour) opens the focused row. - const onKeyDown = (event: React.KeyboardEvent) => { - const delta = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0; - const rows = Array.from( - event.currentTarget.querySelectorAll("[data-attention-item]"), - ); - if (delta !== 0) { - event.preventDefault(); - focusRelative(rows, document.activeElement, delta); - return; - } - if (event.key === "Home" || event.key === "End") { - event.preventDefault(); - (event.key === "Home" ? rows[0] : rows[rows.length - 1])?.focus(); - } - }; - - return ( -
- - {groups.map((machine) => ( - -
- - - - - {machine.machine.name} - - {machine.machine.online - ? "Online now" - : machine.machine.lastSeenAt - ? `Offline · ${relativeWhen(machine.machine.lastSeenAt)}` - : "Offline"} - - - - {machine.itemCount} -
- {machine.projects.map((project) => ( -
-
- - {project.project.name.slice(0, 1).toUpperCase()} - - {project.project.name} - {project.items.length} -
-
- {project.items.map((item) => ( - onSelect(item)} - /> - ))} -
-
- ))} -
- ))} -
-
- ); -} - -function EmptyAttention({ - view, - scoped, - syncError, - onClearScope, - onRetry, -}: { - view: AttentionView; - scoped: boolean; - syncError: string | null; - onClearScope: () => void; - onRetry: () => void; -}) { - const copy = attentionViewEmptyCopy(view); - const Icon = view === "inbox" ? CheckCircle : view === "recent" ? ClockCounterClockwise : Sparkle; - if (syncError) { - return ( - - - - - Couldn’t sync Attention -

{syncError}

- -
- ); - } - return ( - - - {scoped ? "Nothing in this filter" : copy.title} -

{scoped ? "Clear it to see every machine and project." : copy.body}

- {scoped ? ( - - ) : null} -
- ); -} - -function DetailAction({ - item, - action, - pending, - opensDestination, - onRun, -}: { - item: AttentionItem; - action: AttentionAction; - pending: boolean; - opensDestination: boolean; - onRun: () => void; -}) { - const Icon = actionIcon(action); - const disabled = pending || (!item.machine.online && !itemSupportsActionOffline(action)); - const label = opensDestination - && action.kind !== "open" - && action.kind !== "mark_seen" - && action.kind !== "dismiss" - ? `Open to ${action.label.toLocaleLowerCase()}` - : action.label; - return ( - - - {pending ? "Working…" : label} - - ); -} - -function AttentionDetail({ - item, - pendingActionId, - acknowledgementError, - navigationError, - opensDestinationForActions, - onAction, -}: { - item: AttentionItem; - pendingActionId: string | null; - acknowledgementError: string | null; - navigationError: string | null; - opensDestinationForActions: boolean; - onAction: (action: AttentionAction) => void; -}) { - const phase = attentionPhasePresentation(item.phase); - const actions = item.actions.some((action) => action.kind === "open") - ? item.actions - : [ - ...item.actions, - { id: `open:${item.id}`, kind: "open" as const, label: "Open" }, - ]; - const primaryActions = actions.filter( - (action) => action.kind !== "mark_seen" && action.kind !== "dismiss", - ); - const planTotal = Math.max(0, item.planProgress?.total ?? 0); - const planCompleted = Math.min(planTotal, Math.max(0, item.planProgress?.completed ?? 0)); - const planPercent = planTotal > 0 ? Math.round((planCompleted / planTotal) * 100) : 0; - - return ( - -
-
-
- - {item.machine.online ? : } - - {item.machine.name} - / - {item.project.name} - {item.laneName ? ( - <> - / - {item.laneName} - - ) : null} -
-
- {item.seenAt ? ( - Seen - ) : ( - - )} - -
-
- -
- {itemIcon(item, 24)} -
-
- - -
-

{item.title}

-

{item.preview}

-
-
- - {!item.machine.online ? ( -
- - - {item.machine.name} is offline. - This is its last-known state. Remote actions unlock when it reconnects. - -
- ) : null} - - {acknowledgementError ? ( -
- - - That update didn’t stick. - {acknowledgementError} - -
- ) : null} - - {navigationError ? ( -
- - - Couldn’t open this work. - {navigationError} - -
- ) : null} - - {primaryActions.length > 0 ? ( -
- {primaryActions.map((action) => ( - onAction(action)} - /> - ))} -
- ) : null} - -
- {item.detail ? ( -
-
- -

What’s happening

-
-

{item.detail}

-
- ) : null} - - {item.planProgress ? ( -
-
- -

Plan progress

- {planCompleted} of {planTotal} -
-
- -
- {item.planProgress.current ? ( -

- - {item.planProgress.current} -

- ) : null} -
- ) : null} - - {item.recentActivity?.length ? ( -
-
- -

Recent activity

-
-
    - {item.recentActivity.slice(0, 8).map((activity, index) => ( -
  1. - - {activity} -
  2. - ))} -
-
- ) : null} - - {!item.detail && !item.planProgress && !item.recentActivity?.length ? ( -
- -
-

Ready when you are

-

Open it to pick up in the exact session it came from.

-
-
- ) : null} -
- -
- {item.kind === "agent" ? item.provider || "Agent" : "Pull request"} - {item.model ? {item.model} : null} - Updated {relativeWhen(item.updatedAt)} -
- - ); -} - -function DetailPlaceholder() { - return ( -
- - Nothing selected -

Pick an item to see its activity and act on it.

-
- ); -} - -export function AttentionCenter({ onAction, onOpenItem }: AttentionCenterProps = {}) { - const state = useAttentionStore((value) => value); - const { - itemsById, - view, - scope, - selectedItemId, - generatedAt, - syncStatus, - syncError, - acknowledgementErrors, - setView, - setScope, - selectItem, - markSeen, - dismiss, - } = state; - const reducedMotion = useReducedMotion() ?? false; - const [now, setNow] = useState(() => Date.now()); - const [pendingActionId, setPendingActionId] = useState(null); - const [navigationFailure, setNavigationFailure] = useState<{ - itemId: string; - message: string; - } | null>(null); - const allItems = useMemo(() => Object.values(itemsById), [itemsById]); - const visibleItems = useMemo( - () => selectAttentionItems(state, now), - [now, state], - ); - const counts = useMemo( - () => selectAttentionCounts(state, now), - [now, state], - ); - const selectedItem = (selectedItemId ? itemsById[selectedItemId] : null) - ?? visibleItems[0] - ?? null; - // Exactly one row carries tabIndex 0. The selected item can be filtered out - // of the current view, so fall back to the first visible row rather than - // leaving the whole roster unreachable by keyboard. - const rosterFocusId = useMemo(() => { - if (selectedItem && visibleItems.some((entry) => entry.id === selectedItem.id)) { - return selectedItem.id; - } - return visibleItems[0]?.id ?? null; - }, [selectedItem, visibleItems]); - const machineCount = new Set(allItems.map((item) => item.machine.machineKey)).size; - const liveMachineCount = new Set( - allItems.filter((item) => item.machine.online).map((item) => item.machine.machineKey), - ).size; - - useEffect(() => { - const timer = window.setInterval(() => setNow(Date.now()), 30_000); - return () => window.clearInterval(timer); - }, []); - - useEffect(() => { - if (!selectedItem && selectedItemId) selectItem(null); - }, [selectItem, selectedItem, selectedItemId]); - - const openItem = async (item: AttentionItem): Promise => { - setNavigationFailure((current) => current?.itemId === item.id ? null : current); - try { - if (onOpenItem) { - await onOpenItem(item); - } else { - openAdeDeeplink(attentionDestinationDeepLink(item.destination, item)); - } - } catch (error) { - setNavigationFailure({ - itemId: item.id, - message: navigationErrorMessage(error), - }); - return false; - } - // Opening is the user-visible proof that the exact destination resolved. - // Only then may the ambient item leave the unseen state. - await acknowledgeAttentionItem(item.id, "seen").catch(() => {}); - return true; - }; - - const runAction = async (item: AttentionItem, action: AttentionAction) => { - if (pendingActionId) return; - if (action.kind === "open") { - await openItem(item); - return; - } - setPendingActionId(action.id); - try { - if (action.kind === "mark_seen" || action.kind === "dismiss") { - if (onAction) { - if (action.kind === "mark_seen") markSeen(item.id); - else dismiss(item.id); - await onAction(item, action); - } else { - await acknowledgeAttentionItem( - item.id, - action.kind === "dismiss" ? "dismiss" : "seen", - ); - } - } else if (onAction) { - await onAction(item, action); - } else { - await openItem(item); - } - } catch { - // Account acknowledgement helpers already roll back optimistic state and - // expose their bounded error in the detail panel. Keep the click promise - // contained so React event dispatch never produces an unhandled rejection. - } finally { - setPendingActionId(null); - } - }; - - return ( - -
-
-
- -
-
- - - {counts.inbox > 0 ? {Math.min(99, counts.inbox)} : null} - -
-

Attention

-

- {machineCount > 0 - ? `${liveMachineCount} of ${machineCount} machine${machineCount === 1 ? "" : "s"} online` - : "Across every machine on your account"} -

-
-
-
- - {syncStatus === "error" ? ( - - ) : syncStatus === "syncing" ? ( - - - Syncing - - ) : generatedAt ? ( - - - Synced {relativeWhen(generatedAt)} - - ) : null} - -
-
- -
- - {scope.kind !== "all" ? ( - - ) : ( - - - Highest priority first - - )} -
- -
-
-
-
- - {view === "live" ? "In motion" : view === "inbox" ? "Needs review" : "Latest outcomes"} - - {visibleItems.length} item{visibleItems.length === 1 ? "" : "s"} -
- {counts.inbox > 0 && view !== "inbox" ? ( - // "in inbox", not "needs you": this count is the whole inbox — - // failures, review requests and unseen outcomes as well as raised - // hands — and "needs you" is a claim only a needs_you row may - // make. The button jumps to the Inbox tab, which says the same. - - ) : null} -
- {visibleItems.length > 0 ? ( - { - selectItem(item.id); - void acknowledgeAttentionItem(item.id, "seen").catch(() => {}); - }} - /> - ) : ( - setScope({ kind: "all" })} - onRetry={() => void refreshAttentionSnapshot()} - /> - )} -
- -
- - {selectedItem ? ( - void runAction(selectedItem, action)} - /> - ) : ( - - - - )} - -
-
-
- - ); -} - -export default AttentionCenter; diff --git a/apps/desktop/src/renderer/components/attention/AttentionSettingsPopover.tsx b/apps/desktop/src/renderer/components/attention/AttentionSettingsPopover.tsx deleted file mode 100644 index 96b01a8eb..000000000 --- a/apps/desktop/src/renderer/components/attention/AttentionSettingsPopover.tsx +++ /dev/null @@ -1,449 +0,0 @@ -import React, { useEffect, useRef, useState } from "react"; -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { - ArrowSquareOut, - ArrowsOutSimple, - BellRinging, - Check, - Confetti, - CursorClick, - DeviceMobile, - GearSix, - HourglassMedium, - LockKey, - Notches, - SpeakerHigh, - WarningCircle, -} from "@phosphor-icons/react"; - -import { - DEFAULT_ATTENTION_PREFERENCES, - isAttentionNotchRevealMode, - type AttentionNotchRevealMode, - type AttentionPreferences, -} from "../../../shared/types"; -import { - attentionNotchSettingsFromPreferences, - normalizeAttentionPreferences, - onAttentionNotchSettingsChanged, - readAttentionNotchEnabled, - readAttentionNotchPresentation, - writeAttentionNotchEnabled, - writeAttentionNotchPresentation, -} from "./attentionNotchLocalSettings"; -import { useAccountStatus } from "../../lib/account"; -import { navigateToAppTarget } from "../../lib/openExternal"; -// This component now has two mount points — the Attention center and the -// Activity header popover — so it carries its own `.attention-settings-*` -// styles instead of inheriting them from whichever parent happened to be -// mounted first. The import moves with the component when the center's -// stylesheet is retired. -import "./AttentionCenter.css"; - -const DESKTOP_FIRST_OPTIONS = [ - { value: 0, label: "Immediately" }, - { value: 30, label: "After 30 seconds" }, - { value: 120, label: "After 2 minutes" }, - { value: 300, label: "After 5 minutes" }, -] as const; - -const NOTCH_REVEAL_OPTIONS: ReadonlyArray<{ - value: AttentionNotchRevealMode; - label: string; -}> = [ - { value: "minimal", label: "Compact + peek" }, - { value: "hover", label: "Reveal on hover" }, - { value: "click", label: "Click only" }, -]; - -const NOTCH_REVEAL_HELP: Record = { - minimal: "Keep a tiny status visible; hover or click for a short peek.", - hover: "Hide the surface until the pointer reaches the top-edge hot zone.", - click: "Keep the compact status visible and expand only when clicked.", -}; - -type ToggleRowProps = { - icon: React.ElementType; - label: string; - description: string; - checked: boolean; - disabled?: boolean; - badge?: string; - onChange: (checked: boolean) => void; -}; - -function ToggleRow({ - icon: Icon, - label, - description, - checked, - disabled = false, - badge, - onChange, -}: ToggleRowProps) { - return ( -
- - - - - - {label} - {badge ? {badge} : null} - - {description} - - -
- ); -} - -export function AttentionSettingsPopover() { - const { status: accountStatus } = useAccountStatus(); - const accountOwnerId = accountStatus.signedIn ? accountStatus.userId : null; - const [open, setOpen] = useState(false); - const [loading, setLoading] = useState(false); - const [saving, setSaving] = useState(false); - const [saved, setSaved] = useState(false); - const [error, setError] = useState(null); - const [preferences, setPreferences] = - useState(DEFAULT_ATTENTION_PREFERENCES); - const [notchEnabled, setNotchEnabled] = useState(readAttentionNotchEnabled); - const [notchPresentation, setNotchPresentation] = useState(readAttentionNotchPresentation); - const reducedMotion = useReducedMotion() ?? false; - const rootRef = useRef(null); - const triggerRef = useRef(null); - const restoreTriggerFocusRef = useRef(false); - const accountOwnerRef = useRef(accountOwnerId); - const previousAccountOwnerRef = useRef(accountOwnerId); - const accountEffectMountedRef = useRef(false); - const requestGenerationRef = useRef(0); - accountOwnerRef.current = accountOwnerId; - if (previousAccountOwnerRef.current !== accountOwnerId) { - previousAccountOwnerRef.current = accountOwnerId; - requestGenerationRef.current += 1; - } - - useEffect(() => onAttentionNotchSettingsChanged((settings) => { - setNotchEnabled(settings.enabled); - setNotchPresentation({ - revealMode: settings.revealMode, - expandedPanelEnabled: settings.expandedPanelEnabled, - }); - }), []); - - const dialogElement = () => - rootRef.current?.querySelector('[role="dialog"]') ?? null; - - const closePopover = (returnFocus: boolean) => { - requestGenerationRef.current += 1; - restoreTriggerFocusRef.current = returnFocus; - setSaving(false); - setOpen(false); - }; - - useEffect(() => { - if (!accountEffectMountedRef.current) { - accountEffectMountedRef.current = true; - return; - } - restoreTriggerFocusRef.current = false; - setOpen(false); - setLoading(false); - setSaving(false); - setSaved(false); - setError(null); - setPreferences(DEFAULT_ATTENTION_PREFERENCES); - }, [accountOwnerId]); - - useEffect(() => { - if (!open) return; - const onPointerDown = (event: PointerEvent) => { - if (!rootRef.current?.contains(event.target as Node)) closePopover(false); - }; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") closePopover(true); - }; - document.addEventListener("pointerdown", onPointerDown); - document.addEventListener("keydown", onKeyDown); - return () => { - document.removeEventListener("pointerdown", onPointerDown); - document.removeEventListener("keydown", onKeyDown); - }; - }, [open]); - - // Land focus in the dialog on open so the whole panel is reachable without - // tabbing back through the page behind it. - useEffect(() => { - if (open) dialogElement()?.focus(); - }, [open]); - - // Keep Tab inside the popover while it is open; Escape and the footer - // buttons are the ways out. - const onDialogKeyDown = (event: React.KeyboardEvent) => { - if (event.key !== "Tab") return; - const dialog = dialogElement(); - if (!dialog) return; - const focusable = Array.from( - dialog.querySelectorAll("button, select, [href], input, [tabindex]:not([tabindex='-1'])"), - ).filter((element) => !element.hasAttribute("disabled")); - if (focusable.length === 0) return; - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - const active = document.activeElement; - if (event.shiftKey && (active === first || active === dialog)) { - event.preventDefault(); - last.focus(); - } else if (!event.shiftKey && active === last) { - event.preventDefault(); - first.focus(); - } - }; - - const openSettings = () => { - if (open) { - closePopover(true); - return; - } - setOpen(true); - setLoading(true); - setError(null); - setSaved(false); - setNotchEnabled(readAttentionNotchEnabled()); - setNotchPresentation(readAttentionNotchPresentation()); - const ownerId = accountOwnerId; - const generation = requestGenerationRef.current + 1; - requestGenerationRef.current = generation; - const isCurrentRequest = () => - requestGenerationRef.current === generation - && accountOwnerRef.current === ownerId; - const api = window.ade?.attention; - if (!api || !ownerId) { - setError("Attention settings are unavailable in this ADE session."); - setLoading(false); - return; - } - void api.getPreferences(ownerId) - .then((nextPreferences) => { - if (!isCurrentRequest()) return; - setPreferences(normalizeAttentionPreferences(nextPreferences)); - }) - .catch((loadError: unknown) => { - if (!isCurrentRequest()) return; - setError( - loadError instanceof Error && loadError.message.trim() - ? loadError.message - : "ADE couldn’t load your Attention settings.", - ); - }) - .finally(() => { - if (isCurrentRequest()) setLoading(false); - }); - }; - - const updateAccount = ( - patch: Partial, - ) => { - setSaved(false); - setPreferences((current) => ({ - ...current, - account: { ...current.account, ...patch }, - })); - }; - - const desktopFirstDelay = preferences.account.desktopFirstEnabled - ? preferences.account.desktopFirstDelaySeconds - : 0; - - const save = async () => { - if (saving) return; - const ownerId = accountOwnerId; - const generation = requestGenerationRef.current + 1; - requestGenerationRef.current = generation; - const isCurrentRequest = () => - requestGenerationRef.current === generation - && accountOwnerRef.current === ownerId; - setSaving(true); - setSaved(false); - setError(null); - try { - const api = window.ade?.attention; - if (!api || !ownerId) { - throw new Error("Attention settings are unavailable in this ADE session."); - } - await api.putPreferences(ownerId, preferences); - if (!isCurrentRequest()) return; - writeAttentionNotchEnabled(notchEnabled); - writeAttentionNotchPresentation(notchPresentation); - await window.ade?.attentionNotch?.updateSettings( - attentionNotchSettingsFromPreferences(preferences, notchEnabled, notchPresentation), - ); - if (!isCurrentRequest()) return; - if (notchEnabled) { - const health = await window.ade?.attentionNotch?.getHealth?.(); - if ( - health - && health.state !== "running" - && health.state !== "starting" - ) { - throw new Error(`${health.title}. ${health.message}`); - } - } - setSaved(true); - window.setTimeout(() => { - if (isCurrentRequest()) setSaved(false); - }, 1_800); - } catch (saveError) { - if (!isCurrentRequest()) return; - setError( - saveError instanceof Error && saveError.message.trim() - ? saveError.message - : "ADE couldn’t save your Attention settings.", - ); - } finally { - if (isCurrentRequest()) setSaving(false); - } - }; - - return ( -
- - { - if (!restoreTriggerFocusRef.current) return; - restoreTriggerFocusRef.current = false; - triggerRef.current?.focus(); - }} - > - {open ? ( - -
-
- - - - - Attention settings - Account delivery and this Mac’s notch - -
- Account -
- - {loading ? ( -
- - Loading your preferences… -
- ) : ( - <> -
-

Quick toggles

- - - updateAccount({ notificationsEnabled })} - /> - updateAccount({ soundsEnabled })} - /> - {/* - The full model — per-event delivery policies, quiet hours, - escalation, previews, celebrations — lives in Settings. - Keeping the popover to three toggles stops the two surfaces - drifting apart the way they did before. - */} - {/* - Routed through the app navigation bus rather than - `useNavigate`: the attention subtree is mounted outside the - router in tests and must not take a Router dependency. - */} - -
- - )} - - {error ? ( -
- - {error} -
- ) : null} - -
- - {saved - ? <> Saved - : "Delivery syncs; notch choices stay on this Mac"} - - - -
-
- ) : null} -
-
- ); -} diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index b82c0173c..6a81e7916 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -150,6 +150,8 @@ D30000000000000000000013 /* AttentionDrawerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000003 /* AttentionDrawerSheet.swift */; }; D30000000000000000000015 /* AttentionDrawerModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000005 /* AttentionDrawerModelTests.swift */; }; AC7200000000000000000001 /* ActivityContractDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7100000000000000000001 /* ActivityContractDecodingTests.swift */; }; + AC7400000000000000000001 /* ActivityAckQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7300000000000000000001 /* ActivityAckQueueTests.swift */; }; + AC7400000000000000000002 /* ActivityPollingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7300000000000000000002 /* ActivityPollingTests.swift */; }; D30000000000000000000016 /* SyncEnvelopeChunkAssemblerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000006 /* SyncEnvelopeChunkAssemblerTests.swift */; }; D30000000000000000000017 /* WorkMarkdownStreamingParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000007 /* WorkMarkdownStreamingParsingTests.swift */; }; D30000000000000000000018 /* PrMergeMergeStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000008 /* PrMergeMergeStateTests.swift */; }; @@ -426,6 +428,8 @@ D30000000000000000000003 /* AttentionDrawerSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttentionDrawerSheet.swift; path = ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift; sourceTree = ""; }; D30000000000000000000005 /* AttentionDrawerModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttentionDrawerModelTests.swift; path = ADETests/AttentionDrawerModelTests.swift; sourceTree = ""; }; AC7100000000000000000001 /* ActivityContractDecodingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityContractDecodingTests.swift; path = ADETests/ActivityContractDecodingTests.swift; sourceTree = ""; }; + AC7300000000000000000001 /* ActivityAckQueueTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityAckQueueTests.swift; path = ADETests/ActivityAckQueueTests.swift; sourceTree = ""; }; + AC7300000000000000000002 /* ActivityPollingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityPollingTests.swift; path = ADETests/ActivityPollingTests.swift; sourceTree = ""; }; D30000000000000000000006 /* SyncEnvelopeChunkAssemblerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncEnvelopeChunkAssemblerTests.swift; path = ADETests/SyncEnvelopeChunkAssemblerTests.swift; sourceTree = ""; }; D30000000000000000000007 /* WorkMarkdownStreamingParsingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkMarkdownStreamingParsingTests.swift; path = ADETests/WorkMarkdownStreamingParsingTests.swift; sourceTree = ""; }; D30000000000000000000008 /* PrMergeMergeStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrMergeMergeStateTests.swift; path = ADETests/PrMergeMergeStateTests.swift; sourceTree = ""; }; @@ -1062,6 +1066,8 @@ AC1000000000000000000008 /* ClipPairingHandoffTests.swift */, D30000000000000000000005 /* AttentionDrawerModelTests.swift */, AC7100000000000000000001 /* ActivityContractDecodingTests.swift */, + AC7300000000000000000001 /* ActivityAckQueueTests.swift */, + AC7300000000000000000002 /* ActivityPollingTests.swift */, D30000000000000000000006 /* SyncEnvelopeChunkAssemblerTests.swift */, D30000000000000000000007 /* WorkMarkdownStreamingParsingTests.swift */, D30000000000000000000008 /* PrMergeMergeStateTests.swift */, @@ -1564,6 +1570,8 @@ AC1100000000000000000008 /* ClipPairingHandoffTests.swift in Sources */, D30000000000000000000015 /* AttentionDrawerModelTests.swift in Sources */, AC7200000000000000000001 /* ActivityContractDecodingTests.swift in Sources */, + AC7400000000000000000001 /* ActivityAckQueueTests.swift in Sources */, + AC7400000000000000000002 /* ActivityPollingTests.swift in Sources */, D30000000000000000000016 /* SyncEnvelopeChunkAssemblerTests.swift in Sources */, D30000000000000000000017 /* WorkMarkdownStreamingParsingTests.swift in Sources */, D30000000000000000000018 /* PrMergeMergeStateTests.swift in Sources */, diff --git a/apps/ios/ADE/App/ADEApp.swift b/apps/ios/ADE/App/ADEApp.swift index 04b61dfd8..051cbafa4 100644 --- a/apps/ios/ADE/App/ADEApp.swift +++ b/apps/ios/ADE/App/ADEApp.swift @@ -50,11 +50,13 @@ struct ADEApp: App { .onChange(of: scenePhase) { _, newPhase in if newPhase == .background { didEnterBackground = true + accountService.stopAttentionPolling() ProductAnalytics.shared.flush() Task { await accountService.updateAttentionAppForeground(false) } return } guard newPhase == .active else { return } + accountService.startAttentionPolling() if didEnterBackground { didEnterBackground = false ProductAnalytics.shared.captureAppOpened(.foreground) diff --git a/apps/ios/ADE/App/ADEAppDelegate.swift b/apps/ios/ADE/App/ADEAppDelegate.swift index ca17bb86b..98f29d6c1 100644 --- a/apps/ios/ADE/App/ADEAppDelegate.swift +++ b/apps/ios/ADE/App/ADEAppDelegate.swift @@ -77,9 +77,14 @@ final class ADEAppDelegate: NSObject, UIApplicationDelegate { didReceiveRemoteNotification userInfo: [AnyHashable: Any] ) async -> UIBackgroundFetchResult { await MainActor.run { PushNotificationService.shared.notePushReceived() } - // This callback only records push diagnostics; it fetches/syncs nothing, - // so claiming `.newData` would skew iOS's background-fetch budget. - return .noData + let previousRevision = await MainActor.run { + AccountService.shared.attentionSnapshotRevision + } + await AccountService.shared.refreshAttentionSnapshot() + let refreshedRevision = await MainActor.run { + AccountService.shared.attentionSnapshotRevision + } + return refreshedRevision != previousRevision ? .newData : .noData } } @@ -94,6 +99,9 @@ extension ADEAppDelegate: UNUserNotificationCenterDelegate { willPresent notification: UNNotification ) async -> UNNotificationPresentationOptions { let userInfo = notification.request.content.userInfo + Task { @MainActor in + await AccountService.shared.refreshAttentionSnapshot() + } return await MainActor.run { PushNotificationService.shared.notePushReceived() if let sessionId = ADEAppDelegate.sessionId(from: userInfo), @@ -114,6 +122,9 @@ extension ADEAppDelegate: UNUserNotificationCenterDelegate { let userInfo = response.notification.request.content.userInfo let sessionId = (userInfo["sessionId"] as? String) ?? "" let itemId = (userInfo["itemId"] as? String) ?? "" + Task { @MainActor in + await AccountService.shared.refreshAttentionSnapshot() + } // Both ids are required to target the pending approval — a payload // missing either (older host, malformed push) falls through to the diff --git a/apps/ios/ADE/Services/AccountDirectory.swift b/apps/ios/ADE/Services/AccountDirectory.swift index de202f103..c303675a0 100644 --- a/apps/ios/ADE/Services/AccountDirectory.swift +++ b/apps/ios/ADE/Services/AccountDirectory.swift @@ -296,6 +296,11 @@ struct AccountDirectoryClient { /// relay. It intentionally shares Clerk session semantics with the account /// directory but stores the resulting snapshot in the App Group so widgets /// never need network or authentication access. +struct AccountAttentionAcknowledgmentResult: Equatable, Sendable { + let applied: [String] + let stale: [String] +} + struct AccountAttentionRelayClient { enum RelayError: LocalizedError, Equatable { case unauthorized @@ -355,22 +360,73 @@ struct AccountAttentionRelayClient { itemIds: [String], dismiss: Bool, refreshToken: (() async -> String?)? = nil - ) async throws { - guard !itemIds.isEmpty else { return } - let timestamp = ISO8601DateFormatter().string(from: Date()) + ) async throws -> AccountAttentionAcknowledgmentResult { + let now = Date() + return try await acknowledge( + baseURL: baseURL, + token: token, + itemIds: itemIds, + seenAt: now, + dismissedAt: dismiss ? now : nil, + sourceRevisions: nil, + expectedAccountOwnerId: nil, + refreshToken: refreshToken + ) + } + + func acknowledge( + baseURL: URL, + token: String, + itemIds: [String], + seenAt: Date, + dismissedAt: Date?, + sourceRevisions: [String: Int]?, + expectedAccountOwnerId: String?, + refreshToken: (() async -> String?)? = nil + ) async throws -> AccountAttentionAcknowledgmentResult { + let ids = Array(itemIds.prefix(64)) + guard !ids.isEmpty else { + return AccountAttentionAcknowledgmentResult(applied: [], stale: []) + } + let formatter = ISO8601DateFormatter() var payload: [String: Any] = [ - "itemIds": Array(itemIds.prefix(64)), - "seenAt": timestamp, + "itemIds": ids, + "seenAt": formatter.string(from: seenAt), ] - if dismiss { payload["dismissedAt"] = timestamp } + if let dismissedAt { + payload["dismissedAt"] = formatter.string(from: dismissedAt) + } + if let sourceRevisions { + payload["sourceRevisions"] = sourceRevisions + } + if let expectedAccountOwnerId { + payload["expectedAccountOwnerId"] = expectedAccountOwnerId + } let body = try JSONSerialization.data(withJSONObject: payload) - _ = try await perform( + let data = try await perform( url: endpoint(baseURL, "ack"), method: "POST", token: token, body: body, refreshToken: refreshToken ) + // Older relays returned no applied/stale arrays. Treat a successful legacy + // response as applying every requested id so this additive client remains + // compatible during a staggered rollout. + guard !data.isEmpty else { + return AccountAttentionAcknowledgmentResult(applied: ids, stale: []) + } + struct Response: Decodable { + let applied: [String]? + let stale: [String]? + } + guard let response = try? JSONDecoder().decode(Response.self, from: data) else { + throw RelayError.invalidSnapshot + } + return AccountAttentionAcknowledgmentResult( + applied: response.applied ?? ids, + stale: response.stale ?? [] + ) } func updatePresence( diff --git a/apps/ios/ADE/Services/AccountService.swift b/apps/ios/ADE/Services/AccountService.swift index 4bf6a2d3e..e9bcf9c47 100644 --- a/apps/ios/ADE/Services/AccountService.swift +++ b/apps/ios/ADE/Services/AccountService.swift @@ -321,6 +321,147 @@ struct AccountDeviceRevocationStore { } } +/// One optimistic acknowledgment that still needs to reach the account relay. +/// Entries are owner-scoped by `AccountAttentionPendingAckStore`, so the wire +/// shape stays limited to item state and can never cross an account boundary. +struct AccountAttentionPendingAck: Codable, Equatable, Sendable { + let itemId: String + let seenAt: Date? + let dismissedAt: Date? + let sourceRevision: Int? + + var newestTimestamp: Date { + [seenAt, dismissedAt].compactMap { $0 }.max() ?? .distantPast + } + + func merging(_ other: AccountAttentionPendingAck) -> AccountAttentionPendingAck { + precondition(itemId == other.itemId) + return AccountAttentionPendingAck( + itemId: itemId, + seenAt: Self.latest(seenAt, other.seenAt), + dismissedAt: Self.latest(dismissedAt, other.dismissedAt), + sourceRevision: Self.latest(sourceRevision, other.sourceRevision) + ) + } + + private static func latest(_ lhs: Value?, _ rhs: Value?) -> Value? { + switch (lhs, rhs) { + case (.some(let lhs), .some(let rhs)): return max(lhs, rhs) + case (.some(let lhs), .none): return lhs + case (.none, .some(let rhs)): return rhs + case (.none, .none): return nil + } + } +} + +private struct AccountAttentionPendingAckArchive: Codable { + var entriesByOwner: [String: [AccountAttentionPendingAck]] = [:] +} + +/// App Group-backed queue for account acknowledgments. Reads always normalize +/// duplicate item ids so a crash between enqueue and cleanup cannot multiply +/// relay writes on the next foreground refresh. +struct AccountAttentionPendingAckStore { + private let defaults: UserDefaults + private let key: String + + init( + defaults: UserDefaults = ADESharedContainer.defaults, + key: String = ADESharedContainer.attentionPendingAcksKey + ) { + self.defaults = defaults + self.key = key + } + + func entries(for ownerId: String) -> [AccountAttentionPendingAck] { + let ownerId = normalizedOwnerId(ownerId) + guard !ownerId.isEmpty else { return [] } + return Self.deduplicated(load().entriesByOwner[ownerId] ?? []) + } + + func enqueue(_ entries: [AccountAttentionPendingAck], for ownerId: String) { + let ownerId = normalizedOwnerId(ownerId) + guard !ownerId.isEmpty, !entries.isEmpty else { return } + var archive = load() + archive.entriesByOwner[ownerId] = Self.deduplicated( + (archive.entriesByOwner[ownerId] ?? []) + entries + ) + save(archive) + } + + func replace(_ entries: [AccountAttentionPendingAck], for ownerId: String) { + let ownerId = normalizedOwnerId(ownerId) + guard !ownerId.isEmpty else { return } + var archive = load() + let normalized = Self.deduplicated(entries) + if normalized.isEmpty { + archive.entriesByOwner.removeValue(forKey: ownerId) + } else { + archive.entriesByOwner[ownerId] = normalized + } + save(archive) + } + + func remove(itemIds: Set, for ownerId: String) { + guard !itemIds.isEmpty else { return } + replace( + entries(for: ownerId).filter { !itemIds.contains($0.itemId) }, + for: ownerId + ) + } + + func clear(for ownerId: String) { + replace([], for: ownerId) + } + + static func deduplicated( + _ entries: [AccountAttentionPendingAck] + ) -> [AccountAttentionPendingAck] { + var byId: [String: AccountAttentionPendingAck] = [:] + for entry in entries { + let itemId = entry.itemId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !itemId.isEmpty else { continue } + let normalized = AccountAttentionPendingAck( + itemId: itemId, + seenAt: entry.seenAt, + dismissedAt: entry.dismissedAt, + sourceRevision: entry.sourceRevision + ) + byId[itemId] = byId[itemId]?.merging(normalized) ?? normalized + } + return byId.values.sorted { + if $0.newestTimestamp != $1.newestTimestamp { + return $0.newestTimestamp < $1.newestTimestamp + } + return $0.itemId < $1.itemId + } + } + + private func normalizedOwnerId(_ ownerId: String) -> String { + ownerId.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func load() -> AccountAttentionPendingAckArchive { + guard let data = defaults.data(forKey: key), + let archive = try? JSONDecoder().decode( + AccountAttentionPendingAckArchive.self, + from: data + ) else { + return AccountAttentionPendingAckArchive() + } + return archive + } + + private func save(_ archive: AccountAttentionPendingAckArchive) { + if archive.entriesByOwner.isEmpty { + defaults.removeObject(forKey: key) + } else if let data = try? JSONEncoder().encode(archive) { + defaults.set(data, forKey: key) + } + defaults.synchronize() + } +} + /// Keep token eligibility independently testable from ClerkKit. A cached Clerk /// session is not enough: ADE must currently publish the same signed-in user /// and must not be under a device-local sign-out boundary. @@ -470,6 +611,36 @@ func accountPairingCommitIsAuthorized( ) } +let ActivityPollInterval: UInt64 = 20_000_000_000 +typealias AccountAttentionPollSleep = @MainActor (UInt64) async throws -> Void +typealias AccountAttentionPollSignedIn = @MainActor () -> Bool +typealias AccountAttentionPollRefresh = @MainActor () async -> Void + +private struct AccountAttentionAckFlushOutcome { + var attemptedItemIds: Set = [] + var staleItemIds: Set = [] + var unbackedItemIds: Set = [] + var failureMessage: String? +} + +private struct AccountAttentionAckTiming: Hashable { + let seenAt: Date + let dismissedAt: Date? +} + +/// Small orchestration seam used by the service and unit tests. A stale relay +/// response gets exactly one refresh and one retry; an empty set does neither. +@MainActor +func retryAccountAttentionAcknowledgmentsOnce( + itemIds: Set, + refresh: () async -> Void, + retry: (Set) async -> Output +) async -> Output? { + guard !itemIds.isEmpty else { return nil } + await refresh() + return await retry(itemIds) +} + /// Wraps ClerkKit behind the app's `ObservableObject` convention so SwiftUI /// surfaces observe published state instead of the `@Observable` `Clerk` type /// directly. Owns configuration, session restore, the sign-in/out operations, @@ -505,6 +676,9 @@ final class AccountService: ObservableObject { /// Bumped after a new account Attention snapshot is committed to the App /// Group. The in-app model observes this alongside SyncService revisions. @Published private(set) var attentionSnapshotRevision = 0 + /// Last relay acknowledgment failure. Optimistic local drawer state remains + /// active while the durable queue waits for the next successful refresh. + @Published private(set) var attentionAckFailure: String? /// Transient, user-facing error from the last sign-in attempt. @Published var lastError: String? @@ -520,7 +694,14 @@ final class AccountService: ObservableObject { private var lastRelayCredential: (ownerId: String, token: String)? private var attentionRefreshTask: Task? private var attentionRefreshId: UUID? + private var attentionPollTask: Task? + private var attentionPollGeneration = 0 + private let attentionPollSleep: AccountAttentionPollSleep + private let attentionPollSignedInOverride: AccountAttentionPollSignedIn? + private let attentionPollRefreshOverride: AccountAttentionPollRefresh? private var attentionPresenceState = AccountAttentionPresenceState() + private let attentionPendingAckStore: AccountAttentionPendingAckStore + private var attentionAckFlushExclusions: Set = [] private var isEndingAccountOwnership = false private let accountRegistrationQueue = LatestAccountRegistrationQueue() @@ -546,7 +727,19 @@ final class AccountService: ObservableObject { ?? "ios-device" } - private init() {} + init( + attentionPollSleep: @escaping AccountAttentionPollSleep = { + try await Task.sleep(nanoseconds: $0) + }, + attentionPollSignedIn: AccountAttentionPollSignedIn? = nil, + attentionPollRefresh: AccountAttentionPollRefresh? = nil, + attentionPendingAckStore: AccountAttentionPendingAckStore = AccountAttentionPendingAckStore() + ) { + self.attentionPollSleep = attentionPollSleep + self.attentionPollSignedInOverride = attentionPollSignedIn + self.attentionPollRefreshOverride = attentionPollRefresh + self.attentionPendingAckStore = attentionPendingAckStore + } // MARK: - Lifecycle @@ -639,6 +832,7 @@ final class AccountService: ObservableObject { if phase != .signedIn { phase = .signedIn } + startAttentionPolling() if shouldRefreshMachines { Task { if accountSwitched { @@ -687,6 +881,7 @@ final class AccountService: ObservableObject { accountRegistrationQueue.discardPending() accountPreferencesQueue.discardPending() cancelAttentionRefresh() + stopAttentionPolling() invalidatePairingAuthorization() SyncService.shared?.removeAccountOwnedPairings(exceptOwnerId: nil) identity = nil @@ -720,6 +915,56 @@ final class AccountService: ObservableObject { attentionRefreshId = nil } + /// Starts the foreground account Activity poll. Repeated starts while the + /// same loop is live are a no-op; stop/start advances the generation so a + /// cancellation-insensitive sleeper cannot resurrect an older loop. + func startAttentionPolling() { + guard attentionPollTask == nil, attentionPollingIsSignedIn else { return } + attentionPollGeneration &+= 1 + let generation = attentionPollGeneration + attentionPollTask = Task { @MainActor [weak self] in + guard let self else { return } + while self.attentionPollingIsSignedIn { + do { + try await self.attentionPollSleep(ActivityPollInterval) + } catch { + break + } + guard !Task.isCancelled, + self.attentionPollGeneration == generation, + self.attentionPollingIsSignedIn else { + break + } + if let refresh = self.attentionPollRefreshOverride { + await refresh() + } else { + await self.refreshAttentionSnapshot() + } + } + if self.attentionPollGeneration == generation { + self.attentionPollTask = nil + } + } + } + + func stopAttentionPolling() { + attentionPollGeneration &+= 1 + attentionPollTask?.cancel() + attentionPollTask = nil + } + + var isAttentionPolling: Bool { + attentionPollTask != nil + } + + var currentAttentionPollGeneration: Int { + attentionPollGeneration + } + + private var attentionPollingIsSignedIn: Bool { + attentionPollSignedInOverride?() ?? isSignedIn + } + /// Called only after an explicit sign-in operation completes and Clerk has /// published a real user. Merely receiving a cached auth event never clears /// the local sign-out boundary. @@ -1012,6 +1257,18 @@ final class AccountService: ObservableObject { } let existing = ADESharedContainer.readAttentionSnapshot() + let pendingAckOutcome = await flushPendingAttentionAcks( + ownerId: requestedOwnerId, + session: initialSession, + baseURL: baseURL, + snapshot: existing + ) + if let failureMessage = pendingAckOutcome.failureMessage { + attentionAckFailure = failureMessage + } else if !pendingAckOutcome.attemptedItemIds.isEmpty, + pendingAckOutcome.staleItemIds.isEmpty { + attentionAckFailure = nil + } do { let delta = try await attentionRelay.fetchSnapshot( baseURL: baseURL, @@ -1042,43 +1299,234 @@ final class AccountService: ObservableObject { guard ADESharedContainer.writeAttentionSnapshot(complete) else { return } attentionSnapshotRevision &+= 1 WidgetReloadBridge.reloadAllTimelines() + + // A stale fence needs one fresh snapshot before retrying. Items queued + // before their account row existed get the same single post-refresh + // opportunity; if they are still absent they remain durable for a later + // publisher reconcile. + let retryItemIds = pendingAckOutcome.staleItemIds + .union(pendingAckOutcome.unbackedItemIds) + if pendingAckOutcome.failureMessage == nil, !retryItemIds.isEmpty { + let retryOutcome = await flushPendingAttentionAcks( + ownerId: requestedOwnerId, + session: initialSession, + baseURL: baseURL, + snapshot: complete, + limitingTo: retryItemIds + ) + if let failureMessage = retryOutcome.failureMessage { + attentionAckFailure = failureMessage + } else if !retryOutcome.staleItemIds.isEmpty { + attentionAckFailure = "Activity changed again before the update was applied. Try again." + } else { + attentionAckFailure = nil + } + } else if pendingAckOutcome.failureMessage == nil { + attentionAckFailure = nil + } } catch { // Keep the last-known account snapshot and machine-local fallback. } } func acknowledgeAttentionItems(_ itemIds: [String], dismiss: Bool) async { - let ids = Array(Set(itemIds.filter { !$0.isEmpty })).prefix(64) - guard !ids.isEmpty, - isSignedIn, - let requestedOwnerId = identity?.userId, + let ids = Array(Set(itemIds.compactMap { itemId -> String? in + let normalized = itemId.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + })).prefix(64) + guard !ids.isEmpty else { return } + guard let requestedOwnerId = identity?.userId + ?? deviceOwnershipStore.state.ownerId else { + attentionAckFailure = "Sign in to sync this Activity update." + return + } + + let now = Date() + let revisionById = Dictionary( + uniqueKeysWithValues: (ADESharedContainer.readAttentionSnapshot()?.items ?? []) + .map { ($0.id, $0.revision) } + ) + let pending = ids.map { + AccountAttentionPendingAck( + itemId: $0, + seenAt: now, + dismissedAt: dismiss ? now : nil, + sourceRevision: revisionById[$0] + ) + } + attentionPendingAckStore.enqueue(pending, for: requestedOwnerId) + + guard isSignedIn, let baseURL = AccountConfig.attentionRelayBaseURL, let initialSession = await pairingSession(), initialSession.authorization.ownerId == requestedOwnerId, isPairingCommitAuthorized(initialSession.authorization) else { + attentionAckFailure = "Couldn't sync this Activity update yet. It will retry automatically." return } - do { - try await attentionRelay.acknowledge( - baseURL: baseURL, - token: initialSession.token, - itemIds: Array(ids), - dismiss: dismiss, - refreshToken: { [weak self] in - guard let self, - self.isPairingCommitAuthorized(initialSession.authorization) else { - return nil - } - return try? await self.freshRelaySession( - expectedAuthorization: initialSession.authorization - ).token + let attemptedIds = Set(ids) + let outcome = await flushPendingAttentionAcks( + ownerId: requestedOwnerId, + session: initialSession, + baseURL: baseURL, + snapshot: ADESharedContainer.readAttentionSnapshot(), + limitingTo: attemptedIds + ) + if let failureMessage = outcome.failureMessage { + attentionAckFailure = failureMessage + return + } + + let retryItemIds = outcome.staleItemIds.union(outcome.unbackedItemIds) + if retryItemIds.isEmpty { + attentionAckFailure = nil + await refreshAttentionSnapshot() + return + } + + // Keep the refresh's top-of-cycle queue drain from sending the stale ids a + // second time before the snapshot fence has advanced. The retry below is + // their one allowed post-refresh attempt. + attentionAckFlushExclusions.formUnion(retryItemIds) + let retryOutcome = await retryAccountAttentionAcknowledgmentsOnce( + itemIds: retryItemIds, + refresh: { [weak self] in + await self?.refreshAttentionSnapshot() + }, + retry: { [weak self] retryItemIds in + guard let self else { + return AccountAttentionAckFlushOutcome( + failureMessage: "Couldn't finish the Activity update." + ) } + self.attentionAckFlushExclusions.subtract(retryItemIds) + guard self.isPairingCommitAuthorized(initialSession.authorization), + self.identity?.userId == requestedOwnerId else { + return AccountAttentionAckFlushOutcome( + failureMessage: "The signed-in account changed before the Activity update completed." + ) + } + return await self.flushPendingAttentionAcks( + ownerId: requestedOwnerId, + session: initialSession, + baseURL: baseURL, + snapshot: ADESharedContainer.readAttentionSnapshot(), + limitingTo: retryItemIds + ) + } + ) ?? AccountAttentionAckFlushOutcome() + if let failureMessage = retryOutcome.failureMessage { + attentionAckFailure = failureMessage + } else if !retryOutcome.staleItemIds.isEmpty { + attentionAckFailure = "Activity changed again before the update was applied. Try again." + } else { + attentionAckFailure = nil + } + } + + private func flushPendingAttentionAcks( + ownerId: String, + session initialSession: AccountPairingSession, + baseURL: URL, + snapshot: AccountAttentionSnapshot?, + limitingTo itemIds: Set? = nil + ) async -> AccountAttentionAckFlushOutcome { + var outcome = AccountAttentionAckFlushOutcome() + guard isPairingCommitAuthorized(initialSession.authorization), + initialSession.authorization.ownerId == ownerId else { + outcome.failureMessage = "The signed-in account changed before the Activity update completed." + return outcome + } + + let revisionById = Dictionary( + uniqueKeysWithValues: (snapshot?.items ?? []).map { ($0.id, $0.revision) } + ) + let stored = attentionPendingAckStore.entries(for: ownerId) + let hydrated = stored.map { entry in + AccountAttentionPendingAck( + itemId: entry.itemId, + seenAt: entry.seenAt, + dismissedAt: entry.dismissedAt, + sourceRevision: revisionById[entry.itemId] ?? entry.sourceRevision ) - await refreshAttentionSnapshot() - } catch { - // The local seen state remains useful offline. A later snapshot refresh - // will reconcile shared acknowledgment. } + if hydrated != stored { + attentionPendingAckStore.replace(hydrated, for: ownerId) + } + + let selected = hydrated.filter { entry in + (itemIds == nil || itemIds?.contains(entry.itemId) == true) + && !attentionAckFlushExclusions.contains(entry.itemId) + } + // Only the complete snapshot proves an item is account-backed *now*. + // Persisted source revisions are historical context, never authority for a + // send after the row disappeared or the account stream reset. + let ready = selected.filter { revisionById[$0.itemId] != nil } + outcome.unbackedItemIds = Set( + selected.filter { revisionById[$0.itemId] == nil }.map(\.itemId) + ) + guard !ready.isEmpty else { return outcome } + + let grouped = Dictionary(grouping: ready) { entry in + AccountAttentionAckTiming( + seenAt: entry.seenAt ?? entry.dismissedAt ?? Date(), + dismissedAt: entry.dismissedAt + ) + } + for (timing, entries) in grouped { + var startIndex = 0 + while startIndex < entries.count { + let endIndex = min(startIndex + 64, entries.count) + let batch = Array(entries[startIndex.. = ["item-a", "item-b"] + + let retriedIds = await retryAccountAttentionAcknowledgmentsOnce( + itemIds: staleIds, + refresh: { refreshCount += 1 }, + retry: { itemIds in + retryCount += 1 + return itemIds + } + ) + + XCTAssertEqual(refreshCount, 1) + XCTAssertEqual(retryCount, 1) + XCTAssertEqual(retriedIds, staleIds) + } + + func testHardOwnerMismatchClearsOnlyRejectedOwnerQueue() throws { + let suiteName = "ActivityAckQueueTests.owner.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = AccountAttentionPendingAckStore(defaults: defaults, key: "pending-acks") + let entry = AccountAttentionPendingAck( + itemId: "item-a", + seenAt: Date(timeIntervalSince1970: 100), + dismissedAt: nil, + sourceRevision: 1 + ) + store.enqueue([entry], for: "account-a") + store.enqueue([entry], for: "account-b") + + store.clear(for: "account-a") + + XCTAssertTrue(store.entries(for: "account-a").isEmpty) + XCTAssertEqual(store.entries(for: "account-b"), [entry]) + } +} diff --git a/apps/ios/ADETests/ActivityPollingTests.swift b/apps/ios/ADETests/ActivityPollingTests.swift new file mode 100644 index 000000000..0738cd176 --- /dev/null +++ b/apps/ios/ADETests/ActivityPollingTests.swift @@ -0,0 +1,102 @@ +import XCTest +@testable import ADE + +private actor ActivityPollSleepHarness { + private var requests: [(UInt64, CheckedContinuation)] = [] + + func sleep(nanoseconds: UInt64) async throws { + try await withCheckedThrowingContinuation { continuation in + requests.append((nanoseconds, continuation)) + } + } + + var requestCount: Int { requests.count } + var intervals: [UInt64] { requests.map(\.0) } + + func resumeFirst() { + guard !requests.isEmpty else { return } + let request = requests.removeFirst() + request.1.resume() + } +} + +@MainActor +final class ActivityPollingTests: XCTestCase { + func testStartIsIdempotentAndGenerationStopsOldLoop() async { + let sleeper = ActivityPollSleepHarness() + var pollingEnabled = true + var refreshCount = 0 + let service = AccountService( + attentionPollSleep: { nanoseconds in + try await sleeper.sleep(nanoseconds: nanoseconds) + }, + attentionPollSignedIn: { pollingEnabled }, + attentionPollRefresh: { + refreshCount += 1 + pollingEnabled = false + } + ) + + service.startAttentionPolling() + await waitForRequestCount(1, sleeper: sleeper) + let firstGeneration = service.currentAttentionPollGeneration + + service.startAttentionPolling() + XCTAssertEqual(service.currentAttentionPollGeneration, firstGeneration) + let idempotentRequestCount = await sleeper.requestCount + XCTAssertEqual(idempotentRequestCount, 1) + + service.stopAttentionPolling() + service.startAttentionPolling() + await waitForRequestCount(2, sleeper: sleeper) + XCTAssertGreaterThan(service.currentAttentionPollGeneration, firstGeneration) + let intervals = await sleeper.intervals + XCTAssertEqual( + intervals, + [ActivityPollInterval, ActivityPollInterval] + ) + + // The injected sleeper intentionally ignores task cancellation. Resuming + // the old generation must still not refresh or clear the newer task. + await sleeper.resumeFirst() + await Task.yield() + XCTAssertEqual(refreshCount, 0) + XCTAssertTrue(service.isAttentionPolling) + + await sleeper.resumeFirst() + await waitForRefreshCount(1) { refreshCount } + await waitForPollingStop(service) + XCTAssertEqual(refreshCount, 1) + XCTAssertFalse(service.isAttentionPolling) + } + + private func waitForRequestCount( + _ expected: Int, + sleeper: ActivityPollSleepHarness + ) async { + for _ in 0..<1_000 { + if await sleeper.requestCount >= expected { return } + await Task.yield() + } + XCTFail("Timed out waiting for \(expected) poll sleeps") + } + + private func waitForRefreshCount( + _ expected: Int, + current: () -> Int + ) async { + for _ in 0..<1_000 { + if current() >= expected { return } + await Task.yield() + } + XCTFail("Timed out waiting for \(expected) poll refreshes") + } + + private func waitForPollingStop(_ service: AccountService) async { + for _ in 0..<1_000 { + if !service.isAttentionPolling { return } + await Task.yield() + } + XCTFail("Timed out waiting for polling to stop") + } +} diff --git a/apps/ios/ADETests/PairingAndDpopTests.swift b/apps/ios/ADETests/PairingAndDpopTests.swift index 3537c6816..5f54b93a0 100644 --- a/apps/ios/ADETests/PairingAndDpopTests.swift +++ b/apps/ios/ADETests/PairingAndDpopTests.swift @@ -314,13 +314,23 @@ final class PairingAndDpopTests: XCTestCase { XCTAssertEqual(payload["itemIds"] as? [String], ["item-a", "item-b"]) XCTAssertNotNil(payload["seenAt"] as? String) XCTAssertNotNil(payload["dismissedAt"] as? String) + XCTAssertEqual( + payload["sourceRevisions"] as? [String: Int], + ["item-a": 7, "item-b": 11] + ) + XCTAssertEqual(payload["expectedAccountOwnerId"] as? String, "account-a") let response = try XCTUnwrap(HTTPURLResponse( url: request.url ?? URL(string: "https://relay.example")!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"] )) - return (response, Data(#"{"ok":true,"revision":9}"#.utf8)) + return ( + response, + Data( + #"{"ok":true,"revision":9,"applied":["item-a"],"stale":["item-b"]}"#.utf8 + ) + ) } defer { AccountDirectoryURLProtocolStub.reset() } @@ -330,12 +340,17 @@ final class PairingAndDpopTests: XCTestCase { session: URLSession(configuration: configuration) ) - try await client.acknowledge( + let result = try await client.acknowledge( baseURL: try XCTUnwrap(URL(string: "https://relay.example")), token: "clerk-token", itemIds: ["item-a", "item-b"], - dismiss: true + seenAt: Date(timeIntervalSince1970: 100), + dismissedAt: Date(timeIntervalSince1970: 101), + sourceRevisions: ["item-a": 7, "item-b": 11], + expectedAccountOwnerId: "account-a" ) + XCTAssertEqual(result.applied, ["item-a"]) + XCTAssertEqual(result.stale, ["item-b"]) } func testAccountAttentionDevicePreferencesUseScopedPatch() async throws { From 85122f582f78154d01f454fa8ddccafd13d7d0bf Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:23:26 -0400 Subject: [PATCH 09/19] =?UTF-8?q?activity(p4):=20expanded=20pane=20+=20set?= =?UTF-8?q?tings=20+=20route=20=E2=80=94=20split=20Sessions/Inbox=20column?= =?UTF-8?q?s,=20slide-over=20detail,=20filters,=20tenth=20settings=20tab,?= =?UTF-8?q?=20/activity=20route=20with=20legacy=20redirect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../src/renderer/components/app/App.tsx | 24 +- .../src/renderer/components/app/AppShell.tsx | 35 +- .../renderer/components/app/SettingsPage.tsx | 5 + .../renderer/components/app/TabNav.test.tsx | 5 +- .../renderer/components/app/TopBar.test.tsx | 11 +- .../src/renderer/components/app/TopBar.tsx | 15 +- .../components/attention/Activity.css | 1207 +++++++++++++++++ .../components/attention/ActivityCard.tsx | 8 + .../attention/ActivityDetailSheet.tsx | 275 ++++ .../attention/ActivityFilters.test.tsx | 158 +++ .../components/attention/ActivityFilters.tsx | 222 +++ .../attention/ActivityInboxColumn.tsx | 189 +++ .../attention/ActivityPane.test.tsx | 429 ++++++ .../components/attention/ActivityPane.tsx | 341 +++++ .../attention/ActivitySessionsColumn.tsx | 222 +++ .../ActivitySettingsPopover.test.tsx | 146 ++ .../attention/ActivitySettingsPopover.tsx | 202 +++ .../attention/HeaderActivityControl.css | 78 +- .../attention/HeaderActivityControl.tsx | 4 +- .../attentionNotchLocalSettings.test.ts | 117 ++ .../attention/attentionNotchLocalSettings.ts | 36 + .../settings/ActivitySection.test.tsx | 169 +++ .../components/settings/ActivitySection.tsx | 54 + .../settings/ActivitySettingsControls.tsx | 703 ++++++++++ .../settings/NotificationsSection.test.tsx | 44 +- .../settings/NotificationsSection.tsx | 131 +- .../settings/settingsManifest.test.ts | 17 + .../components/settings/settingsManifest.ts | 106 +- .../src/renderer/lib/legacyRoutes.test.ts | 38 + apps/desktop/src/renderer/lib/legacyRoutes.ts | 37 + .../adapter/__tests__/adapter.test.ts | 54 +- .../renderer/webclient/adapter/attention.ts | 61 +- .../src/renderer/webclient/adapter/index.ts | 13 + .../webclient/shell/WebClientRoot.tsx | 5 + apps/desktop/src/shared/types/attention.ts | 10 + .../onboarding-and-settings/README.md | 3 +- docs/features/web-client/README.md | 10 +- 37 files changed, 4852 insertions(+), 332 deletions(-) create mode 100644 apps/desktop/src/renderer/components/attention/Activity.css create mode 100644 apps/desktop/src/renderer/components/attention/ActivityDetailSheet.tsx create mode 100644 apps/desktop/src/renderer/components/attention/ActivityFilters.test.tsx create mode 100644 apps/desktop/src/renderer/components/attention/ActivityFilters.tsx create mode 100644 apps/desktop/src/renderer/components/attention/ActivityInboxColumn.tsx create mode 100644 apps/desktop/src/renderer/components/attention/ActivityPane.test.tsx create mode 100644 apps/desktop/src/renderer/components/attention/ActivityPane.tsx create mode 100644 apps/desktop/src/renderer/components/attention/ActivitySessionsColumn.tsx create mode 100644 apps/desktop/src/renderer/components/attention/ActivitySettingsPopover.test.tsx create mode 100644 apps/desktop/src/renderer/components/attention/ActivitySettingsPopover.tsx create mode 100644 apps/desktop/src/renderer/components/attention/attentionNotchLocalSettings.test.ts create mode 100644 apps/desktop/src/renderer/components/settings/ActivitySection.test.tsx create mode 100644 apps/desktop/src/renderer/components/settings/ActivitySection.tsx create mode 100644 apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx create mode 100644 apps/desktop/src/renderer/lib/legacyRoutes.test.ts create mode 100644 apps/desktop/src/renderer/lib/legacyRoutes.ts diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 21e836c33..70096afe7 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -99,9 +99,6 @@ const WorkspaceGraphPage = React.lazy(() => const PersonalChatsPage = React.lazy(() => import("../personalChats/PersonalChatsPage").then((m) => ({ default: m.PersonalChatsPage })) ); -const AttentionCenter = React.lazy(() => - import("../attention/AttentionCenter").then((m) => ({ default: m.AttentionCenter })) -); const AccountPage = React.lazy(() => import("../account/AccountPage").then((m) => ({ default: m.AccountPage })) ); @@ -727,8 +724,6 @@ function ProjectTabHost() { const lruRef = React.useRef([]); const [routesBySurfaceKey, setRoutesBySurfaceKey] = React.useState>({}); const isPersonalChatsRoute = location.pathname === "/chats" || location.pathname.startsWith("/chats/"); - const isAttentionRoute = - location.pathname === "/attention" || location.pathname.startsWith("/attention/"); const isAccountRoute = location.pathname === "/account" || location.pathname.startsWith("/account/"); const isWebHubRoute = location.pathname === "/hub"; const isExternalFilesRoute = location.pathname === "/files" && new URLSearchParams(location.search).has("externalPath"); @@ -777,7 +772,7 @@ function ProjectTabHost() { // Machine-level routes (personal chats, account) are not project surfaces; // the route-restore below would otherwise clobber them with the active // project's stored route on load. - if (isPersonalChatsRoute || isAttentionRoute || isAccountRoute || isWebHubRoute) return; + if (isPersonalChatsRoute || isAccountRoute || isWebHubRoute) return; const previousSurfaceKey = previousActiveSurfaceKeyRef.current; if (previousSurfaceKey === activeSurfaceKey) return; const currentRoute = serializeStoredProjectRoute(location); @@ -800,7 +795,7 @@ function ProjectTabHost() { if (currentRoute !== nextRoute) { navigate(nextRoute, { replace: true }); } - }, [activeSurfaceKey, isAccountRoute, isAttentionRoute, isPersonalChatsRoute, isWebHubRoute, location, navigate, routesBySurfaceKey]); + }, [activeSurfaceKey, isAccountRoute, isPersonalChatsRoute, isWebHubRoute, location, navigate, routesBySurfaceKey]); React.useEffect(() => { if (!activeSurfaceKey) return; @@ -950,11 +945,11 @@ function ProjectTabHost() { ); } - if (!isWebHubRoute && !isAttentionRoute && !projectHydrated && !activeProject) { + if (!isWebHubRoute && !projectHydrated && !activeProject) { return GuardLoadingFallback; } - if (!isWebHubRoute && !isPersonalChatsRoute && !isAttentionRoute && !isAccountRoute && (!activeProject || showWelcome || mountedProjects.length === 0)) { + if (!isWebHubRoute && !isPersonalChatsRoute && !isAccountRoute && (!activeProject || showWelcome || mountedProjects.length === 0)) { return ( @@ -993,7 +988,7 @@ function ProjectTabHost() { return ( ) : null} - {isAttentionRoute ? ( - - - window.ade.attention.openItem(item)} - /> - - - ) : null} {isAccountRoute ? ( diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 668aca150..a10fe21c7 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -83,7 +83,9 @@ import { } from "../analytics/ProductAnalyticsLifecycle"; import { useAppWideSessionAttention } from "../../hooks/useAppWideSessionAttention"; import { useCtoAttention } from "../../hooks/useCtoAttention"; +import { ActivityPane } from "../attention/ActivityPane"; import { useAttentionSync } from "../attention/useAttentionSync"; +import { isActivityRoute } from "../../lib/legacyRoutes"; type PrToast = { id: string; @@ -98,12 +100,13 @@ type AutoLinkToast = { }; function primaryTabPath(pathname: string): string { - const roots = ["/hub", "/attention", "/lanes", "/files", "/work", "/graph", "/prs", "/history", "/automations", "/cto", "/settings"]; + const roots = ["/hub", "/activity", "/attention", "/lanes", "/files", "/work", "/graph", "/prs", "/history", "/automations", "/cto", "/settings"]; return roots.find((root) => pathname === root || pathname.startsWith(`${root}/`)) ?? pathname; } const PRODUCT_ANALYTICS_ROUTE_ROOTS = [ "/hub", + "/activity", "/attention", "/lanes", "/files", @@ -121,6 +124,10 @@ const PRODUCT_ANALYTICS_ROUTE_ROOTS = [ export function productAnalyticsScreenForPathname(pathname: string): string { if (pathname === "/project" || pathname.startsWith("/project/")) return "project"; + // Activity used to be the "/attention" route, and the screen name is derived + // from the path root. Mapping it explicitly keeps one PostHog series across + // the rename instead of forking it into "attention" and "activity". + if (isActivityRoute(pathname)) return "attention"; const root = PRODUCT_ANALYTICS_ROUTE_ROOTS.find( (candidate) => pathname === candidate || pathname.startsWith(`${candidate}/`), ); @@ -344,8 +351,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { const isOnboardingRoute = location.pathname === "/onboarding"; const isPersonalChatsRoute = location.pathname === "/chats" || location.pathname.startsWith("/chats/"); - const isAttentionRoute = - location.pathname === "/attention" || location.pathname.startsWith("/attention/"); + const activityDeepLink = isActivityRoute(location.pathname); const isAccountRoute = location.pathname === "/account" || location.pathname.startsWith("/account/"); const isWebHubRoute = isWebClientMode() && location.pathname === "/hub"; @@ -358,9 +364,26 @@ export function AppShell({ children }: { children: React.ReactNode }) { }); const isWorkAdjacentRoute = isWorkRoute || isLanesRoute; const isLanesRouteRef = useRef(isLanesRoute); + + // Activity is a modal over whatever tab is in front, not a tab of its own, so + // the shell owns whether it is up. `/activity` (and its `/attention` + // predecessor) stay valid deep links: they open the pane and immediately hand + // the URL back, so the surface underneath is a real tab rather than a blank + // route that exists only to host an overlay. + const [activityPaneOpen, setActivityPaneOpen] = useState(false); + const lastNonActivityRouteRef = useRef("/work"); + if (!activityDeepLink) { + lastNonActivityRouteRef.current = `${location.pathname}${location.search}`; + } + useEffect(() => { + if (!activityDeepLink) return; + setActivityPaneOpen(true); + navigate(lastNonActivityRouteRef.current, { replace: true }); + }, [activityDeepLink, navigate]); + useAppWideSessionAttention(); useCtoAttention(); - useAttentionSync(isAttentionRoute); + useAttentionSync(activityPaneOpen); useEffect(() => { isLanesRouteRef.current = isLanesRoute; @@ -1130,6 +1153,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { const tintClass = useMemo(() => { const tintMap: Record = { + "/activity": "tab-tint-work", "/attention": "tab-tint-work", "/lanes": "tab-tint-lanes", "/files": "tab-tint-files", @@ -1187,6 +1211,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { accountRouteActive={isAccountRoute} hubRouteActive={isWebHubRoute} onNavigate={(path, opts) => navigate(path, opts)} + onOpenActivityPane={() => setActivityPaneOpen(true)} />
@@ -1693,6 +1718,8 @@ export function AppShell({ children }: { children: React.ReactNode }) {
+ setActivityPaneOpen(false)} /> +
diff --git a/apps/desktop/src/renderer/components/app/SettingsPage.tsx b/apps/desktop/src/renderer/components/app/SettingsPage.tsx index e93bd576a..55cd71f0c 100644 --- a/apps/desktop/src/renderer/components/app/SettingsPage.tsx +++ b/apps/desktop/src/renderer/components/app/SettingsPage.tsx @@ -12,7 +12,9 @@ import { MagnifyingGlass, Palette, PlugsConnected, + Pulse as PulseIcon, } from "@phosphor-icons/react"; +import { ActivitySection } from "../settings/ActivitySection"; import { AppearanceSection } from "../settings/AppearanceSection"; import { AboutSection } from "../settings/AboutSection"; import { AdeCliSection } from "../settings/AdeCliSection"; @@ -62,6 +64,7 @@ const TAB_ICONS: Record = { "lanes-git": GitBranch, integrations: PlugsConnected, notifications: Bell, + activity: PulseIcon, secrets: Key, storage: HardDrives, stats: ChartLineUp, @@ -117,6 +120,8 @@ function TabContent({ tab }: { tab: SettingsTabId }) { ); case "notifications": return ; + case "activity": + return ; case "secrets": return ; case "storage": diff --git a/apps/desktop/src/renderer/components/app/TabNav.test.tsx b/apps/desktop/src/renderer/components/app/TabNav.test.tsx index a9dd57a8a..e2b744ff0 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.test.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.test.tsx @@ -84,7 +84,7 @@ describe("TabNav", () => { expect(screen.getByRole("link", { name: "Review" }).getAttribute("aria-disabled")).toBe("true"); }); - it("keeps the full Attention center secondary to the global header control", () => { + it("keeps Activity a header control and a modal, never a nav tab", () => { useAppStore.setState({ project: null, projectBinding: null, @@ -97,6 +97,9 @@ describe("TabNav", () => { , ); + // Deliberate: deleting this assertion is the quiet path to a tenth tab + // nobody agreed to. Activity lives in the header and opens as a modal. expect(screen.queryByRole("link", { name: "Attention" })).toBeNull(); + expect(screen.queryByRole("link", { name: "Activity" })).toBeNull(); }); }); diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index adb59b540..1b71c9ced 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -416,7 +416,7 @@ describe("TopBar", () => { publishAccountStatus(SIGNED_OUT_ACCOUNT); }); - it("carries account-wide Activity in the header and routes Open all to the center", () => { + it("carries account-wide Activity in the header and raises the pane from Open all", () => { const needsYou = { contractVersion: ATTENTION_CONTRACT_VERSION, id: "needs-you", @@ -456,8 +456,9 @@ describe("TopBar", () => { imageUrl: null, }); const onNavigate = vi.fn(); + const onOpenActivityPane = vi.fn(); - render(); + render(); const trigger = screen.getByTestId("header-activity-trigger"); // The item belongs to another machine and project entirely — the header is @@ -467,7 +468,11 @@ describe("TopBar", () => { fireEvent.click(trigger); fireEvent.click(screen.getByRole("button", { name: /Open all/ })); - expect(onNavigate).toHaveBeenCalledWith("/attention"); + // Activity is a modal over the current tab, so opening it is shell state — + // navigating would cost the user whatever tab they were on. + expect(onOpenActivityPane).toHaveBeenCalledTimes(1); + expect(onNavigate).not.toHaveBeenCalledWith("/attention"); + expect(onNavigate).not.toHaveBeenCalledWith("/activity"); }); it("shows connections before a project is open without immediate polling", async () => { diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index a62f62fc0..e306cb7b1 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -951,12 +951,15 @@ export function TopBar({ personalChatsRouteActive = false, accountRouteActive = false, hubRouteActive = false, + onOpenActivityPane, onNavigate, }: { personalChatsRouteActive?: boolean; accountRouteActive?: boolean; hubRouteActive?: boolean; onNavigate?: (path: string, opts?: { replace?: boolean }) => void; + /** Raises the shell's Activity pane over whatever tab is in front. */ + onOpenActivityPane?: () => void; } = {}) { const project = useAppStore((s) => s.project); const hasProject = Boolean(project?.rootPath); @@ -1586,12 +1589,14 @@ export function TopBar({ window.ade.app.newWindow().catch(() => {}); }, [isProjectBusy]); - // Activity is account-wide, so it never depends on a project being open. - // P4: this becomes a shell-state flip that opens the Activity pane over the - // current tab; until that pane exists it still routes to the old center. + // Activity is account-wide, so it never depends on a project being open — and + // it is a modal, not a tab, so opening it flips shell state instead of + // navigating. The `/activity` pathname still works as a deep link; the shell + // turns it back into this same flip. const handleOpenActivityPane = useCallback(() => { - onNavigate?.("/attention"); - }, [onNavigate]); + if (onOpenActivityPane) onOpenActivityPane(); + else onNavigate?.("/activity"); + }, [onNavigate, onOpenActivityPane]); // Clicking a project tab while either the personal-chats or account machine // route is foreground must leave it, or ProjectTabHost's route replay never diff --git a/apps/desktop/src/renderer/components/attention/Activity.css b/apps/desktop/src/renderer/components/attention/Activity.css new file mode 100644 index 000000000..950c826e2 --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/Activity.css @@ -0,0 +1,1207 @@ +/* Activity chrome: the tone system every Activity surface shares, the row + chrome for `ActivityCard`, the expanded pane (split columns + slide-over + detail), and the settings popover. + + This replaces `AttentionCenter.css`, which was a 1761-line page stylesheet + for a route that no longer exists. Almost none of it survived on purpose: + the container changed from a full-page route to a modal, and the rows became + Tailwind `ActivityCard`s, so the `attention-item-*` / `attention-roster-*` / + `attention-breadcrumb-*` families died with the components that used them. + What was worth keeping — the tone table, the plan-progress bar, the recent + activity rail, and the settings popover — was copied by hand rather than + swept, because a mechanical `attention-` → `activity-` rename would have + produced `activity-activity-list`. + + Rows are Tailwind; only what needs a per-tone colour lives here, driven by + the `--tone-color` variable each `.activity-tone-*` class sets. */ + +/* ── Tones ────────────────────────────────────────────────────────────── + One hue, one meaning — the same table as + `shared/sessionStatusPresentation.ts`. Unscoped on purpose: a tone class + means the same thing in the header popover, the pane, and the detail sheet, + and scoping it per surface is how three copies drift apart. */ + +.activity-tone-amber { --tone-color: #fbbf24; } +.activity-tone-red { --tone-color: #f87171; } +.activity-tone-violet { --tone-color: #a78bfa; } +.activity-tone-blue { --tone-color: #60a5fa; } +.activity-tone-cyan { --tone-color: #22d3ee; } +.activity-tone-emerald { --tone-color: #34d399; } +.activity-tone-neutral { --tone-color: #a1a1aa; } + +/* 400-level tones sit near 1.7:1 on a white card; light mode uses the 600/700 + equivalents so pills and dots stay legible instead of washing out. */ +[data-theme="light"] .activity-tone-amber { --tone-color: #b45309; } +[data-theme="light"] .activity-tone-red { --tone-color: #dc2626; } +[data-theme="light"] .activity-tone-violet { --tone-color: #6d28d9; } +[data-theme="light"] .activity-tone-blue { --tone-color: #1d4ed8; } +[data-theme="light"] .activity-tone-cyan { --tone-color: #0e7490; } +[data-theme="light"] .activity-tone-emerald { --tone-color: #047857; } +[data-theme="light"] .activity-tone-neutral { --tone-color: #52525b; } + +/* ── Row chrome (the card body itself is Tailwind) ──────────────────── */ + +.activity-card { + --tone-color: #a1a1aa; + transition: background-color 120ms ease, box-shadow 120ms ease; +} + +.activity-card::before { + content: ""; + position: absolute; + left: 0; + top: 8px; + bottom: 8px; + width: 2px; + border-radius: 999px; + background: var(--tone-color); + opacity: 0; + transition: opacity 120ms ease; +} + +.activity-card:hover, +.activity-card:focus-visible, +.activity-card[data-selected="true"] { + background: color-mix(in srgb, var(--color-fg) 6%, transparent); + outline: none; +} + +.activity-card:hover::before, +.activity-card:focus-visible::before, +.activity-card[data-selected="true"]::before { + opacity: 1; +} + +.activity-card:focus-visible { + box-shadow: 0 0 0 1px color-mix(in srgb, var(--tone-color) 55%, transparent); +} + +/* The lane is the row's identity line. Accent, not tone: a lane's colour must + not change because its agent's phase did. */ +.activity-card-lane { + color: color-mix(in srgb, var(--color-accent) 82%, var(--color-fg)); +} + +.activity-card-unseen { + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--tone-color); +} + +/* ── Pane shell ─────────────────────────────────────────────────────── */ + +.activity-pane { + --activity-fs-2xs: 10px; + --activity-fs-xs: 11px; + --activity-fs-sm: 12px; + --activity-fs-md: 13px; + --activity-hairline: color-mix(in srgb, var(--color-border) 70%, transparent); + --activity-surface: color-mix(in srgb, var(--color-card) 94%, var(--color-bg)); + --activity-sunken: color-mix(in srgb, var(--color-bg) 45%, transparent); + --tone-color: #a1a1aa; + color: var(--color-fg); + background: var(--activity-surface); +} + +.activity-pane:focus-visible, +.activity-pane [role="dialog"]:focus-visible { + outline: none; +} + +.activity-pane-head { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 8px; + padding: 10px 10px 10px 14px; + border-bottom: 1px solid var(--activity-hairline); +} + +.activity-pane-head h2 { + margin: 0; + flex-shrink: 0; + font-size: 14px; + font-weight: 650; + letter-spacing: -0.01em; +} + +.activity-pane-machines { + min-width: 0; + flex: 1; + overflow: hidden; + color: var(--color-muted-fg); + font-size: var(--activity-fs-xs); + text-overflow: ellipsis; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.activity-pane-freshness { + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: 4px; + padding: 3px 7px; + border: 1px solid var(--activity-hairline); + border-radius: 999px; + background: color-mix(in srgb, var(--color-card) 60%, transparent); + color: var(--color-muted-fg); + font-size: var(--activity-fs-2xs); + font-weight: 600; +} + +button.activity-pane-freshness { + cursor: pointer; +} + +.activity-pane-freshness.is-error { + border-color: color-mix(in srgb, var(--color-error, #ef4444) 45%, transparent); + color: var(--color-error, #ef4444); +} + +.activity-pane-icon-button { + display: inline-flex; + height: 24px; + width: 24px; + flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: 7px; + color: var(--color-muted-fg); + transition: background-color 120ms ease, color 120ms ease; +} + +.activity-pane-icon-button:hover, +.activity-pane-icon-button:focus-visible { + background: color-mix(in srgb, var(--color-fg) 8%, transparent); + color: var(--color-fg); + outline: none; +} + +.activity-pane-spin { + animation: activity-spin 1.1s linear infinite; +} + +@keyframes activity-spin { + to { transform: rotate(360deg); } +} + +.activity-pane-alert { + display: flex; + flex-shrink: 0; + align-items: flex-start; + gap: 7px; + padding: 8px 14px; + border-bottom: 1px solid var(--activity-hairline); + background: color-mix(in srgb, var(--color-error, #ef4444) 10%, transparent); + color: var(--color-error, #ef4444); + font-size: var(--activity-fs-xs); + line-height: 1.45; +} + +/* ── Filters ────────────────────────────────────────────────────────── */ + +.activity-filters { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-bottom: 1px solid var(--activity-hairline); +} + +.activity-filter { + position: relative; +} + +.activity-filter-trigger { + display: inline-flex; + align-items: center; + gap: 5px; + height: 24px; + padding: 0 8px; + border: 1px solid var(--activity-hairline); + border-radius: 999px; + background: color-mix(in srgb, var(--color-card) 60%, transparent); + color: var(--color-muted-fg); + font-size: var(--activity-fs-xs); + font-weight: 600; + transition: color 120ms ease, border-color 120ms ease, background-color 120ms ease; +} + +.activity-filter-trigger:hover, +.activity-filter-trigger:focus-visible { + color: var(--color-fg); + outline: none; +} + +.activity-filter-trigger[data-active="true"] { + border-color: color-mix(in srgb, var(--color-accent) 45%, transparent); + background: color-mix(in srgb, var(--color-accent) 14%, transparent); + color: var(--color-accent); +} + +.activity-filter-menu { + position: absolute; + z-index: 5; + top: calc(100% + 5px); + left: 0; + display: flex; + min-width: 190px; + max-height: 280px; + flex-direction: column; + gap: 1px; + overflow-y: auto; + padding: 4px; + border: 1px solid var(--activity-hairline); + border-radius: 10px; + background: color-mix(in srgb, var(--color-card) 97%, var(--color-bg)); + box-shadow: 0 22px 55px -25px rgba(0, 0, 0, 0.75); +} + +.activity-filter-option { + display: flex; + align-items: center; + gap: 7px; + padding: 6px 7px; + border-radius: 7px; + color: var(--color-fg); + font-size: var(--activity-fs-sm); + text-align: left; +} + +.activity-filter-option:hover, +.activity-filter-option:focus-visible { + background: color-mix(in srgb, var(--color-fg) 6%, transparent); + outline: none; +} + +.activity-filter-option > span:first-child { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.activity-filter-clear { + flex-shrink: 0; + padding: 0 6px; + color: var(--color-muted-fg); + font-size: var(--activity-fs-xs); + font-weight: 600; +} + +.activity-filter-clear:hover, +.activity-filter-clear:focus-visible { + color: var(--color-fg); + outline: none; + text-decoration: underline; +} + +/* ── Split body ─────────────────────────────────────────────────────── */ + +.activity-pane-body { + position: relative; + display: grid; + min-height: 0; + flex: 1; + grid-template-columns: minmax(0, 1.55fr) minmax(280px, 1fr); + overflow: hidden; +} + +.activity-column { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + overflow: hidden; +} + +.activity-column + .activity-column { + border-left: 1px solid var(--activity-hairline); + background: var(--activity-sunken); +} + +.activity-column-head { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 8px; + padding: 9px 12px 8px; + border-bottom: 1px solid var(--activity-hairline); +} + +.activity-column-head h3 { + margin: 0; + font-size: var(--activity-fs-2xs); + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--color-muted-fg); +} + +.activity-column-head-count { + flex: 1; + color: var(--color-muted-fg); + font-family: var(--font-mono); + font-size: var(--activity-fs-2xs); + font-variant-numeric: tabular-nums; +} + +.activity-column-action { + flex-shrink: 0; + padding: 2px 6px; + border-radius: 6px; + color: var(--color-muted-fg); + font-size: var(--activity-fs-xs); + font-weight: 600; + transition: color 120ms ease, background-color 120ms ease; +} + +.activity-column-action:hover, +.activity-column-action:focus-visible { + color: var(--color-fg); + background: color-mix(in srgb, var(--color-fg) 6%, transparent); + outline: none; +} + +.activity-column-scroll { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 1px; + overflow-y: auto; + padding: 5px; +} + +.activity-section-heading { + position: sticky; + top: -5px; + z-index: 1; + display: flex; + align-items: center; + gap: 6px; + margin: 0; + padding: 8px 7px 5px; + background: color-mix(in srgb, var(--color-card) 94%, var(--color-bg)); + color: var(--color-muted-fg); + font-size: var(--activity-fs-2xs); + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.activity-section-dot { + width: 6px; + height: 6px; + flex-shrink: 0; + border-radius: 999px; + background: var(--tone-color); +} + +.activity-section-count { + color: var(--tone-color); + font-family: var(--font-mono); + font-size: var(--activity-fs-2xs); + font-variant-numeric: tabular-nums; +} + +/* An offline machine's rows are memory, not observation: label the boundary + once and dim what follows rather than repeating a warning per row. */ +.activity-offline-divider { + display: flex; + align-items: center; + gap: 6px; + margin: 6px 7px 3px; + padding-top: 6px; + border-top: 1px dashed color-mix(in srgb, var(--color-border) 80%, transparent); + color: var(--color-muted-fg); + font-size: var(--activity-fs-2xs); + font-weight: 600; +} + +.activity-offline-group { + display: flex; + flex-direction: column; + gap: 1px; + opacity: 0.55; +} + +.activity-more { + align-self: flex-start; + margin: 4px 0 6px 8px; + padding: 3px 7px; + border-radius: 7px; + color: var(--color-muted-fg); + font-size: var(--activity-fs-xs); + font-weight: 600; +} + +.activity-more:hover, +.activity-more:focus-visible { + color: var(--color-fg); + background: color-mix(in srgb, var(--color-fg) 6%, transparent); + outline: none; +} + +/* ── Inbox rows ─────────────────────────────────────────────────────── */ + +.activity-inbox-row { + position: relative; + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 7px 8px; + border-radius: 9px; + text-align: left; + transition: background-color 120ms ease; +} + +.activity-inbox-row:hover, +.activity-inbox-row:focus-within { + background: color-mix(in srgb, var(--color-fg) 6%, transparent); +} + +.activity-inbox-open { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 8px; + text-align: left; +} + +.activity-inbox-open:focus-visible { + outline: none; +} + +.activity-inbox-icon { + display: inline-flex; + height: 24px; + width: 24px; + flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: 7px; + border: 1px solid color-mix(in srgb, var(--tone-color) 26%, transparent); + background: color-mix(in srgb, var(--tone-color) 11%, transparent); + color: var(--tone-color); +} + +.activity-inbox-copy { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; +} + +.activity-inbox-copy strong { + overflow: hidden; + font-size: var(--activity-fs-sm); + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.activity-inbox-copy span { + overflow: hidden; + color: var(--color-muted-fg); + font-size: var(--activity-fs-xs); + text-overflow: ellipsis; + white-space: nowrap; +} + +.activity-inbox-dismiss { + display: inline-flex; + height: 22px; + width: 22px; + flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: 6px; + color: var(--color-muted-fg); + opacity: 0; + transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease; +} + +.activity-inbox-row:hover .activity-inbox-dismiss, +.activity-inbox-dismiss:focus-visible { + opacity: 1; +} + +.activity-inbox-dismiss:hover, +.activity-inbox-dismiss:focus-visible { + background: color-mix(in srgb, var(--color-fg) 9%, transparent); + color: var(--color-fg); + outline: none; +} + +/* ── Detail sheet ───────────────────────────────────────────────────── */ + +/* The sheet slides over both columns rather than replacing one, so the list + you came from stays where your eye left it. */ +.activity-sheet-scrim { + position: absolute; + inset: 0; + z-index: 2; + background: color-mix(in srgb, var(--color-bg) 55%, transparent); + backdrop-filter: blur(2px); +} + +.activity-sheet { + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 3; + display: flex; + width: 62%; + min-width: 380px; + flex-direction: column; + overflow: hidden; + border-left: 1px solid var(--activity-hairline); + background: color-mix(in srgb, var(--color-card) 98%, var(--color-bg)); + box-shadow: -24px 0 60px -32px rgba(0, 0, 0, 0.75); + transform: translateX(0); +} + +@media (prefers-reduced-motion: no-preference) { + .activity-sheet { + animation: activity-sheet-in 180ms cubic-bezier(0.2, 0.8, 0.2, 1); + } +} + +@keyframes activity-sheet-in { + from { transform: translateX(14px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +.activity-sheet-head { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 8px; + padding: 9px 10px 9px 8px; + border-bottom: 1px solid var(--activity-hairline); +} + +.activity-sheet-breadcrumb { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 5px; + overflow: hidden; + color: var(--color-muted-fg); + font-size: var(--activity-fs-xs); + text-overflow: ellipsis; + white-space: nowrap; +} + +.activity-sheet-body { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 16px; + overflow-y: auto; + padding: 16px; +} + +.activity-sheet-title { + margin: 0; + font-size: 16px; + font-weight: 640; + letter-spacing: -0.01em; +} + +.activity-sheet-kicker { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + color: var(--tone-color); + font-size: var(--activity-fs-xs); + font-weight: 650; +} + +.activity-sheet-note { + margin: 8px 0 0; + color: var(--color-muted-fg); + font-size: var(--activity-fs-md); + font-style: italic; + line-height: 1.55; + overflow-wrap: anywhere; +} + +.activity-sheet-meta { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + color: var(--color-muted-fg); + font-size: var(--activity-fs-xs); +} + +.activity-sheet-meta > span { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.activity-sheet-section h4 { + margin: 0 0 8px; + color: var(--color-muted-fg); + font-size: var(--activity-fs-2xs); + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.activity-sheet-actions { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.activity-action { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 11px; + border: 1px solid var(--activity-hairline); + border-radius: 8px; + color: var(--color-fg); + font-size: var(--activity-fs-sm); + font-weight: 600; + transition: background-color 120ms ease, border-color 120ms ease; +} + +.activity-action[data-tone="primary"] { + border-color: color-mix(in srgb, var(--color-accent) 55%, transparent); + background: color-mix(in srgb, var(--color-accent) 70%, var(--color-accent-deep, var(--color-accent))); + color: #fff; +} + +.activity-action[data-tone="danger"] { + border-color: color-mix(in srgb, #f87171 45%, transparent); + color: #f87171; +} + +.activity-action[data-tone="secondary"]:hover, +.activity-action[data-tone="ghost"]:hover { + background: color-mix(in srgb, var(--color-fg) 7%, transparent); +} + +.activity-action:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.activity-progress-track { + height: 5px; + margin-top: 10px; + overflow: hidden; + border-radius: 99px; + background: color-mix(in srgb, var(--color-fg) 8%, transparent); +} + +.activity-progress-fill { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, color-mix(in srgb, var(--tone-color) 72%, white), var(--tone-color)); + box-shadow: 0 0 12px color-mix(in srgb, var(--tone-color) 35%, transparent); + transition: width 260ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.activity-plan-current { + margin: 9px 0 0; + color: var(--color-muted-fg); + font-size: var(--activity-fs-md); + overflow-wrap: anywhere; +} + +/* A vertical rail through the bullets: the list is a sequence, and the rail is + what says so without numbering it. */ +.activity-recent { + position: relative; + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; +} + +.activity-recent::before { + content: ""; + position: absolute; + top: 10px; + bottom: 10px; + left: 3px; + width: 1px; + background: color-mix(in srgb, var(--color-border) 85%, transparent); +} + +.activity-recent li { + position: relative; + display: flex; + gap: 9px; + padding: 5px 0; + color: color-mix(in srgb, var(--color-muted-fg) 92%, transparent); + font-size: var(--activity-fs-md); + line-height: 1.45; + overflow-wrap: anywhere; +} + +.activity-recent-node { + z-index: 1; + width: 7px; + height: 7px; + flex: 0 0 auto; + margin-top: 5px; + border: 2px solid color-mix(in srgb, var(--color-card) 82%, var(--color-bg)); + border-radius: 99px; + background: color-mix(in srgb, var(--tone-color) 68%, var(--color-muted-fg)); +} + +.activity-sheet-banner { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 9px 11px; + border: 1px solid color-mix(in srgb, var(--color-border) 80%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--color-fg) 4%, transparent); + color: var(--color-muted-fg); + font-size: var(--activity-fs-xs); + line-height: 1.45; +} + +.activity-sheet-banner[data-tone="error"] { + border-color: color-mix(in srgb, #f87171 30%, transparent); + background: color-mix(in srgb, #f87171 8%, transparent); + color: #f87171; +} + +/* ── Empty states ───────────────────────────────────────────────────── */ + +.activity-empty { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 40px 26px; + text-align: center; + color: var(--color-muted-fg); +} + +.activity-empty strong { + color: var(--color-fg); + font-size: var(--activity-fs-md); + font-weight: 650; +} + +.activity-empty p { + margin: 0; + max-width: 32ch; + font-size: var(--activity-fs-xs); + line-height: 1.5; +} + +/* All-clear is a state worth designing, not a gap to apologise for. */ +.activity-calm-dot { + position: relative; + width: 9px; + height: 9px; + margin-bottom: 4px; + border-radius: 999px; + background: color-mix(in srgb, #34d399 78%, transparent); +} + +.activity-calm-dot::after { + content: ""; + position: absolute; + inset: -6px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, #34d399 26%, transparent); + animation: activity-calm 3.6s ease-in-out infinite; +} + +@keyframes activity-calm { + 0%, 100% { opacity: 0.55; transform: scale(0.9); } + 50% { opacity: 0.15; transform: scale(1.12); } +} + +/* ── Settings popover ─────────────────────────────────────────────────── + Moved verbatim (bar the tokens it used to inherit from the center's page + scope) so the gear behaves the same wherever it is mounted. */ + +.attention-settings-wrap { + position: relative; +} + +.attention-settings-trigger { + display: inline-flex; + width: 24px; + height: 24px; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 7px; + background: transparent; + color: color-mix(in srgb, var(--color-muted-fg) 92%, transparent); + transition: color 140ms ease, border-color 140ms ease, background 140ms ease, transform 140ms ease; +} + +.attention-settings-trigger:hover, +.attention-settings-trigger[aria-expanded="true"] { + color: var(--color-fg); + border-color: color-mix(in srgb, var(--color-accent) 24%, var(--color-border)); + background: color-mix(in srgb, var(--color-accent) 8%, var(--color-card)); +} + +.attention-settings-trigger:active { + transform: scale(0.94); +} + +.attention-settings-popover { + position: absolute; + top: calc(100% + 9px); + right: 0; + z-index: 80; + width: min(400px, calc(100vw - 32px)); + max-height: min(560px, calc(100vh - 120px)); + overflow-y: auto; + border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent); + border-radius: 16px; + background: color-mix(in srgb, var(--color-card) 97%, var(--color-bg)); + box-shadow: 0 30px 80px -32px rgba(0, 0, 0, 0.86); + backdrop-filter: blur(30px) saturate(1.25); + transform-origin: top right; + color: var(--color-fg); +} + +.attention-settings-popover:focus { + outline: none; +} + +.attention-settings-popover > header { + position: sticky; + top: 0; + z-index: 1; + display: flex; + min-height: 52px; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 11px 13px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 68%, transparent); + background: color-mix(in srgb, var(--color-card) 96%, var(--color-bg)); +} + +.attention-settings-popover > header > div { + display: flex; + min-width: 0; + align-items: center; + gap: 9px; +} + +.attention-settings-popover > header > div > span:last-child { + display: flex; + min-width: 0; + flex-direction: column; +} + +.attention-settings-popover > header strong { + font-size: 12px; + font-weight: 660; + letter-spacing: -0.01em; +} + +.attention-settings-popover > header small { + margin-top: 2px; + color: color-mix(in srgb, var(--color-muted-fg) 88%, transparent); + font-size: 11px; +} + +.attention-settings-heading-icon { + display: inline-flex; + width: 31px; + height: 31px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid color-mix(in srgb, var(--color-accent) 24%, transparent); + border-radius: 9px; + background: color-mix(in srgb, var(--color-accent) 9%, transparent); + color: var(--color-accent-bright, var(--color-accent)); +} + +.attention-settings-account-badge { + flex: 0 0 auto; + padding: 3px 7px; + border: 1px solid color-mix(in srgb, var(--color-accent) 22%, transparent); + border-radius: 99px; + background: color-mix(in srgb, var(--color-accent) 7%, transparent); + color: color-mix(in srgb, var(--color-accent) 55%, var(--color-fg)); + font-size: 10px; + font-weight: 650; + letter-spacing: 0.02em; +} + +.attention-settings-popover section { + padding: 10px 10px 6px; +} + +.attention-settings-popover section + section { + padding-top: 9px; + border-top: 1px solid color-mix(in srgb, var(--color-border) 68%, transparent); +} + +.attention-settings-popover section h3 { + margin: 0 0 5px 3px; + color: color-mix(in srgb, var(--color-muted-fg) 88%, transparent); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.attention-settings-row { + display: grid; + min-height: 44px; + grid-template-columns: 30px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + padding: 7px; + border-radius: 10px; + transition: background 130ms ease; +} + +.attention-settings-row:hover { + background: color-mix(in srgb, var(--color-fg) 4%, transparent); +} + +.attention-settings-row[data-disabled] { + opacity: 0.5; +} + +.attention-settings-row-icon { + display: inline-flex; + width: 29px; + height: 29px; + align-items: center; + justify-content: center; + border: 1px solid color-mix(in srgb, var(--color-border) 68%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--color-bg) 46%, transparent); + color: color-mix(in srgb, var(--color-accent) 43%, var(--color-muted-fg)); +} + +.attention-settings-row-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.attention-settings-row-copy > span { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; +} + +.attention-settings-row-copy strong { + font-size: 12px; + font-weight: 630; +} + +.attention-settings-row-copy small { + flex: 0 0 auto; + padding: 2px 5px; + border-radius: 4px; + background: color-mix(in srgb, var(--color-accent) 10%, transparent); + color: color-mix(in srgb, var(--color-accent) 50%, var(--color-fg)); + font-size: 10px; + font-weight: 650; +} + +.attention-settings-row-copy em { + margin-top: 3px; + color: color-mix(in srgb, var(--color-muted-fg) 88%, transparent); + font-size: 11px; + font-style: normal; + line-height: 1.35; +} + +.attention-settings-row select { + width: 148px; + height: 27px; + padding: 0 7px; + border: 1px solid color-mix(in srgb, var(--color-border) 68%, transparent); + border-radius: 7px; + background: color-mix(in srgb, var(--color-bg) 62%, var(--color-card)); + color: var(--color-fg); + font-family: var(--font-sans); + font-size: 11px; +} + +.attention-settings-switch { + position: relative; + width: 32px; + height: 19px; + flex: 0 0 auto; + padding: 0; + border: 1px solid color-mix(in srgb, var(--color-border) 90%, transparent); + border-radius: 99px; + background: color-mix(in srgb, var(--color-muted) 80%, transparent); + transition: border-color 150ms ease, background 150ms ease; +} + +.attention-settings-switch > span { + position: absolute; + top: 2px; + left: 2px; + width: 13px; + height: 13px; + border-radius: 99px; + background: color-mix(in srgb, var(--color-muted-fg) 82%, white); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + transition: transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1), background 150ms ease; +} + +.attention-settings-switch[aria-checked="true"] { + border-color: color-mix(in srgb, var(--color-accent) 50%, transparent); + background: color-mix(in srgb, var(--color-accent) 62%, var(--color-accent-deep, var(--color-accent))); +} + +.attention-settings-switch[aria-checked="true"] > span { + background: #fff; + transform: translateX(13px); +} + +.attention-settings-machines { + display: flex; + flex-direction: column; + gap: 1px; +} + +.attention-settings-loading { + display: flex; + min-height: 180px; + align-items: center; + justify-content: center; + gap: 9px; + color: color-mix(in srgb, var(--color-muted-fg) 88%, transparent); + font-size: 13px; +} + +.attention-settings-loading > span { + width: 14px; + height: 14px; + border: 2px solid color-mix(in srgb, var(--color-accent) 18%, transparent); + border-top-color: var(--color-accent); + border-radius: 50%; + animation: activity-spin 700ms linear infinite; +} + +.attention-settings-error { + display: flex; + align-items: flex-start; + gap: 7px; + margin: 5px 10px 8px; + padding: 8px 9px; + border: 1px solid color-mix(in srgb, #f87171 26%, transparent); + border-radius: 8px; + background: color-mix(in srgb, #f87171 7%, transparent); + color: #f87171; + font-size: 11px; + line-height: 1.4; +} + +.attention-settings-error svg { + flex: 0 0 auto; + margin-top: 1px; +} + +.attention-settings-popover > footer { + position: sticky; + bottom: 0; + display: flex; + min-height: 46px; + align-items: center; + gap: 7px; + padding: 9px 10px; + border-top: 1px solid color-mix(in srgb, var(--color-border) 68%, transparent); + background: color-mix(in srgb, var(--color-card) 96%, var(--color-bg)); +} + +.attention-settings-popover > footer > span { + display: inline-flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 5px; + color: color-mix(in srgb, var(--color-muted-fg) 82%, transparent); + font-size: 11px; +} + +.attention-settings-open-full { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + margin-top: 4px; + padding: 8px 10px; + border: 1px solid color-mix(in srgb, var(--color-border) 85%, transparent); + border-radius: 9px; + background: color-mix(in srgb, var(--color-fg) 4%, transparent); + color: var(--color-secondary-fg); + font-size: 12px; + font-weight: 500; + transition: background 120ms ease, color 120ms ease; +} + +.attention-settings-open-full:hover, +.attention-settings-open-full:focus-visible { + color: var(--color-fg); + background: color-mix(in srgb, var(--color-fg) 8%, transparent); + outline: none; +} + +/* ── Responsive and motion ──────────────────────────────────────────── */ + +/* Below this the inbox column cannot hold a title and a meta line side by side + with the sessions list, so the sheet takes the whole width instead of + leaving a 120px sliver of columns nobody can read. */ +@media (max-width: 1080px) { + .activity-pane-body { + grid-template-columns: minmax(0, 1fr) minmax(240px, 0.8fr); + } + + .activity-sheet { + width: 100%; + min-width: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .activity-pane *, + .activity-pane *::before, + .activity-pane *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } + + .activity-calm-dot::after { + animation: none; + } +} diff --git a/apps/desktop/src/renderer/components/attention/ActivityCard.tsx b/apps/desktop/src/renderer/components/attention/ActivityCard.tsx index eba83ffb5..f7b5a95ee 100644 --- a/apps/desktop/src/renderer/components/attention/ActivityCard.tsx +++ b/apps/desktop/src/renderer/components/attention/ActivityCard.tsx @@ -8,6 +8,10 @@ import { SessionStatusLabel } from "../terminals/SessionStatusLabel"; import { LaneIcon } from "../ui/vcsIcons"; import { cn } from "../ui/cn"; import { activityItemPresentation } from "./attentionPresentation"; +// The row carries its own chrome and the shared tone table, so every surface +// that can render an `ActivityCard` gets both without importing a stylesheet +// it does not otherwise use. +import "./Activity.css"; /* ── Why this is not `terminals/SessionCard` ─────────────────────────────── Three reasons, all of them load-bearing. Anyone tempted to "simplify" this @@ -120,6 +124,8 @@ export type ActivityCardProps = { hideDetails?: boolean; /** Two-line form for dense mirrors (notch panel, mobile hub strip). */ compact?: boolean; + /** Keeps the hover treatment on the row whose detail is open. */ + selected?: boolean; }; /** @@ -132,6 +138,7 @@ export function ActivityCard({ onOpen, hideDetails = false, compact = false, + selected = false, }: ActivityCardProps) { const presentation = activityItemPresentation(item); const tone = presentation?.tone ?? "neutral"; @@ -155,6 +162,7 @@ export function ActivityCard({ type="button" data-activity-row={item.id} data-activity-tone={tone} + data-selected={selected ? "true" : undefined} className={cn( "activity-card group/activity relative block w-full rounded-lg text-left", `activity-tone-${tone}`, diff --git a/apps/desktop/src/renderer/components/attention/ActivityDetailSheet.tsx b/apps/desktop/src/renderer/components/attention/ActivityDetailSheet.tsx new file mode 100644 index 000000000..1858b40d3 --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/ActivityDetailSheet.tsx @@ -0,0 +1,275 @@ +import React, { useEffect, useRef } from "react"; +import { + ArrowClockwise, + ArrowSquareOut, + CaretLeft, + Check, + CheckCircle, + Lightning, + WarningCircle, + WifiSlash, + X, + XCircle, +} from "@phosphor-icons/react"; + +import type { AttentionAction, AttentionItem } from "../../../shared/types"; +import { relativeWhen } from "../../lib/format"; +import { ProviderLogo } from "../shared/ProviderLogos"; +import { SessionStatusLabel } from "../terminals/SessionStatusLabel"; +import { cn } from "../ui/cn"; +import { activityCardPreview } from "./ActivityCard"; +import { activityItemPresentation, attentionActionTone } from "./attentionPresentation"; + +function actionIcon(action: AttentionAction): React.ElementType { + if (action.kind === "approve") return Check; + if (action.kind === "deny") return X; + if (action.kind === "restart" || action.kind === "rerun_checks") return ArrowClockwise; + if (action.kind === "open") return ArrowSquareOut; + if (action.kind === "dismiss") return XCircle; + if (action.kind === "mark_seen") return CheckCircle; + return Lightning; +} + +/** Seen and dismiss are local bookkeeping; everything else needs the machine. */ +function actionWorksOffline(action: AttentionAction): boolean { + return action.kind === "mark_seen" || action.kind === "dismiss"; +} + +/** + * The slide-over detail. It covers the columns rather than replacing one, so + * the row you came from is still where you left it when you close the sheet. + * + * There is no placeholder twin of this component. The old center rendered a + * "Ready when you are" card whenever nothing was selected and again whenever a + * selected item happened to carry no detail — an apology for a state that only + * ever meant "you have not clicked anything yet". Nothing selected now renders + * nothing at all. + */ +export function ActivityDetailSheet({ + item, + hideDetails, + pendingActionId, + errorMessage, + onClose, + onOpen, + onAction, +}: { + item: AttentionItem; + hideDetails: boolean; + pendingActionId: string | null; + errorMessage: string | null; + onClose: () => void; + onOpen: (item: AttentionItem) => void; + onAction: (item: AttentionItem, action: AttentionAction) => void; +}) { + const sheetRef = useRef(null); + const presentation = activityItemPresentation(item); + const tone = presentation?.tone ?? "neutral"; + const note = activityCardPreview(item, hideDetails); + const planTotal = Math.max(0, item.planProgress?.total ?? 0); + const planCompleted = Math.min(planTotal, Math.max(0, item.planProgress?.completed ?? 0)); + const planPercent = planTotal > 0 ? Math.round((planCompleted / planTotal) * 100) : 0; + const actions = item.actions.filter( + (action) => action.kind !== "open" && action.kind !== "mark_seen", + ); + + // Focus lands in the sheet so Escape, Tab and a screen reader all agree that + // this is the thing on top now. + useEffect(() => { + sheetRef.current?.focus(); + }, [item.id]); + + return ( + <> + + + {item.machine.name} + / + {item.project.name} + {item.laneName ? ( + <> + / + {item.laneName} + + ) : null} + + + +
+ +
+
+
+ + {presentation?.label ?? "Tracked"} + · + + {relativeWhen(item.updatedAt)} + +
+

{item.title}

+ {note ?

{note}

: null} +
+ +
+ {item.model ? {item.model} : null} + + {item.machine.name} + {item.machine.online + ? " · online" + : item.machine.lastSeenAt + ? ` · last seen ${relativeWhen(item.machine.lastSeenAt)}` + : " · offline"} + + {item.project.name} + {item.laneName ? {item.laneName} : null} +
+ + {item.machine.online ? null : ( +
+ + + {item.machine.name} is offline.{" "} + This is its last-known state. Remote actions unlock when it reconnects. + +
+ )} + + {errorMessage ? ( +
+ + {errorMessage} +
+ ) : null} + + {actions.length > 0 ? ( +
+

Actions

+
+ {actions.map((action) => { + const blocked = !item.machine.online && !actionWorksOffline(action); + const Icon = actionIcon(action); + return ( + + ); + })} +
+
+ ) : null} + + {item.planProgress ? ( +
+

Plan progress

+
+
+
+

+ {planCompleted} of {planTotal} + {item.planProgress.current ? ` · ${item.planProgress.current}` : ""} +

+
+ ) : null} + + {item.recentActivity?.length ? ( +
+

Recent activity

+
    + {item.recentActivity.slice(0, 8).map((entry, index) => ( +
  1. + + {entry} +
  2. + ))} +
+
+ ) : null} + +
+ + +
+
+
+ + ); +} + +export default ActivityDetailSheet; diff --git a/apps/desktop/src/renderer/components/attention/ActivityFilters.test.tsx b/apps/desktop/src/renderer/components/attention/ActivityFilters.test.tsx new file mode 100644 index 000000000..7b84df2d9 --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/ActivityFilters.test.tsx @@ -0,0 +1,158 @@ +// @vitest-environment jsdom + +import React from "react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ATTENTION_CONTRACT_VERSION, type AttentionItem } from "../../../shared/types"; +import { + ActivityFilters, + activityFiltersAreEmpty, + applyActivityFilters, + EMPTY_ACTIVITY_FILTERS, +} from "./ActivityFilters"; + +function item(id: string, patch: Partial = {}): AttentionItem { + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + id, + revision: 1, + fingerprint: `fingerprint-${id}`, + kind: "agent", + eventKind: "agent_running", + phase: "running", + machine: { machineKey: "studio", name: "Studio Mac", online: true, lastSeenAt: null }, + project: { projectId: "ade", name: "ADE", rootPath: "/repo/ade" }, + provider: "codex", + model: "GPT-5", + title: `Task ${id}`, + preview: "", + privacyPreview: "", + destination: { kind: "session", sessionId: `session-${id}` }, + actions: [], + occurredAt: "2026-07-28T14:00:00.000Z", + updatedAt: "2026-07-28T14:00:00.000Z", + seenAt: null, + dismissedAt: null, + expiresAt: null, + ...patch, + }; +} + +afterEach(cleanup); + +describe("applyActivityFilters", () => { + const studio = item("studio"); + const laptop = item("laptop", { + machine: { machineKey: "laptop", name: "MacBook", online: true, lastSeenAt: null }, + model: "Claude Opus 5", + }); + const pr = item("pr", { + kind: "pull_request", + model: null, + provider: null, + machine: { machineKey: "cloud", name: "Cloud Mac", online: true, lastSeenAt: null }, + }); + const items = [studio, laptop, pr]; + + it("treats an empty axis as everything", () => { + expect(activityFiltersAreEmpty(EMPTY_ACTIVITY_FILTERS)).toBe(true); + expect(applyActivityFilters(items, EMPTY_ACTIVITY_FILTERS)).toHaveLength(3); + }); + + it("intersects across axes and unions within one", () => { + expect( + applyActivityFilters(items, { ...EMPTY_ACTIVITY_FILTERS, machineKeys: ["studio", "laptop"] }) + .map((entry) => entry.id), + ).toEqual(["studio", "laptop"]); + + expect( + applyActivityFilters(items, { + machineKeys: ["laptop"], + kinds: ["agent"], + models: [], + }).map((entry) => entry.id), + ).toEqual(["laptop"]); + }); + + it("excludes items with no model from a model filter", () => { + // "Which model is running" is a claim an item without one cannot make, so + // it must not sneak through as a wildcard match. + expect( + applyActivityFilters(items, { ...EMPTY_ACTIVITY_FILTERS, models: ["GPT-5"] }) + .map((entry) => entry.id), + ).toEqual(["studio"]); + }); +}); + +describe("ActivityFilters", () => { + it("offers only options the snapshot actually contains", () => { + render( + {}} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Filter by machine" })); + expect(screen.getAllByRole("menuitemcheckbox").map((node) => node.textContent)) + .toEqual(["Studio Mac"]); + }); + + it("hides an axis with nothing to choose from", () => { + render( + {}} + />, + ); + + expect(screen.queryByRole("button", { name: "Filter by model" })).toBeNull(); + }); + + it("toggles a value on and back off", () => { + const onChange = vi.fn(); + const items = [item("studio")]; + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Filter by machine" })); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Studio Mac" })); + expect(onChange).toHaveBeenCalledWith({ ...EMPTY_ACTIVITY_FILTERS, machineKeys: ["studio"] }); + + onChange.mockClear(); + rerender( + , + ); + // The menu is still open from the first click — reopening would close it. + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Studio Mac" })); + expect(onChange).toHaveBeenCalledWith(EMPTY_ACTIVITY_FILTERS); + }); + + it("shows the clear affordance only while something is filtered", () => { + const { rerender } = render( + {}} + />, + ); + expect(screen.queryByRole("button", { name: "Clear filters" })).toBeNull(); + + rerender( + {}} + />, + ); + expect(screen.getByRole("button", { name: "Clear filters" })).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/renderer/components/attention/ActivityFilters.tsx b/apps/desktop/src/renderer/components/attention/ActivityFilters.tsx new file mode 100644 index 000000000..8b8ee7b36 --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/ActivityFilters.tsx @@ -0,0 +1,222 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { CaretDown, Check } from "@phosphor-icons/react"; + +import type { AttentionItem } from "../../../shared/types"; +import { cn } from "../ui/cn"; + +/** + * Activity's three filters — machine, chat type, model. Every option is derived + * from the snapshot on screen rather than a fixed list, so a filter can never + * offer a machine the account no longer has or hide one it just gained. + * + * Selection is a set per axis; an empty set means "everything", which is why + * clearing a filter and selecting all of its options read the same on screen + * and produce the same list. + */ +export type ActivityFilterState = { + machineKeys: string[]; + kinds: AttentionItem["kind"][]; + models: string[]; +}; + +export const EMPTY_ACTIVITY_FILTERS: ActivityFilterState = { + machineKeys: [], + kinds: [], + models: [], +}; + +export function activityFiltersAreEmpty(filters: ActivityFilterState): boolean { + return filters.machineKeys.length === 0 + && filters.kinds.length === 0 + && filters.models.length === 0; +} + +export function applyActivityFilters( + items: readonly AttentionItem[], + filters: ActivityFilterState, +): AttentionItem[] { + if (activityFiltersAreEmpty(filters)) return [...items]; + const machines = new Set(filters.machineKeys); + const kinds = new Set(filters.kinds); + const models = new Set(filters.models); + return items.filter((item) => { + if (machines.size > 0 && !machines.has(item.machine.machineKey)) return false; + if (kinds.size > 0 && !kinds.has(item.kind)) return false; + // An item with no model can only ever match "everything": a model filter is + // a claim about which model is running, and "unknown" is not one. + if (models.size > 0 && !(item.model && models.has(item.model))) return false; + return true; + }); +} + +type FilterOption = { value: string; label: string }; + +const KIND_LABEL: Record = { + agent: "Agents", + pull_request: "Pull requests", +}; + +function optionsFrom( + items: readonly AttentionItem[], + pick: (item: AttentionItem) => { value: string; label: string } | null, +): FilterOption[] { + const byValue = new Map(); + for (const item of items) { + const option = pick(item); + if (!option || !option.value) continue; + if (!byValue.has(option.value)) byValue.set(option.value, option.label); + } + return [...byValue.entries()] + .map(([value, label]) => ({ value, label })) + .sort((left, right) => left.label.localeCompare(right.label)); +} + +function FilterChip({ + label, + options, + selected, + onChange, +}: { + label: string; + options: FilterOption[]; + selected: readonly string[]; + onChange: (next: string[]) => void; +}) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const triggerRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, [open]); + + const toggle = (value: string) => { + onChange( + selected.includes(value) + ? selected.filter((entry) => entry !== value) + : [...selected, value], + ); + }; + + const selectedLabels = options + .filter((option) => selected.includes(option.value)) + .map((option) => option.label); + const summary = selectedLabels.length === 0 + ? label + : selectedLabels.length === 1 + ? selectedLabels[0] + : `${label} · ${selectedLabels.length}`; + + if (options.length === 0) return null; + + return ( +
+ + {open ? ( +
+ {options.map((option) => { + const checked = selected.includes(option.value); + return ( + + ); + })} +
+ ) : null} +
+ ); +} + +export function ActivityFilters({ + items, + filters, + onChange, +}: { + /** Every unfiltered item in the pane — the option lists come from these. */ + items: readonly AttentionItem[]; + filters: ActivityFilterState; + onChange: (next: ActivityFilterState) => void; +}) { + const machineOptions = useMemo( + () => optionsFrom(items, (item) => ({ + value: item.machine.machineKey, + label: item.machine.name, + })), + [items], + ); + const kindOptions = useMemo( + () => optionsFrom(items, (item) => ({ + value: item.kind, + label: KIND_LABEL[item.kind], + })), + [items], + ); + const modelOptions = useMemo( + () => optionsFrom(items, (item) => ( + item.model ? { value: item.model, label: item.model } : null + )), + [items], + ); + + const clear = useCallback(() => onChange(EMPTY_ACTIVITY_FILTERS), [onChange]); + + return ( +
+ onChange({ ...filters, machineKeys })} + /> + onChange({ + ...filters, + kinds: kinds as AttentionItem["kind"][], + })} + /> + onChange({ ...filters, models })} + /> + {activityFiltersAreEmpty(filters) ? null : ( + + )} +
+ ); +} + +export default ActivityFilters; diff --git a/apps/desktop/src/renderer/components/attention/ActivityInboxColumn.tsx b/apps/desktop/src/renderer/components/attention/ActivityInboxColumn.tsx new file mode 100644 index 000000000..c50f4d177 --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/ActivityInboxColumn.tsx @@ -0,0 +1,189 @@ +import React, { useMemo, useState } from "react"; +import { + ArrowsClockwise, + CheckCircle, + CircleDashed, + GitBranch, + GitMerge, + GitPullRequest, + PencilSimpleLine, + Prohibit, + WarningCircle, + X, +} from "@phosphor-icons/react"; + +import { ACTIVITY_EVENT_BY_KIND, type ActivityIconKey } from "../../../shared/activityCatalog"; +import { + attentionItemNeedsInbox, + sortAttentionItems, + type AttentionItem, +} from "../../../shared/types"; +import { relativeWhen } from "../../lib/format"; +import { cn } from "../ui/cn"; +import { activityItemPresentation } from "./attentionPresentation"; + +const INITIAL_ROW_BUDGET = 60; +const ROW_BUDGET_STEP = 60; + +/** The catalog names an icon per event; this is the renderer's half of that. */ +const CATALOG_ICON: Record = { + working: CircleDashed, + "needs-you": WarningCircle, + failed: WarningCircle, + done: CheckCircle, + checks: ArrowsClockwise, + review: PencilSimpleLine, + changes: PencilSimpleLine, + "merge-ready": GitMerge, + "pull-request": GitPullRequest, + closed: Prohibit, +}; + +export function activityInboxItems( + items: readonly AttentionItem[], +): AttentionItem[] { + return sortAttentionItems(items.filter(attentionItemNeedsInbox)); +} + +function InboxRow({ + item, + selected, + onOpen, + onDismiss, +}: { + item: AttentionItem; + selected: boolean; + onOpen: (item: AttentionItem) => void; + onDismiss: (item: AttentionItem) => void; +}) { + const descriptor = ACTIVITY_EVENT_BY_KIND[item.eventKind]; + const Icon = CATALOG_ICON[descriptor?.iconKey ?? "pull-request"] ?? GitBranch; + const tone = activityItemPresentation(item)?.tone ?? "neutral"; + return ( +
+ + +
+ ); +} + +/** + * The right column: the things that would have pushed a notification — raised + * hands, failures, review requests, and finished work nobody has looked at yet. + * Dismiss is per row here because the whole point of the column is that it + * should empty, and a list you can only clear wholesale never does. + */ +export function ActivityInboxColumn({ + items, + selectedItemId, + filtered, + onOpenItem, + onDismissItem, + onClearAll, +}: { + /** Already filtered; inbox eligibility is decided here. */ + items: readonly AttentionItem[]; + selectedItemId: string | null; + filtered: boolean; + onOpenItem: (item: AttentionItem) => void; + onDismissItem: (item: AttentionItem) => void; + onClearAll: (items: readonly AttentionItem[]) => void; +}) { + const [budget, setBudget] = useState(INITIAL_ROW_BUDGET); + const inbox = useMemo(() => activityInboxItems(items), [items]); + const shown = inbox.slice(0, budget); + const hidden = inbox.length - shown.length; + + return ( +
+
+

Inbox

+ {inbox.length} + {inbox.length > 0 ? ( + + ) : null} +
+
+ {inbox.length === 0 ? ( +
+ {filtered ? ( + <> + Nothing here matches +

Clear a filter to see the rest of your inbox.

+ + ) : ( + <> + + Inbox zero +

+ Failures, review requests, and finished work you haven’t seen + collect here. +

+ + )} +
+ ) : ( + <> + {shown.map((item) => ( + + ))} + {hidden > 0 ? ( + + ) : null} + + )} +
+
+ ); +} + +export default ActivityInboxColumn; diff --git a/apps/desktop/src/renderer/components/attention/ActivityPane.test.tsx b/apps/desktop/src/renderer/components/attention/ActivityPane.test.tsx new file mode 100644 index 000000000..b79cf436b --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/ActivityPane.test.tsx @@ -0,0 +1,429 @@ +// @vitest-environment jsdom + +import React from "react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ATTENTION_CONTRACT_VERSION, + DEFAULT_ATTENTION_PREFERENCES, + type AttentionItem, +} from "../../../shared/types"; +import { + attentionStore, + resetAttentionStoreForTests, +} from "../../state/attentionStore"; +import { publishAccountStatus, SIGNED_OUT_ACCOUNT } from "../../lib/account"; +import { ActivityPane } from "./ActivityPane"; + +const originalAde = window.ade; +const signedInAccount = { + signedIn: true as const, + userId: "account-a", + email: null, + name: null, + expiresAt: null, + provider: null, + imageUrl: null, +}; + +function item(id: string, patch: Partial = {}): AttentionItem { + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + id, + revision: 1, + fingerprint: `fingerprint-${id}`, + kind: "agent", + eventKind: "agent_needs_you", + phase: "needs_you", + machine: { + machineKey: "studio", + name: "Studio Mac", + online: true, + lastSeenAt: "2026-07-28T14:00:00.000Z", + }, + project: { projectId: "ade", name: "ADE", rootPath: "/repo/ade" }, + laneName: "attention-revamp", + provider: "codex", + model: "GPT-5", + title: `Task ${id}`, + preview: "Waiting for a safe decision", + privacyPreview: "Agent needs your attention", + detail: "The agent reached an approval checkpoint.", + recentActivity: ["Edited AuthService.ts", "Ran focused tests"], + planProgress: { completed: 2, total: 4, current: "Verify the approval flow" }, + destination: { kind: "session", sessionId: `session-${id}` }, + actions: [ + { id: `approve-${id}`, kind: "approve", label: "Approve" }, + { id: `deny-${id}`, kind: "deny", label: "Deny" }, + ], + occurredAt: "2026-07-28T14:00:00.000Z", + updatedAt: "2026-07-28T14:00:00.000Z", + seenAt: null, + dismissedAt: null, + expiresAt: null, + ...patch, + }; +} + +function installAde(overrides: Record = {}) { + const attention = { + getSnapshot: vi.fn(), + acknowledge: vi.fn(async () => {}), + reportPresence: vi.fn(), + getPreferences: vi.fn(async () => DEFAULT_ATTENTION_PREFERENCES), + putPreferences: vi.fn(async () => {}), + openItem: vi.fn(async () => {}), + ...overrides, + }; + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + account: { status: vi.fn(async () => signedInAccount) }, + attention, + }, + }); + return attention; +} + +beforeEach(() => { + publishAccountStatus(signedInAccount); + installAde(); +}); + +afterEach(() => { + cleanup(); + resetAttentionStoreForTests(); + publishAccountStatus(SIGNED_OUT_ACCOUNT); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: originalAde, + }); +}); + +/** Rows appear in both columns; the sessions card is the one under test. */ +function openDetail(title: string) { + const sessions = screen.getByRole("region", { name: "Sessions" }); + fireEvent.click(within(sessions).getByTitle(new RegExp(`^${title} —`))); +} + +function sessionRow(title: string) { + const sessions = screen.getByRole("region", { name: "Sessions" }); + return within(sessions).queryByTitle(new RegExp(`^${title} —`)); +} + +describe("ActivityPane", () => { + it("renders both columns at once so neither question hides the other", () => { + const needsYou = item("approval"); + const running = item("running", { + phase: "running", + eventKind: "agent_running", + title: "Task running", + }); + attentionStore.setState({ itemsById: { approval: needsYou, running } }); + + render( {}} />); + + const sessions = screen.getByRole("region", { name: "Sessions" }); + const inbox = screen.getByRole("region", { name: "Inbox" }); + expect(within(sessions).getByTitle(/^Task approval —/)).toBeTruthy(); + expect(within(sessions).getByTitle(/^Task running —/)).toBeTruthy(); + // Running work is not an inbox item: nothing is waiting on the reader. + expect(within(inbox).queryByText("Task running")).toBeNull(); + expect(within(inbox).getByText("Task approval")).toBeTruthy(); + }); + + it("never offers the placeholder copy the old center apologised with", () => { + attentionStore.setState({ itemsById: { approval: item("approval") } }); + render( {}} />); + + expect(screen.queryByText(/Ready when you are/i)).toBeNull(); + expect(screen.queryByText(/Nothing selected/i)).toBeNull(); + }); + + it("designs the all-clear state instead of leaving a gap", async () => { + render( {}} />); + + // Opening the pane kicks a refresh, so the all-clear is what is left once + // that settles — not what shows while it is in flight. + expect(await screen.findByText("All agents idle")).toBeTruthy(); + expect(screen.getByText("Inbox zero")).toBeTruthy(); + }); + + it("holds placeholders rather than claiming all-clear before the first snapshot", () => { + attentionStore.setState({ syncStatus: "syncing" }); + render( {}} />); + + // "All agents idle" is a claim, and before a snapshot lands it is one ADE + // has no grounds for — a user would read it and stop looking. + expect(screen.queryByText("All agents idle")).toBeNull(); + expect( + document.body.querySelectorAll("[data-activity-skeleton]").length, + ).toBeGreaterThan(0); + }); + + it("slides the detail over the columns with the item's real content", () => { + attentionStore.setState({ itemsById: { approval: item("approval") } }); + render( {}} />); + + openDetail("Task approval"); + + const sheet = screen.getByRole("dialog", { name: "Task approval detail" }); + expect(within(sheet).getByText("Waiting for a safe decision")).toBeTruthy(); + expect(within(sheet).getByText("GPT-5")).toBeTruthy(); + expect(within(sheet).getByText("Edited AuthService.ts")).toBeTruthy(); + expect(within(sheet).getByRole("button", { name: /Approve/ })).toBeTruthy(); + expect(within(sheet).getByRole("button", { name: /Deny/ })).toBeTruthy(); + + const progress = within(sheet).getByRole("progressbar", { name: "Plan progress" }); + expect(progress.getAttribute("aria-valuenow")).toBe("2"); + expect(progress.getAttribute("aria-valuemax")).toBe("4"); + expect(within(sheet).getByText(/2 of 4 · Verify the approval flow/)).toBeTruthy(); + + // The columns are still mounted underneath — that is the point of a sheet. + expect(screen.getByRole("region", { name: "Sessions" })).toBeTruthy(); + }); + + it("closes the detail before the pane, one layer per Escape", () => { + const onClose = vi.fn(); + attentionStore.setState({ itemsById: { approval: item("approval") } }); + render(); + + openDetail("Task approval"); + fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.queryByRole("dialog", { name: "Task approval detail" })).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + + fireEvent.keyDown(window, { key: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("closes on a click outside and on the backdrop", () => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Close Activity backdrop" })); + expect(onClose).toHaveBeenCalled(); + + onClose.mockClear(); + fireEvent.mouseDown(document.body); + expect(onClose).toHaveBeenCalled(); + }); + + it("keeps clicks inside the pane from closing it", () => { + const onClose = vi.fn(); + attentionStore.setState({ itemsById: { approval: item("approval") } }); + render(); + + fireEvent.mouseDown(screen.getByTestId("activity-pane")); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("opens exact context before acknowledging, and reports a failure honestly", async () => { + const openItem = vi.fn(async () => { + throw new Error("Studio Mac stopped responding."); + }); + installAde({ openItem }); + attentionStore.setState({ itemsById: { approval: item("approval") } }); + render( {}} />); + + openDetail("Task approval"); + fireEvent.click(screen.getByRole("button", { name: "Open" })); + + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain("Studio Mac stopped responding."); + }); + expect(attentionStore.getState().itemsById.approval?.seenAt).toBeNull(); + }); + + it("marks an item seen only once its destination resolved", async () => { + const onClose = vi.fn(); + const openItem = vi.fn(async () => {}); + installAde({ openItem }); + attentionStore.setState({ itemsById: { approval: item("approval") } }); + render(); + + openDetail("Task approval"); + fireEvent.click(screen.getByRole("button", { name: "Open" })); + + await waitFor(() => { + expect(openItem).toHaveBeenCalledWith(expect.objectContaining({ id: "approval" })); + expect(attentionStore.getState().itemsById.approval?.seenAt).not.toBeNull(); + }); + expect(onClose).toHaveBeenCalled(); + }); + + it("disables remote actions for last-known state from an offline machine", () => { + attentionStore.setState({ + itemsById: { + offline: item("offline", { + machine: { + machineKey: "cloud", + name: "Cloud Mac", + online: false, + lastSeenAt: "2026-07-28T13:00:00.000Z", + }, + }), + }, + }); + render( {}} />); + + openDetail("Task offline"); + const sheet = screen.getByRole("dialog", { name: "Task offline detail" }); + expect(within(sheet).getByText(/Cloud Mac is offline\./)).toBeTruthy(); + expect( + (within(sheet).getByRole("button", { name: /Approve/ }) as HTMLButtonElement).disabled, + ).toBe(true); + // Dismiss is local bookkeeping, so it stays live while the machine is away. + expect( + (within(sheet).getByRole("button", { name: /Dismiss/ }) as HTMLButtonElement).disabled, + ).toBe(false); + }); + + it("files an offline machine's sessions under a last-seen divider", () => { + attentionStore.setState({ + itemsById: { + here: item("here"), + gone: item("gone", { + machine: { + machineKey: "cloud", + name: "Cloud Mac", + online: false, + lastSeenAt: new Date(Date.now() - 2 * 60 * 60_000).toISOString(), + }, + }), + }, + }); + render( {}} />); + + // The pane is portalled to the body, so the render container is empty. + const divider = document.body.querySelector('[data-activity-offline-machine="cloud"]'); + expect(divider).toBeTruthy(); + expect(divider!.textContent).toContain("Cloud Mac"); + expect(divider!.textContent).toContain("last seen"); + // The dimmed group holds that machine's row, and only that machine's row. + const group = divider!.closest(".activity-offline-group")!; + expect(group.querySelector('[data-activity-row="gone"]')).toBeTruthy(); + expect(group.querySelector('[data-activity-row="here"]')).toBeNull(); + }); + + it("dismisses one inbox row without touching the rest", async () => { + const acknowledge = vi.fn(async () => {}); + installAde({ acknowledge }); + attentionStore.setState({ + itemsById: { first: item("first"), second: item("second") }, + }); + render( {}} />); + + fireEvent.click(screen.getByRole("button", { name: "Dismiss Task first" })); + + await waitFor(() => { + expect(attentionStore.getState().itemsById.first?.dismissedAt).not.toBeNull(); + }); + expect(attentionStore.getState().itemsById.second?.dismissedAt).toBeNull(); + expect(acknowledge).toHaveBeenCalledTimes(1); + }); + + it("clears the whole inbox from its header", async () => { + installAde(); + attentionStore.setState({ + itemsById: { first: item("first"), second: item("second") }, + }); + render( {}} />); + + fireEvent.click(screen.getByRole("button", { name: "Clear all" })); + + await waitFor(() => { + expect(attentionStore.getState().itemsById.first?.dismissedAt).not.toBeNull(); + expect(attentionStore.getState().itemsById.second?.dismissedAt).not.toBeNull(); + }); + }); + + it("filters both columns by machine and says so when nothing matches", async () => { + attentionStore.setState({ + itemsById: { + studio: item("studio"), + laptop: item("laptop", { + machine: { + machineKey: "laptop", + name: "MacBook", + online: true, + lastSeenAt: null, + }, + }), + }, + }); + render( {}} />); + + fireEvent.click(screen.getByRole("button", { name: "Filter by machine" })); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "MacBook" })); + + await waitFor(() => expect(sessionRow("Task studio")).toBeNull()); + expect(sessionRow("Task laptop")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Clear filters" })); + await waitFor(() => expect(sessionRow("Task studio")).toBeTruthy()); + }); + + it("explains an empty column as a filter result, not as all-clear", async () => { + attentionStore.setState({ + itemsById: { + studio: item("studio", { model: "GPT-5" }), + }, + }); + render( {}} />); + + fireEvent.click(screen.getByRole("button", { name: "Filter by type" })); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Agents" })); + // Selecting the only kind present keeps everything visible… + expect(sessionRow("Task studio")).toBeTruthy(); + + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Agents" })); + fireEvent.click(screen.getByRole("button", { name: "Filter by model" })); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "GPT-5" })); + expect(sessionRow("Task studio")).toBeTruthy(); + }); + + it("counts machines and sessions in the header", () => { + attentionStore.setState({ + itemsById: { + studio: item("studio"), + cloud: item("cloud", { + machine: { + machineKey: "cloud", + name: "Cloud Mac", + online: false, + lastSeenAt: "2026-07-28T13:00:00.000Z", + }, + }), + }, + }); + render( {}} />); + + expect(screen.getByText(/1 of 2 machines online · 2 sessions/)).toBeTruthy(); + }); + + it("renders nothing at all when closed", () => { + attentionStore.setState({ itemsById: { approval: item("approval") } }); + render( {}} />); + + expect(screen.queryByTestId("activity-pane")).toBeNull(); + }); + + it("drops the detail when its item leaves the snapshot", async () => { + attentionStore.setState({ itemsById: { approval: item("approval") } }); + render( {}} />); + openDetail("Task approval"); + + act(() => { + attentionStore.setState({ itemsById: {} }); + }); + + await waitFor(() => { + expect(screen.queryByRole("dialog", { name: "Task approval detail" })).toBeNull(); + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/attention/ActivityPane.tsx b/apps/desktop/src/renderer/components/attention/ActivityPane.tsx new file mode 100644 index 000000000..71d7451a8 --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/ActivityPane.tsx @@ -0,0 +1,341 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { + ArrowClockwise, + WarningCircle, + WifiHigh, + WifiSlash, + X, +} from "@phosphor-icons/react"; + +import { + attentionDestinationDeepLink, + type AttentionAction, + type AttentionItem, +} from "../../../shared/types"; +import { relativeWhen } from "../../lib/format"; +import { openAdeDeeplink } from "../../lib/openExternal"; +import { + ADE_BROWSER_VIEW_OCCLUSION_END_EVENT, + ADE_BROWSER_VIEW_OCCLUSION_START_EVENT, +} from "../../lib/workSidebarBrowserResize"; +import { + acknowledgeAttentionItem, + attentionStore, + selectActivityHideDetails, + useAttentionStore, +} from "../../state/attentionStore"; +import { ActivityDetailSheet } from "./ActivityDetailSheet"; +import { + ActivityFilters, + activityFiltersAreEmpty, + applyActivityFilters, + EMPTY_ACTIVITY_FILTERS, + type ActivityFilterState, +} from "./ActivityFilters"; +import { ActivityInboxColumn } from "./ActivityInboxColumn"; +import { ActivitySessionsColumn } from "./ActivitySessionsColumn"; +import { ActivitySettingsPopover } from "./ActivitySettingsPopover"; +import { summarizeActivity } from "./activityPriority"; +import { refreshAttentionSnapshot } from "./useAttentionSync"; +import "./Activity.css"; + +function navigationErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim()) return error.message.trim(); + return "ADE couldn’t open the exact machine and project for this item."; +} + +function pluralize(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} + +/** + * The expanded Activity surface: a modal popup over whatever tab is in front, + * modelled on `app/LinearPaneModal` so ADE's two big overlay surfaces feel like + * the same object. It replaces the `/attention` full-page route and the + * tabs-plus-roster-plus-detail IA that route encoded. + * + * Split view, not a master/detail swap. Sessions and Inbox are both always + * visible because they answer different questions — "what is running" and + * "what is waiting on me" — and the detail slides over the top so opening one + * row never costs you your place in either list. + */ +export function ActivityPane({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + const itemsById = useAttentionStore((state) => state.itemsById); + const syncStatus = useAttentionStore((state) => state.syncStatus); + const syncError = useAttentionStore((state) => state.syncError); + const generatedAt = useAttentionStore((state) => state.generatedAt); + const availability = useAttentionStore((state) => state.availability); + const acknowledgementErrors = useAttentionStore((state) => state.acknowledgementErrors); + const hideDetails = useAttentionStore(selectActivityHideDetails); + + const paneRef = useRef(null); + const [now, setNow] = useState(() => Date.now()); + const [filters, setFilters] = useState(EMPTY_ACTIVITY_FILTERS); + const [selectedItemId, setSelectedItemId] = useState(null); + const [pendingActionId, setPendingActionId] = useState(null); + const [navigationError, setNavigationError] = useState(null); + + const allItems = useMemo(() => Object.values(itemsById), [itemsById]); + const visibleItems = useMemo( + () => applyActivityFilters(allItems, filters), + [allItems, filters], + ); + const summary = useMemo(() => summarizeActivity(visibleItems, now), [now, visibleItems]); + const selectedItem = selectedItemId ? itemsById[selectedItemId] ?? null : null; + + useEffect(() => { + if (!open) return; + setNavigationError(null); + setNow(Date.now()); + void refreshAttentionSnapshot(); + const timer = window.setInterval(() => setNow(Date.now()), 30_000); + return () => window.clearInterval(timer); + }, [open]); + + // The pane is a header surface too: while it is up, presence reporting should + // say the user is looking at Activity. + useEffect(() => { + if (!open) return; + attentionStore.getState().setHeaderSurfaceVisible(true); + return () => attentionStore.getState().setHeaderSurfaceVisible(false); + }, [open]); + + // An embedded BrowserView paints above the DOM, so tell it to step aside. + useEffect(() => { + if (!open || typeof window === "undefined") return; + window.dispatchEvent(new Event(ADE_BROWSER_VIEW_OCCLUSION_START_EVENT)); + return () => { + window.dispatchEvent(new Event(ADE_BROWSER_VIEW_OCCLUSION_END_EVENT)); + }; + }, [open]); + + const closeSheet = useCallback(() => setSelectedItemId(null), []); + + useEffect(() => { + if (!open) return; + const onKey = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + // The settings popover is a dialog inside this one and owns its own + // Escape; closing both at once would be a single key undoing two steps. + if (document.querySelector(".attention-settings-popover")) return; + event.preventDefault(); + // Escape peels one layer: the detail sheet first, the pane only once + // nothing is stacked on top of it. + if (selectedItemId) closeSheet(); + else onClose(); + }; + const onDown = (event: MouseEvent) => { + const target = event.target as Node | null; + if (!target || paneRef.current?.contains(target)) return; + onClose(); + }; + window.addEventListener("keydown", onKey); + window.addEventListener("mousedown", onDown); + return () => { + window.removeEventListener("keydown", onKey); + window.removeEventListener("mousedown", onDown); + }; + }, [closeSheet, onClose, open, selectedItemId]); + + // A dismissed or expired row cannot keep a sheet open over an empty list. + useEffect(() => { + if (selectedItemId && !itemsById[selectedItemId]) setSelectedItemId(null); + }, [itemsById, selectedItemId]); + + const openItem = useCallback(async (item: AttentionItem) => { + setNavigationError(null); + try { + const bridge = typeof window !== "undefined" ? window.ade?.attention : null; + if (bridge?.openItem) await bridge.openItem(item); + else openAdeDeeplink(attentionDestinationDeepLink(item.destination, item)); + } catch (error) { + setNavigationError(navigationErrorMessage(error)); + return; + } + // Only a destination that actually resolved earns the item leaving unseen. + await acknowledgeAttentionItem(item.id, "seen").catch(() => {}); + onClose(); + }, [onClose]); + + const runAction = useCallback(async (item: AttentionItem, action: AttentionAction) => { + if (pendingActionId) return; + if (action.kind === "open") { + await openItem(item); + return; + } + setPendingActionId(action.id); + try { + if (action.kind === "dismiss" || action.kind === "mark_seen") { + await acknowledgeAttentionItem( + item.id, + action.kind === "dismiss" ? "dismiss" : "seen", + ); + if (action.kind === "dismiss") closeSheet(); + } else { + // Everything else is a remote mutation ADE cannot yet perform from + // here, so the honest fallback is to take the user to where it can be + // done rather than to pretend the button did it. + await openItem(item); + } + } catch { + // `acknowledgeAttentionItem` rolls its own optimistic state back and + // records the message in the store; the sheet renders it. + } finally { + setPendingActionId(null); + } + }, [closeSheet, openItem, pendingActionId]); + + const dismissItem = useCallback((item: AttentionItem) => { + void acknowledgeAttentionItem(item.id, "dismiss").catch(() => {}); + }, []); + + const clearInbox = useCallback((items: readonly AttentionItem[]) => { + for (const item of items) { + void acknowledgeAttentionItem(item.id, "dismiss").catch(() => {}); + } + }, []); + + if (!open || typeof document === "undefined") return null; + + const degraded = availability != null + && availability.state !== "ready" + && availability.state !== "signed_out"; + const freshness = degraded + ? { tone: "error" as const, label: availability.title, retry: true } + : syncStatus === "error" + ? { tone: "error" as const, label: "Sync failed", retry: true } + : syncStatus === "syncing" + ? { tone: "syncing" as const, label: "Syncing", retry: false } + : generatedAt + ? { tone: "ready" as const, label: `Synced ${relativeWhen(generatedAt)}`, retry: false } + : null; + const machineLine = summary.machinesTotal === 0 + ? "No machines reporting yet" + : summary.machinesOnline === summary.machinesTotal + ? `${pluralize(summary.machinesTotal, "machine")} online` + : `${summary.machinesOnline} of ${pluralize(summary.machinesTotal, "machine")} online`; + const filtered = !activityFiltersAreEmpty(filters); + + return createPortal( + <> + + ) : ( + + {freshness.tone === "syncing" ? ( + + ) : ( + + )} + {freshness.label} + + ) + ) : null} + + + + + + + + {/* While the sheet is up it covers this strip, so the failure is + reported there instead — one alert, wherever the click was. */} + {navigationError && !selectedItem ? ( +
+ + {navigationError} +
+ ) : null} + +
+ setSelectedItemId(item.id)} + /> + setSelectedItemId(item.id)} + onDismissItem={dismissItem} + onClearAll={clearInbox} + /> + {selectedItem ? ( + void openItem(item)} + onAction={(item, action) => void runAction(item, action)} + /> + ) : null} +
+
+ , + document.body, + ); +} + +export default ActivityPane; diff --git a/apps/desktop/src/renderer/components/attention/ActivitySessionsColumn.tsx b/apps/desktop/src/renderer/components/attention/ActivitySessionsColumn.tsx new file mode 100644 index 000000000..eae4b9f24 --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/ActivitySessionsColumn.tsx @@ -0,0 +1,222 @@ +import React, { useMemo, useState } from "react"; + +import type { AttentionItem } from "../../../shared/types"; +import { relativeWhen } from "../../lib/format"; +import { cn } from "../ui/cn"; +import { ActivityCard } from "./ActivityCard"; +import { ActivityCardSkeleton } from "./ActivityCardSkeleton"; +import { + ACTIVITY_SECTION_TONE, + activitySections, + type ActivitySection, +} from "./activityPriority"; + +/** + * The rows rendered before the column stops and offers the rest behind a + * button. Activity is account-wide, so a busy fleet routinely lands hundreds of + * rows here; painting all of them costs more than anyone reads. Sixty is about + * two screens, which is as far as anyone scrolls before reaching for a filter. + */ +const INITIAL_ROW_BUDGET = 60; +const ROW_BUDGET_STEP = 60; + +type MachineGroup = { + machineKey: string; + name: string; + lastSeenAt: string | null; + items: AttentionItem[]; +}; + +/** + * Split a section into the work being observed and the work being remembered. + * An offline machine's rows are last-known state, so they sit below a labelled + * divider instead of mixing into a list that otherwise means "right now". + */ +function partitionByPresence(items: readonly AttentionItem[]): { + online: AttentionItem[]; + offline: MachineGroup[]; +} { + const online: AttentionItem[] = []; + const offline = new Map(); + for (const item of items) { + if (item.machine.online) { + online.push(item); + continue; + } + const existing = offline.get(item.machine.machineKey); + if (existing) { + existing.items.push(item); + continue; + } + offline.set(item.machine.machineKey, { + machineKey: item.machine.machineKey, + name: item.machine.name, + lastSeenAt: item.machine.lastSeenAt, + items: [item], + }); + } + return { + online, + offline: [...offline.values()].sort((left, right) => + left.name.localeCompare(right.name)), + }; +} + +function SectionRows({ + section, + hideDetails, + selectedItemId, + onOpenItem, +}: { + section: ActivitySection; + hideDetails: boolean; + selectedItemId: string | null; + onOpenItem: (item: AttentionItem) => void; +}) { + const { online, offline } = useMemo( + () => partitionByPresence(section.items), + [section.items], + ); + + const card = (item: AttentionItem) => ( + + ); + + return ( + <> + {online.map(card)} + {offline.map((group) => ( +
+
+ {group.name} + · + + {group.lastSeenAt + ? `last seen ${relativeWhen(group.lastSeenAt)}` + : "offline"} + +
+ {group.items.map(card)} +
+ ))} + + ); +} + +/** + * The left column: every tracked session, priority-flat across needs-you → + * working → done, with section headings that stay put while the list scrolls. + */ +export function ActivitySessionsColumn({ + items, + hideDetails, + selectedItemId, + filtered, + loading, + onOpenItem, +}: { + /** Already filtered. The column does not know the filter exists. */ + items: readonly AttentionItem[]; + hideDetails: boolean; + selectedItemId: string | null; + /** Changes the all-clear copy: nothing here is not the same as nothing at all. */ + filtered: boolean; + /** No snapshot has landed yet — which is not the same as nothing running. */ + loading: boolean; + onOpenItem: (item: AttentionItem) => void; +}) { + const [budget, setBudget] = useState(INITIAL_ROW_BUDGET); + const sections = useMemo(() => activitySections(items), [items]); + const total = sections.reduce((count, section) => count + section.items.length, 0); + + // The budget is spent across sections in priority order, so needs-you rows + // can never be the ones hidden behind "Show more". + const { budgeted, hidden } = useMemo(() => { + let remaining = budget; + const budgetedSections: ActivitySection[] = []; + for (const section of sections) { + if (section.items.length === 0) continue; + const take = Math.max(0, Math.min(section.items.length, remaining)); + remaining -= take; + if (take > 0) budgetedSections.push({ ...section, items: section.items.slice(0, take) }); + } + return { budgeted: budgetedSections, hidden: Math.max(0, total - budget) }; + }, [budget, sections, total]); + + return ( +
+
+

Sessions

+ {total} +
+
+ {total === 0 && loading ? ( + // Placeholders, not an all-clear: claiming every agent is idle before + // the first snapshot lands is a lie the user would act on. + Array.from({ length: 4 }, (_, index) => ) + ) : total === 0 ? ( +
+ {filtered ? ( + <> + No sessions match +

Clear a filter to see the rest of your account.

+ + ) : ( + <> + + All agents idle +

Work from every signed-in machine lands here the moment it starts.

+ + )} +
+ ) : ( + <> + {budgeted.map((section) => ( + +

+ + {section.label} + + {sections.find((entry) => entry.id === section.id)?.items.length ?? 0} + +

+ +
+ ))} + {hidden > 0 ? ( + + ) : null} + + )} +
+
+ ); +} + +export default ActivitySessionsColumn; diff --git a/apps/desktop/src/renderer/components/attention/ActivitySettingsPopover.test.tsx b/apps/desktop/src/renderer/components/attention/ActivitySettingsPopover.test.tsx new file mode 100644 index 000000000..7b1696de9 --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/ActivitySettingsPopover.test.tsx @@ -0,0 +1,146 @@ +// @vitest-environment jsdom + +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { DEFAULT_ATTENTION_PREFERENCES } from "../../../shared/types"; +import { publishAccountStatus, SIGNED_OUT_ACCOUNT } from "../../lib/account"; +import { resetAttentionStoreForTests } from "../../state/attentionStore"; +import { ActivitySettingsPopover } from "./ActivitySettingsPopover"; + +const originalAde = window.ade; +const signedInAccount = { + signedIn: true as const, + userId: "account-a", + email: null, + name: null, + expiresAt: null, + provider: null, + imageUrl: null, +}; + +function installAde() { + const putPreferences = vi.fn(async () => undefined); + const updateSettings = vi.fn(async () => undefined); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + account: { status: vi.fn(async () => signedInAccount) }, + attention: { + getSnapshot: vi.fn(), + acknowledge: vi.fn(), + reportPresence: vi.fn(), + getPreferences: vi.fn(async () => DEFAULT_ATTENTION_PREFERENCES), + putPreferences, + }, + attentionNotch: { updateSettings, publishSnapshot: vi.fn() }, + }, + }); + return { putPreferences, updateSettings }; +} + +beforeEach(() => { + window.localStorage.clear(); + publishAccountStatus(signedInAccount); +}); + +afterEach(() => { + cleanup(); + resetAttentionStoreForTests(); + publishAccountStatus(SIGNED_OUT_ACCOUNT); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: originalAde, + }); +}); + +describe("ActivitySettingsPopover", () => { + it("saves as you go, with no Save button to forget", async () => { + const { putPreferences, updateSettings } = installAde(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Activity settings" })); + await screen.findByRole("dialog", { name: "Activity settings" }); + await waitFor(() => expect(screen.getByRole("switch", { name: "Activity sounds" })).toBeTruthy()); + + expect(screen.queryByRole("button", { name: /^save$/i })).toBeNull(); + + fireEvent.click(screen.getByRole("switch", { name: "Activity sounds" })); + + await waitFor(() => { + expect(putPreferences).toHaveBeenCalledWith( + "account-a", + expect.objectContaining({ + account: expect.objectContaining({ soundsEnabled: true }), + }), + ); + expect(updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ soundsEnabled: true }), + ); + }); + }); + + it("keeps the notch enabled flag on this Mac while syncing its presentation", async () => { + const { putPreferences, updateSettings } = installAde(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Activity settings" })); + await screen.findByRole("dialog", { name: "Activity settings" }); + await waitFor(() => expect(screen.getByRole("switch", { name: "ADE notch" })).toBeTruthy()); + + fireEvent.click(screen.getByRole("switch", { name: "ADE notch" })); + await waitFor(() => { + expect(window.localStorage.getItem("ade:attention:notch-enabled")).toBe("false"); + expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })); + }); + // Whether this Mac shows a notch at all is this Mac's business. + const [, savedPreferences] = putPreferences.mock.calls.at(-1) as unknown as [ + string, + { account: Record }, + ]; + expect(savedPreferences.account).not.toHaveProperty("notchEnabled"); + }); + + it("returns focus to the trigger when Escape dismisses it", async () => { + installAde(); + render(); + const trigger = screen.getByRole("button", { name: "Activity settings" }); + fireEvent.click(trigger); + + const dialog = await screen.findByRole("dialog", { name: "Activity settings" }); + expect(document.activeElement).toBe(dialog); + + fireEvent.keyDown(document, { key: "Escape" }); + await waitFor(() => { + expect(screen.queryByRole("dialog", { name: "Activity settings" })).toBeNull(); + }); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + }); + + it("links to Settings through the navigation bus, not the router", async () => { + // Activity mounts outside the router here (and in the notch), so the link + // must dispatch an app-navigation target rather than calling useNavigate — + // which would throw "may be used only in the context of a ". + installAde(); + const targets: unknown[] = []; + const onNavigate = (event: Event) => { + targets.push((event as CustomEvent).detail?.target); + }; + window.addEventListener("ade:navigate-target", onNavigate); + try { + render(); + fireEvent.click(screen.getByRole("button", { name: "Activity settings" })); + await screen.findByRole("dialog", { name: "Activity settings" }); + + fireEvent.click(await screen.findByRole("button", { name: /All Activity settings/ })); + + expect(targets).toEqual([{ kind: "settings", tab: "activity" }]); + } finally { + window.removeEventListener("ade:navigate-target", onNavigate); + } + }); +}); diff --git a/apps/desktop/src/renderer/components/attention/ActivitySettingsPopover.tsx b/apps/desktop/src/renderer/components/attention/ActivitySettingsPopover.tsx new file mode 100644 index 000000000..a0716cdbd --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/ActivitySettingsPopover.tsx @@ -0,0 +1,202 @@ +import React, { useEffect, useRef, useState } from "react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { + ArrowSquareOut, + BellRinging, + Check, + GearSix, + WarningCircle, +} from "@phosphor-icons/react"; + +import { navigateToAppTarget } from "../../lib/openExternal"; +import { + ActivitySettingsControls, + useActivitySettings, +} from "../settings/ActivitySettingsControls"; +import "./Activity.css"; + +/** + * The gear in the Activity header popover and pane. + * + * It replaces `AttentionSettingsPopover`, whose three toggles sat behind a Save + * button while the settings page saved instantly — the same preference, + * two different contracts. Every row here comes from + * `settings/ActivitySettingsControls`, mounted in its `popover` variant, and + * every change saves the moment it is made. There is no Save button anywhere in + * ADE settings; there is no longer one here either. + */ +export function ActivitySettingsPopover() { + const model = useActivitySettings(); + const [open, setOpen] = useState(false); + const reducedMotion = useReducedMotion() ?? false; + const rootRef = useRef(null); + const triggerRef = useRef(null); + const restoreTriggerFocusRef = useRef(false); + + const dialogElement = () => + rootRef.current?.querySelector('[role="dialog"]') ?? null; + + const closePopover = (returnFocus: boolean) => { + restoreTriggerFocusRef.current = returnFocus; + setOpen(false); + }; + + // An account switch invalidates everything on screen, so the popover closes + // rather than showing the previous account's choices under a new name. + useEffect(() => { + if (!model.accountOwnerId) return; + restoreTriggerFocusRef.current = false; + setOpen(false); + }, [model.accountOwnerId]); + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) closePopover(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + // Stopped here so the surrounding pane or popover does not also close: + // one Escape, one layer. + event.stopPropagation(); + closePopover(true); + } + }; + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + useEffect(() => { + if (open) dialogElement()?.focus(); + }, [open]); + + // Keep Tab inside the popover while it is open; Escape and the Done button + // are the ways out. + const onDialogKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== "Tab") return; + const dialog = dialogElement(); + if (!dialog) return; + const focusable = Array.from( + dialog.querySelectorAll( + "button, select, [href], input, [tabindex]:not([tabindex='-1'])", + ), + ).filter((element) => !element.hasAttribute("disabled")); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + if (event.shiftKey && (active === first || active === dialog)) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + } + }; + + return ( +
+ + { + if (!restoreTriggerFocusRef.current) return; + restoreTriggerFocusRef.current = false; + triggerRef.current?.focus(); + }} + > + {open ? ( + +
+
+ + + + + Activity settings + Account delivery and this Mac’s notch + +
+ Account +
+ + {model.loading ? ( +
+ + Loading your preferences… +
+ ) : ( + + )} + + {model.error ? ( +
+ + {model.error} +
+ ) : null} + +
+ {/* + Routed through the app navigation bus rather than `useNavigate`: + Activity mounts outside the router in tests and in the notch, + and must not take a Router dependency. + */} + +
+ +
+ + {model.saved + ? <> Saved + : "Changes save as you make them"} + + +
+
+ ) : null} +
+
+ ); +} + +export default ActivitySettingsPopover; diff --git a/apps/desktop/src/renderer/components/attention/HeaderActivityControl.css b/apps/desktop/src/renderer/components/attention/HeaderActivityControl.css index 2799e7d10..3baa2ecc4 100644 --- a/apps/desktop/src/renderer/components/attention/HeaderActivityControl.css +++ b/apps/desktop/src/renderer/components/attention/HeaderActivityControl.css @@ -20,37 +20,14 @@ --activity-hdr-shadow: 0 28px 70px -30px rgba(0, 0, 0, 0.8); } -.activity-hdr-trigger.activity-tone-amber, -.activity-hdr-panel .activity-tone-amber { --tone-color: #fbbf24; } -.activity-hdr-trigger.activity-tone-red, -.activity-hdr-panel .activity-tone-red { --tone-color: #f87171; } -.activity-hdr-trigger.activity-tone-violet, -.activity-hdr-panel .activity-tone-violet { --tone-color: #a78bfa; } -.activity-hdr-trigger.activity-tone-blue, -.activity-hdr-panel .activity-tone-blue { --tone-color: #60a5fa; } -.activity-hdr-trigger.activity-tone-emerald, -.activity-hdr-panel .activity-tone-emerald { --tone-color: #34d399; } -.activity-hdr-trigger.activity-tone-neutral, -.activity-hdr-panel .activity-tone-neutral { --tone-color: #a1a1aa; } - -/* 400-level tones sit near 1.7:1 on a white card; light mode uses the 600/700 - equivalents so pills and dots stay legible instead of washing out. */ +/* The `.activity-tone-*` table itself lives in `Activity.css`, which every + Activity surface loads through `ActivityCard`. A second, popover-scoped copy + is how this panel and the pane would end up disagreeing about what amber is. */ + [data-theme="light"] .activity-hdr-trigger, [data-theme="light"] .activity-hdr-panel { --activity-hdr-shadow: 0 22px 55px -26px rgba(15, 23, 42, 0.3); } -[data-theme="light"] .activity-hdr-trigger.activity-tone-amber, -[data-theme="light"] .activity-hdr-panel .activity-tone-amber { --tone-color: #b45309; } -[data-theme="light"] .activity-hdr-trigger.activity-tone-red, -[data-theme="light"] .activity-hdr-panel .activity-tone-red { --tone-color: #dc2626; } -[data-theme="light"] .activity-hdr-trigger.activity-tone-violet, -[data-theme="light"] .activity-hdr-panel .activity-tone-violet { --tone-color: #6d28d9; } -[data-theme="light"] .activity-hdr-trigger.activity-tone-blue, -[data-theme="light"] .activity-hdr-panel .activity-tone-blue { --tone-color: #1d4ed8; } -[data-theme="light"] .activity-hdr-trigger.activity-tone-emerald, -[data-theme="light"] .activity-hdr-panel .activity-tone-emerald { --tone-color: #047857; } -[data-theme="light"] .activity-hdr-trigger.activity-tone-neutral, -[data-theme="light"] .activity-hdr-panel .activity-tone-neutral { --tone-color: #52525b; } /* ---- trigger ---------------------------------------------------------- */ @@ -325,53 +302,6 @@ button.activity-hdr-freshness { outline: none; } -/* ---- row chrome (the card itself is Tailwind) -------------------------- */ - -.activity-card { - transition: background-color 120ms ease, box-shadow 120ms ease; -} - -.activity-card::before { - content: ""; - position: absolute; - left: 0; - top: 8px; - bottom: 8px; - width: 2px; - border-radius: 999px; - background: var(--tone-color); - opacity: 0; - transition: opacity 120ms ease; -} - -.activity-card:hover, -.activity-card:focus-visible { - background: color-mix(in srgb, var(--color-fg) 6%, transparent); - outline: none; -} - -.activity-card:hover::before, -.activity-card:focus-visible::before { - opacity: 1; -} - -.activity-card:focus-visible { - box-shadow: 0 0 0 1px color-mix(in srgb, var(--tone-color) 55%, transparent); -} - -/* The lane is the row's identity line. Accent, not tone: a lane's colour must - not change because its agent's phase did. */ -.activity-card-lane { - color: color-mix(in srgb, var(--color-accent) 82%, var(--color-fg)); -} - -.activity-card-unseen { - width: 6px; - height: 6px; - border-radius: 999px; - background: var(--tone-color); -} - /* ---- empty states and footer ------------------------------------------ */ .activity-hdr-empty { diff --git a/apps/desktop/src/renderer/components/attention/HeaderActivityControl.tsx b/apps/desktop/src/renderer/components/attention/HeaderActivityControl.tsx index 0bf6520dd..6d4945a47 100644 --- a/apps/desktop/src/renderer/components/attention/HeaderActivityControl.tsx +++ b/apps/desktop/src/renderer/components/attention/HeaderActivityControl.tsx @@ -32,7 +32,7 @@ import { import { useDialogFocusTrap } from "../app/HeaderSheet"; import { cn } from "../ui/cn"; import { ActivityCard } from "./ActivityCard"; -import { AttentionSettingsPopover } from "./AttentionSettingsPopover"; +import { ActivitySettingsPopover } from "./ActivitySettingsPopover"; import { ACTIVITY_SECTION_TONE, activityTriggerLabel, @@ -399,7 +399,7 @@ export function HeaderActivityControl({ ) ) : null} - + + ); +} + +/** + * The rows themselves. `variant` picks the chrome; the copy, the ordering, and + * every `onChange` come from the one model above. + */ +export function ActivitySettingsControls({ + variant, + model, +}: { + variant: "popover" | "page"; + model: ActivitySettingsModel; +}) { + const { + account, + loading, + signedOut, + machines, + notchEnabled, + notchPresentation, + notchSupported, + updateAccount, + toggleNotchEnabled, + setNotchPresentation, + setMachineMuted, + machineMuted, + } = model; + const busy = loading || signedOut; + + if (variant === "popover") { + return ( + <> + {notchSupported ? ( +
+

This Mac

+ + } + /> + setNotchPresentation({ + revealMode: event.target.value as AttentionNotchRevealMode, + })} + > + {REVEAL_OPTIONS.map((option) => ( + + ))} + + } + /> + + setNotchPresentation({ expandedPanelEnabled })} + /> + } + /> +
+ ) : null} + +
+

Account

+ updateAccount({ celebrationsEnabled })} + /> + } + /> + updateAccount({ soundsEnabled })} + /> + } + /> + updateAccount({ hideDetails })} + /> + } + /> + updateAccount({ + desktopFirstDelaySeconds: Number(event.target.value), + })} + > + {ESCALATION_OPTIONS.map((option) => ( + + ))} + + } + /> +
+ + {machines.length > 0 ? ( +
+

Machines

+
+ {machines.map((machine) => ( + + void setMachineMuted(machine.machineKey, !enabled)} + /> + } + /> + ))} +
+
+ ) : null} + + ); + } + + return ( + <> + + + } + /> + setNotchPresentation({ revealMode })} + /> + } + /> + setNotchPresentation({ expandedPanelEnabled })} + /> + } + /> + updateAccount({ celebrationsEnabled })} + /> + } + /> + + + + updateAccount({ soundsEnabled })} + /> + } + /> + + + + updateAccount({ hideDetails })} + /> + } + /> + + + + + {machines.length === 0 ? ( +

+ No machines have reported Activity yet. +

+ ) : ( +
+ {machines.map((machine, index) => ( +
+
+
+ {machine.name} +
+
+ {machineMuted(machine.machineKey) + ? "Muted — visible in Activity, never notifies." + : machine.online + ? "Online" + : "Offline"} +
+
+ void setMachineMuted(machine.machineKey, !enabled)} + /> +
+ ))} +
+ )} +
+
+ + ); +} + +export default ActivitySettingsControls; diff --git a/apps/desktop/src/renderer/components/settings/NotificationsSection.test.tsx b/apps/desktop/src/renderer/components/settings/NotificationsSection.test.tsx index ad9c34910..96e9f510d 100644 --- a/apps/desktop/src/renderer/components/settings/NotificationsSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/NotificationsSection.test.tsx @@ -11,6 +11,8 @@ vi.mock("../../lib/account", () => ({ useAccountStatus: () => ({ status: { signedIn: true, userId: "user-1" } }), })); +// Notch presentation, celebrations, previews, and per-machine mute moved to +// the Activity tab; their coverage moved with them to ActivitySection.test.tsx. function installAdeMock() { const putPreferences = vi.fn(async (_ownerId: string, _prefs: any) => {}); const getPreferences = vi.fn(async () => DEFAULT_ATTENTION_PREFERENCES); @@ -88,18 +90,6 @@ describe("NotificationsSection", () => { expect(putPreferences.mock.calls[0]![1].account.notificationsEnabled).toBe(false); }); - it("pushes notch presentation to the notch process without touching synced prefs", async () => { - const { putPreferences, updateSettings } = installAdeMock(); - render(); - await screen.findByText("Notify me about"); - - fireEvent.click(screen.getByRole("switch", { name: "Celebrations" })); - - await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1)); - expect(putPreferences).toHaveBeenCalledTimes(1); - expect(updateSettings.mock.calls[0]![0].celebrationsEnabled).toBe(false); - }); - it("reveals quiet-hour times only once quiet hours are on", async () => { installAdeMock(); render(); @@ -109,34 +99,4 @@ describe("NotificationsSection", () => { fireEvent.click(screen.getByRole("switch", { name: "Quiet hours" })); expect(await screen.findByLabelText("From")).toBeTruthy(); }); - it("keeps notch presentation on this Mac and pushes it to the native helper", async () => { - const { putPreferences, updateSettings } = installAdeMock(); - render(); - await screen.findByText("Notify me about"); - - // Moved here from the header popover: reveal mode and the expanded panel - // are machine-local, so they must reach the notch process and localStorage - // without being written into the synced account preferences. - const behavior = await screen.findByRole("combobox", { name: "Notch behavior" }); - fireEvent.change(behavior, { target: { value: "click" } }); - - await waitFor(() => expect(updateSettings).toHaveBeenCalled()); - const pushed = updateSettings.mock.calls.at(-1)![0]; - expect(pushed.revealMode).toBe("click"); - expect(window.localStorage.getItem("ade:attention:notch-reveal-mode")).toBe("click"); - // The synced payload carries no notch presentation. - expect(putPreferences.mock.calls.at(-1)![1]).not.toHaveProperty("revealMode"); - }); - - it("hides notch presentation controls while the notch is off", async () => { - installAdeMock(); - render(); - await screen.findByText("Notify me about"); - - expect(screen.queryByRole("combobox", { name: "Notch behavior" })).toBeTruthy(); - fireEvent.click(screen.getByRole("switch", { name: "ADE notch" })); - await waitFor(() => { - expect(screen.queryByRole("combobox", { name: "Notch behavior" })).toBeNull(); - }); - }); }); diff --git a/apps/desktop/src/renderer/components/settings/NotificationsSection.tsx b/apps/desktop/src/renderer/components/settings/NotificationsSection.tsx index d2db048ac..79a68c11f 100644 --- a/apps/desktop/src/renderer/components/settings/NotificationsSection.tsx +++ b/apps/desktop/src/renderer/components/settings/NotificationsSection.tsx @@ -6,15 +6,7 @@ import { type AttentionPreferences, } from "../../../shared/types/attention"; import { ACTIVITY_EVENT_CATALOG } from "../../../shared/activityCatalog"; -import { - attentionNotchSettingsFromPreferences, - normalizeAttentionPreferences, - readAttentionNotchEnabled, - readAttentionNotchPresentation, - writeAttentionNotchEnabled, - writeAttentionNotchPresentation, - type AttentionNotchPresentation, -} from "../attention/attentionNotchLocalSettings"; +import { normalizeAttentionPreferences } from "../attention/attentionNotchLocalSettings"; import { useAccountStatus } from "../../lib/account"; import { DEFAULT_LANE_BANNER_BUDGET } from "../../../shared/types/config"; import { COLORS, SANS_FONT } from "../lanes/laneDesignTokens"; @@ -38,8 +30,10 @@ import { AgentCompletionSoundSection } from "./AgentCompletionSoundSection"; * in the header, behind a Save button. The per-event policies and quiet hours * had no UI at all despite being fully modelled and honored. * - * This section is the canonical home for that model. The popover stays as a - * quick toggle; both write through `window.ade.attention.putPreferences`. + * This section is the canonical home for delivery: what ADE interrupts you + * for, when, and on which device. The surfaces Activity itself paints — the + * notch, celebrations, previews, per-machine mute — live on the Activity tab + * instead, because that is where the thing they describe lives. */ /** The events worth giving a user a dial for, in the order they'll scan them. */ @@ -66,12 +60,6 @@ const ESCALATION_OPTIONS = [ { value: "300", label: "After 5 minutes" }, ]; -const REVEAL_OPTIONS: { value: AttentionNotchPresentation["revealMode"]; label: string }[] = [ - { value: "minimal", label: "Compact + peek" }, - { value: "hover", label: "Reveal on hover" }, - { value: "click", label: "Click only" }, -]; - function minutesToTimeValue(minute: number): string { const safe = ((Math.floor(minute) % 1440) + 1440) % 1440; const hours = String(Math.floor(safe / 60)).padStart(2, "0"); @@ -93,10 +81,6 @@ export function NotificationsSection() { const accountOwnerId = accountStatus.signedIn ? accountStatus.userId : null; const [preferences, setPreferences] = useState(DEFAULT_ATTENTION_PREFERENCES); - const [notchEnabled, setNotchEnabled] = useState(() => readAttentionNotchEnabled()); - const [notchPresentation, setNotchPresentation] = useState( - () => readAttentionNotchPresentation(), - ); const [loading, setLoading] = useState(true); const { state: saveState, flash, fail } = useSavedFlash(); const mounted = useRef(true); @@ -133,10 +117,7 @@ export function NotificationsSection() { * state update commits, so reading component state here would save the * previous value. */ - const persist = useCallback(async ( - nextPreferences: AttentionPreferences, - nextNotch?: { enabled?: boolean; presentation?: AttentionNotchPresentation }, - ) => { + const persist = useCallback(async (nextPreferences: AttentionPreferences) => { const api = window.ade?.attention; if (!api || !accountOwnerId) { fail("Sign in to change notification settings."); @@ -144,22 +125,13 @@ export function NotificationsSection() { } try { await api.putPreferences(accountOwnerId, nextPreferences); - // Notch presentation is deliberately machine-local: the delivery policy - // syncs across devices, but where the HUD sits on *this* screen doesn't. - const enabled = nextNotch?.enabled ?? notchEnabled; - const presentation = nextNotch?.presentation ?? notchPresentation; - writeAttentionNotchEnabled(enabled); - writeAttentionNotchPresentation(presentation); - await window.ade?.attentionNotch?.updateSettings( - attentionNotchSettingsFromPreferences(nextPreferences, enabled, presentation), - ); if (!mounted.current) return; flash(); } catch (error) { if (!mounted.current) return; fail(error instanceof Error ? error.message : String(error)); } - }, [accountOwnerId, notchEnabled, notchPresentation, flash, fail]); + }, [accountOwnerId, flash, fail]); // Build the next value outside the state updater and save it explicitly. // Saving *inside* an updater would fire twice under StrictMode, which @@ -344,35 +316,9 @@ export function NotificationsSection() { /> } /> - updateAccount({ hideDetails })} - /> - } - /> - updateAccount({ soundsEnabled })} - /> - } - /> @@ -380,69 +326,6 @@ export function NotificationsSection() { - - { - setNotchEnabled(enabled); - void persist(preferences, { enabled }); - }} - /> - } - > - {notchEnabled ? ( -
-
- Behavior - ({ value: option.value, label: option.label }))} - onChange={(revealMode) => { - const presentation = { ...notchPresentation, revealMode }; - setNotchPresentation(presentation); - void persist(preferences, { presentation }); - }} - /> -
-
- Expanded panel - { - const presentation = { ...notchPresentation, expandedPanelEnabled }; - setNotchPresentation(presentation); - void persist(preferences, { presentation }); - }} - /> -
-
- ) : null} -
- - updateAccount({ celebrationsEnabled })} - /> - } - /> -
); } diff --git a/apps/desktop/src/renderer/components/settings/settingsManifest.test.ts b/apps/desktop/src/renderer/components/settings/settingsManifest.test.ts index 1ff4877d9..118795abf 100644 --- a/apps/desktop/src/renderer/components/settings/settingsManifest.test.ts +++ b/apps/desktop/src/renderer/components/settings/settingsManifest.test.ts @@ -67,6 +67,23 @@ describe("settings manifest", () => { } }); + it("follows the Attention → Activity move for links already in the wild", () => { + // `?tab=notifications#attention-notch` shipped in tour steps and deeplinks + // before Activity had a tab of its own. Landing those on Notifications — + // which no longer holds the card — would be an invisible dead end. + for (const [hash, expectedTab] of [ + ["attention-notch", "activity"], + ["celebrations", "activity"], + ["attention-sounds", "activity"], + ["hide-previews", "activity"], + ] as const) { + const entry = resolveSettingsHash(hash); + expect(entry, `hash "${hash}" did not resolve`).not.toBeNull(); + expect(entry!.tab).toBe(expectedTab); + } + expect(resolveSettingsTab("attention")).toBe("activity"); + }); + it("resolves a live anchor directly, without needing an alias", () => { for (const entry of SETTINGS_ENTRIES) { expect(resolveSettingsHash(entry.anchor)?.id).toBe(entry.id); diff --git a/apps/desktop/src/renderer/components/settings/settingsManifest.ts b/apps/desktop/src/renderer/components/settings/settingsManifest.ts index ef83c13cb..d2438e0d9 100644 --- a/apps/desktop/src/renderer/components/settings/settingsManifest.ts +++ b/apps/desktop/src/renderer/components/settings/settingsManifest.ts @@ -28,6 +28,7 @@ export const SETTINGS_TAB_IDS = [ "lanes-git", "integrations", "notifications", + "activity", "secrets", "storage", "stats", @@ -49,6 +50,7 @@ export const SETTINGS_TABS: readonly SettingsTab[] = [ { id: "lanes-git", label: "Lanes & Git", description: "How lanes start, stay current, and tell you they fell behind." }, { id: "integrations", label: "Integrations", description: "GitHub, Linear, and the ADE command line." }, { id: "notifications", label: "Notifications & Sound", description: "What ADE interrupts you for, and how." }, + { id: "activity", label: "Activity", description: "What's running everywhere, and how ADE shows it." }, // Named "Secrets & Environment" while planning, on the assumption that // `EnvironmentSection` held environment-variable mappings. It doesn't — it // was App version + ADE CLI, which now live in General and Integrations — @@ -422,24 +424,6 @@ export const SETTINGS_ENTRIES: readonly SettingEntry[] = [ scope: "machine", group: "Delivery", }, - { - id: "notifications.hide-previews", - label: "Hide previews", - keywords: ["privacy", "redact", "private", "content", "summary"], - tab: "notifications", - anchor: "hide-previews", - scope: "machine", - group: "Delivery", - }, - { - id: "notifications.attention-sounds", - label: "Attention sounds", - keywords: ["sound", "audio", "cue", "chime"], - tab: "notifications", - anchor: "attention-sounds", - scope: "machine", - group: "Sound", - }, { id: "notifications.completion-sound", label: "Agent completion sound", @@ -449,15 +433,6 @@ export const SETTINGS_ENTRIES: readonly SettingEntry[] = [ scope: "app", group: "Sound", }, - { - id: "notifications.celebrations", - label: "Celebrations", - keywords: ["confetti", "flourish", "animation", "success"], - tab: "notifications", - anchor: "celebrations", - scope: "machine", - group: "Attention notch", - }, { id: "notifications.lane-banners", label: "Lane banner budget", @@ -467,14 +442,72 @@ export const SETTINGS_ENTRIES: readonly SettingEntry[] = [ scope: "machine", group: "On-screen banners", }, + + // ── Activity ───────────────────────────────────────────────────────────── { - id: "notifications.notch", - label: "Attention notch", - keywords: ["notch", "menu bar", "hud", "reveal", "celebration", "overlay"], - tab: "notifications", - anchor: "attention-notch", + id: "activity.notch-enabled", + label: "ADE notch", + keywords: ["notch", "menu bar", "hud", "overlay", "ambient", "attention"], + tab: "activity", + anchor: "activity-notch", + scope: "machine", + showScopeChip: true, + group: "Notch & menu bar", + }, + { + id: "activity.notch-reveal", + label: "Notch behavior", + keywords: ["reveal", "hover", "click", "peek", "compact"], + tab: "activity", + anchor: "activity-notch-reveal", scope: "machine", - group: "Attention notch", + group: "Notch & menu bar", + }, + { + id: "activity.notch-expanded", + label: "Expanded panel", + keywords: ["panel", "expand", "list", "sessions", "tall"], + tab: "activity", + anchor: "activity-notch-expanded", + scope: "machine", + group: "Notch & menu bar", + }, + { + id: "activity.celebrations", + label: "Celebrations", + keywords: ["confetti", "flourish", "animation", "success"], + tab: "activity", + anchor: "activity-celebrations", + scope: "machine", + group: "Notch & menu bar", + }, + { + id: "activity.sounds", + label: "Activity sounds", + keywords: ["sound", "audio", "cue", "chime", "attention"], + tab: "activity", + anchor: "activity-sounds", + scope: "machine", + group: "Sound", + }, + { + id: "activity.hide-details", + label: "Hide previews", + keywords: ["privacy", "redact", "private", "content", "summary", "preview"], + tab: "activity", + anchor: "activity-hide-details", + scope: "machine", + group: "Privacy", + }, + { + id: "activity.machines", + label: "Notify me about", + keywords: ["machine", "mute", "silence", "mac", "device", "per-machine"], + tab: "activity", + anchor: "activity-machines", + scope: "machine", + showScopeChip: true, + group: "Machines", }, // ── Secrets ────────────────────────────────────────────────────────────── @@ -570,6 +603,8 @@ export const LEGACY_TAB_ALIASES: Readonly> = { onboarding: "general", help: "general", tours: "general", + // The Attention center became the Activity pane and tab. + attention: "activity", }; /** @@ -592,6 +627,11 @@ export const LEGACY_HASH_ALIASES: Readonly> = { "auto-updates": "general.auto-updates", "product-analytics": "general.analytics", storage: "storage.usage", + // Moved out of Notifications when Activity got its own tab. + "attention-notch": "activity.notch-enabled", + celebrations: "activity.celebrations", + "attention-sounds": "activity.sounds", + "hide-previews": "activity.hide-details", }; const ENTRIES_BY_ID = new Map(SETTINGS_ENTRIES.map((entry) => [entry.id, entry])); diff --git a/apps/desktop/src/renderer/lib/legacyRoutes.test.ts b/apps/desktop/src/renderer/lib/legacyRoutes.test.ts new file mode 100644 index 000000000..107ce559c --- /dev/null +++ b/apps/desktop/src/renderer/lib/legacyRoutes.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { + isActivityRoute, + LEGACY_ROUTE_ALIASES, + resolveLegacyRoute, +} from "./legacyRoutes"; + +describe("legacy routes", () => { + it("resolves every alias to a route the app serves today", () => { + for (const [legacy, target] of Object.entries(LEGACY_ROUTE_ALIASES)) { + expect(resolveLegacyRoute(legacy)).toBe(target); + expect(LEGACY_ROUTE_ALIASES[target]).toBeUndefined(); + } + }); + + it("carries a sub-path across the rename", () => { + expect(resolveLegacyRoute("/attention/inbox")).toBe("/activity/inbox"); + }); + + it("tolerates a trailing slash", () => { + expect(resolveLegacyRoute("/attention/")).toBe("/activity"); + }); + + it("leaves an unknown path alone", () => { + expect(resolveLegacyRoute("/work")).toBe("/work"); + expect(resolveLegacyRoute("/attentiveness")).toBe("/attentiveness"); + }); + + it("recognises Activity under either of its names", () => { + expect(isActivityRoute("/activity")).toBe(true); + expect(isActivityRoute("/attention")).toBe(true); + expect(isActivityRoute("/attention/inbox")).toBe(true); + // The prefix check must not catch a route that merely starts the same way. + expect(isActivityRoute("/attentiveness")).toBe(false); + expect(isActivityRoute("/work")).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/lib/legacyRoutes.ts b/apps/desktop/src/renderer/lib/legacyRoutes.ts new file mode 100644 index 000000000..5abda6c3d --- /dev/null +++ b/apps/desktop/src/renderer/lib/legacyRoutes.ts @@ -0,0 +1,37 @@ +/** + * Renamed renderer routes, and where they now live. + * + * ADE's shell does not use `` elements for its top-level surfaces — they + * are pathname predicates duplicated in `App.tsx` and `AppShell.tsx` — so there + * is no router-level redirect to hang a rename on. This is the same shape as + * `settingsManifest.ts`'s `LEGACY_TAB_ALIASES` / `resolveSettingsTab`: every + * path ADE has ever shipped in a deeplink, a tour step, or a bookmark stays + * resolvable, and the app has one place to look it up. + */ +export const LEGACY_ROUTE_ALIASES: Readonly> = { + // The Attention center became the Activity pane. The pathname survives as a + // deep link that opens the pane over whatever tab is current. + "/attention": "/activity", +}; + +/** + * Resolve a pathname to the route that serves it today. Unknown paths come back + * unchanged so callers can keep treating this as a total function. + */ +export function resolveLegacyRoute(pathname: string): string { + const normalized = pathname.replace(/\/+$/, "") || "/"; + const direct = LEGACY_ROUTE_ALIASES[normalized]; + if (direct) return direct; + for (const [legacy, target] of Object.entries(LEGACY_ROUTE_ALIASES)) { + if (normalized.startsWith(`${legacy}/`)) { + return `${target}${normalized.slice(legacy.length)}`; + } + } + return pathname; +} + +/** Whether a pathname opens the Activity pane, under either of its names. */ +export function isActivityRoute(pathname: string): boolean { + const resolved = resolveLegacyRoute(pathname); + return resolved === "/activity" || resolved.startsWith("/activity/"); +} diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts index b2c7cd18c..a94f7c232 100644 --- a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts @@ -317,7 +317,7 @@ describe("createAdeWebAdapter", () => { const adapter = createAdeWebAdapter(fake.asClient(), undefined, accountClient); await expect(adapter.ade.attention.getSnapshot()).rejects.toThrow( - "ADE Attention returned an incompatible response. Update ADE and retry.", + "ADE Activity returned an incompatible response. Update ADE and retry.", ); adapter.dispose(); }); @@ -358,11 +358,59 @@ describe("createAdeWebAdapter", () => { .resolves.toEqual(DEFAULT_ATTENTION_PREFERENCES); await expect(adapter.ade.attention.getPreferences("account-a")) .rejects.toThrow( - "Account Attention preferences were incompatible. Update ADE and retry.", + "Activity preferences were incompatible. Update ADE and retry.", ); adapter.dispose(); }); + it("patches one machine's notification mute without rewriting the account document", async () => { + const snapshot: BrowserAccountSnapshot = { + state: "signed_in", + userId: "account-a", + email: "owner@example.test", + name: "Owner", + imageUrl: null, + expiresAt: "2026-07-30T00:00:00.000Z", + machines: [], + relayBaseUrls: ["wss://relay.example"], + message: null, + }; + const accountClient = { + getSnapshot: () => snapshot, + captureSessionLease: () => ({ userId: "account-a", generation: 1 }), + isSessionLeaseCurrent: () => true, + getAccessToken: vi.fn(async () => "account-token"), + } as unknown as BrowserAccountClient; + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { + status: 200, + })); + vi.stubGlobal("fetch", fetchMock); + const adapter = createAdeWebAdapter(fake.asClient(), undefined, accountClient); + + await expect(adapter.ade.attention.putMachinePreferences!( + "account-a", + "studio mac/1", + { notificationsEnabled: false }, + )).resolves.toBeUndefined(); + + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + // The machine key goes in the path, so it has to survive a space and a slash. + expect(url).toContain("/attention/account/preferences/machines/studio%20mac%2F1"); + expect(init.method).toBe("PATCH"); + expect(JSON.parse(String(init.body))).toEqual({ notificationsEnabled: false }); + expect(init.headers).toMatchObject({ authorization: "Bearer account-token" }); + + // A machine mute written after an account switch would land on the wrong + // account's preferences entirely. + await expect(adapter.ade.attention.putMachinePreferences!( + "account-b", + "studio", + { notificationsEnabled: false }, + )).rejects.toThrow(/account changed/i); + expect(fetchMock).toHaveBeenCalledTimes(1); + adapter.dispose(); + }); + it("loads real machine Attention from the paired host while signed out", async () => { const snapshot: BrowserAccountSnapshot = { state: "signed_out", @@ -546,7 +594,7 @@ describe("createAdeWebAdapter", () => { const adapter = createAdeWebAdapter(fake.asClient(), undefined, accountClient); await expect(adapter.ade.attention.getSnapshot()).rejects.toThrow( - "ADE Attention returned an incompatible response. Update ADE and retry.", + "ADE Activity returned an incompatible response. Update ADE and retry.", ); adapter.dispose(); }); diff --git a/apps/desktop/src/renderer/webclient/adapter/attention.ts b/apps/desktop/src/renderer/webclient/adapter/attention.ts index 52313c4b8..01c49d7a9 100644 --- a/apps/desktop/src/renderer/webclient/adapter/attention.ts +++ b/apps/desktop/src/renderer/webclient/adapter/attention.ts @@ -230,7 +230,7 @@ function parseAttentionSnapshot(value: unknown): AttentionSnapshot { || (candidate.itemsTruncated !== undefined && typeof candidate.itemsTruncated !== "boolean") ) { throw new Error( - "ADE Attention returned an incompatible response. Update ADE and retry.", + "ADE Activity returned an incompatible response. Update ADE and retry.", ); } return { @@ -304,7 +304,7 @@ function parseAttentionPreferences(value: unknown): AttentionPreferences { || !candidate.mutedSessionIds.every((id) => typeof id === "string") ) { throw new Error( - "Account Attention preferences were incompatible. Update ADE and retry.", + "Activity preferences were incompatible. Update ADE and retry.", ); } return candidate as AttentionPreferences; @@ -324,7 +324,7 @@ function relayError(action: string, result: RelayResult): Error { : typeof body?.error === "string" ? body.error : `HTTP ${result.response.status}`; - return new Error(`Account Attention ${action} failed. ${reason}`); + return new Error(`Activity ${action} failed. ${reason}`); } export function createAttentionNamespace( @@ -352,7 +352,7 @@ export function createAttentionNamespace( availability: { state: "incompatible", title: `${hostName} needs an ADE update`, - message: `Update ADE on ${hostName}, then reconnect to load this machine's Attention.`, + message: `Update ADE on ${hostName}, then reconnect to load this machine's Activity.`, recovery: "update_host", hostName, }, @@ -366,16 +366,16 @@ export function createAttentionNamespace( const request = async ( action: string, - method: "GET" | "POST" | "PUT", + method: "GET" | "POST" | "PUT" | "PATCH", path: string, body?: unknown, ): Promise => { const lease = accountClient.captureSessionLease(); - if (!lease) throw new Error("Sign in to use account-wide Attention."); + if (!lease) throw new Error("Sign in to use account-wide Activity."); const requestOnce = async (forceRefresh: boolean): Promise => { const accessToken = await accountClient.getAccessToken({ forceRefresh }); if (!accountClient.isSessionLeaseCurrent(lease)) { - throw new Error("The ADE account changed before Attention could load."); + throw new Error("The ADE account changed before Activity could load."); } const response = await fetch(`${relayBaseUrl()}${path}`, { method, @@ -422,7 +422,7 @@ export function createAttentionNamespace( availability: { state: "signed_out", title: `Showing ${hostName} only`, - message: `Attention from ${hostName} is available. Sign in to combine work across every ADE machine.`, + message: `Activity from ${hostName} is available. Sign in to combine work across every ADE machine.`, recovery: "sign_in", hostName, }, @@ -443,7 +443,7 @@ export function createAttentionNamespace( accountOwnerId: accountClient.getSnapshot().userId?.trim() || null, availability: { state: "ready", - title: "Account Attention is live", + title: "Activity is live", message: "Work from every signed-in ADE machine is available.", recovery: null, }, @@ -457,7 +457,7 @@ export function createAttentionNamespace( : null; if (currentAccountOwnerId !== lastSnapshotAccountOwnerId) { throw new Error( - "The ADE account changed after Attention loaded. Refresh Attention, then try again.", + "The ADE account changed after Activity loaded. Refresh Activity, then try again.", ); } if (lastSnapshotScope === "machine") { @@ -466,7 +466,7 @@ export function createAttentionNamespace( || args.itemIds.some((itemId) => !lastMachineItemIds.has(itemId)) ) { throw new Error( - "Refresh this machine's Attention before acknowledging the item.", + "Refresh this machine's Activity before acknowledging the item.", ); } if ( @@ -475,13 +475,13 @@ export function createAttentionNamespace( !Number.isFinite(args.sourceRevisions?.[itemId])) ) { throw new Error( - "Refresh this machine's Attention before acknowledging a changed item.", + "Refresh this machine's Activity before acknowledging a changed item.", ); } if (!infra.commands.hasAction("attention.acknowledgeMachine")) { const hostName = infra.client.getStatus().hostName?.trim() || "the connected ADE host"; throw new Error( - `Update ADE on ${hostName}, reconnect, then try this Attention action again.`, + `Update ADE on ${hostName}, reconnect, then try this Activity action again.`, ); } await infra.commands.call( @@ -500,7 +500,7 @@ export function createAttentionNamespace( return; } if (lastSnapshotScope !== "account" || !currentAccountOwnerId) { - throw new Error("Refresh account Attention before acknowledging this item."); + throw new Error("Refresh account Activity before acknowledging this item."); } await request("acknowledgment", "POST", "/attention/account/ack", args); }, @@ -513,7 +513,7 @@ export function createAttentionNamespace( async getPreferences(accountOwnerId: string) { const owner = accountClient.getSnapshot().userId?.trim() ?? ""; if (!owner || owner !== accountOwnerId.trim()) { - throw new Error("The ADE account changed before Attention preferences could load."); + throw new Error("The ADE account changed before Activity settings could load."); } const result = record(await request( "preferences", @@ -531,7 +531,7 @@ export function createAttentionNamespace( ) { const owner = accountClient.getSnapshot().userId?.trim() ?? ""; if (!owner || owner !== accountOwnerId.trim()) { - throw new Error("The ADE account changed before Attention preferences could be saved."); + throw new Error("The ADE account changed before Activity settings could be saved."); } const { devices: _deviceOverrides, ...accountPreferences } = preferences; await request( @@ -542,6 +542,31 @@ export function createAttentionNamespace( ); }, + /** + * Per-machine notification mute. It has its own relay route rather than + * riding the preferences PUT because that PUT strips `devices` and replaces + * the whole document — a partial machine scope written that way would race + * every other tab editing the same preferences. + */ + async putMachinePreferences( + accountOwnerId: string, + machineKey: string, + preferences: Partial, + ) { + const owner = accountClient.getSnapshot().userId?.trim() ?? ""; + if (!owner || owner !== accountOwnerId.trim()) { + throw new Error("The ADE account changed before Activity settings could be saved."); + } + const key = machineKey.trim(); + if (!key) throw new Error("A machine is required to change its notifications."); + await request( + "machine preference update", + "PATCH", + `/attention/account/preferences/machines/${encodeURIComponent(key)}`, + preferences, + ); + }, + async openItem(item: AttentionItem) { const accountSnapshot = accountClient.getSnapshot(); const ownerMachineKey = item.machine.accountMachineKey?.trim() ?? ""; @@ -553,7 +578,7 @@ export function createAttentionNamespace( const currentHostDeviceId = infra.client.getStatus().hostDeviceId?.trim() ?? ""; if (ownerMachine && ownerMachine.deviceId !== currentHostDeviceId) { const lease = accountClient.captureSessionLease(); - if (!lease) throw new Error("Sign in again to open this Attention item."); + if (!lease) throw new Error("Sign in again to open this Activity item."); const accessToken = await accountClient.getAccessToken(); await infra.client.pairWithAccountMachine({ machine: ownerMachine, @@ -590,7 +615,7 @@ export function createAttentionNamespace( } } const parsed = parseDeeplink(attentionDestinationDeepLink(item.destination, item)); - if (!parsed.ok) throw new Error("This Attention destination is invalid."); + if (!parsed.ok) throw new Error("This Activity destination is invalid."); infra.events.emit("navigate", { target: deeplinkToNavigationTarget(parsed.target), source: "attention", diff --git a/apps/desktop/src/renderer/webclient/adapter/index.ts b/apps/desktop/src/renderer/webclient/adapter/index.ts index 08092b712..cce343bd4 100644 --- a/apps/desktop/src/renderer/webclient/adapter/index.ts +++ b/apps/desktop/src/renderer/webclient/adapter/index.ts @@ -1,4 +1,5 @@ import type { ProjectInfo } from "../../../shared/types"; +import { isWebClientMode } from "../../lib/webClientMode"; import type { SyncMobileProjectSummary } from "../../../shared/types/sync"; import type { AdeSyncClient } from "../sync"; import { BrowserAccountClient } from "../account/client"; @@ -47,8 +48,20 @@ export const WEB_HIDDEN_CAPABILITIES = { cursorCloud: false, transcription: false, automations: false, + // The notch is a native macOS helper supervised by the desktop main process. + // On web the namespace never registers, so `withFallbackProxy` resolves it to + // null; naming it here lets settings hide the controls instead of showing + // switches that silently do nothing. + attentionNotch: false, } as const; +export type WebHiddenCapability = keyof typeof WEB_HIDDEN_CAPABILITIES; + +/** Whether a capability is unavailable because this window is the web client. */ +export function isWebHiddenCapability(capability: WebHiddenCapability): boolean { + return isWebClientMode() && WEB_HIDDEN_CAPABILITIES[capability] === false; +} + const DOMAIN_EVENTS = { lanes: "lanesInvalidated", sessions: "sessionsInvalidated", diff --git a/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx b/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx index ecb690949..bff2ac345 100644 --- a/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx +++ b/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx @@ -60,6 +60,11 @@ const PENDING_TARGET_KEY = "ade-web:pending-target"; const ACCOUNT_LEASE_CHECK_INTERVAL_MS = 30_000; const APP_ROUTE_ROOTS = [ "/work", + // Activity is a modal, but its pathname is a real deep link the shell turns + // back into one. Without these two a hard reload on the hosted client drops + // the user at the sign-in shell instead of the app. + "/activity", + "/attention", "/lanes", "/files", "/prs", diff --git a/apps/desktop/src/shared/types/attention.ts b/apps/desktop/src/shared/types/attention.ts index 11bb2a8b3..af6deaeb1 100644 --- a/apps/desktop/src/shared/types/attention.ts +++ b/apps/desktop/src/shared/types/attention.ts @@ -200,6 +200,16 @@ export type AttentionPreferenceScope = { celebrationsEnabled: boolean; hideDetails: boolean; dockBadgeScope: "local" | "account"; + /** + * Notch presentation, synced so a second Mac inherits the choice instead of + * starting from the shipped default. Optional because every relay and + * publisher older than this build omits them, and because localStorage + * remains the offline cache of record — readers take the synced value when + * it is present and the local one otherwise. The localStorage key strings + * are unchanged; only the source of truth moved. + */ + notchRevealMode?: AttentionNotchRevealMode; + notchExpandedPanel?: boolean; quietHours: { enabled: boolean; startMinute: number; diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 8cb6bdeea..8fbd5a89e 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -827,7 +827,8 @@ changing rather than which service backs it: | Agents & Models | `ProvidersSection.tsx`, `OAuthConnectModal.tsx`, `AiFeaturesSection.tsx`, `BudgetCapEditor.tsx`, `DictationSection.tsx` | Provider connections, model routing, background helpers, spend cap, and voice input — merged because provider auth and per-task model routing are one mental model. **Coding Agents** cards (Claude Code, Codex CLI, Cursor, Droid) and **OpenCode — Universal Model Access**. Background helpers cover summaries, PR descriptions, commit messages, auto-naming, and scheduled-work recovery. Legacy `?tab=ai`, `?tab=providers`, `?tab=background-jobs`, and `?tab=automations` land here. | | Lanes & Git | `LaneBehaviorSection.tsx`, `LaneTemplatesSection.tsx`, `PrChatTranscriptsSection.tsx` | How lanes start (`new lane base`), stay current (`auto-rebase`), and tell you they fell behind (`rebase suggestions` off/badge/banner + min-behind threshold), plus lane init recipes and PR transcript gists. Legacy `?tab=lane-templates` lands here. | | Integrations | `GitHubIntegrationSection.tsx`, `LinearIntegrationSection.tsx`, `AdeCliSection.tsx` | GitHub, Linear, and the `ade` command line — reinstated as its own tab. Legacy `?tab=integrations`, `?tab=github`, and `?tab=linear` land here, as does `?integration=github|linear|cli`. | -| Notifications & Sound | `NotificationsSection.tsx`, `AgentCompletionSoundSection.tsx` | The canonical home for `AttentionPreferences`. Per-event delivery policy (off / ambient / notify) for agent and PR events, quiet hours, focus suppression, phone delivery and escalation, previews, sounds, celebrations, the attention notch, and the Lanes banner budget. The per-event matrix and quiet hours were fully modelled with balanced defaults but had **no UI at all** before this tab. The header `AttentionSettingsPopover` is now three quick toggles that point here. | +| Notifications & Sound | `NotificationsSection.tsx`, `AgentCompletionSoundSection.tsx` | Delivery for `AttentionPreferences`: per-event policy (off / ambient / notify) for agent and PR events, quiet hours, focus suppression, phone delivery and escalation, the agent completion sound, and the Lanes banner budget. The per-event matrix and quiet hours were fully modelled with balanced defaults but had **no UI at all** before this tab. | +| Activity | `ActivitySection.tsx`, `ActivitySettingsControls.tsx` | The surfaces Activity itself paints: the ADE notch (enabled, reveal mode, expanded panel), celebrations, Activity sounds, hide-previews, and the per-machine notification mute. `ActivitySettingsControls` is mounted here **and** by the gear inside the Activity popover and pane, so the two entry points cannot drift. Legacy `?tab=attention` plus the `#attention-notch`, `#celebrations`, `#attention-sounds`, and `#hide-previews` hashes land here. | | Secrets | `SecretsSection.tsx` | Encrypted key/value pairs for agents, desktop, and the CLI, with `.env` import. Legacy `?tab=secret` lands here. | | Storage & Diagnostics | `StorageSection.tsx`, `storage/*`, `SessionLifecycleSection.tsx` | Disk-usage and lane-storage dashboard, lane storage rules, session lifecycle, and diagnostics. Rule fields now show the value actually in force with an explicit "Inherited" marker instead of an empty box whose real value hid in the placeholder. Legacy `?tab=disk` and `?tab=diagnostics` land here. See [Storage and recovery](../storage-and-recovery/README.md). | | Stats | `AdeUsageSection.tsx`, `ActivityModule.tsx`, `providerColors.ts` | Usage page with live Limits plus a sectioned Activity dashboard. Legacy `?tab=usage` and `?tab=ade-usage` land here. | diff --git a/docs/features/web-client/README.md b/docs/features/web-client/README.md index 7d2e633f3..251b739b7 100644 --- a/docs/features/web-client/README.md +++ b/docs/features/web-client/README.md @@ -366,10 +366,14 @@ Reused desktop renderer (web-mode adaptation): updater, the onboarding tour, and tabs with no sync-protocol backing instead of rendering broken affordances. - `apps/desktop/src/renderer/components/attention/HeaderActivityControl.tsx` - and `AttentionCenter.tsx` - the project-independent header drawer and its - secondary Open all/history route. Attention is a global utility route, not + and `ActivityPane.tsx` - the project-independent header popover and the + expanded pane its "Open all" raises. Activity is a global utility surface, not another selected-machine tab, so it is intentionally separate from - `WEB_CLIENT_TAB_PATHS`. + `WEB_CLIENT_TAB_PATHS`. Its `/activity` pathname (and the `/attention` name it + replaced) is a deep link that opens the pane over the current tab; both are in + `APP_ROUTE_ROOTS` so a hard reload keeps it. The notch has no web counterpart, + so `attentionNotch` is listed in `WEB_HIDDEN_CAPABILITIES` and its settings + rows are hidden rather than rendered inert. - `apps/desktop/src/renderer/components/app/TopBar.tsx` and `ConnectionsPanel.tsx` - the single desktop Connections control and its Machines, Phone, and Web tabs. The Web tab reports connected browser peers From 71cbf4f27495d71ccf35734bdaf5c06bc067de45 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:34:55 -0400 Subject: [PATCH 10/19] =?UTF-8?q?activity(p6b):=20iOS=20drawer=20+=20hub?= =?UTF-8?q?=20=E2=80=94=20two-bucket=20ActivityDrawerSheet=20with=20swipe?= =?UTF-8?q?=20dismiss,=20ActivityRowPresentation=20shared=20mapper,=20hub?= =?UTF-8?q?=20bell=20+=20live=20strip=20+=20status=20dots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- apps/ios/ADE.xcodeproj/project.pbxproj | 60 +- apps/ios/ADE/App/ContentView.swift | 2 +- apps/ios/ADE/Services/AccountService.swift | 8 +- apps/ios/ADE/Services/SyncService.swift | 7 +- .../ADE/Shared/ActivityRowPresentation.swift | 362 ++++++ .../ActivityBellButton.swift} | 26 +- .../Views/Activity/ActivityDrawerModel.swift | 572 +++++++++ .../Views/Activity/ActivityDrawerSheet.swift | 495 ++++++++ apps/ios/ADE/Views/Activity/ActivityRow.swift | 349 ++++++ .../AttentionDrawerModel.swift | 784 ------------ .../AttentionDrawerSheet.swift | 1010 --------------- .../Views/Components/ADEDesignSystem.swift | 8 +- apps/ios/ADE/Views/Hub/HubComponents.swift | 108 +- apps/ios/ADE/Views/Hub/HubLiveStrip.swift | 56 + apps/ios/ADE/Views/Hub/HubScreen.swift | 4 + .../ADETests/ActivityDrawerModelTests.swift | 436 +++++++ .../ActivityRowPresentationTests.swift | 268 ++++ .../ADETests/AttentionDrawerModelTests.swift | 1097 ----------------- .../HubProjectPresentationTests.swift | 151 +++ 19 files changed, 2859 insertions(+), 2944 deletions(-) create mode 100644 apps/ios/ADE/Shared/ActivityRowPresentation.swift rename apps/ios/ADE/Views/{AttentionDrawer/AttentionDrawerButton.swift => Activity/ActivityBellButton.swift} (75%) create mode 100644 apps/ios/ADE/Views/Activity/ActivityDrawerModel.swift create mode 100644 apps/ios/ADE/Views/Activity/ActivityDrawerSheet.swift create mode 100644 apps/ios/ADE/Views/Activity/ActivityRow.swift delete mode 100644 apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift delete mode 100644 apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift create mode 100644 apps/ios/ADE/Views/Hub/HubLiveStrip.swift create mode 100644 apps/ios/ADETests/ActivityDrawerModelTests.swift create mode 100644 apps/ios/ADETests/ActivityRowPresentationTests.swift delete mode 100644 apps/ios/ADETests/AttentionDrawerModelTests.swift create mode 100644 apps/ios/ADETests/HubProjectPresentationTests.swift diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 6a81e7916..0586cabd3 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -20,6 +20,12 @@ E2000000000000000000009B /* LinearConnectionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000000000000000000009B /* LinearConnectionScreen.swift */; }; AA1100000000000000000001 /* ADESharedContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000001 /* ADESharedContainer.swift */; }; AA1100000000000000000002 /* ADESharedModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000002 /* ADESharedModels.swift */; }; + AA1100000000000000000004 /* ActivityRowPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000004 /* ActivityRowPresentation.swift */; }; + AA1100000000000000000014 /* ActivityRowPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000004 /* ActivityRowPresentation.swift */; }; + D3000000000000000000002A /* ActivityRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3000000000000000000001A /* ActivityRow.swift */; }; + E200000000000000000000A2 /* HubLiveStrip.swift in Sources */ = {isa = PBXBuildFile; fileRef = D200000000000000000000A2 /* HubLiveStrip.swift */; }; + AC7600000000000000000001 /* ActivityRowPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7500000000000000000001 /* ActivityRowPresentationTests.swift */; }; + AC7600000000000000000002 /* HubProjectPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7500000000000000000002 /* HubProjectPresentationTests.swift */; }; AA1100000000000000000003 /* ADESharedTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000003 /* ADESharedTheme.swift */; }; AA1100000000000000000011 /* ADESharedContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000001 /* ADESharedContainer.swift */; }; AA1100000000000000000012 /* ADESharedModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000002 /* ADESharedModels.swift */; }; @@ -145,10 +151,10 @@ C85070CCC923CAB6FD61AF85 /* RecordingPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C7F48CB9D3EBD80F1FDBD9F /* RecordingPill.swift */; }; B1D40000000000000000A001 /* GlobalDictationPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D40000000000000000A002 /* GlobalDictationPill.swift */; }; 7B70BE6839672E5D2D006B28 /* ADETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C0DF7FEB4C2EB854BAC888 /* ADETests.swift */; }; - D30000000000000000000011 /* AttentionDrawerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000001 /* AttentionDrawerModel.swift */; }; - D30000000000000000000012 /* AttentionDrawerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000002 /* AttentionDrawerButton.swift */; }; - D30000000000000000000013 /* AttentionDrawerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000003 /* AttentionDrawerSheet.swift */; }; - D30000000000000000000015 /* AttentionDrawerModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000005 /* AttentionDrawerModelTests.swift */; }; + D30000000000000000000011 /* ActivityDrawerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000001 /* ActivityDrawerModel.swift */; }; + D30000000000000000000012 /* ActivityBellButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000002 /* ActivityBellButton.swift */; }; + D30000000000000000000013 /* ActivityDrawerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000003 /* ActivityDrawerSheet.swift */; }; + D30000000000000000000015 /* ActivityDrawerModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000005 /* ActivityDrawerModelTests.swift */; }; AC7200000000000000000001 /* ActivityContractDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7100000000000000000001 /* ActivityContractDecodingTests.swift */; }; AC7400000000000000000001 /* ActivityAckQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7300000000000000000001 /* ActivityAckQueueTests.swift */; }; AC7400000000000000000002 /* ActivityPollingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7300000000000000000002 /* ActivityPollingTests.swift */; }; @@ -312,6 +318,11 @@ D2000000000000000000009B /* LinearConnectionScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LinearConnectionScreen.swift; path = ADE/Views/Linear/LinearConnectionScreen.swift; sourceTree = ""; }; AA1000000000000000000001 /* ADESharedContainer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADESharedContainer.swift; path = ADE/Shared/ADESharedContainer.swift; sourceTree = ""; }; AA1000000000000000000002 /* ADESharedModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADESharedModels.swift; path = ADE/Shared/ADESharedModels.swift; sourceTree = ""; }; + AA1000000000000000000004 /* ActivityRowPresentation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityRowPresentation.swift; path = ADE/Shared/ActivityRowPresentation.swift; sourceTree = ""; }; + D3000000000000000000001A /* ActivityRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityRow.swift; path = ADE/Views/Activity/ActivityRow.swift; sourceTree = ""; }; + D200000000000000000000A2 /* HubLiveStrip.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HubLiveStrip.swift; path = ADE/Views/Hub/HubLiveStrip.swift; sourceTree = ""; }; + AC7500000000000000000001 /* ActivityRowPresentationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityRowPresentationTests.swift; path = ADETests/ActivityRowPresentationTests.swift; sourceTree = ""; }; + AC7500000000000000000002 /* HubProjectPresentationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HubProjectPresentationTests.swift; path = ADETests/HubProjectPresentationTests.swift; sourceTree = ""; }; AA1000000000000000000003 /* ADESharedTheme.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADESharedTheme.swift; path = ADE/Shared/ADESharedTheme.swift; sourceTree = ""; }; AA0000000000000000000002 /* ADEWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ADEWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; AA5000000000000000000001 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = ADEWidgets/Info.plist; sourceTree = ""; }; @@ -423,10 +434,10 @@ D200000000000000000000A1 /* HubQuickConnect.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HubQuickConnect.swift; path = ADE/Views/Hub/HubQuickConnect.swift; sourceTree = ""; }; F40000000000000000000002 /* PersonalChatsScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PersonalChatsScreen.swift; path = ADE/Views/PersonalChats/PersonalChatsScreen.swift; sourceTree = ""; }; 14C0DF7FEB4C2EB854BAC888 /* ADETests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADETests.swift; path = ADETests/ADETests.swift; sourceTree = ""; }; - D30000000000000000000001 /* AttentionDrawerModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttentionDrawerModel.swift; path = ADE/Views/AttentionDrawer/AttentionDrawerModel.swift; sourceTree = ""; }; - D30000000000000000000002 /* AttentionDrawerButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttentionDrawerButton.swift; path = ADE/Views/AttentionDrawer/AttentionDrawerButton.swift; sourceTree = ""; }; - D30000000000000000000003 /* AttentionDrawerSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttentionDrawerSheet.swift; path = ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift; sourceTree = ""; }; - D30000000000000000000005 /* AttentionDrawerModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttentionDrawerModelTests.swift; path = ADETests/AttentionDrawerModelTests.swift; sourceTree = ""; }; + D30000000000000000000001 /* ActivityDrawerModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityDrawerModel.swift; path = ADE/Views/Activity/ActivityDrawerModel.swift; sourceTree = ""; }; + D30000000000000000000002 /* ActivityBellButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityBellButton.swift; path = ADE/Views/Activity/ActivityBellButton.swift; sourceTree = ""; }; + D30000000000000000000003 /* ActivityDrawerSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityDrawerSheet.swift; path = ADE/Views/Activity/ActivityDrawerSheet.swift; sourceTree = ""; }; + D30000000000000000000005 /* ActivityDrawerModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityDrawerModelTests.swift; path = ADETests/ActivityDrawerModelTests.swift; sourceTree = ""; }; AC7100000000000000000001 /* ActivityContractDecodingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityContractDecodingTests.swift; path = ADETests/ActivityContractDecodingTests.swift; sourceTree = ""; }; AC7300000000000000000001 /* ActivityAckQueueTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityAckQueueTests.swift; path = ADETests/ActivityAckQueueTests.swift; sourceTree = ""; }; AC7300000000000000000002 /* ActivityPollingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityPollingTests.swift; path = ADETests/ActivityPollingTests.swift; sourceTree = ""; }; @@ -685,7 +696,7 @@ G20000000000000000000000 /* PRs */, A10000000000000000000015 /* Settings */, AD0000000000000000000C01 /* Account */, - D30000000000000000000004 /* AttentionDrawer */, + D30000000000000000000004 /* Activity */, K30000000000000000000001 /* Deeplinks */, 9270CF8A67F3FA79089F39C1 /* LanesTabView.swift */, 5EE4D463D21266B62B422D11 /* PRsTabView.swift */, @@ -701,6 +712,7 @@ D20000000000000000000052 /* HubScreen+ChatNavigation.swift */, D20000000000000000000053 /* HubComposerDrawer.swift */, D200000000000000000000A1 /* HubQuickConnect.swift */, + D200000000000000000000A2 /* HubLiveStrip.swift */, ); name = Hub; sourceTree = ""; @@ -713,14 +725,15 @@ name = PersonalChats; sourceTree = ""; }; - D30000000000000000000004 /* AttentionDrawer */ = { + D30000000000000000000004 /* Activity */ = { isa = PBXGroup; children = ( - D30000000000000000000001 /* AttentionDrawerModel.swift */, - D30000000000000000000002 /* AttentionDrawerButton.swift */, - D30000000000000000000003 /* AttentionDrawerSheet.swift */, + D30000000000000000000001 /* ActivityDrawerModel.swift */, + D30000000000000000000002 /* ActivityBellButton.swift */, + D30000000000000000000003 /* ActivityDrawerSheet.swift */, + D3000000000000000000001A /* ActivityRow.swift */, ); - name = AttentionDrawer; + name = Activity; sourceTree = ""; }; K30000000000000000000001 /* Deeplinks */ = { @@ -967,6 +980,7 @@ AA1000000000000000000001 /* ADESharedContainer.swift */, AA1000000000000000000002 /* ADESharedModels.swift */, AA1000000000000000000003 /* ADESharedTheme.swift */, + AA1000000000000000000004 /* ActivityRowPresentation.swift */, AA5100000000000000000004 /* AttentionActionIntents.swift */, AE00000000000000000000A5 /* ADEAgentActivityAttributes.swift */, ); @@ -1064,7 +1078,9 @@ B90000000000000000000001 /* SyncTransportSelectionTests.swift */, AF00000000000000000000A4 /* PairingAndDpopTests.swift */, AC1000000000000000000008 /* ClipPairingHandoffTests.swift */, - D30000000000000000000005 /* AttentionDrawerModelTests.swift */, + D30000000000000000000005 /* ActivityDrawerModelTests.swift */, + AC7500000000000000000001 /* ActivityRowPresentationTests.swift */, + AC7500000000000000000002 /* HubProjectPresentationTests.swift */, AC7100000000000000000001 /* ActivityContractDecodingTests.swift */, AC7300000000000000000001 /* ActivityAckQueueTests.swift */, AC7300000000000000000002 /* ActivityPollingTests.swift */, @@ -1352,6 +1368,7 @@ AA1100000000000000000001 /* ADESharedContainer.swift in Sources */, AA1100000000000000000002 /* ADESharedModels.swift in Sources */, AA1100000000000000000003 /* ADESharedTheme.swift in Sources */, + AA1100000000000000000004 /* ActivityRowPresentation.swift in Sources */, C10000000000000000000002 /* ADECodeRenderingCache.swift in Sources */, 0A1E077A24A5367ED58900F9 /* ADEDesignSystem.swift in Sources */, E7C4AFA1DEBFC844E11CC907 /* SpeechDictationService.swift in Sources */, @@ -1362,9 +1379,10 @@ 91D46087242081A9F29BBFF5 /* DictationMicButton.swift in Sources */, C85070CCC923CAB6FD61AF85 /* RecordingPill.swift in Sources */, B1D40000000000000000A001 /* GlobalDictationPill.swift in Sources */, - D30000000000000000000011 /* AttentionDrawerModel.swift in Sources */, - D30000000000000000000012 /* AttentionDrawerButton.swift in Sources */, - D30000000000000000000013 /* AttentionDrawerSheet.swift in Sources */, + D30000000000000000000011 /* ActivityDrawerModel.swift in Sources */, + D30000000000000000000012 /* ActivityBellButton.swift in Sources */, + D30000000000000000000013 /* ActivityDrawerSheet.swift in Sources */, + D3000000000000000000002A /* ActivityRow.swift in Sources */, C10000000000000000000001 /* ADEMobilePrimitives.swift in Sources */, C1000000000000000000B001 /* MachineRowView.swift in Sources */, F2A1C9D8456E7B3C1D2E4F90 /* FilesCodeSupport.swift in Sources */, @@ -1412,6 +1430,7 @@ E20000000000000000000052 /* HubScreen+ChatNavigation.swift in Sources */, E20000000000000000000053 /* HubComposerDrawer.swift in Sources */, E200000000000000000000A1 /* HubQuickConnect.swift in Sources */, + E200000000000000000000A2 /* HubLiveStrip.swift in Sources */, F40000000000000000000001 /* PersonalChatsScreen.swift in Sources */, B10000000000000000000002 /* LaneAttachSheet.swift in Sources */, B10000000000000000000003 /* LaneBatchManageSheet.swift in Sources */, @@ -1568,7 +1587,9 @@ B90000000000000000000002 /* SyncTransportSelectionTests.swift in Sources */, AF00000000000000000000C4 /* PairingAndDpopTests.swift in Sources */, AC1100000000000000000008 /* ClipPairingHandoffTests.swift in Sources */, - D30000000000000000000015 /* AttentionDrawerModelTests.swift in Sources */, + D30000000000000000000015 /* ActivityDrawerModelTests.swift in Sources */, + AC7600000000000000000001 /* ActivityRowPresentationTests.swift in Sources */, + AC7600000000000000000002 /* HubProjectPresentationTests.swift in Sources */, AC7200000000000000000001 /* ActivityContractDecodingTests.swift in Sources */, AC7400000000000000000001 /* ActivityAckQueueTests.swift in Sources */, AC7400000000000000000002 /* ActivityPollingTests.swift in Sources */, @@ -1592,6 +1613,7 @@ AA1100000000000000000011 /* ADESharedContainer.swift in Sources */, AA1100000000000000000012 /* ADESharedModels.swift in Sources */, AA1100000000000000000013 /* ADESharedTheme.swift in Sources */, + AA1100000000000000000014 /* ActivityRowPresentation.swift in Sources */, AA5200000000000000000011 /* ADEWidgetBundle.swift in Sources */, AA5200000000000000000014 /* ADELockScreenWidget.swift in Sources */, AA5100000000000000000024 /* AttentionActionIntents.swift in Sources */, diff --git a/apps/ios/ADE/App/ContentView.swift b/apps/ios/ADE/App/ContentView.swift index 93188d59b..044ddddd8 100644 --- a/apps/ios/ADE/App/ContentView.swift +++ b/apps/ios/ADE/App/ContentView.swift @@ -111,7 +111,7 @@ struct ContentView: View { ConnectionSettingsView(syncService: syncService) } .sheet(isPresented: $syncService.attentionDrawerPresented) { - AttentionDrawerSheet() + ActivityDrawerSheet() .environmentObject(syncService) .environmentObject(syncService.attentionDrawer) } diff --git a/apps/ios/ADE/Services/AccountService.swift b/apps/ios/ADE/Services/AccountService.swift index e9bcf9c47..b83f606ce 100644 --- a/apps/ios/ADE/Services/AccountService.swift +++ b/apps/ios/ADE/Services/AccountService.swift @@ -679,6 +679,9 @@ final class AccountService: ObservableObject { /// Last relay acknowledgment failure. Optimistic local drawer state remains /// active while the durable queue waits for the next successful refresh. @Published private(set) var attentionAckFailure: String? + /// Last snapshot-refresh failure. Without it an unreachable relay and a + /// genuinely quiet account render the same empty drawer. + @Published private(set) var attentionRefreshFailure: String? /// Transient, user-facing error from the last sign-in attempt. @Published var lastError: String? @@ -1297,6 +1300,7 @@ final class AccountService: ObservableObject { incoming: delta ) guard ADESharedContainer.writeAttentionSnapshot(complete) else { return } + attentionRefreshFailure = nil attentionSnapshotRevision &+= 1 WidgetReloadBridge.reloadAllTimelines() @@ -1325,7 +1329,9 @@ final class AccountService: ObservableObject { attentionAckFailure = nil } } catch { - // Keep the last-known account snapshot and machine-local fallback. + // Keep the last-known account snapshot and machine-local fallback, but + // say so: an unreachable relay must not read as "nothing is happening". + attentionRefreshFailure = "Couldn't reach your machines. Showing the last known activity." } } diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index cb4eedd5c..886c8850c 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -3743,16 +3743,15 @@ final class SyncService: ObservableObject { /// uses to observe `activeSessions` / workspace snapshot writes. Lazily /// initialised on first access so tests + previews that never touch the /// drawer don't allocate an extra `ObservableObject`. - private var attentionDrawerStorage: AttentionDrawerModel? + private var attentionDrawerStorage: ActivityDrawerModel? private var attentionDrawerCancellables: Set = [] /// Drawer surface injected into the root view via `.environmentObject`. /// Rebuilt from the App Group `WorkspaceSnapshot` each time the host /// state changes — no independent transport. - @available(iOS 17.0, *) - var attentionDrawer: AttentionDrawerModel { + var attentionDrawer: ActivityDrawerModel { if let existing = attentionDrawerStorage { return existing } - let fresh = AttentionDrawerModel() + let fresh = ActivityDrawerModel() attentionDrawerCancellables = fresh.bind(to: self) attentionDrawerStorage = fresh return fresh diff --git a/apps/ios/ADE/Shared/ActivityRowPresentation.swift b/apps/ios/ADE/Shared/ActivityRowPresentation.swift new file mode 100644 index 000000000..26a13b648 --- /dev/null +++ b/apps/ios/ADE/Shared/ActivityRowPresentation.swift @@ -0,0 +1,362 @@ +import Foundation + +/// The iOS mirror of `apps/desktop/src/shared/sessionStatusPresentation.ts` and +/// the PR half of `renderer/components/attention/attentionPresentation.ts`. +/// +/// One item in, one row's worth of vocabulary out: what it is called, which hue +/// it wears, which glyph it carries, and whether the label is followed by a +/// ticking elapsed duration. Nothing here imports SwiftUI — tones are tokens, +/// not colours — so the app, the widget extension, and any future surface all +/// read the same table without inheriting the app's design system. +/// +/// **iOS 17 constraint.** This file compiles into the widget extension, whose +/// deployment target is 17.0. Keep it free of any newer API. +/// +/// ── The one-hue-one-meaning rule ──────────────────────────────────────────── +/// +/// blue work is happening, nothing is asked of you +/// amber YOUR MOVE — and nothing else, ever +/// emerald finished cleanly, you have not looked yet +/// red it broke +/// violet a human review is outstanding +/// neutral true, but not actionable +/// +/// Exactly one phase is amber: `needsYou`. A hue added here that the desktop +/// table does not have is a drift bug, not a feature. +public enum ActivityTone: String, Codable, Hashable, Sendable { + case blue + case violet + case amber + case emerald + case red + case neutral +} + +/// Glyph identity, not an icon import — the same split the desktop makes so the +/// table stays renderer-free. `systemImage` is the SF Symbols binding both +/// Apple-platform consumers happen to share. +public enum ActivityGlyph: String, Codable, Hashable, Sendable { + case working + case planning + case waiting + case needsYou + case done + case stale + case failed + case review + case merged + + public var systemImage: String { + switch self { + case .working: return "circle.dotted" + case .planning: return "list.bullet.rectangle" + case .waiting: return "hourglass" + case .needsYou: return "bell.badge.fill" + case .done: return "checkmark.circle.fill" + case .stale: return "clock.badge.exclamationmark" + case .failed: return "exclamationmark.triangle.fill" + case .review: return "eye.fill" + case .merged: return "arrow.triangle.merge" + } + } +} + +/// Which of the three priority bands a row belongs to. Mirrors desktop's +/// `activityPriority.ts`: needs-you first, then work in flight, then outcomes. +public enum ActivityBand: String, Codable, Hashable, Sendable, CaseIterable { + case needsYou + case working + case done + + public var title: String { + switch self { + case .needsYou: return "Needs you" + case .working: return "Working" + case .done: return "Done" + } + } +} + +/// The label/tone/glyph triple for one phase, before an item's own data is +/// folded in. +public struct ActivityPhasePresentation: Hashable, Sendable { + public let label: String + public let tone: ActivityTone + public let glyph: ActivityGlyph? + /// Whether the label should be followed by a live elapsed duration + /// ("Working 14s"). Only where elapsed time is the useful fact — a failed + /// run's age is noise. + public let showsElapsed: Bool + /// Whether this state should pull the eye. Working is deliberately not + /// prominent: an agent mid-turn is not yet your problem. + public let prominent: Bool + /// Liveness, not prominence — drives the pulsing dot on an online row. + public let active: Bool + + public init( + label: String, + tone: ActivityTone, + glyph: ActivityGlyph?, + showsElapsed: Bool, + prominent: Bool, + active: Bool + ) { + self.label = label + self.tone = tone + self.glyph = glyph + self.showsElapsed = showsElapsed + self.prominent = prominent + self.active = active + } +} + +public enum ActivityPhaseVocabulary { + /// Session-derived phases delegate to the desktop's `PHASE_PRESENTATION`; + /// PR phases come from `NON_SESSION_PRESENTATION` + `NON_SESSION_STATUS_DETAILS`. + /// Both tables are transcribed verbatim — if one changes, this changes. + public static func presentation(for phase: AccountAttentionPhase) -> ActivityPhasePresentation { + switch phase { + case .starting: + return .init(label: "Starting", tone: .blue, glyph: .working, showsElapsed: false, prominent: false, active: true) + case .running: + return .init(label: "Working", tone: .blue, glyph: .working, showsElapsed: true, prominent: false, active: true) + case .needsYou: + return .init(label: "Needs you", tone: .amber, glyph: .needsYou, showsElapsed: false, prominent: true, active: true) + case .completed: + return .init(label: "Done", tone: .emerald, glyph: .done, showsElapsed: false, prominent: true, active: false) + case .failed: + return .init(label: "Failed", tone: .red, glyph: .failed, showsElapsed: false, prominent: true, active: false) + // Running but silent past the threshold. Neutral, not blue: the process + // is technically alive, but "how long has it been quiet" is the actual + // question, so the elapsed ticker stays on. + case .stale: + return .init(label: "Stale", tone: .neutral, glyph: .stale, showsElapsed: true, prominent: false, active: false) + // Merge-blocked, not "your move" — frequently something the reader + // cannot clear at all, so it makes no claim on them and never paints amber. + case .blocked: + return .init(label: "Blocked", tone: .neutral, glyph: nil, showsElapsed: false, prominent: false, active: false) + case .checksFailing: + return .init(label: "Checks failing", tone: .red, glyph: .failed, showsElapsed: false, prominent: true, active: false) + case .reviewRequested: + return .init(label: "Review requested", tone: .violet, glyph: .review, showsElapsed: false, prominent: true, active: false) + case .changesRequested: + return .init(label: "Changes requested", tone: .red, glyph: .failed, showsElapsed: false, prominent: true, active: false) + case .mergeReady: + return .init(label: "Ready to merge", tone: .emerald, glyph: .done, showsElapsed: false, prominent: true, active: false) + case .open: + return .init(label: "Open", tone: .blue, glyph: nil, showsElapsed: false, prominent: false, active: false) + case .merged: + return .init(label: "Merged", tone: .emerald, glyph: .merged, showsElapsed: false, prominent: true, active: false) + case .closed: + return .init(label: "Closed", tone: .neutral, glyph: nil, showsElapsed: false, prominent: false, active: false) + case .unrecognized(let raw): + return unrecognizedPresentation(raw) + } + } + + /// A phase this build has never heard of gets the quietest presentation + /// there is. The one exception is `planning`, which the desktop already + /// renders as violet "Planning" from a session's chat activity mode and + /// which a newer publisher may start sending as a phase. + private static func unrecognizedPresentation(_ raw: String) -> ActivityPhasePresentation { + switch raw.lowercased() { + case "planning", "plan": + return .init(label: "Planning", tone: .violet, glyph: .planning, showsElapsed: true, prominent: false, active: true) + case "waiting": + return .init(label: "Waiting", tone: .neutral, glyph: .waiting, showsElapsed: false, prominent: false, active: false) + case "stopped": + return .init(label: "Stopped", tone: .neutral, glyph: nil, showsElapsed: false, prominent: false, active: false) + case "ended": + return .init(label: "Ended", tone: .neutral, glyph: nil, showsElapsed: false, prominent: false, active: false) + default: + // Never manufacture a hue for a state we cannot describe: a + // fallback that could paint amber would defeat the rule it exists + // to protect. + return .init(label: "Unknown", tone: .neutral, glyph: nil, showsElapsed: false, prominent: false, active: false) + } + } + + /// Which priority band a phase files under. `idle`-tier rows are forced out + /// of the needs-you band by `ActivityRowPresentation` — a row nobody is + /// waiting on must never sit at the top of the drawer. + public static func band(for phase: AccountAttentionPhase) -> ActivityBand { + switch phase { + case .needsYou, .failed, .checksFailing, .changesRequested: + return .needsYou + case .starting, .running, .blocked, .stale, .open, .reviewRequested: + return .working + case .completed, .merged, .closed, .mergeReady: + return .done + case .unrecognized: + return .done + } + } +} + +/// Everything one Activity row renders, derived from one `AccountAttentionItem`. +/// +/// Pure value type with no transport, no service reference, and no colour — the +/// drawer, the hub strip, and (from P7) the lock-screen widget all build their +/// rows from this so the three surfaces cannot describe one session three ways. +public struct ActivityRowPresentation: Identifiable, Hashable, Sendable { + public let id: String + public let title: String + public let laneName: String? + public let projectName: String + public let phaseLabel: String + public let tone: ActivityTone + public let glyph: ActivityGlyph? + public let showsElapsed: Bool + public let prominent: Bool + public let isActive: Bool + public let band: ActivityBand + /// Anchor for the elapsed ticker. `statusSince` when the publisher supplies + /// it (immutable for the life of a phase); `occurredAt` otherwise, which is + /// approximate but never wrong enough to mislead. + public let elapsedSince: Date? + /// The italic one-liner under the title. `nil` when the item carries no + /// prose worth the row height — the phase label already says the state. + public let statusNote: String? + public let modelLabel: String? + public let providerSlug: String? + public let machineKey: String + public let machineName: String + public let machineOnline: Bool + public let machineLastSeenAt: Date? + public let tier: AccountActivityTier + public let isPullRequest: Bool + public let prNumber: Int? + public let sessionId: String? + /// Pending approval/input item, when the row is holding for one. + public let pendingItemId: String? + public let planProgress: AccountAttentionPlanProgress? + public let recentActivity: [String] + public let actions: [AccountAttentionAction] + public let deepLink: URL? + public let updatedAt: Date + public let seenAt: Date? + /// Whether this row belongs in the Inbox bucket (PR/CI traffic and + /// unlooked-at outcomes), per `AccountAttentionItem.needsInbox`. + public let needsInbox: Bool + /// Inline App Intents execute against the currently paired host, so an item + /// owned by another machine must navigate instead of acting locally. + public let inlineActionsAllowed: Bool + + public init(item: AccountAttentionItem, inlineActionsAllowed: Bool = false) { + let presentation = ActivityPhaseVocabulary.presentation(for: item.phase) + let rawBand = ActivityPhaseVocabulary.band(for: item.phase) + + id = item.id + title = Self.nonEmpty(item.title) ?? "Untitled session" + laneName = Self.nonEmpty(item.laneName) + projectName = Self.nonEmpty(item.project.name) ?? "Project" + phaseLabel = presentation.label + tone = presentation.tone + glyph = presentation.glyph + showsElapsed = presentation.showsElapsed + prominent = presentation.prominent + isActive = presentation.active && item.machine.online + tier = item.tier + // An idle row is by definition not waiting on the reader. Letting one + // reach the needs-you band is how a drawer stops meaning anything. + band = (item.tier == .idle && rawBand == .needsYou) ? .working : rawBand + elapsedSince = item.statusSince ?? item.occurredAt + statusNote = Self.nonEmpty(item.preview) + ?? Self.nonEmpty(item.detail) + ?? Self.nonEmpty(item.privacyPreview) + modelLabel = Self.nonEmpty(item.model) + providerSlug = Self.nonEmpty(item.provider) + machineKey = item.machine.machineKey + machineName = Self.nonEmpty(item.machine.name) ?? "Mac" + machineOnline = item.machine.online + machineLastSeenAt = item.machine.lastSeenAt + isPullRequest = item.kind == .pullRequest + planProgress = item.planProgress + recentActivity = item.recentActivity ?? [] + actions = item.actions + deepLink = item.deepLinkURL + updatedAt = item.updatedAt + seenAt = item.seenAt + needsInbox = item.needsInbox + self.inlineActionsAllowed = inlineActionsAllowed + + switch item.destination { + case .session(let sessionId, let itemId, _): + self.sessionId = Self.nonEmpty(sessionId) + pendingItemId = Self.nonEmpty(itemId) + prNumber = nil + case .pullRequest(_, _, _, let number, _, _): + self.sessionId = nil + pendingItemId = nil + prNumber = number > 0 ? number : nil + } + } + + /// "Studio Mac · ADE" — the row's scope in one line. + public var scopeLabel: String { + let project = projectName.trimmingCharacters(in: .whitespacesAndNewlines) + let machine = machineName.trimmingCharacters(in: .whitespacesAndNewlines) + if machine.isEmpty { return project } + if project.isEmpty { return machine } + return "\(machine) · \(project)" + } + + /// Compact elapsed copy for the "Working 14s" ticker. Mirrors + /// `formatWorkingDuration`: seconds, then minutes, then hours, then days — + /// deliberately lossy above the hour, where the exact figure stops changing + /// any decision. + public func elapsedLabel(now: Date = Date()) -> String? { + guard showsElapsed, let elapsedSince else { return nil } + return Self.formatDuration(now.timeIntervalSince(elapsedSince)) + } + + /// "last seen 2h ago" copy for an offline machine's banner. + public func lastSeenLabel(now: Date = Date()) -> String? { + guard !machineOnline, let machineLastSeenAt else { return nil } + guard let duration = Self.formatDuration(now.timeIntervalSince(machineLastSeenAt)) else { + return nil + } + return "last seen \(duration) ago" + } + + public static func formatDuration(_ seconds: TimeInterval) -> String? { + guard seconds.isFinite, seconds >= 0 else { return nil } + let totalSeconds = Int(seconds) + if totalSeconds < 60 { return "\(totalSeconds)s" } + let totalMinutes = totalSeconds / 60 + if totalMinutes < 60 { return "\(totalMinutes)m" } + let totalHours = totalMinutes / 60 + if totalHours < 24 { return "\(totalHours)h" } + return "\(totalHours / 24)d" + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { return nil } + return value + } +} + +public extension Array where Element == ActivityRowPresentation { + /// Priority order inside a band: rows that want a human first, then the + /// freshest, then a stable id tiebreak so equal rows never swap places + /// between snapshots. + func sortedByActivityPriority() -> [ActivityRowPresentation] { + sorted { lhs, rhs in + if lhs.band != rhs.band { + return activityBandRank(lhs.band) < activityBandRank(rhs.band) + } + if lhs.prominent != rhs.prominent { return lhs.prominent } + if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt > rhs.updatedAt } + return lhs.id < rhs.id + } + } +} + +public func activityBandRank(_ band: ActivityBand) -> Int { + switch band { + case .needsYou: return 0 + case .working: return 1 + case .done: return 2 + } +} diff --git a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerButton.swift b/apps/ios/ADE/Views/Activity/ActivityBellButton.swift similarity index 75% rename from apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerButton.swift rename to apps/ios/ADE/Views/Activity/ActivityBellButton.swift index 4781918fe..8f388e455 100644 --- a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerButton.swift +++ b/apps/ios/ADE/Views/Activity/ActivityBellButton.swift @@ -1,18 +1,18 @@ import SwiftUI import UIKit -/// Bell affordance rendered next to the root toolbar connection and project controls. +/// Bell affordance for the Activity drawer, mounted on every root's toolbar and +/// (since this pass) on the hub's top bar too. /// /// Tapping flips `SyncService.attentionDrawerPresented` to `true`, which -/// surfaces `AttentionDrawerSheet` (mounted once on the root `ContentView`). +/// surfaces `ActivityDrawerSheet` (mounted once on the root `ContentView`). /// -/// Visual spec: liquid-glass disc with an amber tint + glow when there are -/// unread attention items; a red 16pt badge overlays the top-right corner -/// when `unreadCount > 0` (count-capped at `9+`). -@available(iOS 17.0, *) -struct AttentionDrawerButton: View { +/// Visual spec is unchanged: liquid-glass disc with an amber tint + glow when +/// something needs the user, and a 16pt badge capped at `9+`. What changed is +/// what the number counts — needs-you rows only, never ambient work in flight. +struct ActivityBellButton: View { @EnvironmentObject private var syncService: SyncService - @EnvironmentObject private var drawer: AttentionDrawerModel + @EnvironmentObject private var drawer: ActivityDrawerModel private var hasUnread: Bool { drawer.unreadCount > 0 } @@ -23,7 +23,7 @@ struct AttentionDrawerButton: View { var body: some View { Button(action: openDrawer) { Label { - Text("Attention") + Text("Activity") } icon: { ZStack { PrsGlassDisc(tint: tint, isAlive: hasUnread) { @@ -45,8 +45,12 @@ struct AttentionDrawerButton: View { } .buttonStyle(.plain) .animation(.snappy(duration: 0.2), value: drawer.unreadCount) - .accessibilityLabel("Attention items: \(drawer.unreadCount)") - .accessibilityHint("Opens the attention drawer.") + .accessibilityLabel( + hasUnread + ? "Activity, \(drawer.unreadCount) need you" + : "Activity" + ) + .accessibilityHint("Opens the Activity drawer.") .accessibilityShowsLargeContentViewer() } diff --git a/apps/ios/ADE/Views/Activity/ActivityDrawerModel.swift b/apps/ios/ADE/Views/Activity/ActivityDrawerModel.swift new file mode 100644 index 000000000..4f6540a7c --- /dev/null +++ b/apps/ios/ADE/Views/Activity/ActivityDrawerModel.swift @@ -0,0 +1,572 @@ +import Combine +import Foundation +import SwiftUI + +/// The two buckets the whole product now agrees on. +/// +/// Sessions is "what are my agents doing"; Inbox is "what arrived and wants an +/// acknowledgement" — PR and CI traffic plus outcomes nobody has looked at. +/// The old third bucket, Recent, is gone: a time-ordered pile of things that +/// already resolved is not a bucket, it is a scroll. +public enum ActivityBucket: String, CaseIterable, Hashable, Sendable { + case sessions + case inbox + + public var title: String { + switch self { + case .sessions: return "Sessions" + case .inbox: return "Inbox" + } + } +} + +/// Where the currently rendered rows came from. The drawer needs this to tell +/// "genuinely all clear" apart from "we could not reach anything" — two states +/// that used to render the same empty screen. +public enum ActivitySource: Hashable, Sendable { + /// A signed-in account snapshot, fresh enough to trust. + case account + /// This machine's local workspace snapshot only. + case machineFallback + /// Nothing at all — no account snapshot, no local snapshot. + case none +} + +/// One rendered line in a bucket: a row, or the divider that explains why the +/// rows beneath it are dimmed. +public enum ActivityListEntry: Identifiable, Hashable, Sendable { + case offlineMachine(machineKey: String, name: String, lastSeenLabel: String?) + case row(ActivityRowPresentation) + + public var id: String { + switch self { + case .offlineMachine(let machineKey, _, _): return "offline:\(machineKey)" + case .row(let row): return row.id + } + } +} + +public struct ActivitySection: Identifiable, Hashable, Sendable { + public let band: ActivityBand + public let rows: [ActivityRowPresentation] + public let entries: [ActivityListEntry] + + public var id: String { band.rawValue } + public var title: String { band.title } + public var count: Int { rows.count } +} + +/// Source of truth for the in-app Activity drawer. +/// +/// Reducer-only: it never opens its own transport. It prefers the account-wide +/// snapshot written to the App Group and falls back to `SyncService`'s current +/// workspace snapshot, projecting both through the same +/// `AccountAttentionItem` → `ActivityRowPresentation` path so a locally-derived +/// row and an account row can never look different. +/// +/// Persistence keys are deliberately unchanged from the Attention era: they are +/// user state, not naming. +@MainActor +public final class ActivityDrawerModel: ObservableObject { + /// Agent-kind rows, priority-flat: needs you → working → done. + @Published public private(set) var sessions: [ActivityRowPresentation] = [] + /// PR/CI traffic plus outcomes nobody has looked at yet. + @Published public private(set) var inbox: [ActivityRowPresentation] = [] + /// Machine presence from the snapshot, for the offline banners. + @Published public private(set) var machines: [AccountAttentionMachine] = [] + @Published public private(set) var unreadCount: Int = 0 + @Published public private(set) var source: ActivitySource = .none + /// The relay capped the account feed. Surfaced so the drawer can say so + /// rather than quietly showing a partial list. + @Published public private(set) var itemsTruncated: Bool = false + + public static let lastSeenAtKey = "ade.attention.lastSeenAt" + public static let dismissedItemIDsKey = "ade.attention.dismissedItemIDs" + public static let seenItemIDsKey = "ade.attention.seenItemIDs" + + private var lastSeenAt: Date { + didSet { + defaults.set( + lastSeenAt.timeIntervalSince1970, + forKey: Self.lastSeenAtKey + ) + recomputeUnreadCount() + } + } + + private let defaults: UserDefaults + private var dismissedItemIDs: Set + private var seenItemIDs: Set + + public init(defaults: UserDefaults = ADESharedContainer.defaults) { + self.defaults = defaults + let stored = defaults.double(forKey: Self.lastSeenAtKey) + self.lastSeenAt = stored > 0 + ? Date(timeIntervalSince1970: stored) + : .distantPast + self.dismissedItemIDs = Set(defaults.stringArray(forKey: Self.dismissedItemIDsKey) ?? []) + self.seenItemIDs = Set(defaults.stringArray(forKey: Self.seenItemIDsKey) ?? []) + } + + // MARK: - Reducer + + /// Rebuild from the account-level contract — the real path once signed in. + public func rebuild(from snapshot: AccountAttentionSnapshot) { + let now = Date() + let active = snapshot.items.filter { item in + item.dismissedAt == nil + && (item.expiresAt == nil || item.expiresAt! > now) + } + apply( + items: active, + machines: snapshot.machines ?? [], + source: .account, + truncated: snapshot.itemsTruncated ?? false, + inlineActionsAllowed: false + ) + } + + /// Rebuild from this machine's workspace snapshot. Projected into the same + /// account item shape first, so the fallback path shares every rule above + /// it instead of maintaining a parallel one. + public func rebuild(from snapshot: WorkspaceSnapshot) { + let machine = AccountAttentionMachine( + machineKey: Self.nonEmpty(snapshot.machineId) ?? "current-machine", + name: Self.nonEmpty(snapshot.machineName) ?? "Connected Mac", + online: snapshot.connection.lowercased() != "disconnected", + lastSeenAt: snapshot.generatedAt + ) + apply( + items: Self.accountItems(from: snapshot, machine: machine), + machines: [machine], + source: .machineFallback, + truncated: false, + // These rows belong to the paired host, so inline App Intents are + // pointed at the machine that actually owns them. + inlineActionsAllowed: true + ) + } + + /// Clear everything — used when no snapshot of any kind is available, so an + /// empty drawer reports "no source" rather than "all clear". + public func clearAll() { + sessions = [] + inbox = [] + machines = [] + source = .none + itemsTruncated = false + recomputeUnreadCount() + } + + private func apply( + items: [AccountAttentionItem], + machines: [AccountAttentionMachine], + source: ActivitySource, + truncated: Bool, + inlineActionsAllowed: Bool + ) { + let rows = items.map { + ActivityRowPresentation(item: $0, inlineActionsAllowed: inlineActionsAllowed) + } + pruneDismissedItems(activeIDs: Set(rows.map(\.id))) + let visible = rows.filter { !dismissedItemIDs.contains($0.id) } + + sessions = visible + .filter { !$0.isPullRequest } + .sortedByActivityPriority() + // PR/CI traffic always files here; agent rows join it only once they + // have finished and nobody has looked — which is exactly the set that + // would otherwise be a push nobody can act on. + inbox = visible + .filter { $0.isPullRequest || ($0.needsInbox && $0.band == .done) } + .sortedByActivityPriority() + self.machines = machines + self.source = source + itemsTruncated = truncated + pruneSeenItems(activeIDs: Set(rows.map(\.id))) + recomputeUnreadCount() + } + + // MARK: - Derived views + + /// Sessions grouped into the three priority bands, each carrying its + /// offline-machine dividers. + public var sessionSections: [ActivitySection] { + ActivityBand.allCases.compactMap { band in + let rows = sessions.filter { $0.band == band } + guard !rows.isEmpty else { return nil } + return ActivitySection(band: band, rows: rows, entries: Self.entries(for: rows)) + } + } + + public var inboxEntries: [ActivityListEntry] { + Self.entries(for: inbox) + } + + /// Rows for the hub's "Live now" strip: work actually in flight across every + /// machine on the account, quietest tier excluded. + public var liveNow: [ActivityRowPresentation] { + sessions.filter { row in + guard row.tier != .idle else { return false } + return row.band == .needsYou || row.band == .working + } + } + + public var isEmpty: Bool { sessions.isEmpty && inbox.isEmpty } + + public func rows(in bucket: ActivityBucket) -> [ActivityRowPresentation] { + switch bucket { + case .sessions: return sessions + case .inbox: return inbox + } + } + + /// Ids currently on screen, for the presence ping. Capped the same way the + /// relay caps its side of the call. + public var visibleItemIds: [String] { + Array((sessions + inbox).map(\.id).prefix(64)) + } + + /// Count label for the bell. `nil` at zero, `"9+"` past nine so the 16pt + /// circle never grows past two glyphs. + public var badgeLabel: String? { + guard unreadCount > 0 else { return nil } + return unreadCount > 9 ? "9+" : "\(unreadCount)" + } + + // MARK: - Acknowledgements + + /// Per-item dismiss — the affordance iOS never had. Optimistic locally, and + /// durable in `AccountService`'s pending-ack queue if the relay is out of + /// reach, so the intent survives a refresh. + public func dismiss(_ itemId: String) { + dismissedItemIDs.insert(itemId) + persistDismissedItems() + sessions.removeAll { $0.id == itemId } + inbox.removeAll { $0.id == itemId } + recomputeUnreadCount() + Task { await AccountService.shared.acknowledgeAttentionItems([itemId], dismiss: true) } + } + + public func markSeen(_ itemId: String) { + seenItemIDs.insert(itemId) + persistSeenItems() + recomputeUnreadCount() + Task { await AccountService.shared.acknowledgeAttentionItems([itemId], dismiss: false) } + } + + /// Mark every visible row seen. Rows stay listed — the underlying work has + /// not changed — but the bell stops asking. + public func markAllSeen() { + lastSeenAt = Date() + let ids = (sessions + inbox).filter { $0.seenAt == nil }.map(\.id) + seenItemIDs.formUnion(ids) + persistSeenItems() + recomputeUnreadCount() + guard !ids.isEmpty else { return } + Task { await AccountService.shared.acknowledgeAttentionItems(ids, dismiss: false) } + } + + /// Bulk dismiss for one bucket. Scoped to the ids on screen and pruned once + /// the backing state clears, so a future regression reappears. + public func dismissVisible(in bucket: ActivityBucket) { + let ids = rows(in: bucket).map(\.id) + guard !ids.isEmpty else { return } + dismissedItemIDs.formUnion(ids) + persistDismissedItems() + let dismissed = Set(ids) + sessions.removeAll { dismissed.contains($0.id) } + inbox.removeAll { dismissed.contains($0.id) } + recomputeUnreadCount() + Task { await AccountService.shared.acknowledgeAttentionItems(ids, dismiss: true) } + } + + // MARK: - Private + + /// The bell counts one thing: rows in the needs-you band, at signal tier, + /// that have not been dismissed or already looked at. Ambient work in + /// flight is visible in the drawer and never on the badge. + private func recomputeUnreadCount() { + unreadCount = sessions.filter { row in + row.band == .needsYou + && row.tier == .signal + && row.seenAt == nil + && !seenItemIDs.contains(row.id) + && row.updatedAt > lastSeenAt + }.count + } + + /// Online rows first; then one banner per offline machine followed by its + /// rows, so the explanation always precedes the dimmed run it explains. + private static func entries(for rows: [ActivityRowPresentation]) -> [ActivityListEntry] { + let online = rows.filter(\.machineOnline) + let offline = rows.filter { !$0.machineOnline } + var entries = online.map { ActivityListEntry.row($0) } + var seenMachines: Set = [] + for row in offline { + if seenMachines.insert(row.machineKey).inserted { + entries.append( + .offlineMachine( + machineKey: row.machineKey, + name: row.machineName, + lastSeenLabel: row.lastSeenLabel() + ) + ) + } + entries.append(.row(row)) + } + return entries + } + + private func pruneDismissedItems(activeIDs: Set) { + let pruned = dismissedItemIDs.intersection(activeIDs) + guard pruned != dismissedItemIDs else { return } + dismissedItemIDs = pruned + persistDismissedItems() + } + + private func persistDismissedItems() { + defaults.set(Array(dismissedItemIDs).sorted(), forKey: Self.dismissedItemIDsKey) + } + + private func pruneSeenItems(activeIDs: Set) { + let pruned = seenItemIDs.intersection(activeIDs) + guard pruned != seenItemIDs else { return } + seenItemIDs = pruned + persistSeenItems() + } + + private func persistSeenItems() { + defaults.set(Array(seenItemIDs).sorted(), forKey: Self.seenItemIDsKey) + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { return nil } + return value + } +} + +// MARK: - Workspace snapshot projection + +extension ActivityDrawerModel { + /// Project the machine-local snapshot into account items. Ids keep their + /// historical prefixes (`awaiting:`, `live:`, `ci:` …) so dismissals + /// persisted before this rewrite still match their rows. + static func accountItems( + from snapshot: WorkspaceSnapshot, + machine: AccountAttentionMachine + ) -> [AccountAttentionItem] { + let project = AccountAttentionProject( + projectId: nonEmpty(snapshot.projectId) ?? "current-project", + name: nonEmpty(snapshot.projectName) ?? "Current project" + ) + var items: [AccountAttentionItem] = [] + + for agent in snapshot.agents { + let phase = agentPhase(agent, machineOnline: machine.online) + let idPrefix: String + switch phase { + case .needsYou: idPrefix = "awaiting" + case .failed: idPrefix = "failed" + case .completed: idPrefix = "completed" + default: idPrefix = "live" + } + guard phase != .completed + || Date().timeIntervalSince(agent.lastActivityAt) <= 86_400 else { continue } + items.append( + AccountAttentionItem( + id: "\(idPrefix):\(agent.sessionId)", + revision: 0, + fingerprint: "local:\(agent.sessionId)", + kind: .agent, + eventKind: agentEventKind(phase), + phase: phase, + machine: machine, + project: project, + laneName: agent.laneName, + provider: agent.provider, + model: agent.modelId, + title: agentTitle(agent), + preview: nonEmpty(agent.preview) ?? "", + privacyPreview: "", + destination: .session( + sessionId: agent.sessionId, + itemId: agent.pendingInputItemId, + eventId: nil + ), + occurredAt: agent.lastActivityAt, + updatedAt: agent.lastActivityAt + ) + ) + } + + for pr in snapshot.prs { + guard let phase = prPhase(pr) else { continue } + let timestamp = pr.updatedAt ?? snapshot.generatedAt + if pr.state != "open", + Date().timeIntervalSince(timestamp) > 86_400 { continue } + items.append( + AccountAttentionItem( + id: "\(prIdPrefix(phase, state: pr.state)):\(pr.id)", + revision: 0, + fingerprint: "local:\(pr.id)", + kind: .pullRequest, + eventKind: prEventKind(phase), + phase: phase, + machine: machine, + project: project, + title: "PR #\(pr.number) · \(pr.title)", + preview: "", + privacyPreview: "", + destination: .pullRequest( + prId: pr.id, + repoOwner: nil, + repoName: nil, + number: pr.number, + tab: "overview", + eventId: nil + ), + occurredAt: timestamp, + updatedAt: timestamp + ) + ) + } + + return items + } + + private static func agentPhase( + _ agent: AgentSnapshot, + machineOnline: Bool + ) -> AccountAttentionPhase { + let status = agent.status.lowercased() + if agent.awaitingInput { return .needsYou } + if status == "failed" || status == "error" { return .failed } + if status == "completed" || status == "ended" { return .completed } + if status == "idle" { return .completed } + if !machineOnline { return .stale } + if nonEmpty(agent.phase)?.lowercased() == "blocked" { return .blocked } + return .running + } + + private static func agentEventKind( + _ phase: AccountAttentionPhase + ) -> AccountAttentionEventKind { + switch phase { + case .needsYou: return .agentNeedsYou + case .failed: return .agentFailed + case .completed: return .agentCompleted + default: return .agentRunning + } + } + + private static func prPhase(_ pr: PrSnapshot) -> AccountAttentionPhase? { + switch pr.state { + case "merged": return .merged + case "closed": return .closed + case "open": + if pr.checks == "failing" { return .checksFailing } + if pr.mergeReady { return .mergeReady } + if pr.review == "changes_requested" { return .changesRequested } + if pr.review == "pending" { return .reviewRequested } + return .open + default: return nil + } + } + + private static func prIdPrefix( + _ phase: AccountAttentionPhase, + state: String + ) -> String { + switch phase { + case .checksFailing: return "ci" + case .mergeReady: return "merge" + case .reviewRequested, .changesRequested: return "review" + default: return state + } + } + + private static func prEventKind( + _ phase: AccountAttentionPhase + ) -> AccountAttentionEventKind { + switch phase { + case .checksFailing: return .prChecksFailing + case .mergeReady: return .prMergeReady + case .reviewRequested: return .prReviewRequested + case .changesRequested: return .prChangesRequested + case .merged: return .prMerged + case .closed: return .prClosed + default: return .prOpened + } + } + + private static func agentTitle(_ agent: AgentSnapshot) -> String { + if let title = nonEmpty(agent.title) { return title } + let provider = ADESharedTheme.providerDisplayName(for: agent.provider) ?? "Agent" + return "\(provider) · \(agent.sessionId)" + } +} + +// MARK: - SyncService wiring + +extension ActivityDrawerModel { + /// Wire the model up to a live `SyncService`: rebuild whenever its sessions + /// or the App Group snapshots change. The workspace snapshot is read from + /// the App Group because `SyncService` already writes the authoritative blob + /// there — no separate transport. + /// + /// Returns the cancellables so callers (typically `SyncService` itself) can + /// retain them for the drawer's lifetime. + func bind(to syncService: SyncService) -> Set { + var bag: Set = [] + + let refresh: () -> Void = { [weak self, weak syncService] in + guard let self, let syncService else { return } + if let account = ADESharedContainer.readAttentionSnapshot(), + Date().timeIntervalSince(account.generatedAt) <= 86_400 { + self.rebuild(from: account) + return + } + if let snapshot = ADESharedContainer.readWorkspaceSnapshot() { + self.rebuild(from: snapshot) + return + } + guard !syncService.activeSessions.isEmpty else { + self.clearAll() + return + } + self.rebuild( + from: WorkspaceSnapshot( + generatedAt: Date(), + agents: syncService.activeSessions, + prs: [], + connection: "disconnected" + ) + ) + } + + syncService.$activeSessions + .receive(on: DispatchQueue.main) + .sink { _ in refresh() } + .store(in: &bag) + + syncService.$localStateRevision + .receive(on: DispatchQueue.main) + .sink { _ in refresh() } + .store(in: &bag) + + syncService.$workspaceSnapshotRevision + .receive(on: DispatchQueue.main) + .sink { _ in refresh() } + .store(in: &bag) + + AccountService.shared.$attentionSnapshotRevision + .receive(on: DispatchQueue.main) + .sink { _ in refresh() } + .store(in: &bag) + + refresh() + return bag + } +} diff --git a/apps/ios/ADE/Views/Activity/ActivityDrawerSheet.swift b/apps/ios/ADE/Views/Activity/ActivityDrawerSheet.swift new file mode 100644 index 000000000..d8c15432e --- /dev/null +++ b/apps/ios/ADE/Views/Activity/ActivityDrawerSheet.swift @@ -0,0 +1,495 @@ +import AppIntents +import SwiftUI + +/// Account-wide Activity, in two buckets: Sessions and Inbox. +/// +/// Sessions is every agent across every signed-in machine, priority-flat +/// (needs you → working → done). Inbox is the traffic that wants an +/// acknowledgement — pull requests, CI, and outcomes nobody has looked at. +/// Rows carry a swipe to dismiss or mark seen, which is the first per-item +/// affordance this surface has ever had. +struct ActivityDrawerSheet: View { + @EnvironmentObject private var drawer: ActivityDrawerModel + @EnvironmentObject private var accountService: AccountService + @Environment(\.dismiss) private var dismiss + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var bucket: ActivityBucket = .sessions + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + bucketPicker + if let message = failureMessage { + ActivityErrorBanner(message: message) + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + content + } + .navigationTitle("Activity") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Done") { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button { + drawer.markAllSeen() + } label: { + Label("Mark all seen", systemImage: "checkmark.circle") + } + Button(role: .destructive) { + drawer.dismissVisible(in: bucket) + } label: { + Label("Dismiss \(bucket.title.lowercased())", systemImage: "rectangle.stack.badge.minus") + } + .disabled(drawer.rows(in: bucket).isEmpty) + } label: { + Image(systemName: "ellipsis.circle") + } + .accessibilityLabel("Activity actions") + } + } + .adeScreenBackground() + .adeNavigationGlass() + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .presentationContentInteraction(.scrolls) + .task { + await accountService.refreshAttentionSnapshot() + await accountService.updateAttentionPresence( + centerVisible: true, + visibleItemIds: drawer.visibleItemIds + ) + } + .onDisappear { + Task { + await accountService.updateAttentionPresence( + centerVisible: false, + visibleItemIds: [] + ) + } + } + } + + private var bucketPicker: some View { + Picker("Activity bucket", selection: $bucket) { + ForEach(ActivityBucket.allCases, id: \.self) { value in + Text(bucketLabel(value)).tag(value) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .padding(.top, 10) + .padding(.bottom, 10) + } + + private func bucketLabel(_ value: ActivityBucket) -> String { + let count = drawer.rows(in: value).count + return count > 0 ? "\(value.title) \(count)" : value.title + } + + /// The relay is the only thing that can tell us an acknowledgement or a + /// refresh failed; both used to vanish into an empty `catch`. + private var failureMessage: String? { + accountService.attentionAckFailure ?? accountService.attentionRefreshFailure + } + + @ViewBuilder + private var content: some View { + switch bucket { + case .sessions: + if drawer.sessions.isEmpty { + emptyState + } else { + sessionsList + } + case .inbox: + if drawer.inbox.isEmpty { + emptyState + } else { + inboxList + } + } + } + + private var sessionsList: some View { + List { + ForEach(drawer.sessionSections) { section in + Section { + ForEach(section.entries) { entry in + entryView(entry) + } + } header: { + ActivitySectionHeader(band: section.band, count: section.count) + } + } + if drawer.itemsTruncated { + truncationNote + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + + private var inboxList: some View { + List { + ForEach(drawer.inboxEntries) { entry in + entryView(entry) + } + if drawer.itemsTruncated { + truncationNote + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + + @ViewBuilder + private func entryView(_ entry: ActivityListEntry) -> some View { + switch entry { + case .offlineMachine(_, let name, let lastSeenLabel): + ActivityOfflineMachineBanner(machineName: name, lastSeenLabel: lastSeenLabel) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 4, trailing: 16)) + case .row(let row): + VStack(alignment: .leading, spacing: 8) { + ActivityRow(row: row, dimmed: !row.machineOnline) { follow(row) } + ActivityActionButtons( + row: row, + open: { follow(row) }, + markSeen: { drawer.markSeen(row.id) } + ) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 2, trailing: 16)) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + drawer.dismiss(row.id) + } label: { + Label("Dismiss", systemImage: "xmark") + } + Button { + drawer.markSeen(row.id) + } label: { + Label("Mark seen", systemImage: "checkmark") + } + .tint(ADEColor.accent) + } + } + } + + private var truncationNote: some View { + Text("Showing the most recent activity. Older rows stay on their machine.") + .font(.system(.caption2, design: .rounded)) + .foregroundStyle(ADEColor.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 20, trailing: 16)) + } + + /// Three genuinely different empty states: nothing to reach, nothing to do, + /// and nothing new. They used to be one grey placeholder. + private var emptyState: some View { + let copy = emptyCopy + return VStack(spacing: 14) { + Spacer() + Image(systemName: copy.symbol) + .font(.system(size: 30, weight: .regular)) + .foregroundStyle(copy.tint) + VStack(spacing: 5) { + Text(copy.title) + .font(.system(.title3, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text(copy.body) + .font(.system(.subheadline, design: .rounded)) + .foregroundStyle(ADEColor.textSecondary) + .multilineTextAlignment(.center) + } + if drawer.source == .none { + Button { + Task { await accountService.refreshAttentionSnapshot() } + } label: { + Text("Try again") + .font(.system(.footnote, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.accent) + .padding(.horizontal, 16) + .padding(.vertical, 9) + .background(ADEColor.accent.opacity(0.14), in: Capsule()) + } + .buttonStyle(.plain) + } + Spacer() + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 32) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(copy.title). \(copy.body)") + } + + private var emptyCopy: (symbol: String, tint: Color, title: String, body: String) { + if drawer.source == .none { + return ( + "antenna.radiowaves.left.and.right.slash", + ADEColor.textMuted, + "Can't reach your machines", + "Sign in or reconnect to see what your agents are doing." + ) + } + switch bucket { + case .sessions: + return ( + "moon.zzz", + ADEColor.textMuted, + "All agents idle.", + "Sessions appear here the moment one starts working." + ) + case .inbox: + return ( + "checkmark.seal", + ADESharedTheme.statusSuccess, + "Nothing needs you.", + "Pull requests, checks, and finished runs land here." + ) + } + } + + private func follow(_ row: ActivityRowPresentation) { + guard let url = row.deepLink else { return } + drawer.markSeen(row.id) + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + (reduceMotion ? 0 : 0.18)) { + DeepLinkRouter.shared.handle(url) + } + } +} + +// MARK: - Section header + +private struct ActivitySectionHeader: View { + let band: ActivityBand + let count: Int + + private var tint: Color { + switch band { + case .needsYou: return ADESharedTheme.warningAmber + case .working: return ADESharedTheme.statusRunning + case .done: return ADESharedTheme.statusSuccess + } + } + + var body: some View { + HStack(spacing: 7) { + Text(band.title) + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .textCase(nil) + Text("\(count)") + .font(.system(.caption, design: .rounded).weight(.semibold).monospacedDigit()) + .foregroundStyle(tint) + .contentTransition(.numericText()) + Spacer(minLength: 0) + } + .padding(.vertical, 2) + .listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 4, trailing: 16)) + .accessibilityElement(children: .combine) + } +} + +// MARK: - Error banner + +private struct ActivityErrorBanner: View { + let message: String + + var body: some View { + HStack(spacing: 9) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(ADESharedTheme.warningAmber) + Text(message) + .font(.system(.caption, design: .rounded)) + .foregroundStyle(ADEColor.textPrimary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .padding(.horizontal, 11) + .padding(.vertical, 9) + .background(ADESharedTheme.warningAmber.opacity(0.10), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(ADESharedTheme.warningAmber.opacity(0.28), lineWidth: 0.7) + ) + .accessibilityElement(children: .combine) + } +} + +// MARK: - Server-supplied actions + +/// The item's own `actions[]`, rendered instead of the per-kind buttons this +/// sheet used to hardcode. Inline App Intents run against the paired host, so +/// they only appear when the row's machine is both this one and reachable — +/// otherwise every action degrades to navigation. +struct ActivityActionButtons: View { + let row: ActivityRowPresentation + let open: () -> Void + let markSeen: () -> Void + + private var canActInline: Bool { row.inlineActionsAllowed && row.machineOnline } + + private var visibleActions: [AccountAttentionAction] { + row.actions.filter { action in + switch action.kind { + case .approve, .deny, .answer, .restart, .rerunChecks: + return canActInline + case .open: + return row.deepLink != nil + case .markSeen, .dismiss, .unrecognized: + return false + } + } + } + + var body: some View { + if visibleActions.isEmpty { + EmptyView() + } else { + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { buttons } + VStack(spacing: 8) { buttons } + } + .padding(.bottom, 4) + } + } + + @ViewBuilder + private var buttons: some View { + ForEach(visibleActions, id: \.id) { action in + actionButton(action) + } + } + + @ViewBuilder + private func actionButton(_ action: AccountAttentionAction) -> some View { + switch action.kind { + case .approve: + Button(intent: ApproveSessionIntent( + sessionId: row.sessionId ?? "", + itemId: row.pendingItemId ?? "" + )) { + ActivityActionLabel(action.label, systemImage: "checkmark", variant: .primary(ADEColor.success)) + } + .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded(markSeen)) + + case .deny: + Button(intent: DenySessionIntent( + sessionId: row.sessionId ?? "", + itemId: row.pendingItemId ?? "" + )) { + ActivityActionLabel(action.label, systemImage: "xmark", variant: .danger) + } + .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded(markSeen)) + + case .restart: + Button(intent: RestartSessionIntent(sessionId: row.sessionId ?? "")) { + ActivityActionLabel(action.label, systemImage: "arrow.uturn.backward", variant: .secondary) + } + .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded(markSeen)) + + case .rerunChecks: + Button(intent: RetryCheckIntent( + prNumber: row.prNumber ?? 0, + prId: row.actionPayloadString(action, key: "prId") ?? "" + )) { + ActivityActionLabel(action.label, systemImage: "arrow.clockwise", variant: .secondary) + } + .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded(markSeen)) + + // Answering happens in the session, not in a drawer row. + case .answer, .open, .markSeen, .dismiss, .unrecognized: + Button(action: open) { + ActivityActionLabel(action.label, systemImage: "arrow.right", variant: .secondary) + } + .buttonStyle(.plain) + } + } +} + +private extension ActivityRowPresentation { + func actionPayloadString(_ action: AccountAttentionAction, key: String) -> String? { + guard case .string(let value)? = action.payload?[key] else { return nil } + return value + } +} + +private enum ActivityActionVariant { + case primary(Color) + case secondary + case danger + + var foreground: Color { + switch self { + case .primary(let tint): return tint + case .secondary: return ADEColor.textPrimary + case .danger: return ADEColor.danger + } + } + + var background: Color { + switch self { + case .primary(let tint): return tint.opacity(0.18) + case .secondary: return ADEColor.surfaceBackground.opacity(0.72) + case .danger: return ADEColor.danger.opacity(0.14) + } + } + + var stroke: Color { + switch self { + case .primary(let tint): return tint.opacity(0.32) + case .secondary: return ADEColor.glassBorder + case .danger: return ADEColor.danger.opacity(0.30) + } + } +} + +private struct ActivityActionLabel: View { + let title: String + let systemImage: String + let variant: ActivityActionVariant + + init(_ title: String, systemImage: String, variant: ActivityActionVariant) { + self.title = title + self.systemImage = systemImage + self.variant = variant + } + + var body: some View { + HStack(spacing: 5) { + Image(systemName: systemImage) + .font(.system(size: 10, weight: .bold)) + Text(title) + .font(.system(.caption, design: .rounded).weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.76) + } + .foregroundStyle(variant.foreground) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .padding(.horizontal, 10) + .background(variant.background, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(variant.stroke, lineWidth: 0.6) + ) + .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } +} diff --git a/apps/ios/ADE/Views/Activity/ActivityRow.swift b/apps/ios/ADE/Views/Activity/ActivityRow.swift new file mode 100644 index 000000000..8c3f70f2a --- /dev/null +++ b/apps/ios/ADE/Views/Activity/ActivityRow.swift @@ -0,0 +1,349 @@ +import SwiftUI + +/// The one Activity row, in two densities. +/// +/// `regular` is the drawer/list row; `compact` is the fixed-width card in the +/// hub's "Live now" strip. Both read every field from `ActivityRowPresentation` +/// and nothing else — no service, no snapshot, no transport — so the drawer, +/// the hub, and the widget cannot describe one session three different ways. +/// +/// Colours are resolved here rather than in the presentation so the mapper can +/// stay iOS-17-safe and design-system-free. +enum ActivityRowDensity { + case regular + case compact +} + +/// Tone token → the app's palette. The five session hues keep the meanings +/// documented on `ActivityTone`; violet is the PR-review hue. +func activityToneColor(_ tone: ActivityTone) -> Color { + switch tone { + case .blue: return ADESharedTheme.statusRunning + case .violet: return ADESharedTheme.statusReview + case .amber: return ADESharedTheme.warningAmber + case .emerald: return ADESharedTheme.statusSuccess + case .red: return ADESharedTheme.statusFailed + case .neutral: return ADESharedTheme.statusIdle + } +} + +struct ActivityRow: View { + let row: ActivityRowPresentation + var density: ActivityRowDensity = .regular + /// Rows belonging to an offline machine recede — the banner above them + /// carries the explanation, so the rows only need to stop competing. + var dimmed: Bool = false + let onOpen: () -> Void + + var body: some View { + Button(action: onOpen) { + content + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .opacity(dimmed ? 0.55 : 1) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + .accessibilityHint(row.isPullRequest ? "Opens the pull request." : "Opens the session.") + } + + private var accessibilityLabel: String { + var parts = [row.title, row.phaseLabel, row.scopeLabel] + if let model = row.modelLabel { parts.append(model) } + return parts.joined(separator: ", ") + } + + @ViewBuilder + private var content: some View { + switch density { + case .regular: regularContent + case .compact: compactContent + } + } + + // MARK: - Regular + + private var regularContent: some View { + HStack(alignment: .top, spacing: 11) { + ActivityProviderMark(slug: row.providerSlug, size: 26, pulse: row.isActive) + + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(row.title) + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + Spacer(minLength: 6) + ActivityStatusLabel(row: row) + } + + if let note = row.statusNote { + Text(note) + .font(.system(.caption, design: .rounded)) + .italic() + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + + if let progress = row.planProgress, progress.total > 0 { + ActivityPlanProgressBar(progress: progress, tone: row.tone) + } + + metaRow + } + } + .padding(.vertical, 9) + } + + private var metaRow: some View { + HStack(spacing: 6) { + if let lane = row.laneName { + ActivityLaneChip(name: lane) + } + ActivityMachineChip( + name: row.machineName, + online: row.machineOnline, + lastSeenLabel: row.lastSeenLabel() + ) + if let model = row.modelLabel { + Text(model) + .font(.system(.caption2, design: .rounded)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer(minLength: 0) + } + } + + // MARK: - Compact + + private var compactContent: some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 7) { + ActivityProviderMark(slug: row.providerSlug, size: 18, pulse: row.isActive) + ActivityStatusLabel(row: row) + Spacer(minLength: 0) + } + + Text(row.title) + .font(.system(.footnote, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(row.laneName.map { "\($0) · \(row.machineName)" } ?? row.machineName) + .font(.system(.caption2, design: .rounded)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + .padding(11) + .frame(width: 208, alignment: .leading) + .background(ADEColor.cardBackground.opacity(0.62), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke( + row.prominent + ? activityToneColor(row.tone).opacity(0.45) + : ADEColor.border.opacity(0.8), + lineWidth: 1 + ) + ) + } +} + +/// Status dot + phase label + the elapsed ticker, in the tone the phase owns. +struct ActivityStatusLabel: View { + let row: ActivityRowPresentation + /// Re-renders once a second only while a row is actually ticking. + @State private var now = Date() + + var body: some View { + let tint = activityToneColor(row.tone) + HStack(spacing: 5) { + ActivityStatusDot(tone: row.tone, active: row.isActive) + Text(label) + .font(.system(.caption2, design: .rounded).weight(.semibold).monospacedDigit()) + .foregroundStyle(tint) + .lineLimit(1) + .fixedSize() + } + .task(id: row.showsElapsed) { + guard row.showsElapsed else { return } + while !Task.isCancelled { + now = Date() + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + } + } + + private var label: String { + guard let elapsed = row.elapsedLabel(now: now) else { return row.phaseLabel } + return "\(row.phaseLabel) \(elapsed)" + } +} + +struct ActivityStatusDot: View { + let tone: ActivityTone + var active: Bool = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + let tint = activityToneColor(tone) + ZStack { + if active && !reduceMotion { + Circle() + .fill(tint) + .frame(width: 7, height: 7) + .phaseAnimator([false, true]) { circle, expanded in + circle + .scaleEffect(expanded ? 2.1 : 1) + .opacity(expanded ? 0 : 0.4) + } animation: { _ in + .easeOut(duration: 1.5) + } + } + Circle() + .fill(tint) + .frame(width: 7, height: 7) + } + .frame(width: 7, height: 7) + .accessibilityHidden(true) + } +} + +/// Provider logo on its brand-tinted disc. Falls back to the ADE mark's neutral +/// disc when the item carries no provider. +struct ActivityProviderMark: View { + let slug: String? + let size: CGFloat + var pulse: Bool = false + + var body: some View { + let resolved = slug ?? "ade" + let color = ADESharedTheme.brandColor(for: resolved) + Circle() + .fill(color.opacity(0.16)) + .frame(width: size, height: size) + .overlay { + if let assetName = ADESharedTheme.providerAssetName(for: resolved) { + Image(assetName) + .resizable() + .scaledToFit() + .frame(width: size * 0.66, height: size * 0.66) + } else { + Image(systemName: "terminal.fill") + .font(.system(size: size * 0.46, weight: .semibold)) + .foregroundStyle(color) + } + } + .overlay(Circle().strokeBorder(color.opacity(0.3), lineWidth: 0.7)) + .accessibilityHidden(true) + } +} + +/// Neutral tower glyph + machine name. Machine identity is deliberately not +/// tinted: amber means "your move" and nothing else, so it can never also mean +/// "this ran somewhere else". +struct ActivityMachineChip: View { + let name: String + let online: Bool + var lastSeenLabel: String? + + var body: some View { + HStack(spacing: 4) { + Image(systemName: online ? "desktopcomputer" : "wifi.slash") + .font(.system(size: 8, weight: .semibold)) + Text(lastSeenLabel.map { "\(name) · \($0)" } ?? name) + .font(.system(.caption2, design: .rounded).weight(.medium)) + .lineLimit(1) + } + .foregroundStyle(online ? ADEColor.textSecondary : ADEColor.textMuted) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(ADEColor.surfaceBackground.opacity(online ? 0.7 : 0.45), in: Capsule()) + } +} + +struct ActivityLaneChip: View { + let name: String + + var body: some View { + Text(name) + .font(.system(.caption2, design: .rounded).weight(.medium)) + .foregroundStyle(ADEColor.accent) + .lineLimit(1) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(ADEColor.accent.opacity(0.12), in: Capsule()) + } +} + +/// The plan bar iOS carried in the contract and never rendered: "3 of 7" plus +/// the current step, over a hairline track. +struct ActivityPlanProgressBar: View { + let progress: AccountAttentionPlanProgress + let tone: ActivityTone + + private var fraction: Double { + guard progress.total > 0 else { return 0 } + return min(1, max(0, Double(progress.completed) / Double(progress.total))) + } + + var body: some View { + let tint = activityToneColor(tone) + VStack(alignment: .leading, spacing: 3) { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule() + .fill(ADEColor.recessedBackground) + Capsule() + .fill(tint.opacity(0.75)) + .frame(width: max(2, geometry.size.width * fraction)) + } + } + .frame(height: 3) + + HStack(spacing: 5) { + Text("\(progress.completed) of \(progress.total)") + .font(.system(.caption2, design: .rounded).weight(.semibold).monospacedDigit()) + .foregroundStyle(ADEColor.textSecondary) + if let current = progress.current, !current.isEmpty { + Text(current) + .font(.system(.caption2, design: .rounded)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Plan progress: \(progress.completed) of \(progress.total)") + } +} + +/// Inline banner above the rows of a machine that is no longer reachable. The +/// rows below it dim rather than disappear — an offline machine's work still +/// happened, it just cannot be acted on from here. +struct ActivityOfflineMachineBanner: View { + let machineName: String + let lastSeenLabel: String? + + var body: some View { + HStack(spacing: 7) { + Image(systemName: "wifi.slash") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(ADEColor.textMuted) + Text(lastSeenLabel.map { "\(machineName) · \($0)" } ?? machineName) + .font(.system(.caption2, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + Rectangle() + .fill(ADEColor.border.opacity(0.55)) + .frame(height: 1) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(machineName) is offline. \(lastSeenLabel ?? "")") + } +} diff --git a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift deleted file mode 100644 index dbc74858c..000000000 --- a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift +++ /dev/null @@ -1,784 +0,0 @@ -import Combine -import Foundation -import SwiftUI - -/// Presentation bucket for a drawer row — what glyph and hue it wears. -/// -/// `awaitingInput` is the only amber kind, and that is deliberate: amber means -/// "your move" and nothing else (see `AgentRunPhase` and the desktop's -/// `sessionStatusPresentation`). `blocked` is a separate, neutral kind for -/// exactly that reason — it used to share `awaitingInput`, which put a raised -/// hand and an unmet dependency under one bell and one colour. -@available(iOS 17.0, *) -public enum AttentionKind: String, Codable, Hashable, Sendable { - case awaitingInput - case blocked - case failed - case ciFailing - case reviewRequested - case mergeReady - case running - case open - case completed - case merged - case stale -} - -@available(iOS 17.0, *) -public enum AttentionCollection: String, CaseIterable, Hashable, Sendable { - case needsYou - case live - case recent -} - -@available(iOS 17.0, *) -public struct AttentionProjectLens: Identifiable, Equatable, Sendable { - public let id: String - public let name: String - public let machineCount: Int - public let itemCount: Int -} - -/// A single row rendered inside the in-app Attention Drawer sheet. -/// -/// Built by `AttentionDrawerModel.rebuild(from:)` from the active -/// `WorkspaceSnapshot` so the drawer doesn't need its own transport. -@available(iOS 17.0, *) -public struct AttentionItem: Identifiable, Equatable { - public let id: String - public let kind: AttentionKind - public let title: String - public let subtitle: String - public let providerSlug: String? - public let sessionId: String? - public let itemId: String? - public let prId: String? - public let prNumber: Int? - public let deepLink: URL? - public let timestamp: Date - public let collection: AttentionCollection - public let machineId: String - public let machineName: String - public let machineOnline: Bool - public let projectId: String - public let projectName: String - public let laneName: String? - public let phaseLabel: String - public let seenAt: Date? - /// Inline App Intents execute against the currently paired host. Account - /// items can belong to another machine, so they must navigate to their exact - /// destination instead of invoking a local-host action. - public let inlineActionsAllowed: Bool - - public init( - id: String, - kind: AttentionKind, - title: String, - subtitle: String, - providerSlug: String? = nil, - sessionId: String? = nil, - itemId: String? = nil, - prId: String? = nil, - prNumber: Int? = nil, - deepLink: URL? = nil, - timestamp: Date, - collection: AttentionCollection = .needsYou, - machineId: String = "current-machine", - machineName: String = "Connected Mac", - machineOnline: Bool = true, - projectId: String = "current-project", - projectName: String = "Current project", - laneName: String? = nil, - phaseLabel: String? = nil, - seenAt: Date? = nil, - inlineActionsAllowed: Bool = true - ) { - self.id = id - self.kind = kind - self.title = title - self.subtitle = subtitle - self.providerSlug = providerSlug - self.sessionId = sessionId - self.itemId = itemId - self.prId = prId - self.prNumber = prNumber - self.deepLink = deepLink - self.timestamp = timestamp - self.collection = collection - self.machineId = machineId - self.machineName = machineName - self.machineOnline = machineOnline - self.projectId = projectId - self.projectName = projectName - self.laneName = laneName - self.phaseLabel = phaseLabel ?? Self.defaultPhaseLabel(for: kind) - self.seenAt = seenAt - self.inlineActionsAllowed = inlineActionsAllowed - } - - public var scopeLabel: String { - let project = projectName.trimmingCharacters(in: .whitespacesAndNewlines) - let machine = machineName.trimmingCharacters(in: .whitespacesAndNewlines) - if machine.isEmpty { return project } - if project.isEmpty { return machine } - return "\(machine) · \(project)" - } - - private static func defaultPhaseLabel(for kind: AttentionKind) -> String { - switch kind { - case .awaitingInput: return "Needs you" - case .blocked: return "Blocked" - case .failed: return "Failed" - case .ciFailing: return "Checks failing" - case .reviewRequested: return "Review" - case .mergeReady: return "Ready" - case .running: return "Working" - case .open: return "Open" - case .completed: return "Done" - case .merged: return "Merged" - // "Stale", not "Offline": the run is reachable and silent. Calling it - // offline points at the network, which is the one thing that is not - // wrong here. - case .stale: return "Stale" - } - } -} - -/// Source of truth for the in-app Attention Drawer. -/// -/// Reducer-only: never opens its own transport. It prefers the account-wide -/// snapshot written to the App Group and falls back to SyncService's current -/// workspace snapshot. Per-item seen IDs are persisted so opening one event -/// never clears another machine's unread badge. -@available(iOS 17.0, *) -@MainActor -public final class AttentionDrawerModel: ObservableObject { - @Published public private(set) var items: [AttentionItem] = [] - @Published public private(set) var liveItems: [AttentionItem] = [] - @Published public private(set) var recentItems: [AttentionItem] = [] - @Published public private(set) var unreadCount: Int = 0 - @Published public private(set) var selectedProjectId: String? - - public static let lastSeenAtKey = "ade.attention.lastSeenAt" - public static let dismissedItemIDsKey = "ade.attention.dismissedItemIDs" - public static let seenItemIDsKey = "ade.attention.seenItemIDs" - - private var lastSeenAt: Date { - didSet { - defaults.set( - lastSeenAt.timeIntervalSince1970, - forKey: Self.lastSeenAtKey - ) - recomputeUnreadCount() - } - } - - private let defaults: UserDefaults - private var dismissedItemIDs: Set - private var seenItemIDs: Set - private var accountBackedItemIDs: Set = [] - - public init(defaults: UserDefaults = ADESharedContainer.defaults) { - self.defaults = defaults - let stored = defaults.double(forKey: Self.lastSeenAtKey) - self.lastSeenAt = stored > 0 - ? Date(timeIntervalSince1970: stored) - : .distantPast - self.dismissedItemIDs = Set(defaults.stringArray(forKey: Self.dismissedItemIDsKey) ?? []) - self.seenItemIDs = Set(defaults.stringArray(forKey: Self.seenItemIDsKey) ?? []) - } - - // MARK: - Reducer - - /// Rebuild `items` from the current workspace snapshot. Items are sorted - /// by kind priority (awaiting > failed > ci > review > merge) then by - /// newest timestamp first. `unreadCount` is recomputed against - /// `lastSeenAt`. - public func rebuild(from snapshot: WorkspaceSnapshot) { - accountBackedItemIDs = [] - var result: [AttentionItem] = [] - var live: [AttentionItem] = [] - var recent: [AttentionItem] = [] - let generated = snapshot.generatedAt - let machineId = Self.nonEmpty(snapshot.machineId) ?? "current-machine" - let machineName = Self.nonEmpty(snapshot.machineName) ?? "Connected Mac" - let projectId = Self.nonEmpty(snapshot.projectId) ?? "current-project" - let projectName = Self.nonEmpty(snapshot.projectName) ?? "Current project" - let machineOnline = snapshot.connection.lowercased() != "disconnected" - - for agent in snapshot.agents { - if agent.awaitingInput { - let preview = agent.preview?.trimmingCharacters(in: .whitespacesAndNewlines) - let subtitle = preview.flatMap { $0.isEmpty ? nil : $0 } ?? "Approval needed" - result.append( - AttentionItem( - id: "awaiting:\(agent.sessionId)", - kind: .awaitingInput, - title: Self.humanAgentTitle(agent), - subtitle: subtitle, - providerSlug: agent.provider, - sessionId: agent.sessionId, - itemId: agent.pendingInputItemId, - deepLink: URL(string: "ade://session/\(agent.sessionId)"), - timestamp: agent.lastActivityAt, - machineId: machineId, - machineName: machineName, - machineOnline: machineOnline, - projectId: projectId, - projectName: projectName, - laneName: agent.laneName - ) - ) - } else if Self.isAgentFailed(agent) { - result.append( - AttentionItem( - id: "failed:\(agent.sessionId)", - kind: .failed, - title: Self.humanAgentTitle(agent), - subtitle: "Agent failed", - providerSlug: agent.provider, - sessionId: agent.sessionId, - deepLink: URL(string: "ade://session/\(agent.sessionId)"), - timestamp: agent.lastActivityAt, - machineId: machineId, - machineName: machineName, - machineOnline: machineOnline, - projectId: projectId, - projectName: projectName, - laneName: agent.laneName - ) - ) - } else if Self.isAgentCompleted(agent) { - guard Date().timeIntervalSince(agent.lastActivityAt) <= 86_400 else { continue } - recent.append( - AttentionItem( - id: "completed:\(agent.sessionId)", - kind: .completed, - title: Self.humanAgentTitle(agent), - subtitle: Self.nonEmpty(agent.preview) ?? "Agent work completed", - providerSlug: agent.provider, - sessionId: agent.sessionId, - deepLink: URL(string: "ade://session/\(agent.sessionId)"), - timestamp: agent.lastActivityAt, - collection: .recent, - machineId: machineId, - machineName: machineName, - machineOnline: machineOnline, - projectId: projectId, - projectName: projectName, - laneName: agent.laneName - ) - ) - } else if Self.isAgentLive(agent) { - let isBlocked = Self.nonEmpty(agent.phase)?.lowercased() == "blocked" - live.append( - AttentionItem( - id: "live:\(agent.sessionId)", - kind: machineOnline - ? (isBlocked ? .blocked : .running) - : .stale, - title: Self.humanAgentTitle(agent), - subtitle: Self.nonEmpty(agent.preview) - ?? Self.agentPhaseLabel(agent.phase) - ?? "Working", - providerSlug: agent.provider, - sessionId: agent.sessionId, - deepLink: URL(string: "ade://session/\(agent.sessionId)"), - timestamp: agent.lastActivityAt, - collection: .live, - machineId: machineId, - machineName: machineName, - machineOnline: machineOnline, - projectId: projectId, - projectName: projectName, - laneName: agent.laneName - ) - ) - } - } - - for pr in snapshot.prs where pr.state == "open" { - let prTimestamp = pr.updatedAt ?? generated - if pr.checks == "failing" { - result.append( - AttentionItem( - id: "ci:\(pr.id)", - kind: .ciFailing, - title: "PR #\(pr.number) · \(pr.title)", - subtitle: "Checks failing", - prId: pr.id, - prNumber: pr.number, - deepLink: URL(string: "ade://pr/\(pr.number)"), - timestamp: prTimestamp, - machineId: machineId, - machineName: machineName, - machineOnline: machineOnline, - projectId: projectId, - projectName: projectName - ) - ) - } else if pr.mergeReady { - result.append( - AttentionItem( - id: "merge:\(pr.id)", - kind: .mergeReady, - title: "PR #\(pr.number) · \(pr.title)", - subtitle: "Ready to merge", - prId: pr.id, - prNumber: pr.number, - deepLink: URL(string: "ade://pr/\(pr.number)"), - timestamp: prTimestamp, - machineId: machineId, - machineName: machineName, - machineOnline: machineOnline, - projectId: projectId, - projectName: projectName - ) - ) - } else if pr.review == "pending" || pr.review == "changes_requested" { - result.append( - AttentionItem( - id: "review:\(pr.id)", - kind: .reviewRequested, - title: "PR #\(pr.number) · \(pr.title)", - subtitle: pr.review == "changes_requested" - ? "Changes requested" - : "Review requested", - prId: pr.id, - prNumber: pr.number, - deepLink: URL(string: "ade://pr/\(pr.number)"), - timestamp: prTimestamp, - machineId: machineId, - machineName: machineName, - machineOnline: machineOnline, - projectId: projectId, - projectName: projectName - ) - ) - } - } - - pruneDismissedItems(activeIDs: Set(result.map(\.id))) - result.removeAll { dismissedItemIDs.contains($0.id) } - for pr in snapshot.prs where pr.state == "merged" || pr.state == "closed" { - let timestamp = pr.updatedAt ?? generated - guard Date().timeIntervalSince(timestamp) <= 86_400 else { continue } - recent.append( - AttentionItem( - id: "\(pr.state):\(pr.id)", - kind: pr.state == "merged" ? .merged : .completed, - title: "PR #\(pr.number) · \(pr.title)", - subtitle: pr.state == "merged" ? "Pull request merged" : "Pull request closed", - prId: pr.id, - prNumber: pr.number, - deepLink: URL(string: "ade://pr/\(pr.number)"), - timestamp: timestamp, - collection: .recent, - machineId: machineId, - machineName: machineName, - machineOnline: machineOnline, - projectId: projectId, - projectName: projectName - ) - ) - } - - sort(&result) - sort(&live) - sort(&recent) - items = result - liveItems = live - recentItems = recent - pruneSeenItems(activeIDs: Set((result + live + recent).map(\.id))) - validateSelectedProject() - recomputeUnreadCount() - } - - /// Rebuild from the account-level contract. This path supplies real - /// machine/project scope and shared seen state; `WorkspaceSnapshot` remains - /// the local fallback until the signed-in transport writes this snapshot. - public func rebuild(from snapshot: AccountAttentionSnapshot) { - let now = Date() - let active = snapshot.items.filter { item in - item.dismissedAt == nil - && (item.expiresAt == nil || item.expiresAt! > now) - } - let converted = active.map(Self.makeItem) - accountBackedItemIDs = Set(converted.map(\.id)) - var needs = converted.filter { $0.collection == .needsYou } - var live = converted.filter { $0.collection == .live } - var recent = converted.filter { item in - guard item.collection == .recent else { return false } - return item.seenAt == nil || snapshot.generatedAt.timeIntervalSince(item.timestamp) <= 86_400 - } - - pruneDismissedItems(activeIDs: Set(needs.map(\.id))) - needs.removeAll { dismissedItemIDs.contains($0.id) } - sort(&needs) - sort(&live) - sort(&recent) - items = needs - liveItems = live - recentItems = recent - pruneSeenItems(activeIDs: Set(converted.map(\.id))) - validateSelectedProject() - recomputeUnreadCount() - } - - public func selectProject(_ projectId: String?) { - selectedProjectId = projectId - } - - public func visibleItems(in collection: AttentionCollection) -> [AttentionItem] { - let source: [AttentionItem] - switch collection { - case .needsYou: source = items - case .live: source = liveItems - case .recent: source = recentItems - } - guard let selectedProjectId else { return source } - return source.filter { $0.projectId == selectedProjectId } - } - - public var projectLenses: [AttentionProjectLens] { - let all = items + liveItems + recentItems - let grouped = Dictionary(grouping: all, by: \.projectId) - return grouped.map { projectId, projectItems in - AttentionProjectLens( - id: projectId, - name: projectItems.first?.projectName ?? "Project", - machineCount: Set(projectItems.map(\.machineId)).count, - itemCount: projectItems.count - ) - } - .sorted { - if $0.itemCount != $1.itemCount { return $0.itemCount > $1.itemCount } - return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending - } - } - - public var visibleMachineCount: Int { - Set( - (items + liveItems + recentItems) - .filter { selectedProjectId == nil || $0.projectId == selectedProjectId } - .map(\.machineId) - ).count - } - - /// Dismiss-all entry point. Updates `lastSeenAt` → `Date.now` and - /// zeroes `unreadCount`. `items` is untouched (the drawer still lists - /// outstanding attention until the underlying state clears). - public func markAllSeen() { - lastSeenAt = Date() - let ids = accountBackedItemIDs.intersection( - Set((items + recentItems.filter { $0.seenAt == nil }).map(\.id)) - ) - if !ids.isEmpty { - Task { await AccountService.shared.acknowledgeAttentionItems(Array(ids), dismiss: false) } - } - } - - public func markSeen(_ itemId: String) { - seenItemIDs.insert(itemId) - persistSeenItems() - recomputeUnreadCount() - if accountBackedItemIDs.contains(itemId) { - Task { await AccountService.shared.acknowledgeAttentionItems([itemId], dismiss: false) } - } - } - - /// Clear the currently visible attention cards from the drawer. The - /// dismissal is scoped to the active attention IDs and is pruned once the - /// backing state clears, so a future CI/review/agent regression reappears. - public func clearVisibleItems() { - let visible = visibleItems(in: .needsYou) - guard !visible.isEmpty else { return } - - let visibleIds = Set(visible.map(\.id)) - dismissedItemIDs.formUnion(visibleIds) - persistDismissedItems() - items.removeAll { visibleIds.contains($0.id) } - validateSelectedProject() - recomputeUnreadCount() - // Account rows may arrive after a machine-local card is dismissed. - // AccountService persists unbacked ids and applies them when the relay - // snapshot catches up instead of dropping that user intent here. - Task { await AccountService.shared.acknowledgeAttentionItems(Array(visibleIds), dismiss: true) } - } - - // MARK: - Bell affordance - - /// Count label for the drawer badge. Returns `nil` at zero, `"9+"` for - /// anything > 9 so the 16pt circle never grows past two glyphs. - public var badgeLabel: String? { - guard unreadCount > 0 else { return nil } - return unreadCount > 9 ? "9+" : "\(unreadCount)" - } - - // MARK: - Private - - private func recomputeUnreadCount() { - let inbox = items + recentItems.filter { $0.seenAt == nil } - unreadCount = inbox.filter { - $0.seenAt == nil - && !seenItemIDs.contains($0.id) - && $0.timestamp > lastSeenAt - }.count - } - - private func pruneDismissedItems(activeIDs: Set) { - let pruned = dismissedItemIDs.intersection(activeIDs) - guard pruned != dismissedItemIDs else { return } - dismissedItemIDs = pruned - persistDismissedItems() - } - - private func persistDismissedItems() { - defaults.set(Array(dismissedItemIDs).sorted(), forKey: Self.dismissedItemIDsKey) - } - - private func pruneSeenItems(activeIDs: Set) { - let pruned = seenItemIDs.intersection(activeIDs) - guard pruned != seenItemIDs else { return } - seenItemIDs = pruned - persistSeenItems() - } - - private func persistSeenItems() { - defaults.set(Array(seenItemIDs).sorted(), forKey: Self.seenItemIDsKey) - } - - private static func kindPriority(_ kind: AttentionKind) -> Int { - switch kind { - case .awaitingInput: return 0 - case .failed: return 1 - case .ciFailing: return 2 - case .reviewRequested: return 3 - case .mergeReady: return 4 - case .running: return 5 - // Blocked sorts below live work and above a silent one: it is not - // asking for anything, but it has not gone quiet either. - case .blocked: return 6 - case .stale: return 7 - case .open: return 8 - case .completed: return 8 - case .merged: return 8 - } - } - - private func sort(_ values: inout [AttentionItem]) { - values.sort { lhs, rhs in - let lp = Self.kindPriority(lhs.kind) - let rp = Self.kindPriority(rhs.kind) - if lp != rp { return lp < rp } - if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } - return lhs.id < rhs.id - } - } - - private static func humanAgentTitle(_ snapshot: AgentSnapshot) -> String { - let provider = ADESharedTheme.providerDisplayName(for: snapshot.provider) ?? "Agent" - if let title = snapshot.title, !title.isEmpty { - return "\(provider) · \(title)" - } - return "\(provider) · \(snapshot.sessionId)" - } - - private static func agentPhaseLabel(_ phase: String?) -> String? { - guard let phase = nonEmpty(phase)?.lowercased() else { return nil } - switch phase { - case "starting": return "Starting" - case "running": return "Working" - case "planning", "plan": return "Planning" - case "development", "developing", "implementation", "implementing": return "Building" - case "testing", "test": return "Testing" - case "validation", "validating": return "Validating" - case "review", "reviewing": return "Reviewing" - case "pr", "pull_request": return "Preparing pull request" - case "waiting_for_approval", "needs_approval": return "Needs approval" - case "waiting_for_input", "awaiting_input", "needs_you": return "Needs reply" - case "blocked": return "Blocked" - case "completed", "done": return "Done" - case "failed", "error": return "Failed" - case "stale": return "Stale" - default: return nil - } - } - - private static func isAgentFailed(_ snapshot: AgentSnapshot) -> Bool { - let s = snapshot.status.lowercased() - return s == "failed" || s == "error" - } - - private static func isAgentCompleted(_ snapshot: AgentSnapshot) -> Bool { - let status = snapshot.status.lowercased() - return status == "completed" || status == "ended" - } - - private static func isAgentLive(_ snapshot: AgentSnapshot) -> Bool { - let status = snapshot.status.lowercased() - return status != "idle" - && status != "completed" - && status != "ended" - && status != "failed" - && status != "error" - } - - private static func nonEmpty(_ value: String?) -> String? { - guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), - !value.isEmpty else { - return nil - } - return value - } - - private static func makeItem(_ source: AccountAttentionItem) -> AttentionItem { - let kind: AttentionKind - let collection: AttentionCollection - switch source.phase { - case .needsYou: - kind = .awaitingInput - collection = .needsYou - // Blocked already filed itself under `live` rather than `needsYou` — - // the drawer never thought it was the user's move. It now looks the - // part too, instead of borrowing the amber bell from a raised hand. - case .blocked: - kind = .blocked - collection = .live - case .failed: - kind = .failed - collection = .needsYou - case .checksFailing: - kind = .ciFailing - collection = .needsYou - case .reviewRequested, .changesRequested: - kind = .reviewRequested - collection = .needsYou - case .mergeReady: - kind = .mergeReady - collection = .needsYou - case .starting, .running: - kind = .running - collection = .live - case .stale: - kind = .stale - collection = .live - case .open: - kind = .open - collection = .recent - case .completed, .closed: - kind = .completed - collection = .recent - case .merged: - kind = .merged - collection = .recent - case .unrecognized: - kind = .open - collection = .recent - } - - let destination = source.destination - let session: (String?, String?) - let pullRequest: (String?, Int?) - switch destination { - case .session(let sessionId, let itemId, _): - session = (sessionId, itemId) - pullRequest = (nil, nil) - case .pullRequest(let prId, _, _, let number, _, _): - session = (nil, nil) - pullRequest = (prId, number) - } - - let preview = nonEmpty(source.preview) - ?? nonEmpty(source.detail) - ?? nonEmpty(source.privacyPreview) - ?? source.phase.displayLabel - - return AttentionItem( - id: source.id, - kind: kind, - title: source.title, - subtitle: preview, - providerSlug: source.provider, - sessionId: session.0, - itemId: session.1, - prId: pullRequest.0, - prNumber: pullRequest.1, - deepLink: source.deepLinkURL, - timestamp: source.updatedAt, - collection: collection, - machineId: source.machine.machineKey, - machineName: source.machine.name, - machineOnline: source.machine.online, - projectId: source.project.projectId, - projectName: source.project.name, - laneName: source.laneName, - phaseLabel: source.phase.displayLabel, - seenAt: source.seenAt, - inlineActionsAllowed: false - ) - } - - private func validateSelectedProject() { - guard let selectedProjectId else { return } - if !(items + liveItems + recentItems).contains(where: { $0.projectId == selectedProjectId }) { - self.selectedProjectId = nil - } - } -} - -// MARK: - SyncService wiring - -@available(iOS 17.0, *) -extension AttentionDrawerModel { - /// Wire the drawer model up to a live `SyncService`: rebuild whenever - /// the service's `activeSessions` or App Group workspace snapshot changes. The - /// workspace snapshot is read from the App Group since `SyncService` - /// already writes the authoritative blob there — no separate transport. - /// - /// Returns the set of cancellables so callers (typically `SyncService` - /// itself) can retain them for the drawer's lifetime. - func bind(to syncService: SyncService) -> Set { - var bag: Set = [] - - let refresh: () -> Void = { [weak self, weak syncService] in - guard let self, let syncService else { return } - if let attention = ADESharedContainer.readAttentionSnapshot(), - Date().timeIntervalSince(attention.generatedAt) <= 86_400 { - self.rebuild(from: attention) - return - } - let snapshot = ADESharedContainer.readWorkspaceSnapshot() - ?? WorkspaceSnapshot( - generatedAt: Date(), - agents: syncService.activeSessions, - prs: [], - connection: "disconnected" - ) - self.rebuild(from: snapshot) - } - - syncService.$activeSessions - .receive(on: DispatchQueue.main) - .sink { _ in refresh() } - .store(in: &bag) - - syncService.$localStateRevision - .receive(on: DispatchQueue.main) - .sink { _ in refresh() } - .store(in: &bag) - - syncService.$workspaceSnapshotRevision - .receive(on: DispatchQueue.main) - .sink { _ in refresh() } - .store(in: &bag) - - AccountService.shared.$attentionSnapshotRevision - .receive(on: DispatchQueue.main) - .sink { _ in refresh() } - .store(in: &bag) - - refresh() - return bag - } -} diff --git a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift deleted file mode 100644 index 3d5570eeb..000000000 --- a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift +++ /dev/null @@ -1,1010 +0,0 @@ -import AppIntents -import SwiftUI - -/// Account-wide attention center. It renders the same priority stack whether -/// its source is the signed-in account snapshot or the current -/// `WorkspaceSnapshot` fallback. -@available(iOS 17.0, *) -struct AttentionDrawerSheet: View { - @EnvironmentObject private var drawer: AttentionDrawerModel - @EnvironmentObject private var accountService: AccountService - @Environment(\.dismiss) private var dismiss - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var didAppear = false - - private var needsYou: [AttentionItem] { drawer.visibleItems(in: .needsYou) } - private var live: [AttentionItem] { drawer.visibleItems(in: .live) } - private var recent: [AttentionItem] { drawer.visibleItems(in: .recent) } - - var body: some View { - NavigationStack { - Group { - if needsYou.isEmpty && live.isEmpty && recent.isEmpty { - emptyState - } else { - priorityStack - } - } - .navigationTitle("Attention") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Done") { dismiss() } - } - ToolbarItem(placement: .topBarTrailing) { - Menu { - Button { - drawer.markAllSeen() - } label: { - Label("Mark all seen", systemImage: "checkmark.circle") - } - Button(role: .destructive) { - drawer.clearVisibleItems() - } label: { - Label("Dismiss pending", systemImage: "rectangle.stack.badge.minus") - } - .disabled(needsYou.isEmpty) - } label: { - Image(systemName: "ellipsis.circle") - } - .accessibilityLabel("Attention actions") - } - } - .adeScreenBackground() - .adeNavigationGlass() - } - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - .presentationContentInteraction(.scrolls) - .task { - await accountService.refreshAttentionSnapshot() - await accountService.updateAttentionPresence( - centerVisible: true, - visibleItemIds: visibleItemIds - ) - } - .onDisappear { - Task { - await accountService.updateAttentionPresence( - centerVisible: false, - visibleItemIds: [] - ) - } - } - .onChange(of: drawer.selectedProjectId) { - Task { - await accountService.updateAttentionPresence( - centerVisible: true, - visibleItemIds: visibleItemIds - ) - } - } - .onAppear { - guard !didAppear else { return } - if reduceMotion { - didAppear = true - } else { - withAnimation(.spring(response: 0.5, dampingFraction: 0.86)) { - didAppear = true - } - } - } - } - - private var visibleItemIds: [String] { - Array((needsYou + live + recent).map(\.id).prefix(64)) - } - - private var priorityStack: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 22) { - overview - .opacity(didAppear ? 1 : 0) - .offset(y: didAppear ? 0 : 8) - - if !drawer.projectLenses.isEmpty { - projectLensStrip - } - - if !needsYou.isEmpty { - AttentionSectionHeader( - title: "Needs you", - count: needsYou.count, - systemImage: "bell.badge.fill", - tint: ADESharedTheme.warningAmber, - detail: "Decisions, failures, and reviews" - ) - - AttentionHeroCard(item: needsYou[0]) { - follow(needsYou[0]) - } markSeen: { - drawer.markSeen(needsYou[0].id) - } - - ForEach(needsYou.dropFirst()) { item in - AttentionCenterCard(item: item) { - follow(item) - } markSeen: { - drawer.markSeen(item.id) - } - } - } else { - allCaughtUpStrip - } - - if !live.isEmpty { - AttentionSectionHeader( - title: "Live", - count: live.count, - systemImage: "waveform.path.ecg", - tint: ADESharedTheme.statusRunning, - detail: "Work moving across your machines" - ) - - VStack(spacing: 0) { - ForEach(Array(live.enumerated()), id: \.element.id) { index, item in - AttentionLiveRow(item: item) { - follow(item) - } - if index < live.count - 1 { - Divider() - .overlay(Color.white.opacity(0.06)) - .padding(.leading, 50) - } - } - } - .background(ADEColor.cardBackground.opacity(0.9), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(ADEColor.glassBorder, lineWidth: 0.7) - ) - } - - if !recent.isEmpty { - AttentionSectionHeader( - title: "Recent", - count: recent.count, - systemImage: "clock.arrow.circlepath", - tint: ADEColor.textSecondary, - detail: "Outcomes from the last 24 hours" - ) - - VStack(spacing: 10) { - ForEach(recent) { item in - AttentionRecentRow(item: item) { - follow(item) - } - } - } - } - } - .padding(.horizontal, 16) - .padding(.top, 14) - .padding(.bottom, 34) - } - .scrollBounceBehavior(.basedOnSize) - } - - private var overview: some View { - HStack(spacing: 14) { - ZStack { - Circle() - .fill( - RadialGradient( - colors: [ - PrGlassPalette.purple.opacity(0.34), - PrGlassPalette.purple.opacity(0), - ], - center: .center, - startRadius: 2, - endRadius: 42 - ) - ) - .frame(width: 74, height: 74) - .blur(radius: 5) - - Circle() - .fill(.ultraThinMaterial) - .frame(width: 48, height: 48) - .overlay(Circle().strokeBorder(PrGlassPalette.accentGradient, lineWidth: 0.8)) - - Image(systemName: "scope") - .font(.system(size: 21, weight: .semibold)) - .foregroundStyle(PrGlassPalette.purple) - .symbolEffect(.pulse, options: reduceMotion ? .nonRepeating : .repeating) - } - .accessibilityHidden(true) - - VStack(alignment: .leading, spacing: 5) { - Text(overviewTitle) - .font(.title3.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Text(overviewSubtitle) - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(2) - - HStack(spacing: 7) { - AttentionCountPill(count: needsYou.count, label: "need you", tint: ADESharedTheme.warningAmber) - AttentionCountPill(count: live.count, label: "live", tint: ADESharedTheme.statusRunning) - } - } - Spacer(minLength: 0) - } - .padding(14) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .strokeBorder( - LinearGradient( - colors: [Color.white.opacity(0.18), PrGlassPalette.purple.opacity(0.12)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ), - lineWidth: 0.8 - ) - ) - .accessibilityElement(children: .combine) - } - - private var overviewTitle: String { - if let selected = drawer.projectLenses.first(where: { $0.id == drawer.selectedProjectId }) { - return selected.name - } - return "Across your work" - } - - private var overviewSubtitle: String { - let count = drawer.visibleMachineCount - if count == 0 { return "Your connected projects will appear here." } - return count == 1 - ? "One machine, every active thread in one place." - : "\(count) machines, every active thread in one place." - } - - private var projectLensStrip: some View { - ScrollView(.horizontal) { - HStack(spacing: 8) { - ProjectLensButton( - title: "All projects", - count: drawer.projectLenses.reduce(0) { $0 + $1.itemCount }, - selected: drawer.selectedProjectId == nil - ) { - selectProject(nil) - } - - ForEach(drawer.projectLenses) { project in - ProjectLensButton( - title: project.name, - count: project.itemCount, - selected: drawer.selectedProjectId == project.id - ) { - selectProject(project.id) - } - } - } - .padding(.horizontal, 1) - } - .scrollIndicators(.hidden) - .accessibilityLabel("Project filter") - } - - private var allCaughtUpStrip: some View { - HStack(spacing: 11) { - Image(systemName: "checkmark.seal.fill") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(ADESharedTheme.statusSuccess) - .symbolEffect(.bounce, value: didAppear) - VStack(alignment: .leading, spacing: 2) { - Text("Nothing needs you") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Text(live.isEmpty ? "Everything is quiet." : "Live work is moving without a blocker.") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - Spacer(minLength: 0) - } - .padding(13) - .background(ADESharedTheme.statusSuccess.opacity(0.08), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(ADESharedTheme.statusSuccess.opacity(0.2), lineWidth: 0.7) - ) - } - - private var emptyState: some View { - VStack(spacing: 16) { - Spacer() - ZStack { - Circle() - .fill( - RadialGradient( - colors: [PrGlassPalette.purple.opacity(0.30), .clear], - center: .center, - startRadius: 0, - endRadius: 56 - ) - ) - .frame(width: 120, height: 120) - .blur(radius: 10) - - Circle() - .fill(.ultraThinMaterial) - .frame(width: 64, height: 64) - .overlay(Circle().strokeBorder(PrGlassPalette.accentGradient, lineWidth: 1).opacity(0.6)) - - Image(systemName: "sparkles") - .font(.system(size: 28, weight: .regular)) - .foregroundStyle(PrGlassPalette.purple.opacity(0.95)) - .modifier(DrawerPulseEffect(active: !reduceMotion)) - } - - VStack(spacing: 6) { - Text("All clear") - .font(.title3.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Text("Agent work from your connected machines will gather here.") - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - .multilineTextAlignment(.center) - } - Spacer() - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.horizontal, 32) - .accessibilityElement(children: .combine) - .accessibilityLabel("All clear. No pending attention items.") - } - - private func selectProject(_ id: String?) { - if reduceMotion { - drawer.selectProject(id) - } else { - withAnimation(.snappy(duration: 0.28)) { - drawer.selectProject(id) - } - } - } - - private func follow(_ item: AttentionItem) { - guard let url = item.deepLink else { return } - drawer.markSeen(item.id) - dismiss() - DispatchQueue.main.asyncAfter(deadline: .now() + (reduceMotion ? 0 : 0.18)) { - DeepLinkRouter.shared.handle(url) - } - } -} - -@available(iOS 17.0, *) -private struct AttentionSectionHeader: View { - let title: String - let count: Int - let systemImage: String - let tint: Color - let detail: String - - var body: some View { - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 8) { - Image(systemName: systemImage) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(tint) - Text(title) - .font(.headline) - .foregroundStyle(ADEColor.textPrimary) - Text("\(count)") - .font(.caption.weight(.semibold).monospacedDigit()) - .foregroundStyle(tint) - .contentTransition(.numericText()) - Spacer(minLength: 0) - } - Text(detail) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - .padding(.horizontal, 2) - .accessibilityElement(children: .combine) - } -} - -@available(iOS 17.0, *) -private struct AttentionHeroCard: View { - let item: AttentionItem - let open: () -> Void - let markSeen: () -> Void - - var body: some View { - let tint = AttentionIcon.tint(for: item.kind) - VStack(alignment: .leading, spacing: 13) { - Button(action: open) { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .center, spacing: 11) { - AttentionBadge(kind: item.kind, size: 38, pulse: item.kind == .awaitingInput) - VStack(alignment: .leading, spacing: 2) { - Text(item.phaseLabel.uppercased()) - .font(.caption2.weight(.bold).monospaced()) - .tracking(0.6) - .foregroundStyle(tint) - Text(item.scopeLabel) - .font(.caption.weight(.medium)) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(1) - } - Spacer(minLength: 0) - OfflineBadge(online: item.machineOnline) - } - - Text(item.title) - .font(.title3.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(2) - .multilineTextAlignment(.leading) - - Text(item.subtitle) - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(3) - .multilineTextAlignment(.leading) - - HStack(spacing: 7) { - if let provider = item.providerSlug { - BrandDot(slug: provider, size: 14, pulse: item.kind == .running) - Text(ADESharedTheme.providerDisplayName(for: provider) ?? provider) - } - if let lane = item.laneName, !lane.isEmpty { - Text("·") - Text(lane) - } - Spacer(minLength: 0) - Text(item.timestamp, style: .relative) - } - .font(.caption2.weight(.medium)) - .foregroundStyle(ADEColor.textSecondary) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - AttentionDrawerActionRow(item: item, open: open, markSeen: markSeen) - } - .padding(16) - .background( - ZStack { - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(ADEColor.cardBackground.opacity(0.98)) - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill( - RadialGradient( - colors: [tint.opacity(0.16), tint.opacity(0.02), .clear], - center: .topLeading, - startRadius: 0, - endRadius: 260 - ) - ) - } - ) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder( - LinearGradient( - colors: [tint.opacity(0.45), Color.white.opacity(0.08)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ), - lineWidth: 0.9 - ) - ) - .shadow(color: tint.opacity(0.11), radius: 18, x: 0, y: 8) - .accessibilityElement(children: .contain) - } -} - -@available(iOS 17.0, *) -private struct AttentionCenterCard: View { - let item: AttentionItem - let open: () -> Void - let markSeen: () -> Void - - var body: some View { - let tint = AttentionIcon.tint(for: item.kind) - VStack(alignment: .leading, spacing: 11) { - Button(action: open) { - HStack(alignment: .top, spacing: 12) { - AttentionBadge(kind: item.kind, size: 30, pulse: item.kind == .awaitingInput) - VStack(alignment: .leading, spacing: 4) { - Text(item.title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(2) - .multilineTextAlignment(.leading) - Text(item.subtitle) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(2) - .multilineTextAlignment(.leading) - Text(item.scopeLabel) - .font(.caption2.weight(.medium)) - .foregroundStyle(ADEColor.textSecondary.opacity(0.86)) - .lineLimit(1) - } - Spacer(minLength: 0) - Text(item.timestamp, style: .relative) - .font(.caption2) - .foregroundStyle(ADEColor.textSecondary) - } - } - .buttonStyle(.plain) - - AttentionDrawerActionRow(item: item, open: open, markSeen: markSeen) - } - .padding(14) - .background(ADEColor.cardBackground.opacity(0.96), in: RoundedRectangle(cornerRadius: 15, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 15, style: .continuous) - .strokeBorder(tint.opacity(0.24), lineWidth: 0.7) - ) - } -} - -@available(iOS 17.0, *) -private struct AttentionLiveRow: View { - let item: AttentionItem - let open: () -> Void - - var body: some View { - Button(action: open) { - HStack(spacing: 11) { - BrandDot(slug: item.providerSlug ?? "ade", size: 16, pulse: item.machineOnline) - .frame(width: 24) - VStack(alignment: .leading, spacing: 3) { - Text(item.title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - Text(item.scopeLabel) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(1) - } - Spacer(minLength: 6) - VStack(alignment: .trailing, spacing: 3) { - Label(item.phaseLabel, systemImage: item.machineOnline ? "waveform.path" : "wifi.slash") - .font(.caption2.weight(.semibold)) - .foregroundStyle(AttentionIcon.tint(for: item.kind)) - .labelStyle(.titleAndIcon) - Text(item.timestamp, style: .relative) - .font(.caption2) - .foregroundStyle(ADEColor.textSecondary) - } - } - .padding(.horizontal, 14) - .padding(.vertical, 12) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel("\(item.title), \(item.phaseLabel), \(item.scopeLabel)") - .accessibilityHint("Opens the related agent.") - } -} - -@available(iOS 17.0, *) -private struct AttentionRecentRow: View { - let item: AttentionItem - let open: () -> Void - - var body: some View { - Button(action: open) { - HStack(spacing: 11) { - Image(systemName: AttentionIcon.symbol(for: item.kind)) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(AttentionIcon.tint(for: item.kind)) - .frame(width: 30, height: 30) - .background(AttentionIcon.tint(for: item.kind).opacity(0.1), in: Circle()) - VStack(alignment: .leading, spacing: 3) { - Text(item.title) - .font(.subheadline.weight(.medium)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - Text(item.scopeLabel) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(1) - } - Spacer(minLength: 6) - Text(item.timestamp, style: .relative) - .font(.caption2) - .foregroundStyle(ADEColor.textSecondary) - } - .padding(12) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .background(ADEColor.cardBackground.opacity(0.7), in: RoundedRectangle(cornerRadius: 13, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .strokeBorder(ADEColor.glassBorder.opacity(0.8), lineWidth: 0.6) - ) - } -} - -@available(iOS 17.0, *) -private struct AttentionDrawerActionRow: View { - let item: AttentionItem - let open: () -> Void - let markSeen: () -> Void - - var body: some View { - ViewThatFits(in: .horizontal) { - HStack(spacing: 8) { buttons } - VStack(spacing: 8) { buttons } - } - } - - @ViewBuilder - private var buttons: some View { - switch item.kind { - case .awaitingInput: - let canAnswerInline = item.inlineActionsAllowed - && !(item.itemId ?? "").isEmpty - && item.machineOnline - if canAnswerInline { - Button(intent: ApproveSessionIntent(sessionId: item.sessionId ?? "", itemId: item.itemId ?? "")) { - AttentionDrawerActionLabel("Approve", systemImage: "checkmark", variant: .primary(ADEColor.success)) - } - .buttonStyle(.plain) - .simultaneousGesture(TapGesture().onEnded(markSeen)) - - Button(intent: DenySessionIntent(sessionId: item.sessionId ?? "", itemId: item.itemId ?? "")) { - AttentionDrawerActionLabel("Deny", systemImage: "xmark", variant: .danger) - } - .buttonStyle(.plain) - .simultaneousGesture(TapGesture().onEnded(markSeen)) - } - Button(action: open) { - AttentionDrawerActionLabel(canAnswerInline ? "Reply" : "Open session", systemImage: "text.bubble", variant: .secondary) - } - .buttonStyle(.plain) - - case .failed: - Button(action: open) { - AttentionDrawerActionLabel("Open agent", systemImage: "arrow.right", variant: .primary(ADEColor.accent)) - } - .buttonStyle(.plain) - if item.inlineActionsAllowed && item.machineOnline { - Button(intent: RestartSessionIntent(sessionId: item.sessionId ?? "")) { - AttentionDrawerActionLabel("Restart", systemImage: "arrow.uturn.backward", variant: .secondary) - } - .buttonStyle(.plain) - .simultaneousGesture(TapGesture().onEnded(markSeen)) - } - - case .ciFailing: - Button(action: open) { - AttentionDrawerActionLabel(prLabel("Open"), systemImage: "arrow.triangle.branch", variant: .primary(ADEColor.accent)) - } - .buttonStyle(.plain) - if item.inlineActionsAllowed && item.machineOnline { - Button(intent: RetryCheckIntent(prNumber: item.prNumber ?? 0, prId: item.prId ?? "")) { - AttentionDrawerActionLabel("Rerun CI", systemImage: "arrow.uturn.backward", variant: .secondary) - } - .buttonStyle(.plain) - .simultaneousGesture(TapGesture().onEnded(markSeen)) - } - - case .reviewRequested: - Button(action: open) { - AttentionDrawerActionLabel(prLabel("Review"), systemImage: "eye", variant: .primary(ADEColor.accent)) - } - .buttonStyle(.plain) - - case .mergeReady: - Button(action: open) { - AttentionDrawerActionLabel(prLabel("Review merge"), systemImage: "checkmark.seal", variant: .primary(ADEColor.success)) - } - .buttonStyle(.plain) - - // Nothing to approve, rerun, or restart on any of these — a blocked row - // included, which is the point: it is waiting on something that is not - // a button in this drawer. - case .running, .blocked, .open, .completed, .merged, .stale: - Button(action: open) { - AttentionDrawerActionLabel("Open", systemImage: "arrow.right", variant: .secondary) - } - .buttonStyle(.plain) - } - } - - private func prLabel(_ verb: String) -> String { - guard let number = item.prNumber, number > 0 else { return "\(verb) PR" } - return "\(verb) #\(number)" - } -} - -@available(iOS 17.0, *) -private enum AttentionDrawerActionVariant { - case primary(Color) - case secondary - case danger - - var foreground: Color { - switch self { - case .primary(let tint): return tint - case .secondary: return ADEColor.textPrimary - case .danger: return ADEColor.danger - } - } - - var background: Color { - switch self { - case .primary(let tint): return tint.opacity(0.18) - case .secondary: return ADEColor.surfaceBackground.opacity(0.72) - case .danger: return ADEColor.danger.opacity(0.14) - } - } - - var stroke: Color { - switch self { - case .primary(let tint): return tint.opacity(0.32) - case .secondary: return ADEColor.glassBorder - case .danger: return ADEColor.danger.opacity(0.30) - } - } -} - -@available(iOS 17.0, *) -private struct AttentionDrawerActionLabel: View { - let title: String - let systemImage: String - let variant: AttentionDrawerActionVariant - - init(_ title: String, systemImage: String, variant: AttentionDrawerActionVariant) { - self.title = title - self.systemImage = systemImage - self.variant = variant - } - - var body: some View { - HStack(spacing: 5) { - Image(systemName: systemImage) - .font(.system(size: 10, weight: .bold)) - Text(title) - .font(.caption.weight(.semibold)) - .lineLimit(1) - .minimumScaleFactor(0.76) - } - .foregroundStyle(variant.foreground) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .padding(.horizontal, 10) - .background(variant.background, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(variant.stroke, lineWidth: 0.6) - ) - .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - } -} - -@available(iOS 17.0, *) -private struct AttentionCountPill: View { - let count: Int - let label: String - let tint: Color - - var body: some View { - Text("\(count) \(label)") - .font(.caption2.weight(.semibold).monospacedDigit()) - .foregroundStyle(count > 0 ? tint : ADEColor.textSecondary) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background((count > 0 ? tint : ADEColor.textSecondary).opacity(0.1), in: Capsule()) - } -} - -@available(iOS 17.0, *) -private struct ProjectLensButton: View { - let title: String - let count: Int - let selected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(spacing: 6) { - Text(title) - .lineLimit(1) - Text("\(count)") - .font(.caption2.monospacedDigit()) - .opacity(0.72) - } - .font(.caption.weight(.semibold)) - .foregroundStyle(selected ? Color.white : ADEColor.textSecondary) - .padding(.horizontal, 11) - .padding(.vertical, 7) - .background( - selected ? AnyShapeStyle(PrGlassPalette.accentGradient) : AnyShapeStyle(Color.white.opacity(0.055)), - in: Capsule(style: .continuous) - ) - .overlay( - Capsule(style: .continuous) - .strokeBorder(selected ? Color.white.opacity(0.2) : ADEColor.glassBorder, lineWidth: 0.7) - ) - } - .buttonStyle(.plain) - .accessibilityAddTraits(selected ? .isSelected : []) - } -} - -@available(iOS 17.0, *) -private struct OfflineBadge: View { - let online: Bool - - var body: some View { - if !online { - Label("Offline", systemImage: "wifi.slash") - .font(.caption2.weight(.semibold)) - .foregroundStyle(ADESharedTheme.statusIdle) - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(ADESharedTheme.statusIdle.opacity(0.1), in: Capsule()) - } - } -} - -@available(iOS 17.0, *) -private enum AttentionIcon { - static func symbol(for kind: AttentionKind) -> String { - switch kind { - case .awaitingInput: return "bell.badge.fill" - case .blocked: return "hourglass" - case .failed: return "xmark.octagon.fill" - case .ciFailing: return "exclamationmark.triangle.fill" - case .reviewRequested: return "eye.fill" - case .mergeReady: return "checkmark.seal.fill" - // The dashed circle the widgets and the desktop sidebar use for work - // in flight, rather than a heartbeat trace only this surface knew. - case .running: return "circle.dotted" - case .open: return "arrow.triangle.pull" - case .completed: return "checkmark.circle.fill" - case .merged: return "arrow.triangle.merge" - // A clock, not `wifi.slash`: a stale run is reachable and silent, so - // the question is how long it has been quiet, not whether the network - // dropped. - case .stale: return "clock.badge.exclamationmark" - } - } - - /// Amber lives on exactly one kind here — `awaitingInput` — and everything - /// that merely reports a fact takes a hue that makes no claim on the user. - static func tint(for kind: AttentionKind) -> Color { - switch kind { - case .awaitingInput: return ADESharedTheme.warningAmber - case .failed, .ciFailing: return ADESharedTheme.statusFailed - case .reviewRequested: return ADESharedTheme.statusReview - case .mergeReady, .completed, .merged: return ADESharedTheme.statusSuccess - case .running, .open: return ADESharedTheme.statusRunning - case .blocked, .stale: return ADESharedTheme.statusIdle - } - } -} - -@available(iOS 17.0, *) -private struct AttentionBadge: View { - let kind: AttentionKind - let size: CGFloat - let pulse: Bool - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - var body: some View { - let color = AttentionIcon.tint(for: kind) - ZStack { - Circle() - .fill(color.opacity(0.13)) - .frame(width: size, height: size) - - if pulse && kind == .awaitingInput && !reduceMotion { - Circle() - .stroke(color, lineWidth: 1.5) - .frame(width: size, height: size) - .phaseAnimator([false, true]) { circle, expanded in - circle - .scaleEffect(expanded ? 1.5 : 1) - .opacity(expanded ? 0 : 0.9) - } animation: { _ in - .easeOut(duration: 1.6) - } - } - - Image(systemName: AttentionIcon.symbol(for: kind)) - .font(.system(size: size * 0.5, weight: .semibold)) - .foregroundStyle(color) - .modifier(BellWiggle(active: pulse && kind == .awaitingInput && !reduceMotion)) - } - .accessibilityHidden(true) - } -} - -@available(iOS 17.0, *) -private struct BrandDot: View { - let slug: String - let size: CGFloat - let pulse: Bool - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - var body: some View { - let color = ADESharedTheme.brandColor(for: slug) - ZStack { - if pulse && !reduceMotion { - Circle() - .fill(color) - .frame(width: size, height: size) - .phaseAnimator([false, true]) { circle, expanded in - circle - .scaleEffect(expanded ? 1.6 : 1) - .opacity(expanded ? 0 : 0.34) - } animation: { _ in - .easeInOut(duration: 1.5) - } - } - Circle() - .fill(color.opacity(0.18)) - .frame(width: size, height: size) - .overlay { - if let assetName = ADESharedTheme.providerAssetName(for: slug) { - Image(assetName) - .resizable() - .scaledToFit() - .frame(width: size * 0.7, height: size * 0.7) - } else { - Circle() - .fill(color) - .frame(width: size * 0.48, height: size * 0.48) - } - } - .overlay(Circle().strokeBorder(color.opacity(0.32), lineWidth: 0.6)) - .shadow(color: color.opacity(0.3), radius: size * 0.22) - } - .frame(width: size, height: size) - .accessibilityHidden(true) - } -} - -@available(iOS 17.0, *) -private struct BellWiggle: ViewModifier { - let active: Bool - - func body(content: Content) -> some View { - if active { - content.keyframeAnimator(initialValue: 0.0, repeating: true) { view, rotation in - view.rotationEffect(.degrees(rotation)) - } keyframes: { _ in - KeyframeTrack { - LinearKeyframe(0, duration: 1.35) - CubicKeyframe(-13, duration: 0.16) - CubicKeyframe(11, duration: 0.16) - CubicKeyframe(-7, duration: 0.16) - CubicKeyframe(4, duration: 0.16) - CubicKeyframe(0, duration: 0.16) - } - } - } else { - content - } - } -} - -@available(iOS 17.0, *) -private struct DrawerPulseEffect: ViewModifier { - let active: Bool - - func body(content: Content) -> some View { - if active { - content.symbolEffect(.pulse, options: .repeating) - } else { - content - } - } -} diff --git a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift index 524fecc7e..0de52a21b 100644 --- a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift +++ b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift @@ -859,7 +859,7 @@ struct ADEHubBackButton: View { struct ADERootToolbarControls: View { @EnvironmentObject private var syncService: SyncService - @EnvironmentObject private var drawer: AttentionDrawerModel + @EnvironmentObject private var drawer: ActivityDrawerModel /// Disambiguator folded into inspector ids so two simultaneous instances of /// this control (e.g. the active and incoming root tab during a transition) @@ -938,7 +938,7 @@ struct ADERootToolbarControls: View { icon: "bell.fill", tint: hasUnread ? ADESharedTheme.warningAmber : PrsGlass.textSecondary, isAlive: hasUnread, - accessibilityLabel: "Attention items: \(drawer.unreadCount)", + accessibilityLabel: hasUnread ? "Activity, \(drawer.unreadCount) need you" : "Activity", action: { syncService.attentionDrawerPresented = true } ) } @@ -1020,7 +1020,7 @@ struct ADERootToolbarLeading: View { HStack(spacing: 10) { ADEConnectionDot() ADEProjectHubButton() - AttentionDrawerButton() + ActivityBellButton() } .fixedSize(horizontal: true, vertical: false) } @@ -1124,7 +1124,7 @@ struct ADERootToolbarLeadingItems: ToolbarContent { .sharedBackgroundVisibility(.hidden) ToolbarItem(placement: .topBarLeading) { - AttentionDrawerButton() + ActivityBellButton() } .sharedBackgroundVisibility(.hidden) } diff --git a/apps/ios/ADE/Views/Hub/HubComponents.swift b/apps/ios/ADE/Views/Hub/HubComponents.swift index 962c351b4..5ee13f451 100644 --- a/apps/ios/ADE/Views/Hub/HubComponents.swift +++ b/apps/ios/ADE/Views/Hub/HubComponents.swift @@ -28,6 +28,10 @@ struct HubTopBar: View { HubConnectionPill() .layoutPriority(1) + // The hub was the one root without a bell, which made the phone's home + // screen the only place you could not see that something needed you. + ActivityBellButton() + HubCircularButton(systemImage: "plus", tint: ADEColor.accent, action: onAdd) .accessibilityLabel("Add project") @@ -186,7 +190,14 @@ struct HubProjectPresentation: Equatable, Identifiable { let laneCount: Int let chatCount: Int let lanes: [HubLanePresentation] + /// Chats on this project awaiting input, and chats currently producing. Both + /// were computed by the roster and used only as a sort tiebreak until now. + let attentionCount: Int + let runningCount: Int let metaLine: String + /// "2 need you · 3 working", or nil when the project is quiet. Rendered + /// beside the lane/chat counts with a status dot per clause. + let statusLine: String? fileprivate let renderSignature: Int var id: String { project.id } @@ -198,7 +209,9 @@ struct HubProjectPresentation: Equatable, Identifiable { isLoading: Bool, laneCount: Int, chatCount: Int, - lanes: [HubLanePresentation] + lanes: [HubLanePresentation], + attentionCount: Int = 0, + runningCount: Int = 0 ) { self.project = project self.isActive = isActive @@ -207,9 +220,15 @@ struct HubProjectPresentation: Equatable, Identifiable { self.laneCount = laneCount self.chatCount = chatCount self.lanes = lanes + self.attentionCount = attentionCount + self.runningCount = runningCount let lanePart = "\(laneCount) lane\(laneCount == 1 ? "" : "s")" let chatPart = "\(chatCount) chat\(chatCount == 1 ? "" : "s")" self.metaLine = "\(lanePart) · \(chatPart)" + self.statusLine = hubProjectStatusLine( + attentionCount: attentionCount, + runningCount: runningCount + ) self.renderSignature = hubProjectRenderSignature( project: project, isActive: isActive, @@ -217,7 +236,9 @@ struct HubProjectPresentation: Equatable, Identifiable { isLoading: isLoading, laneCount: laneCount, chatCount: chatCount, - lanes: lanes + lanes: lanes, + attentionCount: attentionCount, + runningCount: runningCount ) } @@ -320,9 +341,13 @@ private func hubProjectRenderSignature( isLoading: Bool, laneCount: Int, chatCount: Int, - lanes: [HubLanePresentation] + lanes: [HubLanePresentation], + attentionCount: Int, + runningCount: Int ) -> Int { var hasher = Hasher() + hasher.combine(attentionCount) + hasher.combine(runningCount) hasher.combine(project.id) hasher.combine(project.displayName) hasher.combine(hubProjectIconSignature(project.iconDataUrl)) @@ -337,6 +362,15 @@ private func hubProjectRenderSignature( return hasher.finalize() } +/// Sentence-case, count-first, and silent when there is nothing to report — +/// "0 need you" is filler that trains people to stop reading the line. +func hubProjectStatusLine(attentionCount: Int, runningCount: Int) -> String? { + var clauses: [String] = [] + if attentionCount > 0 { clauses.append("\(attentionCount) need you") } + if runningCount > 0 { clauses.append("\(runningCount) working") } + return clauses.isEmpty ? nil : clauses.joined(separator: " · ") +} + private func hubProjectIconSignature(_ dataUrl: String?) -> String { guard let dataUrl, !dataUrl.isEmpty else { return "" } let byteCount = dataUrl.utf8.count @@ -450,7 +484,9 @@ func buildHubProjectPresentation( isLoading: false, laneCount: roster.lanes.count, chatCount: chatCount, - lanes: lanes + lanes: lanes, + attentionCount: roster.attentionCount, + runningCount: roster.runningCount ) } @@ -543,12 +579,35 @@ struct HubProjectCard: View, Equatable { .buttonStyle(.plain) // Lane/chat counts live to the left of the open arrow now that the name - // owns the full leading run. - Text(presentation.metaLine) - .font(.system(.caption, design: .rounded)) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) - .fixedSize() + // owns the full leading run, with the live status stacked above them. + VStack(alignment: .trailing, spacing: 2) { + if presentation.attentionCount > 0 || presentation.runningCount > 0 { + HStack(spacing: 5) { + if presentation.attentionCount > 0 { + HubStatusDot(status: "awaiting-input") + Text("\(presentation.attentionCount) need you") + .font(.system(.caption2, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.warning) + } + if presentation.runningCount > 0 { + HubStatusDot(status: "active") + Text("\(presentation.runningCount) working") + .font(.system(.caption2, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.success) + } + } + .lineLimit(1) + .fixedSize() + .accessibilityElement(children: .combine) + .accessibilityLabel(presentation.statusLine ?? "") + } + + Text(presentation.metaLine) + .font(.system(.caption, design: .rounded)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + .fixedSize() + } if presentation.isSwitching { ProgressView().controlSize(.small) @@ -706,18 +765,29 @@ struct HubChatRow: View, Equatable { let onDelete: () -> Void var body: some View { - // Deliberately minimal: provider logo, chat name, and the relative - // timestamp. Nothing else competes for the eye at the hub's glance level. + // Provider logo, a status dot, the chat name, and the relative timestamp. + // The status the roster already carried was never rendered here, so a row + // asking for input looked exactly like one that finished an hour ago. Button(action: onOpen) { HStack(spacing: 10) { WorkProviderBareLogo(provider: row.providerKey, fallbackSymbol: "terminal.fill", tint: ADEColor.textSecondary, size: compact ? 16 : 20) + HubStatusDot(status: row.statusString) + Text(row.title) .font(.system(.footnote, design: .rounded).weight(.medium)) .foregroundStyle(ADEColor.textPrimary) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) + if let status = hubChatStatusLabel(row.statusString) { + Text(status) + .font(.system(.caption2, design: .rounded).weight(.semibold)) + .foregroundStyle(workChatStatusTint(row.statusString)) + .lineLimit(1) + .fixedSize() + } + if let activity = row.activityLabel { Text(activity) .font(.system(.caption2, design: .rounded)) @@ -729,7 +799,9 @@ struct HubChatRow: View, Equatable { .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityLabel(row.title) + .accessibilityLabel( + hubChatStatusLabel(row.statusString).map { "\(row.title), \($0)" } ?? row.title + ) .accessibilityHint(row.chat.isChatTool ? "Opens chat." : "Opens session.") // The hub uses a scrolling LazyVStack (not a List), where SwiftUI // `.swipeActions` are unavailable — so pin/archive/close are offered through @@ -752,6 +824,16 @@ struct HubChatRow: View, Equatable { } } +/// Row status in one word, and only when it says something. A resting chat +/// gets its dot and nothing else — the timestamp already tells that story. +func hubChatStatusLabel(_ status: String) -> String? { + switch status { + case "awaiting-input": return "Needs you" + case "active": return "Working" + default: return nil + } +} + struct HubStatusDot: View { let status: String var body: some View { diff --git a/apps/ios/ADE/Views/Hub/HubLiveStrip.swift b/apps/ios/ADE/Views/Hub/HubLiveStrip.swift new file mode 100644 index 000000000..a193710ba --- /dev/null +++ b/apps/ios/ADE/Views/Hub/HubLiveStrip.swift @@ -0,0 +1,56 @@ +import SwiftUI + +/// "Live now" — a horizontal strip of the agents currently working, across +/// every machine on the account rather than only the paired one. +/// +/// It reads the account snapshot through `ActivityDrawerModel`, so a session on +/// the Studio shows up on the phone's home screen without opening anything. +/// Hidden entirely when nothing is live: an empty strip is a permanent reminder +/// that nothing is happening, which is the opposite of the point. +struct HubLiveStrip: View { + @EnvironmentObject private var drawer: ActivityDrawerModel + + private var rows: [ActivityRowPresentation] { drawer.liveNow } + + var body: some View { + if !rows.isEmpty { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 7) { + Text("Live now") + .font(.system(.caption, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + Text("\(rows.count)") + .font(.system(.caption2, design: .rounded).weight(.semibold).monospacedDigit()) + .foregroundStyle(ADEColor.textMuted) + .contentTransition(.numericText()) + Spacer(minLength: 0) + } + .padding(.horizontal, 2) + + ScrollView(.horizontal) { + HStack(spacing: 10) { + ForEach(rows) { row in + ActivityRow( + row: row, + density: .compact, + dimmed: !row.machineOnline + ) { + open(row) + } + } + } + .padding(.horizontal, 2) + .padding(.vertical, 1) + } + .scrollIndicators(.hidden) + } + .accessibilityLabel("Live now, \(rows.count) sessions") + } + } + + private func open(_ row: ActivityRowPresentation) { + guard let url = row.deepLink else { return } + drawer.markSeen(row.id) + DeepLinkRouter.shared.handle(url) + } +} diff --git a/apps/ios/ADE/Views/Hub/HubScreen.swift b/apps/ios/ADE/Views/Hub/HubScreen.swift index 5fa3b3e5a..c63115010 100644 --- a/apps/ios/ADE/Views/Hub/HubScreen.swift +++ b/apps/ios/ADE/Views/Hub/HubScreen.swift @@ -206,6 +206,10 @@ struct HubScreen: View { ) ScrollView { LazyVStack(spacing: 12) { + // Everything running right now, across every machine on the account — + // not just the paired one. Hidden entirely when nothing is live. + HubLiveStrip() + // Keep the project catalog mounted while a switch is in flight: only // fall back to the connecting card when there's nothing to show yet. // The switching row carries its own spinner and the others disable, diff --git a/apps/ios/ADETests/ActivityDrawerModelTests.swift b/apps/ios/ADETests/ActivityDrawerModelTests.swift new file mode 100644 index 000000000..2f94fc175 --- /dev/null +++ b/apps/ios/ADETests/ActivityDrawerModelTests.swift @@ -0,0 +1,436 @@ +import XCTest +@testable import ADE + +@MainActor +final class ActivityDrawerModelTests: XCTestCase { + private var defaults: UserDefaults! + private var suiteName: String! + + override func setUp() { + super.setUp() + suiteName = "ade.activity-drawer.tests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + defaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + // MARK: - Two buckets + + func testAgentRowsFileUnderSessionsAndPullRequestsUnderInbox() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + + model.rebuild(from: snapshot(items: [ + item(id: "agent-live", phase: .running, now: now), + item(id: "agent-needs", phase: .needsYou, now: now), + pullRequest(id: "pr-ci", phase: .checksFailing, number: 992, now: now), + ])) + + XCTAssertEqual(Set(model.sessions.map(\.id)), ["agent-live", "agent-needs"]) + XCTAssertEqual(model.inbox.map(\.id), ["pr-ci"]) + } + + func testSessionsAreGroupedNeedsYouThenWorkingThenDone() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + + model.rebuild(from: snapshot(items: [ + item(id: "done", phase: .completed, now: now), + item(id: "working", phase: .running, now: now), + item(id: "needs", phase: .needsYou, now: now), + ])) + + XCTAssertEqual(model.sessionSections.map(\.band), [.needsYou, .working, .done]) + XCTAssertEqual(model.sessionSections.map { $0.rows.map(\.id) }, [["needs"], ["working"], ["done"]]) + } + + func testFinishedButUnseenAgentRowsAlsoLandInTheInbox() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + + model.rebuild(from: snapshot(items: [ + item(id: "done-unseen", phase: .completed, now: now), + item(id: "done-seen", phase: .completed, now: now, seenAt: now), + ])) + + XCTAssertEqual(model.inbox.map(\.id), ["done-unseen"]) + XCTAssertEqual(Set(model.sessions.map(\.id)), ["done-unseen", "done-seen"]) + } + + func testIdleTierNeedsYouNeverReachesTheNeedsYouSection() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + + model.rebuild(from: snapshot(items: [ + item(id: "roster-row", phase: .needsYou, now: now, activityTier: "idle"), + ])) + + XCTAssertTrue(model.sessionSections.filter { $0.band == .needsYou }.isEmpty) + XCTAssertEqual(model.unreadCount, 0, "a roster-derived row must never badge the bell") + } + + func testDismissedAndExpiredItemsAreFilteredOut() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + + model.rebuild(from: snapshot(items: [ + item(id: "alive", phase: .running, now: now), + item(id: "server-dismissed", phase: .running, now: now, dismissedAt: now), + item(id: "expired", phase: .running, now: now, expiresAt: now.addingTimeInterval(-1)), + ])) + + XCTAssertEqual(model.sessions.map(\.id), ["alive"]) + } + + // MARK: - Per-item acknowledgement + + func testDismissRemovesOneRowAndPersistsIt() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + model.rebuild(from: snapshot(items: [ + item(id: "a", phase: .needsYou, now: now), + item(id: "b", phase: .needsYou, now: now), + ])) + + model.dismiss("a") + + XCTAssertEqual(model.sessions.map(\.id), ["b"]) + XCTAssertEqual(defaults.stringArray(forKey: ActivityDrawerModel.dismissedItemIDsKey), ["a"]) + } + + func testDismissedRowStaysDismissedAcrossRebuilds() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + let items = [item(id: "a", phase: .needsYou, now: now)] + model.rebuild(from: snapshot(items: items)) + + model.dismiss("a") + model.rebuild(from: snapshot(items: items, revision: 2)) + + XCTAssertTrue(model.sessions.isEmpty) + } + + func testDismissalIsPrunedOnceTheBackingRowDisappears() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + model.rebuild(from: snapshot(items: [item(id: "a", phase: .needsYou, now: now)])) + model.dismiss("a") + + model.rebuild(from: snapshot(items: [item(id: "other", phase: .running, now: now)], revision: 2)) + model.rebuild(from: snapshot(items: [item(id: "a", phase: .needsYou, now: now)], revision: 3)) + + XCTAssertEqual(model.sessions.map(\.id), ["a"], "a later regression must resurface") + } + + func testBulkDismissIsScopedToOneBucket() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + model.rebuild(from: snapshot(items: [ + item(id: "agent", phase: .needsYou, now: now), + pullRequest(id: "pr", phase: .checksFailing, number: 1, now: now), + ])) + + model.dismissVisible(in: .inbox) + + XCTAssertEqual(model.sessions.map(\.id), ["agent"]) + XCTAssertTrue(model.inbox.isEmpty) + } + + // MARK: - Badge + + func testBadgeCountsOnlySignalTierNeedsYou() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + + model.rebuild(from: snapshot(items: [ + item(id: "needs", phase: .needsYou, now: now), + item(id: "failed", phase: .failed, now: now), + item(id: "working", phase: .running, now: now), + pullRequest(id: "pr", phase: .checksFailing, number: 3, now: now), + ])) + + XCTAssertEqual(model.unreadCount, 2) + XCTAssertEqual(model.badgeLabel, "2") + } + + func testBadgeCapsAtNinePlus() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + let items = (0..<12).map { item(id: "needs-\($0)", phase: .needsYou, now: now) } + + model.rebuild(from: snapshot(items: items)) + + XCTAssertEqual(model.unreadCount, 12) + XCTAssertEqual(model.badgeLabel, "9+") + } + + func testMarkAllSeenSilencesTheBellWithoutRemovingRows() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + model.rebuild(from: snapshot(items: [item(id: "needs", phase: .needsYou, now: now)])) + XCTAssertEqual(model.unreadCount, 1) + + model.markAllSeen() + + XCTAssertEqual(model.unreadCount, 0) + XCTAssertNil(model.badgeLabel) + XCTAssertEqual(model.sessions.map(\.id), ["needs"], "seen is not dismissed") + } + + func testMarkSeenIsPersistedPerItem() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + model.rebuild(from: snapshot(items: [ + item(id: "a", phase: .needsYou, now: now), + item(id: "b", phase: .needsYou, now: now), + ])) + + model.markSeen("a") + + XCTAssertEqual(model.unreadCount, 1) + XCTAssertEqual(defaults.stringArray(forKey: ActivityDrawerModel.seenItemIDsKey), ["a"]) + } + + // MARK: - Offline machines + + func testOfflineRowsSitBehindABannerForTheirMachine() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + let offline = AccountAttentionMachine( + machineKey: "laptop", + name: "MacBook", + online: false, + lastSeenAt: now.addingTimeInterval(-3_600) + ) + + model.rebuild(from: snapshot(items: [ + item(id: "online", phase: .running, now: now), + item(id: "offline", phase: .running, now: now, machine: offline), + ])) + + let entries = model.sessionSections.first { $0.band == .working }?.entries ?? [] + XCTAssertEqual(entries.map(\.id), ["online", "offline:laptop", "offline"]) + if case .offlineMachine(_, let name, let lastSeen) = entries[1] { + XCTAssertEqual(name, "MacBook") + XCTAssertEqual(lastSeen, "last seen 1h ago") + } else { + XCTFail("expected an offline banner before the offline row") + } + } + + // MARK: - Source, for honest empty states + + func testAccountSnapshotReportsAnAccountSource() { + let model = ActivityDrawerModel(defaults: defaults) + + model.rebuild(from: snapshot(items: [])) + + XCTAssertEqual(model.source, .account) + XCTAssertTrue(model.isEmpty, "empty-and-reachable is 'all clear', not 'unreachable'") + } + + func testClearAllReportsNoSourceSoTheDrawerCanSaySo() { + let model = ActivityDrawerModel(defaults: defaults) + model.rebuild(from: snapshot(items: [item(id: "a", phase: .running, now: Date())])) + + model.clearAll() + + XCTAssertEqual(model.source, .none) + XCTAssertTrue(model.isEmpty) + } + + func testTruncationFlagRidesTheSnapshot() { + let model = ActivityDrawerModel(defaults: defaults) + + model.rebuild(from: snapshot(items: [], truncated: true)) + + XCTAssertTrue(model.itemsTruncated) + } + + // MARK: - Live-now strip + + func testLiveNowExcludesFinishedAndIdleTierRows() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + + model.rebuild(from: snapshot(items: [ + item(id: "running", phase: .running, now: now), + item(id: "needs", phase: .needsYou, now: now), + item(id: "done", phase: .completed, now: now), + item(id: "roster", phase: .running, now: now, activityTier: "idle"), + ])) + + XCTAssertEqual(Set(model.liveNow.map(\.id)), ["running", "needs"]) + } + + // MARK: - Machine-local fallback + + func testWorkspaceFallbackProducesTheSameRowShape() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + + model.rebuild(from: WorkspaceSnapshot( + generatedAt: now, + agents: [ + agent(sessionId: "s-awaiting", status: "running", awaitingInput: true, at: now), + agent(sessionId: "s-running", status: "running", awaitingInput: false, at: now), + ], + prs: [ + PrSnapshot( + id: "pr-1", + number: 7, + title: "Activity revamp", + checks: "failing", + review: "pending", + state: "open", + mergeReady: false, + updatedAt: now + ), + ], + connection: "connected", + machineName: "This Mac", + projectName: "ADE" + )) + + XCTAssertEqual(model.source, .machineFallback) + XCTAssertEqual(model.sessions.map(\.id), ["awaiting:s-awaiting", "live:s-running"]) + XCTAssertEqual(model.inbox.map(\.id), ["ci:pr-1"]) + XCTAssertEqual(model.sessions.first?.phaseLabel, "Needs you") + XCTAssertEqual(model.sessions.first?.machineName, "This Mac") + XCTAssertTrue( + model.sessions.first?.inlineActionsAllowed == true, + "rows from the paired host may run inline intents" + ) + } + + func testDisconnectedMachineDowngradesLiveWorkToStale() { + let model = ActivityDrawerModel(defaults: defaults) + let now = Date() + + model.rebuild(from: WorkspaceSnapshot( + generatedAt: now, + agents: [agent(sessionId: "s", status: "running", awaitingInput: false, at: now)], + prs: [], + connection: "disconnected" + )) + + XCTAssertEqual(model.sessions.first?.phaseLabel, "Stale") + } + + // MARK: - Fixtures + + private func snapshot( + items: [AccountAttentionItem], + revision: Int = 1, + truncated: Bool = false + ) -> AccountAttentionSnapshot { + AccountAttentionSnapshot( + revision: revision, + generatedAt: Date(), + machines: nil, + items: items, + tombstones: nil, + itemsTruncated: truncated + ) + } + + private func item( + id: String, + phase: AccountAttentionPhase, + now: Date, + activityTier: String? = nil, + machine: AccountAttentionMachine? = nil, + seenAt: Date? = nil, + dismissedAt: Date? = nil, + expiresAt: Date? = nil + ) -> AccountAttentionItem { + AccountAttentionItem( + id: id, + revision: 1, + fingerprint: "\(id):1", + kind: .agent, + eventKind: .agentRunning, + phase: phase, + activityTier: activityTier, + machine: machine ?? AccountAttentionMachine( + machineKey: "studio", + name: "Studio Mac", + online: true, + lastSeenAt: now + ), + project: AccountAttentionProject(projectId: "ade", name: "ADE"), + title: id, + preview: "Working", + privacyPreview: "Agent working", + destination: .session(sessionId: id, itemId: nil, eventId: nil), + occurredAt: now, + updatedAt: now, + seenAt: seenAt, + dismissedAt: dismissedAt, + expiresAt: expiresAt + ) + } + + private func pullRequest( + id: String, + phase: AccountAttentionPhase, + number: Int, + now: Date + ) -> AccountAttentionItem { + AccountAttentionItem( + id: id, + revision: 1, + fingerprint: "\(id):1", + kind: .pullRequest, + eventKind: .prChecksFailing, + phase: phase, + machine: AccountAttentionMachine( + machineKey: "studio", + name: "Studio Mac", + online: true, + lastSeenAt: now + ), + project: AccountAttentionProject(projectId: "ade", name: "ADE"), + title: "PR #\(number)", + preview: "Checks failing", + privacyPreview: "Checks failing", + destination: .pullRequest( + prId: id, + repoOwner: nil, + repoName: nil, + number: number, + tab: "overview", + eventId: nil + ), + occurredAt: now, + updatedAt: now + ) + } + + private func agent( + sessionId: String, + status: String, + awaitingInput: Bool, + at: Date + ) -> AgentSnapshot { + AgentSnapshot( + sessionId: sessionId, + provider: "claude", + title: "Session \(sessionId)", + status: status, + awaitingInput: awaitingInput, + lastActivityAt: at, + elapsedSeconds: 12, + preview: "Working", + progress: nil, + phase: nil, + toolCalls: 0 + ) + } +} diff --git a/apps/ios/ADETests/ActivityRowPresentationTests.swift b/apps/ios/ADETests/ActivityRowPresentationTests.swift new file mode 100644 index 000000000..0704e171b --- /dev/null +++ b/apps/ios/ADETests/ActivityRowPresentationTests.swift @@ -0,0 +1,268 @@ +import XCTest +@testable import ADE + +/// The anti-drift test for the iOS half of the status vocabulary. Every +/// expectation here is transcribed from +/// `apps/desktop/src/shared/sessionStatusPresentation.ts` (session phases) and +/// `renderer/components/attention/attentionPresentation.ts` +/// (`NON_SESSION_PRESENTATION` + `NON_SESSION_STATUS_DETAILS`). If a hue or a +/// word moves on desktop and not here, this fails — which is the whole point. +final class ActivityRowPresentationTests: XCTestCase { + + // MARK: - Phase parity table + + func testPhaseTableMatchesDesktopVocabulary() { + let expectations: [(AccountAttentionPhase, String, ActivityTone, ActivityGlyph?, Bool, Bool)] = [ + (.starting, "Starting", .blue, .working, false, false), + (.running, "Working", .blue, .working, true, false), + (.needsYou, "Needs you", .amber, .needsYou, false, true), + (.completed, "Done", .emerald, .done, false, true), + (.failed, "Failed", .red, .failed, false, true), + (.stale, "Stale", .neutral, .stale, true, false), + (.blocked, "Blocked", .neutral, nil, false, false), + (.checksFailing, "Checks failing", .red, .failed, false, true), + (.reviewRequested, "Review requested", .violet, .review, false, true), + (.changesRequested, "Changes requested", .red, .failed, false, true), + (.mergeReady, "Ready to merge", .emerald, .done, false, true), + (.open, "Open", .blue, nil, false, false), + (.merged, "Merged", .emerald, .merged, false, true), + (.closed, "Closed", .neutral, nil, false, false), + ] + + for (phase, label, tone, glyph, showsElapsed, prominent) in expectations { + let presentation = ActivityPhaseVocabulary.presentation(for: phase) + XCTAssertEqual(presentation.label, label, "label for \(phase.rawValue)") + XCTAssertEqual(presentation.tone, tone, "tone for \(phase.rawValue)") + XCTAssertEqual(presentation.glyph, glyph, "glyph for \(phase.rawValue)") + XCTAssertEqual(presentation.showsElapsed, showsElapsed, "elapsed for \(phase.rawValue)") + XCTAssertEqual(presentation.prominent, prominent, "prominence for \(phase.rawValue)") + } + } + + func testAmberIsSpentOnExactlyOnePhase() { + let amber: [AccountAttentionPhase] = [ + .starting, .running, .needsYou, .completed, .failed, .stale, .blocked, + .checksFailing, .reviewRequested, .changesRequested, .mergeReady, + .open, .merged, .closed, + ].filter { ActivityPhaseVocabulary.presentation(for: $0).tone == .amber } + + XCTAssertEqual(amber, [.needsYou]) + } + + func testUnknownPhaseIsNeutralAndSilent() { + let presentation = ActivityPhaseVocabulary.presentation(for: .unrecognized("teleporting")) + + XCTAssertEqual(presentation.tone, .neutral) + XCTAssertEqual(presentation.label, "Unknown") + XCTAssertNil(presentation.glyph) + XCTAssertFalse(presentation.prominent) + } + + func testPlanningPhaseKeepsTheDesktopVioletLabel() { + let presentation = ActivityPhaseVocabulary.presentation(for: .unrecognized("planning")) + + XCTAssertEqual(presentation.label, "Planning") + XCTAssertEqual(presentation.tone, .violet) + XCTAssertTrue(presentation.showsElapsed) + } + + // MARK: - Bands + + func testBandsFileNeedsYouFirstAndOutcomesLast() { + XCTAssertEqual(ActivityPhaseVocabulary.band(for: .needsYou), .needsYou) + XCTAssertEqual(ActivityPhaseVocabulary.band(for: .failed), .needsYou) + XCTAssertEqual(ActivityPhaseVocabulary.band(for: .running), .working) + XCTAssertEqual(ActivityPhaseVocabulary.band(for: .blocked), .working) + XCTAssertEqual(ActivityPhaseVocabulary.band(for: .completed), .done) + XCTAssertEqual(ActivityPhaseVocabulary.band(for: .merged), .done) + } + + func testIdleTierNeverReachesTheNeedsYouBand() { + let row = ActivityRowPresentation( + item: makeItem(phase: .needsYou, activityTier: "idle") + ) + + XCTAssertEqual(row.tier, .idle) + XCTAssertEqual(row.band, .working, "an idle row must never sit at the top of the drawer") + } + + func testSignalTierNeedsYouStaysInTheNeedsYouBand() { + let row = ActivityRowPresentation(item: makeItem(phase: .needsYou)) + + XCTAssertEqual(row.tier, .signal) + XCTAssertEqual(row.band, .needsYou) + } + + // MARK: - Elapsed + + func testElapsedAnchorsOnStatusSinceWhenPresent() { + let now = Date() + let row = ActivityRowPresentation( + item: makeItem( + phase: .running, + statusSince: now.addingTimeInterval(-42), + occurredAt: now.addingTimeInterval(-9_000) + ) + ) + + XCTAssertEqual(row.elapsedSince, now.addingTimeInterval(-42)) + XCTAssertEqual(row.elapsedLabel(now: now), "42s") + } + + func testElapsedFallsBackToOccurredAtWithoutStatusSince() { + let now = Date() + let row = ActivityRowPresentation( + item: makeItem(phase: .running, occurredAt: now.addingTimeInterval(-180)) + ) + + XCTAssertEqual(row.elapsedLabel(now: now), "3m") + } + + func testElapsedIsSuppressedForPhasesWhereAgeIsNoise() { + let now = Date() + let row = ActivityRowPresentation( + item: makeItem(phase: .failed, occurredAt: now.addingTimeInterval(-180)) + ) + + XCTAssertNil(row.elapsedLabel(now: now)) + } + + func testDurationFormattingIsLossyAboveTheHour() { + XCTAssertEqual(ActivityRowPresentation.formatDuration(0), "0s") + XCTAssertEqual(ActivityRowPresentation.formatDuration(59), "59s") + XCTAssertEqual(ActivityRowPresentation.formatDuration(60), "1m") + XCTAssertEqual(ActivityRowPresentation.formatDuration(3_599), "59m") + XCTAssertEqual(ActivityRowPresentation.formatDuration(3_600), "1h") + XCTAssertEqual(ActivityRowPresentation.formatDuration(86_400), "1d") + XCTAssertNil(ActivityRowPresentation.formatDuration(-1)) + } + + // MARK: - Machine presence + + func testOfflineMachineCarriesLastSeenCopyAndStopsPulsing() { + let now = Date() + let row = ActivityRowPresentation( + item: makeItem( + phase: .running, + machine: AccountAttentionMachine( + machineKey: "studio", + name: "Studio Mac", + online: false, + lastSeenAt: now.addingTimeInterval(-7_200) + ) + ) + ) + + XCTAssertFalse(row.machineOnline) + XCTAssertFalse(row.isActive, "a row on an unreachable machine must not read as live") + XCTAssertEqual(row.lastSeenLabel(now: now), "last seen 2h ago") + } + + func testOnlineMachineHasNoLastSeenCopy() { + let row = ActivityRowPresentation(item: makeItem(phase: .running)) + + XCTAssertTrue(row.machineOnline) + XCTAssertNil(row.lastSeenLabel()) + } + + // MARK: - Field projection + + func testStatusNoteFallsBackThroughPreviewDetailThenPrivacyPreview() { + XCTAssertEqual( + ActivityRowPresentation(item: makeItem(phase: .running, preview: "Editing the router")).statusNote, + "Editing the router" + ) + XCTAssertEqual( + ActivityRowPresentation(item: makeItem(phase: .running, preview: " ", detail: "Ran 4 tools")).statusNote, + "Ran 4 tools" + ) + XCTAssertEqual( + ActivityRowPresentation( + item: makeItem(phase: .running, preview: "", detail: nil, privacyPreview: "Agent working") + ).statusNote, + "Agent working" + ) + XCTAssertNil( + ActivityRowPresentation( + item: makeItem(phase: .running, preview: "", detail: nil, privacyPreview: "") + ).statusNote + ) + } + + func testPullRequestItemsCarryTheirNumberAndNoSession() { + let row = ActivityRowPresentation( + item: makeItem( + phase: .checksFailing, + kind: .pullRequest, + destination: .pullRequest( + prId: "pr-1", + repoOwner: "arul", + repoName: "ade", + number: 992, + tab: "checks", + eventId: nil + ) + ) + ) + + XCTAssertTrue(row.isPullRequest) + XCTAssertEqual(row.prNumber, 992) + XCTAssertNil(row.sessionId) + } + + func testModelAndLaneAreProjectedRatherThanDropped() { + let row = ActivityRowPresentation( + item: makeItem(phase: .running, laneName: "activity-revamp", model: "claude-fable-5") + ) + + XCTAssertEqual(row.laneName, "activity-revamp") + XCTAssertEqual(row.modelLabel, "claude-fable-5") + XCTAssertEqual(row.scopeLabel, "Studio Mac · ADE") + } + + // MARK: - Fixture + + private func makeItem( + id: String = "item-1", + phase: AccountAttentionPhase, + kind: AccountAttentionItemKind = .agent, + activityTier: String? = nil, + statusSince: Date? = nil, + occurredAt: Date = Date(), + machine: AccountAttentionMachine? = nil, + laneName: String? = nil, + model: String? = nil, + preview: String = "Working", + detail: String? = nil, + privacyPreview: String = "Agent working", + destination: AccountAttentionDestination? = nil + ) -> AccountAttentionItem { + AccountAttentionItem( + id: id, + revision: 1, + fingerprint: "\(id):1", + kind: kind, + eventKind: .agentRunning, + phase: phase, + activityTier: activityTier, + statusSince: statusSince, + machine: machine ?? AccountAttentionMachine( + machineKey: "studio", + name: "Studio Mac", + online: true, + lastSeenAt: occurredAt + ), + project: AccountAttentionProject(projectId: "ade", name: "ADE"), + laneName: laneName, + provider: "claude", + model: model, + title: "Wire the drawer", + preview: preview, + privacyPreview: privacyPreview, + detail: detail, + destination: destination ?? .session(sessionId: "s-1", itemId: nil, eventId: nil), + occurredAt: occurredAt, + updatedAt: occurredAt + ) + } +} diff --git a/apps/ios/ADETests/AttentionDrawerModelTests.swift b/apps/ios/ADETests/AttentionDrawerModelTests.swift deleted file mode 100644 index 4415063cd..000000000 --- a/apps/ios/ADETests/AttentionDrawerModelTests.swift +++ /dev/null @@ -1,1097 +0,0 @@ -import XCTest -@testable import ADE - -@available(iOS 17.0, *) -@MainActor -final class AttentionDrawerModelTests: XCTestCase { - private var defaults: UserDefaults! - private var suiteName: String! - - override func setUp() { - super.setUp() - suiteName = "ade.attention-drawer.tests.\(UUID().uuidString)" - defaults = UserDefaults(suiteName: suiteName) - defaults.removePersistentDomain(forName: suiteName) - } - - override func tearDown() { - defaults.removePersistentDomain(forName: suiteName) - defaults = nil - suiteName = nil - super.tearDown() - } - - // MARK: - Empty snapshot - - func testEmptySnapshotProducesNoItems() { - let model = AttentionDrawerModel(defaults: defaults) - - model.rebuild(from: .empty) - - XCTAssertTrue(model.items.isEmpty) - XCTAssertEqual(model.unreadCount, 0) - XCTAssertNil(model.badgeLabel) - } - - // MARK: - Mixed attention - - func testMixedSnapshotBuildsAwaitingFailedCiAndMergeItems() { - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - - let awaitingAgent = AgentSnapshot( - sessionId: "s-awaiting", - provider: "claude", - title: "Approve import", - status: "running", - awaitingInput: true, - lastActivityAt: now, - elapsedSeconds: 30, - preview: "Choose the release target", - pendingInputItemId: "pending-approval-1", - progress: nil, - phase: nil, - toolCalls: 0 - ) - let failedAgent = AgentSnapshot( - sessionId: "s-failed", - provider: "codex", - title: "Broken test run", - status: "failed", - awaitingInput: false, - lastActivityAt: now.addingTimeInterval(-120), - elapsedSeconds: 80, - preview: nil, - progress: nil, - phase: nil, - toolCalls: 0 - ) - let healthyAgent = AgentSnapshot( - sessionId: "s-healthy", - provider: "cursor", - title: "Idle", - status: "running", - awaitingInput: false, - lastActivityAt: now.addingTimeInterval(-60), - elapsedSeconds: 60, - preview: nil, - progress: nil, - phase: nil, - toolCalls: 0 - ) - - let ciFailingPr = PrSnapshot( - id: "pr-1", - number: 412, - title: "Migrate auth", - checks: "failing", - review: "pending", - state: "open", - mergeReady: false - ) - let mergeReadyPr = PrSnapshot( - id: "pr-2", - number: 401, - title: "Tidy logs", - checks: "passing", - review: "approved", - state: "open", - mergeReady: true - ) - let reviewPr = PrSnapshot( - id: "pr-3", - number: 408, - title: "Add caching", - checks: "passing", - review: "pending", - state: "open", - mergeReady: false - ) - let mergedPr = PrSnapshot( - id: "pr-4", - number: 390, - title: "Closed already", - checks: "failing", - review: "approved", - state: "merged", - mergeReady: false - ) - - let snapshot = WorkspaceSnapshot( - generatedAt: now, - agents: [awaitingAgent, failedAgent, healthyAgent], - prs: [ciFailingPr, mergeReadyPr, reviewPr, mergedPr], - connection: "connected" - ) - - model.rebuild(from: snapshot) - - XCTAssertEqual(model.items.count, 5, "healthy agent + merged PR should be filtered out") - - // Priority order: awaiting, failed, ci, review, merge. - XCTAssertEqual(model.items.map(\.kind), [ - .awaitingInput, - .failed, - .ciFailing, - .reviewRequested, - .mergeReady, - ]) - - let awaiting = try? XCTUnwrap(model.items.first) - XCTAssertEqual(awaiting?.sessionId, "s-awaiting") - XCTAssertEqual(awaiting?.itemId, "pending-approval-1") - XCTAssertEqual(awaiting?.deepLink, URL(string: "ade://session/s-awaiting")) - XCTAssertEqual(awaiting?.subtitle, "Choose the release target") - - let ci = model.items.first(where: { $0.kind == .ciFailing }) - XCTAssertEqual(ci?.prNumber, 412) - XCTAssertEqual(ci?.deepLink, URL(string: "ade://pr/412")) - } - - func testItemsOfSameKindAreSortedNewestFirst() { - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - - let older = AgentSnapshot( - sessionId: "older", - provider: "claude", - title: "A", - status: "failed", - awaitingInput: false, - lastActivityAt: now.addingTimeInterval(-500), - elapsedSeconds: 0, - preview: nil, - progress: nil, - phase: nil, - toolCalls: 0 - ) - let newer = AgentSnapshot( - sessionId: "newer", - provider: "claude", - title: "B", - status: "failed", - awaitingInput: false, - lastActivityAt: now, - elapsedSeconds: 0, - preview: nil, - progress: nil, - phase: nil, - toolCalls: 0 - ) - - model.rebuild(from: .init( - generatedAt: now, - agents: [older, newer], - prs: [], - connection: "connected" - )) - - XCTAssertEqual(model.items.map(\.sessionId), ["newer", "older"]) - } - - func testWorkspaceFallbackBuildsPriorityLiveAndRecentStacks() { - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - let snapshot = WorkspaceSnapshot( - generatedAt: now, - agents: [ - AgentSnapshot( - sessionId: "waiting", - provider: "claude", - laneName: "Primary", - title: "Approve release", - status: "awaiting_input", - awaitingInput: true, - lastActivityAt: now, - elapsedSeconds: 12, - preview: "Approve the push", - pendingInputItemId: "approval-1", - progress: nil, - phase: "validation", - toolCalls: 2 - ), - AgentSnapshot( - sessionId: "working", - provider: "codex", - laneName: "feature/attention", - title: "Polish mobile UI", - status: "running", - awaitingInput: false, - lastActivityAt: now.addingTimeInterval(-30), - elapsedSeconds: 300, - preview: "Rendering the priority stack", - progress: 0.7, - phase: "development", - toolCalls: 8 - ), - AgentSnapshot( - sessionId: "done", - provider: "codex", - laneName: "feature/attention", - title: "Model contract", - status: "completed", - awaitingInput: false, - lastActivityAt: now.addingTimeInterval(-120), - elapsedSeconds: 180, - preview: "Completed", - progress: 1, - phase: "validation", - toolCalls: 4 - ), - ], - prs: [], - connection: "connected", - machineId: "studio", - machineName: "Studio Mac", - projectId: "ade", - projectName: "ADE" - ) - - model.rebuild(from: snapshot) - - XCTAssertEqual(model.items.map(\.sessionId), ["waiting"]) - XCTAssertEqual(model.liveItems.map(\.sessionId), ["working"]) - XCTAssertEqual(model.recentItems.map(\.sessionId), ["done"]) - XCTAssertEqual(model.projectLenses.map(\.name), ["ADE"]) - XCTAssertEqual(model.visibleMachineCount, 1) - XCTAssertEqual(model.liveItems.first?.scopeLabel, "Studio Mac · ADE") - } - - func testAccountSnapshotSupportsProjectLensAndExactDestinations() { - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - let studio = AccountAttentionMachine( - machineKey: "studio", - accountMachineKey: "account-studio", - name: "Studio Mac", - online: true, - lastSeenAt: now - ) - let laptop = AccountAttentionMachine( - machineKey: "laptop", - name: "MacBook", - online: false, - lastSeenAt: now.addingTimeInterval(-120) - ) - - let snapshot = AccountAttentionSnapshot( - revision: 7, - generatedAt: now, - items: [ - AccountAttentionItem( - id: "approval", - revision: 2, - fingerprint: "approval:2", - kind: .agent, - eventKind: .agentNeedsYou, - phase: .needsYou, - machine: studio, - project: .init(projectId: "ade", name: "ADE"), - laneName: "Primary", - provider: "claude", - title: "Release ADE", - preview: "Approve git push", - privacyPreview: "Approval required", - destination: .session(sessionId: "session-a", itemId: "item-a", eventId: "event-a"), - occurredAt: now, - updatedAt: now - ), - AccountAttentionItem( - id: "live", - revision: 1, - fingerprint: "live:1", - kind: .agent, - eventKind: .agentCompleted, - phase: .running, - machine: laptop, - project: .init(projectId: "versic", name: "Versic"), - provider: "codex", - title: "Fix Windows sync", - preview: "Running tests", - privacyPreview: "Agent working", - destination: .session(sessionId: "session-b", itemId: nil, eventId: nil), - occurredAt: now, - updatedAt: now - ), - ] - ) - - model.rebuild(from: snapshot) - - XCTAssertEqual(model.projectLenses.map(\.name).sorted(), ["ADE", "Versic"]) - XCTAssertEqual( - model.items.first?.deepLink, - URL( - string: "ade://session/session-a?item=item-a&event=event-a&accountMachineKey=account-studio" - ) - ) - XCTAssertEqual(model.items.first?.inlineActionsAllowed, false) - XCTAssertEqual(model.visibleMachineCount, 2) - XCTAssertEqual( - AccountAttentionDestination.pullRequest( - prId: "pr-42", - repoOwner: "openai", - repoName: "ade", - number: 42, - tab: "checks", - eventId: "event-pr" - ).deepLinkURL(accountMachineKey: studio.accountMachineKey), - URL( - string: "ade://pr/openai/ade/42?tab=checks&event=event-pr&accountMachineKey=account-studio" - ) - ) - - model.selectProject("versic") - XCTAssertTrue(model.visibleItems(in: .needsYou).isEmpty) - XCTAssertEqual(model.visibleItems(in: .live).map(\.id), ["live"]) - XCTAssertEqual(model.visibleMachineCount, 1) - } - - func testAccountSnapshotDeltaHonorsItemAndTombstoneRevisions() { - let now = Date() - let current = AccountAttentionSnapshot( - revision: 8, - generatedAt: now, - items: [ - makeAccountItem(id: "keep", revision: 5, title: "Newest value", now: now), - makeAccountItem(id: "remove", revision: 2, title: "Remove me", now: now), - ] - ) - let delta = AccountAttentionSnapshot( - revision: 9, - generatedAt: now.addingTimeInterval(1), - items: [ - makeAccountItem(id: "keep", revision: 4, title: "Stale value", now: now), - makeAccountItem(id: "add", revision: 1, title: "Added", now: now), - ], - tombstones: [ - AccountAttentionTombstone( - id: "keep", - revision: 4, - deletedAt: now - ), - AccountAttentionTombstone( - id: "remove", - revision: 3, - deletedAt: now - ), - ] - ) - - let merged = current.merging(delta) - - XCTAssertEqual(merged.revision, 9) - XCTAssertEqual(Set(merged.items.map(\.id)), ["keep", "add"]) - XCTAssertEqual( - merged.items.first(where: { $0.id == "keep" })?.title, - "Newest value" - ) - } - - func testAccountSnapshotRefreshesCachedMachinePresenceWithoutItemChanges() { - let now = Date() - let cachedMachine = AccountAttentionMachine( - machineKey: "studio", - accountMachineKey: "account-studio", - name: "Studio Mac", - online: false, - lastSeenAt: now.addingTimeInterval(-120) - ) - let current = AccountAttentionSnapshot( - revision: 8, - generatedAt: now, - machines: [cachedMachine], - items: [ - makeAccountItem( - id: "cached", - revision: 8, - title: "Cached", - now: now, - machine: cachedMachine - ), - ] - ) - let refreshedPresence = AccountAttentionMachine( - machineKey: "studio", - name: "Studio Mac", - online: true, - lastSeenAt: now.addingTimeInterval(1) - ) - let unchangedRevision = AccountAttentionSnapshot( - revision: 8, - generatedAt: now.addingTimeInterval(1), - machines: [refreshedPresence], - items: [] - ) - - let merged = current.merging(unchangedRevision) - - XCTAssertEqual(merged.items.count, 1) - XCTAssertTrue(merged.items[0].machine.online) - XCTAssertEqual(merged.items[0].machine.lastSeenAt, refreshedPresence.lastSeenAt) - XCTAssertEqual( - merged.items[0].machine.accountMachineKey, - cachedMachine.accountMachineKey, - "Presence-only rows must not erase the canonical routing identity" - ) - XCTAssertEqual(merged.machines, [refreshedPresence]) - } - - func testAccountSnapshotDuplicateItemIdsKeepHighestRevision() { - let now = Date() - let incoming = AccountAttentionSnapshot( - revision: 7, - generatedAt: now, - items: [ - makeAccountItem(id: "duplicate", revision: 2, title: "Older", now: now), - makeAccountItem(id: "duplicate", revision: 7, title: "Newest", now: now), - ] - ) - - let committed = accountAttentionSnapshotForCommit(current: nil, incoming: incoming) - - XCTAssertEqual(committed.items.count, 1) - XCTAssertEqual(committed.items[0].revision, 7) - XCTAssertEqual(committed.items[0].title, "Newest") - } - - func testOutOfOrderSnapshotCommitCannotRegressRevisionOrDropNewerItems() { - let now = Date() - let base = AccountAttentionSnapshot( - streamId: "account-a", - revision: 10, - generatedAt: now, - items: [ - makeAccountItem(id: "existing", revision: 10, title: "Existing", now: now), - ] - ) - let revisionTwelve = AccountAttentionSnapshot( - streamId: "account-a", - revision: 12, - generatedAt: now.addingTimeInterval(2), - items: [ - makeAccountItem(id: "newer", revision: 12, title: "Newer", now: now), - ] - ) - let revisionEleven = AccountAttentionSnapshot( - streamId: "account-a", - revision: 11, - generatedAt: now.addingTimeInterval(1), - items: [ - makeAccountItem(id: "stale", revision: 11, title: "Stale", now: now), - ] - ) - - let committedTwelve = accountAttentionSnapshotForCommit( - current: base, - incoming: revisionTwelve - ) - let afterLateEleven = accountAttentionSnapshotForCommit( - current: committedTwelve, - incoming: revisionEleven - ) - - XCTAssertEqual(afterLateEleven.revision, 12) - XCTAssertEqual( - Set(afterLateEleven.items.map(\.id)), - ["existing", "newer"] - ) - } - - func testSnapshotStreamChangeResetsPriorAccountItems() { - let now = Date() - let priorAccount = AccountAttentionSnapshot( - streamId: "account-a", - revision: 42, - generatedAt: now, - items: [ - makeAccountItem(id: "private-a", revision: 42, title: "Private A", now: now), - ] - ) - let newAccount = AccountAttentionSnapshot( - streamId: "account-b", - revision: 1, - generatedAt: now.addingTimeInterval(1), - items: [ - makeAccountItem(id: "private-b", revision: 1, title: "Private B", now: now), - ] - ) - - let committed = accountAttentionSnapshotForCommit( - current: priorAccount, - incoming: newAccount - ) - - XCTAssertEqual(committed.streamId, "account-b") - XCTAssertEqual(committed.revision, 1) - XCTAssertEqual(committed.items.map(\.id), ["private-b"]) - } - - func testOpenPullRequestIsRecentAndExpiredItemsAreRemoved() { - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - let scope = AccountAttentionMachine( - machineKey: "studio", - name: "Studio Mac", - online: true, - lastSeenAt: now - ) - let openPullRequest = AccountAttentionItem( - id: "pr-open", - revision: 1, - fingerprint: "pr-open:1", - kind: .pullRequest, - eventKind: .prOpened, - phase: .open, - machine: scope, - project: .init(projectId: "ade", name: "ADE"), - title: "Open pull request", - preview: "Waiting for activity", - privacyPreview: "Pull request open", - destination: .pullRequest( - prId: "pr-open", - repoOwner: "ade", - repoName: "ade", - number: 42, - tab: "overview", - eventId: nil - ), - occurredAt: now, - updatedAt: now - ) - let expired = AccountAttentionItem( - id: "expired", - revision: 1, - fingerprint: "expired:1", - kind: .agent, - eventKind: .agentNeedsYou, - phase: .needsYou, - machine: scope, - project: .init(projectId: "ade", name: "ADE"), - title: "Old approval", - preview: "No longer actionable", - privacyPreview: "Approval required", - destination: .session(sessionId: "old", itemId: "item-old", eventId: nil), - occurredAt: now.addingTimeInterval(-120), - updatedAt: now.addingTimeInterval(-120), - expiresAt: now.addingTimeInterval(-1) - ) - - model.rebuild(from: .init( - revision: 1, - generatedAt: now.addingTimeInterval(-60), - items: [openPullRequest, expired] - )) - - XCTAssertFalse(openPullRequest.isLive) - XCTAssertTrue(model.items.isEmpty) - XCTAssertTrue(model.liveItems.isEmpty) - XCTAssertEqual(model.recentItems.map(\.id), ["pr-open"]) - XCTAssertEqual(model.recentItems.first?.kind, .open) - } - - func testMarkingOneItemSeenDoesNotClearOtherUnreadItems() { - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - let agents = ["one", "two"].map { id in - AgentSnapshot( - sessionId: id, - provider: "codex", - title: id, - status: "awaiting_input", - awaitingInput: true, - lastActivityAt: now, - elapsedSeconds: 0, - preview: nil, - progress: nil, - phase: nil, - toolCalls: 0 - ) - } - model.rebuild(from: .init( - generatedAt: now, - agents: agents, - prs: [], - connection: "connected" - )) - - model.markSeen("awaiting:one") - - XCTAssertEqual(model.unreadCount, 1) - XCTAssertEqual(model.badgeLabel, "1") - } - - // MARK: - markAllSeen - - func testMarkAllSeenZeroesUnreadCount() { - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - - let awaiting = AgentSnapshot( - sessionId: "s1", - provider: "claude", - title: "Do thing", - status: "running", - awaitingInput: true, - lastActivityAt: now, - elapsedSeconds: 10, - preview: nil, - progress: nil, - phase: nil, - toolCalls: 0 - ) - model.rebuild(from: .init( - generatedAt: now, - agents: [awaiting], - prs: [], - connection: "connected" - )) - - XCTAssertEqual(model.unreadCount, 1) - XCTAssertEqual(model.badgeLabel, "1") - - model.markAllSeen() - - XCTAssertEqual(model.unreadCount, 0) - XCTAssertNil(model.badgeLabel) - XCTAssertEqual(model.items.count, 1, "items stay; only unread count clears") - - let stored = defaults.double(forKey: AttentionDrawerModel.lastSeenAtKey) - XCTAssertGreaterThan(stored, 0, "markAllSeen should persist the new lastSeenAt") - } - - func testClearVisibleItemsHidesCurrentCardsAndPersistsDismissal() { - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - let snapshot = WorkspaceSnapshot( - generatedAt: now, - agents: [], - prs: [ - PrSnapshot( - id: "pr-1", - number: 9101, - title: "Mobile attention CI failing", - checks: "failing", - review: "approved", - state: "open", - mergeReady: false - ) - ], - connection: "connected" - ) - - model.rebuild(from: snapshot) - XCTAssertEqual(model.items.map(\.id), ["ci:pr-1"]) - - model.clearVisibleItems() - - XCTAssertTrue(model.items.isEmpty) - XCTAssertEqual(model.unreadCount, 0) - XCTAssertEqual( - Set(defaults.stringArray(forKey: AttentionDrawerModel.dismissedItemIDsKey) ?? []), - ["ci:pr-1"] - ) - - let freshModel = AttentionDrawerModel(defaults: defaults) - freshModel.rebuild(from: snapshot) - XCTAssertTrue(freshModel.items.isEmpty, "persisted dismissals should hide the same still-active attention") - } - - func testClearVisibleItemsOnlyDismissesNeedsYouItemsInSelectedProject() { - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - model.rebuild(from: AccountAttentionSnapshot( - revision: 2, - generatedAt: now, - items: [ - makeAccountItem( - id: "project-a", - revision: 1, - title: "Project A", - now: now, - eventKind: .agentNeedsYou, - phase: .needsYou, - projectId: "a", - projectName: "Project A" - ), - makeAccountItem( - id: "project-b", - revision: 2, - title: "Project B", - now: now, - eventKind: .agentNeedsYou, - phase: .needsYou, - projectId: "b", - projectName: "Project B" - ), - ] - )) - model.selectProject("a") - - model.clearVisibleItems() - - XCTAssertEqual(model.items.map(\.id), ["project-b"]) - XCTAssertNil(model.selectedProjectId) - XCTAssertEqual(model.unreadCount, 1) - XCTAssertEqual( - Set(defaults.stringArray(forKey: AttentionDrawerModel.dismissedItemIDsKey) ?? []), - ["project-a"] - ) - } - - func testClearedItemsReappearAfterBackingStateClears() { - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - let failing = WorkspaceSnapshot( - generatedAt: now, - agents: [], - prs: [ - PrSnapshot( - id: "pr-1", - number: 9101, - title: "Mobile attention CI failing", - checks: "failing", - review: "approved", - state: "open", - mergeReady: false - ) - ], - connection: "connected" - ) - - model.rebuild(from: failing) - model.clearVisibleItems() - model.rebuild(from: failing) - XCTAssertTrue(model.items.isEmpty) - - model.rebuild(from: .init( - generatedAt: now.addingTimeInterval(1), - agents: [], - prs: [], - connection: "connected" - )) - model.rebuild(from: .init( - generatedAt: now.addingTimeInterval(2), - agents: [], - prs: failing.prs, - connection: "connected" - )) - - XCTAssertEqual(model.items.map(\.id), ["ci:pr-1"]) - } - - func testBadgeCapsAtNinePlus() { - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - - let agents = (0..<12).map { idx in - AgentSnapshot( - sessionId: "s-\(idx)", - provider: "claude", - title: "T\(idx)", - status: "running", - awaitingInput: true, - lastActivityAt: now.addingTimeInterval(TimeInterval(idx)), - elapsedSeconds: 0, - preview: nil, - progress: nil, - phase: nil, - toolCalls: 0 - ) - } - model.rebuild(from: .init( - generatedAt: now, - agents: agents, - prs: [], - connection: "connected" - )) - - XCTAssertEqual(model.unreadCount, 12) - XCTAssertEqual(model.badgeLabel, "9+") - } - - func testUnreadCountOnlyCountsItemsNewerThanLastSeenAt() { - // Seed a lastSeenAt in the future so nothing qualifies as unread. - defaults.set( - Date().addingTimeInterval(3_600).timeIntervalSince1970, - forKey: AttentionDrawerModel.lastSeenAtKey - ) - - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - - let awaiting = AgentSnapshot( - sessionId: "s1", - provider: "claude", - title: "Do thing", - status: "running", - awaitingInput: true, - lastActivityAt: now, - elapsedSeconds: 0, - preview: nil, - progress: nil, - phase: nil, - toolCalls: 0 - ) - model.rebuild(from: .init( - generatedAt: now, - agents: [awaiting], - prs: [], - connection: "connected" - )) - - XCTAssertEqual(model.items.count, 1) - XCTAssertEqual(model.unreadCount, 0, "all items are older than a future lastSeenAt") - } - - func testViewedOpenPrDoesNotRebadgeWhenSnapshotRegenerates() { - let model = AttentionDrawerModel(defaults: defaults) - let prUpdatedAt = Date().addingTimeInterval(-60) - let firstSnapshot = WorkspaceSnapshot( - generatedAt: Date(), - agents: [], - prs: [ - PrSnapshot( - id: "pr-still-open", - number: 83, - title: "Still open", - checks: "failing", - review: "approved", - state: "open", - mergeReady: false, - updatedAt: prUpdatedAt - ) - ], - connection: "connected" - ) - - model.rebuild(from: firstSnapshot) - XCTAssertEqual(model.unreadCount, 1) - - model.markAllSeen() - model.rebuild(from: WorkspaceSnapshot( - generatedAt: Date().addingTimeInterval(2), - agents: [], - prs: firstSnapshot.prs, - connection: "connected" - )) - - XCTAssertEqual(model.items.map(\.id), ["ci:pr-still-open"]) - XCTAssertEqual(model.unreadCount, 0) - XCTAssertNil(model.badgeLabel) - } - - // MARK: - Inline summary - - func testInlineSummaryIgnoresClosedPrsWhenPickingFocus() { - let now = Date() - let snapshot = WorkspaceSnapshot( - generatedAt: now, - agents: [], - prs: [ - PrSnapshot( - id: "closed-failing", - number: 14, - title: "Already merged", - checks: "failing", - review: "approved", - state: "merged", - mergeReady: false - ), - PrSnapshot( - id: "open-review", - number: 42, - title: "Needs review", - checks: "passing", - review: "pending", - state: "open", - mergeReady: false - ), - ], - connection: "connected" - ) - - XCTAssertEqual(ADESharedContainer.inlineSummary(for: snapshot), "ADE · #42 ·") - } - - func testInlineSummaryReturnsIdleWhenOnlyClosedPrsExist() { - let snapshot = WorkspaceSnapshot( - generatedAt: Date(), - agents: [], - prs: [ - PrSnapshot( - id: "closed", - number: 9, - title: "Merged", - checks: "failing", - review: "approved", - state: "closed", - mergeReady: false - ), - ], - connection: "connected" - ) - - XCTAssertEqual(ADESharedContainer.inlineSummary(for: snapshot), "ADE · idle") - } - - func testAccountAttentionPhaseLabelsUseUnifiedVocabulary() { - // Same words as `AgentRunPhase.label` and the desktop sidebar. The - // drawer and the Lock Screen sit on one device; two names for one - // session state is the bug this vocabulary exists to prevent. - XCTAssertEqual(AccountAttentionPhase.running.displayLabel, "Working") - XCTAssertEqual(AccountAttentionPhase.needsYou.displayLabel, "Needs you") - XCTAssertEqual(AccountAttentionPhase.checksFailing.displayLabel, "Checks failing") - XCTAssertEqual(AccountAttentionPhase.reviewRequested.displayLabel, "Review requested") - XCTAssertEqual(AccountAttentionPhase.mergeReady.displayLabel, "Ready to merge") - XCTAssertEqual(AccountAttentionPhase.completed.displayLabel, "Done") - XCTAssertEqual(AccountAttentionPhase.stale.displayLabel, "Stale") - XCTAssertEqual(AgentRunPhase.running.label, AccountAttentionPhase.running.displayLabel) - XCTAssertEqual(AgentRunPhase.completed.label, AccountAttentionPhase.completed.displayLabel) - } - - func testWorkspaceAgentPhaseFallbackUsesUnifiedVocabulary() { - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - let agents = [ - AgentSnapshot( - sessionId: "working", - provider: "codex", - title: "Working copy", - status: "running", - awaitingInput: false, - lastActivityAt: now, - elapsedSeconds: 20, - preview: nil, - progress: nil, - phase: "running", - toolCalls: 1 - ), - AgentSnapshot( - sessionId: "done-phase", - provider: "claude", - title: "Done copy", - status: "running", - awaitingInput: false, - lastActivityAt: now.addingTimeInterval(-1), - elapsedSeconds: 30, - preview: nil, - progress: nil, - phase: "completed", - toolCalls: 2 - ), - ] - - model.rebuild(from: WorkspaceSnapshot( - generatedAt: now, - agents: agents, - prs: [], - connection: "connected" - )) - - XCTAssertEqual(model.liveItems.map(\.sessionId), ["working", "done-phase"]) - XCTAssertEqual(model.liveItems.first(where: { $0.sessionId == "working" })?.subtitle, "Working") - XCTAssertEqual(model.liveItems.first(where: { $0.sessionId == "done-phase" })?.subtitle, "Done") - } - - func testWorkspaceBlockedPhaseUsesNeutralBlockedPresentation() { - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - let blocked = AgentSnapshot( - sessionId: "blocked-local", - provider: "codex", - title: "Waiting on dependency", - status: "running", - awaitingInput: false, - lastActivityAt: now, - elapsedSeconds: 20, - preview: "Blocked", - progress: nil, - phase: "blocked", - toolCalls: 1 - ) - - model.rebuild(from: WorkspaceSnapshot( - generatedAt: now, - agents: [blocked], - prs: [], - connection: "connected" - )) - - XCTAssertEqual(model.liveItems.first?.kind, .blocked) - XCTAssertTrue(model.items.isEmpty) - } - - func testBlockedItemIsNotFiledOrColouredAsYourMove() { - // `blocked` used to borrow `awaitingInput`'s amber bell while filing - // itself under `live` — the row said "act on me" in colour and "just - // watching" in placement. `needsInbox` excludes it, so neutral is the - // honest reading. - let model = AttentionDrawerModel(defaults: defaults) - let now = Date() - let machine = AccountAttentionMachine( - machineKey: "studio", - accountMachineKey: "account-studio", - name: "Studio Mac", - online: true, - lastSeenAt: now - ) - let blocked = AccountAttentionItem( - id: "blocked", - revision: 1, - fingerprint: "blocked:1", - kind: .agent, - eventKind: .agentRunning, - phase: .blocked, - machine: machine, - project: .init(projectId: "ade", name: "ADE"), - title: "Waiting on a dependency", - preview: "Blocked", - privacyPreview: "Agent blocked", - destination: .session(sessionId: "session-blocked", itemId: nil, eventId: nil), - occurredAt: now, - updatedAt: now - ) - - XCTAssertFalse(blocked.needsInbox, "blocked never reaches the inbox") - - model.rebuild(from: AccountAttentionSnapshot(revision: 1, generatedAt: now, items: [blocked])) - - XCTAssertEqual(model.liveItems.first?.kind, .blocked) - XCTAssertTrue(model.items.isEmpty, "blocked must not land in the Needs you collection") - } - - private func makeAccountItem( - id: String, - revision: Int, - title: String, - now: Date, - machine: AccountAttentionMachine? = nil, - eventKind: AccountAttentionEventKind = .agentRunning, - phase: AccountAttentionPhase = .running, - projectId: String = "ade", - projectName: String = "ADE" - ) -> AccountAttentionItem { - AccountAttentionItem( - id: id, - revision: revision, - fingerprint: "\(id):\(revision)", - kind: .agent, - eventKind: eventKind, - phase: phase, - machine: machine ?? .init( - machineKey: "studio", - name: "Studio Mac", - online: true, - lastSeenAt: now - ), - project: .init(projectId: projectId, name: projectName), - title: title, - preview: "Working", - privacyPreview: "Agent working", - destination: .session(sessionId: id, itemId: nil, eventId: nil), - occurredAt: now, - updatedAt: now - ) - } -} diff --git a/apps/ios/ADETests/HubProjectPresentationTests.swift b/apps/ios/ADETests/HubProjectPresentationTests.swift new file mode 100644 index 000000000..c79c858c8 --- /dev/null +++ b/apps/ios/ADETests/HubProjectPresentationTests.swift @@ -0,0 +1,151 @@ +import XCTest +@testable import ADE + +/// The hub's project card and chat rows carried live counts and a status string +/// that were computed and then never rendered. These lock the presentation seam +/// so the numbers cannot silently go quiet again — including the equatable +/// short-circuit, which is what would freeze them. +final class HubProjectPresentationTests: XCTestCase { + + // MARK: - Status line copy + + func testStatusLineIsSilentWhenNothingIsHappening() { + XCTAssertNil(hubProjectStatusLine(attentionCount: 0, runningCount: 0)) + } + + func testStatusLineNamesOnlyTheNonZeroClauses() { + XCTAssertEqual(hubProjectStatusLine(attentionCount: 2, runningCount: 0), "2 need you") + XCTAssertEqual(hubProjectStatusLine(attentionCount: 0, runningCount: 3), "3 working") + XCTAssertEqual(hubProjectStatusLine(attentionCount: 2, runningCount: 3), "2 need you · 3 working") + } + + // MARK: - Counts reach the card + + func testProjectPresentationCarriesRosterCounts() { + let presentation = buildHubProjectPresentation( + project: project(), + roster: roster(attentionCount: 1, runningCount: 2), + isActive: false, + isSwitching: false + ) + + XCTAssertEqual(presentation.attentionCount, 1) + XCTAssertEqual(presentation.runningCount, 2) + XCTAssertEqual(presentation.statusLine, "1 need you · 2 working") + } + + func testEquatableShortCircuitDoesNotFreezeTheCounts() { + let quiet = buildHubProjectPresentation( + project: project(), + roster: roster(attentionCount: 0, runningCount: 0), + isActive: false, + isSwitching: false + ) + let busy = buildHubProjectPresentation( + project: project(), + roster: roster(attentionCount: 1, runningCount: 0), + isActive: false, + isSwitching: false + ) + + XCTAssertNotEqual(quiet, busy, "a count change must re-render the card") + } + + func testMissingRosterReportsNoLiveCounts() { + let presentation = buildHubProjectPresentation( + project: project(), + roster: nil, + isActive: false, + isSwitching: false + ) + + XCTAssertEqual(presentation.attentionCount, 0) + XCTAssertNil(presentation.statusLine) + } + + // MARK: - Chat row status + + func testChatRowStatusLabelSpeaksOnlyWhenItHasSomethingToSay() { + XCTAssertEqual(hubChatStatusLabel("awaiting-input"), "Needs you") + XCTAssertEqual(hubChatStatusLabel("active"), "Working") + XCTAssertNil(hubChatStatusLabel("idle")) + XCTAssertNil(hubChatStatusLabel("ended")) + } + + func testChatRowPresentationCarriesTheNormalizedStatus() { + let row = HubChatRowPresentation.make( + chat: chat(id: "c-1", status: .running, awaitingInput: true) + ) + + XCTAssertEqual(row.statusString, "awaiting-input") + } + + func testChatRowEquatableTracksAStatusChange() { + let waiting = HubChatRowPresentation.make( + chat: chat(id: "c-1", status: .running, awaitingInput: true) + ) + let working = HubChatRowPresentation.make( + chat: chat(id: "c-1", status: .running, awaitingInput: false) + ) + + XCTAssertNotEqual(waiting, working, "the status dot must not stick on a stale value") + } + + // MARK: - Fixtures + + private func project() -> MobileProjectSummary { + MobileProjectSummary( + id: "p-1", + displayName: "ADE", + laneCount: 2, + isAvailable: true, + isCached: true + ) + } + + private func roster(attentionCount: Int, runningCount: Int) -> RemoteRosterProject { + RemoteRosterProject( + projectId: "p-1", + rootPath: nil, + displayName: "ADE", + iconDataUrl: nil, + lastOpenedAt: nil, + booted: true, + runningCount: runningCount, + attentionCount: attentionCount, + lanes: [ + RemoteRosterLane( + id: "lane-1", + name: "activity-revamp", + color: nil, + icon: nil, + laneType: nil, + branchRef: nil + ), + ], + chats: [chat(id: "c-1", status: .running, awaitingInput: false)] + ) + } + + private func chat( + id: String, + status: RemoteRosterChatStatus, + awaitingInput: Bool + ) -> RemoteRosterChat { + RemoteRosterChat( + id: id, + laneId: "lane-1", + chatSessionId: nil, + title: "Wire the drawer", + provider: "claude", + model: nil, + toolType: "chat", + status: status, + awaitingInput: awaitingInput, + pinned: nil, + archived: nil, + lastActivityAt: "2026-08-01T00:00:00Z", + preview: nil + ) + } +} From 945195a9987461b4be0dc8376098f204ff16e360 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:56:09 -0400 Subject: [PATCH 11/19] =?UTF-8?q?activity(p5):=20notch=20+=20menu=20bar=20?= =?UTF-8?q?rework=20=E2=80=94=20event=20toasts=20with=20per-kind=20treatme?= =?UTF-8?q?nts,=20counts=20hover=20strip,=20scrollable=20card=20panel,=20c?= =?UTF-8?q?ap=20fix,=20live=20ticker,=20geometry=20760x640?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../NotchContextMenuController.swift | 28 +- .../NotchPanelController.swift | 6 - .../NotchStatusItemController.swift | 37 +- .../ADEAttentionNotch/NotchSurfaceView.swift | 690 +++++++++++++----- .../ADEAttentionNotch/NotchViewModel.swift | 249 ++++--- .../AttentionModels.swift | 223 +++++- .../ADEAttentionNotchCore/NotchGeometry.swift | 7 +- .../NotchInteractionState.swift | 110 ++- .../NotchGeometryTests.swift | 47 ++ .../NotchInteractionStateTests.swift | 217 ++++-- .../NotchProtocolTests.swift | 240 +++++- apps/desktop/src/main/main.ts | 33 +- .../attention/attentionNotchHelper.test.ts | 224 ++++++ .../attention/attentionNotchHelper.ts | 48 +- .../attention/attentionNotchRouter.test.ts | 154 ++++ .../attention/attentionNotchRouter.ts | 81 +- .../src/main/services/ipc/registerIpc.ts | 11 + apps/desktop/src/preload/global.d.ts | 5 + apps/desktop/src/preload/preload.ts | 3 + .../attentionNotchLocalSettings.test.ts | 35 +- .../attention/attentionNotchLocalSettings.ts | 27 + .../attention/attentionPresentation.ts | 33 +- .../attention/useAttentionSync.test.tsx | 218 ++++++ .../components/attention/useAttentionSync.ts | 224 +++++- .../settings/ActivitySettingsControls.tsx | 64 ++ .../components/settings/settingsManifest.ts | 18 + apps/desktop/src/shared/ipc.ts | 1 + apps/desktop/src/shared/types/attention.ts | 109 +++ 28 files changed, 2729 insertions(+), 413 deletions(-) diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchContextMenuController.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchContextMenuController.swift index 29e463ebd..be5b440c5 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchContextMenuController.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchContextMenuController.swift @@ -24,7 +24,7 @@ final class NotchContextMenuController: NSObject { private func menu() -> NSMenu { let menu = NSMenu(title: "ADE Notch") menu.autoenablesItems = false - menu.addItem(item("Open Attention Center", action: #selector(openAttentionCenter))) + menu.addItem(item("Open Activity", action: #selector(openActivity))) menu.addItem(item("Refresh", action: #selector(refresh))) menu.addItem(.separator()) @@ -43,6 +43,18 @@ final class NotchContextMenuController: NSObject { let expanded = item("Allow expanded panel", action: #selector(toggleExpandedPanel)) expanded.state = model.settings.expandedPanelEnabled ? .on : .off menu.addItem(expanded) + let automaticReveal = item("Automatic reveal", action: #selector(toggleAutomaticReveal)) + automaticReveal.state = model.settings.automaticRevealEnabled ? .on : .off + // "Click only" already means nothing but a click opens anything, so the + // checkmark would claim a behaviour the mode overrides. + automaticReveal.isEnabled = model.settings.revealMode != .click + menu.addItem(automaticReveal) + let ticker = item("Live ticker", action: #selector(toggleTicker)) + ticker.state = model.settings.tickerEnabled ? .on : .off + // The ticker lives in the pinned strip, which only compact mode keeps + // on screen at rest. + ticker.isEnabled = model.settings.revealMode == .minimal + menu.addItem(ticker) menu.addItem(.separator()) menu.addItem(item("Hide ADE Notch…", action: #selector(confirmHide))) return menu @@ -54,8 +66,8 @@ final class NotchContextMenuController: NSObject { return item } - @objc private func openAttentionCenter() { - model.openAttentionCenter() + @objc private func openActivity() { + model.openActivity() } @objc private func refresh() { @@ -74,11 +86,19 @@ final class NotchContextMenuController: NSObject { model.applySettingsMenuAction(.toggleExpandedPanel) } + @objc private func toggleAutomaticReveal() { + model.applySettingsMenuAction(.toggleAutomaticReveal) + } + + @objc private func toggleTicker() { + model.applySettingsMenuAction(.toggleTicker) + } + @objc private func confirmHide() { let alert = NSAlert() alert.alertStyle = .informational alert.messageText = "Hide ADE Notch?" - alert.informativeText = "This removes the notch and menu-bar activity surface. You can turn it back on anytime in ADE Attention settings." + alert.informativeText = "This removes the notch and menu-bar activity surface. You can turn it back on anytime in ADE’s Activity settings." alert.addButton(withTitle: "Hide ADE Notch") alert.addButton(withTitle: "Cancel") guard alert.runModal() == .alertFirstButtonReturn else { return } diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchPanelController.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchPanelController.swift index 705e51d0b..b86a2ac74 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchPanelController.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchPanelController.swift @@ -194,12 +194,6 @@ final class NotchPanelController { case 53: model.dismissExpanded() return nil - case 123: - model.navigate(delta: -1) - return nil - case 124: - model.navigate(delta: 1) - return nil case 36, 76: model.openSelected() return nil diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchStatusItemController.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchStatusItemController.swift index 5a0351d08..154d1b459 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchStatusItemController.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchStatusItemController.swift @@ -19,8 +19,8 @@ final class NotchStatusItemController { panelController.fallbackAnchorFrame = { [weak self] in self?.statusItemScreenFrame } - Publishers.CombineLatest3(model.$items, model.$interaction, model.$settings) - .sink { [weak self] _, _, _ in self?.refresh() } + Publishers.CombineLatest4(model.$items, model.$interaction, model.$settings, model.$counts) + .sink { [weak self] _, _, _, _ in self?.refresh() } .store(in: &cancellables) refresh() } @@ -43,7 +43,7 @@ final class NotchStatusItemController { button.image = statusIcon button.imagePosition = .imageOnly button.toolTip = statusToolTip - button.setAccessibilityLabel("ADE Attention Center, \(statusToolTip)") + button.setAccessibilityLabel("ADE Activity, \(statusToolTip)") } private var statusIcon: NSImage { @@ -56,7 +56,7 @@ final class NotchStatusItemController { // an invisible, unclickable gap. return NSImage( systemSymbolName: "app.dashed", - accessibilityDescription: "ADE Attention Center" + accessibilityDescription: "ADE Activity" ) ?? NSImage() } @@ -79,10 +79,14 @@ final class NotchStatusItemController { NSBezierPath(ovalIn: badgeRect).fill() image.unlockFocus() image.isTemplate = false - image.accessibilityDescription = "ADE Attention Center" + image.accessibilityDescription = "ADE Activity" return image } + /// One hue per section, same table as every other Activity surface: amber + /// is "your move" and nothing else, blue is work in progress, emerald is + /// finished cleanly. Read from the account's counts rather than one selected + /// row, so the badge describes the whole account. private var statusBadgeColor: NSColor { if let status = model.statusPresentation, status.isProblem { switch status.tone { @@ -91,23 +95,24 @@ final class NotchStatusItemController { default: return .systemPurple } } - if model.items.contains(where: \.isAttention) { return .systemOrange } - if model.items.contains(where: { $0.phase == "running" || $0.phase == "starting" }) { - return .systemBlue - } - if model.items.isEmpty { return .systemGray } - return .systemGreen + let counts = model.counts + if counts.needsYou > 0 { return .systemOrange } + if counts.working > 0 { return .systemBlue } + if counts.done > 0 { return .systemGreen } + return .systemGray } private var statusToolTip: String { if let status = model.statusPresentation, status.isProblem { return [status.title, status.hint].compactMap { $0 }.joined(separator: " ") } - guard let item = model.selectedItem else { - return model.statusPresentation?.message ?? "No active attention items" - } - let presentation = item.presentation(hideDetails: model.settings.hideDetails) - return "\(item.statusLabel): \(presentation.title)" + let counts = model.counts + var parts: [String] = [] + if counts.needsYou > 0 { parts.append("\(counts.needsYou) need\(counts.needsYou == 1 ? "s" : "") you") } + if counts.working > 0 { parts.append("\(counts.working) working") } + if counts.done > 0 { parts.append("\(counts.done) done") } + guard parts.isEmpty else { return parts.joined(separator: " · ") } + return model.statusPresentation?.message ?? "All agents idle" } private var statusItemScreenFrame: NSRect? { diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchSurfaceView.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchSurfaceView.swift index da312669c..124d1c039 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchSurfaceView.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchSurfaceView.swift @@ -151,7 +151,7 @@ struct NotchSurfaceView: View { case .compact, .prehover: compactContent case .peek: - peekContent + toastContent case .expanded: expandedContent case .attention: @@ -173,20 +173,16 @@ struct NotchSurfaceView: View { } } - /// Split around the hardware cutout: identity on the left ear, live status - /// on the right. Nothing is ever drawn under the cutout itself. + /// Split around the hardware cutout: the agents at work on the left ear, + /// the account's counts on the right. Nothing is ever drawn under the + /// cutout itself. private func physicalCompactContent(notchWidth: Double) -> some View { let reserved = min(size.width - 120, notchWidth + 14) let earWidth = max(64, (size.width - reserved) / 2) return HStack(spacing: 0) { HStack(spacing: 7) { Spacer(minLength: 0) - Text(compactIdentityLabel) - .font(.system(size: ADE.fsXs, weight: .semibold)) - .foregroundStyle(ADE.fg) - .lineLimit(1) - .truncationMode(.tail) - ProviderMark(item: item, status: status, diameter: 18, active: isMarkActive, reducedMotion: reduceMotion) + compactIdentityCluster } .padding(.leading, 10) .padding(.trailing, 7) @@ -209,99 +205,181 @@ struct NotchSurfaceView: View { private var floatingCompactContent: some View { HStack(spacing: 8) { - ProviderMark(item: item, status: status, diameter: 18, active: isMarkActive, reducedMotion: reduceMotion) - // The identity is the only elastic element: it truncates so the - // status never collapses to an ellipsis. - Text(compactIdentityLabel) - .font(.system(size: ADE.fsSm, weight: .semibold)) - .foregroundStyle(ADE.fg) - .lineLimit(1) - .truncationMode(.tail) + compactIdentityCluster Spacer(minLength: 4) + if showsTicker { + NotchTickerView(items: model.tickerItems, hideDetails: model.settings.hideDetails) + .frame(maxWidth: 150) + } compactStatusCluster } .padding(.horizontal, 13) .frame(height: CGFloat(size.height)) } - /// Status is short, fixed, and always fully legible. + /// Up to three agent marks. With N sessions running, one item's name and + /// elapsed time is a lie about the other N-1 — the marks say "these are the + /// agents at work" without claiming to be all of them. + private var compactIdentityCluster: some View { + let leading = model.leadingItems + return HStack(spacing: leading.isEmpty ? 7 : -4) { + if leading.isEmpty { + ProviderMark( + item: nil, + status: status, + diameter: 18, + active: false, + reducedMotion: reduceMotion + ) + Text(status?.compactLabel ?? "ADE") + .font(.system(size: ADE.fsXs, weight: .semibold)) + .foregroundStyle(ADE.fg) + .lineLimit(1) + .truncationMode(.tail) + } else { + ForEach(leading) { leadingItem in + ProviderMark( + item: leadingItem, + status: nil, + diameter: 16, + active: leadingItem.isAttention, + reducedMotion: reduceMotion + ) + .overlay { + RoundedRectangle(cornerRadius: 16 * 0.3, style: .continuous) + .stroke(hasPhysicalNotch ? Color.black : ADE.bg, lineWidth: 1.4) + } + } + } + } + .accessibilityHidden(true) + } + + /// The account's shape, not one row's: `● 5` live and `⚠ 2 need you`. Short, + /// fixed, and always fully legible. private var compactStatusCluster: some View { - HStack(spacing: 5) { + let counts = model.counts + let liveCount = counts.working + counts.needsYou + return HStack(spacing: 6) { if status?.isProblem == true, item != nil { // Items are still showing, but they may be stale. Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 7.5, weight: .bold)) .foregroundStyle(notchToneColor(status?.tone ?? .amber)) } - Circle() - .fill(toneColor) - .frame(width: 5, height: 5) - .shadow(color: toneColor.opacity(0.6), radius: reduceMotion ? 0 : 2) - Text(compactStatusLabel) - .font(.system(size: ADE.fs2xs, weight: .semibold)) - .foregroundStyle(toneColor) - .lineLimit(1) - .truncationMode(.tail) - if let item { - ElapsedTimeLabel(isoDate: item.occurredAt) - .fixedSize() + if liveCount == 0 { + Text(compactStatusLabel) + .font(.system(size: ADE.fs2xs, weight: .semibold)) + .foregroundStyle(toneColor) + .lineLimit(1) + .truncationMode(.tail) + } else { + CountChip( + symbol: "circle.fill", + symbolSize: 5, + text: "\(liveCount)", + tone: notchToneColor(.blue), + pulses: !reduceMotion && counts.working > 0 + ) + if counts.needsYou > 0 { + CountChip( + symbol: "exclamationmark.triangle.fill", + symbolSize: 8, + text: "\(counts.needsYou) need\(counts.needsYou == 1 ? "s" : "") you", + tone: notchToneColor(.amber), + pulses: false + ) + } } } .layoutPriority(1) + .accessibilityElement(children: .ignore) + .accessibilityLabel(countsAccessibilityLabel) } - // MARK: - Peek + // MARK: - Toast + // + // This is the old peek layout. Hover no longer opens it — a hover that grew + // into a card competed with the toast it looked identical to — so the 316×76 + // geometry now belongs to events, and to the short card a click opens when + // the tall panel is off. - private var peekContent: some View { - HStack(spacing: 11) { - ProviderMark(item: item, status: status, diameter: 26, active: isMarkActive, reducedMotion: reduceMotion) - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 8) { - Text(peekTitle) - .font(.system(size: ADE.fsMd, weight: .semibold)) - .foregroundStyle(ADE.fg) - .lineLimit(1) - Spacer(minLength: 4) - Text(peekStatusLabel) - .font(.system(size: ADE.fs2xs, weight: .bold)) - .foregroundStyle(toneColor) - .lineLimit(1) - } - if let progress = itemPresentation?.planProgress, progress.total > 0 { - PlanProgressBar(progress: progress, tone: toneColor) - } else { - Text(peekSubtitle) - .font(.system(size: ADE.fsXs, weight: .medium)) - .foregroundStyle(ADE.secondaryFg) - .lineLimit(1) + @ViewBuilder + private var toastContent: some View { + if let toast = model.toastPresentation { + let tone = notchToneColor(toast.resolvedTone) + HStack(spacing: 11) { + ToastGlyph(treatment: toast.treatment, tone: tone, reducedMotion: reduceMotion) + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 8) { + Text(toast.title) + .font(.system(size: ADE.fsMd, weight: .semibold)) + .foregroundStyle(ADE.fg) + .lineLimit(1) + Spacer(minLength: 4) + Text(toastStatusLabel(for: toast)) + .font(.system(size: ADE.fs2xs, weight: .bold)) + .foregroundStyle(tone) + .lineLimit(1) + } + if let progress = itemPresentation?.planProgress, + progress.total > 0, + model.activeToast == nil { + PlanProgressBar(progress: progress, tone: tone) + } else if let subtitle = toast.subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: ADE.fsXs, weight: .medium)) + .foregroundStyle(ADE.secondaryFg) + .lineLimit(1) + } } } + .padding(.horizontal, 15) + .padding(.top, 9) + } + } + + /// The phase the toast is about, or the treatment's own word when it is not + /// tied to a row that is still on screen. + private func toastStatusLabel(for toast: AttentionToast) -> String { + if let itemId = toast.itemId, + let match = model.items.first(where: { $0.id == itemId }) { + return match.statusLabel + } + switch toast.treatment { + case .celebration: return "Merged" + case .success: return "Done" + case .alert: return "Needs you" + case .info: return status?.compactLabel ?? "Update" } - .padding(.horizontal, 15) - .padding(.top, 9) } // MARK: - Expanded + /// A scrolling list of every row the frame carried, filed under the same + /// three headings as the desktop popover. The pager it replaced showed one + /// card at a time, which was unusable the moment the feed went account-wide. private var expandedContent: some View { VStack(spacing: 0) { expandedHeader Rectangle().fill(ADE.hairline).frame(height: 0.8) // Only a banner when items are still on screen and may be stale; // with no items the body below already carries the same copy. - if let status, status.isProblem, item != nil { + if let status, status.isProblem, !model.items.isEmpty { StatusBanner(status: status) Rectangle().fill(ADE.hairline).frame(height: 0.8) } - if let item { - expandedItemBody(item: item) - Spacer(minLength: 4) - actionBar - .padding(.horizontal, 15) - .padding(.bottom, 14) - } else if let status { - StatusBody(status: status) + if model.items.isEmpty { + if let status { + StatusBody(status: status) + } else { + AllClearBody() + } + } else { + expandedList } + Rectangle().fill(ADE.hairline).frame(height: 0.8) + expandedFooter } } @@ -309,7 +387,7 @@ struct NotchSurfaceView: View { HStack(spacing: 10) { AttentionGlyph(tone: surfaceTone) VStack(alignment: .leading, spacing: 2) { - Text("ADE Attention Center") + Text("Activity") .font(.system(size: ADE.fsMd, weight: .semibold)) .foregroundStyle(ADE.fg) Text(accountScopeLabel) @@ -317,91 +395,80 @@ struct NotchSurfaceView: View { .foregroundStyle(ADE.mutedFg) } Spacer(minLength: 8) - if model.items.count > 1 { - navigationControls + Button { + model.openSettings() + } label: { + Image(systemName: "gearshape") } + .buttonStyle(NotchIconButtonStyle()) + .accessibilityLabel("Activity settings") + .accessibilityHint("Opens Activity settings in ADE") } .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 12) } - private func expandedItemBody(item: AttentionItem) -> some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .top, spacing: 10) { - VStack(alignment: .leading, spacing: 3) { - Text(itemPresentation?.title ?? "ADE attention") - .font(.system(size: ADE.fsLg, weight: .semibold)) - .foregroundStyle(ADE.fg) - .lineLimit(2) - Text(itemPresentation?.scopeLabel ?? "Account-wide activity") - .font(.system(size: ADE.fsXs, weight: .medium)) - .foregroundStyle(ADE.mutedFg) - .lineLimit(1) - } - Spacer(minLength: 8) - PhasePill(label: item.statusLabel, tone: surfaceTone) + private var expandedList: some View { + let sections = model.sections + return ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { + expandedSection("Needs you", tone: .amber, items: sections.needsYou) + expandedSection("Working", tone: .blue, items: sections.working) + expandedSection("Done", tone: .emerald, items: sections.done) } + .padding(.bottom, 6) + } + .scrollIndicators(.automatic) + .frame(maxHeight: .infinity) + } - Text(model.visiblePreview) - .font(.system(size: ADE.fsSm, weight: .regular)) - .foregroundStyle(ADE.secondaryFg) - .lineLimit(2) - .frame(maxWidth: .infinity, alignment: .leading) - - if let progress = itemPresentation?.planProgress, progress.total > 0 { - VStack(alignment: .leading, spacing: 5) { - HStack(spacing: 8) { - Text(progress.current ?? "Plan progress") - .lineLimit(1) - Spacer(minLength: 4) - Text("\(progress.completed)/\(progress.total)") - .monospacedDigit() - } - .font(.system(size: ADE.fs2xs, weight: .medium)) - .foregroundStyle(ADE.mutedFg) - PlanProgressBar(progress: progress, tone: toneColor) - } - } else if let activity = itemPresentation?.recentActivity, !activity.isEmpty { - VStack(alignment: .leading, spacing: 4) { - ForEach(Array(activity.prefix(2).enumerated()), id: \.offset) { _, line in - HStack(alignment: .firstTextBaseline, spacing: 7) { - Circle() - .fill(toneColor.opacity(0.75)) - .frame(width: 3.5, height: 3.5) - Text(line) - .font(.system(size: ADE.fsXs, weight: .regular)) - .foregroundStyle(ADE.mutedFg) - .lineLimit(1) - } - } + @ViewBuilder + private func expandedSection( + _ label: String, + tone: NotchStatusTone, + items: [AttentionItem] + ) -> some View { + if !items.isEmpty { + Section { + ForEach(items) { sectionItem in + NotchActivityRow( + item: sectionItem, + hideDetails: model.settings.hideDetails, + selected: sectionItem.id == model.selectedItem?.id, + reducedMotion: reduceMotion, + onOpen: { model.open(sectionItem) }, + onDismiss: { model.dismiss(sectionItem) }, + onFocus: { model.focus(sectionItem) } + ) } + } header: { + SectionHeader(label: label, count: items.count, tone: tone) } } - .padding(.horizontal, 16) - .padding(.top, 13) } - private var actionBar: some View { + private var expandedFooter: some View { HStack(spacing: 8) { - if model.items.count > 1 { - Text("\(model.interaction.selectedIndex + 1) of \(model.items.count)") + if model.overflowCount > 0 { + Text("+\(model.overflowCount) more") .font(.system(size: ADE.fs2xs, weight: .medium)) .foregroundStyle(ADE.mutedFg) .monospacedDigit() - .padding(.leading, 2) } Spacer(minLength: 4) secondaryActionButtons Button { - model.openSelected() + model.openActivity() } label: { - Label("Open in ADE", systemImage: "arrow.up.forward") + Label("Open all in ADE", systemImage: "arrow.up.forward") .labelStyle(.titleAndIcon) } .buttonStyle(NotchButtonStyle(prominent: true)) - .accessibilityHint("Opens the exact agent or pull request in ADE") + .accessibilityHint("Opens Activity in ADE") } + .padding(.horizontal, 15) + .padding(.vertical, 11) } /// `model.navigationActions` already drops a plain `open`, which the @@ -417,42 +484,26 @@ struct NotchSurfaceView: View { } } - private var navigationControls: some View { - HStack(spacing: 4) { - Button { - model.navigate(delta: -1) - } label: { - Image(systemName: "chevron.left") - } - .accessibilityLabel("Previous attention item") - Button { - model.navigate(delta: 1) - } label: { - Image(systemName: "chevron.right") - } - .accessibilityLabel("Next attention item") - } - .buttonStyle(NotchIconButtonStyle()) - } - // MARK: - Attention / celebration private var attentionContent: some View { - VStack(alignment: .leading, spacing: 9) { + let toast = model.toastPresentation + let tone = toast.map { notchToneColor($0.resolvedTone) } ?? toneColor + return VStack(alignment: .leading, spacing: 9) { HStack(spacing: 10) { ProviderMark(item: item, status: status, diameter: 26, active: true, reducedMotion: reduceMotion) VStack(alignment: .leading, spacing: 2) { - Text(item?.statusLabel ?? status?.title ?? "Needs you") + Text(toast.map(toastStatusLabel(for:)) ?? item?.statusLabel ?? "Needs you") .font(.system(size: ADE.fs2xs, weight: .bold)) - .foregroundStyle(toneColor) - Text(itemPresentation?.title ?? "ADE needs your attention") + .foregroundStyle(tone) + Text(toast?.title ?? itemPresentation?.title ?? "ADE needs your attention") .font(.system(size: ADE.fsSm + 1, weight: .semibold)) .foregroundStyle(ADE.fg) .lineLimit(1) } Spacer(minLength: 4) } - Text(model.visiblePreview) + Text(toast?.subtitle ?? model.visiblePreview) .font(.system(size: ADE.fsXs, weight: .regular)) .foregroundStyle(ADE.secondaryFg) .lineLimit(2) @@ -482,10 +533,10 @@ struct NotchSurfaceView: View { .font(.system(size: 26, weight: .semibold)) .symbolRenderingMode(.palette) .foregroundStyle(ADE.bg, notchToneColor(.emerald)) - Text("Merged") + Text(model.activeToast.map(toastStatusLabel(for:)) ?? "Merged") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(ADE.fg) - Text(itemPresentation?.celebrationTitle ?? "Pull request merged") + Text(model.toastPresentation?.title ?? itemPresentation?.celebrationTitle ?? "Pull request merged") .font(.system(size: ADE.fsXs, weight: .medium)) .foregroundStyle(ADE.mutedFg) .lineLimit(1) @@ -566,47 +617,54 @@ struct NotchSurfaceView: View { item?.isAttention == true || state == .prehover || state == .peek } - // MARK: - Copy - - private var compactIdentityLabel: String { - itemPresentation?.compactIdentity ?? "ADE" + /// The pinned strip is the only mode that keeps a bar on screen at rest, so + /// it is the only one with anywhere to run a ticker. + private var showsTicker: Bool { + model.settings.tickerEnabled + && model.settings.revealMode == .minimal + && !reduceMotion + && !model.tickerItems.isEmpty } + // MARK: - Copy + /// The canonical phase vocabulary from the renderer; no shortened synonyms. + /// Only used when the account has nothing live to count. private var compactStatusLabel: String { - item?.statusLabel ?? status?.compactLabel ?? "Ready" - } - - private var peekTitle: String { - itemPresentation?.title ?? status?.title ?? "ADE Attention Center" + if model.counts.done > 0 { return "\(model.counts.done) done" } + return status?.compactLabel ?? "All clear" } - private var peekSubtitle: String { - item == nil ? (status?.message ?? "ADE is ready") : model.visiblePreview - } - - private var peekStatusLabel: String { - item?.statusLabel ?? status?.compactLabel ?? "Ready" + private var countsAccessibilityLabel: String { + let counts = model.counts + var parts: [String] = [] + if counts.needsYou > 0 { + parts.append("\(counts.needsYou) need\(counts.needsYou == 1 ? "s" : "") you") + } + if counts.working > 0 { parts.append("\(counts.working) working") } + if counts.done > 0 { parts.append("\(counts.done) done") } + return parts.isEmpty ? "All agents idle" : parts.joined(separator: ", ") } private var accountScopeLabel: String { if let status, model.items.isEmpty { - return status.isProblem ? "Account attention unavailable" : "Account-wide activity" + return status.isProblem ? "Activity unavailable" : "Account-wide activity" } if model.settings.hideDetails { return "Account-wide activity" } - return attentionScopeSummary( - itemCount: model.items.count, - projectCount: Set(model.items.map(\.project.projectId)).count, - machineCount: Set(model.items.map(\.machine.machineKey)).count - ) + let counts = model.counts + return [ + attentionPluralized(counts.total, "session"), + "\(counts.machinesOnline)/\(counts.machinesTotal) machines online", + ].joined(separator: " · ") } private var accessibilitySummary: String { + if state == .expanded { return "Activity. \(countsAccessibilityLabel)" } if let presentation = itemPresentation { return presentation.accessibilitySummary } if let status { return "\(status.title). \(status.message)" } - return "ADE Attention Center" + return "ADE Activity" } private var accessibilityHint: String { @@ -614,8 +672,8 @@ struct NotchSurfaceView: View { return "Press Escape to close" } return model.settings.expandedPanelEnabled - ? "Click to expand ADE Attention Center" - : "Click to preview ADE Attention Center" + ? "Click to open Activity" + : "Click to preview Activity" } } @@ -787,6 +845,292 @@ private struct ProviderMark: View { } } +/// The Swift mirror of the renderer's compact `ActivityCard`: provider mark, +/// status dot + label + elapsed, title, lane, machine. Same anatomy and the +/// same one-hue-one-meaning table, so a row reads identically in the notch and +/// in the desktop popover. +private struct NotchActivityRow: View { + let item: AttentionItem + let hideDetails: Bool + let selected: Bool + let reducedMotion: Bool + let onOpen: () -> Void + let onDismiss: () -> Void + let onFocus: () -> Void + + @State private var hovering = false + + var body: some View { + let presentation = item.presentation(hideDetails: hideDetails) + let tone = notchStatusColor(for: item.phase) + Button(action: onOpen) { + HStack(alignment: .top, spacing: 9) { + ProviderMark( + item: item, + status: nil, + diameter: 20, + active: item.isAttention && !reducedMotion, + reducedMotion: reducedMotion + ) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(presentation.title) + .font(.system(size: ADE.fsSm, weight: .medium)) + .foregroundStyle(ADE.fg) + .lineLimit(1) + Spacer(minLength: 4) + Circle() + .fill(tone) + .frame(width: 4.5, height: 4.5) + Text(item.statusLabel) + .font(.system(size: ADE.fs2xs, weight: .semibold)) + .foregroundStyle(tone) + .lineLimit(1) + // `statusSince` is immutable for the life of a phase; + // `occurredAt` is the honest approximation while a + // publisher predates it. + ElapsedTimeLabel(isoDate: item.elapsedAnchor) + .fixedSize() + } + HStack(spacing: 6) { + Text(laneLabel) + .font(.system(size: ADE.fsXs, weight: .medium)) + .foregroundStyle(ADE.mutedFg) + .lineLimit(1) + if !presentation.preview.isEmpty { + Text("·").foregroundStyle(ADE.mutedFg.opacity(0.5)) + Text(presentation.preview) + .font(.system(size: ADE.fsXs, weight: .regular)) + .italic() + .foregroundStyle(ADE.secondaryFg.opacity(0.85)) + .lineLimit(1) + } + Spacer(minLength: 4) + MachineChip(machine: item.machine, hideDetails: hideDetails) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, alignment: .leading) + .background(rowBackground) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + // An offline machine's rows are last-known state, not observed state. + .opacity(item.machine.online ? 1 : 0.55) + .onHover { inside in + hovering = inside + if inside { onFocus() } + } + .overlay(alignment: .trailing) { + if hovering { + Button(action: onDismiss) { + Image(systemName: "xmark") + } + .buttonStyle(NotchIconButtonStyle()) + .padding(.trailing, 6) + .accessibilityLabel("Dismiss \(presentation.title)") + } + } + .contextMenu { + Button("Open in ADE", action: onOpen) + Button("Dismiss", action: onDismiss) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(presentation.accessibilitySummary) + .accessibilityAddTraits(.isButton) + } + + private var laneLabel: String { + if hideDetails { return "Private" } + let lane = item.laneName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return lane.isEmpty ? item.project.name : lane + } + + @ViewBuilder + private var rowBackground: some View { + if selected || hovering { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(.white.opacity(hovering ? 0.06 : 0.035)) + .padding(.horizontal, 8) + } + } +} + +/// Neutral by design: amber means "your move" everywhere in Activity, so a +/// machine chip may never borrow it for identity. +private struct MachineChip: View { + let machine: AttentionMachine + let hideDetails: Bool + + var body: some View { + if hideDetails { + EmptyView() + } else { + HStack(spacing: 3) { + Image(systemName: portable ? "laptopcomputer" : "desktopcomputer") + .font(.system(size: 8, weight: .medium)) + Text(machine.name) + .font(.system(size: ADE.fs2xs, weight: .medium)) + .lineLimit(1) + } + .foregroundStyle(ADE.mutedFg.opacity(machine.online ? 0.75 : 0.4)) + .padding(.horizontal, 5) + .padding(.vertical, 1.5) + .background(.white.opacity(0.04), in: Capsule()) + } + } + + /// A read of the name, not a hardware fact — decoration either way. + private var portable: Bool { + machine.name.range( + of: "macbook|laptop|air|book", + options: [.regularExpression, .caseInsensitive] + ) != nil + } +} + +private struct SectionHeader: View { + let label: String + let count: Int + let tone: NotchStatusTone + + var body: some View { + HStack(spacing: 6) { + Text(label.uppercased()) + .font(.system(size: 8.5, weight: .heavy)) + .tracking(0.6) + .foregroundStyle(notchToneColor(tone)) + Text("\(count)") + .font(.system(size: 8.5, weight: .bold)) + .monospacedDigit() + .foregroundStyle(ADE.mutedFg) + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.top, 9) + .padding(.bottom, 5) + .frame(maxWidth: .infinity, alignment: .leading) + .background(ADE.bg.opacity(0.94)) + } +} + +/// The pinned strip's ticker: what each live agent is doing, one at a time. +/// Gated on the ticker setting and on reduced motion by its caller — a +/// cross-fading strip is exactly the kind of ambient movement that setting is +/// about. +private struct NotchTickerView: View { + let items: [AttentionItem] + let hideDetails: Bool + + private static let intervalSeconds: Double = 4 + + var body: some View { + TimelineView(.periodic(from: .now, by: Self.intervalSeconds)) { timeline in + if let current = item(at: timeline.date) { + Text(current.presentation(hideDetails: hideDetails).preview) + .font(.system(size: ADE.fs2xs, weight: .medium)) + .foregroundStyle(ADE.mutedFg) + .lineLimit(1) + .truncationMode(.tail) + .id(current.id) + .transition(.opacity) + .animation(.easeInOut(duration: 0.35), value: current.id) + } + } + .accessibilityHidden(true) + } + + private func item(at date: Date) -> AttentionItem? { + guard !items.isEmpty else { return nil } + let step = Int(date.timeIntervalSinceReferenceDate / Self.intervalSeconds) + return items[((step % items.count) + items.count) % items.count] + } +} + +/// `● 5` / `⚠ 2 need you` — the account's shape in the space of a phase label. +private struct CountChip: View { + let symbol: String + let symbolSize: CGFloat + let text: String + let tone: Color + let pulses: Bool + + var body: some View { + HStack(spacing: 3.5) { + TimelineView(.animation(minimumInterval: 1 / 20, paused: !pulses)) { timeline in + let pulse = pulses + ? (sin(timeline.date.timeIntervalSinceReferenceDate * 3.2) + 1) / 2 + : 0 + Image(systemName: symbol) + .font(.system(size: symbolSize, weight: .bold)) + .foregroundStyle(tone) + .shadow(color: tone.opacity(0.6), radius: 1 + pulse * 2) + } + Text(text) + .font(.system(size: ADE.fs2xs, weight: .semibold)) + .foregroundStyle(tone) + .monospacedDigit() + .lineLimit(1) + } + } +} + +private struct ToastGlyph: View { + let treatment: NotchToastTreatment + let tone: Color + let reducedMotion: Bool + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(tone.opacity(0.16)) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(tone.opacity(0.32), lineWidth: 0.8) + } + Image(systemName: symbolName) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(tone) + } + .frame(width: 26, height: 26) + .accessibilityHidden(true) + } + + private var symbolName: String { + switch treatment { + case .celebration: return "checkmark.seal.fill" + case .success: return "checkmark.circle.fill" + case .alert: return "exclamationmark.triangle.fill" + case .info: return "bell.fill" + } + } +} + +/// Nothing wrong, nothing running — said plainly rather than left blank, so an +/// empty panel never reads as a broken one. +private struct AllClearBody: View { + var body: some View { + VStack(spacing: 8) { + Spacer(minLength: 0) + Image(systemName: "moon.zzz") + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(ADE.mutedFg.opacity(0.7)) + Text("All agents idle.") + .font(.system(size: ADE.fsSm + 1, weight: .semibold)) + .foregroundStyle(ADE.fg) + Text("Nothing is running anywhere on your account.") + .font(.system(size: ADE.fsXs, weight: .regular)) + .foregroundStyle(ADE.secondaryFg) + .multilineTextAlignment(.center) + Spacer(minLength: 0) + } + .padding(.horizontal, 26) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + /// ADE's mark for the panel header: the accent gradient tile the app uses for /// its own identity, tinted by the current tone. private struct AttentionGlyph: View { diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchViewModel.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchViewModel.swift index dc0cd3963..2ba891097 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchViewModel.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchViewModel.swift @@ -3,30 +3,35 @@ import Combine import Foundation import ADEAttentionNotchCore +/// Transients are host-driven since the Activity revamp. +/// +/// The helper used to synthesise its own alerts by diffing item fingerprints, +/// which fired on every cosmetic republish. The renderer now owns that decision +/// (`useAttentionSync`'s toast emitter) because only it can see the account's +/// delivery policy, the per-item 10-minute cooldown, and the global rate limit. +/// The machinery below is unchanged; the trigger moved. @MainActor final class NotchViewModel: ObservableObject { - private enum DeferredTransient { - case attention(itemId: String) - case celebration(itemId: String) - } @Published private(set) var items: [AttentionItem] = [] @Published private(set) var interaction = NotchInteractionState() @Published private(set) var pointerInside = false @Published private(set) var settings = NotchSettings() @Published private(set) var availability: AttentionAvailability? + @Published private(set) var counts = AttentionCounts() + /// The event currently being shown as a transient, if any. Cleared when the + /// transient settles so a later hover cannot resurrect stale news. + @Published private(set) var activeToast: AttentionToast? var emit: (NotchOutput) -> Void = { _ in } var requestReanchor: () -> Void = {} var requestQuit: () -> Void = {} - private var peekTask: Task? private var closeTask: Task? private var transientTask: Task? - private var fingerprintsById: [String: String] = [:] private var hostVisibilityRequested = true private var hoveredItemId: String? - private var deferredTransient: DeferredTransient? + private var deferredTransient: AttentionToast? private var snapshotCursor = AttentionSnapshotCursor() var selectedItem: AttentionItem? { @@ -34,6 +39,24 @@ final class NotchViewModel: ObservableObject { return items[interaction.selectedIndex] } + /// The panel's three sections, filed exactly as the desktop popover files + /// them. Recomputed from `items` rather than cached: the list is capped at + /// the host's 48-row projection, so this is a trivial pass. + var sections: NotchActivitySections { notchActivitySections(items) } + + /// Rows the hover strip's avatars are drawn from: the highest-priority + /// work, which is what someone glancing at the notch is looking for. + var leadingItems: [AttentionItem] { Array(sections.live.prefix(3)) } + + /// What the pinned ticker cycles. Empty means the strip stays still. + var tickerItems: [AttentionItem] { + guard settings.tickerEnabled, settings.revealMode == .minimal else { return [] } + return Array(sections.live.prefix(8)) + } + + /// Rows the account has that this frame did not carry. + var overflowCount: Int { counts.overflow(shownItemCount: items.count) } + /// How far the user lets the surface grow, and what opens it. var policy: NotchPresentationPolicy { NotchPresentationPolicy(settings: settings) } @@ -73,6 +96,49 @@ final class NotchViewModel: ObservableObject { notchSecondaryActions(selectedItem?.actions ?? []) } + /// What the transient card shows. A live toast wins; otherwise this is the + /// short card a click opens in compact mode, so the layout is never empty. + var toastPresentation: AttentionToast? { + if let activeToast { + guard !settings.hideDetails else { + return AttentionToast( + itemId: activeToast.itemId, + eventKind: activeToast.eventKind, + treatment: activeToast.treatment, + title: activeToast.itemId.flatMap { id in + items.first(where: { $0.id == id })? + .presentation(hideDetails: true).title + } ?? "ADE update", + subtitle: activeToast.itemId.flatMap { id in + items.first(where: { $0.id == id })?.privacyPreview + }, + tone: activeToast.tone, + durationMs: activeToast.durationMs + ) + } + return activeToast + } + guard let item = selectedItem else { + guard let status = statusPresentation else { return nil } + return AttentionToast( + eventKind: "status", + treatment: status.isProblem ? .alert : .info, + title: status.title, + subtitle: status.message, + tone: status.tone.rawValue + ) + } + let presentation = item.presentation(hideDetails: settings.hideDetails) + return AttentionToast( + itemId: item.id, + eventKind: item.eventKind, + treatment: item.isAttention ? .alert : .info, + title: presentation.title, + subtitle: presentation.preview, + tone: notchStatusTone(for: item.phase).rawValue + ) + } + func handle(_ input: NotchInput) { switch input { case .snapshot(let snapshot): @@ -82,6 +148,8 @@ final class NotchViewModel: ObservableObject { setVisible(settings.enabled && hostVisibilityRequested) applyPresentationPolicy() requestReanchor() + case .toast(let toast): + present(toast) case .visibility(let visible): hostVisibilityRequested = visible setVisible(settings.enabled && visible) @@ -96,11 +164,14 @@ final class NotchViewModel: ObservableObject { let acceptance = snapshotCursor.accept(snapshot) guard acceptance != .rejectedStale else { return } if case .accepted(resetPresentationState: true) = acceptance { - fingerprintsById.removeAll() + // An account switch: news from the previous account may not be + // waiting to interrupt the new one. deferredTransient = nil + activeToast = nil transientTask?.cancel() } availability = snapshot.availability + counts = snapshot.resolvedCounts() let focusedItemId = pointerInside ? hoveredItemId : selectedItem?.id var deduplicated: [String: AttentionItem] = [:] for item in snapshot.items where item.contractVersion == 1 { @@ -110,9 +181,6 @@ final class NotchViewModel: ObservableObject { deduplicated[item.id] = item } let sorted = sortedAttentionItems(Array(deduplicated.values)) - let changed = sorted.filter { fingerprintsById[$0.id] != $0.fingerprint } - let initialSnapshot = fingerprintsById.isEmpty - fingerprintsById = Dictionary(uniqueKeysWithValues: sorted.map { ($0.id, $0.fingerprint) }) items = sorted var next = interaction @@ -124,45 +192,36 @@ final class NotchViewModel: ObservableObject { } interaction = next - guard !sorted.isEmpty else { - transientTask?.cancel() - deferredTransient = nil - hoveredItemId = nil - // Draining to zero is not a reason to yank the surface out from - // under the pointer or out of a panel the user opened: those states - // now render the empty/error copy instead. - if interaction.presentation == .attention || interaction.presentation == .celebration { - var settled = interaction - settled.finishTransient(pointerInside: pointerInside, policy: policy) - interaction = settled - } - return + guard sorted.isEmpty else { return } + transientTask?.cancel() + deferredTransient = nil + activeToast = nil + hoveredItemId = nil + // Draining to zero is not a reason to yank the surface out from under + // the pointer or out of a panel the user opened: those states render + // the empty/error copy instead. + if interaction.presentation == .attention || interaction.presentation == .celebration { + var settled = interaction + settled.finishTransient(pointerInside: pointerInside, policy: policy) + interaction = settled } + } - if settings.celebrationsEnabled, - let merged = changed.first(where: \.isCelebration), - (!initialSnapshot || isRecent(merged.occurredAt, within: 120)) { - if pointerInside { - deferredTransient = .celebration(itemId: merged.id) - return - } - selectItem(id: merged.id) - beginCelebration() + /// Shows one event. Celebrations honour the account's celebrations setting; + /// everything else rides the alert layout. A toast that arrives while the + /// pointer is on the surface waits rather than yanking the content out from + /// under it. + func present(_ toast: AttentionToast) { + if toast.treatment == .celebration, !settings.celebrationsEnabled { return } + if pointerInside { + deferredTransient = toast return } - - if let attention = changed.first(where: \.isAttention) { - if pointerInside { - deferredTransient = .attention(itemId: attention.id) - return - } - selectItem(id: attention.id) - beginAttention() - } + if let itemId = toast.itemId { selectItem(id: itemId) } + begin(toast) } func pointerChanged(isInside: Bool) { - peekTask?.cancel() closeTask?.cancel() if isInside { @@ -170,18 +229,8 @@ final class NotchViewModel: ObservableObject { pointerInside = true hoveredItemId = selectedItem?.id var next = interaction - let token = next.pointerEntered(hasItems: hasPresentableContent, policy: policy) + next.pointerEntered(hasItems: hasPresentableContent, policy: policy) interaction = next - // Nothing to schedule when the pointer is not allowed to open the - // peek: the delayed task would only ever be a no-op. - guard policy.allowsHoverReveal else { return } - peekTask = Task { [weak self] in - try? await Task.sleep(for: .milliseconds(145)) - guard !Task.isCancelled, let self else { return } - var delayed = self.interaction - delayed.applyPeek(generation: token, pointerInside: self.pointerInside) - self.interaction = delayed - } } else { guard pointerInside else { return } closeTask = Task { [weak self] in @@ -198,8 +247,8 @@ final class NotchViewModel: ObservableObject { } func toggleExpanded() { - peekTask?.cancel() transientTask?.cancel() + activeToast = nil var next = interaction next.explicitToggle(hasItems: hasPresentableContent, policy: policy) interaction = next @@ -212,17 +261,20 @@ final class NotchViewModel: ObservableObject { interaction = next } - func navigate(delta: Int) { - var next = interaction - next.navigate(delta: delta, itemCount: items.count) - interaction = next - if pointerInside { - hoveredItemId = selectedItem?.id - } + /// Focus a row the pointer is over, so "Open in ADE" and the tooltip agree + /// with what the user is looking at. The pager it replaced is gone: the + /// panel is a scrolling list now, not one card at a time. + func focus(_ item: AttentionItem) { + selectItem(id: item.id) + if pointerInside { hoveredItemId = item.id } } func openSelected() { guard let item = selectedItem else { return } + open(item) + } + + func open(_ item: AttentionItem) { emit(NotchOutput( type: "open", itemId: item.id, @@ -231,6 +283,20 @@ final class NotchViewModel: ObservableObject { )) } + /// Asks the host to file the row away. The helper never mutates the feed + /// itself — the next snapshot is what removes the row. + func dismiss(_ item: AttentionItem) { + emit(NotchOutput( + type: "dismiss_item", + itemId: item.id, + destination: item.destination + )) + } + + func openSettings() { + emit(NotchOutput(type: "open_settings")) + } + func openFor(_ action: AttentionAction) { guard let item = selectedItem else { return } emit(NotchOutput( @@ -242,7 +308,9 @@ final class NotchViewModel: ObservableObject { )) } - func openAttentionCenter() { + /// The wire name stays `open_center`: the host routes on it and the surface + /// only renamed what it calls the destination. + func openActivity() { emit(NotchOutput(type: "open_center")) } @@ -260,60 +328,52 @@ final class NotchViewModel: ObservableObject { } private func setVisible(_ visible: Bool) { - peekTask?.cancel() closeTask?.cancel() transientTask?.cancel() pointerInside = false hoveredItemId = nil deferredTransient = nil + activeToast = nil var next = interaction next.setVisible(visible) interaction = next } - private func beginAttention() { + private func begin(_ toast: AttentionToast) { transientTask?.cancel() // The cue still fires in compact/manual modes: the user asked the // surface to stay small, not to stop telling them something needs them. if settings.soundsEnabled { - NSSound(named: "Glass")?.play() + NSSound(named: toast.treatment == .celebration ? "Hero" : "Glass")?.play() } + activeToast = toast guard policy.allowsAutomaticReveal else { return } var next = interaction - next.setAttention(policy: policy) - interaction = next - transientTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(5)) - guard !Task.isCancelled, let self else { return } - var finished = self.interaction - finished.finishTransient(pointerInside: self.pointerInside, policy: self.policy) - self.interaction = finished - } - } - - private func beginCelebration() { - transientTask?.cancel() - if settings.soundsEnabled { - NSSound(named: "Hero")?.play() + if toast.treatment == .celebration { + next.setCelebration(policy: policy) + } else { + next.setAttention(policy: policy) } - guard policy.allowsAutomaticReveal else { return } - var next = interaction - next.setCelebration(policy: policy) interaction = next + let durationMs = toast.resolvedDurationMs transientTask = Task { [weak self] in - try? await Task.sleep(for: .milliseconds(1_650)) + try? await Task.sleep(for: .milliseconds(durationMs)) guard !Task.isCancelled, let self else { return } var finished = self.interaction finished.finishTransient(pointerInside: self.pointerInside, policy: self.policy) self.interaction = finished + self.activeToast = nil } } /// Applies the current settings to whatever is already on screen. private func applyPresentationPolicy() { + // Turning automatic reveal off mid-toast has to collapse what is on + // screen; otherwise the setting looks broken until the next event. if !policy.allowsAutomaticReveal { transientTask?.cancel() deferredTransient = nil + activeToast = nil } var next = interaction next.applyPolicy(policy) @@ -330,20 +390,11 @@ final class NotchViewModel: ObservableObject { private func presentDeferredTransientIfNeeded() { guard let deferredTransient else { return } self.deferredTransient = nil - switch deferredTransient { - case .attention(let itemId): - guard items.contains(where: { $0.id == itemId }) else { return } - selectItem(id: itemId) - beginAttention() - case .celebration(let itemId): - guard items.contains(where: { $0.id == itemId }) else { return } - selectItem(id: itemId) - beginCelebration() + // The row it was about may have drained while the pointer sat there. + if let itemId = deferredTransient.itemId, + !items.contains(where: { $0.id == itemId }) { + return } - } - - private func isRecent(_ value: String, within seconds: TimeInterval) -> Bool { - guard let date = parseAttentionDate(value) else { return false } - return abs(date.timeIntervalSinceNow) <= seconds + present(deferredTransient) } } diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/AttentionModels.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/AttentionModels.swift index 97bc82fbb..94b459845 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/AttentionModels.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/AttentionModels.swift @@ -211,10 +211,38 @@ public struct AttentionItem: Codable, Equatable, Sendable, Identifiable { public let actions: [AttentionAction] public let occurredAt: String public let updatedAt: String + /// Immutable for the life of a phase, so the row's elapsed ticker survives + /// the cosmetic republishes that churn `updatedAt` every poll. Absent from + /// publishers older than the Activity revamp — fall back to `occurredAt`. + public let statusSince: String? + /// `"signal" | "ambient" | "idle"`, decoded as a plain string so a tier + /// this build has never heard of degrades instead of costing us the item. + /// The wire name stays `activityTier`; `tier` is the local shorthand. + public let activityTier: String? public let seenAt: String? public let dismissedAt: String? public let expiresAt: String? + public var tier: String? { activityTier } + + /// Idle rows are disk-only roster history: quiet, never alerting, always + /// filed under Done no matter what phase they preserved. + public var isIdleTier: Bool { activityTier == "idle" } + + /// Only signal-tier rows may interrupt. Legacy items without a tier fall + /// back to the phase test the surface has always used. + public var isSignalTier: Bool { + guard let activityTier else { return isAttention } + return activityTier == "signal" + } + + /// What "Working 3s" counts from. `updatedAt` is deliberately not a + /// candidate: it churns on every cosmetic republish, so a ticker anchored + /// to it would reset itself every poll. + public var elapsedAnchor: String { + statusSince?.notchNonEmpty ?? occurredAt + } + public init( contractVersion: Int = 1, id: String, @@ -239,6 +267,8 @@ public struct AttentionItem: Codable, Equatable, Sendable, Identifiable { actions: [AttentionAction] = [], occurredAt: String, updatedAt: String, + statusSince: String? = nil, + activityTier: String? = nil, seenAt: String? = nil, dismissedAt: String? = nil, expiresAt: String? = nil @@ -266,6 +296,8 @@ public struct AttentionItem: Codable, Equatable, Sendable, Identifiable { self.actions = actions self.occurredAt = occurredAt self.updatedAt = updatedAt + self.statusSince = statusSince + self.activityTier = activityTier self.seenAt = seenAt self.dismissedAt = dismissedAt self.expiresAt = expiresAt @@ -593,6 +625,137 @@ public struct AttentionAvailability: Codable, Equatable, Sendable { public var isProblem: Bool { state != .ready } } +/// The whole account's shape, sent alongside a bounded projection of its items. +/// +/// Load-bearing: the host publishes only the top-priority slice (48 rows) to +/// stay inside the pipe's byte budget, so "5 working · 2 need you · 61 total" +/// can only be honest if the totals travel separately from the rows. +public struct AttentionCounts: Codable, Equatable, Sendable { + public let needsYou: Int + public let working: Int + public let done: Int + public let total: Int + public let machinesOnline: Int + public let machinesTotal: Int + + public init( + needsYou: Int = 0, + working: Int = 0, + done: Int = 0, + total: Int = 0, + machinesOnline: Int = 0, + machinesTotal: Int = 0 + ) { + self.needsYou = needsYou + self.working = working + self.done = done + self.total = total + self.machinesOnline = machinesOnline + self.machinesTotal = machinesTotal + } + + private enum CodingKeys: String, CodingKey { + case needsYou, working, done, total, machinesOnline, machinesTotal + } + + /// Totally decoding, like every other advisory block: a host that learns to + /// send a seventh count, or forgets one, must not cost us the snapshot. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + func count(_ key: CodingKeys) -> Int { + max(0, ((try? container.decodeIfPresent(Int.self, forKey: key)) ?? nil) ?? 0) + } + needsYou = count(.needsYou) + working = count(.working) + done = count(.done) + total = count(.total) + machinesOnline = count(.machinesOnline) + machinesTotal = count(.machinesTotal) + } + + /// How many rows the account has that this frame did not carry. + public func overflow(shownItemCount: Int) -> Int { + max(0, total - shownItemCount) + } +} + +/// Per-kind delight for an event that just happened, rendered as a transient +/// rather than a row. `celebration` and `alert` drive the two presentations the +/// surface already knows how to animate; `success` and `info` ride the alert +/// machinery with a calmer tone. +public enum NotchToastTreatment: String, Codable, Equatable, Sendable, CaseIterable { + case celebration + case success + case alert + case info + + /// A treatment this build has never heard of reads as ordinary news rather + /// than throwing — a decode failure here would be reported to the host as a + /// protocol error and latch the helper into "needs an update". + public init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = NotchToastTreatment(rawValue: raw) ?? .info + } + + /// Only a merge earns the confetti; everything else uses the alert layout. + public var presentation: NotchPresentationState { + self == .celebration ? .celebration : .attention + } + + public var defaultTone: NotchStatusTone { + switch self { + case .celebration, .success: return .emerald + case .alert: return .amber + case .info: return .blue + } + } + + /// Matches the transient timers the surface already runs. + public var defaultDurationMs: Int { + self == .celebration ? 1_650 : 5_000 + } +} + +public struct AttentionToast: Codable, Equatable, Sendable { + public let itemId: String? + public let eventKind: String + public let treatment: NotchToastTreatment + public let title: String + public let subtitle: String? + public let tone: String? + public let durationMs: Int? + + public init( + itemId: String? = nil, + eventKind: String, + treatment: NotchToastTreatment, + title: String, + subtitle: String? = nil, + tone: String? = nil, + durationMs: Int? = nil + ) { + self.itemId = itemId + self.eventKind = eventKind + self.treatment = treatment + self.title = title + self.subtitle = subtitle + self.tone = tone + self.durationMs = durationMs + } + + /// Host-chosen hue when it sent one, otherwise the treatment's own. + public var resolvedTone: NotchStatusTone { + tone.flatMap { NotchStatusTone(rawValue: $0) } ?? treatment.defaultTone + } + + /// Clamped so a drifted host cannot pin the surface open, or flash it so + /// briefly that it reads as a glitch. + public var resolvedDurationMs: Int { + guard let durationMs else { return treatment.defaultDurationMs } + return max(800, min(15_000, durationMs)) + } +} + public struct AttentionSnapshot: Codable, Equatable, Sendable { public let contractVersion: Int /// Revisions are monotonic only within one stream. Account switches and @@ -602,6 +765,10 @@ public struct AttentionSnapshot: Codable, Equatable, Sendable { public let generatedAt: String public let items: [AttentionItem] public let availability: AttentionAvailability? + /// The account's real totals, independent of how many rows this frame + /// carried. Absent from hosts older than the Activity revamp, in which case + /// the surface counts what it can see. + public let counts: AttentionCounts? public init( contractVersion: Int = 1, @@ -609,7 +776,8 @@ public struct AttentionSnapshot: Codable, Equatable, Sendable { revision: Int, generatedAt: String, items: [AttentionItem], - availability: AttentionAvailability? = nil + availability: AttentionAvailability? = nil, + counts: AttentionCounts? = nil ) { self.contractVersion = contractVersion self.streamId = streamId @@ -617,10 +785,11 @@ public struct AttentionSnapshot: Codable, Equatable, Sendable { self.generatedAt = generatedAt self.items = items self.availability = availability + self.counts = counts } private enum CodingKeys: String, CodingKey { - case contractVersion, streamId, revision, generatedAt, items, availability + case contractVersion, streamId, revision, generatedAt, items, availability, counts } public init(from decoder: Decoder) throws { @@ -635,6 +804,28 @@ public struct AttentionSnapshot: Codable, Equatable, Sendable { items = try container.decode([AttentionItem].self, forKey: .items) // Availability is advisory chrome. Never fail a snapshot over it. availability = (try? container.decodeIfPresent(AttentionAvailability.self, forKey: .availability)) ?? nil + counts = (try? container.decodeIfPresent(AttentionCounts.self, forKey: .counts)) ?? nil + } + + /// The counts the host sent, or an honest tally of the rows on hand when it + /// sent none. Never invents an overflow it cannot see. + public func resolvedCounts() -> AttentionCounts { + if let counts { return counts } + let sections = notchActivitySections(items) + var online = Set() + var machines = Set() + for item in items where item.dismissedAt == nil { + machines.insert(item.machine.machineKey) + if item.machine.online { online.insert(item.machine.machineKey) } + } + return AttentionCounts( + needsYou: sections.needsYou.count, + working: sections.working.count, + done: sections.done.count, + total: sections.total, + machinesOnline: online.count, + machinesTotal: machines.count + ) } } @@ -715,6 +906,11 @@ public struct NotchSettings: Codable, Equatable, Sendable { public var hideDetails: Bool public var celebrationsEnabled: Bool public var soundsEnabled: Bool + /// Whether an event may briefly open the surface by itself. The reveal mode + /// still wins: "click only" means only when I ask, in every case. + public var automaticRevealEnabled: Bool + /// Whether the pinned strip cycles what each live agent is doing. + public var tickerEnabled: Bool public init( enabled: Bool = false, @@ -723,7 +919,9 @@ public struct NotchSettings: Codable, Equatable, Sendable { preferredDisplayId: UInt32? = nil, hideDetails: Bool = true, celebrationsEnabled: Bool = true, - soundsEnabled: Bool = false + soundsEnabled: Bool = false, + automaticRevealEnabled: Bool = true, + tickerEnabled: Bool = true ) { self.enabled = enabled self.revealMode = revealMode @@ -732,11 +930,14 @@ public struct NotchSettings: Codable, Equatable, Sendable { self.hideDetails = hideDetails self.celebrationsEnabled = celebrationsEnabled self.soundsEnabled = soundsEnabled + self.automaticRevealEnabled = automaticRevealEnabled + self.tickerEnabled = tickerEnabled } private enum CodingKeys: String, CodingKey { case enabled, revealMode, expandedPanelEnabled, preferredDisplayId case hideDetails, celebrationsEnabled, soundsEnabled + case automaticRevealEnabled, tickerEnabled } /// Decoding is total. A host that predates the presentation keys keeps the @@ -758,6 +959,11 @@ public struct NotchSettings: Codable, Equatable, Sendable { ?? defaults.celebrationsEnabled soundsEnabled = ((try? container.decodeIfPresent(Bool.self, forKey: .soundsEnabled)) ?? nil) ?? defaults.soundsEnabled + automaticRevealEnabled = + ((try? container.decodeIfPresent(Bool.self, forKey: .automaticRevealEnabled)) ?? nil) + ?? defaults.automaticRevealEnabled + tickerEnabled = ((try? container.decodeIfPresent(Bool.self, forKey: .tickerEnabled)) ?? nil) + ?? defaults.tickerEnabled } } @@ -767,6 +973,8 @@ public struct NotchSettings: Codable, Equatable, Sendable { public enum NotchSettingsMenuAction: Equatable, Sendable { case setRevealMode(NotchRevealMode) case toggleExpandedPanel + case toggleAutomaticReveal + case toggleTicker case hide } @@ -780,6 +988,10 @@ public func applyingNotchSettingsMenuAction( next.revealMode = revealMode case .toggleExpandedPanel: next.expandedPanelEnabled.toggle() + case .toggleAutomaticReveal: + next.automaticRevealEnabled.toggle() + case .toggleTicker: + next.tickerEnabled.toggle() case .hide: next.enabled = false } @@ -789,6 +1001,7 @@ public func applyingNotchSettingsMenuAction( public enum NotchInput: Equatable, Sendable { case snapshot(AttentionSnapshot) case settings(NotchSettings) + case toast(AttentionToast) case visibility(Bool) case reanchor case quit @@ -798,6 +1011,7 @@ private struct CommandEnvelope: Decodable { let type: String let snapshot: AttentionSnapshot? let settings: NotchSettings? + let toast: AttentionToast? let visible: Bool? } @@ -812,6 +1026,9 @@ public enum NotchInputDecoder { case "settings": guard let settings = envelope.settings else { throw NotchProtocolError.missingPayload("settings") } return .settings(settings) + case "toast": + guard let toast = envelope.toast else { throw NotchProtocolError.missingPayload("toast") } + return .toast(toast) case "visibility": guard let visible = envelope.visible else { throw NotchProtocolError.missingPayload("visible") } return .visibility(visible) diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchGeometry.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchGeometry.swift index 9ba4daa42..bc86b2b50 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchGeometry.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchGeometry.swift @@ -19,7 +19,12 @@ public struct NotchRect: Equatable, Sendable { } public struct NotchDisplayGeometry: Equatable, Sendable { - public static let panelSize = NotchSize(width: 720, height: 460) + /// The transparent host every surface state is drawn into, top-aligned. + /// Sized for the tallest state (the scrollable expanded panel at 440pt of + /// surface below the menu-bar band) with room to spare, so growing a state + /// never needs a second window. Verified against a 13" MacBook's 1440×900 + /// by `NotchGeometryTests.testExpandedSurfaceFitsUnderA13InchMenuBar`. + public static let panelSize = NotchSize(width: 760, height: 640) public let displayId: UInt32 public let frame: NotchRect diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchInteractionState.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchInteractionState.swift index c2ee8b440..dc99a4378 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchInteractionState.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/NotchInteractionState.swift @@ -18,28 +18,46 @@ public enum NotchPresentationState: String, Codable, Equatable, Sendable { public struct NotchPresentationPolicy: Equatable, Sendable { public let revealMode: NotchRevealMode public let expandedPanelEnabled: Bool + public let automaticRevealEnabled: Bool + public let tickerEnabled: Bool public static let `default` = NotchPresentationPolicy() - public init(revealMode: NotchRevealMode = .hover, expandedPanelEnabled: Bool = true) { + public init( + revealMode: NotchRevealMode = .hover, + expandedPanelEnabled: Bool = true, + automaticRevealEnabled: Bool = true, + tickerEnabled: Bool = true + ) { self.revealMode = revealMode self.expandedPanelEnabled = expandedPanelEnabled + self.automaticRevealEnabled = automaticRevealEnabled + self.tickerEnabled = tickerEnabled } public init(settings: NotchSettings) { self.init( revealMode: settings.revealMode, - expandedPanelEnabled: settings.expandedPanelEnabled + expandedPanelEnabled: settings.expandedPanelEnabled, + automaticRevealEnabled: settings.automaticRevealEnabled, + tickerEnabled: settings.tickerEnabled ) } - /// Only hover mode lets the pointer alone open the peek. + /// Hover mode is the only one where the pointer alone changes the surface, + /// and since the Activity revamp it stops at prehover: the old peek-on-hover + /// is gone and its layout now belongs to event toasts. public var allowsHoverReveal: Bool { revealMode == .hover } - /// All three user-selectable modes are manual. In particular, "Reveal on - /// hover" is literal: a needs-you update may change notification/status - /// state, but it cannot make the hidden surface appear by itself. - public var allowsAutomaticReveal: Bool { false } + /// An event may open the surface by itself when the user allows it — except + /// in "click only", which is literal: nothing but a click opens anything. + public var allowsAutomaticReveal: Bool { + automaticRevealEnabled && revealMode != .click + } + + /// The ticker is a property of the pinned strip, so it only ever runs in the + /// mode that keeps a strip on screen at rest. + public var showsTicker: Bool { tickerEnabled && revealMode == .minimal } /// Compact mode never grows past a short peek. Other modes may open the /// tall panel unless the user disabled it globally. @@ -76,6 +94,9 @@ public struct NotchInteractionState: Equatable, Sendable { public init() {} + /// Hover stops here. Before the Activity revamp a 145ms timer promoted this + /// to `.peek`; the peek layout is now the toast's, and a hover that grew + /// into a card competed with the toast it looks identical to. @discardableResult public mutating func pointerEntered( hasItems: Bool, @@ -91,11 +112,6 @@ public struct NotchInteractionState: Equatable, Sendable { return generation } - public mutating func applyPeek(generation token: UInt64, pointerInside: Bool) { - guard token == generation, pointerInside, isVisible, presentation == .prehover else { return } - presentation = .peek - } - @discardableResult public mutating func pointerExited() -> UInt64 { generation &+= 1 @@ -138,22 +154,15 @@ public struct NotchInteractionState: Equatable, Sendable { presentation = .celebration } + /// A toast always settles back to the compact bar. `.peek` is the toast's + /// own layout now, so landing there would leave a card on screen with + /// nothing left to say. public mutating func finishTransient( pointerInside: Bool, policy: NotchPresentationPolicy = .default ) { generation &+= 1 - // Settling under a pointer that is not allowed to reveal anything has - // to land on compact, not on the peek hover never opened. - presentation = (pointerInside && policy.allowsHoverReveal) ? .peek : .compact - } - - public mutating func navigate(delta: Int, itemCount: Int) { - guard itemCount > 0 else { - selectedIndex = 0 - return - } - selectedIndex = (selectedIndex + delta % itemCount + itemCount) % itemCount + presentation = (pointerInside && policy.allowsHoverReveal) ? .prehover : .compact } public mutating func select(index: Int, itemCount: Int) { @@ -236,6 +245,57 @@ public func sortedAttentionItems(_ items: [AttentionItem]) -> [AttentionItem] { } } +/// The priority-flat three, mirroring `activityPriority.ts` in the renderer so +/// the panel files a row exactly where the desktop popover files it. +public struct NotchActivitySections: Equatable, Sendable { + public let needsYou: [AttentionItem] + public let working: [AttentionItem] + public let done: [AttentionItem] + + public init(needsYou: [AttentionItem], working: [AttentionItem], done: [AttentionItem]) { + self.needsYou = needsYou + self.working = working + self.done = done + } + + public var total: Int { needsYou.count + working.count + done.count } + public var isEmpty: Bool { total == 0 } + + /// Rows still doing something, in priority order — what the ticker cycles + /// and what the hover strip's live dot counts. + public var live: [AttentionItem] { needsYou + working } +} + +/// Mirrors `activitySectionId` in `activityPriority.ts`, including its rule that +/// an idle roster row is quiet history regardless of the phase it preserved. +public func notchActivitySectionId(for item: AttentionItem) -> String { + if item.isIdleTier { return "done" } + let priority = phasePriorities[item.phase] ?? 99 + if priority <= (phasePriorities["blocked"] ?? 2) { return "needs-you" } + if priority <= (phasePriorities["stale"] ?? 4) { return "working" } + return "done" +} + +public func notchActivitySections(_ items: [AttentionItem]) -> NotchActivitySections { + var needsYou: [AttentionItem] = [] + var working: [AttentionItem] = [] + var done: [AttentionItem] = [] + for item in sortedAttentionItems(items) { + switch notchActivitySectionId(for: item) { + case "needs-you": needsYou.append(item) + case "working": working.append(item) + default: done.append(item) + } + } + // Idle roster history is the ambient tail even when its preserved phase has + // a numerically higher priority than a fresh completed outcome. + return NotchActivitySections( + needsYou: needsYou, + working: working, + done: done.filter { !$0.isIdleTier } + done.filter(\.isIdleTier) + ) +} + /// Height of the menu-bar band the hardware notch lives in. The surface's top /// `band` points sit *inside* that strip, so compact ends exactly on the /// hardware notch's bottom edge and expanded content starts just below it. @@ -276,7 +336,7 @@ public func notchSurfaceSize( case .compact: return NotchSize(width: 272, height: 34) case .prehover: return NotchSize(width: 282, height: 38) case .peek: return NotchSize(width: 316, height: 76) - case .expanded: return NotchSize(width: 396, height: 232) + case .expanded: return NotchSize(width: 420, height: 440) case .attention: return NotchSize(width: 336, height: 130) case .celebration: return NotchSize(width: 352, height: 150) } @@ -298,7 +358,7 @@ public func notchSurfaceSize( case .peek: return NotchSize(width: compactWidth + 10, height: band + 62) case .expanded: - return NotchSize(width: max(400, compactWidth + 10), height: band + 232) + return NotchSize(width: max(420, compactWidth + 10), height: band + 440) case .attention: return NotchSize(width: max(384, compactWidth + 10), height: band + 126) case .celebration: diff --git a/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchGeometryTests.swift b/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchGeometryTests.swift index 4782f28a0..04bc33dfb 100644 --- a/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchGeometryTests.swift +++ b/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchGeometryTests.swift @@ -105,6 +105,53 @@ final class NotchGeometryTests: XCTestCase { XCTAssertEqual(frame.maxY, status.y - 4) } + /// The panel grew to 760×640 for the scrolling Activity list. The surface + /// is top-aligned inside it, so what has to fit above the display's bottom + /// edge is the 440pt surface, not the 640pt transparent host — verified + /// here on the smallest Mac ADE supports, a 1440×900 13". + func testExpandedSurfaceFitsUnderA13InchMenuBar() { + XCTAssertEqual(NotchDisplayGeometry.panelSize, NotchSize(width: 760, height: 640)) + + let display = NotchRect(x: 0, y: 0, width: 1_440, height: 900) + let status = NotchRect(x: 1_390, y: 875, width: 24, height: 24) + let surface = notchSurfaceSize(presentation: .expanded, physicalNotchWidth: nil) + XCTAssertEqual(surface, NotchSize(width: 420, height: 440)) + + let frame = menuBarAnchoredPanelFrame( + statusItemFrame: status, + displayFrame: display, + surfaceSize: surface + ) + // Top of the surface sits just under the menu bar… + XCTAssertEqual(frame.maxY, status.y - 4) + // …and its bottom edge stays comfortably on screen. + XCTAssertGreaterThan(frame.maxY - surface.height, display.y) + XCTAssertGreaterThanOrEqual(frame.midX - surface.width / 2, display.x + 8) + XCTAssertLessThanOrEqual(frame.midX + surface.width / 2, display.maxX - 8) + + // Same on a notched 13" built-in, where the panel hangs from the very + // top of the display rather than from a status item. + let notched = NotchDisplayGeometry( + displayId: 1, + frame: display, + visibleFrame: NotchRect(x: 0, y: 0, width: 1_440, height: 875), + safeAreaTop: 34, + auxiliaryLeft: NotchRect(x: 0, y: 866, width: 630, height: 34), + auxiliaryRight: NotchRect(x: 810, y: 866, width: 630, height: 34), + isBuiltIn: true + ) + XCTAssertTrue(notched.hasPhysicalNotch) + let notchedSurface = notchSurfaceSize( + presentation: .expanded, + physicalNotchWidth: notched.physicalNotchWidth, + safeAreaTop: 34 + ) + XCTAssertEqual(notchedSurface.height, 474) + XCTAssertGreaterThan(notched.frame.maxY - notchedSurface.height, display.y) + XCTAssertLessThanOrEqual(notchedSurface.width, NotchDisplayGeometry.panelSize.width) + XCTAssertLessThanOrEqual(notchedSurface.height, NotchDisplayGeometry.panelSize.height) + } + private func geometry( safeAreaTop: Double, left: NotchRect?, diff --git a/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchInteractionStateTests.swift b/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchInteractionStateTests.swift index 0130c49f4..072325d9e 100644 --- a/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchInteractionStateTests.swift +++ b/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchInteractionStateTests.swift @@ -2,14 +2,19 @@ import XCTest @testable import ADEAttentionNotchCore final class NotchInteractionStateTests: XCTestCase { - func testStaleHoverGenerationCannotOpenPeekAfterPointerExit() { + /// Hover stops at prehover. The 145ms promotion to `.peek` is gone: that + /// layout belongs to event toasts now, and a hover that grew into a card + /// competed with the toast it looked identical to. + func testHoverStopsAtPrehoverAndNeverOpensTheToastLayout() { var state = NotchInteractionState() - let hoverGeneration = state.pointerEntered(hasItems: true) + state.pointerEntered(hasItems: true) XCTAssertEqual(state.presentation, .prehover) - state.pointerExited() - state.applyPeek(generation: hoverGeneration, pointerInside: true) + // Nothing else the hover flow can do promotes it further. + state.pointerEntered(hasItems: true) + XCTAssertEqual(state.presentation, .prehover) + state.pointerExited() XCTAssertEqual(state.presentation, .compact) } @@ -45,9 +50,8 @@ final class NotchInteractionStateTests: XCTestCase { XCTAssertEqual(NotchPresentationPolicy(settings: NotchSettings()), .default) var state = NotchInteractionState() - let token = state.pointerEntered(hasItems: true, policy: .default) - state.applyPeek(generation: token, pointerInside: true) - XCTAssertEqual(state.presentation, .peek) + state.pointerEntered(hasItems: true, policy: .default) + XCTAssertEqual(state.presentation, .prehover) } func testHoverModeIsVisuallyDormantOnlyWhileResting() { @@ -97,10 +101,8 @@ final class NotchInteractionStateTests: XCTestCase { for mode in [NotchRevealMode.click, .minimal] { let policy = NotchPresentationPolicy(revealMode: mode) var state = NotchInteractionState() - let token = state.pointerEntered(hasItems: true, policy: policy) + state.pointerEntered(hasItems: true, policy: policy) XCTAssertEqual(state.presentation, .compact, "\(mode) grew on hover") - state.applyPeek(generation: token, pointerInside: true) - XCTAssertEqual(state.presentation, .compact, "\(mode) peeked on hover") } } @@ -116,16 +118,65 @@ final class NotchInteractionStateTests: XCTestCase { } } - /// Presentation choices never let an event override the user's reveal - /// preference; hover means the pointer is what reveals the surface. - func testEveryModeSuppressesAlertAndCelebrationGrowth() { + /// Automatic reveal is a setting now, but "click only" still outranks it: + /// that mode is literal, and nothing but a click may open anything. + func testAutomaticRevealHonoursTheSettingAndDefersToClickOnlyMode() { for mode in NotchRevealMode.allCases { - let policy = NotchPresentationPolicy(revealMode: mode) + let allowed = NotchPresentationPolicy(revealMode: mode, automaticRevealEnabled: true) + XCTAssertEqual(allowed.allowsAutomaticReveal, mode != .click, "\(mode)") + + var alerting = NotchInteractionState() + alerting.setAttention(policy: allowed) + XCTAssertEqual(alerting.presentation, mode == .click ? .compact : .attention, "\(mode)") + + var celebrating = NotchInteractionState() + celebrating.setCelebration(policy: allowed) + XCTAssertEqual(celebrating.presentation, mode == .click ? .compact : .celebration, "\(mode)") + + // Turned off, no mode may grow the surface on its own. + let off = NotchPresentationPolicy(revealMode: mode, automaticRevealEnabled: false) + XCTAssertFalse(off.allowsAutomaticReveal, "\(mode)") + var suppressed = NotchInteractionState() + suppressed.setAttention(policy: off) + XCTAssertEqual(suppressed.presentation, .compact, "\(mode)") + suppressed.setCelebration(policy: off) + XCTAssertEqual(suppressed.presentation, .compact, "\(mode)") + } + } + + /// Turning automatic reveal off while a toast is on screen has to collapse + /// it; otherwise the setting looks broken until the toast expires. + func testTurningOffAutomaticRevealCollapsesAToastAlreadyOnScreen() { + for treatment in [NotchToastTreatment.alert, .celebration] { var state = NotchInteractionState() - state.setAttention(policy: policy) - XCTAssertEqual(state.presentation, .compact) - state.setCelebration(policy: policy) - XCTAssertEqual(state.presentation, .compact) + let on = NotchPresentationPolicy(revealMode: .hover, automaticRevealEnabled: true) + if treatment == .celebration { + state.setCelebration(policy: on) + } else { + state.setAttention(policy: on) + } + XCTAssertEqual(state.presentation, treatment.presentation) + + state.applyPolicy(NotchPresentationPolicy( + revealMode: .hover, + automaticRevealEnabled: false + )) + XCTAssertEqual(state.presentation, .compact, "\(treatment)") + } + } + + /// The ticker belongs to the pinned strip, the one mode that keeps a bar on + /// screen at rest. + func testTickerOnlyRunsInThePinnedMode() { + for mode in NotchRevealMode.allCases { + XCTAssertEqual( + NotchPresentationPolicy(revealMode: mode, tickerEnabled: true).showsTicker, + mode == .minimal, + "\(mode)" + ) + XCTAssertFalse( + NotchPresentationPolicy(revealMode: mode, tickerEnabled: false).showsTicker + ) } } @@ -148,12 +199,11 @@ final class NotchInteractionStateTests: XCTestCase { /// A hover-opened peek is not "open": clicking through one has to latch the /// surface rather than dismiss it. - func testClickingThroughAHoverPeekLatchesInsteadOfClosing() { + func testClickingThroughAHoverLatchesInsteadOfClosing() { let policy = NotchPresentationPolicy(revealMode: .hover, expandedPanelEnabled: false) var state = NotchInteractionState() - let token = state.pointerEntered(hasItems: true, policy: policy) - state.applyPeek(generation: token, pointerInside: true) - XCTAssertEqual(state.presentation, .peek) + state.pointerEntered(hasItems: true, policy: policy) + XCTAssertEqual(state.presentation, .prehover) XCTAssertFalse(state.isExplicitlyInteractive) state.explicitToggle(hasItems: true, policy: policy) @@ -178,14 +228,16 @@ final class NotchInteractionStateTests: XCTestCase { /// mode had already put on screen. func testSwitchingModesCollapsesSurfacesTheNewModeForbids() { var hovering = NotchInteractionState() - let token = hovering.pointerEntered(hasItems: true, policy: .default) - hovering.applyPeek(generation: token, pointerInside: true) + hovering.pointerEntered(hasItems: true, policy: .default) hovering.applyPolicy(NotchPresentationPolicy(revealMode: .click)) XCTAssertEqual(hovering.presentation, .compact) var alerting = NotchInteractionState() alerting.setAttention(policy: .default) - alerting.applyPolicy(NotchPresentationPolicy(revealMode: .minimal)) + alerting.applyPolicy(NotchPresentationPolicy( + revealMode: .minimal, + automaticRevealEnabled: false + )) XCTAssertEqual(alerting.presentation, .compact) var manuallyExpanded = NotchInteractionState() @@ -195,17 +247,23 @@ final class NotchInteractionStateTests: XCTestCase { XCTAssertTrue(manuallyExpanded.isExplicitlyInteractive) } - /// Settling out of an alert under a pointer that is not allowed to reveal - /// anything has to land on compact, not on a peek hover never opened. - func testTransientsSettleToCompactWhenHoverCannotReveal() { - var state = NotchInteractionState() - state.setAttention(policy: NotchPresentationPolicy(revealMode: .click)) - state.finishTransient(pointerInside: true, policy: NotchPresentationPolicy(revealMode: .click)) - XCTAssertEqual(state.presentation, .compact) - - let hoverToken = state.pointerEntered(hasItems: true, policy: .default) - state.applyPeek(generation: hoverToken, pointerInside: true) - XCTAssertEqual(state.presentation, .peek) + /// A toast always settles back to the bar. `.peek` is the toast's own + /// layout now, so landing there would leave a card on screen with nothing + /// left to say — under a hovering pointer it lands on prehover instead. + func testTransientsNeverSettleOntoTheToastLayout() { + for (mode, pointerInside, expected) in [ + (NotchRevealMode.click, true, NotchPresentationState.compact), + (.hover, true, .prehover), + (.hover, false, .compact), + (.minimal, true, .compact), + ] { + var state = NotchInteractionState() + let policy = NotchPresentationPolicy(revealMode: mode) + state.setAttention(policy: policy) + state.finishTransient(pointerInside: pointerInside, policy: policy) + XCTAssertEqual(state.presentation, expected, "\(mode) inside=\(pointerInside)") + XCTAssertNotEqual(state.presentation, .peek) + } } /// Turning the notch off entirely stays the strongest setting: it outranks @@ -221,6 +279,7 @@ final class NotchInteractionStateTests: XCTestCase { XCTAssertEqual(state.presentation, .compact) state.explicitToggle(hasItems: true, policy: policy) state.setAttention(policy: policy) + state.setCelebration(policy: policy) XCTAssertEqual(state.presentation, .compact, "\(mode) reappeared while off") } } @@ -244,14 +303,90 @@ final class NotchInteractionStateTests: XCTestCase { } } - func testNavigationWrapsInBothDirections() { + /// The pager is gone — the panel scrolls — so selection is only ever set by + /// pointing at a row, and only has to stay inside the list. + func testSelectionIsClampedToTheListInsteadOfPaged() { var state = NotchInteractionState() - state.navigate(delta: -1, itemCount: 3) - XCTAssertEqual(state.selectedIndex, 2) - state.navigate(delta: 1, itemCount: 3) - XCTAssertEqual(state.selectedIndex, 0) state.select(index: 9, itemCount: 3) XCTAssertEqual(state.selectedIndex, 2) + state.select(index: -4, itemCount: 3) + XCTAssertEqual(state.selectedIndex, 0) + state.select(index: 2, itemCount: 3) + state.clampSelection(itemCount: 1) + XCTAssertEqual(state.selectedIndex, 0) + state.clampSelection(itemCount: 0) + XCTAssertEqual(state.selectedIndex, 0) + } + + // MARK: - Activity sections + + /// The panel files a row exactly where the desktop popover files it, + /// including the rule that idle roster history is the ambient tail of Done + /// no matter what phase it preserved. + func testSectionsMirrorTheRendererPriorityFlatThree() { + let needsYou = sectionFixture(id: "needs", phase: "needs_you") + let failed = sectionFixture(id: "failed", phase: "failed") + let running = sectionFixture(id: "running", phase: "running") + let completed = sectionFixture(id: "completed", phase: "completed") + let idleButRunning = sectionFixture(id: "idle", phase: "running", tier: "idle") + + let sections = notchActivitySections([completed, running, idleButRunning, failed, needsYou]) + XCTAssertEqual(sections.needsYou.map(\.id), ["needs", "failed"]) + XCTAssertEqual(sections.working.map(\.id), ["running"]) + XCTAssertEqual(sections.done.map(\.id), ["completed", "idle"]) + XCTAssertEqual(sections.live.map(\.id), ["needs", "failed", "running"]) + XCTAssertEqual(sections.total, 5) + } + + /// A host that predates the counts block must not make the surface claim an + /// overflow it cannot see. + func testCountsFallBackToTheRowsOnHandWhenTheHostSendsNone() { + let snapshot = AttentionSnapshot( + revision: 1, + generatedAt: "2026-08-01T12:00:00Z", + items: [ + sectionFixture(id: "needs", phase: "needs_you"), + sectionFixture(id: "running", phase: "running"), + ] + ) + let counts = snapshot.resolvedCounts() + XCTAssertEqual(counts.needsYou, 1) + XCTAssertEqual(counts.working, 1) + XCTAssertEqual(counts.total, 2) + XCTAssertEqual(counts.overflow(shownItemCount: 2), 0) + + // With counts, the totals are the account's, not the frame's. + let projected = AttentionSnapshot( + revision: 2, + generatedAt: "2026-08-01T12:00:01Z", + items: [sectionFixture(id: "needs", phase: "needs_you")], + counts: AttentionCounts(needsYou: 3, working: 9, done: 49, total: 61) + ) + XCTAssertEqual(projected.resolvedCounts().total, 61) + XCTAssertEqual(projected.resolvedCounts().overflow(shownItemCount: 1), 60) + } + + private func sectionFixture( + id: String, + phase: String, + tier: String? = nil + ) -> AttentionItem { + AttentionItem( + id: id, + fingerprint: "fingerprint-\(id)", + kind: "agent", + eventKind: "agent_running", + phase: phase, + machine: AttentionMachine(machineKey: "mac-1", name: "Studio", online: true, lastSeenAt: nil), + project: AttentionProject(projectId: "ade", name: "ADE"), + title: "Work", + preview: "Working", + privacyPreview: "Agent update", + destination: AttentionDestination(kind: "session", sessionId: "session-\(id)"), + occurredAt: "2026-08-01T12:00:00Z", + updatedAt: "2026-08-01T12:00:00Z", + activityTier: tier + ) } func testPhysicalSurfaceReservesHardwareAndSideEars() { diff --git a/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchProtocolTests.swift b/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchProtocolTests.swift index 0cefce962..136503a1f 100644 --- a/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchProtocolTests.swift +++ b/apps/desktop/native/ADEAttentionNotch/Tests/ADEAttentionNotchCoreTests/NotchProtocolTests.swift @@ -466,10 +466,245 @@ final class NotchProtocolTests: XCTestCase { XCTAssertEqual(AttentionAction(id: "open", kind: "open", label: "Open").navigationLabel, "Open in ADE") } + // MARK: - Activity revamp protocol additions + + /// The elapsed anchor and the tier are additive. A publisher that has them + /// is decoded exactly; one that does not still lands, and the surface falls + /// back to `occurredAt` rather than to `updatedAt`, which churns on every + /// cosmetic republish. + func testItemDecodesStatusSinceAndTierAndDegradesWithoutThem() throws { + let modern = """ + {"contractVersion":1,"revision":7,"generatedAt":"2026-08-01T12:00:00Z","items":[ + {"contractVersion":1,"id":"a","revision":1,"fingerprint":"f","kind":"agent", + "eventKind":"agent_running","phase":"running", + "machine":{"machineKey":"m","name":"Studio","online":true,"lastSeenAt":null}, + "project":{"projectId":"p","name":"ADE"},"title":"T","preview":"P", + "privacyPreview":"Agent update", + "destination":{"kind":"session","sessionId":"s"},"actions":[], + "occurredAt":"2026-08-01T11:00:00Z","updatedAt":"2026-08-01T12:00:00Z", + "statusSince":"2026-08-01T11:30:00Z","activityTier":"ambient", + "seenAt":null,"dismissedAt":null,"expiresAt":null}]} + """ + guard case .snapshot(let snapshot) = try NotchInputDecoder.decode(line: modern) else { + return XCTFail("expected a snapshot") + } + let item = try XCTUnwrap(snapshot.items.first) + XCTAssertEqual(item.statusSince, "2026-08-01T11:30:00Z") + XCTAssertEqual(item.tier, "ambient") + XCTAssertEqual(item.elapsedAnchor, "2026-08-01T11:30:00Z") + XCTAssertFalse(item.isSignalTier) + XCTAssertFalse(item.isIdleTier) + + let legacy = fixtureItem() + XCTAssertNil(legacy.statusSince) + XCTAssertNil(legacy.tier) + XCTAssertEqual(legacy.elapsedAnchor, legacy.occurredAt) + // Without a tier the surface falls back to the phase test it has always + // used, so a mixed-version fleet still files rows consistently. + XCTAssertEqual(fixtureItem(phase: "needs_you").isSignalTier, true) + XCTAssertEqual(legacy.isSignalTier, false) + + // A tier this build has never heard of is not a signal and not idle. + let drifted = fixtureItem(tier: "telepathic") + XCTAssertFalse(drifted.isSignalTier) + XCTAssertFalse(drifted.isIdleTier) + } + + func testSnapshotDecodesCountsAndSurvivesWithoutThem() throws { + let withCounts = """ + {"contractVersion":1,"revision":3,"generatedAt":"2026-08-01T12:00:00Z","items":[], + "counts":{"needsYou":2,"working":5,"done":54,"total":61, + "machinesOnline":1,"machinesTotal":3}} + """ + guard case .snapshot(let snapshot) = try NotchInputDecoder.decode(line: withCounts) else { + return XCTFail("expected a snapshot") + } + let counts = try XCTUnwrap(snapshot.counts) + XCTAssertEqual(counts.needsYou, 2) + XCTAssertEqual(counts.working, 5) + XCTAssertEqual(counts.done, 54) + XCTAssertEqual(counts.total, 61) + XCTAssertEqual(counts.machinesOnline, 1) + XCTAssertEqual(counts.machinesTotal, 3) + // 61 rows exist; this frame carried 48 of them. + XCTAssertEqual(counts.overflow(shownItemCount: 48), 13) + + // Partial and malformed count blocks are advisory chrome like + // availability: they may never cost us the items that came with them. + let partial = """ + {"contractVersion":1,"revision":4,"generatedAt":"2026-08-01T12:00:00Z","items":[], + "counts":{"needsYou":1,"unknownFuture":9}} + """ + guard case .snapshot(let partialSnapshot) = try NotchInputDecoder.decode(line: partial) else { + return XCTFail("expected a snapshot") + } + XCTAssertEqual(partialSnapshot.counts?.needsYou, 1) + XCTAssertEqual(partialSnapshot.counts?.working, 0) + + let malformed = """ + {"contractVersion":1,"revision":5,"generatedAt":"2026-08-01T12:00:00Z","items":[],"counts":"broken"} + """ + guard case .snapshot(let malformedSnapshot) = try NotchInputDecoder.decode(line: malformed) else { + return XCTFail("expected a snapshot") + } + XCTAssertNil(malformedSnapshot.counts) + XCTAssertEqual(malformedSnapshot.revision, 5) + } + + /// A bare snapshot with none of the new keys is still the whole legacy + /// contract — this is the regression guard for hosts mid-rollout. + func testBareLegacySnapshotStillDecodes() throws { + let bare = """ + {"contractVersion":1,"revision":1,"generatedAt":"2026-08-01T12:00:00Z","items":[]} + """ + guard case .snapshot(let snapshot) = try NotchInputDecoder.decode(line: bare) else { + return XCTFail("expected a snapshot") + } + XCTAssertNil(snapshot.counts) + XCTAssertNil(snapshot.availability) + XCTAssertNil(snapshot.streamId) + XCTAssertTrue(snapshot.items.isEmpty) + XCTAssertEqual(snapshot.resolvedCounts(), AttentionCounts()) + } + + func testToastCommandDecodesEveryTreatment() throws { + for (raw, expected) in [ + ("celebration", NotchToastTreatment.celebration), + ("success", .success), + ("alert", .alert), + ("info", .info), + ] { + let line = """ + {"type":"toast","toast":{"itemId":"pr-1","eventKind":"pr_merged", + "treatment":"\(raw)","title":"Merged #42","subtitle":"ade/desktop", + "tone":"emerald","durationMs":2000}} + """ + guard case .toast(let toast) = try NotchInputDecoder.decode(line: line) else { + return XCTFail("expected a toast for \(raw)") + } + XCTAssertEqual(toast.treatment, expected) + XCTAssertEqual(toast.itemId, "pr-1") + XCTAssertEqual(toast.title, "Merged #42") + XCTAssertEqual(toast.subtitle, "ade/desktop") + XCTAssertEqual(toast.resolvedTone, .emerald) + XCTAssertEqual(toast.resolvedDurationMs, 2_000) + // Only a merge earns the confetti. + XCTAssertEqual( + toast.treatment.presentation, + expected == .celebration ? .celebration : .attention + ) + } + } + + /// A treatment this build has never heard of reads as ordinary news. It may + /// not throw: a decode failure is reported to the host as a protocol error + /// and latches the helper into "needs an update" for the rest of its life. + func testUnknownToastTreatmentDegradesToInfoInsteadOfFailing() throws { + let line = """ + {"type":"toast","toast":{"eventKind":"agent_needs_you","treatment":"telepathy", + "title":"Needs you"}} + """ + guard case .toast(let toast) = try NotchInputDecoder.decode(line: line) else { + return XCTFail("expected a toast") + } + XCTAssertEqual(toast.treatment, .info) + XCTAssertEqual(toast.treatment.presentation, .attention) + XCTAssertNil(toast.itemId) + XCTAssertEqual(toast.resolvedTone, .blue) + XCTAssertEqual(toast.resolvedDurationMs, 5_000) + } + + /// A drifted host may not pin the surface open, or flash it so briefly that + /// it reads as a glitch. + func testToastDurationIsClampedToASaneWindow() { + XCTAssertEqual(toastFixture(durationMs: 0).resolvedDurationMs, 800) + XCTAssertEqual(toastFixture(durationMs: -5_000).resolvedDurationMs, 800) + XCTAssertEqual(toastFixture(durationMs: 600_000).resolvedDurationMs, 15_000) + XCTAssertEqual(toastFixture(durationMs: 3_000).resolvedDurationMs, 3_000) + XCTAssertEqual(toastFixture(durationMs: nil).resolvedDurationMs, 5_000) + } + + func testToastCommandWithoutAPayloadIsRejected() { + XCTAssertThrowsError(try NotchInputDecoder.decode(line: #"{"type":"toast"}"#)) { error in + XCTAssertEqual(error as? NotchProtocolError, .missingPayload("toast")) + } + } + + func testAutomaticRevealAndTickerDefaultOnAndRoundTrip() throws { + let defaults = NotchSettings() + XCTAssertTrue(defaults.automaticRevealEnabled) + XCTAssertTrue(defaults.tickerEnabled) + + // A host built before these keys keeps the shipped behaviour. + let legacy = """ + {"type":"settings","settings":{"enabled":true,"revealMode":"hover", + "expandedPanelEnabled":true,"hideDetails":false, + "celebrationsEnabled":true,"soundsEnabled":false}} + """ + guard case .settings(let inherited) = try NotchInputDecoder.decode(line: legacy) else { + return XCTFail("expected settings") + } + XCTAssertTrue(inherited.automaticRevealEnabled) + XCTAssertTrue(inherited.tickerEnabled) + + let off = """ + {"type":"settings","settings":{"enabled":true,"revealMode":"minimal", + "expandedPanelEnabled":true,"hideDetails":false,"celebrationsEnabled":true, + "soundsEnabled":false,"automaticRevealEnabled":false,"tickerEnabled":false}} + """ + guard case .settings(let explicit) = try NotchInputDecoder.decode(line: off) else { + return XCTFail("expected settings") + } + XCTAssertFalse(explicit.automaticRevealEnabled) + XCTAssertFalse(explicit.tickerEnabled) + + // Both survive the round trip back to the host through the settings + // output, which is how the context menu's checkmarks are persisted. + let encoded = try JSONEncoder().encode(NotchOutput(type: "settings", settings: explicit)) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + let settings = try XCTUnwrap(object["settings"] as? [String: Any]) + XCTAssertEqual(settings["automaticRevealEnabled"] as? Bool, false) + XCTAssertEqual(settings["tickerEnabled"] as? Bool, false) + } + + func testContextMenuTogglesForAutomaticRevealAndTickerPreserveEverythingElse() { + let original = NotchSettings( + enabled: true, + revealMode: .minimal, + expandedPanelEnabled: false, + preferredDisplayId: 42, + hideDetails: false, + celebrationsEnabled: false, + soundsEnabled: true + ) + + let noReveal = applyingNotchSettingsMenuAction(.toggleAutomaticReveal, to: original) + XCTAssertFalse(noReveal.automaticRevealEnabled) + XCTAssertTrue(noReveal.tickerEnabled) + XCTAssertEqual(noReveal.revealMode, .minimal) + XCTAssertEqual(noReveal.preferredDisplayId, 42) + XCTAssertTrue(noReveal.soundsEnabled) + + let noTicker = applyingNotchSettingsMenuAction(.toggleTicker, to: noReveal) + XCTAssertFalse(noTicker.tickerEnabled) + XCTAssertFalse(noTicker.automaticRevealEnabled) + XCTAssertFalse(noTicker.hideDetails) + } + + private func toastFixture(durationMs: Int?) -> AttentionToast { + AttentionToast( + eventKind: "agent_needs_you", + treatment: .alert, + title: "Needs you", + durationMs: durationMs + ) + } + private func fixtureItem( id: String = "agent-1", phase: String = "running", - updatedAt: String = "2026-07-28T12:00:00Z" + updatedAt: String = "2026-07-28T12:00:00Z", + tier: String? = nil ) -> AttentionItem { AttentionItem( id: id, @@ -484,7 +719,8 @@ final class NotchProtocolTests: XCTestCase { privacyPreview: "Agent update", destination: AttentionDestination(kind: "session", sessionId: "session-1"), occurredAt: updatedAt, - updatedAt: updatedAt + updatedAt: updatedAt, + activityTier: tier ) } } diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 750a4b525..23583235e 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -6915,11 +6915,37 @@ app.whenReady().then(async () => { requestAttentionNotchRefresh(true); return; } + if (output.type === "open_settings") { + dispatchAppNavigationRequest?.({ + target: { kind: "settings", tab: "activity", anchor: null }, + source: "attention-notch", + }); + return; + } + if (output.type === "dismiss_item") { + void sendAttentionNotchAcknowledge({ + itemId: output.itemId, + mode: "dismiss", + }).catch((error: unknown) => { + getActiveContext().logger.warn("attention.notch_ack_route_failed", { + itemId: output.itemId, + error: error instanceof Error ? error.message : String(error), + }); + }); + return; + } if (output.type === "settings") { - attentionNotchHelper?.updateSettings(output.settings); + // A helper older than the presentation booleans omits them; both default + // on, so an absent field must read as enabled rather than undefined. + const settings: AttentionNotchSettings = { + ...output.settings, + automaticRevealEnabled: output.settings.automaticRevealEnabled !== false, + tickerEnabled: output.settings.tickerEnabled !== false, + }; + attentionNotchHelper?.updateSettings(settings); for (const win of BrowserWindow.getAllWindows()) { if (win.isDestroyed() || win.webContents.isDestroyed()) continue; - win.webContents.send(IPC.attentionNotchSettingsChanged, output.settings); + win.webContents.send(IPC.attentionNotchSettingsChanged, settings); } return; } @@ -7029,6 +7055,9 @@ app.whenReady().then(async () => { latestAttentionNotchSnapshot = snapshot; attentionNotchHelper?.publishSnapshot(snapshot); }, + publishAttentionNotchToast: (toast) => { + attentionNotchHelper?.publishToast(toast); + }, updateAttentionNotchSettings: (settings: AttentionNotchSettings) => { attentionNotchHelper?.updateSettings(settings); }, diff --git a/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts b/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts index 7f9ea7d96..ab56b52fd 100644 --- a/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts +++ b/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts @@ -76,6 +76,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: true, @@ -136,6 +138,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: true, @@ -158,6 +162,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: true, @@ -187,6 +193,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: true, @@ -258,6 +266,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: true, @@ -288,6 +298,8 @@ describe("AttentionNotchHelper", () => { enabled: false, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: false, celebrationsEnabled: true, @@ -323,6 +335,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: false, @@ -356,6 +370,8 @@ describe("AttentionNotchHelper", () => { enabled: false, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: false, @@ -393,6 +409,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: true, @@ -402,6 +420,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: false, celebrationsEnabled: true, @@ -460,6 +480,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: false, @@ -479,6 +501,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: false, celebrationsEnabled: false, @@ -488,6 +512,8 @@ describe("AttentionNotchHelper", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: false, @@ -513,4 +539,202 @@ describe("AttentionNotchHelper", () => { expect(JSON.parse(lines[4] ?? "{}").visible).toBe(false); helper.dispose(); }); + const enabledSettings = { + enabled: true as const, + revealMode: "hover" as const, + expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, + preferredDisplayId: null, + hideDetails: true, + celebrationsEnabled: false, + soundsEnabled: false, + }; + + const toast = { + itemId: "agent-1", + eventKind: "agent_needs_you" as const, + treatment: "alert" as const, + title: "Agent needs you", + subtitle: "Approve the command", + tone: null, + durationMs: null, + }; + + // The router now writes up to 192KB; if this buffer were still 256KB a + // legitimately large frame would be accepted and then silently dropped here. + it("parses an output line far larger than the old 256KB buffer", () => { + const child = fakeChild(); + spawnMock.mockReturnValue(child); + const onOutput = vi.fn(); + const helper = new AttentionNotchHelper({ + executablePath: "/tmp/notch", + logger, + onOutput, + platform: "darwin", + }); + helper.updateSettings(enabledSettings); + child.emit("spawn"); + + const line = JSON.stringify({ + type: "protocol_error", + message: "x".repeat(300 * 1024), + }); + expect(Buffer.byteLength(line, "utf8")).toBeGreaterThan(256 * 1024); + (child.stdout as PassThrough).write(`${line}\n`); + + expect(logger.warn).not.toHaveBeenCalledWith("attention.notch_helper_output_overflow"); + expect(onOutput).toHaveBeenCalledTimes(1); + expect(onOutput.mock.calls[0]?.[0]?.type).toBe("protocol_error"); + helper.dispose(); + }); + + it("accepts the two new output types and settings output with or without the new flags", () => { + const child = fakeChild(); + spawnMock.mockReturnValue(child); + const onOutput = vi.fn(); + const helper = new AttentionNotchHelper({ + executablePath: "/tmp/notch", + logger, + onOutput, + platform: "darwin", + }); + helper.updateSettings(enabledSettings); + child.emit("spawn"); + + (child.stdout as PassThrough).write([ + JSON.stringify({ type: "open_settings" }), + JSON.stringify({ + type: "dismiss_item", + itemId: "agent-1", + destination: { kind: "session", sessionId: "session-1" }, + }), + // A dismiss without an item is not routable and must be rejected. + JSON.stringify({ type: "dismiss_item" }), + JSON.stringify({ + type: "settings", + settings: { + enabled: true, + revealMode: "click", + expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: false, + preferredDisplayId: null, + hideDetails: true, + celebrationsEnabled: true, + soundsEnabled: false, + }, + }), + // Optional: a helper predating the flags still lands. + JSON.stringify({ + type: "settings", + settings: { + enabled: true, + revealMode: "click", + expandedPanelEnabled: false, + preferredDisplayId: null, + hideDetails: true, + celebrationsEnabled: true, + soundsEnabled: false, + }, + }), + JSON.stringify({ + type: "settings", + settings: { + enabled: true, + revealMode: "click", + expandedPanelEnabled: false, + tickerEnabled: "yes", + preferredDisplayId: null, + hideDetails: true, + celebrationsEnabled: true, + soundsEnabled: false, + }, + }), + "", + ].join("\n")); + + expect(onOutput.mock.calls.map((call) => call[0]?.type)).toEqual([ + "open_settings", + "dismiss_item", + "settings", + "settings", + ]); + expect(onOutput.mock.calls[1]?.[0]).toMatchObject({ itemId: "agent-1" }); + helper.dispose(); + }); + + it("writes a toast only when the helper is already running", () => { + const child = fakeChild(); + spawnMock.mockReturnValue(child); + const lines: string[] = []; + (child.stdin as PassThrough).setEncoding("utf8"); + child.stdin.on("data", (chunk: Buffer | string) => { + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + lines.push(...text.trim().split("\n")); + }); + const helper = new AttentionNotchHelper({ + executablePath: "/tmp/notch", + logger, + onOutput: vi.fn(), + platform: "darwin", + }); + + // No child yet: a toast must never be the thing that starts the surface, + // and must not be retained for replay. + helper.publishToast(toast); + expect(spawnMock).not.toHaveBeenCalled(); + expect(lines).toEqual([]); + + helper.updateSettings(enabledSettings); + child.emit("spawn"); + helper.publishToast(toast); + + const parsed = lines.map((line) => JSON.parse(line)); + expect(parsed.map((entry) => entry.type)).toEqual(["settings", "toast"]); + expect(parsed[1]?.toast).toMatchObject({ + itemId: "agent-1", + eventKind: "agent_needs_you", + treatment: "alert", + }); + helper.dispose(); + }); + + it("never collapses two queued toasts into one", () => { + const child = fakeChild(); + spawnMock.mockReturnValue(child); + const lines: string[] = []; + (child.stdin as PassThrough).setEncoding("utf8"); + child.stdin.on("data", (chunk: Buffer | string) => { + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + lines.push(...text.trim().split("\n")); + }); + const originalWrite = child.stdin.write.bind(child.stdin); + let writeCount = 0; + vi.spyOn(child.stdin, "write").mockImplementation(((...args: Parameters) => { + writeCount += 1; + const accepted = originalWrite(...args); + return writeCount === 1 ? false : accepted; + }) as typeof child.stdin.write); + + const helper = new AttentionNotchHelper({ + executablePath: "/tmp/notch", + logger, + onOutput: vi.fn(), + platform: "darwin", + }); + helper.updateSettings(enabledSettings); + child.emit("spawn"); + + helper.publishToast({ ...toast, itemId: "agent-1", title: "First" }); + helper.publishToast({ ...toast, itemId: "agent-2", title: "Second" }); + child.stdin.emit("drain"); + + const toasts = lines + .map((line) => JSON.parse(line)) + .filter((entry) => entry.type === "toast") + .map((entry) => entry.toast.title); + expect(toasts).toEqual(["First", "Second"]); + helper.dispose(); + }); }); diff --git a/apps/desktop/src/main/services/attention/attentionNotchHelper.ts b/apps/desktop/src/main/services/attention/attentionNotchHelper.ts index 9b3ae4c6a..fe4170f0b 100644 --- a/apps/desktop/src/main/services/attention/attentionNotchHelper.ts +++ b/apps/desktop/src/main/services/attention/attentionNotchHelper.ts @@ -6,11 +6,14 @@ import type { AttentionDestination, AttentionNotchHealth, AttentionNotchSettings, + AttentionNotchToast, AttentionSnapshot, } from "../../../shared/types/attention"; import type { Logger } from "../logging/logger"; -const MAX_HELPER_LINE_BYTES = 256 * 1024; +// Must stay above the router's snapshot write cap so a snapshot the router +// accepted can never overflow this buffer and be dropped on arrival. +const MAX_HELPER_LINE_BYTES = 512 * 1024; const MAX_RESTART_ATTEMPTS = 3; const GRACEFUL_SHUTDOWN_MS = 500; const DEFAULT_REFRESH_INTERVAL_MS = 15_000; @@ -49,11 +52,20 @@ export type AttentionNotchOutput = | { type: "settings"; settings: AttentionNotchSettings; + } + | { + type: "open_settings"; + } + | { + type: "dismiss_item"; + itemId: string; + destination?: AttentionDestination | null; }; type AttentionNotchInput = | { type: "settings"; settings: AttentionNotchSettings } | { type: "snapshot"; snapshot: AttentionSnapshot } + | { type: "toast"; toast: AttentionNotchToast } | { type: "visibility"; visible: boolean } | { type: "reanchor" } | { type: "quit" }; @@ -273,6 +285,16 @@ export class AttentionNotchHelper { } } + /** + * A toast is a one-shot event, so it never starts the helper and is never + * retained as latest state: replaying it after a restart would announce + * something that already happened, minutes late. + */ + publishToast(toast: AttentionNotchToast): void { + if (!this.child) return; + this.write({ type: "toast", toast }); + } + updateSettings(settings: AttentionNotchSettings): void { this.latestSettings = settings; if (!settings.enabled) { @@ -356,10 +378,14 @@ export class AttentionNotchHelper { } private enqueuePendingWrite(payload: AttentionNotchInput): void { - // Every helper command is state-setting/idempotent. Keep only the newest + // Every state-setting helper command is idempotent. Keep only the newest // value per type, append it after other controls to preserve causal order, // and retain a hard cap in case the protocol grows new command types. - this.pendingWrites = this.pendingWrites.filter((entry) => entry.type !== payload.type); + // Toasts are the exception: they are events, and collapsing two of them + // into one would drop an announcement rather than refresh it. + if (payload.type !== "toast") { + this.pendingWrites = this.pendingWrites.filter((entry) => entry.type !== payload.type); + } this.pendingWrites.push(payload); if (this.pendingWrites.length > MAX_PENDING_WRITES) { this.pendingWrites.splice(0, this.pendingWrites.length - MAX_PENDING_WRITES); @@ -479,7 +505,12 @@ function isAttentionNotchOutput(value: unknown): value is AttentionNotchOutput { ); } if (value.type === "protocol_error") return typeof value.message === "string"; - if (value.type === "open_center" || value.type === "refresh") return true; + if ( + value.type === "open_center" + || value.type === "refresh" + || value.type === "open_settings" + ) return true; + if (value.type === "dismiss_item") return typeof value.itemId === "string"; if (value.type === "settings") { if (!isRecord(value.settings)) return false; const settings = value.settings; @@ -491,6 +522,15 @@ function isAttentionNotchOutput(value: unknown): value is AttentionNotchOutput { || settings.revealMode === "click" ) && typeof settings.expandedPanelEnabled === "boolean" + // Optional: a helper built before these existed must still be accepted. + && ( + settings.automaticRevealEnabled === undefined + || typeof settings.automaticRevealEnabled === "boolean" + ) + && ( + settings.tickerEnabled === undefined + || typeof settings.tickerEnabled === "boolean" + ) && ( settings.preferredDisplayId == null || typeof settings.preferredDisplayId === "number" diff --git a/apps/desktop/src/main/services/attention/attentionNotchRouter.test.ts b/apps/desktop/src/main/services/attention/attentionNotchRouter.test.ts index 8905e2ee8..bd63dcb87 100644 --- a/apps/desktop/src/main/services/attention/attentionNotchRouter.test.ts +++ b/apps/desktop/src/main/services/attention/attentionNotchRouter.test.ts @@ -5,6 +5,7 @@ import { attentionItemNavigationRequest, parseAttentionNotchSettings, parseAttentionNotchSnapshot, + parseAttentionNotchToast, resolveAttentionNotchOutput, } from "./attentionNotchRouter"; import type { AttentionItem, AttentionSnapshot } from "../../../shared/types"; @@ -127,10 +128,14 @@ describe("Attention Notch routing", () => { hideDetails: false, celebrationsEnabled: true, soundsEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: false, })).toEqual({ enabled: true, revealMode: "click", expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: false, preferredDisplayId: 12, hideDetails: false, celebrationsEnabled: true, @@ -151,6 +156,10 @@ describe("Attention Notch routing", () => { enabled: true, revealMode: "hover", expandedPanelEnabled: true, + // Both new presentation booleans default on, so an older payload keeps + // the shipped behaviour rather than silently going quiet. + automaticRevealEnabled: true, + tickerEnabled: true, preferredDisplayId: null, hideDetails: false, celebrationsEnabled: true, @@ -158,6 +167,25 @@ describe("Attention Notch routing", () => { }); }); + it("rejects non-boolean automatic reveal or ticker flags", () => { + expect(parseAttentionNotchSettings({ + enabled: true, + preferredDisplayId: null, + hideDetails: false, + celebrationsEnabled: true, + soundsEnabled: false, + automaticRevealEnabled: "sometimes", + })).toBeNull(); + expect(parseAttentionNotchSettings({ + enabled: true, + preferredDisplayId: null, + hideDetails: false, + celebrationsEnabled: true, + soundsEnabled: false, + tickerEnabled: 1, + })).toBeNull(); + }); + it("rejects an invented notch reveal mode", () => { expect(parseAttentionNotchSettings({ enabled: true, @@ -251,6 +279,132 @@ describe("Attention Notch routing", () => { }); }); + it("accepts a well-formed counts block and rejects a malformed one", () => { + const counts = { + needsYou: 2, + working: 5, + done: 1, + total: 61, + machinesOnline: 1, + machinesTotal: 3, + }; + expect(parseAttentionNotchSnapshot({ ...snapshot(), counts })).not.toBeNull(); + // Absent stays valid: publishers older than the counts block still land. + expect(parseAttentionNotchSnapshot(snapshot())).not.toBeNull(); + expect(parseAttentionNotchSnapshot({ + ...snapshot(), + counts: { ...counts, total: -1 }, + })).toBeNull(); + expect(parseAttentionNotchSnapshot({ + ...snapshot(), + counts: { ...counts, working: 1.5 }, + })).toBeNull(); + expect(parseAttentionNotchSnapshot({ + ...snapshot(), + counts: { ...counts, machinesTotal: undefined }, + })).toBeNull(); + }); + + // The router's write cap has to stay under the helper's read cap, or an + // accepted snapshot is silently dropped on the far side of the pipe. + it("caps the published projection at 64 items and 192KB", () => { + const many = (count: number) => ({ + ...snapshot(), + items: Array.from({ length: count }, (_unused, index) => + item({ id: `agent-${index}`, fingerprint: `agent-${index}:3` })), + }); + expect(parseAttentionNotchSnapshot(many(64))).not.toBeNull(); + expect(parseAttentionNotchSnapshot(many(65))).toBeNull(); + expect(parseAttentionNotchSnapshot({ + ...snapshot(), + items: [item({ detail: "x".repeat(8_000) })], + // A single oversized field is enough once the payload clears 192KB. + generatedAt: "2026-07-28T12:00:03.000Z", + streamId: "s".repeat(400), + tombstones: Array.from({ length: 4_000 }, (_unused, index) => ({ + id: `tombstone-${index}-${"x".repeat(40)}`, + revision: 1, + deletedAt: "2026-07-28T12:00:03.000Z", + })), + })).toBeNull(); + }); + + it("validates toasts and rejects anything the native side would have to bend", () => { + expect(parseAttentionNotchToast({ + itemId: "agent-1", + eventKind: "pr_merged", + treatment: "celebration", + title: "Merged #42", + subtitle: "acme/ade", + tone: "emerald", + durationMs: 1_650, + })).toEqual({ + itemId: "agent-1", + eventKind: "pr_merged", + treatment: "celebration", + title: "Merged #42", + subtitle: "acme/ade", + tone: "emerald", + durationMs: 1_650, + }); + + // itemId is optional: a toast can be about the account, not a row. + expect(parseAttentionNotchToast({ + eventKind: "agent_needs_you", + treatment: "alert", + title: "Agent needs you", + })).toEqual({ + itemId: null, + eventKind: "agent_needs_you", + treatment: "alert", + title: "Agent needs you", + subtitle: null, + tone: null, + durationMs: null, + }); + + expect(parseAttentionNotchToast({ + eventKind: "agent_vibed", + treatment: "alert", + title: "Agent needs you", + })).toBeNull(); + expect(parseAttentionNotchToast({ + eventKind: "agent_needs_you", + treatment: "fanfare", + title: "Agent needs you", + })).toBeNull(); + expect(parseAttentionNotchToast({ + eventKind: "agent_needs_you", + treatment: "alert", + title: "x".repeat(257), + })).toBeNull(); + expect(parseAttentionNotchToast({ + eventKind: "agent_needs_you", + treatment: "alert", + title: "", + })).toBeNull(); + expect(parseAttentionNotchToast({ + eventKind: "agent_needs_you", + treatment: "alert", + title: "Agent needs you", + tone: "chartreuse", + })).toBeNull(); + // Out of range is rejected rather than clamped: 800..15000 mirrors the + // native clamp, and a host outside it has drifted. + expect(parseAttentionNotchToast({ + eventKind: "agent_needs_you", + treatment: "alert", + title: "Agent needs you", + durationMs: 200, + })).toBeNull(); + expect(parseAttentionNotchToast({ + eventKind: "agent_needs_you", + treatment: "alert", + title: "Agent needs you", + durationMs: 60_000, + })).toBeNull(); + }); + it("preserves exact PR ids and detail tabs", () => { const pr = item({ id: "pr-1", diff --git a/apps/desktop/src/main/services/attention/attentionNotchRouter.ts b/apps/desktop/src/main/services/attention/attentionNotchRouter.ts index 663e561cd..47eef9bcd 100644 --- a/apps/desktop/src/main/services/attention/attentionNotchRouter.ts +++ b/apps/desktop/src/main/services/attention/attentionNotchRouter.ts @@ -1,21 +1,36 @@ import type { AppNavigationRequest, AttentionAction, + AttentionEventKind, AttentionItem, AttentionNotchSettings, + AttentionNotchToast, + AttentionNotchToastTreatment, AttentionSnapshot, + AttentionTone, OpenProjectBinding, } from "../../../shared/types"; import { ATTENTION_CONTRACT_VERSION, + ATTENTION_NOTCH_TOAST_MAX_DURATION_MS, + ATTENTION_NOTCH_TOAST_MIN_DURATION_MS, + ATTENTION_NOTCH_TOAST_TREATMENTS, + ATTENTION_TONES, DEFAULT_ATTENTION_NOTCH_REVEAL_MODE, isAttentionNotchRevealMode, } from "../../../shared/types/attention"; import type { AttentionNotchOutput } from "./attentionNotchHelper"; -const MAX_NOTCH_ITEMS = 256; +// The write cap must stay under the helper's own read cap, or a snapshot the +// router happily accepts is silently dropped on the far side of the pipe. +const MAX_NOTCH_ITEMS = 64; const MAX_NOTCH_ACTIONS = 12; -const MAX_SNAPSHOT_BYTES = 512 * 1024; +const MAX_SNAPSHOT_BYTES = 192 * 1024; +const MAX_TOAST_TITLE_LENGTH = 256; +const MAX_TOAST_SUBTITLE_LENGTH = 512; +const MAX_TOAST_ITEM_ID_LENGTH = 512; +const TOAST_TREATMENTS = new Set(ATTENTION_NOTCH_TOAST_TREATMENTS); +const TONES = new Set(ATTENTION_TONES); const ATTENTION_PHASES = new Set([ "starting", "running", @@ -238,6 +253,23 @@ function isAttentionItem(value: unknown): value is AttentionItem { return true; } +const ATTENTION_COUNT_KEYS = [ + "needsYou", + "working", + "done", + "total", + "machinesOnline", + "machinesTotal", +] as const; + +function isAttentionCounts(value: unknown): boolean { + if (!isRecord(value)) return false; + return ATTENTION_COUNT_KEYS.every((key) => { + const count = value[key]; + return Number.isSafeInteger(count) && Number(count) >= 0; + }); +} + export function parseAttentionNotchSnapshot(input: unknown): AttentionSnapshot | null { if (!isRecord(input)) return null; try { @@ -255,12 +287,50 @@ export function parseAttentionNotchSnapshot(input: unknown): AttentionSnapshot | || input.items.length > MAX_NOTCH_ITEMS || !input.items.every(isAttentionItem) || (input.itemsTruncated !== undefined && typeof input.itemsTruncated !== "boolean") + || (input.counts !== undefined && input.counts !== null && !isAttentionCounts(input.counts)) ) { return null; } return input as AttentionSnapshot; } +/** + * A toast is an event, not state: a malformed one is dropped rather than + * clamped, so a drifted renderer cannot quietly pin the surface open. + */ +export function parseAttentionNotchToast(input: unknown): AttentionNotchToast | null { + if (!isRecord(input)) return null; + if ( + typeof input.eventKind !== "string" + || !ATTENTION_EVENTS.has(input.eventKind) + || typeof input.treatment !== "string" + || !TOAST_TREATMENTS.has(input.treatment) + || !isNonEmptyString(input.title, MAX_TOAST_TITLE_LENGTH) + || !isNullableString(input.subtitle, MAX_TOAST_SUBTITLE_LENGTH) + || !isNullableString(input.itemId, MAX_TOAST_ITEM_ID_LENGTH) + || (input.tone != null && (typeof input.tone !== "string" || !TONES.has(input.tone))) + || ( + input.durationMs != null + && ( + !Number.isSafeInteger(input.durationMs) + || Number(input.durationMs) < ATTENTION_NOTCH_TOAST_MIN_DURATION_MS + || Number(input.durationMs) > ATTENTION_NOTCH_TOAST_MAX_DURATION_MS + ) + ) + ) { + return null; + } + return { + itemId: input.itemId == null ? null : String(input.itemId), + eventKind: input.eventKind as AttentionEventKind, + treatment: input.treatment as AttentionNotchToastTreatment, + title: input.title, + subtitle: input.subtitle == null ? null : String(input.subtitle), + tone: input.tone == null ? null : (input.tone as AttentionTone), + durationMs: input.durationMs == null ? null : Number(input.durationMs), + }; +} + export function parseAttentionNotchSettings(input: unknown): AttentionNotchSettings | null { if (!isRecord(input)) return null; if ( @@ -280,6 +350,11 @@ export function parseAttentionNotchSettings(input: unknown): AttentionNotchSetti input.expandedPanelEnabled !== undefined && typeof input.expandedPanelEnabled !== "boolean" ) + || ( + input.automaticRevealEnabled !== undefined + && typeof input.automaticRevealEnabled !== "boolean" + ) + || (input.tickerEnabled !== undefined && typeof input.tickerEnabled !== "boolean") ) { return null; } @@ -289,6 +364,8 @@ export function parseAttentionNotchSettings(input: unknown): AttentionNotchSetti ? input.revealMode : DEFAULT_ATTENTION_NOTCH_REVEAL_MODE, expandedPanelEnabled: input.expandedPanelEnabled !== false, + automaticRevealEnabled: input.automaticRevealEnabled !== false, + tickerEnabled: input.tickerEnabled !== false, preferredDisplayId: input.preferredDisplayId == null ? null : Number(input.preferredDisplayId), diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 72782b987..b33c512b2 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -20,6 +20,7 @@ import { IPC } from "../../../shared/ipc"; import type { AttentionItem, AttentionNotchSettings, + AttentionNotchToast, AttentionSnapshot, } from "../../../shared/types/attention"; import { ATTENTION_CONTRACT_VERSION } from "../../../shared/types/attention"; @@ -105,6 +106,7 @@ import { authorizeRecentProjectRuntimeRoot } from "../projects/recentProjectRunt import { parseAttentionNotchSettings, parseAttentionNotchSnapshot, + parseAttentionNotchToast, } from "../attention/attentionNotchRouter"; import { AttentionAccountCoordinator } from "../attention/attentionAccountCoordinator"; import type { @@ -1589,6 +1591,7 @@ export function registerIpc({ builtInBrowserService, productAnalyticsService, publishAttentionNotchSnapshot, + publishAttentionNotchToast, updateAttentionNotchSettings, getAttentionNotchHealth, retryAttentionNotch, @@ -1616,6 +1619,7 @@ export function registerIpc({ builtInBrowserService?: ReturnType | null; productAnalyticsService?: ProductAnalyticsService; publishAttentionNotchSnapshot?: (snapshot: AttentionSnapshot) => void; + publishAttentionNotchToast?: (toast: AttentionNotchToast) => void; updateAttentionNotchSettings?: (settings: AttentionNotchSettings) => void; getAttentionNotchHealth?: () => import("../../../shared/types").AttentionNotchHealth; retryAttentionNotch?: () => import("../../../shared/types").AttentionNotchHealth; @@ -1858,6 +1862,7 @@ export function registerIpc({ [IPC.accountRenameMachine]: new Set(["machineKey", "customName"]), [IPC.accountRemoveMachine]: new Set(["machineKey"]), [IPC.attentionNotchPublishSnapshot]: new Set(["items"]), + [IPC.attentionNotchPublishToast]: new Set(["title", "subtitle"]), }; const redactIpcArgsForChannel = (channel: string, args: unknown[]): unknown[] => { @@ -3209,6 +3214,12 @@ export function registerIpc({ publishAttentionNotchSnapshot?.(snapshot); }); + ipcMain.handle(IPC.attentionNotchPublishToast, async (_event, input: unknown) => { + const toast = parseAttentionNotchToast(input); + if (!toast) throw new Error("Invalid Attention Notch toast."); + publishAttentionNotchToast?.(toast); + }); + ipcMain.handle(IPC.attentionNotchUpdateSettings, async (_event, input: unknown) => { const settings = parseAttentionNotchSettings(input); if (!settings) throw new Error("Invalid Attention Notch settings."); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 15e08c806..a98d452c9 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -1216,6 +1216,11 @@ declare global { publishSnapshot: ( snapshot: import("../shared/types").AttentionSnapshot, ) => Promise; + // Optional like `onRefreshRequested`: the web adapter has no notch at + // all, so every call site must optional-chain through it. + publishToast?: ( + toast: import("../shared/types").AttentionNotchToast, + ) => Promise; updateSettings: ( settings: import("../shared/types").AttentionNotchSettings, ) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index c12f5c3d7..d2cced96d 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -4893,6 +4893,9 @@ contextBridge.exposeInMainWorld("ade", { attentionNotch: { publishSnapshot: async (snapshot: AttentionSnapshot): Promise => ipcRenderer.invoke(IPC.attentionNotchPublishSnapshot, snapshot), + publishToast: async ( + toast: import("../shared/types").AttentionNotchToast, + ): Promise => ipcRenderer.invoke(IPC.attentionNotchPublishToast, toast), updateSettings: async (settings: AttentionNotchSettings): Promise => ipcRenderer.invoke(IPC.attentionNotchUpdateSettings, settings), getHealth: async (): Promise => diff --git a/apps/desktop/src/renderer/components/attention/attentionNotchLocalSettings.test.ts b/apps/desktop/src/renderer/components/attention/attentionNotchLocalSettings.test.ts index fb4a5080d..cfc7e9864 100644 --- a/apps/desktop/src/renderer/components/attention/attentionNotchLocalSettings.test.ts +++ b/apps/desktop/src/renderer/components/attention/attentionNotchLocalSettings.test.ts @@ -30,6 +30,8 @@ describe("attention notch local settings", () => { expect(readAttentionNotchPresentation()).toEqual({ revealMode: "hover", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, }); window.localStorage.setItem("ade:attention:notch-reveal-mode", "telepathy"); @@ -38,10 +40,17 @@ describe("attention notch local settings", () => { it("round-trips every presentation mode independently from full disable", () => { for (const revealMode of ["minimal", "hover", "click"] as const) { - writeAttentionNotchPresentation({ revealMode, expandedPanelEnabled: false }); + writeAttentionNotchPresentation({ + revealMode, + expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: true, + }); expect(readAttentionNotchPresentation()).toEqual({ revealMode, expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: true, }); } writeAttentionNotchEnabled(false); @@ -63,6 +72,8 @@ describe("attention notch local settings", () => { enabled: false, revealMode: "minimal", expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: false, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: true, @@ -73,6 +84,8 @@ describe("attention notch local settings", () => { expect(readAttentionNotchPresentation()).toEqual({ revealMode: "minimal", expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: false, }); expect(observed).toMatchObject({ enabled: false, @@ -83,27 +96,43 @@ describe("attention notch local settings", () => { }); it("prefers the synced presentation and falls back to this Mac's cache", () => { - writeAttentionNotchPresentation({ revealMode: "click", expandedPanelEnabled: false }); + writeAttentionNotchPresentation({ + revealMode: "click", + expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: false, + }); // Nothing synced yet: the local cache is the whole answer, so an offline or // signed-out launch opens the notch the way this Mac last had it. expect(resolveAttentionNotchPresentation(null)).toEqual({ revealMode: "click", expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: false, }); const synced = attentionPreferencesWithNotchPresentation(DEFAULT_ATTENTION_PREFERENCES, { revealMode: "minimal", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, }); expect(resolveAttentionNotchPresentation(synced)).toEqual({ revealMode: "minimal", expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, }); }); it("ignores a synced reveal mode this build has never heard of", () => { - writeAttentionNotchPresentation({ revealMode: "minimal", expandedPanelEnabled: true }); + writeAttentionNotchPresentation({ + revealMode: "minimal", + expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, + }); const preferences = { ...DEFAULT_ATTENTION_PREFERENCES, account: { diff --git a/apps/desktop/src/renderer/components/attention/attentionNotchLocalSettings.ts b/apps/desktop/src/renderer/components/attention/attentionNotchLocalSettings.ts index a9c57b65e..b6142dfd8 100644 --- a/apps/desktop/src/renderer/components/attention/attentionNotchLocalSettings.ts +++ b/apps/desktop/src/renderer/components/attention/attentionNotchLocalSettings.ts @@ -12,6 +12,10 @@ import { const ATTENTION_NOTCH_ENABLED_KEY = "ade:attention:notch-enabled"; const ATTENTION_NOTCH_REVEAL_MODE_KEY = "ade:attention:notch-reveal-mode"; const ATTENTION_NOTCH_EXPANDED_PANEL_KEY = "ade:attention:notch-expanded-panel"; +// New settings get new keys: the three above are frozen wire for anyone who +// has already made a choice on this Mac. +const ATTENTION_NOTCH_AUTO_REVEAL_KEY = "ade:attention:notch-auto-reveal"; +const ATTENTION_NOTCH_TICKER_KEY = "ade:attention:notch-ticker"; const ATTENTION_NOTCH_SETTINGS_CHANGED_EVENT = "ade:attention-notch-settings-changed"; /** @@ -22,12 +26,16 @@ const ATTENTION_NOTCH_SETTINGS_CHANGED_EVENT = "ade:attention-notch-settings-cha export type AttentionNotchPresentation = { revealMode: AttentionNotchRevealMode; expandedPanelEnabled: boolean; + automaticRevealEnabled: boolean; + tickerEnabled: boolean; }; /** What a Mac that has never been configured gets: today's behaviour. */ export const DEFAULT_ATTENTION_NOTCH_PRESENTATION: AttentionNotchPresentation = { revealMode: DEFAULT_ATTENTION_NOTCH_REVEAL_MODE, expandedPanelEnabled: true, + automaticRevealEnabled: true, + tickerEnabled: true, }; function readLocalItem(key: string): string | null { @@ -65,6 +73,8 @@ export function readAttentionNotchPresentation(): AttentionNotchPresentation { ? revealMode : DEFAULT_ATTENTION_NOTCH_PRESENTATION.revealMode, expandedPanelEnabled: readLocalItem(ATTENTION_NOTCH_EXPANDED_PANEL_KEY) !== "false", + automaticRevealEnabled: readLocalItem(ATTENTION_NOTCH_AUTO_REVEAL_KEY) !== "false", + tickerEnabled: readLocalItem(ATTENTION_NOTCH_TICKER_KEY) !== "false", }; } @@ -76,6 +86,11 @@ export function writeAttentionNotchPresentation( ATTENTION_NOTCH_EXPANDED_PANEL_KEY, String(presentation.expandedPanelEnabled), ); + writeLocalItem( + ATTENTION_NOTCH_AUTO_REVEAL_KEY, + String(presentation.automaticRevealEnabled), + ); + writeLocalItem(ATTENTION_NOTCH_TICKER_KEY, String(presentation.tickerEnabled)); } /** @@ -96,6 +111,12 @@ export function resolveAttentionNotchPresentation( expandedPanelEnabled: typeof account?.notchExpandedPanel === "boolean" ? account.notchExpandedPanel : local.expandedPanelEnabled, + automaticRevealEnabled: typeof account?.notchAutomaticReveal === "boolean" + ? account.notchAutomaticReveal + : local.automaticRevealEnabled, + tickerEnabled: typeof account?.notchTicker === "boolean" + ? account.notchTicker + : local.tickerEnabled, }; } @@ -110,6 +131,8 @@ export function attentionPreferencesWithNotchPresentation( ...preferences.account, notchRevealMode: presentation.revealMode, notchExpandedPanel: presentation.expandedPanelEnabled, + notchAutomaticReveal: presentation.automaticRevealEnabled, + notchTicker: presentation.tickerEnabled, }, }; } @@ -121,6 +144,8 @@ export function persistAttentionNotchSettings( writeAttentionNotchPresentation({ revealMode: settings.revealMode, expandedPanelEnabled: settings.expandedPanelEnabled, + automaticRevealEnabled: settings.automaticRevealEnabled, + tickerEnabled: settings.tickerEnabled, }); if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent( @@ -178,6 +203,8 @@ export function attentionNotchSettingsFromPreferences( enabled, revealMode: presentation.revealMode, expandedPanelEnabled: presentation.expandedPanelEnabled, + automaticRevealEnabled: presentation.automaticRevealEnabled, + tickerEnabled: presentation.tickerEnabled, preferredDisplayId: null, hideDetails: normalized.account.hideDetails, celebrationsEnabled: normalized.account.celebrationsEnabled, diff --git a/apps/desktop/src/renderer/components/attention/attentionPresentation.ts b/apps/desktop/src/renderer/components/attention/attentionPresentation.ts index bafc04c17..dc4142c3b 100644 --- a/apps/desktop/src/renderer/components/attention/attentionPresentation.ts +++ b/apps/desktop/src/renderer/components/attention/attentionPresentation.ts @@ -2,6 +2,7 @@ import type { AttentionActionKind, AttentionItem, AttentionPhase, + AttentionTone, } from "../../../shared/types"; import type { CanonicalSessionPhase } from "../../../shared/sessionCanonicalState"; import { @@ -12,34 +13,12 @@ import { } from "../../../shared/sessionStatusPresentation"; /** - * Attention's tone vocabulary is `sessionStatusPresentation`'s five hues plus - * two that only pull requests ever use. The session five keep their meanings - * exactly — see the one-hue-one-meaning rule in - * `apps/desktop/src/shared/sessionStatusPresentation.ts`: - * - * blue work is happening, nothing is asked of you - * amber YOUR MOVE — and nothing else, ever - * emerald finished cleanly, you have not looked yet - * red it broke - * neutral true, but not actionable - * - * Exactly one phase in this module is amber: `needs_you`. - * - * `violet` carries "a human review is outstanding" — neither "your move" (it is - * usually someone else's) nor an outcome, and without its own hue it would have - * to borrow amber, which is precisely the erosion the rule forbids. `cyan` is - * currently unused by any phase; it stays in the union and the stylesheets as - * the spare for the next PR-side distinction, and must never be handed to a - * session state — those five hues are settled. + * The tone vocabulary itself moved to `shared/types/attention.ts` — the native + * notch protocol carries it on the wire, so the main process has to name it + * too. Its meanings, and the reason `violet` and `cyan` exist at all, are + * documented there. Exactly one phase in this module is amber: `needs_you`. */ -export type AttentionTone = - | "amber" - | "red" - | "violet" - | "blue" - | "cyan" - | "emerald" - | "neutral"; +export type { AttentionTone }; export type AttentionPhasePresentation = { label: string; diff --git a/apps/desktop/src/renderer/components/attention/useAttentionSync.test.tsx b/apps/desktop/src/renderer/components/attention/useAttentionSync.test.tsx index b730783b6..f5c243bf3 100644 --- a/apps/desktop/src/renderer/components/attention/useAttentionSync.test.tsx +++ b/apps/desktop/src/renderer/components/attention/useAttentionSync.test.tsx @@ -21,9 +21,13 @@ import { publishAccountStatus, SIGNED_OUT_ACCOUNT } from "../../lib/account"; import { attentionNotchSettingsFromPreferences, attentionNotchSnapshotSignature, + attentionToastForTransition, materializeAttentionNotchSnapshot, refreshAttentionSnapshot, useAttentionSync, + MAX_NOTCH_PROJECTION_ITEMS, + TOAST_ITEM_COOLDOWN_MS, + TOAST_MIN_INTERVAL_MS, } from "./useAttentionSync"; const originalAde = window.ade; @@ -288,6 +292,8 @@ describe("useAttentionSync", () => { enabled: false, revealMode: "click", expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: false, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: true, @@ -861,7 +867,17 @@ describe("Attention Notch renderer bridge", () => { streamId: null, revision: 8, generatedAt: "2026-07-28T12:00:02.000Z", + // `recentActivity` is dropped from the projection; `runningItem` has none. items: [runningItem], + itemsTruncated: false, + counts: { + needsYou: 0, + working: 1, + done: 0, + total: 1, + machinesOnline: 1, + machinesTotal: 1, + }, tombstones: [], }); }); @@ -878,10 +894,14 @@ describe("Attention Notch renderer bridge", () => { }, true, { revealMode: "click", expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: true, })).toEqual({ enabled: true, revealMode: "click", expandedPanelEnabled: false, + automaticRevealEnabled: false, + tickerEnabled: true, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: false, @@ -936,4 +956,202 @@ describe("Attention Notch renderer bridge", () => { expect(routingChanged.items[0]?.machine.accountMachineKey) .toBe("canonical-machine-1"); }); + + it("publishes a bounded, priority-ordered projection with full-set counts", () => { + const itemsById: Record = {}; + // 60 ambient rows plus 5 that need you: more than the projection carries, + // so the ordering and the counts both have to be doing real work. + for (let index = 0; index < 60; index += 1) { + const id = `working-${String(index).padStart(3, "0")}`; + itemsById[id] = { + ...runningItem, + id, + fingerprint: `${id}:1`, + preview: `x`.repeat(400), + recentActivity: ["Read package.json", "Ran tests"], + }; + } + for (let index = 0; index < 5; index += 1) { + const id = `needs-${index}`; + itemsById[id] = { + ...runningItem, + id, + fingerprint: `${id}:1`, + eventKind: "agent_needs_you", + phase: "needs_you", + activityTier: "signal", + }; + } + attentionStore.setState({ + revision: 9, + generatedAt: "2026-07-28T12:00:02.000Z", + itemsById, + }); + + const snapshot = materializeAttentionNotchSnapshot(); + expect(snapshot.items).toHaveLength(MAX_NOTCH_PROJECTION_ITEMS); + expect(snapshot.itemsTruncated).toBe(true); + // Needs-you first, always: the slice is the top of Activity's own order. + expect(snapshot.items.slice(0, 5).map((entry) => entry.id).sort()).toEqual([ + "needs-0", + "needs-1", + "needs-2", + "needs-3", + "needs-4", + ]); + for (const entry of snapshot.items) { + expect(entry.preview.length).toBeLessThanOrEqual(160); + expect(entry).not.toHaveProperty("recentActivity"); + } + // The store's own objects must be untouched by the projection. + expect(attentionStore.getState().itemsById["working-000"]?.preview).toHaveLength(400); + expect(attentionStore.getState().itemsById["working-000"]?.recentActivity) + .toHaveLength(2); + // Counts describe the whole account, not the 48 rows that travelled. + expect(snapshot.counts).toEqual({ + needsYou: 5, + working: 60, + done: 0, + total: 65, + machinesOnline: 1, + machinesTotal: 1, + }); + }); + + it("republishes when only the counts changed", () => { + const base = materializeAttentionNotchSnapshot(); + expect(attentionNotchSnapshotSignature({ + ...base, + counts: { + needsYou: 1, + working: 0, + done: 0, + total: 1, + machinesOnline: 1, + machinesTotal: 1, + }, + })).not.toBe(attentionNotchSnapshotSignature({ ...base, counts: undefined })); + }); +}); + +describe("Attention Notch toast decisions", () => { + const signalItem: AttentionItem = { + ...runningItem, + id: "agent-needs-you", + eventKind: "agent_needs_you", + phase: "needs_you", + activityTier: "signal", + title: "Approve the command", + preview: "rm -rf ./build", + privacyPreview: "Agent needs your attention", + }; + + const decide = ( + overrides: Partial[0]> = {}, + ) => attentionToastForTransition({ + items: [signalItem], + previousPhases: new Map([[signalItem.id, "running"]]), + lastToastAtByItem: new Map(), + lastToastAt: 0, + availabilityState: "ready", + automaticRevealEnabled: true, + hideDetails: false, + now: 1_000_000, + ...overrides, + }); + + it("fires once on a phase transition and never on first sighting", () => { + expect(decide()).toMatchObject({ + itemId: "agent-needs-you", + eventKind: "agent_needs_you", + treatment: "alert", + title: "Approve the command", + subtitle: "rm -rf ./build", + }); + expect(decide({ previousPhases: new Map() })).toBeNull(); + // Same phase twice is not a transition. + expect(decide({ + previousPhases: new Map([[signalItem.id, "needs_you"]]), + })).toBeNull(); + }); + + it("holds an item quiet for ten minutes after its own toast", () => { + const lastToastAtByItem = new Map([[signalItem.id, 1_000_000 - 60_000]]); + expect(decide({ lastToastAtByItem })).toBeNull(); + expect(decide({ + lastToastAtByItem, + now: 1_000_000 - 60_000 + TOAST_ITEM_COOLDOWN_MS, + })).not.toBeNull(); + }); + + it("rate-limits the account to one toast per five seconds", () => { + expect(decide({ lastToastAt: 1_000_000 - (TOAST_MIN_INTERVAL_MS - 1) })).toBeNull(); + expect(decide({ lastToastAt: 1_000_000 - TOAST_MIN_INTERVAL_MS })).not.toBeNull(); + }); + + it("emits only the highest-priority transition in a burst, and drops the rest", () => { + const failed: AttentionItem = { + ...signalItem, + id: "agent-failed", + eventKind: "agent_failed", + phase: "failed", + activityTier: "signal", + title: "Build failed", + }; + const toast = decide({ + items: [failed, signalItem], + previousPhases: new Map([ + [failed.id, "running"], + [signalItem.id, "running"], + ]), + }); + // needs_you outranks failed in ATTENTION_PHASE_PRIORITY. + expect(toast?.itemId).toBe(signalItem.id); + }); + + it("stays silent when suppressed", () => { + expect(decide({ automaticRevealEnabled: false })).toBeNull(); + expect(decide({ availabilityState: "degraded" })).toBeNull(); + expect(decide({ availabilityState: null })).toBeNull(); + // Ambient rows never interrupt, however much they change. + expect(decide({ + items: [{ ...signalItem, activityTier: "ambient" }], + })).toBeNull(); + expect(decide({ + items: [{ ...signalItem, seenAt: "2026-07-28T12:00:00.000Z" }], + })).toBeNull(); + expect(decide({ + items: [{ ...signalItem, dismissedAt: "2026-07-28T12:00:00.000Z" }], + })).toBeNull(); + }); + + it("uses privacy copy when hide-details is on", () => { + expect(decide({ hideDetails: true })).toMatchObject({ + title: "Agent update", + subtitle: "Agent needs your attention", + }); + expect(decide({ + hideDetails: true, + items: [{ ...signalItem, kind: "pull_request", destination: { + kind: "pull_request", + number: 42, + tab: "overview", + } }], + })).toMatchObject({ title: "Pull request update" }); + }); + + it("maps a merge to the celebration treatment", () => { + expect(decide({ + items: [{ + ...signalItem, + kind: "pull_request", + eventKind: "pr_merged", + phase: "merged", + activityTier: "signal", + destination: { kind: "pull_request", number: 42, tab: "overview" }, + }], + previousPhases: new Map([[signalItem.id, "merge_ready"]]), + })).toMatchObject({ treatment: "celebration" }); + }); }); + diff --git a/apps/desktop/src/renderer/components/attention/useAttentionSync.ts b/apps/desktop/src/renderer/components/attention/useAttentionSync.ts index bcb1f8d77..9d23736c5 100644 --- a/apps/desktop/src/renderer/components/attention/useAttentionSync.ts +++ b/apps/desktop/src/renderer/components/attention/useAttentionSync.ts @@ -2,7 +2,16 @@ import { useEffect, useMemo, useRef } from "react"; import { ATTENTION_CONTRACT_VERSION, + activityItemTier, + attentionPhasePriority, + sanitizeAttentionPreview, + type AttentionCounts, + type AttentionEventKind, + type AttentionItem, type AttentionNotchSettings, + type AttentionNotchToast, + type AttentionNotchToastTreatment, + type AttentionPhase, type AttentionPresence, type AttentionSnapshot, } from "../../../shared/types"; @@ -12,6 +21,7 @@ import { useAttentionStore, } from "../../state/attentionStore"; import { useAccountStatus } from "../../lib/account"; +import { activitySections, summarizeActivity } from "./activityPriority"; import { attentionNotchSettingsFromPreferences, persistAttentionNotchSettings, @@ -29,6 +39,18 @@ const HIDDEN_PRESENCE_INTERVAL_MS = 120_000; const ATTENTION_SNAPSHOT_TIMEOUT_MS = 75_000; const NOTCH_SETTINGS_REFRESH_MS = 60_000; const MAX_VISIBLE_PRESENCE_ITEMS = 64; +/** + * The notch receives a projection, not the store. The pipe has a byte budget + * the router enforces at 192KB, and a 400-row account blows through it — so we + * ship the top-priority slice and let `counts` carry the honest totals. + */ +export const MAX_NOTCH_PROJECTION_ITEMS = 48; +const MAX_NOTCH_PREVIEW_LENGTH = 160; +const MAX_TOAST_SUBTITLE_LENGTH = 120; +/** One toast per item per 10 minutes, however many times it flaps. */ +export const TOAST_ITEM_COOLDOWN_MS = 600_000; +/** And at most one toast every 5s across the whole account. */ +export const TOAST_MIN_INTERVAL_MS = 5_000; type AttentionAccountScope = { generation: number; @@ -103,6 +125,8 @@ function failClosedAttentionNotchSettings(): AttentionNotchSettings { // chosen mode here stops a lost account load from re-covering the menu bar. revealMode: presentation.revealMode, expandedPanelEnabled: presentation.expandedPanelEnabled, + automaticRevealEnabled: presentation.automaticRevealEnabled, + tickerEnabled: presentation.tickerEnabled, preferredDisplayId: null, hideDetails: true, celebrationsEnabled: false, @@ -184,8 +208,42 @@ export async function refreshAttentionSnapshot(): Promise { return promise; } +/** + * One projected row: bounded preview, no `recentActivity` (the notch never + * renders it and it is the single largest field on a busy agent row). Built + * fresh so the store's objects are never mutated. + */ +function projectAttentionNotchItem(item: AttentionItem): AttentionItem { + const { recentActivity: _recentActivity, ...rest } = item; + return { + ...rest, + preview: item.preview.length > MAX_NOTCH_PREVIEW_LENGTH + ? `${item.preview.slice(0, MAX_NOTCH_PREVIEW_LENGTH - 1)}…` + : item.preview, + }; +} + +function attentionNotchCounts(items: readonly AttentionItem[]): AttentionCounts { + const summary = summarizeActivity(items); + return { + needsYou: summary.needsYouCount, + working: summary.workingCount, + done: summary.doneCount, + total: summary.trackedCount, + machinesOnline: summary.machinesOnline, + machinesTotal: summary.machinesTotal, + }; +} + export function materializeAttentionNotchSnapshot(): AttentionSnapshot { const state = attentionStore.getState(); + const allItems = Object.values(state.itemsById); + // Activity's own order, flattened: needs-you, then working, then done. + // `activitySections` already drops dismissed and expired rows. + const ordered = activitySections(allItems).flatMap((section) => section.items); + const projected = ordered + .slice(0, MAX_NOTCH_PROJECTION_ITEMS) + .map(projectAttentionNotchItem); return { contractVersion: ATTENTION_CONTRACT_VERSION, scope: state.snapshotScope ?? (attentionAccountOwnerId ? "account" : "machine"), @@ -200,7 +258,9 @@ export function materializeAttentionNotchSnapshot(): AttentionSnapshot { streamId: state.streamId, revision: state.revision, generatedAt: state.generatedAt ?? new Date().toISOString(), - items: Object.values(state.itemsById), + items: projected, + itemsTruncated: projected.length < ordered.length, + counts: attentionNotchCounts(allItems), tombstones: [], }; } @@ -213,6 +273,9 @@ export function attentionNotchSnapshotSignature( snapshot.availability ?? null, snapshot.streamId ?? null, snapshot.revision, + // Counts derive from the full set, so a row falling off the projection can + // change them without changing a single published row. + snapshot.counts ?? null, ...[...snapshot.items] .sort((left, right) => left.id.localeCompare(right.id)) .map((item) => [ @@ -230,10 +293,155 @@ export function attentionNotchSnapshotSignature( ]); } -async function publishAttentionNotchSnapshot(): Promise { +async function publishAttentionNotchSnapshot( + snapshot = materializeAttentionNotchSnapshot(), +): Promise { const api = typeof window !== "undefined" ? window.ade?.attentionNotch : null; if (!api) return; - await api.publishSnapshot(materializeAttentionNotchSnapshot()); + await api.publishSnapshot(snapshot); +} + +/** + * Every event kind, mapped. Left exhaustive on purpose: a new kind added to + * `AttentionEventKind` must fail the build here rather than silently arrive as + * an `info` toast for something that broke. + */ +const TOAST_TREATMENT_BY_EVENT: Record = { + agent_running: "info", + agent_needs_you: "alert", + agent_failed: "alert", + agent_completed: "info", + pr_checks_failing: "alert", + pr_review_requested: "info", + pr_changes_requested: "alert", + pr_merge_ready: "success", + pr_merged: "celebration", + pr_opened: "info", + pr_closed: "info", +}; + +const PRIVACY_TOAST_TITLE: Record = { + agent: "Agent update", + pull_request: "Pull request update", +}; + +export type AttentionToastDecisionInput = { + items: readonly AttentionItem[]; + /** Phase each item carried the last time this window looked at it. */ + previousPhases: ReadonlyMap; + lastToastAtByItem: ReadonlyMap; + lastToastAt: number; + availabilityState: string | null; + automaticRevealEnabled: boolean; + hideDetails: boolean; + now?: number; +}; + +/** + * The whole "should this interrupt" decision, pure and injectable so cooldown + * and rate-limit behaviour can be driven deterministically in tests. + * + * When several items transition inside one merge exactly one toast is emitted — + * the highest-priority one — and the rest are dropped rather than queued: a + * queue would still be announcing the last burst when the next one arrives. + */ +export function attentionToastForTransition( + input: AttentionToastDecisionInput, +): AttentionNotchToast | null { + const now = input.now ?? Date.now(); + if (!input.automaticRevealEnabled) return null; + // Degraded/signed-out snapshots carry last-known rows; announcing one as if + // it just happened would be a lie about freshness. + if (input.availabilityState !== "ready") return null; + if (now - input.lastToastAt < TOAST_MIN_INTERVAL_MS) return null; + + let best: AttentionItem | null = null; + for (const item of input.items) { + const previous = input.previousPhases.get(item.id); + // First sighting is not a transition: a window that just opened would + // otherwise toast the entire backlog. + if (previous === undefined || previous === item.phase) continue; + if (activityItemTier(item) !== "signal") continue; + if (item.seenAt != null || item.dismissedAt != null) continue; + const lastForItem = input.lastToastAtByItem.get(item.id); + if (lastForItem !== undefined && now - lastForItem < TOAST_ITEM_COOLDOWN_MS) continue; + if ( + best === null + || attentionPhasePriority(item.phase) < attentionPhasePriority(best.phase) + || ( + attentionPhasePriority(item.phase) === attentionPhasePriority(best.phase) + && item.id.localeCompare(best.id) < 0 + ) + ) { + best = item; + } + } + if (!best) return null; + + const treatment = TOAST_TREATMENT_BY_EVENT[best.eventKind] ?? "info"; + return { + itemId: best.id, + eventKind: best.eventKind, + treatment, + title: input.hideDetails ? PRIVACY_TOAST_TITLE[best.kind] : best.title, + subtitle: input.hideDetails + ? best.privacyPreview + : sanitizeAttentionPreview(best.preview, MAX_TOAST_SUBTITLE_LENGTH), + tone: null, + durationMs: null, + }; +} + +const notchToastPhases = new Map(); +const notchToastCooldownByItem = new Map(); +let notchLastToastAt = 0; + +function resetAttentionToastState(): void { + notchToastPhases.clear(); + notchToastCooldownByItem.clear(); + notchLastToastAt = 0; +} + +/** + * Runs on the same store subscription that publishes the snapshot, so a merge + * can never publish rows without having considered whether one of them earned + * an announcement. + */ +function emitAttentionNotchToast(snapshot: AttentionSnapshot): void { + const items = Object.values(attentionStore.getState().itemsById); + const presentation = readAttentionNotchPresentation(); + const toast = attentionToastForTransition({ + items, + previousPhases: notchToastPhases, + lastToastAtByItem: notchToastCooldownByItem, + lastToastAt: notchLastToastAt, + availabilityState: snapshot.availability?.state ?? null, + automaticRevealEnabled: presentation.automaticRevealEnabled, + // The notch takes hide-details' fail-closed default, exactly like + // `failClosedAttentionNotchSettings`: it paints over the menu bar of a Mac + // whose owner may have walked away. + hideDetails: attentionStore.getState().preferences?.account?.hideDetails !== false, + }); + // Record every phase seen, toast or not, so a suppressed transition is not + // re-detected as new on the next merge. + const liveIds = new Set(); + for (const item of items) { + liveIds.add(item.id); + notchToastPhases.set(item.id, item.phase); + } + for (const id of [...notchToastPhases.keys()]) { + if (!liveIds.has(id)) notchToastPhases.delete(id); + } + const now = Date.now(); + for (const [id, at] of [...notchToastCooldownByItem]) { + if (now - at >= TOAST_ITEM_COOLDOWN_MS) notchToastCooldownByItem.delete(id); + } + if (!toast?.itemId) return; + notchLastToastAt = now; + notchToastCooldownByItem.set(toast.itemId, now); + void Promise.resolve( + window.ade?.attentionNotch?.publishToast?.(toast), + ).catch(() => {}); } async function refreshAttentionNotchSettings( @@ -421,6 +629,7 @@ export function useAttentionSync(routeSurfaceVisible: boolean): void { identityPromise = null; notchSettingsRefreshPromise = null; notchSettingsRefreshed = null; + resetAttentionToastState(); attentionStore.getState().resetStream(); } const accountScope = { @@ -431,10 +640,15 @@ export function useAttentionSync(routeSurfaceVisible: boolean): void { let active = true; let unsubscribe = () => {}; const publishNotchIfChanged = () => { - const nextSignature = attentionNotchSnapshotSignature(); + const snapshot = materializeAttentionNotchSnapshot(); + // Toasts ride this same pass, and deliberately before the signature + // gate: a phase transition is exactly what earns an announcement, and a + // republish-suppressed frame must still be able to carry one. + emitAttentionNotchToast(snapshot); + const nextSignature = attentionNotchSnapshotSignature(snapshot); if (nextSignature === lastNotchSignature) return; lastNotchSignature = nextSignature; - void publishAttentionNotchSnapshot().catch(() => {}); + void publishAttentionNotchSnapshot(snapshot).catch(() => {}); }; void prepareAttentionNotchForAccount(accountScope).then((prepared) => { if (!active || !prepared || !isCurrentAccountScope(accountScope)) return; diff --git a/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx b/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx index b72f06fd4..e06991c54 100644 --- a/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx +++ b/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx @@ -8,6 +8,8 @@ import { SpeakerHigh, ArrowsOutSimple, CursorClick, + Eye, + Waveform, } from "@phosphor-icons/react"; import { @@ -118,6 +120,8 @@ export function useActivitySettings() { setPreferences((current) => attentionPreferencesWithNotchPresentation(current, { revealMode: settings.revealMode, expandedPanelEnabled: settings.expandedPanelEnabled, + automaticRevealEnabled: settings.automaticRevealEnabled, + tickerEnabled: settings.tickerEnabled, })); }), []); @@ -430,6 +434,35 @@ export function ActivitySettingsControls({ /> } /> + + setNotchPresentation({ automaticRevealEnabled })} + /> + } + /> + setNotchPresentation({ tickerEnabled })} + /> + } + />
) : null} @@ -590,6 +623,37 @@ export function ActivitySettingsControls({ /> } /> + + setNotchPresentation({ automaticRevealEnabled })} + /> + } + /> + setNotchPresentation({ tickerEnabled })} + /> + } + /> Date: Sat, 1 Aug 2026 09:01:56 -0400 Subject: [PATCH 12/19] =?UTF-8?q?activity(p7):=20iOS=20parity=20+=20widget?= =?UTF-8?q?=20=E2=80=94=20headerless=20singleton=20lanes,=20desktop=20lane?= =?UTF-8?q?=20ordering,=20full=20badge/tone=20vocabulary,=20multi-session?= =?UTF-8?q?=20lock-screen=20widget=20with=20smart=20deep-link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- apps/ios/ADE.xcodeproj/project.pbxproj | 18 + apps/ios/ADE/App/DeepLinkRouter.swift | 11 + .../ADE/Shared/ActivityRowPresentation.swift | 6 + .../Shared/ActivityWidgetPresentation.swift | 149 ++++++ .../Views/Activity/ActivityDrawerModel.swift | 61 +++ apps/ios/ADE/Views/Activity/ActivityRow.swift | 14 +- apps/ios/ADE/Views/Work/WorkLaneOrder.swift | 269 +++++++++++ .../ADE/Views/Work/WorkRootComponents.swift | 104 +++-- .../Views/Work/WorkRootScreen+Actions.swift | 7 +- apps/ios/ADE/Views/Work/WorkRootScreen.swift | 69 ++- .../Work/WorkSessionCanonicalState.swift | 153 +++++- .../ADE/Views/Work/WorkSessionGrouping.swift | 195 +++++++- .../Work/WorkStatusAndFormattingHelpers.swift | 43 +- apps/ios/ADETests/ADETests.swift | 9 +- .../ActivityWidgetPresentationTests.swift | 155 +++++++ .../WorkSessionCanonicalStateTests.swift | 163 ++++++- .../ADETests/WorkSessionGroupingTests.swift | 436 ++++++++++++++++++ apps/ios/ADEWidgets/ADELockScreenWidget.swift | 124 ++++- 18 files changed, 1875 insertions(+), 111 deletions(-) create mode 100644 apps/ios/ADE/Shared/ActivityWidgetPresentation.swift create mode 100644 apps/ios/ADE/Views/Work/WorkLaneOrder.swift create mode 100644 apps/ios/ADETests/ActivityWidgetPresentationTests.swift create mode 100644 apps/ios/ADETests/WorkSessionGroupingTests.swift diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 0586cabd3..e547d3b90 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -21,10 +21,14 @@ AA1100000000000000000001 /* ADESharedContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000001 /* ADESharedContainer.swift */; }; AA1100000000000000000002 /* ADESharedModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000002 /* ADESharedModels.swift */; }; AA1100000000000000000004 /* ActivityRowPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000004 /* ActivityRowPresentation.swift */; }; + AA1100000000000000000005 /* ActivityWidgetPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000005 /* ActivityWidgetPresentation.swift */; }; AA1100000000000000000014 /* ActivityRowPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000004 /* ActivityRowPresentation.swift */; }; + AA1100000000000000000015 /* ActivityWidgetPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000005 /* ActivityWidgetPresentation.swift */; }; D3000000000000000000002A /* ActivityRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3000000000000000000001A /* ActivityRow.swift */; }; E200000000000000000000A2 /* HubLiveStrip.swift in Sources */ = {isa = PBXBuildFile; fileRef = D200000000000000000000A2 /* HubLiveStrip.swift */; }; AC7600000000000000000001 /* ActivityRowPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7500000000000000000001 /* ActivityRowPresentationTests.swift */; }; + AC7600000000000000000003 /* WorkSessionGroupingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7500000000000000000003 /* WorkSessionGroupingTests.swift */; }; + AC7600000000000000000004 /* ActivityWidgetPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7500000000000000000004 /* ActivityWidgetPresentationTests.swift */; }; AC7600000000000000000002 /* HubProjectPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7500000000000000000002 /* HubProjectPresentationTests.swift */; }; AA1100000000000000000003 /* ADESharedTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000003 /* ADESharedTheme.swift */; }; AA1100000000000000000011 /* ADESharedContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000001 /* ADESharedContainer.swift */; }; @@ -112,6 +116,7 @@ E1000000000000000000003A /* WorkReasoningCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000003A /* WorkReasoningCard.swift */; }; E1000000000000000000003B /* WorkActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000003B /* WorkActivityIndicator.swift */; }; E1000000000000000000003C /* WorkSessionGrouping.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000003C /* WorkSessionGrouping.swift */; }; + E10000000000000000000F01 /* WorkLaneOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000F01 /* WorkLaneOrder.swift */; }; H10000000000000000000001 /* CtoRootScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = H10000000000000000000010 /* CtoRootScreen.swift */; }; H10000000000000000000002 /* CtoSessionDestinationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = H10000000000000000000011 /* CtoSessionDestinationView.swift */; }; H10000000000000000000005 /* CtoIdentityEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = H10000000000000000000014 /* CtoIdentityEditor.swift */; }; @@ -319,9 +324,12 @@ AA1000000000000000000001 /* ADESharedContainer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADESharedContainer.swift; path = ADE/Shared/ADESharedContainer.swift; sourceTree = ""; }; AA1000000000000000000002 /* ADESharedModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADESharedModels.swift; path = ADE/Shared/ADESharedModels.swift; sourceTree = ""; }; AA1000000000000000000004 /* ActivityRowPresentation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityRowPresentation.swift; path = ADE/Shared/ActivityRowPresentation.swift; sourceTree = ""; }; + AA1000000000000000000005 /* ActivityWidgetPresentation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityWidgetPresentation.swift; path = ADE/Shared/ActivityWidgetPresentation.swift; sourceTree = ""; }; D3000000000000000000001A /* ActivityRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityRow.swift; path = ADE/Views/Activity/ActivityRow.swift; sourceTree = ""; }; D200000000000000000000A2 /* HubLiveStrip.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HubLiveStrip.swift; path = ADE/Views/Hub/HubLiveStrip.swift; sourceTree = ""; }; AC7500000000000000000001 /* ActivityRowPresentationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityRowPresentationTests.swift; path = ADETests/ActivityRowPresentationTests.swift; sourceTree = ""; }; + AC7500000000000000000003 /* WorkSessionGroupingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSessionGroupingTests.swift; path = ADETests/WorkSessionGroupingTests.swift; sourceTree = ""; }; + AC7500000000000000000004 /* ActivityWidgetPresentationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityWidgetPresentationTests.swift; path = ADETests/ActivityWidgetPresentationTests.swift; sourceTree = ""; }; AC7500000000000000000002 /* HubProjectPresentationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HubProjectPresentationTests.swift; path = ADETests/HubProjectPresentationTests.swift; sourceTree = ""; }; AA1000000000000000000003 /* ADESharedTheme.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADESharedTheme.swift; path = ADE/Shared/ADESharedTheme.swift; sourceTree = ""; }; AA0000000000000000000002 /* ADEWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ADEWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -407,6 +415,7 @@ D10000000000000000000049 /* TerminalSessionScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TerminalSessionScreen.swift; path = ADE/Views/Work/TerminalSessionScreen.swift; sourceTree = ""; }; D1000000000000000000004A /* SwiftTermSessionView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SwiftTermSessionView.swift; path = ADE/Views/Work/SwiftTermSessionView.swift; sourceTree = ""; }; D1000000000000000000003C /* WorkSessionGrouping.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSessionGrouping.swift; path = ADE/Views/Work/WorkSessionGrouping.swift; sourceTree = ""; }; + D10000000000000000000F01 /* WorkLaneOrder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkLaneOrder.swift; path = ADE/Views/Work/WorkLaneOrder.swift; sourceTree = ""; }; H10000000000000000000010 /* CtoRootScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoRootScreen.swift; path = ADE/Views/Cto/CtoRootScreen.swift; sourceTree = ""; }; H10000000000000000000011 /* CtoSessionDestinationView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoSessionDestinationView.swift; path = ADE/Views/Cto/CtoSessionDestinationView.swift; sourceTree = ""; }; H10000000000000000000014 /* CtoIdentityEditor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoIdentityEditor.swift; path = ADE/Views/Cto/CtoIdentityEditor.swift; sourceTree = ""; }; @@ -845,6 +854,7 @@ D10000000000000000000049 /* TerminalSessionScreen.swift */, D1000000000000000000004A /* SwiftTermSessionView.swift */, D1000000000000000000003C /* WorkSessionGrouping.swift */, + D10000000000000000000F01 /* WorkLaneOrder.swift */, D1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift */, D1000000000000000000002D /* WorkChatRichCardViews.swift */, D10000000000000000000055 /* WorkChatPrViews.swift */, @@ -981,6 +991,7 @@ AA1000000000000000000002 /* ADESharedModels.swift */, AA1000000000000000000003 /* ADESharedTheme.swift */, AA1000000000000000000004 /* ActivityRowPresentation.swift */, + AA1000000000000000000005 /* ActivityWidgetPresentation.swift */, AA5100000000000000000004 /* AttentionActionIntents.swift */, AE00000000000000000000A5 /* ADEAgentActivityAttributes.swift */, ); @@ -1080,6 +1091,8 @@ AC1000000000000000000008 /* ClipPairingHandoffTests.swift */, D30000000000000000000005 /* ActivityDrawerModelTests.swift */, AC7500000000000000000001 /* ActivityRowPresentationTests.swift */, + AC7500000000000000000003 /* WorkSessionGroupingTests.swift */, + AC7500000000000000000004 /* ActivityWidgetPresentationTests.swift */, AC7500000000000000000002 /* HubProjectPresentationTests.swift */, AC7100000000000000000001 /* ActivityContractDecodingTests.swift */, AC7300000000000000000001 /* ActivityAckQueueTests.swift */, @@ -1369,6 +1382,7 @@ AA1100000000000000000002 /* ADESharedModels.swift in Sources */, AA1100000000000000000003 /* ADESharedTheme.swift in Sources */, AA1100000000000000000004 /* ActivityRowPresentation.swift in Sources */, + AA1100000000000000000005 /* ActivityWidgetPresentation.swift in Sources */, C10000000000000000000002 /* ADECodeRenderingCache.swift in Sources */, 0A1E077A24A5367ED58900F9 /* ADEDesignSystem.swift in Sources */, E7C4AFA1DEBFC844E11CC907 /* SpeechDictationService.swift in Sources */, @@ -1536,6 +1550,7 @@ E10000000000000000000049 /* TerminalSessionScreen.swift in Sources */, E1000000000000000000004A /* SwiftTermSessionView.swift in Sources */, E1000000000000000000003C /* WorkSessionGrouping.swift in Sources */, + E10000000000000000000F01 /* WorkLaneOrder.swift in Sources */, E1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift in Sources */, E1000000000000000000002D /* WorkChatRichCardViews.swift in Sources */, E10000000000000000000055 /* WorkChatPrViews.swift in Sources */, @@ -1589,6 +1604,8 @@ AC1100000000000000000008 /* ClipPairingHandoffTests.swift in Sources */, D30000000000000000000015 /* ActivityDrawerModelTests.swift in Sources */, AC7600000000000000000001 /* ActivityRowPresentationTests.swift in Sources */, + AC7600000000000000000003 /* WorkSessionGroupingTests.swift in Sources */, + AC7600000000000000000004 /* ActivityWidgetPresentationTests.swift in Sources */, AC7600000000000000000002 /* HubProjectPresentationTests.swift in Sources */, AC7200000000000000000001 /* ActivityContractDecodingTests.swift in Sources */, AC7400000000000000000001 /* ActivityAckQueueTests.swift in Sources */, @@ -1614,6 +1631,7 @@ AA1100000000000000000012 /* ADESharedModels.swift in Sources */, AA1100000000000000000013 /* ADESharedTheme.swift in Sources */, AA1100000000000000000014 /* ActivityRowPresentation.swift in Sources */, + AA1100000000000000000015 /* ActivityWidgetPresentation.swift in Sources */, AA5200000000000000000011 /* ADEWidgetBundle.swift in Sources */, AA5200000000000000000014 /* ADELockScreenWidget.swift in Sources */, AA5100000000000000000024 /* AttentionActionIntents.swift in Sources */, diff --git a/apps/ios/ADE/App/DeepLinkRouter.swift b/apps/ios/ADE/App/DeepLinkRouter.swift index 77d1c5652..ee8138856 100644 --- a/apps/ios/ADE/App/DeepLinkRouter.swift +++ b/apps/ios/ADE/App/DeepLinkRouter.swift @@ -132,6 +132,17 @@ final class DeepLinkRouter { isValidLinearIssueBranch(url: url) else { return } routeLinearIssue(identifier: identifier, url: url) + case "activity": + // `ade://activity` — the lock-screen widget's fallback when nothing in + // particular is asking for you. It opens the drawer rather than picking a + // row on the user's behalf, which is the honest answer to "show me + // everything". Takes no path or query, so there is nothing to validate. + SyncService.shared?.attentionDrawerPresented = true + NotificationCenter.default.post( + name: .adeDeepLinkRequested, + object: nil, + userInfo: ["kind": "activity", "identifier": ""] + ) default: return } diff --git a/apps/ios/ADE/Shared/ActivityRowPresentation.swift b/apps/ios/ADE/Shared/ActivityRowPresentation.swift index 26a13b648..29d0dff9a 100644 --- a/apps/ios/ADE/Shared/ActivityRowPresentation.swift +++ b/apps/ios/ADE/Shared/ActivityRowPresentation.swift @@ -164,6 +164,12 @@ public enum ActivityPhaseVocabulary { return .init(label: "Planning", tone: .violet, glyph: .planning, showsElapsed: true, prominent: false, active: true) case "waiting": return .init(label: "Waiting", tone: .neutral, glyph: .waiting, showsElapsed: false, prominent: false, active: false) + // The two resting states a session sits in between turns. Neither is a + // claim on anyone, so both are neutral and neither ticks. + case "ready": + return .init(label: "Ready", tone: .neutral, glyph: nil, showsElapsed: false, prominent: false, active: false) + case "idle": + return .init(label: "Idle", tone: .neutral, glyph: nil, showsElapsed: false, prominent: false, active: false) case "stopped": return .init(label: "Stopped", tone: .neutral, glyph: nil, showsElapsed: false, prominent: false, active: false) case "ended": diff --git a/apps/ios/ADE/Shared/ActivityWidgetPresentation.swift b/apps/ios/ADE/Shared/ActivityWidgetPresentation.swift new file mode 100644 index 000000000..b9f61a6b2 --- /dev/null +++ b/apps/ios/ADE/Shared/ActivityWidgetPresentation.swift @@ -0,0 +1,149 @@ +import SwiftUI + +/// The two things every Activity surface needs beyond the pure mapper in +/// `ActivityRowPresentation`: the tone → colour binding, and the lock-screen +/// widget's ranking. +/// +/// Both live here rather than in the widget because the widget target cannot +/// see the app's views and the app cannot see the widget's — a copy on each +/// side is exactly how the lock screen ends up describing a session in words +/// and colours the app does not use. +/// +/// **iOS 17 constraint.** Compiled into the widget extension. Keep it free of +/// any newer API, and of anything from the app's design system beyond +/// `ADESharedTheme`, which is in both targets. + +/// Tone token → the shared palette. The five session hues keep the meanings +/// documented on `ActivityTone`; violet is the PR-review hue. +public func activityToneColor(_ tone: ActivityTone) -> Color { + switch tone { + case .blue: return ADESharedTheme.statusRunning + case .violet: return ADESharedTheme.statusReview + case .amber: return ADESharedTheme.warningAmber + case .emerald: return ADESharedTheme.statusSuccess + case .red: return ADESharedTheme.statusFailed + case .neutral: return ADESharedTheme.statusIdle + } +} + +public enum ActivityWidgetPresentation { + /// Where a widget tap lands when nothing in particular is asking. Handled by + /// `DeepLinkRouter`'s workspace route, which is the Activity surface's home. + public static let activityURL = URL(string: "ade://activity") ?? URL(fileURLWithPath: "/") + + /// One line of the rectangular family: a glyph, a title, and the phase. + public struct CompactLine: Identifiable, Hashable, Sendable { + public let id: String + public let title: String + public let phaseLabel: String + public let tone: ActivityTone + public let glyph: ActivityGlyph? + + public init( + id: String, + title: String, + phaseLabel: String, + tone: ActivityTone, + glyph: ActivityGlyph? + ) { + self.id = id + self.title = title + self.phaseLabel = phaseLabel + self.tone = tone + self.glyph = glyph + } + } + + /// Items worth showing at all: not dismissed, not expired. + public static func visibleItems( + _ items: [AccountAttentionItem], + now: Date = Date() + ) -> [AccountAttentionItem] { + items.filter { item in + item.dismissedAt == nil && (item.expiresAt.map { $0 > now } ?? true) + } + } + + /// Where a tap goes. + /// + /// The widget used to follow whatever sorted first, which on a busy account + /// is usually a PR notification — so the one glance-and-tap surface people + /// have could not reliably reach the session actually blocked on them. + /// Ranking is explicit now: the top needs-you row, else the top live agent, + /// else the Activity surface itself. The item URLs already carry + /// `?item=&event=&accountMachineKey=`, so the ack path is unchanged. + public static func deepLink( + for items: [AccountAttentionItem], + now: Date = Date() + ) -> URL { + let visible = visibleItems(items, now: now) + let ordered = ranked(visible) + if let needsYou = ordered.first(where: { $0.phase == .needsYou }), + let url = needsYou.deepLinkURL { + return url + } + // `active`, not `AccountAttentionItem.isLive`: the latter counts a PR + // with failing checks as live, and a tap that lands on PR traffic when + // an agent is mid-turn is exactly the miss this ranking exists to stop. + let live = ordered.first { ActivityPhaseVocabulary.presentation(for: $0.phase).active } + if let live, let url = live.deepLinkURL { + return url + } + return activityURL + } + + /// Priority order for the rectangular lines: the same band ranking the + /// drawer uses, then the freshest, then a stable id tiebreak so two equal + /// rows never trade places between 60-second timeline entries. + public static func ranked(_ items: [AccountAttentionItem]) -> [AccountAttentionItem] { + items.sorted { lhs, rhs in + let lhsBand = activityBandRank(ActivityPhaseVocabulary.band(for: lhs.phase)) + let rhsBand = activityBandRank(ActivityPhaseVocabulary.band(for: rhs.phase)) + if lhsBand != rhsBand { return lhsBand < rhsBand } + let lhsProminent = ActivityPhaseVocabulary.presentation(for: lhs.phase).prominent + let rhsProminent = ActivityPhaseVocabulary.presentation(for: rhs.phase).prominent + if lhsProminent != rhsProminent { return lhsProminent } + if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt > rhs.updatedAt } + return lhs.id < rhs.id + } + } + + /// The top `limit` rows as compact lines. `hideDetails` swaps in the + /// publisher's privacy preview, which is what that setting is for — a lock + /// screen is readable by anyone holding the phone. + public static func compactLines( + for items: [AccountAttentionItem], + limit: Int = 2, + hideDetails: Bool = false, + now: Date = Date() + ) -> [CompactLine] { + ranked(visibleItems(items, now: now)).prefix(limit).map { item in + let presentation = ActivityPhaseVocabulary.presentation(for: item.phase) + return CompactLine( + id: item.id, + title: title(for: item, hideDetails: hideDetails), + phaseLabel: presentation.label, + tone: presentation.tone, + glyph: presentation.glyph + ) + } + } + + /// How many visible rows the compact lines left off, for the "+N more" tail. + public static func overflowCount( + for items: [AccountAttentionItem], + limit: Int = 2, + now: Date = Date() + ) -> Int { + max(0, visibleItems(items, now: now).count - limit) + } + + private static func title(for item: AccountAttentionItem, hideDetails: Bool) -> String { + guard hideDetails else { + let title = item.title.trimmingCharacters(in: .whitespacesAndNewlines) + return title.isEmpty ? "Untitled session" : title + } + let privateTitle = item.privacyPreview.trimmingCharacters(in: .whitespacesAndNewlines) + return privateTitle.isEmpty ? "Activity update" : privateTitle + } +} diff --git a/apps/ios/ADE/Views/Activity/ActivityDrawerModel.swift b/apps/ios/ADE/Views/Activity/ActivityDrawerModel.swift index 4f6540a7c..627cdc3f9 100644 --- a/apps/ios/ADE/Views/Activity/ActivityDrawerModel.swift +++ b/apps/ios/ADE/Views/Activity/ActivityDrawerModel.swift @@ -74,6 +74,10 @@ public final class ActivityDrawerModel: ObservableObject { @Published public private(set) var inbox: [ActivityRowPresentation] = [] /// Machine presence from the snapshot, for the offline banners. @Published public private(set) var machines: [AccountAttentionMachine] = [] + /// Where each offline machine's work lives, so a surface scoped to one + /// project — the Work list — can tell whether the outage touches it. The + /// drawer itself banners per row and does not need this. + @Published public private(set) var offlineScopes: [ActivityOfflineScope] = [] @Published public private(set) var unreadCount: Int = 0 @Published public private(set) var source: ActivitySource = .none /// The relay capped the account feed. Surfaced so the drawer can say so @@ -153,6 +157,7 @@ public final class ActivityDrawerModel: ObservableObject { sessions = [] inbox = [] machines = [] + offlineScopes = [] source = .none itemsTruncated = false recomputeUnreadCount() @@ -181,6 +186,7 @@ public final class ActivityDrawerModel: ObservableObject { .filter { $0.isPullRequest || ($0.needsInbox && $0.band == .done) } .sortedByActivityPriority() self.machines = machines + offlineScopes = Self.offlineScopes(from: items) self.source = source itemsTruncated = truncated pruneSeenItems(activeIDs: Set(rows.map(\.id))) @@ -340,6 +346,26 @@ public final class ActivityDrawerModel: ObservableObject { defaults.set(Array(seenItemIDs).sorted(), forKey: Self.seenItemIDsKey) } + /// One scope entry per offline (machine, project, lane) an item mentions. + /// Deduplicated so a machine with forty stalled rows contributes one entry + /// per lane, not forty. + static func offlineScopes(from items: [AccountAttentionItem]) -> [ActivityOfflineScope] { + var seen: Set = [] + var scopes: [ActivityOfflineScope] = [] + for item in items where !item.machine.online { + let scope = ActivityOfflineScope( + machineKey: item.machine.machineKey, + machineName: nonEmpty(item.machine.name) ?? "Mac", + lastSeenAt: item.machine.lastSeenAt, + projectId: item.project.projectId, + laneId: nonEmpty(item.laneId) + ) + guard seen.insert(scope.id).inserted else { continue } + scopes.append(scope) + } + return scopes + } + private static func nonEmpty(_ value: String?) -> String? { guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } @@ -347,6 +373,41 @@ public final class ActivityDrawerModel: ObservableObject { } } +/// Where one offline machine's work lives. Presence plus scope, nothing else — +/// the row vocabulary stays in `ActivityRowPresentation`. +public struct ActivityOfflineScope: Identifiable, Hashable, Sendable { + public let machineKey: String + public let machineName: String + public let lastSeenAt: Date? + public let projectId: String + public let laneId: String? + + public var id: String { "\(machineKey)|\(projectId)|\(laneId ?? "")" } + + public init( + machineKey: String, + machineName: String, + lastSeenAt: Date?, + projectId: String, + laneId: String? + ) { + self.machineKey = machineKey + self.machineName = machineName + self.lastSeenAt = lastSeenAt + self.projectId = projectId + self.laneId = laneId + } + + /// "last seen 2h ago" — the same wording and the same clock arithmetic the + /// row banner uses, so two banners for one machine cannot disagree. + public func lastSeenLabel(now: Date = Date()) -> String? { + guard let lastSeenAt, + let duration = ActivityRowPresentation.formatDuration(now.timeIntervalSince(lastSeenAt)) + else { return nil } + return "last seen \(duration) ago" + } +} + // MARK: - Workspace snapshot projection extension ActivityDrawerModel { diff --git a/apps/ios/ADE/Views/Activity/ActivityRow.swift b/apps/ios/ADE/Views/Activity/ActivityRow.swift index 8c3f70f2a..f3d71a7ba 100644 --- a/apps/ios/ADE/Views/Activity/ActivityRow.swift +++ b/apps/ios/ADE/Views/Activity/ActivityRow.swift @@ -14,18 +14,8 @@ enum ActivityRowDensity { case compact } -/// Tone token → the app's palette. The five session hues keep the meanings -/// documented on `ActivityTone`; violet is the PR-review hue. -func activityToneColor(_ tone: ActivityTone) -> Color { - switch tone { - case .blue: return ADESharedTheme.statusRunning - case .violet: return ADESharedTheme.statusReview - case .amber: return ADESharedTheme.warningAmber - case .emerald: return ADESharedTheme.statusSuccess - case .red: return ADESharedTheme.statusFailed - case .neutral: return ADESharedTheme.statusIdle - } -} +// `activityToneColor` lives in `ADE/Shared/ActivityWidgetPresentation.swift` so +// the widget extension can read the same table; it is not app-only. struct ActivityRow: View { let row: ActivityRowPresentation diff --git a/apps/ios/ADE/Views/Work/WorkLaneOrder.swift b/apps/ios/ADE/Views/Work/WorkLaneOrder.swift new file mode 100644 index 000000000..7aff135fc --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkLaneOrder.swift @@ -0,0 +1,269 @@ +import Foundation + +/// Lane ordering and the singleton/headerless rule for the Work session list. +/// +/// The iOS port of `apps/desktop/src/renderer/components/terminals/workLaneOrder.ts` +/// plus the `headerlessLaneIds` memo in `SessionListPane.tsx`. Both are pure — +/// callers derive `quiet` / `pinned` / activity and hand over plain data — so the +/// rules are unit-testable without mounting a list, exactly as on desktop. +/// +/// iOS has no manual lane drag and no per-lane handoff jobs today, so those two +/// inputs are always at their defaults here. They are modelled anyway: they are +/// the two rules that decide whether a lane KEEPS its header, and leaving them +/// out is how a port silently loses a rule the moment the surface catches up. + +// MARK: - Sort mode + +/// Mirrors the desktop `WorkLaneSortMode`. iOS exposes no sort-mode picker yet, +/// so every call site passes `.created` — the mode desktop also falls back to. +enum WorkLaneSortMode: String, CaseIterable { + case activity + case name + case created + case manual +} + +func normalizeWorkLaneSortMode(_ value: String?) -> WorkLaneSortMode { + WorkLaneSortMode(rawValue: value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "") + ?? .created +} + +// MARK: - Filing tier + +/// Pins outrank quietness: a pinned lane stays up top even when every one of its +/// sessions has settled. +enum WorkLaneTier: Int { + case pinned = 0 + case active = 1 + case quiet = 2 +} + +func workLaneTier(pinned: Bool, quiet: Bool) -> WorkLaneTier { + if pinned { return .pinned } + return quiet ? .quiet : .active +} + +/// One lane's ordering inputs. `lastActivityAt` is the most recent session +/// activity in the lane, nil when the lane has none. +struct WorkLaneOrderInput: Equatable { + let id: String + let name: String + let laneType: String + let createdAt: String + let lastActivityAt: Date? + /// Every session in the lane is settled, snoozed, or archived. + let quiet: Bool + let pinned: Bool + + init( + id: String, + name: String, + laneType: String, + createdAt: String, + lastActivityAt: Date? = nil, + quiet: Bool = false, + pinned: Bool = false + ) { + self.id = id + self.name = name + self.laneType = laneType + self.createdAt = createdAt + self.lastActivityAt = lastActivityAt + self.quiet = quiet + self.pinned = pinned + } + + var tier: WorkLaneTier { workLaneTier(pinned: pinned, quiet: quiet) } +} + +/// Descending compare that always sorts nil last, in either direction. +private func compareDescNilsLast(_ a: Date?, _ b: Date?) -> Int { + if a == b { return 0 } + guard let a else { return 1 } + guard let b else { return -1 } + if a == b { return 0 } + return a > b ? -1 : 1 +} + +private func compareByMode( + _ a: WorkLaneOrderInput, + _ b: WorkLaneOrderInput, + mode: WorkLaneSortMode, + manualIndex: [String: Int] +) -> Int { + switch mode { + case .activity: + return compareDescNilsLast(a.lastActivityAt, b.lastActivityAt) + case .name: + let result = a.name.compare( + b.name, + options: [.caseInsensitive, .numeric, .diacriticInsensitive] + ) + return result == .orderedSame ? 0 : (result == .orderedAscending ? -1 : 1) + case .manual: + // A lane with no recorded position sorts after every placed lane, in the + // fallback order below, so a newly created lane appears predictably rather + // than jumping to an arbitrary slot. + let ai = manualIndex[a.id] ?? Int.max + let bi = manualIndex[b.id] ?? Int.max + return ai == bi ? 0 : (ai < bi ? -1 : 1) + case .created: + return compareDescNilsLast( + workLaneOrderParsedDate(a.createdAt), + workLaneOrderParsedDate(b.createdAt) + ) + } +} + +/// Full ordering key, in priority order: +/// +/// 1. the primary lane, always first — in every mode +/// 2. tier: pinned → active → quiet +/// 3. the active sort mode +/// 4. createdAt desc, then id — a total, stable tiebreak +/// +/// Step 4 exists so the comparator is total: without it, two lanes that tie on +/// the mode key can swap places between renders and the list visibly jitters. +func compareWorkLanes( + _ a: WorkLaneOrderInput, + _ b: WorkLaneOrderInput, + mode: WorkLaneSortMode = .created, + manualIndex: [String: Int] = [:] +) -> Int { + let aPrimary = a.laneType == "primary" ? 0 : 1 + let bPrimary = b.laneType == "primary" ? 0 : 1 + if aPrimary != bPrimary { return aPrimary - bPrimary } + + let tierDelta = a.tier.rawValue - b.tier.rawValue + if tierDelta != 0 { return tierDelta } + + let modeDelta = compareByMode(a, b, mode: mode, manualIndex: manualIndex) + if modeDelta != 0 { return modeDelta } + + let createdDelta = compareDescNilsLast( + workLaneOrderParsedDate(a.createdAt), + workLaneOrderParsedDate(b.createdAt) + ) + if createdDelta != 0 { return createdDelta } + + let idResult = a.id.compare(b.id) + return idResult == .orderedSame ? 0 : (idResult == .orderedAscending ? -1 : 1) +} + +/// Order lanes by the full key. `inputs` supplies the derived quiet/pinned/ +/// activity facts a `LaneSummary` does not carry; a lane with no entry is +/// treated as active and unpinned. +func orderWorkLanes( + _ lanes: [LaneSummary], + inputs: [String: WorkLaneOrderInput], + mode: WorkLaneSortMode = .created, + manualOrder: [String] = [] +) -> [LaneSummary] { + var manualIndex: [String: Int] = [:] + for (index, id) in manualOrder.enumerated() where manualIndex[id] == nil { + manualIndex[id] = index + } + return lanes.enumerated().sorted { lhs, rhs in + let a = inputs[lhs.element.id] ?? WorkLaneOrderInput(lane: lhs.element) + let b = inputs[rhs.element.id] ?? WorkLaneOrderInput(lane: rhs.element) + let delta = compareWorkLanes(a, b, mode: mode, manualIndex: manualIndex) + // Enumeration offset keeps the sort stable for genuinely equal lanes, which + // the total comparator above only leaves for duplicate ids. + return delta == 0 ? lhs.offset < rhs.offset : delta < 0 + }.map(\.element) +} + +extension WorkLaneOrderInput { + init(lane: LaneSummary, lastActivityAt: Date? = nil, quiet: Bool = false, pinned: Bool = false) { + self.init( + id: lane.id, + name: lane.name, + laneType: lane.laneType, + createdAt: lane.createdAt, + lastActivityAt: lastActivityAt, + quiet: quiet, + pinned: pinned + ) + } +} + +// MARK: - Headerless (singleton) lanes + +/// One lane's inputs to the singleton rule. +struct WorkHeaderlessLaneInput: Equatable { + let laneId: String + /// TOP-LEVEL rows only, from the UNFILTERED roster. A chat with terminal + /// children is one unit and must not summon a header; reading the unfiltered + /// roster is what stops the list reshaping while the user types in search. + let topLevelSessionCount: Int + /// A pin is an explicit "keep this where I can see it" — the pin glyph lives + /// on the header, so a pinned lane keeps it. + let pinned: Bool + /// A pending handoff placeholder counts as a second row, so a lane does not + /// lose its header for the second it takes the real session to land. Always + /// false today: iOS has no handoff-job records. + let hasPendingHandoff: Bool + /// A lane whose machine is unreachable keeps its header: the header is the + /// only thing that can carry the dimmed, folded-shut group treatment, and + /// "that machine is gone" is precisely when its work should stop occupying a + /// prime row. + let machineOnline: Bool + + init( + laneId: String, + topLevelSessionCount: Int, + pinned: Bool = false, + hasPendingHandoff: Bool = false, + machineOnline: Bool = true + ) { + self.laneId = laneId + self.topLevelSessionCount = topLevelSessionCount + self.pinned = pinned + self.hasPendingHandoff = hasPendingHandoff + self.machineOnline = machineOnline + } +} + +/// Lanes that render their group WITHOUT a header — the singleton form. +/// +/// One chat per lane is the common workflow, and it used to produce +/// header/card/header/card with the lane name usually duplicating the chat +/// title. The lone card carries the lane identity instead +/// (`WorkSessionGroup.isHeaderless` → `showsLaneIdentity` on the row). +/// +/// Manual sort opts out entirely: a singleton has no header to grab. +func workHeaderlessLaneIds( + _ lanes: [WorkHeaderlessLaneInput], + sortMode: WorkLaneSortMode = .created +) -> Set { + guard sortMode != .manual else { return [] } + var ids: Set = [] + for lane in lanes { + if lane.pinned { continue } + if lane.hasPendingHandoff { continue } + if !lane.machineOnline { continue } + if lane.topLevelSessionCount == 1 { ids.insert(lane.laneId) } + } + return ids +} + +// MARK: - Shared date parsing + +private let workLaneOrderISO8601: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter +}() + +private let workLaneOrderISO8601NoFractional: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter +}() + +func workLaneOrderParsedDate(_ value: String?) -> Date? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return workLaneOrderISO8601.date(from: trimmed) ?? workLaneOrderISO8601NoFractional.date(from: trimmed) +} diff --git a/apps/ios/ADE/Views/Work/WorkRootComponents.swift b/apps/ios/ADE/Views/Work/WorkRootComponents.swift index 97e4fc647..7281af7c0 100644 --- a/apps/ios/ADE/Views/Work/WorkRootComponents.swift +++ b/apps/ios/ADE/Views/Work/WorkRootComponents.swift @@ -557,6 +557,9 @@ struct WorkSessionListRow: View { let isArchived: Bool let transitionNamespace: Namespace.ID? var compact: Bool = false + /// True when no lane header sits above this row — the singleton form, where + /// the row carries the lane identity itself. + var showsLaneIdentity: Bool = true var isLaneDeleting = false @Binding var selectedSessionId: String? let isSelecting: Bool @@ -653,7 +656,8 @@ struct WorkSessionListRow: View { isMuted: isMuted, transitionNamespace: transitionNamespace, isSelectedTransitionSource: selectedSessionId == session.id, - compact: compact + compact: compact, + showsLaneIdentity: showsLaneIdentity ) .equatable() } @@ -1075,6 +1079,13 @@ private struct WorkSessionRowRenderSignature: Equatable { let pullRequestState: String? let status: String let canonicalPhase: CanonicalSessionPhase + /// The rendered capsule and dot, not just the phase behind them: the badge + /// kind moves on its own when planning starts or stops, and the tone is what + /// the dot is painted with. + let badgeKind: SessionBadgeKind? + let rowTone: ActivityTone + let model: String? + let showsLaneIdentity: Bool let settledAt: String? let statusNote: String? let attentionRequestedAt: String? @@ -1107,7 +1118,8 @@ private struct WorkSessionRowRenderSignature: Equatable { isArchived: Bool, isMuted: Bool, isSelectedTransitionSource: Bool, - compact: Bool + compact: Bool, + showsLaneIdentity: Bool ) { self.sessionId = session.id self.title = chatSummary?.title ?? session.title @@ -1130,6 +1142,10 @@ private struct WorkSessionRowRenderSignature: Equatable { self.pullRequestState = pullRequest.map { lanePrStateLabel($0.state) } self.status = status self.canonicalPhase = canonical.phase + self.badgeKind = workSessionStatusBadge(session: session, summary: chatSummary)?.kind + self.rowTone = workSessionRowTone(session: session, summary: chatSummary) + self.model = chatSummary?.model + self.showsLaneIdentity = showsLaneIdentity self.settledAt = session.settledAt self.statusNote = session.statusNote self.attentionRequestedAt = session.attentionRequestedAt @@ -1161,6 +1177,9 @@ struct WorkSessionRow: View, Equatable { let transitionNamespace: Namespace.ID? let isSelectedTransitionSource: Bool var compact: Bool = false + /// The singleton form: no lane header above this row, so the row shows the + /// lane itself. Under a lane header the chip would just repeat the header. + var showsLaneIdentity: Bool = true private let renderSignature: WorkSessionRowRenderSignature init( @@ -1173,7 +1192,8 @@ struct WorkSessionRow: View, Equatable { isMuted: Bool = false, transitionNamespace: Namespace.ID?, isSelectedTransitionSource: Bool, - compact: Bool = false + compact: Bool = false, + showsLaneIdentity: Bool = true ) { self.session = session self.lane = lane @@ -1185,6 +1205,7 @@ struct WorkSessionRow: View, Equatable { self.transitionNamespace = transitionNamespace self.isSelectedTransitionSource = isSelectedTransitionSource self.compact = compact + self.showsLaneIdentity = showsLaneIdentity self.renderSignature = WorkSessionRowRenderSignature( session: session, lane: lane, @@ -1194,7 +1215,8 @@ struct WorkSessionRow: View, Equatable { isArchived: isArchived, isMuted: isMuted, isSelectedTransitionSource: isSelectedTransitionSource, - compact: compact + compact: compact, + showsLaneIdentity: showsLaneIdentity ) } @@ -1270,8 +1292,11 @@ struct WorkSessionRow: View, Equatable { HStack(alignment: .center, spacing: 6) { Group { if isSettled { + // Hollow, not filled: settled work is put away, and the ring says + // that without spending a solid dot on it. Tinted rather than + // white so it still carries the phase's hue. Circle() - .stroke(Color.white.opacity(0.35), lineWidth: 1) + .stroke(rowTint.opacity(0.7), lineWidth: 1) } else { Circle() .fill(rowTint) @@ -1319,25 +1344,45 @@ struct WorkSessionRow: View, Equatable { .foregroundStyle(ADEColor.textMuted) .lineLimit(1) - Text("·") - .font(.caption2) - .foregroundStyle(ADEColor.textMuted.opacity(0.5)) - - if let laneAccent = LaneColorPalette.color(forHex: lane?.color) { - Circle() - .fill(laneAccent) - .frame(width: 6, height: 6) - } else { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 10, weight: .semibold)) + // The model the turn actually runs on. iOS carried it in the chat + // summary and never showed it, so two rows on the same provider were + // indistinguishable. + if let model = renderSignature.model, !model.isEmpty { + Text("·") + .font(.caption2) + .foregroundStyle(ADEColor.textMuted.opacity(0.5)) + Text(shortModelLabel(model)) + .font(.caption2) .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(-1) + } + + // Under a lane header the lane chip only repeats the header, so it is + // spent here on the model instead. A headerless (singleton) row is the + // only thing carrying the lane, and always shows it. + if showsLaneIdentity { + Text("·") + .font(.caption2) + .foregroundStyle(ADEColor.textMuted.opacity(0.5)) + + if let laneAccent = LaneColorPalette.color(forHex: lane?.color) { + Circle() + .fill(laneAccent) + .frame(width: 6, height: 6) + } else { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(ADEColor.textMuted) + } + Text(session.laneName) + .font(.caption2) + .foregroundStyle(LaneColorPalette.color(forHex: lane?.color) ?? ADEColor.textMuted) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(-1) } - Text(session.laneName) - .font(.caption2) - .foregroundStyle(LaneColorPalette.color(forHex: lane?.color) ?? ADEColor.textMuted) - .lineLimit(1) - .truncationMode(.middle) - .layoutPriority(-1) if lane?.status.dirty == true { Circle() @@ -1417,10 +1462,11 @@ struct WorkSessionRow: View, Equatable { providerTint(chatSummary?.provider ?? session.toolType) } - /// Canonical attention capsule (needs_you / failed / stale); nil for calm - /// states so the row never shifts layout when no capsule renders. + /// The row's status capsule, in the full shared vocabulary — needs you, + /// failed, stale, working, planning, done. Nil for the resting states, so the + /// row never shifts layout to say that nothing is happening. var capsuleBadge: SessionBadge? { - canonicalState.badge + workSessionStatusBadge(session: session, summary: chatSummary) } var canonicalState: CanonicalSessionState { @@ -1442,14 +1488,20 @@ struct WorkSessionRow: View, Equatable { workIsPendingChatCreationSession(session) } + /// The status dot's hue. Reads the canonical phase through the shared tone + /// table rather than the coarse four-value status string, so the dot and the + /// capsule above it can never tell different stories. var rowTint: Color { if isPendingSyncCreation { return ADEColor.textMuted } if isArchived { return ADEColor.warning } - return workChatStatusTint(status) + return activityToneColor(renderSignature.rowTone) } var accessibilityLabel: String { var parts = [chatSummary?.title ?? session.title, session.laneName, sessionStatusLabel(for: status)] + if let model = renderSignature.model, !model.isEmpty { + parts.append(shortModelLabel(model)) + } if session.pinned { parts.append("pinned") } diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift index 45ab96673..ce83bd633 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift @@ -53,6 +53,10 @@ extension WorkRootScreen { buffers: searchTextSnapshot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? [:] : syncService.terminalBuffers ) let organization = WorkSessionOrganization(rawValue: sessionOrganizationRaw) ?? .byStatus + // A pin is an explicit "keep this where I can see it": it lifts the lane to + // the top tier and keeps its header, singleton or not. Same store the Lanes + // tab writes, so one pin means one thing across both surfaces. + let pinnedLaneIdsSnapshot = workPinnedLaneIds sessionPresentationRebuildTask = Task.detached(priority: .utility) { try? await Task.sleep(for: .milliseconds(40)) @@ -70,7 +74,8 @@ extension WorkRootScreen { orderedLanes: lanesSnapshot, pullRequests: pullRequestsSnapshot, githubPrs: githubPrsSnapshot, - deletingLaneIds: deletingLaneIds + deletingLaneIds: deletingLaneIds, + pinnedLaneIds: pinnedLaneIdsSnapshot ) await MainActor.run { guard generation == sessionPresentationRebuildGeneration, !Task.isCancelled else { return } diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index b9d338601..0a8f440a8 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -112,6 +112,9 @@ struct WorkRootSessionPresentationTaskKey: Equatable { struct WorkRootScreen: View { @Environment(\.accessibilityReduceMotion) var reduceMotion @EnvironmentObject var syncService: SyncService + /// Machine presence for the offline banner. Injected on the root content in + /// `ContentView`, the same place the bell above this list reads it from. + @EnvironmentObject private var activityDrawer: ActivityDrawerModel /// App-level dictation singleton. Re-injected into pushed composer /// destinations below since `navigationDestination` builds outside the view /// tree and does not inherit environment objects. @@ -167,6 +170,10 @@ struct WorkRootScreen: View { /// the new project's live roster while the database reload catches up. @State var loadedProjectionProjectId: String? @AppStorage("ade.work.archivedSessionIds") var archivedSessionIdsStorage = "" + /// Read-only mirror of the Lanes tab's pin store. Pins decide the top lane + /// tier and keep a lane's header, so the Work list has to see them; it never + /// writes here, so pinning stays a Lanes-tab gesture with one owner. + @AppStorage("ade.lanes.pinnedIds") private var pinnedLaneIdsStorage: String = "" @State var sessionOrganizationRaw = WorkSessionOrganization.byLane.rawValue @State var collapsedSectionIdsStorage = "" /// The project+host scope the five view-state properties above currently hold. @@ -450,6 +457,22 @@ struct WorkRootScreen: View { sessionPresentation.sessionGroups } + /// Lanes the user has pinned, read from the Lanes tab's store. + var workPinnedLaneIds: Set { + Set(pinnedLaneIdsStorage.split(separator: ",").map(String.init).filter { !$0.isEmpty }) + } + + /// Machines that own work in this project and are no longer reachable. The + /// connected host is online by definition, so anything here is a second Mac + /// whose lanes reached this list through the account feed. + var offlineMachineBanners: [WorkOfflineMachineBanner] { + workOfflineMachineBanners( + scopes: activityDrawer.offlineScopes, + activeProjectId: syncService.activeProjectId, + laneIds: Set(lanes.map(\.id)) + ) + } + var isWorkRootActive: Bool { isTabActive && path.isEmpty } @@ -562,6 +585,19 @@ struct WorkRootScreen: View { .listRowSeparator(.hidden) } + // Above the list, not per row: every row below belongs to the same + // project, so one banner explains the whole outage instead of + // repeating itself down the column. + ForEach(offlineMachineBanners) { banner in + ActivityOfflineMachineBanner( + machineName: banner.machineName, + lastSeenLabel: banner.lastSeenLabel + ) + .listRowInsets(EdgeInsets(top: 2, leading: 16, bottom: 6, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + if displaySessions.isEmpty { ADEEmptyStateView( symbol: isLive ? "bubble.left.and.bubble.right" : "terminal", @@ -892,14 +928,32 @@ struct WorkRootScreen: View { /// A quiet lane is collapsed unless explicitly expanded; every other section /// is expanded unless explicitly collapsed. + /// + /// A headerless lane is never collapsed: there is no header to collapse it + /// with, so a collapsed one would be a row the user could not get back. private func workGroupIsCollapsed(_ group: WorkSessionGroup) -> Bool { - group.isQuiet + if group.isHeaderless { return false } + return group.isQuiet ? !collapsedSectionIds.contains(group.quietOpenSectionId) : collapsedSectionIds.contains(group.id) } @ViewBuilder private func workSessionGroupRows(_ group: WorkSessionGroup) -> some View { + // The singleton form: one top-level row in the lane, so the header would be + // a divider carrying a name the row already says. The row takes the lane + // identity instead (its meta line and its "Go to lane" / PR actions). + if group.isHeaderless { + ForEach(group.sessions.filter { sessionPresentation.topLevelDisplaySessionIds.contains($0.id) }) { session in + workSessionRows(session, showsLaneIdentity: true) + } + } else { + workSessionGroupRowsWithHeader(group) + } + } + + @ViewBuilder + private func workSessionGroupRowsWithHeader(_ group: WorkSessionGroup) -> some View { let isLaneDeleting = group.laneId.map(syncService.pendingLaneDeletionIds.contains) ?? false let collapsed = workGroupIsCollapsed(group) let isQuietRow = group.isQuiet && collapsed @@ -947,7 +1001,14 @@ struct WorkRootScreen: View { // An expanded quiet lane holds only settled rows: the full card's // preview line and meta row are about work in flight, of which there is // none here. - workSessionRows(session, compact: group.isQuiet) + // + // A row under a lane header does not repeat the lane name — the header + // two rows up already says it, and the space is worth more as the model. + workSessionRows( + session, + compact: group.isQuiet, + showsLaneIdentity: group.laneId == nil + ) } } } @@ -955,7 +1016,8 @@ struct WorkRootScreen: View { @ViewBuilder private func workSessionRows( _ session: TerminalSessionSummary, - compact: Bool = false + compact: Bool = false, + showsLaneIdentity: Bool = true ) -> some View { WorkSessionListRow( session: session, @@ -970,6 +1032,7 @@ struct WorkRootScreen: View { ? sessionTransitionNamespace : nil, compact: compact, + showsLaneIdentity: showsLaneIdentity, isLaneDeleting: syncService.pendingLaneDeletionIds.contains(session.laneId), selectedSessionId: $selectedSessionTransitionId, isSelecting: isSelecting, diff --git a/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift b/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift index 849f54c92..461daa5eb 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift @@ -26,17 +26,41 @@ enum CanonicalSessionPhase: Equatable { case settled } -/// The three attention states that earn a capsule. Calm phases get no badge. +/// The states that earn a capsule on a Work row. +/// +/// The first three are the attention states — the only ones `badge` has ever +/// carried, and the only ones that survive on the `CanonicalSessionState` value +/// so nothing downstream starts treating "Working" as something to act on. The +/// rest are the descriptive half of the vocabulary, reached through +/// `workSessionStatusBadge`, which is what the row actually renders. +/// +/// The truly resting phases (ready / idle / stopped / ended) still earn no +/// capsule: their story is the neutral dot and the timestamp, and a row must not +/// shift layout to say "nothing is happening". enum SessionBadgeKind: Equatable { case needsYou case failed case stale + case working + case planning + case done } struct SessionBadge: Equatable { let kind: SessionBadgeKind - /// Short capsule copy; calm states get no badge at all. + /// Short capsule copy; resting states get no badge at all. let label: String + /// Hue token from the shared `ActivityPhaseVocabulary`, so a Work row and an + /// Activity row describing the same session cannot pick different colours. + let tone: ActivityTone + let glyph: ActivityGlyph? + + init(kind: SessionBadgeKind, label: String, tone: ActivityTone, glyph: ActivityGlyph? = nil) { + self.kind = kind + self.label = label + self.tone = tone + self.glyph = glyph + } } struct CanonicalSessionState: Equatable { @@ -53,12 +77,24 @@ struct CanonicalSessionState: Equatable { /// (e.g. the 7-day chat reclassification in `normalizedWorkChatSessionStatus`). let sessionStaleAfterSeconds: TimeInterval = 3 * 60 * 60 +/// The attention badges, worded and hued by the shared vocabulary rather than +/// by a second table that could drift away from it. private let badgeByKind: [SessionBadgeKind: SessionBadge] = [ - .needsYou: SessionBadge(kind: .needsYou, label: "Needs you"), - .failed: SessionBadge(kind: .failed, label: "Failed"), - .stale: SessionBadge(kind: .stale, label: "Stale"), + .needsYou: workSessionBadge(kind: .needsYou, phase: .needsYou), + .failed: workSessionBadge(kind: .failed, phase: .failed), + .stale: workSessionBadge(kind: .stale, phase: .stale), ] +private func workSessionBadge(kind: SessionBadgeKind, phase: AccountAttentionPhase) -> SessionBadge { + let presentation = ActivityPhaseVocabulary.presentation(for: phase) + return SessionBadge( + kind: kind, + label: presentation.label, + tone: presentation.tone, + glyph: presentation.glyph + ) +} + private func isSilentPast(_ lastActivityAt: String?, now: Date, thresholdSeconds: TimeInterval) -> Bool { guard let at = workParsedDate(lastActivityAt) else { return false } return now.timeIntervalSince(at) >= thresholdSeconds @@ -235,6 +271,76 @@ func workSessionCapsuleBadge( ).badge } +// MARK: - The full status vocabulary + +/// Canonical phase → the shared Activity phase, so a Work row reads its label +/// and its hue out of the same table the drawer, the hub strip, and the widget +/// use. The four resting phases have no Activity phase of their own and are +/// carried as additive raw values, which `ActivityPhaseVocabulary` answers with +/// the quiet neutral presentation they want. +func workActivityPhase(for phase: CanonicalSessionPhase) -> AccountAttentionPhase { + switch phase { + case .starting: return .starting + case .running: return .running + case .needsYou: return .needsYou + case .failed: return .failed + case .stale: return .stale + // Settled is a declared "this is finished and filed", which is exactly what + // the emerald `completed` presentation says. + case .settled: return .completed + case .ready: return .unrecognized("ready") + case .idle: return .unrecognized("idle") + case .stopped: return .unrecognized("stopped") + case .ended: return .unrecognized("ended") + } +} + +/// Planning is a PRESENTATION fact, never a canonical phase — the same split +/// desktop makes, where `chatActivityMode` is derived from the chat's +/// interaction mode and folded in at render time +/// (`chatSessionProjection.ts`: `interactionMode === "plan"`). +func workSessionIsPlanning(summary: AgentChatSessionSummary?) -> Bool { + summary?.interactionMode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "plan" +} + +/// The capsule a Work row renders: the full vocabulary, not just the attention +/// third. Nil for the resting phases, which say what they need to say with the +/// neutral dot alone. +func workSessionStatusBadge( + session: TerminalSessionSummary, + summary: AgentChatSessionSummary?, + now: Date = Date() +) -> SessionBadge? { + let canonical = workCanonicalSessionState(session: session, summary: summary, now: now) + if canonical.phase == .running && workSessionIsPlanning(summary: summary) { + return workSessionBadge(kind: .planning, phase: .unrecognized("planning")) + } + switch canonical.phase { + case .needsYou, .failed, .stale: + return canonical.badge + case .starting, .running: + return workSessionBadge(kind: .working, phase: workActivityPhase(for: canonical.phase)) + case .settled: + return workSessionBadge(kind: .done, phase: .completed) + case .ready, .idle, .stopped, .ended: + return nil + } +} + +/// The hue of the row's status dot. Same table as the capsule, so a row whose +/// capsule says "Working" can never wear a green dot. +func workSessionRowTone( + session: TerminalSessionSummary, + summary: AgentChatSessionSummary?, + now: Date = Date() +) -> ActivityTone { + let canonical = workCanonicalSessionState(session: session, summary: summary, now: now) + if canonical.phase == .running && workSessionIsPlanning(summary: summary) { + return ActivityPhaseVocabulary.presentation(for: .unrecognized("planning")).tone + } + return ActivityPhaseVocabulary.presentation(for: workActivityPhase(for: canonical.phase)).tone +} + /// Canonical state for a concrete Work row. This is the one bridge from the /// mobile summary/awaiting projection into the scalar canonical state machine. func workCanonicalSessionState( @@ -659,17 +765,20 @@ struct WorkSessionLifecycleTag: View { } } -/// Small attention capsule shown next to a Work row title. Amber for needs_you -/// (matching the app's existing amber chip language), red for failed, and an -/// outlined muted capsule with a clock glyph for stale. Calm states render -/// nothing, so callers gate on a non-nil badge to avoid any layout shift. +/// Small status capsule shown next to a Work row title. The hue comes from the +/// shared tone token — amber for needs_you and nothing else, blue for work in +/// flight, emerald for finished, red for failed, violet for planning — so the +/// row cannot describe a session differently from the drawer or the widget. +/// Neutral badges render outlined rather than filled, which keeps a calm row +/// calm. Resting states have no badge at all, so callers gate on a non-nil +/// badge and the row never shifts layout. struct WorkSessionStatusCapsule: View { let badge: SessionBadge var body: some View { HStack(spacing: 3) { - if badge.kind == .stale { - Image(systemName: "clock") + if let glyph = badge.glyph, showsGlyph { + Image(systemName: glyph.systemImage) .font(.system(size: 8, weight: .semibold)) } Text(badge.label) @@ -685,20 +794,25 @@ struct WorkSessionStatusCapsule: View { .accessibilityLabel(accessibilityLabel) } + /// Only where the word alone is ambiguous: "Stale" wants the clock that asks + /// how long, "Planning" wants the list that says what kind of work. The rest + /// read fine as plain words and stay uncluttered. + private var showsGlyph: Bool { + badge.kind == .stale || badge.kind == .planning + } + + private var isOutlined: Bool { badge.tone == .neutral } + private var tint: Color { - switch badge.kind { - case .needsYou: return ADEColor.warning - case .failed: return ADEColor.danger - case .stale: return ADEColor.textMuted - } + activityToneColor(badge.tone) } private var fill: Color { - badge.kind == .stale ? Color.clear : tint.opacity(0.14) + isOutlined ? Color.clear : tint.opacity(0.14) } private var stroke: Color { - badge.kind == .stale ? tint.opacity(0.4) : tint.opacity(0.3) + isOutlined ? tint.opacity(0.4) : tint.opacity(0.3) } private var accessibilityLabel: String { @@ -706,6 +820,9 @@ struct WorkSessionStatusCapsule: View { case .needsYou: return "Needs your input" case .failed: return "Failed" case .stale: return "Stale, no recent activity" + case .working: return "Working" + case .planning: return "Planning" + case .done: return "Done" } } } diff --git a/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift b/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift index 7df190cbb..670b946c5 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift @@ -95,6 +95,11 @@ struct WorkSessionGroup: Identifiable, Equatable { /// the `status:snoozed` tail, so they can't be here). The section renders as a /// single thin row instead of a full header over nothing. let isQuiet: Bool + /// The singleton form: one top-level row, so the group renders with no header + /// at all and the lone row carries the lane identity instead. Orthogonal to + /// `isQuiet` — a quiet lane still has a header to fold, and the two rules are + /// derived independently (see `workHeaderlessLaneIds`). + let isHeaderless: Bool enum Icon: Equatable { case statusDot @@ -112,7 +117,8 @@ struct WorkSessionGroup: Identifiable, Equatable { laneColor: String? = nil, laneIcon: LaneIcon? = nil, isOrphaned: Bool = false, - isQuiet: Bool = false + isQuiet: Bool = false, + isHeaderless: Bool = false ) { self.id = id self.label = label @@ -123,6 +129,7 @@ struct WorkSessionGroup: Identifiable, Equatable { self.laneIcon = laneIcon self.isOrphaned = isOrphaned self.isQuiet = isQuiet + self.isHeaderless = isHeaderless } /// Inverted collapse marker: a quiet lane starts collapsed, and only an @@ -145,6 +152,7 @@ struct WorkSessionGroup: Identifiable, Equatable { && lhs.laneIcon == rhs.laneIcon && lhs.isOrphaned == rhs.isOrphaned && lhs.isQuiet == rhs.isQuiet + && lhs.isHeaderless == rhs.isHeaderless && lhs.sessions.map(\.id) == rhs.sessions.map(\.id) } } @@ -246,11 +254,13 @@ func buildWorkRootSessionPresentation( orderedLanes: [LaneSummary], pullRequests: [PullRequestListItem] = [], githubPrs: [GitHubPrListItem] = [], - deletingLaneIds: Set = [] + deletingLaneIds: Set = [], + pinnedLaneIds: Set = [], + laneSortMode: WorkLaneSortMode = .created, + now: Date = Date() ) -> WorkRootSessionPresentation { let committedIds = Set(sessions.map(\.id)) let draftValues = optimisticSessions.values.filter { !committedIds.contains($0.id) } - let workOrderedLanes = sortWorkLanesForTabs(orderedLanes) let laneById = Dictionary(orderedLanes.map { ($0.id, $0) }, uniquingKeysWith: { _, new in new }) let lanePrTagsByLaneId = lanePrTagByLaneId( lanes: orderedLanes, @@ -312,6 +322,30 @@ func buildWorkRootSessionPresentation( } } + // Lane ordering and the singleton rule both read the UNFILTERED roster, so + // neither the shelf a lane sits on nor whether it has a header changes while + // the user types in search. Same precedent as the quiet-lane derivation. + let workOrderedLanes = orderWorkLanes( + orderedLanes, + inputs: workLaneOrderInputs( + lanes: orderedLanes, + sessions: mergedSessions, + chatSummaries: chatSummaries, + archivedSessionIds: archivedSessionIds, + pinnedLaneIds: pinnedLaneIds, + now: now + ), + mode: laneSortMode + ) + let headerlessLaneIds = workHeaderlessLaneIds( + workHeaderlessLaneInputs( + lanes: orderedLanes, + sessions: mergedSessions, + pinnedLaneIds: pinnedLaneIds + ), + sortMode: laneSortMode + ) + let sessionGroups = workSessionGroups( organization: organization, sessions: displaySessions, @@ -320,7 +354,9 @@ func buildWorkRootSessionPresentation( statusBySessionId: statusBySessionId, archivedSessionIds: archivedSessionIds, orderedLanes: workOrderedLanes, - deletingLaneIds: deletingLaneIds + deletingLaneIds: deletingLaneIds, + headerlessLaneIds: headerlessLaneIds, + now: now ) return WorkRootSessionPresentation( @@ -419,6 +455,10 @@ private func workRootSessionPresentationRenderSignature( for group in sessionGroups { hasher.combine(group.id) hasher.combine(group.label) + hasher.combine(group.isQuiet) + // Gaining or losing a header reshapes the whole section; without this the + // equatable short-circuit freezes the old shape on screen. + hasher.combine(group.isHeaderless) hasher.combine(group.sessions.map(\.id)) } for key in childGroupsByParentId.keys.sorted() { @@ -449,21 +489,66 @@ private func workRootSessionPresentationRenderSignature( return hasher.finalize() } -func sortWorkLanesForTabs(_ lanes: [LaneSummary]) -> [LaneSummary] { - lanes.enumerated().sorted { lhsPair, rhsPair in - let lhs = lhsPair.element - let rhs = rhsPair.element - let lhsPrimary = lhs.laneType == "primary" - let rhsPrimary = rhs.laneType == "primary" - if lhsPrimary != rhsPrimary { return lhsPrimary } - - let lhsDate = parseWorkSessionTimestamp(lhs.createdAt) - let rhsDate = parseWorkSessionTimestamp(rhs.createdAt) - if let lhsDate, let rhsDate, lhsDate != rhsDate { - return lhsDate > rhsDate - } - return lhsPair.offset < rhsPair.offset - }.map(\.element) +/// Derive the per-lane ordering facts a `LaneSummary` does not carry: the most +/// recent session activity, whether the lane is quiet, and whether it is pinned. +func workLaneOrderInputs( + lanes: [LaneSummary], + sessions: [TerminalSessionSummary], + chatSummaries: [String: AgentChatSessionSummary], + archivedSessionIds: Set, + pinnedLaneIds: Set, + now: Date = Date() +) -> [String: WorkLaneOrderInput] { + var latestByLaneId: [String: Date] = [:] + for session in sessions { + guard let activity = workParsedDate( + workSessionActivityTimestamp(session: session, summary: chatSummaries[session.id]) + ) else { continue } + if let current = latestByLaneId[session.laneId], current >= activity { continue } + latestByLaneId[session.laneId] = activity + } + + var inputs: [String: WorkLaneOrderInput] = [:] + inputs.reserveCapacity(lanes.count) + for lane in lanes { + inputs[lane.id] = WorkLaneOrderInput( + lane: lane, + lastActivityAt: latestByLaneId[lane.id], + quiet: workLaneSessionsAreQuiet( + laneId: lane.id, + sessions: sessions, + chatSummaries: chatSummaries, + archivedSessionIds: archivedSessionIds, + now: now + ), + pinned: pinnedLaneIds.contains(lane.id) + ) + } + return inputs +} + +/// Per-lane inputs to the singleton rule. Counts TOP-LEVEL rows only — a chat +/// with terminal children is one unit — over the unfiltered roster. +func workHeaderlessLaneInputs( + lanes: [LaneSummary], + sessions: [TerminalSessionSummary], + pinnedLaneIds: Set +) -> [WorkHeaderlessLaneInput] { + let rosterIds = Set(sessions.map(\.id)) + var topLevelByLaneId: [String: Int] = [:] + for session in sessions { + let parentId = session.chatSessionId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let isChild = !parentId.isEmpty && parentId != session.id && rosterIds.contains(parentId) + guard !isChild else { continue } + topLevelByLaneId[session.laneId, default: 0] += 1 + } + return lanes.map { lane in + WorkHeaderlessLaneInput( + laneId: lane.id, + topLevelSessionCount: topLevelByLaneId[lane.id] ?? 0, + pinned: pinnedLaneIds.contains(lane.id) + ) + } } func workSessionChildGroupsByParentId(sessions: [TerminalSessionSummary]) -> [String: WorkSessionChildGroup] { @@ -550,6 +635,7 @@ func workSessionGroups( archivedSessionIds: Set, orderedLanes: [LaneSummary], deletingLaneIds: Set = [], + headerlessLaneIds: Set = [], now: Date = Date() ) -> [WorkSessionGroup] { var snoozed: [TerminalSessionSummary] = [] @@ -575,7 +661,8 @@ func workSessionGroups( groups = workSessionGroupsByLane( sessions: awake, orderedLanes: orderedLanes, - deletingLaneIds: deletingLaneIds + deletingLaneIds: deletingLaneIds, + headerlessLaneIds: headerlessLaneIds ).map { group in group.markingQuiet( workLaneGroupIsQuiet( @@ -618,7 +705,25 @@ extension WorkSessionGroup { sessions: sessions, laneColor: laneColor, laneIcon: laneIcon, - isQuiet: quiet + isOrphaned: isOrphaned, + isQuiet: quiet, + isHeaderless: isHeaderless + ) + } + + func markingHeaderless(_ headerless: Bool) -> WorkSessionGroup { + guard headerless != isHeaderless else { return self } + return WorkSessionGroup( + id: id, + label: label, + icon: icon, + tint: tint, + sessions: sessions, + laneColor: laneColor, + laneIcon: laneIcon, + isOrphaned: isOrphaned, + isQuiet: isQuiet, + isHeaderless: headerless ) } } @@ -736,7 +841,8 @@ func workSessionGroupsByStatus( func workSessionGroupsByLane( sessions: [TerminalSessionSummary], orderedLanes: [LaneSummary], - deletingLaneIds: Set = [] + deletingLaneIds: Set = [], + headerlessLaneIds: Set = [] ) -> [WorkSessionGroup] { var byLaneId: [String: [TerminalSessionSummary]] = [:] for session in sessions { @@ -754,7 +860,11 @@ func workSessionGroupsByLane( tint: LaneColorPalette.displayColor(forHex: lane.color), sessions: list, laneColor: lane.color, - laneIcon: lane.icon + laneIcon: lane.icon, + // Derived from the unfiltered roster, so a search that narrows a busy + // lane to one hit never collapses its header mid-keystroke. `list` may + // still hold that row's terminal children — they render nested under it. + isHeaderless: headerlessLaneIds.contains(lane.id) )) } // Surface any sessions whose lane isn't in the ordered list (e.g., soft-deleted lanes) @@ -833,6 +943,45 @@ func workSessionGroupsByTime(sessions: [TerminalSessionSummary]) -> [WorkSession return groups } +// MARK: - Offline machine banner + +/// One "this machine is gone" banner for the Work list. Presentation only: the +/// rows themselves keep working, they just stop pretending they can be acted on. +struct WorkOfflineMachineBanner: Identifiable, Equatable { + let id: String + let machineName: String + let lastSeenLabel: String? +} + +/// Which offline machines own work in the project the Work list is showing. +/// +/// The connected host is online by definition — that is what "connected" means — +/// so anything this returns is a foreign machine whose lanes are visible through +/// the account feed. Scope match is by project id, falling back to lane id for +/// items published before a project id was carried. +func workOfflineMachineBanners( + scopes: [ActivityOfflineScope], + activeProjectId: String?, + laneIds: Set = [], + now: Date = Date() +) -> [WorkOfflineMachineBanner] { + let project = activeProjectId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + var seen: Set = [] + var banners: [WorkOfflineMachineBanner] = [] + for scope in scopes { + let matchesProject = !project.isEmpty && scope.projectId == project + let matchesLane = scope.laneId.map(laneIds.contains) ?? false + guard matchesProject || matchesLane else { continue } + guard seen.insert(scope.machineKey).inserted else { continue } + banners.append(WorkOfflineMachineBanner( + id: scope.machineKey, + machineName: scope.machineName, + lastSeenLabel: scope.lastSeenLabel(now: now) + )) + } + return banners.sorted { $0.machineName.localizedCaseInsensitiveCompare($1.machineName) == .orderedAscending } +} + /// Persistence helper for the comma-separated collapsed-section-ids string stored in AppStorage. func workParseCollapsedSectionIds(_ raw: String) -> Set { Set(raw.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }) diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index bd1763ae3..3b95a83fc 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -289,6 +289,22 @@ func shortProviderLabel(_ toolType: String?) -> String { return raw.replacingOccurrences(of: "-", with: " ").capitalized } +/// Compact model label for a session meta line. Strips the vendor prefix and the +/// date suffix a model id carries — `anthropic/claude-sonnet-4-5-20250929` reads +/// as `claude-sonnet-4-5` — because on a phone row the version is the only part +/// that distinguishes two rows on the same provider. +func shortModelLabel(_ model: String?) -> String { + let raw = model?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !raw.isEmpty else { return "" } + let withoutVendor = raw.split(separator: "/").last.map(String.init) ?? raw + let parts = withoutVendor.split(separator: "-") + // A trailing 8-digit build date is noise; anything else is part of the name. + if let last = parts.last, last.count == 8, last.allSatisfy(\.isNumber) { + return parts.dropLast().joined(separator: "-") + } + return withoutVendor +} + func providerIcon(_ provider: String) -> String { switch providerFamilyKey(provider) { case "codex", "openai": @@ -581,18 +597,29 @@ func workChatStatusSortRank(_ status: String) -> Int { } } -func workChatStatusTint(_ status: String) -> Color { +/// Tone for the coarse four-value chat status string, for the surfaces that only +/// ever hold that string (the hub roster, personal chats, the session settings +/// sheet). Rows that hold a real session use `workSessionRowTone` instead, which +/// reads the canonical phase. +/// +/// Both route through `ActivityTone`, which is what enforces the one-hue rule: +/// amber is "your move" and nothing else. That rule moved two colours here — an +/// active chat is blue (work is happening) rather than the green that now means +/// "finished", and an idle chat is neutral rather than the amber it used to +/// borrow, which made resting chats shout as loudly as blocked ones. +func workChatStatusTone(_ status: String) -> ActivityTone { switch status { - case "awaiting-input": return ADEColor.warning - case "active": return ADEColor.success - // Match desktop, where idle/needs-attention chats render as amber. Previously - // idle was rendered with the purple accent, which read as "running" and - // diverged from the desktop status-dot semantics. - case "idle": return ADEColor.warning - default: return ADEColor.textSecondary + case "awaiting-input": return .amber + case "active": return .blue + case "idle": return .neutral + default: return .neutral } } +func workChatStatusTint(_ status: String) -> Color { + activityToneColor(workChatStatusTone(status)) +} + func workChatStatusIcon(_ status: String) -> String { switch status { case "awaiting-input": return "exclamationmark.bubble.fill" diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index c2197bb08..854669c9b 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -15298,7 +15298,14 @@ final class ADETests: XCTestCase { ) newer.createdAt = "2026-03-25T00:00:00.000Z" - let ordered = sortWorkLanesForTabs([older, primary, newer]) + let ordered = orderWorkLanes( + [older, primary, newer], + inputs: [ + primary.id: WorkLaneOrderInput(lane: primary), + older.id: WorkLaneOrderInput(lane: older), + newer.id: WorkLaneOrderInput(lane: newer), + ] + ) XCTAssertEqual(ordered.map(\.id), ["lane-primary", "lane-newer", "lane-older"]) } diff --git a/apps/ios/ADETests/ActivityWidgetPresentationTests.swift b/apps/ios/ADETests/ActivityWidgetPresentationTests.swift new file mode 100644 index 000000000..b8c273be1 --- /dev/null +++ b/apps/ios/ADETests/ActivityWidgetPresentationTests.swift @@ -0,0 +1,155 @@ +import XCTest +@testable import ADE + +/// The lock-screen widget's two decisions, as pure functions: which rows the +/// rectangular family lists, and where a tap goes. +/// +/// The deep link is the one that mattered — the widget used to follow whatever +/// sorted first, which on a busy account is usually PR traffic, so the single +/// glance-and-tap surface could not reliably reach the session blocked on you. +final class ActivityWidgetPresentationTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_780_000_000) + + // MARK: - Deep link ranking + + func testDeepLinkPrefersTheTopNeedsYouRow() { + let url = ActivityWidgetPresentation.deepLink( + for: [ + makeItem(id: "pr", phase: .checksFailing, sessionId: "pr-session", updatedAt: now), + makeItem(id: "live", phase: .running, sessionId: "live-session", updatedAt: now), + makeItem(id: "asked", phase: .needsYou, sessionId: "asked-session", updatedAt: now.addingTimeInterval(-600)), + ], + now: now + ) + + XCTAssertEqual(url.absoluteString.contains("asked-session"), true) + } + + func testDeepLinkFallsBackToTheTopLiveRow() { + let url = ActivityWidgetPresentation.deepLink( + for: [ + makeItem(id: "done", phase: .completed, sessionId: "done-session", updatedAt: now), + makeItem(id: "older-live", phase: .running, sessionId: "older-session", updatedAt: now.addingTimeInterval(-900)), + makeItem(id: "live", phase: .running, sessionId: "live-session", updatedAt: now), + ], + now: now + ) + + XCTAssertEqual(url.absoluteString.contains("live-session"), true) + } + + func testDeepLinkFallsBackToActivityWhenNothingIsActionable() { + let url = ActivityWidgetPresentation.deepLink( + for: [makeItem(id: "done", phase: .completed, sessionId: "done-session", updatedAt: now)], + now: now + ) + + XCTAssertEqual(url, ActivityWidgetPresentation.activityURL) + } + + func testDeepLinkIgnoresDismissedAndExpiredRows() { + let url = ActivityWidgetPresentation.deepLink( + for: [ + makeItem( + id: "dismissed", + phase: .needsYou, + sessionId: "dismissed-session", + updatedAt: now, + dismissedAt: now + ), + makeItem( + id: "expired", + phase: .needsYou, + sessionId: "expired-session", + updatedAt: now, + expiresAt: now.addingTimeInterval(-1) + ), + ], + now: now + ) + + XCTAssertEqual(url, ActivityWidgetPresentation.activityURL) + } + + // MARK: - Compact lines + + func testCompactLinesTakeTheTopTwoInBandOrder() { + let lines = ActivityWidgetPresentation.compactLines( + for: [ + makeItem(id: "done", phase: .completed, sessionId: "s-done", updatedAt: now), + makeItem(id: "live", phase: .running, sessionId: "s-live", updatedAt: now), + makeItem(id: "asked", phase: .needsYou, sessionId: "s-asked", updatedAt: now.addingTimeInterval(-600)), + ], + now: now + ) + + XCTAssertEqual(lines.map(\.id), ["asked", "live"]) + XCTAssertEqual(lines.map(\.phaseLabel), ["Needs you", "Working"]) + XCTAssertEqual(lines.map(\.tone), [.amber, .blue]) + } + + func testOverflowCountsTheRowsTheLinesLeftOff() { + let items = (0..<5).map { index in + makeItem(id: "item-\(index)", phase: .running, sessionId: "s-\(index)", updatedAt: now) + } + + XCTAssertEqual(ActivityWidgetPresentation.overflowCount(for: items, now: now), 3) + XCTAssertEqual(ActivityWidgetPresentation.overflowCount(for: Array(items.prefix(2)), now: now), 0) + } + + /// A lock screen is readable by anyone holding the phone, which is the whole + /// point of the setting — the title has to be the publisher's redacted one. + func testHideDetailsSwapsInThePrivacyPreview() { + let lines = ActivityWidgetPresentation.compactLines( + for: [makeItem(id: "asked", phase: .needsYou, sessionId: "s-asked", updatedAt: now)], + hideDetails: true, + now: now + ) + + XCTAssertEqual(lines.first?.title, "Agent needs you") + } + + func testRankingIsStableForRowsThatTieOnEveryKey() { + let first = makeItem(id: "b", phase: .running, sessionId: "s-b", updatedAt: now) + let second = makeItem(id: "a", phase: .running, sessionId: "s-a", updatedAt: now) + + XCTAssertEqual(ActivityWidgetPresentation.ranked([first, second]).map(\.id), ["a", "b"]) + XCTAssertEqual(ActivityWidgetPresentation.ranked([second, first]).map(\.id), ["a", "b"]) + } + + // MARK: - Fixtures + + private func makeItem( + id: String, + phase: AccountAttentionPhase, + sessionId: String, + updatedAt: Date, + dismissedAt: Date? = nil, + expiresAt: Date? = nil + ) -> AccountAttentionItem { + AccountAttentionItem( + id: id, + revision: 1, + fingerprint: "\(id):1", + kind: .agent, + eventKind: .agentRunning, + phase: phase, + machine: AccountAttentionMachine( + machineKey: "studio", + name: "Studio Mac", + online: true, + lastSeenAt: updatedAt + ), + project: AccountAttentionProject(projectId: "ade", name: "ADE"), + provider: "claude", + title: "Wire the widget", + preview: "Working", + privacyPreview: "Agent needs you", + destination: .session(sessionId: sessionId, itemId: nil, eventId: nil), + occurredAt: updatedAt, + updatedAt: updatedAt, + dismissedAt: dismissedAt, + expiresAt: expiresAt + ) + } +} diff --git a/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift b/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift index 8a5a8db47..f78a942f0 100644 --- a/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift +++ b/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift @@ -291,6 +291,160 @@ final class WorkSessionCanonicalStateTests: XCTestCase { XCTAssertNil(badge) } + // MARK: - The full status vocabulary + // + // `badge` stays the attention third — the three states something downstream + // may reasonably treat as "act on this". `workSessionStatusBadge` is the wider + // vocabulary the row renders, and every word and hue in it comes from the + // shared `ActivityPhaseVocabulary`, not from a second table here. + + func testStatusBadgeCoversTheDescriptiveStates() { + struct Case { + let name: String + let session: TerminalSessionSummary + let summary: AgentChatSessionSummary? + let kind: SessionBadgeKind? + let label: String? + let tone: ActivityTone + } + + let cases: [Case] = [ + Case( + name: "running agent", + session: makeSession(status: "running", runtimeState: "running", toolType: "codex", startedAt: iso(now)), + summary: nil, + kind: .working, + label: "Working", + tone: .blue + ), + Case( + name: "planning chat", + session: makeSession(status: "running", runtimeState: "running", toolType: "codex-chat", startedAt: iso(now)), + summary: makeChatSummary(status: "active", awaitingInput: false, interactionMode: "plan"), + kind: .planning, + label: "Planning", + tone: .violet + ), + Case( + name: "settled", + session: makeSession(status: "running", runtimeState: "idle", toolType: "codex-chat", settledAt: iso(now)), + summary: nil, + kind: .done, + label: "Done", + tone: .emerald + ), + Case( + name: "blocked on the user", + session: makeSession( + status: "running", + runtimeState: "running", + toolType: "codex-chat", + pendingInputItemId: "approval-1" + ), + summary: nil, + kind: .needsYou, + label: "Needs you", + tone: .amber + ), + Case( + name: "failed", + session: makeSession(status: "ended", runtimeState: "exited", toolType: "codex", exitCode: 130), + summary: nil, + kind: .failed, + label: "Failed", + tone: .red + ), + Case( + name: "stale", + session: makeSession( + status: "running", + runtimeState: "running", + toolType: "codex-chat", + startedAt: iso(now) + ), + summary: makeChatSummary(status: "active", awaitingInput: false), + kind: .stale, + label: "Stale", + tone: .neutral + ), + ] + + for testCase in cases { + var summary = testCase.summary + if testCase.name == "stale" { + summary?.lastActivityAt = silentFor(sessionStaleAfterSeconds + 60) + } + let badge = workSessionStatusBadge(session: testCase.session, summary: summary, now: now) + XCTAssertEqual(badge?.kind, testCase.kind, testCase.name) + XCTAssertEqual(badge?.label, testCase.label, testCase.name) + XCTAssertEqual(badge?.tone, testCase.tone, testCase.name) + XCTAssertEqual( + workSessionRowTone(session: testCase.session, summary: summary, now: now), + testCase.tone, + testCase.name + ) + } + } + + /// Resting states still earn no capsule: the row must not shift layout to say + /// that nothing is happening. + func testStatusBadgeStaysNilForRestingStates() { + let ready = makeSession(status: "ended", runtimeState: "exited", toolType: "codex-chat") + let idle = makeSession(status: "running", runtimeState: "idle", toolType: "codex") + let stopped = makeSession(status: "disposed", runtimeState: "killed", toolType: "codex", exitCode: 130) + + for session in [ready, idle, stopped] { + XCTAssertNil(workSessionStatusBadge(session: session, summary: nil, now: now)) + XCTAssertEqual(workSessionRowTone(session: session, summary: nil, now: now), .neutral) + } + } + + /// Planning is a presentation fact derived from the chat's interaction mode, + /// exactly as on desktop — it never becomes a canonical phase. + func testPlanningNeverBecomesACanonicalPhase() { + let session = makeSession(status: "running", runtimeState: "running", toolType: "codex-chat", startedAt: iso(now)) + let summary = makeChatSummary(status: "active", awaitingInput: false, interactionMode: "plan") + + XCTAssertEqual(workCanonicalSessionState(session: session, summary: summary, now: now).phase, .running) + XCTAssertEqual(workSessionStatusBadge(session: session, summary: summary, now: now)?.kind, .planning) + } + + /// A blocked session is amber whatever mode it is in — planning must never + /// outvote a raised hand. + func testNeedsYouOutranksPlanning() { + let session = makeSession( + status: "running", + runtimeState: "running", + toolType: "codex-chat", + pendingInputItemId: "approval-1" + ) + let summary = makeChatSummary(status: "active", awaitingInput: false, interactionMode: "plan") + + XCTAssertEqual(workSessionStatusBadge(session: session, summary: summary, now: now)?.kind, .needsYou) + } + + func testCanonicalPhasesMapOntoTheSharedVocabulary() { + XCTAssertEqual(workActivityPhase(for: .running), .running) + XCTAssertEqual(workActivityPhase(for: .starting), .starting) + XCTAssertEqual(workActivityPhase(for: .needsYou), .needsYou) + XCTAssertEqual(workActivityPhase(for: .failed), .failed) + XCTAssertEqual(workActivityPhase(for: .stale), .stale) + XCTAssertEqual(workActivityPhase(for: .settled), .completed) + XCTAssertEqual(workActivityPhase(for: .ready), .unrecognized("ready")) + XCTAssertEqual(workActivityPhase(for: .idle), .unrecognized("idle")) + XCTAssertEqual(workActivityPhase(for: .stopped), .unrecognized("stopped")) + XCTAssertEqual(workActivityPhase(for: .ended), .unrecognized("ended")) + } + + /// The one-hue rule, from the other direction: the coarse status string every + /// roster surface holds must not be able to paint amber for a resting chat. + func testChatStatusToneSpendsAmberOnlyOnNeedsYou() { + XCTAssertEqual(workChatStatusTone("awaiting-input"), .amber) + XCTAssertEqual(workChatStatusTone("active"), .blue) + XCTAssertEqual(workChatStatusTone("idle"), .neutral) + XCTAssertEqual(workChatStatusTone("ended"), .neutral) + } + func testWorkSessionRowPreviewUsesFreshOutputBeforeSummaryAndGoal() { var session = makeSession( status: "running", @@ -470,7 +624,8 @@ final class WorkSessionCanonicalStateTests: XCTestCase { exitCode: Int? = nil, pendingInputItemId: String? = nil, lastOutputPreview: String? = nil, - startedAt: String? = nil + startedAt: String? = nil, + settledAt: String? = nil ) -> TerminalSessionSummary { TerminalSessionSummary( id: "s-1", @@ -487,6 +642,7 @@ final class WorkSessionCanonicalStateTests: XCTestCase { startedAt: startedAt ?? iso(now), endedAt: nil, archivedAt: nil, + settledAt: settledAt, exitCode: exitCode, transcriptPath: "", headShaStart: nil, @@ -557,7 +713,8 @@ final class WorkSessionCanonicalStateTests: XCTestCase { private func makeChatSummary( status: String, awaitingInput: Bool?, - pendingInputItemId: String? = nil + pendingInputItemId: String? = nil, + interactionMode: String? = nil ) -> AgentChatSessionSummary { AgentChatSessionSummary( sessionId: "chat-1", @@ -573,7 +730,7 @@ final class WorkSessionCanonicalStateTests: XCTestCase { fastMode: nil, executionMode: nil, permissionMode: nil, - interactionMode: nil, + interactionMode: interactionMode, claudePermissionMode: nil, codexApprovalPolicy: nil, codexSandbox: nil, diff --git a/apps/ios/ADETests/WorkSessionGroupingTests.swift b/apps/ios/ADETests/WorkSessionGroupingTests.swift new file mode 100644 index 000000000..8b1095ad5 --- /dev/null +++ b/apps/ios/ADETests/WorkSessionGroupingTests.swift @@ -0,0 +1,436 @@ +import XCTest +@testable import ADE + +/// The Work list's two structural rules, neither of which had any coverage: +/// the singleton/headerless lane (desktop `SessionListPane.tsx` `headerlessLaneIds`) +/// and lane ordering (desktop `workLaneOrder.ts` `compareWorkLanes`). +/// +/// Both are load-bearing for how the column reads and both are pure, so they are +/// asserted here rather than through a rendered list. +final class WorkSessionGroupingTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_780_000_000) + + // MARK: - Headerless: the singleton rule + + func testSingletonLaneDropsItsHeader() { + let lane = makeLane(id: "lane-a", name: "feature/one") + let presentation = makePresentation( + sessions: [makeSession(id: "s-1", laneId: lane.id)], + lanes: [lane] + ) + + XCTAssertEqual(presentation.sessionGroups.map(\.id), ["lane:lane-a"]) + XCTAssertEqual(presentation.sessionGroups.first?.isHeaderless, true) + } + + func testLaneWithTwoSessionsKeepsItsHeader() { + let lane = makeLane(id: "lane-a", name: "feature/one") + let presentation = makePresentation( + sessions: [ + makeSession(id: "s-1", laneId: lane.id), + makeSession(id: "s-2", laneId: lane.id), + ], + lanes: [lane] + ) + + XCTAssertEqual(presentation.sessionGroups.first?.isHeaderless, false) + } + + /// A chat and the shells it spawned are one unit. Counting them separately + /// would summon a header for what the user reads as a single row. + func testChatWithChildShellsStaysHeaderless() { + let lane = makeLane(id: "lane-a", name: "feature/one") + let presentation = makePresentation( + sessions: [ + makeSession(id: "chat-1", laneId: lane.id), + makeSession(id: "shell-1", laneId: lane.id, chatSessionId: "chat-1"), + makeSession(id: "shell-2", laneId: lane.id, chatSessionId: "chat-1"), + ], + lanes: [lane] + ) + + XCTAssertEqual(presentation.sessionGroups.first?.isHeaderless, true) + } + + func testPinnedLaneKeepsItsHeader() { + let lane = makeLane(id: "lane-a", name: "feature/one") + let presentation = makePresentation( + sessions: [makeSession(id: "s-1", laneId: lane.id)], + lanes: [lane], + pinnedLaneIds: ["lane-a"] + ) + + XCTAssertEqual(presentation.sessionGroups.first?.isHeaderless, false) + } + + func testPendingHandoffKeepsTheHeader() { + let ids = workHeaderlessLaneIds([ + WorkHeaderlessLaneInput(laneId: "lane-a", topLevelSessionCount: 1, hasPendingHandoff: true), + WorkHeaderlessLaneInput(laneId: "lane-b", topLevelSessionCount: 1), + ]) + + XCTAssertEqual(ids, ["lane-b"]) + } + + func testOfflineMachineLaneKeepsTheHeader() { + let ids = workHeaderlessLaneIds([ + WorkHeaderlessLaneInput(laneId: "lane-a", topLevelSessionCount: 1, machineOnline: false), + WorkHeaderlessLaneInput(laneId: "lane-b", topLevelSessionCount: 1), + ]) + + XCTAssertEqual(ids, ["lane-b"]) + } + + func testManualSortModeOptsEveryLaneOutOfTheSingletonForm() { + let ids = workHeaderlessLaneIds( + [ + WorkHeaderlessLaneInput(laneId: "lane-a", topLevelSessionCount: 1), + WorkHeaderlessLaneInput(laneId: "lane-b", topLevelSessionCount: 1), + ], + sortMode: .manual + ) + + XCTAssertTrue(ids.isEmpty) + } + + func testEmptyLaneIsNotHeaderless() { + XCTAssertTrue(workHeaderlessLaneIds([ + WorkHeaderlessLaneInput(laneId: "lane-a", topLevelSessionCount: 0) + ]).isEmpty) + } + + /// Rule 1: the threshold reads the unfiltered roster. Without it a search that + /// narrows a busy lane to one hit would drop the header mid-keystroke, and + /// put it back on the next one. + func testSearchNarrowingALaneToOneRowDoesNotDropTheHeader() { + let lane = makeLane(id: "lane-a", name: "feature/one") + let presentation = makePresentation( + sessions: [ + makeSession(id: "s-1", laneId: lane.id, title: "Fix login"), + makeSession(id: "s-2", laneId: lane.id, title: "Audit sync"), + ], + lanes: [lane], + searchText: "login" + ) + + XCTAssertEqual(presentation.displaySessionIds, ["s-1"]) + XCTAssertEqual(presentation.sessionGroups.first?.isHeaderless, false) + } + + // MARK: - Quiet lanes stay orthogonal + + /// Quiet ("everything here has settled") and headerless ("there is only one + /// row") answer different questions, and a lane can be both. + func testSettledSingletonLaneIsBothQuietAndHeaderless() { + let lane = makeLane(id: "lane-a", name: "feature/one") + let settled = makeSession(id: "s-1", laneId: lane.id, settledAt: iso(now.addingTimeInterval(-60))) + let presentation = makePresentation(sessions: [settled], lanes: [lane]) + + let group = presentation.sessionGroups.first + XCTAssertEqual(group?.isQuiet, true) + XCTAssertEqual(group?.isHeaderless, true) + } + + func testQuietLaneWithTwoSessionsKeepsItsHeader() { + let lane = makeLane(id: "lane-a", name: "feature/one") + let presentation = makePresentation( + sessions: [ + makeSession(id: "s-1", laneId: lane.id, settledAt: iso(now.addingTimeInterval(-60))), + makeSession(id: "s-2", laneId: lane.id, settledAt: iso(now.addingTimeInterval(-90))), + ], + lanes: [lane] + ) + + let group = presentation.sessionGroups.first + XCTAssertEqual(group?.isQuiet, true) + XCTAssertEqual(group?.isHeaderless, false) + } + + // MARK: - Ordering tiers + + func testPrimaryLaneLeadsEveryTier() { + // The primary lane is the oldest and quiet — every other key would sink it. + let primary = makeLane(id: "lane-primary", name: "Primary", laneType: "primary", createdAt: "2026-01-01T00:00:00.000Z") + let pinned = makeLane(id: "lane-pinned", name: "Pinned", createdAt: "2026-06-01T00:00:00.000Z") + let active = makeLane(id: "lane-active", name: "Active", createdAt: "2026-05-01T00:00:00.000Z") + + let ordered = orderWorkLanes( + [active, pinned, primary], + inputs: [ + "lane-primary": WorkLaneOrderInput(lane: primary, quiet: true), + "lane-pinned": WorkLaneOrderInput(lane: pinned, pinned: true), + "lane-active": WorkLaneOrderInput(lane: active), + ] + ) + + XCTAssertEqual(ordered.map(\.id), ["lane-primary", "lane-pinned", "lane-active"]) + } + + func testTierOrderIsPinnedThenActiveThenQuiet() { + let quiet = makeLane(id: "lane-quiet", name: "Quiet", createdAt: "2026-07-01T00:00:00.000Z") + let active = makeLane(id: "lane-active", name: "Active", createdAt: "2026-06-01T00:00:00.000Z") + let pinned = makeLane(id: "lane-pinned", name: "Pinned", createdAt: "2026-05-01T00:00:00.000Z") + + let ordered = orderWorkLanes( + [quiet, active, pinned], + inputs: [ + "lane-quiet": WorkLaneOrderInput(lane: quiet, quiet: true), + "lane-active": WorkLaneOrderInput(lane: active), + // A pin outranks quietness, and it also outranks being the oldest lane. + "lane-pinned": WorkLaneOrderInput(lane: pinned, pinned: true), + ] + ) + + XCTAssertEqual(ordered.map(\.id), ["lane-pinned", "lane-active", "lane-quiet"]) + } + + func testPinOutranksQuietness() { + let pinnedQuiet = makeLane(id: "lane-pinned", name: "Pinned", createdAt: "2026-05-01T00:00:00.000Z") + let active = makeLane(id: "lane-active", name: "Active", createdAt: "2026-06-01T00:00:00.000Z") + + let ordered = orderWorkLanes( + [active, pinnedQuiet], + inputs: [ + "lane-pinned": WorkLaneOrderInput(lane: pinnedQuiet, quiet: true, pinned: true), + "lane-active": WorkLaneOrderInput(lane: active), + ] + ) + + XCTAssertEqual(ordered.map(\.id), ["lane-pinned", "lane-active"]) + } + + func testCreatedModeSortsNewestFirstWithinATier() { + let older = makeLane(id: "lane-older", name: "Older", createdAt: "2026-05-01T00:00:00.000Z") + let newer = makeLane(id: "lane-newer", name: "Newer", createdAt: "2026-06-01T00:00:00.000Z") + + let ordered = orderWorkLanes( + [older, newer], + inputs: [ + "lane-older": WorkLaneOrderInput(lane: older), + "lane-newer": WorkLaneOrderInput(lane: newer), + ] + ) + + XCTAssertEqual(ordered.map(\.id), ["lane-newer", "lane-older"]) + } + + func testActivityModeSortsByLatestActivityAndFilesLanesWithNoneLast() { + let quietest = makeLane(id: "lane-c", name: "C", createdAt: "2026-06-03T00:00:00.000Z") + let busiest = makeLane(id: "lane-a", name: "A", createdAt: "2026-06-01T00:00:00.000Z") + let middle = makeLane(id: "lane-b", name: "B", createdAt: "2026-06-02T00:00:00.000Z") + + let ordered = orderWorkLanes( + [quietest, busiest, middle], + inputs: [ + "lane-c": WorkLaneOrderInput(lane: quietest, lastActivityAt: nil), + "lane-a": WorkLaneOrderInput(lane: busiest, lastActivityAt: now), + "lane-b": WorkLaneOrderInput(lane: middle, lastActivityAt: now.addingTimeInterval(-600)), + ], + mode: .activity + ) + + XCTAssertEqual(ordered.map(\.id), ["lane-a", "lane-b", "lane-c"]) + } + + /// The comparator has to be total, or two lanes that tie on every key swap + /// places between renders and the column visibly jitters. + func testIdBreaksAnOtherwiseCompleteTie() { + let left = makeLane(id: "lane-b", name: "Same", createdAt: "2026-06-01T00:00:00.000Z") + let right = makeLane(id: "lane-a", name: "Same", createdAt: "2026-06-01T00:00:00.000Z") + + let ordered = orderWorkLanes( + [left, right], + inputs: [ + "lane-b": WorkLaneOrderInput(lane: left), + "lane-a": WorkLaneOrderInput(lane: right), + ] + ) + + XCTAssertEqual(ordered.map(\.id), ["lane-a", "lane-b"]) + } + + func testManualModeFilesUnplacedLanesAfterEveryPlacedOne() { + let placed = makeLane(id: "lane-placed", name: "Placed", createdAt: "2026-05-01T00:00:00.000Z") + let unplaced = makeLane(id: "lane-unplaced", name: "Unplaced", createdAt: "2026-07-01T00:00:00.000Z") + + let ordered = orderWorkLanes( + [unplaced, placed], + inputs: [ + "lane-placed": WorkLaneOrderInput(lane: placed), + "lane-unplaced": WorkLaneOrderInput(lane: unplaced), + ], + mode: .manual, + manualOrder: ["lane-placed"] + ) + + XCTAssertEqual(ordered.map(\.id), ["lane-placed", "lane-unplaced"]) + } + + func testPresentationOrdersLanesByTier() { + let primary = makeLane(id: "lane-primary", name: "Primary", laneType: "primary", createdAt: "2026-01-01T00:00:00.000Z") + let quiet = makeLane(id: "lane-quiet", name: "Quiet", createdAt: "2026-07-01T00:00:00.000Z") + let active = makeLane(id: "lane-active", name: "Active", createdAt: "2026-06-01T00:00:00.000Z") + + let presentation = makePresentation( + sessions: [ + makeSession(id: "s-primary", laneId: primary.id), + makeSession(id: "s-quiet", laneId: quiet.id, settledAt: iso(now.addingTimeInterval(-60))), + makeSession(id: "s-active", laneId: active.id), + ], + lanes: [quiet, active, primary] + ) + + XCTAssertEqual( + presentation.sessionGroups.map(\.id), + ["lane:lane-primary", "lane:lane-active", "lane:lane-quiet"] + ) + } + + // MARK: - Offline machine banner + + func testOfflineBannerSurfacesOneEntryPerMachineInThisProject() { + let banners = workOfflineMachineBanners( + scopes: [ + scope(machineKey: "studio", projectId: "project-1", laneId: "lane-a"), + scope(machineKey: "studio", projectId: "project-1", laneId: "lane-b"), + scope(machineKey: "laptop", projectId: "project-1", laneId: "lane-c"), + ], + activeProjectId: "project-1", + now: now + ) + + XCTAssertEqual(banners.map(\.machineName), ["laptop", "studio"]) + XCTAssertEqual(banners.first?.lastSeenLabel, "last seen 2h ago") + } + + func testOfflineBannerIgnoresOtherProjects() { + let banners = workOfflineMachineBanners( + scopes: [scope(machineKey: "studio", projectId: "project-2", laneId: "lane-z")], + activeProjectId: "project-1", + now: now + ) + + XCTAssertTrue(banners.isEmpty) + } + + /// Items published before a project id was carried still match through the + /// lane they name, so an outage is not silently dropped. + func testOfflineBannerFallsBackToLaneScope() { + let banners = workOfflineMachineBanners( + scopes: [scope(machineKey: "studio", projectId: "", laneId: "lane-a")], + activeProjectId: "project-1", + laneIds: ["lane-a"], + now: now + ) + + XCTAssertEqual(banners.map(\.id), ["studio"]) + } + + // MARK: - Fixtures + + private func makePresentation( + sessions: [TerminalSessionSummary], + lanes: [LaneSummary], + pinnedLaneIds: Set = [], + searchText: String = "" + ) -> WorkRootSessionPresentation { + buildWorkRootSessionPresentation( + sessions: sessions, + optimisticSessions: [:], + chatSummaries: [:], + archivedSessionIds: [], + selectedStatus: .all, + selectedLaneId: "all", + searchText: searchText, + organization: .byLane, + orderedLanes: lanes, + pinnedLaneIds: pinnedLaneIds, + now: now + ) + } + + private func scope(machineKey: String, projectId: String, laneId: String?) -> ActivityOfflineScope { + ActivityOfflineScope( + machineKey: machineKey, + machineName: machineKey, + lastSeenAt: now.addingTimeInterval(-2 * 60 * 60), + projectId: projectId, + laneId: laneId + ) + } + + private func iso(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) + } + + private func makeSession( + id: String, + laneId: String, + title: String = "Session", + status: String = "running", + runtimeState: String = "running", + settledAt: String? = nil, + chatSessionId: String? = nil + ) -> TerminalSessionSummary { + TerminalSessionSummary( + id: id, + laneId: laneId, + laneName: laneId, + ptyId: nil, + tracked: true, + pinned: false, + manuallyNamed: nil, + goal: nil, + toolType: "codex-chat", + title: title, + status: status, + startedAt: iso(now.addingTimeInterval(-300)), + endedAt: nil, + archivedAt: nil, + settledAt: settledAt, + exitCode: nil, + transcriptPath: "", + headShaStart: nil, + headShaEnd: nil, + lastOutputPreview: nil, + summary: nil, + runtimeState: settledAt == nil ? runtimeState : "idle", + resumeCommand: nil, + resumeMetadata: nil, + chatIdleSinceAt: nil, + chatSessionId: chatSessionId + ) + } + + private func makeLane( + id: String, + name: String, + laneType: String = "worktree", + createdAt: String = "2026-06-01T00:00:00.000Z" + ) -> LaneSummary { + LaneSummary( + id: id, + name: name, + description: nil, + laneType: laneType, + baseRef: "main", + branchRef: "feature/\(id)", + worktreePath: "", + attachedRootPath: nil, + parentLaneId: nil, + childCount: 0, + stackDepth: 0, + parentStatus: nil, + isEditProtected: false, + status: LaneStatus(dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false), + color: nil, + icon: nil, + tags: [], + folder: nil, + createdAt: createdAt, + archivedAt: nil + ) + } +} diff --git a/apps/ios/ADEWidgets/ADELockScreenWidget.swift b/apps/ios/ADEWidgets/ADELockScreenWidget.swift index d412a31c1..0f4b1eb04 100644 --- a/apps/ios/ADEWidgets/ADELockScreenWidget.swift +++ b/apps/ios/ADEWidgets/ADELockScreenWidget.swift @@ -134,6 +134,12 @@ private struct LockScreenPriorityStatus { let tint: Color let destinationURL: URL let metrics: [Metric] + /// Up to two rows for the rectangular family. Empty on the machine-local + /// fallback path, which has no per-item feed to list — that path keeps the + /// single-focus layout. + let lines: [ActivityWidgetPresentation.CompactLine] + /// Visible rows the two lines left off. + let overflowCount: Int struct Metric: Identifiable { let id: String @@ -143,21 +149,32 @@ private struct LockScreenPriorityStatus { init(attentionSnapshot: AccountAttentionSnapshot, hideDetails: Bool = false) { let now = Date() - let visible = attentionSnapshot.items.filter { item in - item.dismissedAt == nil - && (item.expiresAt == nil || item.expiresAt! > now) - } + let visible = ActivityWidgetPresentation.visibleItems(attentionSnapshot.items, now: now) let ordered = visible.sorted { lhs, rhs in let priority = Self.priority(lhs.phase) - Self.priority(rhs.phase) if priority != 0 { return priority < 0 } return lhs.updatedAt > rhs.updatedAt } - let inbox = visible.filter(\.needsInbox) + // The rectangular family lists rows; the circular and inline families + // still compress everything into the single focus below. + let lines = ActivityWidgetPresentation.compactLines( + for: visible, + hideDetails: hideDetails, + now: now + ) + let overflow = ActivityWidgetPresentation.overflowCount(for: visible, now: now) + // Tapping goes to whatever is actually blocked on the reader, not to + // whatever happened to sort first. + let destination = ActivityWidgetPresentation.deepLink(for: visible, now: now) + // "N need" means N rows are blocked on the reader. It used to count the + // whole inbox — PR traffic and finished-but-unlooked-at rows included — + // which made a quiet account read as a demanding one. + let needsYou = visible.filter { $0.phase == .needsYou } let live = visible.filter(\.isLive) let machines = Set(visible.map(\.machine.machineKey)) let onlineMachines = Set(visible.filter(\.machine.online).map(\.machine.machineKey)) let metrics = [ - inbox.isEmpty ? nil : Metric(id: "needs", label: "\(inbox.count) need", symbol: "bell.fill"), + needsYou.isEmpty ? nil : Metric(id: "needs", label: "\(needsYou.count) need", symbol: "bell.fill"), live.isEmpty ? nil : Metric(id: "live", label: "\(live.count) live", symbol: "waveform.path.ecg"), machines.isEmpty ? nil : Metric(id: "machines", label: "\(machines.count) Mac", symbol: "desktopcomputer"), ].compactMap { $0 } @@ -172,7 +189,7 @@ private struct LockScreenPriorityStatus { symbol: "moon.zzz.fill", shortLabel: "IDLE", tint: ADESharedTheme.statusIdle, - destinationURL: Self.workspaceURL, + destinationURL: ActivityWidgetPresentation.activityURL, metrics: [] ) return @@ -190,15 +207,17 @@ private struct LockScreenPriorityStatus { symbol: "wifi.slash", shortLabel: "OFF", tint: ADESharedTheme.statusIdle, - destinationURL: focus.deepLinkURL ?? Self.workspaceURL, - metrics: metrics + destinationURL: destination, + metrics: metrics, + lines: lines, + overflowCount: overflow ) return } let presentation = Self.presentation(for: focus.phase) let scope = "\(focus.machine.name) · \(focus.project.name)" - let attentionCount = inbox.count + let attentionCount = needsYou.count let ambientCount = visible.count let privateTitle = focus.privacyPreview .trimmingCharacters(in: .whitespacesAndNewlines) @@ -219,8 +238,10 @@ private struct LockScreenPriorityStatus { symbol: presentation.symbol, shortLabel: presentation.label, tint: presentation.tint, - destinationURL: focus.deepLinkURL ?? Self.workspaceURL, - metrics: metrics + destinationURL: destination, + metrics: metrics, + lines: lines, + overflowCount: overflow ) } @@ -429,8 +450,12 @@ private struct LockScreenPriorityStatus { shortLabel: String, tint: Color, destinationURL: URL, - metrics: [Metric] + metrics: [Metric], + lines: [ActivityWidgetPresentation.CompactLine] = [], + overflowCount: Int = 0 ) { + self.lines = lines + self.overflowCount = overflowCount self.kind = kind self.title = title self.detail = detail @@ -568,6 +593,13 @@ private struct LockScreenPriorityStatus { // MARK: - Rectangular +/// Two compact session lines plus the metrics tail when the account feed can +/// supply them, and the original single-focus layout when it cannot (the +/// machine-local fallback path, which has no per-item feed). +/// +/// The rectangular family is the only one with room for more than one fact, and +/// it used to spend all of it on one row — so a lock screen with four agents +/// running looked identical to one with a single agent. private struct LockScreenRectangularView: View { let status: LockScreenPriorityStatus @Environment(\.isLuminanceReduced) private var isLuminanceReduced @@ -575,6 +607,69 @@ private struct LockScreenRectangularView: View { var body: some View { ZStack { AccessoryWidgetBackground() + if status.lines.isEmpty { + focusLayout + } else { + lineLayout + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("ADE status") + .accessibilityValue(accessibilityValue) + } + + private var accessibilityValue: String { + guard !status.lines.isEmpty else { return "\(status.title). \(status.detail)" } + var parts = status.lines.map { "\($0.title), \($0.phaseLabel)" } + if status.overflowCount > 0 { parts.append("\(status.overflowCount) more") } + return parts.joined(separator: ". ") + } + + private var lineLayout: some View { + VStack(alignment: .leading, spacing: 2) { + ForEach(status.lines) { line in + HStack(spacing: 5) { + Image(systemName: line.glyph?.systemImage ?? "circle.fill") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(activityToneColor(line.tone)) + .widgetAccentable() + Text(line.title) + .font(.footnote.weight(.semibold)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + Text(line.phaseLabel) + .font(.system(size: 9, weight: .bold, design: .rounded)) + .foregroundStyle(activityToneColor(line.tone)) + .lineLimit(1) + .fixedSize() + .widgetAccentable() + } + } + + HStack(spacing: 6) { + if status.overflowCount > 0 { + Text("+\(status.overflowCount) more") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + } + ForEach(status.metrics.prefix(status.overflowCount > 0 ? 1 : 2)) { metric in + Label(metric.label, systemImage: metric.symbol) + .font(.caption2.weight(.semibold)) + .labelStyle(.titleAndIcon) + .lineLimit(1) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + } + .padding(.horizontal, 1) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .opacity(isLuminanceReduced ? 0.85 : 1) + } + + private var focusLayout: some View { + Group { HStack(spacing: 8) { ZStack { Circle() @@ -625,9 +720,6 @@ private struct LockScreenRectangularView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .opacity(isLuminanceReduced ? 0.85 : 1) } - .accessibilityElement(children: .combine) - .accessibilityLabel("ADE status") - .accessibilityValue("\(status.title). \(status.detail)") } } From 55641f472d7f92ec9aaa68b7b2614f6649c111d4 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:29:39 -0400 Subject: [PATCH 13/19] =?UTF-8?q?activity(p8):=20rename=20sweep=20?= =?UTF-8?q?=E2=80=94=20components/activity=20move,=20store/hook/TUI=20rena?= =?UTF-8?q?mes,=20docs=20rewrite;=20wire=20vocabulary=20intentionally=20st?= =?UTF-8?q?ays=20'attention'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- apps/ade-cli/src/adeRpcServer.test.ts | 2 +- apps/ade-cli/src/multiProjectRpcServer.ts | 8 +- .../push/pushPublisherService.test.ts | 4 +- .../src/services/push/pushPublisherService.ts | 18 +- .../src/services/push/pushRelayClient.ts | 4 +- ...ntionPane.test.ts => activityPane.test.ts} | 50 ++-- ...iew.test.tsx => activityPaneView.test.tsx} | 10 +- .../src/tuiClient/__tests__/commands.test.ts | 16 +- .../{attentionPane.ts => activityPane.ts} | 62 ++--- apps/ade-cli/src/tuiClient/app.tsx | 64 ++--- apps/ade-cli/src/tuiClient/commands.ts | 15 +- ...ntionPaneView.tsx => ActivityPaneView.tsx} | 22 +- .../src/tuiClient/components/RightPane.tsx | 12 +- apps/ade-cli/src/tuiClient/types.ts | 4 +- .../native/ADEAttentionNotch/DESIGN_NOTES.md | 2 +- .../ADEAttentionNotch/NotchSurfaceView.swift | 2 +- .../ADEAttentionNotch/NotchViewModel.swift | 2 +- .../ADEAttentionNotch/ProtocolTransport.swift | 2 +- .../AttentionModels.swift | 14 +- apps/desktop/src/main/main.ts | 2 +- .../src/main/services/adeActions/registry.ts | 14 +- .../attention/attentionAccountCoordinator.ts | 36 +-- .../deeplinks/ownerAwareNavigation.ts | 6 +- .../src/main/services/ipc/registerIpc.ts | 10 +- .../{attention => activity}/Activity.css | 82 +++---- .../ActivityCard.test.tsx | 0 .../{attention => activity}/ActivityCard.tsx | 2 +- .../ActivityCardSkeleton.tsx | 0 .../ActivityDetailSheet.tsx | 4 +- .../ActivityFilters.test.tsx | 0 .../ActivityFilters.tsx | 0 .../ActivityInboxColumn.tsx | 2 +- .../ActivityPane.test.tsx | 56 ++--- .../{attention => activity}/ActivityPane.tsx | 46 ++-- .../ActivitySessionsColumn.tsx | 0 .../ActivitySettingsPopover.test.tsx | 4 +- .../ActivitySettingsPopover.tsx | 18 +- .../HeaderActivityControl.css | 0 .../HeaderActivityControl.test.tsx | 28 +-- .../HeaderActivityControl.tsx | 36 +-- .../activityNotchLocalSettings.test.ts} | 58 ++--- .../activityNotchLocalSettings.ts} | 44 ++-- .../activityPresentation.test.ts} | 70 +++--- .../activityPresentation.ts} | 36 +-- .../activityPriority.test.ts | 0 .../activityPriority.ts | 0 .../useActivitySync.test.tsx} | 109 +++++---- .../useActivitySync.ts} | 218 +++++++++--------- .../src/renderer/components/app/AppShell.tsx | 6 +- .../renderer/components/app/TabNav.test.tsx | 2 +- .../renderer/components/app/TopBar.test.tsx | 10 +- .../src/renderer/components/app/TopBar.tsx | 2 +- .../components/chat/ChatComputerUsePanel.tsx | 2 +- .../settings/ActivitySection.test.tsx | 10 +- .../settings/ActivitySettingsControls.tsx | 58 ++--- .../settings/NotificationsSection.tsx | 8 +- .../settings/settingsManifest.test.ts | 2 +- .../terminals/SessionInfoPopover.tsx | 4 +- .../terminals/SessionStatusSlot.tsx | 2 +- .../hooks/useAppWideSessionAttention.test.tsx | 10 +- .../hooks/useAppWideSessionAttention.ts | 16 +- ...ionStore.test.ts => activityStore.test.ts} | 110 ++++----- .../{attentionStore.ts => activityStore.ts} | 92 ++++---- .../src/renderer/state/crossMachineLanes.ts | 2 +- .../adapter/__tests__/adapter.test.ts | 14 +- apps/ios/ADE/Services/AccountDirectory.swift | 6 +- apps/ios/ADE/Services/SyncService.swift | 8 +- apps/ios/ADE/Shared/ADESharedModels.swift | 4 +- apps/ios/ADE/Shared/ADESharedTheme.swift | 2 +- .../ADE/Shared/ActivityRowPresentation.swift | 2 +- .../ADE/Shared/AttentionActionIntents.swift | 2 +- apps/ios/ADE/Views/Lanes/LaneHelpers.swift | 2 +- .../ActivityRowPresentationTests.swift | 2 +- apps/ios/ADETests/PairingAndDpopTests.swift | 2 +- apps/ios/ADEWidgets/ADELockScreenWidget.swift | 2 +- apps/ios/ADEWidgets/ADEWidgetBundle.swift | 2 +- apps/push-relay/README.md | 20 +- docs/ARCHITECTURE.md | 35 ++- docs/PRD.md | 4 +- docs/README.md | 2 +- docs/features/ade-code/README.md | 4 +- docs/features/chat/README.md | 2 +- .../onboarding-and-settings/README.md | 4 +- docs/features/sync-and-multi-device/README.md | 21 +- .../sync-and-multi-device/ios-companion.md | 24 +- .../push-notifications.md | 54 +++-- .../features/terminals-and-sessions/README.md | 2 +- .../terminals-and-sessions/ui-surfaces.md | 6 + docs/features/web-client/README.md | 4 +- 89 files changed, 900 insertions(+), 860 deletions(-) rename apps/ade-cli/src/tuiClient/__tests__/{attentionPane.test.ts => activityPane.test.ts} (85%) rename apps/ade-cli/src/tuiClient/__tests__/{attentionPaneView.test.tsx => activityPaneView.test.tsx} (90%) rename apps/ade-cli/src/tuiClient/{attentionPane.ts => activityPane.ts} (83%) rename apps/ade-cli/src/tuiClient/components/{AttentionPaneView.tsx => ActivityPaneView.tsx} (86%) rename apps/desktop/src/renderer/components/{attention => activity}/Activity.css (95%) rename apps/desktop/src/renderer/components/{attention => activity}/ActivityCard.test.tsx (100%) rename apps/desktop/src/renderer/components/{attention => activity}/ActivityCard.tsx (99%) rename apps/desktop/src/renderer/components/{attention => activity}/ActivityCardSkeleton.tsx (100%) rename apps/desktop/src/renderer/components/{attention => activity}/ActivityDetailSheet.tsx (98%) rename apps/desktop/src/renderer/components/{attention => activity}/ActivityFilters.test.tsx (100%) rename apps/desktop/src/renderer/components/{attention => activity}/ActivityFilters.tsx (100%) rename apps/desktop/src/renderer/components/{attention => activity}/ActivityInboxColumn.tsx (98%) rename apps/desktop/src/renderer/components/{attention => activity}/ActivityPane.test.tsx (89%) rename apps/desktop/src/renderer/components/{attention => activity}/ActivityPane.tsx (89%) rename apps/desktop/src/renderer/components/{attention => activity}/ActivitySessionsColumn.tsx (100%) rename apps/desktop/src/renderer/components/{attention => activity}/ActivitySettingsPopover.test.tsx (98%) rename apps/desktop/src/renderer/components/{attention => activity}/ActivitySettingsPopover.tsx (91%) rename apps/desktop/src/renderer/components/{attention => activity}/HeaderActivityControl.css (100%) rename apps/desktop/src/renderer/components/{attention => activity}/HeaderActivityControl.test.tsx (95%) rename apps/desktop/src/renderer/components/{attention => activity}/HeaderActivityControl.tsx (94%) rename apps/desktop/src/renderer/components/{attention/attentionNotchLocalSettings.test.ts => activity/activityNotchLocalSettings.test.ts} (68%) rename apps/desktop/src/renderer/components/{attention/attentionNotchLocalSettings.ts => activity/activityNotchLocalSettings.ts} (84%) rename apps/desktop/src/renderer/components/{attention/attentionPresentation.test.ts => activity/activityPresentation.test.ts} (73%) rename apps/desktop/src/renderer/components/{attention/attentionPresentation.ts => activity/activityPresentation.ts} (89%) rename apps/desktop/src/renderer/components/{attention => activity}/activityPriority.test.ts (100%) rename apps/desktop/src/renderer/components/{attention => activity}/activityPriority.ts (100%) rename apps/desktop/src/renderer/components/{attention/useAttentionSync.test.tsx => activity/useActivitySync.test.tsx} (92%) rename apps/desktop/src/renderer/components/{attention/useAttentionSync.ts => activity/useActivitySync.ts} (78%) rename apps/desktop/src/renderer/state/{attentionStore.test.ts => activityStore.test.ts} (70%) rename apps/desktop/src/renderer/state/{attentionStore.ts => activityStore.ts} (84%) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 6922bcf0d..36195cfef 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -3465,7 +3465,7 @@ describe("adeRpcServer", () => { (entry: { name: string }) => entry.name === "attention.getSnapshot", ); expect(getSnapshotAction).toMatchObject({ - description: expect.stringContaining("account-wide Attention stream"), + description: expect.stringContaining("account-wide Activity stream"), input: expect.stringContaining("streamId"), example: expect.stringContaining("attention.getSnapshot"), }); diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index bd6966d70..c1f90b2a1 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -1261,7 +1261,7 @@ export function createMultiProjectRpcRequestHandler( if (!publisher) { throw new JsonRpcError( JsonRpcErrorCode.invalidRequest, - "Account Attention is unavailable until the ADE brain is ready.", + "Account Activity is unavailable until the ADE brain is ready.", ); } if (action === "getSnapshot") { @@ -1349,7 +1349,7 @@ export function createMultiProjectRpcRequestHandler( if (!accountOwnerId || currentAccountOwnerUserId() !== accountOwnerId) { throw new JsonRpcError( JsonRpcErrorCode.invalidRequest, - "The ADE account changed before Attention preferences could be read.", + "The ADE account changed before Activity preferences could be read.", ); } return await publisher.getAttentionPreferences(accountOwnerId); @@ -1364,7 +1364,7 @@ export function createMultiProjectRpcRequestHandler( ) { throw new JsonRpcError( JsonRpcErrorCode.invalidRequest, - "The ADE account changed before Attention preferences could be saved.", + "The ADE account changed before Activity preferences could be saved.", ); } await publisher.putAttentionPreferences( @@ -1375,7 +1375,7 @@ export function createMultiProjectRpcRequestHandler( } throw new JsonRpcError( JsonRpcErrorCode.methodNotFound, - `Unknown Attention action: ${action || "(empty)"}`, + `Unknown Activity action: ${action || "(empty)"}`, ); } diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index a68bb190c..80aa66e24 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -1098,7 +1098,7 @@ describe("createPushPublisherService flush", () => { itemIds: ["agent:other-machine:unknown"], sourceRevisions: { "agent:other-machine:unknown": 1 }, expectedAccountOwnerId: "owner-a", - })).rejects.toThrow(/latest Attention snapshot/i); + })).rejects.toThrow(/latest Activity snapshot/i); const current = (await publisher.getMachineAttentionSnapshot()).items[0]!; setAccountOwnerId("owner-b"); @@ -3177,7 +3177,7 @@ describe("createPushRelayClient", () => { }); await expect(client.getAttentionSnapshot()).rejects.toThrow( - /invalid Attention snapshot/i, + /invalid Activity snapshot/i, ); }); diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index 8a4620909..1ca7c7fae 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -1496,7 +1496,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { items: itemPages[page] ?? [], tombstones: tombstonePages[page] ?? [], }); - if (!result) throw new Error("Attention relay became unavailable during reconcile."); + if (!result) throw new Error("Activity relay became unavailable during reconcile."); const response = recordActivityPublishResponse(result, nowMs, page === 0); if (response.protocol < 2) return "legacy"; capShrunk = capShrunk || response.capShrunk; @@ -1558,7 +1558,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { items: [], tombstones: [], }); - if (!result) throw new Error("Attention relay became unavailable during presence publish."); + if (!result) throw new Error("Activity relay became unavailable during presence publish."); const response = recordActivityPublishResponse(result, nowMs); return response.protocol >= 2 ? "unchanged" : "legacy"; } @@ -1587,7 +1587,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { items: pageItems, tombstones: pageTombstones, }); - if (!result) throw new Error("Attention relay became unavailable during delta publish."); + if (!result) throw new Error("Activity relay became unavailable during delta publish."); const response = recordActivityPublishResponse(result, nowMs, page === 0); if (response.protocol < 2) return "legacy"; unchanged = unchanged && (result.unchanged === true || result.suppressed === true); @@ -2664,12 +2664,12 @@ export function createPushPublisherService(deps: PushPublisherDeps) { const expectedAccountOwnerId = args.expectedAccountOwnerId?.trim() || null; if (expectedAccountOwnerId !== currentAccountOwnerId) { throw new Error( - "The ADE account changed after this machine Attention snapshot loaded. Refresh and try again.", + "The ADE account changed after this machine Activity snapshot loaded. Refresh and try again.", ); } if (lastMachineSnapshotAccountOwnerId !== currentAccountOwnerId) { throw new Error( - "Refresh machine Attention after changing ADE accounts, then try again.", + "Refresh machine Activity after changing ADE accounts, then try again.", ); } const items = args.itemIds.flatMap((itemId) => { @@ -2678,26 +2678,26 @@ export function createPushPublisherService(deps: PushPublisherDeps) { }); if (items.length !== args.itemIds.length) { throw new Error( - "This machine can only acknowledge items from its latest Attention snapshot. Refresh and try again.", + "This machine can only acknowledge items from its latest Activity snapshot. Refresh and try again.", ); } const staleItem = items.find((item) => args.sourceRevisions[item.id] !== item.revision); if (staleItem) { throw new Error( - "This Attention item changed after it loaded. Refresh before acknowledging the newer state.", + "This Activity item changed after it loaded. Refresh before acknowledging the newer state.", ); } const updatedAt = new Date(now()).toISOString(); const seenAt = args.seenAt?.trim() || updatedAt; if (Number.isNaN(Date.parse(seenAt))) { - throw new Error("Attention seenAt must be an ISO timestamp."); + throw new Error("Activity seenAt must be an ISO timestamp."); } if ( typeof args.dismissedAt === "string" && Number.isNaN(Date.parse(args.dismissedAt)) ) { - throw new Error("Attention dismissedAt must be an ISO timestamp."); + throw new Error("Activity dismissedAt must be an ISO timestamp."); } deps.store.recordAttentionAcknowledgments?.({ items: items.map((item) => ({ id: item.id, revision: item.revision })), diff --git a/apps/ade-cli/src/services/push/pushRelayClient.ts b/apps/ade-cli/src/services/push/pushRelayClient.ts index 57c6bf18c..449ae382b 100644 --- a/apps/ade-cli/src/services/push/pushRelayClient.ts +++ b/apps/ade-cli/src/services/push/pushRelayClient.ts @@ -269,7 +269,7 @@ export function createPushRelayClient(args: { throw new PushRelayRequestError( "getAttentionSnapshot", 502, - "relay returned an invalid Attention snapshot", + "relay returned an invalid Activity snapshot", ); } return body as unknown as AttentionSnapshot; @@ -399,7 +399,7 @@ export function createPushRelayClient(args: { : acknowledgment.expectedAccountOwnerId?.trim() || null; if (expectedAccountUserId !== currentAccountUserId) { throw new Error( - "The ADE account changed before the Attention acknowledgment could sync.", + "The ADE account changed before the Activity acknowledgment could sync.", ); } if (!currentAccountUserId) return null; diff --git a/apps/ade-cli/src/tuiClient/__tests__/attentionPane.test.ts b/apps/ade-cli/src/tuiClient/__tests__/activityPane.test.ts similarity index 85% rename from apps/ade-cli/src/tuiClient/__tests__/attentionPane.test.ts rename to apps/ade-cli/src/tuiClient/__tests__/activityPane.test.ts index 910e1cc87..6ec328e4f 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/attentionPane.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/activityPane.test.ts @@ -5,13 +5,13 @@ import type { } from "../../../../desktop/src/shared/types/attention"; import type { AdeCodeConnection } from "../types"; import { - acknowledgeAttentionItem, - attentionItemContext, - attentionItemDeepLink, - attentionPaneEntries, - buildAttentionPaneModel, - loadAttentionSnapshot, -} from "../attentionPane"; + acknowledgeActivityItem, + activityItemContext, + activityItemDeepLink, + activityPaneEntries, + buildActivityPaneModel, + loadActivitySnapshot, +} from "../activityPane"; function item(overrides: Partial = {}): AttentionItem { return { @@ -36,7 +36,7 @@ function item(overrides: Partial = {}): AttentionItem { laneId: "lane-1", laneName: "attention", title: "Codex is working", - preview: "Implementing account Attention", + preview: "Implementing account Activity", privacyPreview: "Agent is working", destination: { kind: "session", @@ -59,7 +59,7 @@ function snapshot(items: AttentionItem[]): AttentionSnapshot { scope: "account", availability: { state: "ready", - title: "Account Attention", + title: "Account Activity", message: "Live across your ADE account.", recovery: null, }, @@ -99,9 +99,9 @@ function asRequest( await implementation(method, params) as T; } -describe("account-wide Attention pane", () => { +describe("account-wide Activity pane", () => { it("groups waiting, failure, unreviewed, and live work without counting live as waiting", () => { - const model = buildAttentionPaneModel(snapshot([ + const model = buildActivityPaneModel(snapshot([ item({ id: "needs", phase: "needs_you", eventKind: "agent_needs_you" }), item({ id: "failed", phase: "failed", eventKind: "agent_failed" }), item({ id: "done", phase: "completed", eventKind: "agent_completed" }), @@ -120,7 +120,7 @@ describe("account-wide Attention pane", () => { expect(model.items.map((entry) => entry.id)).not.toContain("dismissed"); }); - it("reads Attention through the project-independent machine RPC", async () => { + it("reads Activity through the project-independent machine RPC", async () => { const accountSnapshot = snapshot([item()]); const request = vi.fn(async (method: string, params?: unknown) => { if (method === "account.call") return { result: { signedIn: true } }; @@ -129,7 +129,7 @@ describe("account-wide Attention pane", () => { return accountSnapshot; }); - await expect(loadAttentionSnapshot(connection(asRequest(request)))).resolves.toMatchObject({ + await expect(loadActivitySnapshot(connection(asRequest(request)))).resolves.toMatchObject({ scope: "account", streamId: "account-1", }); @@ -146,7 +146,7 @@ describe("account-wide Attention pane", () => { return { ...snapshot([item()]), scope: "machine" }; }); - const result = await loadAttentionSnapshot(connection(asRequest(request))); + const result = await loadActivitySnapshot(connection(asRequest(request))); expect(result).toMatchObject({ scope: "machine", availability: { @@ -159,7 +159,7 @@ describe("account-wide Attention pane", () => { it("acknowledges machine fallback items through the machine-scoped contract", async () => { const request = vi.fn(async () => null); - await acknowledgeAttentionItem( + await acknowledgeActivityItem( connection(asRequest(request)), { id: "machine-item", revision: 7 }, "machine", @@ -181,10 +181,10 @@ describe("account-wide Attention pane", () => { it("names an old signed-out host instead of fabricating an empty machine fallback", async () => { const request = vi.fn(async (method: string) => { if (method === "account.call") return { result: { signedIn: false } }; - throw new Error("Unsupported Attention method: attention.call"); + throw new Error("Unsupported Activity method: attention.call"); }); - await expect(loadAttentionSnapshot( + await expect(loadActivitySnapshot( connection(asRequest(request)), { hostName: "Mac Studio" }, )).resolves.toMatchObject({ @@ -203,14 +203,14 @@ describe("account-wide Attention pane", () => { const request = vi.fn(async (method: string, params?: unknown) => { if (method === "account.call") return { result: { signedIn: true } }; if ((params as { action?: string })?.action === "getSnapshot") { - throw new Error("Unsupported Attention method: attention.call"); + throw new Error("Unsupported Activity method: attention.call"); } if ((params as { action?: string })?.action === "getMachineSnapshot") { return machine; } throw new Error(`unexpected ${method}`); }); - const result = await loadAttentionSnapshot(connection(asRequest(request)), { + const result = await loadActivitySnapshot(connection(asRequest(request)), { hostName: "Mac Studio", }); expect(result).toMatchObject({ @@ -227,7 +227,7 @@ describe("account-wide Attention pane", () => { it("never falls back through the selected-project action namespace", async () => { const request = vi.fn(async (method: string) => { if (method === "account.call") return { result: { signedIn: true } }; - throw new Error("Account Attention snapshot failed: unauthorized"); + throw new Error("Account Activity snapshot failed: unauthorized"); }); const selectedProjectActionCalls = vi.fn(); const selectedProjectAction: AdeCodeConnection["action"] = async ( @@ -238,7 +238,7 @@ describe("account-wide Attention pane", () => { selectedProjectActionCalls(domain, action, args); throw new Error("selected-project action must not run"); }; - const result = await loadAttentionSnapshot( + const result = await loadActivitySnapshot( connection(asRequest(request), selectedProjectAction), ); @@ -260,17 +260,17 @@ describe("account-wide Attention pane", () => { lastSeenAt: "2026-07-28T20:00:00.000Z", }, }); - expect(attentionItemDeepLink(target)).toBe( + expect(activityItemDeepLink(target)).toBe( "ade://session/session-1?item=message-1&accountMachineKey=account-machine-2&projectId=project-1", ); - expect(attentionItemContext(target)).toBe("ADE · attention · MacBook Pro"); + expect(activityItemContext(target)).toBe("ADE · attention · MacBook Pro"); }); it("keeps the keyboard selection visible in a bounded pane window", () => { const items = Array.from({ length: 20 }, (_, index) => item({ id: `item-${index}`, phase: index < 10 ? "needs_you" : "running" })); - const model = buildAttentionPaneModel(snapshot(items)); - const window = attentionPaneEntries(model, 18, 8); + const model = buildActivityPaneModel(snapshot(items)); + const window = activityPaneEntries(model, 18, 8); expect(window.entries.some((entry) => entry.kind === "item" && entry.itemIndex === 18)).toBe(true); expect(window.hiddenBefore).toBeGreaterThan(0); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/attentionPaneView.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/activityPaneView.test.tsx similarity index 90% rename from apps/ade-cli/src/tuiClient/__tests__/attentionPaneView.test.tsx rename to apps/ade-cli/src/tuiClient/__tests__/activityPaneView.test.tsx index c32715682..93a9d6072 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/attentionPaneView.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/activityPaneView.test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { describe, expect, it } from "vitest"; import { render } from "ink-testing-library"; import type { AttentionItem } from "../../../../desktop/src/shared/types/attention"; -import { buildAttentionPaneModel } from "../attentionPane"; +import { buildActivityPaneModel } from "../activityPane"; import { RightPane } from "../components/RightPane"; function attentionItem(): AttentionItem { @@ -36,9 +36,9 @@ function attentionItem(): AttentionItem { }; } -describe("AttentionPane", () => { +describe("ActivityPane", () => { it("renders scope, urgency, ownership, offline honesty, and keyboard help", () => { - const model = buildAttentionPaneModel({ + const model = buildActivityPaneModel({ contractVersion: 1, scope: "machine", availability: { @@ -55,14 +55,14 @@ describe("AttentionPane", () => { }); const view = render( , ).lastFrame() ?? ""; - expect(view).toContain("ATTENTION"); + expect(view).toContain("ACTIVITY"); expect(view).toContain("THIS MACHINE"); expect(view).toContain("Showing this machine while you retry."); expect(view).toContain("NEEDS YOU"); diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index 1bd0edbef..159d661df 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -3,17 +3,23 @@ import { commandPlacement, parseCommand, paletteCommands } from "../commands"; import { buildLinearToolRequest, parseLinearArgs } from "../linearCommands"; describe("commands", () => { - it("routes account Attention to the keyboard-accessible right pane", () => { - const parsed = parseCommand("/attention"); - expect(parsed?.name).toBe("/attention"); + it("routes account Activity to the keyboard-accessible right pane", () => { + const parsed = parseCommand("/activity"); + expect(parsed?.name).toBe("/activity"); expect(parsed ? commandPlacement(parsed) : null).toBe("right"); - expect(paletteCommands("/att")).toContainEqual(expect.objectContaining({ - name: "/attention", + expect(paletteCommands("/act")).toContainEqual(expect.objectContaining({ + name: "/activity", source: "ade", description: "Show account-wide work that needs you", })); }); + it("keeps the old Attention command as a non-advertised alias", () => { + const parsed = parseCommand("/attention"); + expect(parsed?.name).toBe("/activity"); + expect(paletteCommands("/attention")).toEqual([]); + }); + it("parses multi-word ADE commands before generic slash commands", () => { const parsed = parseCommand("/linear pull ADE-123"); expect(parsed?.name).toBe("/linear pull"); diff --git a/apps/ade-cli/src/tuiClient/attentionPane.ts b/apps/ade-cli/src/tuiClient/activityPane.ts similarity index 83% rename from apps/ade-cli/src/tuiClient/attentionPane.ts rename to apps/ade-cli/src/tuiClient/activityPane.ts index 65a078fc7..cee95f93c 100644 --- a/apps/ade-cli/src/tuiClient/attentionPane.ts +++ b/apps/ade-cli/src/tuiClient/activityPane.ts @@ -9,22 +9,22 @@ import { } from "../../../desktop/src/shared/types/attention"; import type { AdeCodeConnection } from "./types"; -export type AttentionPaneGroupId = +export type ActivityPaneGroupId = | "needs-you" | "failing" | "done" | "live" | "recent"; -export type AttentionPaneGroup = { - id: AttentionPaneGroupId; +export type ActivityPaneGroup = { + id: ActivityPaneGroupId; label: string; items: AttentionItem[]; }; -export type AttentionPaneModel = { +export type ActivityPaneModel = { snapshot: AttentionSnapshot; - groups: AttentionPaneGroup[]; + groups: ActivityPaneGroup[]; items: AttentionItem[]; title: string; message: string; @@ -33,7 +33,7 @@ export type AttentionPaneModel = { liveCount: number; }; -export type AttentionPaneEntry = +export type ActivityPaneEntry = | { kind: "heading"; key: string; label: string } | { kind: "item"; key: string; item: AttentionItem; itemIndex: number }; @@ -64,7 +64,7 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function isUnsupportedAttentionError(error: unknown): boolean { +function isUnsupportedActivityError(error: unknown): boolean { return /unknown (?:ade )?action|unknown attention action|method not found|unsupported.*attention|attention\.call.*not (?:available|found)/i .test(errorMessage(error)); } @@ -111,12 +111,12 @@ async function machineFallback( } /** - * Reads account Attention from the machine-global RPC rather than from the + * Reads account Activity from the machine-global RPC rather than from the * TUI's selected project action scope. A signed-out or temporarily unavailable * account falls back to this connected machine without pretending that the * result is account-wide. */ -export async function loadAttentionSnapshot( +export async function loadActivitySnapshot( connection: AdeCodeConnection, options: { hostName?: string | null } = {}, ): Promise { @@ -132,19 +132,19 @@ export async function loadAttentionSnapshot( }); } catch (error) { const hostName = options.hostName?.trim() || "this ADE host"; - if (isUnsupportedAttentionError(error)) { + if (isUnsupportedActivityError(error)) { return emptySnapshot({ state: "incompatible", title: `Update ${hostName}`, message: - "This host cannot provide machine Attention yet. Update ADE, restart its brain, then retry.", + "This host cannot provide machine Activity yet. Update ADE, restart its brain, then retry.", recovery: "update_host", hostName, }); } return emptySnapshot({ state: "unavailable", - title: "Machine Attention is unavailable", + title: "Machine Activity is unavailable", message: `ADE Code could not read work from ${hostName}. Reconnect to the host, then retry.`, recovery: "retry", hostName, @@ -163,19 +163,19 @@ export async function loadAttentionSnapshot( scope: snapshot.scope ?? "account", availability: snapshot.availability ?? { state: "ready", - title: "Account Attention", + title: "Account Activity", message: "Live across your ADE account.", recovery: null, }, }; } catch (error) { const hostName = options.hostName?.trim() || "this ADE host"; - if (isUnsupportedAttentionError(error)) { + if (isUnsupportedActivityError(error)) { try { return await machineFallback(connection, { state: "incompatible", title: `Update ${hostName}`, - message: "This host cannot read account-wide Attention yet. Update ADE, then restart its brain. Local work remains available.", + message: "This host cannot read account-wide Activity yet. Update ADE, then restart its brain. Local work remains available.", recovery: "update_host", hostName, }); @@ -184,7 +184,7 @@ export async function loadAttentionSnapshot( state: "incompatible", title: `Update ${hostName}`, message: - "This host cannot provide Attention yet. Update ADE, restart its brain, then retry.", + "This host cannot provide Activity yet. Update ADE, restart its brain, then retry.", recovery: "update_host", hostName, }); @@ -201,7 +201,7 @@ export async function loadAttentionSnapshot( } catch { return emptySnapshot({ state: "unavailable", - title: "Attention is unavailable", + title: "Activity is unavailable", message: "ADE Code could not read the account stream or this host. Reconnect to the host, then retry.", recovery: "retry", @@ -211,7 +211,7 @@ export async function loadAttentionSnapshot( } } -export async function acknowledgeAttentionItem( +export async function acknowledgeActivityItem( connection: AdeCodeConnection, item: Pick, scope: AttentionSnapshot["scope"] = "account", @@ -226,7 +226,7 @@ export async function acknowledgeAttentionItem( }); } -function groupForItem(item: AttentionItem): AttentionPaneGroupId { +function groupForItem(item: AttentionItem): ActivityPaneGroupId { if (item.phase === "needs_you" || item.phase === "review_requested" || item.phase === "merge_ready") { return "needs-you"; } @@ -247,7 +247,7 @@ function groupForItem(item: AttentionItem): AttentionPaneGroupId { return "recent"; } -const GROUP_LABELS: Record = { +const GROUP_LABELS: Record = { "needs-you": "NEEDS YOU", failing: "FAILING OR BLOCKED", done: "DONE, UNREVIEWED", @@ -255,20 +255,20 @@ const GROUP_LABELS: Record = { recent: "RECENT", }; -export function buildAttentionPaneModel(snapshot: AttentionSnapshot): AttentionPaneModel { +export function buildActivityPaneModel(snapshot: AttentionSnapshot): ActivityPaneModel { const visible = sortAttentionItems( snapshot.items.filter((item) => item.dismissedAt === null), ); - const buckets = new Map(); + const buckets = new Map(); for (const item of visible) { const group = groupForItem(item); const bucket = buckets.get(group) ?? []; bucket.push(item); buckets.set(group, bucket); } - const order: AttentionPaneGroupId[] = ["needs-you", "failing", "done", "live", "recent"]; + const order: ActivityPaneGroupId[] = ["needs-you", "failing", "done", "live", "recent"]; const groups = order - .map((id): AttentionPaneGroup => ({ + .map((id): ActivityPaneGroup => ({ id, label: GROUP_LABELS[id], items: buckets.get(id) ?? [], @@ -281,7 +281,7 @@ export function buildAttentionPaneModel(snapshot: AttentionSnapshot): AttentionP const liveCount = groups.find((group) => group.id === "live")?.items.length ?? 0; const availability = snapshot.availability ?? { state: snapshot.scope === "machine" ? "degraded" as const : "ready" as const, - title: snapshot.scope === "machine" ? "This machine only" : "Account Attention", + title: snapshot.scope === "machine" ? "This machine only" : "Account Activity", message: snapshot.scope === "machine" ? "Account sync is unavailable. Showing connected-machine work." : "Live across your ADE account.", @@ -300,22 +300,22 @@ export function buildAttentionPaneModel(snapshot: AttentionSnapshot): AttentionP }; } -export function attentionItemDeepLink(item: AttentionItem): string { +export function activityItemDeepLink(item: AttentionItem): string { return attentionDestinationDeepLink(item.destination, item); } -export function attentionItemContext(item: AttentionItem): string { +export function activityItemContext(item: AttentionItem): string { return [item.project.name, item.laneName, item.machine.name] .filter((value): value is string => Boolean(value?.trim())) .join(" · "); } -export function attentionPaneEntries( - model: AttentionPaneModel, +export function activityPaneEntries( + model: ActivityPaneModel, selectedIndex: number, maxRows = 20, -): { entries: AttentionPaneEntry[]; hiddenBefore: number; hiddenAfter: number } { - const all: AttentionPaneEntry[] = []; +): { entries: ActivityPaneEntry[]; hiddenBefore: number; hiddenAfter: number } { + const all: ActivityPaneEntry[] = []; let itemIndex = 0; for (const group of model.groups) { all.push({ kind: "heading", key: `heading:${group.id}`, label: group.label }); diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 5698bd86d..4b263c3f0 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -350,11 +350,11 @@ import { claudeHomePath, defaultKeybindingsPath, dispatchKeybinding, openKeybind import { buildDeeplinkForRow, buildWebClientUrlForRow, type DeeplinkRow } from "./deeplinkRow"; import { copyToClipboard } from "../lib/clipboard"; import { - acknowledgeAttentionItem, - attentionItemDeepLink, - buildAttentionPaneModel, - loadAttentionSnapshot, -} from "./attentionPane"; + acknowledgeActivityItem, + activityItemDeepLink, + buildActivityPaneModel, + loadActivitySnapshot, +} from "./activityPane"; import { deletePromptSmartLinkBackward, deletePromptSmartLinkForward, @@ -893,7 +893,7 @@ function openExternalUrl(url: string, notice: (message: string, tone?: LocalNoti return true; } -async function openAttentionDeepLink( +async function openActivityDeepLink( url: string, notice: (message: string, tone?: LocalNotice["tone"]) => void, ): Promise { @@ -9501,13 +9501,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, toggleRightChatsClosedGroup, ]); - const activateAttentionItem = useCallback(async (index: number): Promise => { + const activateActivityItem = useCallback(async (index: number): Promise => { const pane = rightPaneRef.current; - if (pane.kind !== "attention") return; + if (pane.kind !== "activity") return; const item = pane.model.items[index]; if (!item) return; - const deepLink = attentionItemDeepLink(item); - if (!await openAttentionDeepLink(deepLink, addNotice)) { + const deepLink = activityItemDeepLink(item); + if (!await openActivityDeepLink(deepLink, addNotice)) { addNotice("ADE could not open this destination on the current platform. The item remains unreviewed.", "error"); return; } @@ -9521,20 +9521,20 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, entry.id === item.id ? { ...entry, seenAt } : entry), }; setRightPane({ - kind: "attention", - model: buildAttentionPaneModel(updatedSnapshot), + kind: "activity", + model: buildActivityPaneModel(updatedSnapshot), }); const conn = connectionRef.current; if (!conn) return; try { - await acknowledgeAttentionItem( + await acknowledgeActivityItem( conn, item, pane.model.snapshot.scope, pane.model.snapshot.accountOwnerId ?? null, ); } catch { - addNotice("The destination opened, but ADE could not sync the seen state. Retry Attention to reconcile it.", "error"); + addNotice("The destination opened, but ADE could not sync the seen state. Retry Activity to reconcile it.", "error"); } }, [addNotice]); @@ -9636,41 +9636,41 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, setPaneFocus("details"); }, [renderHelpPane, setPaneFocus]); - const refreshAttentionPane = useCallback(async (options: { announce?: boolean } = {}) => { + const refreshActivityPane = useCallback(async (options: { announce?: boolean } = {}) => { const conn = connectionRef.current; if (!conn) { setRightPane({ kind: "details", - title: "Attention", + title: "Activity", body: "ADE is still connecting. Retry when the runtime is ready.", }); return; } - const snapshot = await loadAttentionSnapshot(conn, { + const snapshot = await loadActivitySnapshot(conn, { hostName: project.remoteLabel, }); - const model = buildAttentionPaneModel(snapshot); + const model = buildActivityPaneModel(snapshot); setRightSelectionIndex((index) => Math.max(0, Math.min(index, Math.max(0, model.items.length - 1)))); - setRightPane({ kind: "attention", model }); + setRightPane({ kind: "activity", model }); setRightOpen(true); if (options.announce) { addNotice( snapshot.scope === "machine" - ? "Attention refreshed from this connected machine." - : "Account Attention refreshed.", + ? "Activity refreshed from this connected machine." + : "Account Activity refreshed.", snapshot.availability?.state === "ready" ? "success" : "info", ); } }, [addNotice, project.remoteLabel]); useEffect(() => { - if (rightPane.kind !== "attention" || !connection) return; + if (rightPane.kind !== "activity" || !connection) return; const timer = setInterval(() => { - void refreshAttentionPane(); + void refreshActivityPane(); }, 10_000); timer.unref?.(); return () => clearInterval(timer); - }, [connection, refreshAttentionPane, rightPane.kind]); + }, [connection, refreshActivityPane, rightPane.kind]); const runRightCommand = useCallback(async (name: string, args: string) => { const conn = connectionRef.current; @@ -9701,10 +9701,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, }); return; } - if (name === "/attention") { + if (name === "/activity") { setRightPane({ kind: "details", - title: "Attention", + title: "Activity", body: "ADE is still connecting. Retry when the runtime is ready.", }); return; @@ -9757,9 +9757,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, renderHelpPane("", 0, helpRecentsRef.current); return; } - if (name === "/attention") { + if (name === "/activity") { setRightSelectionIndex(0); - await refreshAttentionPane(); + await refreshActivityPane(); return; } if (name === "/keybindings") { @@ -11293,7 +11293,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, addNotice(result.message ?? "Desktop route unavailable from this runtime.", "error"); } } - }, [activeSession?.provider, addNotice, applyLocalModelArg, applySessionSnooze, clearOlderHistoryCursor, displaySessions, loadProviderModels, modelState.provider, openSnoozeDurationPalette, pendingSteers, preferServiceRepair, project, refreshAiSetupStatus, refreshAttentionPane, refreshState, remoteLaunch, requestAppExit, scheduleModelStateCommit, sendClaudeModelCommandToTerminal, setChatScrollOffset, socketPath]); + }, [activeSession?.provider, addNotice, applyLocalModelArg, applySessionSnooze, clearOlderHistoryCursor, displaySessions, loadProviderModels, modelState.provider, openSnoozeDurationPalette, pendingSteers, preferServiceRepair, project, refreshAiSetupStatus, refreshActivityPane, refreshState, remoteLaunch, requestAppExit, scheduleModelStateCommit, sendClaudeModelCommandToTerminal, setChatScrollOffset, socketPath]); const submitRightForm = useCallback(async ( form: Extract, @@ -15096,7 +15096,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } } - if (pane === "details" && rightOpen && rightPane.kind === "attention") { + if (pane === "details" && rightOpen && rightPane.kind === "activity") { const itemCount = rightPane.model.items.length; if (key.upArrow) { setRightSelectionIndex((index) => (index <= 0 ? Math.max(0, itemCount - 1) : index - 1)); @@ -15115,11 +15115,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return; } if (key.return && itemCount > 0) { - void activateAttentionItem(rightSelectionIndex); + void activateActivityItem(rightSelectionIndex); return; } if (input.toLowerCase() === "r" && !key.ctrl && !key.meta) { - void refreshAttentionPane({ announce: true }); + void refreshActivityPane({ announce: true }); return; } } diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index b697c3559..728b2aa38 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -70,8 +70,8 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ // The bare group name is registered so submitting it prints usage instead of // leaking "/session" into the chat as a message. { name: "/session", description: "Run a session lifecycle command", placement: "right", argumentHint: "", category: "Chats" }, - { name: "/session snooze", description: "Snooze a session out of the attention list until a deadline", placement: "right", argumentHint: "[session-id] [30m|1h|4h|1d]", category: "Chats" }, - { name: "/session wake", description: "Wake a snoozed session back into the attention list", placement: "right", argumentHint: "[session-id]", category: "Chats" }, + { name: "/session snooze", description: "Snooze a session out of the Activity list until a deadline", placement: "right", argumentHint: "[session-id] [30m|1h|4h|1d]", category: "Chats" }, + { name: "/session wake", description: "Wake a snoozed session back into the Activity list", placement: "right", argumentHint: "[session-id]", category: "Chats" }, { name: "/session settle", description: "Mark a session settled", placement: "right", argumentHint: "[session-id] [outcome]", category: "Chats" }, { name: "/session unsettle", description: "Remove a session's settled state", placement: "right", argumentHint: "[session-id]", category: "Chats" }, { name: "/session keep-active", description: "Pin a session active against a later settle", placement: "right", argumentHint: "[session-id]", category: "Chats" }, @@ -79,7 +79,7 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/output-style", description: "List or select the active Claude output style", placement: "right", argumentHint: "[style]", providers: ["claude"], category: "Model" }, { name: "/plugin", description: "List, reload, or manage Claude plugins", placement: "right", argumentHint: "[reload|native args]", providers: ["claude"], category: "Model" }, { name: "/status", description: "Show project, lane, and runtime state", placement: "right", category: "Nav" }, - { name: "/attention", description: "Show account-wide work that needs you", placement: "right", category: "Nav" }, + { name: "/activity", description: "Show account-wide work that needs you", placement: "right", category: "Nav" }, { name: "/context", description: "Show chat context usage", placement: "right", category: "Nav" }, { name: "/agents", description: "List Claude agents from user and project config", placement: "right", providers: ["claude"], category: "Nav" }, { name: "/info", description: "Open active chat info, plan, goal, and agents", placement: "right", category: "Nav" }, @@ -163,6 +163,10 @@ export type ParsedCommand = { userCommand: AgentChatSlashCommand | null; }; +const LEGACY_LOCAL_COMMAND_ALIASES: Readonly> = { + "/attention": "/activity", +}; + function normalizeSlashName(value: string): string { return value.trim().replace(/\s+/g, " "); } @@ -172,8 +176,11 @@ function slashCommandKey(value: string): string { } export function parseCommand(input: string, userCommands: AgentChatSlashCommand[] = []): ParsedCommand | null { - const trimmed = input.trim(); + let trimmed = input.trim(); if (!trimmed.startsWith("/")) return null; + const [legacyName = ""] = trimmed.split(/\s+/, 1); + const replacement = LEGACY_LOCAL_COMMAND_ALIASES[slashCommandKey(legacyName)]; + if (replacement) trimmed = `${replacement}${trimmed.slice(legacyName.length)}`; const [first = ""] = trimmed.split(/\s+/, 1); const firstKey = slashCommandKey(first); const candidates = [...BUILTIN_COMMANDS] diff --git a/apps/ade-cli/src/tuiClient/components/AttentionPaneView.tsx b/apps/ade-cli/src/tuiClient/components/ActivityPaneView.tsx similarity index 86% rename from apps/ade-cli/src/tuiClient/components/AttentionPaneView.tsx rename to apps/ade-cli/src/tuiClient/components/ActivityPaneView.tsx index 584165bb4..4e7c4e1d1 100644 --- a/apps/ade-cli/src/tuiClient/components/AttentionPaneView.tsx +++ b/apps/ade-cli/src/tuiClient/components/ActivityPaneView.tsx @@ -3,9 +3,9 @@ import { Box, Text } from "ink"; import type { AttentionItem } from "../../../../desktop/src/shared/types/attention"; import { - attentionItemContext, - attentionPaneEntries, -} from "../attentionPane"; + activityItemContext, + activityPaneEntries, +} from "../activityPane"; import { theme } from "../theme"; import type { RightPaneContent } from "../types"; @@ -15,7 +15,7 @@ function endTruncate(value: string, max: number): string { return `${value.slice(0, max - 1)}…`; } -function attentionTone(item: AttentionItem): string { +function activityTone(item: AttentionItem): string { if (item.phase === "needs_you" || item.phase === "review_requested" || item.phase === "merge_ready") { return theme.color.attention; } @@ -34,7 +34,7 @@ function attentionTone(item: AttentionItem): string { return theme.color.t2; } -function attentionGlyph(item: AttentionItem): string { +function activityGlyph(item: AttentionItem): string { if (item.phase === "needs_you" || item.phase === "review_requested" || item.phase === "merge_ready") return "!"; if (item.phase === "failed" || item.phase === "checks_failing" || item.phase === "changes_requested") return "×"; if (item.phase === "blocked" || item.phase === "stale") return "◆"; @@ -43,12 +43,12 @@ function attentionGlyph(item: AttentionItem): string { return "·"; } -export function AttentionPaneView({ +export function ActivityPaneView({ content, selectedIndex, width, }: { - content: Extract; + content: Extract; selectedIndex: number; width: number; }) { @@ -59,7 +59,7 @@ export function AttentionPaneView({ : availability?.state === "signed_out" ? theme.color.attention : theme.color.error; - const window = attentionPaneEntries(model, selectedIndex, 11); + const window = activityPaneEntries(model, selectedIndex, 11); const inner = Math.max(18, width - 4); return ( @@ -83,15 +83,15 @@ export function AttentionPaneView({ ); } const selected = entry.itemIndex === selectedIndex; - const context = attentionItemContext(entry.item); + const context = activityItemContext(entry.item); return ( - {`${selected ? theme.rail : " "} ${attentionGlyph(entry.item)} ${endTruncate(entry.item.title, Math.max(8, inner - 4))}`} + {`${selected ? theme.rail : " "} ${activityGlyph(entry.item)} ${endTruncate(entry.item.title, Math.max(8, inner - 4))}`} {` ${endTruncate(context, Math.max(8, inner - 4))}`} diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index 8e46f8467..ef375082c 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -74,7 +74,7 @@ import { type FeedbackFormState, type FeedbackType, } from "../feedbackForm"; -import { AttentionPaneView } from "./AttentionPaneView"; +import { ActivityPaneView } from "./ActivityPaneView"; // Cap per-file diff body so a pathological 50k-line file can't make the right // pane build a giant row array on every scroll. The window only shows @@ -1740,7 +1740,7 @@ export function rightPaneScrollableRowCount(content: RightPaneContent): number { case "status": // Flat key/value list — scrolls by row count. return content.rows.length; - case "attention": + case "activity": // Selection keeps the focused account item visible; the pane owns its // compact window rather than participating in generic line scrolling. return 0; @@ -2243,8 +2243,8 @@ function paneTitle(content: RightPaneContent): { title: string; hint?: string; b return { title: "HELP" }; case "status": return { title: "STATUS" }; - case "attention": - return { title: "ATTENTION", hint: content.model.snapshot.scope === "machine" ? "THIS MACHINE" : "ACCOUNT" }; + case "activity": + return { title: "ACTIVITY", hint: content.model.snapshot.scope === "machine" ? "THIS MACHINE" : "ACCOUNT" }; case "diff": return { title: content.title.toUpperCase() }; case "list": @@ -2337,8 +2337,8 @@ function RightPaneComponent({ {content.kind === "help" ? : null} - {content.kind === "attention" ? ( - + {content.kind === "activity" ? ( + ) : null} {content.kind === "status" ? ( diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index 3d7aabd4f..9af28a957 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -28,7 +28,7 @@ import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import type { UsageProviderSource, UsageProviderState } from "../../../desktop/src/shared/types/usage"; import type { BufferedEvent } from "../eventBuffer"; import type { HelpGroup } from "./helpIndex"; -import type { AttentionPaneModel } from "./attentionPane"; +import type { ActivityPaneModel } from "./activityPane"; export type RuntimeMode = "attached" | "embedded"; @@ -258,7 +258,7 @@ export interface FeedbackContextMeta { export type RightPaneContent = | { kind: "empty" } | ModelPickerRightPaneContent - | { kind: "attention"; model: AttentionPaneModel } + | { kind: "activity"; model: ActivityPaneModel } | { kind: "help"; title: string; diff --git a/apps/desktop/native/ADEAttentionNotch/DESIGN_NOTES.md b/apps/desktop/native/ADEAttentionNotch/DESIGN_NOTES.md index 09d797ff8..c88874dbf 100644 --- a/apps/desktop/native/ADEAttentionNotch/DESIGN_NOTES.md +++ b/apps/desktop/native/ADEAttentionNotch/DESIGN_NOTES.md @@ -39,7 +39,7 @@ Two failure modes this replaced, both visible in production screenshots: tone. Colours, type scale and phase vocabulary mirror the renderer's Attention -surfaces (`index.css` tokens, `attentionPresentation.ts`, the +surfaces (`index.css` tokens, `activityPresentation.ts`, the `.attention-tone-*` palette) so a phase reads identically in the notch, the header control and the Attention center. `NotchSurfaceShape` and `NotchPanelController.interactivePath` are built from the same corner metrics diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchSurfaceView.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchSurfaceView.swift index 124d1c039..d2d5b8fe1 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchSurfaceView.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchSurfaceView.swift @@ -3,7 +3,7 @@ import SwiftUI import ADEAttentionNotchCore /// ADE design tokens, mirrored from `apps/desktop/src/renderer/index.css` and -/// the Attention center's tone system. Values are duplicated rather than +/// the Activity pane's tone system. Values are duplicated rather than /// derived because the helper is a separate process with no access to the /// renderer stylesheet; keep them in step with the CSS custom properties named /// in each comment. diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchViewModel.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchViewModel.swift index 2ba891097..8d6b8938e 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchViewModel.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/NotchViewModel.swift @@ -7,7 +7,7 @@ import ADEAttentionNotchCore /// /// The helper used to synthesise its own alerts by diffing item fingerprints, /// which fired on every cosmetic republish. The renderer now owns that decision -/// (`useAttentionSync`'s toast emitter) because only it can see the account's +/// (`useActivitySync`'s toast emitter) because only it can see the account's /// delivery policy, the per-item 10-minute cooldown, and the global rate limit. /// The machinery below is unchanged; the trigger moved. @MainActor diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/ProtocolTransport.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/ProtocolTransport.swift index 6cacef2db..77428b02f 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/ProtocolTransport.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotch/ProtocolTransport.swift @@ -39,7 +39,7 @@ final class StandardIOTransport { data.append(0x0A) FileHandle.standardOutput.write(data) } catch { - let message = "ADE Attention Notch could not encode output: \(error)\n" + let message = "ADE Notch could not encode output: \(error)\n" FileHandle.standardError.write(Data(message.utf8)) } } diff --git a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/AttentionModels.swift b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/AttentionModels.swift index 94b459845..9460439b8 100644 --- a/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/AttentionModels.swift +++ b/apps/desktop/native/ADEAttentionNotch/Sources/ADEAttentionNotchCore/AttentionModels.swift @@ -380,7 +380,7 @@ public struct AttentionItemPresentation: Equatable, Sendable { } /// Mirrors the renderer's `AttentionTone` union so a phase reads as the same -/// colour in the notch as it does in the Attention center and header control. +/// colour in the notch as it does in the Activity pane and header control. public enum NotchStatusTone: String, Equatable, Sendable { case blue case amber @@ -392,7 +392,7 @@ public enum NotchStatusTone: String, Equatable, Sendable { } /// Mirrors `PHASE_PRESENTATION` in -/// `apps/desktop/src/renderer/components/attention/attentionPresentation.ts`. +/// `apps/desktop/src/renderer/components/activity/activityPresentation.ts`. public func notchStatusTone(for phase: String?) -> NotchStatusTone { switch phase { case "starting", "running", "open": @@ -486,7 +486,7 @@ private func problemPresentation( let symbolName: String switch availability.state { case .degraded: - fallbackTitle = "Attention is out of sync" + fallbackTitle = "Activity is out of sync" compactLabel = "Reconnecting" tone = .amber symbolName = "antenna.radiowaves.left.and.right.slash" @@ -496,7 +496,7 @@ private func problemPresentation( tone = .amber symbolName = "person.crop.circle.badge.exclamationmark" case .unavailable: - fallbackTitle = "Attention is unavailable" + fallbackTitle = "Activity is unavailable" compactLabel = "Unavailable" tone = .red symbolName = "exclamationmark.triangle.fill" @@ -506,7 +506,7 @@ private func problemPresentation( tone = .red symbolName = "arrow.up.circle" case .unknown, .ready: - fallbackTitle = "Attention status unknown" + fallbackTitle = "Activity status unknown" compactLabel = "Degraded" tone = .amber symbolName = "questionmark.circle" @@ -514,7 +514,7 @@ private func problemPresentation( let fallbackMessage = itemCount > 0 ? "Showing the last state ADE received." - : "ADE can't reach your account attention stream." + : "ADE can't reach your account Activity stream." return NotchStatusPresentation( title: availability.title.notchNonEmpty ?? fallbackTitle, @@ -538,7 +538,7 @@ private func recoveryHint( case .retry: return "Retry from ADE to reconnect." case .signIn: - return "Sign in to ADE to restore account attention." + return "Sign in to ADE to restore account Activity." case .updateHost: return host.map { "Update ADE on \($0)." } ?? "Update ADE to continue." case .restartHost: diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 23583235e..ba35bf06c 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -6812,7 +6812,7 @@ app.whenReady().then(async () => { if (requiresRemoteMachine && !remoteWindow) { const win = await attentionWindow(); if (!win || win.isDestroyed() || !attentionIpcBridge) { - throw new Error("ADE could not open the remote Attention destination."); + throw new Error("ADE could not open the remote Activity destination."); } const binding = await attentionIpcBridge.openAttentionProject({ machineKey: accountMachineKey, diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index a9073c3e0..f50f762bd 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -956,12 +956,12 @@ const ADE_ACTION_INPUT_CONTRACTS: Partial { if (!args || typeof args.deviceId !== "string") { - throw new Error("A valid Attention presence payload is required."); + throw new Error("A valid Activity presence payload is required."); } return publisher.reportAttentionPresence(args); }, @@ -1881,7 +1881,7 @@ function buildAttentionDomainService(runtime: AdeRuntime): OpaqueService | null preferences?: AttentionPreferences; }) => { if (!args?.preferences || typeof args.preferences !== "object") { - throw new Error("A valid Attention preferences payload is required."); + throw new Error("A valid Activity preferences payload is required."); } return publisher.putAttentionPreferences( requireCurrentAccountOwner(args.accountOwnerId), diff --git a/apps/desktop/src/main/services/attention/attentionAccountCoordinator.ts b/apps/desktop/src/main/services/attention/attentionAccountCoordinator.ts index 6aff0ebca..9c802d296 100644 --- a/apps/desktop/src/main/services/attention/attentionAccountCoordinator.ts +++ b/apps/desktop/src/main/services/attention/attentionAccountCoordinator.ts @@ -161,7 +161,7 @@ export class AttentionAccountCoordinator { : { state: "signed_out", title: `Showing ${machineName}`, - message: "Sign in to combine Attention across every ADE machine.", + message: "Sign in to combine Activity across every ADE machine.", recovery: "sign_in", hostName: machineName, }, @@ -174,7 +174,7 @@ export class AttentionAccountCoordinator { throw new Error( accountFailure ? ( - "Account Attention could not connect, and this Mac cannot provide a fallback. " + "Account Activity could not connect, and this Mac cannot provide a fallback. " + compatibilityMessage ) : compatibilityMessage, @@ -192,7 +192,7 @@ export class AttentionAccountCoordinator { ); } throw new Error( - "Attention cannot reach this Mac's ADE brain. Restart ADE on this Mac, then try again.", + "Activity cannot reach this Mac's ADE brain. Restart ADE on this Mac, then try again.", ); } @@ -206,7 +206,7 @@ export class AttentionAccountCoordinator { .slice(0, 64) : []; if (itemIds.length === 0) { - throw new Error("At least one Attention item id is required."); + throw new Error("At least one Activity item id is required."); } const acknowledgment = { itemIds, @@ -218,7 +218,7 @@ export class AttentionAccountCoordinator { const currentAccountOwnerId = this.currentAccountOwnerId(); if (this.lastSnapshotAccountOwnerId !== currentAccountOwnerId) { throw new Error( - "The ADE account changed after Attention loaded. Refresh Attention, then try again.", + "The ADE account changed after Activity loaded. Refresh Activity, then try again.", ); } if (this.lastSnapshotScope === "machine") { @@ -245,11 +245,11 @@ export class AttentionAccountCoordinator { || requestedAccountOwnerId !== this.lastSnapshotAccountOwnerId ) { throw new Error( - "The machine Attention account scope changed after this item loaded. Refresh and try again.", + "The machine Activity account scope changed after this item loaded. Refresh and try again.", ); } if (!this.options.localRuntimeConnectionPool) { - throw new Error("Machine Attention is unavailable until this Mac's ADE brain is ready."); + throw new Error("Machine Activity is unavailable until this Mac's ADE brain is ready."); } await this.options.localRuntimeConnectionPool.callAttention( "acknowledge", @@ -263,7 +263,7 @@ export class AttentionAccountCoordinator { return; } if (this.lastSnapshotScope !== "account") { - throw new Error("Refresh Attention before acknowledging this item."); + throw new Error("Refresh Activity before acknowledging this item."); } if (currentAccountOwnerId && this.options.accountAttentionClient) { const requestedAccountOwnerId = @@ -302,13 +302,13 @@ export class AttentionAccountCoordinator { } return; } - throw new Error("Sign in again, refresh Attention, then try to acknowledge this item."); + throw new Error("Sign in again, refresh Activity, then try to acknowledge this item."); } async reportPresence(input: unknown): Promise { const presence = isRecord(input) ? input : null; if (!presence || typeof presence.deviceId !== "string" || !presence.deviceId.trim()) { - throw new Error("A valid Attention presence payload is required."); + throw new Error("A valid Activity presence payload is required."); } if (this.currentAccountOwnerId() && this.options.accountAttentionClient) { await this.options.accountAttentionClient.reportAttentionPresence( @@ -341,7 +341,7 @@ export class AttentionAccountCoordinator { async putPreferences(input: unknown): Promise { const request = isRecord(input) ? input as AttentionPreferenceUpdateRequest : null; if (!request || !isRecord(request.preferences)) { - throw new Error("A valid Attention preferences payload is required."); + throw new Error("A valid Activity preferences payload is required."); } const accountOwnerId = this.requireCurrentAccountOwner(request.accountOwnerId); if (this.options.accountAttentionClient) { @@ -352,7 +352,7 @@ export class AttentionAccountCoordinator { return; } if (!this.options.localRuntimeConnectionPool) { - throw new Error("Account Attention is unavailable until this Mac's ADE brain is ready."); + throw new Error("Account Activity is unavailable until this Mac's ADE brain is ready."); } await this.options.localRuntimeConnectionPool.callAttention( "putPreferences", @@ -410,10 +410,10 @@ export class AttentionAccountCoordinator { private requireCurrentAccountOwner(value: unknown): string { const accountOwnerId = typeof value === "string" ? value.trim() : ""; if (!accountOwnerId) { - throw new Error("A valid Attention account owner is required."); + throw new Error("A valid Activity account owner is required."); } if (this.currentAccountOwnerId() !== accountOwnerId) { - throw new Error("The ADE account changed before Attention preferences could be used."); + throw new Error("The ADE account changed before Activity preferences could be used."); } return accountOwnerId; } @@ -434,7 +434,7 @@ export class AttentionAccountCoordinator { }); } return ( - "Account Attention requires a newer connected ADE brain. " + "Account Activity requires a newer connected ADE brain. " + "Update and restart ADE on the host machine so the notch can receive account-wide work." ); } @@ -447,20 +447,20 @@ export class AttentionAccountCoordinator { title: "Account session needs attention", message: "ADE could not verify your account after refreshing the session. " - + "Sign out and back in to restore account-wide Attention.", + + "Sign out and back in to restore account-wide Activity.", recovery: "sign_in", }; } if (error instanceof PushRelayRequestError && error.status === 503) { return { - title: "Account Attention is temporarily unavailable", + title: "Account Activity is temporarily unavailable", message: "ADE's account service is not ready. Machine-scoped work remains available while it recovers.", recovery: "retry", }; } return { - title: "Account Attention is reconnecting", + title: "Account Activity is reconnecting", message: "ADE cannot reach the account stream right now. Machine-scoped work remains available.", recovery: "retry", diff --git a/apps/desktop/src/main/services/deeplinks/ownerAwareNavigation.ts b/apps/desktop/src/main/services/deeplinks/ownerAwareNavigation.ts index 07afd3878..ace4c6558 100644 --- a/apps/desktop/src/main/services/deeplinks/ownerAwareNavigation.ts +++ b/apps/desktop/src/main/services/deeplinks/ownerAwareNavigation.ts @@ -92,19 +92,19 @@ export function ownerNavigationFailureCopy( return { title: "Update the owning ADE machine", message: "This item belongs to a machine running an incompatible ADE service.", - detail: `${detail}\n\nUpdate and restart ADE on that host, then retry from Attention.`, + detail: `${detail}\n\nUpdate and restart ADE on that host, then retry from Activity.`, }; } if (/project .* no longer available on this ADE machine/i.test(detail)) { return { title: "Project no longer available", message: "ADE found the owning machine, but that project is no longer registered there.", - detail: `${detail}\n\nOpen or restore the project on that machine, then retry from Attention.`, + detail: `${detail}\n\nOpen or restore the project on that machine, then retry from Activity.`, }; } return { title: "Owning machine unavailable", message: "ADE couldn’t open this item on the machine and project that own it.", - detail: `${detail}\n\nReconnect that machine from Connections, then retry from Attention.`, + detail: `${detail}\n\nReconnect that machine from Connections, then retry from Activity.`, }; } diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index b33c512b2..9af99cbb5 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -3210,19 +3210,19 @@ export function registerIpc({ ipcMain.handle(IPC.attentionNotchPublishSnapshot, async (_event, input: unknown) => { const snapshot = parseAttentionNotchSnapshot(input); - if (!snapshot) throw new Error("Invalid Attention Notch snapshot."); + if (!snapshot) throw new Error("Invalid ADE Notch snapshot."); publishAttentionNotchSnapshot?.(snapshot); }); ipcMain.handle(IPC.attentionNotchPublishToast, async (_event, input: unknown) => { const toast = parseAttentionNotchToast(input); - if (!toast) throw new Error("Invalid Attention Notch toast."); + if (!toast) throw new Error("Invalid ADE Notch toast."); publishAttentionNotchToast?.(toast); }); ipcMain.handle(IPC.attentionNotchUpdateSettings, async (_event, input: unknown) => { const settings = parseAttentionNotchSettings(input); - if (!settings) throw new Error("Invalid Attention Notch settings."); + if (!settings) throw new Error("Invalid ADE Notch settings."); updateAttentionNotchSettings?.(settings); }); @@ -3303,7 +3303,7 @@ export function registerIpc({ tombstones: [], }); const item = snapshot?.items[0] ?? null; - if (!item) throw new Error("Invalid Attention item."); + if (!item) throw new Error("Invalid Activity item."); await openAttentionItem?.(item); }); @@ -10604,7 +10604,7 @@ export function registerIpc({ windowId: number | null; }) { const machineKey = args.machineKey.trim(); - if (!machineKey) throw new Error("Attention machine identity is required."); + if (!machineKey) throw new Error("Activity machine identity is required."); let targetId = runtimeBridge.resolveTargetIdForMachineKey(machineKey); if (!targetId) { targetId = (await accountBridge.pairMachine(machineKey)).targetId; diff --git a/apps/desktop/src/renderer/components/attention/Activity.css b/apps/desktop/src/renderer/components/activity/Activity.css similarity index 95% rename from apps/desktop/src/renderer/components/attention/Activity.css rename to apps/desktop/src/renderer/components/activity/Activity.css index 950c826e2..408e33cfb 100644 --- a/apps/desktop/src/renderer/components/attention/Activity.css +++ b/apps/desktop/src/renderer/components/activity/Activity.css @@ -842,11 +842,11 @@ button.activity-pane-freshness { Moved verbatim (bar the tokens it used to inherit from the center's page scope) so the gear behaves the same wherever it is mounted. */ -.attention-settings-wrap { +.activity-settings-wrap { position: relative; } -.attention-settings-trigger { +.activity-settings-trigger { display: inline-flex; width: 24px; height: 24px; @@ -859,18 +859,18 @@ button.activity-pane-freshness { transition: color 140ms ease, border-color 140ms ease, background 140ms ease, transform 140ms ease; } -.attention-settings-trigger:hover, -.attention-settings-trigger[aria-expanded="true"] { +.activity-settings-trigger:hover, +.activity-settings-trigger[aria-expanded="true"] { color: var(--color-fg); border-color: color-mix(in srgb, var(--color-accent) 24%, var(--color-border)); background: color-mix(in srgb, var(--color-accent) 8%, var(--color-card)); } -.attention-settings-trigger:active { +.activity-settings-trigger:active { transform: scale(0.94); } -.attention-settings-popover { +.activity-settings-popover { position: absolute; top: calc(100% + 9px); right: 0; @@ -887,11 +887,11 @@ button.activity-pane-freshness { color: var(--color-fg); } -.attention-settings-popover:focus { +.activity-settings-popover:focus { outline: none; } -.attention-settings-popover > header { +.activity-settings-popover > header { position: sticky; top: 0; z-index: 1; @@ -905,32 +905,32 @@ button.activity-pane-freshness { background: color-mix(in srgb, var(--color-card) 96%, var(--color-bg)); } -.attention-settings-popover > header > div { +.activity-settings-popover > header > div { display: flex; min-width: 0; align-items: center; gap: 9px; } -.attention-settings-popover > header > div > span:last-child { +.activity-settings-popover > header > div > span:last-child { display: flex; min-width: 0; flex-direction: column; } -.attention-settings-popover > header strong { +.activity-settings-popover > header strong { font-size: 12px; font-weight: 660; letter-spacing: -0.01em; } -.attention-settings-popover > header small { +.activity-settings-popover > header small { margin-top: 2px; color: color-mix(in srgb, var(--color-muted-fg) 88%, transparent); font-size: 11px; } -.attention-settings-heading-icon { +.activity-settings-heading-icon { display: inline-flex; width: 31px; height: 31px; @@ -943,7 +943,7 @@ button.activity-pane-freshness { color: var(--color-accent-bright, var(--color-accent)); } -.attention-settings-account-badge { +.activity-settings-account-badge { flex: 0 0 auto; padding: 3px 7px; border: 1px solid color-mix(in srgb, var(--color-accent) 22%, transparent); @@ -955,16 +955,16 @@ button.activity-pane-freshness { letter-spacing: 0.02em; } -.attention-settings-popover section { +.activity-settings-popover section { padding: 10px 10px 6px; } -.attention-settings-popover section + section { +.activity-settings-popover section + section { padding-top: 9px; border-top: 1px solid color-mix(in srgb, var(--color-border) 68%, transparent); } -.attention-settings-popover section h3 { +.activity-settings-popover section h3 { margin: 0 0 5px 3px; color: color-mix(in srgb, var(--color-muted-fg) 88%, transparent); font-size: 10px; @@ -973,7 +973,7 @@ button.activity-pane-freshness { text-transform: uppercase; } -.attention-settings-row { +.activity-settings-row { display: grid; min-height: 44px; grid-template-columns: 30px minmax(0, 1fr) auto; @@ -984,15 +984,15 @@ button.activity-pane-freshness { transition: background 130ms ease; } -.attention-settings-row:hover { +.activity-settings-row:hover { background: color-mix(in srgb, var(--color-fg) 4%, transparent); } -.attention-settings-row[data-disabled] { +.activity-settings-row[data-disabled] { opacity: 0.5; } -.attention-settings-row-icon { +.activity-settings-row-icon { display: inline-flex; width: 29px; height: 29px; @@ -1004,25 +1004,25 @@ button.activity-pane-freshness { color: color-mix(in srgb, var(--color-accent) 43%, var(--color-muted-fg)); } -.attention-settings-row-copy { +.activity-settings-row-copy { display: flex; min-width: 0; flex-direction: column; } -.attention-settings-row-copy > span { +.activity-settings-row-copy > span { display: flex; min-width: 0; align-items: center; gap: 6px; } -.attention-settings-row-copy strong { +.activity-settings-row-copy strong { font-size: 12px; font-weight: 630; } -.attention-settings-row-copy small { +.activity-settings-row-copy small { flex: 0 0 auto; padding: 2px 5px; border-radius: 4px; @@ -1032,7 +1032,7 @@ button.activity-pane-freshness { font-weight: 650; } -.attention-settings-row-copy em { +.activity-settings-row-copy em { margin-top: 3px; color: color-mix(in srgb, var(--color-muted-fg) 88%, transparent); font-size: 11px; @@ -1040,7 +1040,7 @@ button.activity-pane-freshness { line-height: 1.35; } -.attention-settings-row select { +.activity-settings-row select { width: 148px; height: 27px; padding: 0 7px; @@ -1052,7 +1052,7 @@ button.activity-pane-freshness { font-size: 11px; } -.attention-settings-switch { +.activity-settings-switch { position: relative; width: 32px; height: 19px; @@ -1064,7 +1064,7 @@ button.activity-pane-freshness { transition: border-color 150ms ease, background 150ms ease; } -.attention-settings-switch > span { +.activity-settings-switch > span { position: absolute; top: 2px; left: 2px; @@ -1076,23 +1076,23 @@ button.activity-pane-freshness { transition: transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1), background 150ms ease; } -.attention-settings-switch[aria-checked="true"] { +.activity-settings-switch[aria-checked="true"] { border-color: color-mix(in srgb, var(--color-accent) 50%, transparent); background: color-mix(in srgb, var(--color-accent) 62%, var(--color-accent-deep, var(--color-accent))); } -.attention-settings-switch[aria-checked="true"] > span { +.activity-settings-switch[aria-checked="true"] > span { background: #fff; transform: translateX(13px); } -.attention-settings-machines { +.activity-settings-machines { display: flex; flex-direction: column; gap: 1px; } -.attention-settings-loading { +.activity-settings-loading { display: flex; min-height: 180px; align-items: center; @@ -1102,7 +1102,7 @@ button.activity-pane-freshness { font-size: 13px; } -.attention-settings-loading > span { +.activity-settings-loading > span { width: 14px; height: 14px; border: 2px solid color-mix(in srgb, var(--color-accent) 18%, transparent); @@ -1111,7 +1111,7 @@ button.activity-pane-freshness { animation: activity-spin 700ms linear infinite; } -.attention-settings-error { +.activity-settings-error { display: flex; align-items: flex-start; gap: 7px; @@ -1125,12 +1125,12 @@ button.activity-pane-freshness { line-height: 1.4; } -.attention-settings-error svg { +.activity-settings-error svg { flex: 0 0 auto; margin-top: 1px; } -.attention-settings-popover > footer { +.activity-settings-popover > footer { position: sticky; bottom: 0; display: flex; @@ -1142,7 +1142,7 @@ button.activity-pane-freshness { background: color-mix(in srgb, var(--color-card) 96%, var(--color-bg)); } -.attention-settings-popover > footer > span { +.activity-settings-popover > footer > span { display: inline-flex; min-width: 0; flex: 1; @@ -1152,7 +1152,7 @@ button.activity-pane-freshness { font-size: 11px; } -.attention-settings-open-full { +.activity-settings-open-full { display: flex; align-items: center; justify-content: space-between; @@ -1169,8 +1169,8 @@ button.activity-pane-freshness { transition: background 120ms ease, color 120ms ease; } -.attention-settings-open-full:hover, -.attention-settings-open-full:focus-visible { +.activity-settings-open-full:hover, +.activity-settings-open-full:focus-visible { color: var(--color-fg); background: color-mix(in srgb, var(--color-fg) 8%, transparent); outline: none; diff --git a/apps/desktop/src/renderer/components/attention/ActivityCard.test.tsx b/apps/desktop/src/renderer/components/activity/ActivityCard.test.tsx similarity index 100% rename from apps/desktop/src/renderer/components/attention/ActivityCard.test.tsx rename to apps/desktop/src/renderer/components/activity/ActivityCard.test.tsx diff --git a/apps/desktop/src/renderer/components/attention/ActivityCard.tsx b/apps/desktop/src/renderer/components/activity/ActivityCard.tsx similarity index 99% rename from apps/desktop/src/renderer/components/attention/ActivityCard.tsx rename to apps/desktop/src/renderer/components/activity/ActivityCard.tsx index f7b5a95ee..1018b70ac 100644 --- a/apps/desktop/src/renderer/components/attention/ActivityCard.tsx +++ b/apps/desktop/src/renderer/components/activity/ActivityCard.tsx @@ -7,7 +7,7 @@ import { ProviderLogo } from "../shared/ProviderLogos"; import { SessionStatusLabel } from "../terminals/SessionStatusLabel"; import { LaneIcon } from "../ui/vcsIcons"; import { cn } from "../ui/cn"; -import { activityItemPresentation } from "./attentionPresentation"; +import { activityItemPresentation } from "./activityPresentation"; // The row carries its own chrome and the shared tone table, so every surface // that can render an `ActivityCard` gets both without importing a stylesheet // it does not otherwise use. diff --git a/apps/desktop/src/renderer/components/attention/ActivityCardSkeleton.tsx b/apps/desktop/src/renderer/components/activity/ActivityCardSkeleton.tsx similarity index 100% rename from apps/desktop/src/renderer/components/attention/ActivityCardSkeleton.tsx rename to apps/desktop/src/renderer/components/activity/ActivityCardSkeleton.tsx diff --git a/apps/desktop/src/renderer/components/attention/ActivityDetailSheet.tsx b/apps/desktop/src/renderer/components/activity/ActivityDetailSheet.tsx similarity index 98% rename from apps/desktop/src/renderer/components/attention/ActivityDetailSheet.tsx rename to apps/desktop/src/renderer/components/activity/ActivityDetailSheet.tsx index 1858b40d3..dde34c7aa 100644 --- a/apps/desktop/src/renderer/components/attention/ActivityDetailSheet.tsx +++ b/apps/desktop/src/renderer/components/activity/ActivityDetailSheet.tsx @@ -18,7 +18,7 @@ import { ProviderLogo } from "../shared/ProviderLogos"; import { SessionStatusLabel } from "../terminals/SessionStatusLabel"; import { cn } from "../ui/cn"; import { activityCardPreview } from "./ActivityCard"; -import { activityItemPresentation, attentionActionTone } from "./attentionPresentation"; +import { activityItemPresentation, activityActionTone } from "./activityPresentation"; function actionIcon(action: AttentionAction): React.ElementType { if (action.kind === "approve") return Check; @@ -193,7 +193,7 @@ export function ActivityDetailSheet({ key={action.id} type="button" className="activity-action" - data-tone={attentionActionTone(action.kind)} + data-tone={activityActionTone(action.kind)} disabled={blocked || pendingActionId === action.id} title={blocked ? `${item.machine.name} is offline` : action.label} onClick={() => onAction(item, action)} diff --git a/apps/desktop/src/renderer/components/attention/ActivityFilters.test.tsx b/apps/desktop/src/renderer/components/activity/ActivityFilters.test.tsx similarity index 100% rename from apps/desktop/src/renderer/components/attention/ActivityFilters.test.tsx rename to apps/desktop/src/renderer/components/activity/ActivityFilters.test.tsx diff --git a/apps/desktop/src/renderer/components/attention/ActivityFilters.tsx b/apps/desktop/src/renderer/components/activity/ActivityFilters.tsx similarity index 100% rename from apps/desktop/src/renderer/components/attention/ActivityFilters.tsx rename to apps/desktop/src/renderer/components/activity/ActivityFilters.tsx diff --git a/apps/desktop/src/renderer/components/attention/ActivityInboxColumn.tsx b/apps/desktop/src/renderer/components/activity/ActivityInboxColumn.tsx similarity index 98% rename from apps/desktop/src/renderer/components/attention/ActivityInboxColumn.tsx rename to apps/desktop/src/renderer/components/activity/ActivityInboxColumn.tsx index c50f4d177..0f3990adf 100644 --- a/apps/desktop/src/renderer/components/attention/ActivityInboxColumn.tsx +++ b/apps/desktop/src/renderer/components/activity/ActivityInboxColumn.tsx @@ -20,7 +20,7 @@ import { } from "../../../shared/types"; import { relativeWhen } from "../../lib/format"; import { cn } from "../ui/cn"; -import { activityItemPresentation } from "./attentionPresentation"; +import { activityItemPresentation } from "./activityPresentation"; const INITIAL_ROW_BUDGET = 60; const ROW_BUDGET_STEP = 60; diff --git a/apps/desktop/src/renderer/components/attention/ActivityPane.test.tsx b/apps/desktop/src/renderer/components/activity/ActivityPane.test.tsx similarity index 89% rename from apps/desktop/src/renderer/components/attention/ActivityPane.test.tsx rename to apps/desktop/src/renderer/components/activity/ActivityPane.test.tsx index b79cf436b..463bc0d24 100644 --- a/apps/desktop/src/renderer/components/attention/ActivityPane.test.tsx +++ b/apps/desktop/src/renderer/components/activity/ActivityPane.test.tsx @@ -10,9 +10,9 @@ import { type AttentionItem, } from "../../../shared/types"; import { - attentionStore, - resetAttentionStoreForTests, -} from "../../state/attentionStore"; + activityStore, + resetActivityStoreForTests, +} from "../../state/activityStore"; import { publishAccountStatus, SIGNED_OUT_ACCOUNT } from "../../lib/account"; import { ActivityPane } from "./ActivityPane"; @@ -95,7 +95,7 @@ beforeEach(() => { afterEach(() => { cleanup(); - resetAttentionStoreForTests(); + resetActivityStoreForTests(); publishAccountStatus(SIGNED_OUT_ACCOUNT); Object.defineProperty(window, "ade", { configurable: true, @@ -123,7 +123,7 @@ describe("ActivityPane", () => { eventKind: "agent_running", title: "Task running", }); - attentionStore.setState({ itemsById: { approval: needsYou, running } }); + activityStore.setState({ itemsById: { approval: needsYou, running } }); render( {}} />); @@ -137,7 +137,7 @@ describe("ActivityPane", () => { }); it("never offers the placeholder copy the old center apologised with", () => { - attentionStore.setState({ itemsById: { approval: item("approval") } }); + activityStore.setState({ itemsById: { approval: item("approval") } }); render( {}} />); expect(screen.queryByText(/Ready when you are/i)).toBeNull(); @@ -154,7 +154,7 @@ describe("ActivityPane", () => { }); it("holds placeholders rather than claiming all-clear before the first snapshot", () => { - attentionStore.setState({ syncStatus: "syncing" }); + activityStore.setState({ syncStatus: "syncing" }); render( {}} />); // "All agents idle" is a claim, and before a snapshot lands it is one ADE @@ -166,7 +166,7 @@ describe("ActivityPane", () => { }); it("slides the detail over the columns with the item's real content", () => { - attentionStore.setState({ itemsById: { approval: item("approval") } }); + activityStore.setState({ itemsById: { approval: item("approval") } }); render( {}} />); openDetail("Task approval"); @@ -189,7 +189,7 @@ describe("ActivityPane", () => { it("closes the detail before the pane, one layer per Escape", () => { const onClose = vi.fn(); - attentionStore.setState({ itemsById: { approval: item("approval") } }); + activityStore.setState({ itemsById: { approval: item("approval") } }); render(); openDetail("Task approval"); @@ -215,7 +215,7 @@ describe("ActivityPane", () => { it("keeps clicks inside the pane from closing it", () => { const onClose = vi.fn(); - attentionStore.setState({ itemsById: { approval: item("approval") } }); + activityStore.setState({ itemsById: { approval: item("approval") } }); render(); fireEvent.mouseDown(screen.getByTestId("activity-pane")); @@ -227,7 +227,7 @@ describe("ActivityPane", () => { throw new Error("Studio Mac stopped responding."); }); installAde({ openItem }); - attentionStore.setState({ itemsById: { approval: item("approval") } }); + activityStore.setState({ itemsById: { approval: item("approval") } }); render( {}} />); openDetail("Task approval"); @@ -236,14 +236,14 @@ describe("ActivityPane", () => { await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain("Studio Mac stopped responding."); }); - expect(attentionStore.getState().itemsById.approval?.seenAt).toBeNull(); + expect(activityStore.getState().itemsById.approval?.seenAt).toBeNull(); }); it("marks an item seen only once its destination resolved", async () => { const onClose = vi.fn(); const openItem = vi.fn(async () => {}); installAde({ openItem }); - attentionStore.setState({ itemsById: { approval: item("approval") } }); + activityStore.setState({ itemsById: { approval: item("approval") } }); render(); openDetail("Task approval"); @@ -251,13 +251,13 @@ describe("ActivityPane", () => { await waitFor(() => { expect(openItem).toHaveBeenCalledWith(expect.objectContaining({ id: "approval" })); - expect(attentionStore.getState().itemsById.approval?.seenAt).not.toBeNull(); + expect(activityStore.getState().itemsById.approval?.seenAt).not.toBeNull(); }); expect(onClose).toHaveBeenCalled(); }); it("disables remote actions for last-known state from an offline machine", () => { - attentionStore.setState({ + activityStore.setState({ itemsById: { offline: item("offline", { machine: { @@ -284,7 +284,7 @@ describe("ActivityPane", () => { }); it("files an offline machine's sessions under a last-seen divider", () => { - attentionStore.setState({ + activityStore.setState({ itemsById: { here: item("here"), gone: item("gone", { @@ -313,7 +313,7 @@ describe("ActivityPane", () => { it("dismisses one inbox row without touching the rest", async () => { const acknowledge = vi.fn(async () => {}); installAde({ acknowledge }); - attentionStore.setState({ + activityStore.setState({ itemsById: { first: item("first"), second: item("second") }, }); render( {}} />); @@ -321,15 +321,15 @@ describe("ActivityPane", () => { fireEvent.click(screen.getByRole("button", { name: "Dismiss Task first" })); await waitFor(() => { - expect(attentionStore.getState().itemsById.first?.dismissedAt).not.toBeNull(); + expect(activityStore.getState().itemsById.first?.dismissedAt).not.toBeNull(); }); - expect(attentionStore.getState().itemsById.second?.dismissedAt).toBeNull(); + expect(activityStore.getState().itemsById.second?.dismissedAt).toBeNull(); expect(acknowledge).toHaveBeenCalledTimes(1); }); it("clears the whole inbox from its header", async () => { installAde(); - attentionStore.setState({ + activityStore.setState({ itemsById: { first: item("first"), second: item("second") }, }); render( {}} />); @@ -337,13 +337,13 @@ describe("ActivityPane", () => { fireEvent.click(screen.getByRole("button", { name: "Clear all" })); await waitFor(() => { - expect(attentionStore.getState().itemsById.first?.dismissedAt).not.toBeNull(); - expect(attentionStore.getState().itemsById.second?.dismissedAt).not.toBeNull(); + expect(activityStore.getState().itemsById.first?.dismissedAt).not.toBeNull(); + expect(activityStore.getState().itemsById.second?.dismissedAt).not.toBeNull(); }); }); it("filters both columns by machine and says so when nothing matches", async () => { - attentionStore.setState({ + activityStore.setState({ itemsById: { studio: item("studio"), laptop: item("laptop", { @@ -369,7 +369,7 @@ describe("ActivityPane", () => { }); it("explains an empty column as a filter result, not as all-clear", async () => { - attentionStore.setState({ + activityStore.setState({ itemsById: { studio: item("studio", { model: "GPT-5" }), }, @@ -388,7 +388,7 @@ describe("ActivityPane", () => { }); it("counts machines and sessions in the header", () => { - attentionStore.setState({ + activityStore.setState({ itemsById: { studio: item("studio"), cloud: item("cloud", { @@ -407,19 +407,19 @@ describe("ActivityPane", () => { }); it("renders nothing at all when closed", () => { - attentionStore.setState({ itemsById: { approval: item("approval") } }); + activityStore.setState({ itemsById: { approval: item("approval") } }); render( {}} />); expect(screen.queryByTestId("activity-pane")).toBeNull(); }); it("drops the detail when its item leaves the snapshot", async () => { - attentionStore.setState({ itemsById: { approval: item("approval") } }); + activityStore.setState({ itemsById: { approval: item("approval") } }); render( {}} />); openDetail("Task approval"); act(() => { - attentionStore.setState({ itemsById: {} }); + activityStore.setState({ itemsById: {} }); }); await waitFor(() => { diff --git a/apps/desktop/src/renderer/components/attention/ActivityPane.tsx b/apps/desktop/src/renderer/components/activity/ActivityPane.tsx similarity index 89% rename from apps/desktop/src/renderer/components/attention/ActivityPane.tsx rename to apps/desktop/src/renderer/components/activity/ActivityPane.tsx index 71d7451a8..89b47eaa4 100644 --- a/apps/desktop/src/renderer/components/attention/ActivityPane.tsx +++ b/apps/desktop/src/renderer/components/activity/ActivityPane.tsx @@ -20,11 +20,11 @@ import { ADE_BROWSER_VIEW_OCCLUSION_START_EVENT, } from "../../lib/workSidebarBrowserResize"; import { - acknowledgeAttentionItem, - attentionStore, + acknowledgeActivityItem, + activityStore, selectActivityHideDetails, - useAttentionStore, -} from "../../state/attentionStore"; + useActivityStore, +} from "../../state/activityStore"; import { ActivityDetailSheet } from "./ActivityDetailSheet"; import { ActivityFilters, @@ -37,7 +37,7 @@ import { ActivityInboxColumn } from "./ActivityInboxColumn"; import { ActivitySessionsColumn } from "./ActivitySessionsColumn"; import { ActivitySettingsPopover } from "./ActivitySettingsPopover"; import { summarizeActivity } from "./activityPriority"; -import { refreshAttentionSnapshot } from "./useAttentionSync"; +import { refreshActivitySnapshot } from "./useActivitySync"; import "./Activity.css"; function navigationErrorMessage(error: unknown): string { @@ -67,13 +67,13 @@ export function ActivityPane({ open: boolean; onClose: () => void; }) { - const itemsById = useAttentionStore((state) => state.itemsById); - const syncStatus = useAttentionStore((state) => state.syncStatus); - const syncError = useAttentionStore((state) => state.syncError); - const generatedAt = useAttentionStore((state) => state.generatedAt); - const availability = useAttentionStore((state) => state.availability); - const acknowledgementErrors = useAttentionStore((state) => state.acknowledgementErrors); - const hideDetails = useAttentionStore(selectActivityHideDetails); + const itemsById = useActivityStore((state) => state.itemsById); + const syncStatus = useActivityStore((state) => state.syncStatus); + const syncError = useActivityStore((state) => state.syncError); + const generatedAt = useActivityStore((state) => state.generatedAt); + const availability = useActivityStore((state) => state.availability); + const acknowledgementErrors = useActivityStore((state) => state.acknowledgementErrors); + const hideDetails = useActivityStore(selectActivityHideDetails); const paneRef = useRef(null); const [now, setNow] = useState(() => Date.now()); @@ -94,7 +94,7 @@ export function ActivityPane({ if (!open) return; setNavigationError(null); setNow(Date.now()); - void refreshAttentionSnapshot(); + void refreshActivitySnapshot(); const timer = window.setInterval(() => setNow(Date.now()), 30_000); return () => window.clearInterval(timer); }, [open]); @@ -103,8 +103,8 @@ export function ActivityPane({ // say the user is looking at Activity. useEffect(() => { if (!open) return; - attentionStore.getState().setHeaderSurfaceVisible(true); - return () => attentionStore.getState().setHeaderSurfaceVisible(false); + activityStore.getState().setHeaderSurfaceVisible(true); + return () => activityStore.getState().setHeaderSurfaceVisible(false); }, [open]); // An embedded BrowserView paints above the DOM, so tell it to step aside. @@ -124,7 +124,7 @@ export function ActivityPane({ if (event.key !== "Escape") return; // The settings popover is a dialog inside this one and owns its own // Escape; closing both at once would be a single key undoing two steps. - if (document.querySelector(".attention-settings-popover")) return; + if (document.querySelector(".activity-settings-popover")) return; event.preventDefault(); // Escape peels one layer: the detail sheet first, the pane only once // nothing is stacked on top of it. @@ -160,7 +160,7 @@ export function ActivityPane({ return; } // Only a destination that actually resolved earns the item leaving unseen. - await acknowledgeAttentionItem(item.id, "seen").catch(() => {}); + await acknowledgeActivityItem(item.id, "seen").catch(() => {}); onClose(); }, [onClose]); @@ -173,7 +173,7 @@ export function ActivityPane({ setPendingActionId(action.id); try { if (action.kind === "dismiss" || action.kind === "mark_seen") { - await acknowledgeAttentionItem( + await acknowledgeActivityItem( item.id, action.kind === "dismiss" ? "dismiss" : "seen", ); @@ -185,7 +185,7 @@ export function ActivityPane({ await openItem(item); } } catch { - // `acknowledgeAttentionItem` rolls its own optimistic state back and + // `acknowledgeActivityItem` rolls its own optimistic state back and // records the message in the store; the sheet renders it. } finally { setPendingActionId(null); @@ -193,12 +193,12 @@ export function ActivityPane({ }, [closeSheet, openItem, pendingActionId]); const dismissItem = useCallback((item: AttentionItem) => { - void acknowledgeAttentionItem(item.id, "dismiss").catch(() => {}); + void acknowledgeActivityItem(item.id, "dismiss").catch(() => {}); }, []); const clearInbox = useCallback((items: readonly AttentionItem[]) => { for (const item of items) { - void acknowledgeAttentionItem(item.id, "dismiss").catch(() => {}); + void acknowledgeActivityItem(item.id, "dismiss").catch(() => {}); } }, []); @@ -252,7 +252,7 @@ export function ActivityPane({ ); } - -export default ActivityCard; diff --git a/apps/desktop/src/renderer/components/activity/ActivityDetailSheet.tsx b/apps/desktop/src/renderer/components/activity/ActivityDetailSheet.tsx index dde34c7aa..e232dc37b 100644 --- a/apps/desktop/src/renderer/components/activity/ActivityDetailSheet.tsx +++ b/apps/desktop/src/renderer/components/activity/ActivityDetailSheet.tsx @@ -271,5 +271,3 @@ export function ActivityDetailSheet({ ); } - -export default ActivityDetailSheet; diff --git a/apps/desktop/src/renderer/components/activity/ActivityFilters.tsx b/apps/desktop/src/renderer/components/activity/ActivityFilters.tsx index 8b8ee7b36..88f24ee05 100644 --- a/apps/desktop/src/renderer/components/activity/ActivityFilters.tsx +++ b/apps/desktop/src/renderer/components/activity/ActivityFilters.tsx @@ -218,5 +218,3 @@ export function ActivityFilters({

); } - -export default ActivityFilters; diff --git a/apps/desktop/src/renderer/components/activity/ActivityInboxColumn.tsx b/apps/desktop/src/renderer/components/activity/ActivityInboxColumn.tsx index 0f3990adf..fad03a2e6 100644 --- a/apps/desktop/src/renderer/components/activity/ActivityInboxColumn.tsx +++ b/apps/desktop/src/renderer/components/activity/ActivityInboxColumn.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from "react"; +import React, { useMemo } from "react"; import { ArrowsClockwise, CheckCircle, @@ -21,9 +21,7 @@ import { import { relativeWhen } from "../../lib/format"; import { cn } from "../ui/cn"; import { activityItemPresentation } from "./activityPresentation"; - -const INITIAL_ROW_BUDGET = 60; -const ROW_BUDGET_STEP = 60; +import { useProgressiveRows } from "./useProgressiveRows"; /** The catalog names an icon per event; this is the renderer's half of that. */ const CATALOG_ICON: Record = { @@ -120,10 +118,13 @@ export function ActivityInboxColumn({ onDismissItem: (item: AttentionItem) => void; onClearAll: (items: readonly AttentionItem[]) => void; }) { - const [budget, setBudget] = useState(INITIAL_ROW_BUDGET); const inbox = useMemo(() => activityInboxItems(items), [items]); - const shown = inbox.slice(0, budget); - const hidden = inbox.length - shown.length; + const { + visibleRows: shown, + hiddenCount, + nextCount, + showMore, + } = useProgressiveRows(inbox); return (
@@ -170,13 +171,13 @@ export function ActivityInboxColumn({ onDismiss={onDismissItem} /> ))} - {hidden > 0 ? ( + {hiddenCount > 0 ? ( ) : null} @@ -185,5 +186,3 @@ export function ActivityInboxColumn({
); } - -export default ActivityInboxColumn; diff --git a/apps/desktop/src/renderer/components/activity/ActivityPane.test.tsx b/apps/desktop/src/renderer/components/activity/ActivityPane.test.tsx index 463bc0d24..f83957184 100644 --- a/apps/desktop/src/renderer/components/activity/ActivityPane.test.tsx +++ b/apps/desktop/src/renderer/components/activity/ActivityPane.test.tsx @@ -136,6 +136,24 @@ describe("ActivityPane", () => { expect(within(inbox).getByText("Task approval")).toBeTruthy(); }); + it("reveals long session lists one bounded page at a time", () => { + const itemsById = Object.fromEntries(Array.from({ length: 61 }, (_unused, index) => { + const id = `running-${String(index).padStart(2, "0")}`; + return [id, item(id, { + eventKind: "agent_running", + phase: "running", + title: `Running ${index}`, + })]; + })); + activityStore.setState({ itemsById }); + render( {}} />); + + const sessions = screen.getByRole("region", { name: "Sessions" }); + expect(sessions.querySelectorAll("[data-activity-row]")).toHaveLength(60); + fireEvent.click(within(sessions).getByRole("button", { name: "Show 1 more" })); + expect(sessions.querySelectorAll("[data-activity-row]")).toHaveLength(61); + }); + it("never offers the placeholder copy the old center apologised with", () => { activityStore.setState({ itemsById: { approval: item("approval") } }); render( {}} />); diff --git a/apps/desktop/src/renderer/components/activity/ActivityPane.tsx b/apps/desktop/src/renderer/components/activity/ActivityPane.tsx index 89b47eaa4..16c570255 100644 --- a/apps/desktop/src/renderer/components/activity/ActivityPane.tsx +++ b/apps/desktop/src/renderer/components/activity/ActivityPane.tsx @@ -337,5 +337,3 @@ export function ActivityPane({ document.body, ); } - -export default ActivityPane; diff --git a/apps/desktop/src/renderer/components/activity/ActivitySessionsColumn.tsx b/apps/desktop/src/renderer/components/activity/ActivitySessionsColumn.tsx index eae4b9f24..5758edb81 100644 --- a/apps/desktop/src/renderer/components/activity/ActivitySessionsColumn.tsx +++ b/apps/desktop/src/renderer/components/activity/ActivitySessionsColumn.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from "react"; +import React, { useMemo } from "react"; import type { AttentionItem } from "../../../shared/types"; import { relativeWhen } from "../../lib/format"; @@ -10,15 +10,7 @@ import { activitySections, type ActivitySection, } from "./activityPriority"; - -/** - * The rows rendered before the column stops and offers the rest behind a - * button. Activity is account-wide, so a busy fleet routinely lands hundreds of - * rows here; painting all of them costs more than anyone reads. Sixty is about - * two screens, which is as far as anyone scrolls before reaching for a filter. - */ -const INITIAL_ROW_BUDGET = 60; -const ROW_BUDGET_STEP = 60; +import { useProgressiveRows } from "./useProgressiveRows"; type MachineGroup = { machineKey: string; @@ -134,23 +126,21 @@ export function ActivitySessionsColumn({ loading: boolean; onOpenItem: (item: AttentionItem) => void; }) { - const [budget, setBudget] = useState(INITIAL_ROW_BUDGET); const sections = useMemo(() => activitySections(items), [items]); const total = sections.reduce((count, section) => count + section.items.length, 0); - - // The budget is spent across sections in priority order, so needs-you rows - // can never be the ones hidden behind "Show more". - const { budgeted, hidden } = useMemo(() => { - let remaining = budget; - const budgetedSections: ActivitySection[] = []; - for (const section of sections) { - if (section.items.length === 0) continue; - const take = Math.max(0, Math.min(section.items.length, remaining)); - remaining -= take; - if (take > 0) budgetedSections.push({ ...section, items: section.items.slice(0, take) }); - } - return { budgeted: budgetedSections, hidden: Math.max(0, total - budget) }; - }, [budget, sections, total]); + // Flatten in section priority order before spending the shared row budget, + // then rebuild headings for the visible slice. Needs-you rows stay first. + const orderedRows = useMemo( + () => sections.flatMap((section) => section.items), + [sections], + ); + const { + visibleRows, + hiddenCount, + nextCount, + showMore, + } = useProgressiveRows(orderedRows); + const budgeted = useMemo(() => activitySections(visibleRows), [visibleRows]); return (
@@ -203,13 +193,13 @@ export function ActivitySessionsColumn({ /> ))} - {hidden > 0 ? ( + {hiddenCount > 0 ? ( ) : null} @@ -218,5 +208,3 @@ export function ActivitySessionsColumn({
); } - -export default ActivitySessionsColumn; diff --git a/apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.tsx b/apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.tsx index f89608848..fb4376916 100644 --- a/apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.tsx +++ b/apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.tsx @@ -198,5 +198,3 @@ export function ActivitySettingsPopover() { ); } - -export default ActivitySettingsPopover; diff --git a/apps/desktop/src/renderer/components/activity/HeaderActivityControl.tsx b/apps/desktop/src/renderer/components/activity/HeaderActivityControl.tsx index e1937b74a..a213ff101 100644 --- a/apps/desktop/src/renderer/components/activity/HeaderActivityControl.tsx +++ b/apps/desktop/src/renderer/components/activity/HeaderActivityControl.tsx @@ -511,5 +511,3 @@ export function HeaderActivityControl({ ); } - -export default HeaderActivityControl; diff --git a/apps/desktop/src/renderer/components/activity/activityNotchLocalSettings.ts b/apps/desktop/src/renderer/components/activity/activityNotchLocalSettings.ts index e75ce228b..6d46415ee 100644 --- a/apps/desktop/src/renderer/components/activity/activityNotchLocalSettings.ts +++ b/apps/desktop/src/renderer/components/activity/activityNotchLocalSettings.ts @@ -19,9 +19,8 @@ const ATTENTION_NOTCH_TICKER_KEY = "ade:attention:notch-ticker"; const ATTENTION_NOTCH_SETTINGS_CHANGED_EVENT = "ade:attention-notch-settings-changed"; /** - * How the notch presents itself on *this* Mac. It describes one display's - * chrome, so it stays beside the enabled flag rather than in account - * preferences that follow the user to every machine. + * How the notch presents itself. Account preferences are authoritative when + * loaded; this Mac keeps the same shape in localStorage as its offline cache. */ export type ActivityNotchPresentation = { revealMode: AttentionNotchRevealMode; @@ -38,6 +37,18 @@ export const DEFAULT_ACTIVITY_NOTCH_PRESENTATION: ActivityNotchPresentation = { tickerEnabled: true, }; +/** + * A property read is not a capability check on the hosted web adapter: its + * fallback proxy fabricates callable namespaces for missing properties. The + * `in` probe reaches the real exposed surface (or the proxy target), so web + * renderers do not build and stringify native-only snapshots on every update. + */ +export function activityNotchSupported(): boolean { + return typeof window !== "undefined" + && window.ade != null + && "attentionNotch" in window.ade; +} + function readLocalItem(key: string): string | null { if (typeof window === "undefined") return null; try { diff --git a/apps/desktop/src/renderer/components/activity/activityPresentation.test.ts b/apps/desktop/src/renderer/components/activity/activityPresentation.test.ts index 23cb0bfd7..765f4a1bf 100644 --- a/apps/desktop/src/renderer/components/activity/activityPresentation.test.ts +++ b/apps/desktop/src/renderer/components/activity/activityPresentation.test.ts @@ -10,7 +10,6 @@ import { sessionStatusPresentation } from "../../../shared/sessionStatusPresenta import { activityPhaseIsSessionDerived, activityPhasePresentation, - activityViewEmptyCopy, activityItemPresentation, SESSION_DERIVED_ACTIVITY_PHASES, type AttentionTone, @@ -195,10 +194,4 @@ describe("Activity phase presentation", () => { attentionPhasePriority("needs_you"), ); }); - - it("uses the row's own word for finished work in empty-state copy", () => { - const recent = activityViewEmptyCopy("recent"); - expect(recent.body).toContain("Done"); - expect(recent.body).not.toMatch(/completed/i); - }); }); diff --git a/apps/desktop/src/renderer/components/activity/activityPresentation.ts b/apps/desktop/src/renderer/components/activity/activityPresentation.ts index ff8add6dc..603f9b1c3 100644 --- a/apps/desktop/src/renderer/components/activity/activityPresentation.ts +++ b/apps/desktop/src/renderer/components/activity/activityPresentation.ts @@ -203,28 +203,3 @@ export function activityActionTone( if (kind === "open" || kind === "restart") return "secondary"; return "ghost"; } - -export function activityViewEmptyCopy(view: "live" | "inbox" | "recent"): { - title: string; - body: string; -} { - if (view === "inbox") { - return { - title: "You’re all caught up", - body: "Approvals, failures, review requests, and finished work you haven’t seen will collect here.", - }; - } - if (view === "recent") { - return { - // "Done" rather than "Completed": the pill on these rows says Done, and - // prose that uses a different word for the same state is how a vocabulary - // starts to fray. - title: "No recent outcomes", - body: "Done and resolved work stays here for 24 hours after you review it.", - }; - } - return { - title: "No live work yet", - body: "Active agents and pull requests from every signed-in machine will appear here as they move.", - }; -} diff --git a/apps/desktop/src/renderer/components/activity/useActivitySync.test.tsx b/apps/desktop/src/renderer/components/activity/useActivitySync.test.tsx index 78d5d8685..52dede910 100644 --- a/apps/desktop/src/renderer/components/activity/useActivitySync.test.tsx +++ b/apps/desktop/src/renderer/components/activity/useActivitySync.test.tsx @@ -13,6 +13,7 @@ import { type AttentionNotchSettings, type AttentionSnapshot, } from "../../../shared/types"; +import { parseAttentionNotchSnapshot } from "../../../main/services/attention/attentionNotchRouter"; import { activityStore, resetActivityStoreForTests, @@ -26,6 +27,7 @@ import { refreshActivitySnapshot, useActivitySync, MAX_NOTCH_PROJECTION_ITEMS, + MAX_NOTCH_SNAPSHOT_BYTES, TOAST_ITEM_COOLDOWN_MS, TOAST_MIN_INTERVAL_MS, } from "./useActivitySync"; @@ -100,6 +102,39 @@ function liveItem(): AttentionItem { }; } +function readySnapshot( + items: AttentionItem[], + revision = 1, +): AttentionSnapshot { + return { + contractVersion: ATTENTION_CONTRACT_VERSION, + scope: "account", + availability: { + state: "ready", + title: "Account Activity", + message: "Live across your ADE account.", + recovery: null, + }, + streamId: "account:test", + revision, + generatedAt: `2026-07-28T14:00:0${revision}.000Z`, + items, + tombstones: [], + }; +} + +function signedInStatus(userId: string) { + return { + signedIn: true as const, + userId, + email: null, + name: null, + expiresAt: null, + provider: null, + imageUrl: null, + }; +} + function Harness({ surfaceVisible = true }: { surfaceVisible?: boolean }) { useActivitySync(surfaceVisible); return null; @@ -844,6 +879,211 @@ describe("useActivitySync", () => { expect(getSnapshot).toHaveBeenCalledTimes(hiddenRefreshBaseline + 1); }); + + it("uses account automatic-reveal settings for both helper settings and toasts", async () => { + window.localStorage.clear(); + window.localStorage.setItem("ade:attention:notch-auto-reveal", "true"); + const accountStatus = signedInStatus("user-account-reveal"); + publishAccountStatus(accountStatus); + const initial = { ...liveItem(), activityTier: "signal" as const }; + const preferences = { + ...DEFAULT_ATTENTION_PREFERENCES, + account: { + ...DEFAULT_ATTENTION_PREFERENCES.account, + hideDetails: false, + notchAutomaticReveal: false, + }, + }; + const updateSettings = vi.fn(async () => undefined); + const publishToast = vi.fn(async () => undefined); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + attention: { + getSnapshot: vi.fn(async () => readySnapshot([initial])), + acknowledge: vi.fn(), + reportPresence: vi.fn(), + getPreferences: vi.fn(async () => preferences), + putPreferences: vi.fn(), + }, + attentionNotch: { + publishSnapshot: vi.fn(async () => undefined), + publishToast, + updateSettings, + onAcknowledgeRequested: vi.fn(() => () => {}), + }, + account: { + ...(originalAde?.account ?? {}), + status: vi.fn(async () => accountStatus), + }, + }, + }); + + render(); + await waitFor(() => expect( + activityStore.getState().preferences?.account.notchAutomaticReveal, + ).toBe(false)); + await waitFor(() => expect(updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ automaticRevealEnabled: false }), + )); + await waitFor(() => expect(activityStore.getState().itemsById[initial.id]).toBeTruthy()); + + act(() => { + activityStore.getState().applySnapshot(readySnapshot([{ + ...initial, + revision: initial.revision + 1, + fingerprint: "account-fingerprint:needs-you", + eventKind: "agent_needs_you", + phase: "needs_you", + }], 2)); + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(publishToast).not.toHaveBeenCalled(); + }); + + it("clamps toast copy and consumes cooldown only after a successful publish", async () => { + window.localStorage.clear(); + window.localStorage.setItem("ade:attention:notch-auto-reveal", "true"); + const accountStatus = signedInStatus("user-toast-publish"); + publishAccountStatus(accountStatus); + const items = ["first", "second", "third"].map((suffix, index) => ({ + ...liveItem(), + id: `toast-${suffix}`, + revision: index + 1, + fingerprint: `toast-${suffix}:running`, + activityTier: "signal" as const, + destination: { kind: "session" as const, sessionId: `session-${suffix}` }, + })); + const publishToast = vi.fn() + .mockRejectedValueOnce(new Error("native helper unavailable")) + .mockResolvedValue(undefined); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + attention: { + getSnapshot: vi.fn(async () => readySnapshot(items)), + acknowledge: vi.fn(), + reportPresence: vi.fn(), + getPreferences: vi.fn(async () => ({ + ...DEFAULT_ATTENTION_PREFERENCES, + account: { + ...DEFAULT_ATTENTION_PREFERENCES.account, + hideDetails: false, + notchAutomaticReveal: true, + }, + })), + putPreferences: vi.fn(), + }, + attentionNotch: { + publishSnapshot: vi.fn(async () => undefined), + publishToast, + updateSettings: vi.fn(async () => undefined), + onAcknowledgeRequested: vi.fn(() => () => {}), + }, + account: { + ...(originalAde?.account ?? {}), + status: vi.fn(async () => accountStatus), + }, + }, + }); + + render(); + await waitFor(() => expect(activityStore.getState().itemsById[items[0]!.id]).toBeTruthy()); + await waitFor(() => expect(activityStore.getState().preferences?.account.hideDetails) + .toBe(false)); + + const transition = (index: number, revision: number) => { + const next = items.map((item, itemIndex) => itemIndex === index + ? { + ...item, + revision: item.revision + 10, + fingerprint: `${item.id}:needs-you`, + eventKind: "agent_needs_you" as const, + phase: "needs_you" as const, + title: index === 0 ? "T".repeat(400) : item.title, + preview: index === 0 ? "S".repeat(700) : item.preview, + } + : item); + activityStore.getState().applySnapshot(readySnapshot(next, revision)); + items.splice(0, items.length, ...next); + }; + + act(() => transition(0, 2)); + await waitFor(() => expect(publishToast).toHaveBeenCalledTimes(1)); + expect(publishToast.mock.calls[0]?.[0]).toMatchObject({ + itemId: "toast-first", + title: "T".repeat(256), + }); + expect(publishToast.mock.calls[0]?.[0]?.subtitle).toHaveLength(512); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + act(() => transition(1, 3)); + await waitFor(() => expect(publishToast).toHaveBeenCalledTimes(2)); + await act(async () => { + await Promise.resolve(); + }); + + act(() => transition(2, 4)); + await act(async () => { + await Promise.resolve(); + }); + expect(publishToast).toHaveBeenCalledTimes(2); + }); + + it("retries lazy notch preparation on the next store change", async () => { + const publishSnapshot = vi.fn() + .mockRejectedValueOnce(new Error("first helper write failed")) + .mockResolvedValue(undefined); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + attention: { + getSnapshot: vi.fn(() => new Promise(() => {})), + acknowledge: vi.fn(), + reportPresence: vi.fn(), + getPreferences: vi.fn(), + putPreferences: vi.fn(), + }, + attentionNotch: { + publishSnapshot, + updateSettings: vi.fn(async () => undefined), + onAcknowledgeRequested: vi.fn(() => () => {}), + }, + }, + }); + + render(); + await waitFor(() => expect(publishSnapshot).toHaveBeenCalledTimes(1)); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + act(() => { + activityStore.setState({ + revision: 1, + generatedAt: "2026-07-28T14:00:01.000Z", + itemsById: { [runningItem.id]: runningItem }, + }); + }); + + await waitFor(() => expect(publishSnapshot).toHaveBeenCalledTimes(2)); + expect(publishSnapshot.mock.calls[1]?.[0]).toMatchObject({ + items: [expect.objectContaining({ id: runningItem.id })], + }); + }); }); describe("Activity renderer-to-notch bridge", () => { @@ -868,7 +1108,7 @@ describe("Activity renderer-to-notch bridge", () => { revision: 8, generatedAt: "2026-07-28T12:00:02.000Z", // `recentActivity` is dropped from the projection; `runningItem` has none. - items: [runningItem], + items: [{ ...runningItem, detail: null }], itemsTruncated: false, counts: { needsYou: 0, @@ -1018,6 +1258,55 @@ describe("Activity renderer-to-notch bridge", () => { }); }); + it("drops detail and tail rows until an oversized projection clears the byte budget", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const itemsById: Record = {}; + for (let index = 0; index < MAX_NOTCH_PROJECTION_ITEMS; index += 1) { + const needsYou = index < 4; + const id = `${needsYou ? "needs" : "working"}-${String(index).padStart(2, "0")}`; + itemsById[id] = { + ...runningItem, + id, + revision: index + 1, + fingerprint: `${id}:1`, + eventKind: needsYou ? "agent_needs_you" : "agent_running", + phase: needsYou ? "needs_you" : "running", + activityTier: needsYou ? "signal" : "ambient", + title: "t".repeat(1_024), + privacyPreview: "p".repeat(1_024), + detail: "d".repeat(8_192), + model: "m".repeat(512), + laneName: "l".repeat(512), + project: { + ...runningItem.project, + rootPath: `/${"r".repeat(4_095)}`, + }, + }; + } + activityStore.setState({ itemsById }); + + const snapshot = materializeActivityNotchSnapshot(); + + expect(snapshot.items.length).toBeLessThan(MAX_NOTCH_PROJECTION_ITEMS); + expect(snapshot.itemsTruncated).toBe(true); + expect(snapshot.items.slice(0, 4).every((item) => item.phase === "needs_you")) + .toBe(true); + expect(snapshot.items.filter((item) => item.phase === "needs_you").map((item) => item.id).sort()) + .toEqual([ + "needs-00", + "needs-01", + "needs-02", + "needs-03", + ]); + expect(snapshot.items.every((item) => item.detail === null)).toBe(true); + expect(new TextEncoder().encode(JSON.stringify(snapshot)).byteLength) + .toBeLessThanOrEqual(MAX_NOTCH_SNAPSHOT_BYTES); + expect(parseAttentionNotchSnapshot(snapshot)).not.toBeNull(); + expect(warn).toHaveBeenCalledWith(expect.stringMatching( + /^activity\.notch_snapshot_truncated \{"reason":"byte_budget"/, + )); + }); + it("republishes when only the counts changed", () => { const base = materializeActivityNotchSnapshot(); expect(activityNotchSnapshotSignature({ diff --git a/apps/desktop/src/renderer/components/activity/useActivitySync.ts b/apps/desktop/src/renderer/components/activity/useActivitySync.ts index 1457590e4..afa7d3b13 100644 --- a/apps/desktop/src/renderer/components/activity/useActivitySync.ts +++ b/apps/desktop/src/renderer/components/activity/useActivitySync.ts @@ -23,10 +23,12 @@ import { import { useAccountStatus } from "../../lib/account"; import { activitySections, summarizeActivity } from "./activityPriority"; import { + activityNotchSupported, activityNotchSettingsFromPreferences, persistActivityNotchSettings, readActivityNotchEnabled, readActivityNotchPresentation, + resolveActivityNotchPresentation, } from "./activityNotchLocalSettings"; export { activityNotchSettingsFromPreferences } from "./activityNotchLocalSettings"; @@ -45,8 +47,10 @@ const MAX_VISIBLE_PRESENCE_ITEMS = 64; * ship the top-priority slice and let `counts` carry the honest totals. */ export const MAX_NOTCH_PROJECTION_ITEMS = 48; +export const MAX_NOTCH_SNAPSHOT_BYTES = 160 * 1024; const MAX_NOTCH_PREVIEW_LENGTH = 160; -const MAX_TOAST_SUBTITLE_LENGTH = 120; +const MAX_TOAST_TITLE_LENGTH = 256; +const MAX_TOAST_SUBTITLE_LENGTH = 512; /** One toast per item per 10 minutes, however many times it flaps. */ export const TOAST_ITEM_COOLDOWN_MS = 600_000; /** And at most one toast every 5s across the whole account. */ @@ -217,6 +221,7 @@ function projectActivityNotchItem(item: AttentionItem): AttentionItem { const { recentActivity: _recentActivity, ...rest } = item; return { ...rest, + detail: null, preview: item.preview.length > MAX_NOTCH_PREVIEW_LENGTH ? `${item.preview.slice(0, MAX_NOTCH_PREVIEW_LENGTH - 1)}…` : item.preview, @@ -244,7 +249,8 @@ export function materializeActivityNotchSnapshot(): AttentionSnapshot { const projected = ordered .slice(0, MAX_NOTCH_PROJECTION_ITEMS) .map(projectActivityNotchItem); - return { + const projectedItemCount = projected.length; + const snapshot: AttentionSnapshot = { contractVersion: ATTENTION_CONTRACT_VERSION, scope: state.snapshotScope ?? (activityAccountOwnerId ? "account" : "machine"), availability: state.availability ?? { @@ -258,11 +264,31 @@ export function materializeActivityNotchSnapshot(): AttentionSnapshot { streamId: state.streamId, revision: state.revision, generatedAt: state.generatedAt ?? new Date().toISOString(), - items: projected, + items: [...projected], itemsTruncated: projected.length < ordered.length, counts: activityNotchCounts(allItems), tombstones: [], }; + const encoder = new TextEncoder(); + const bytesBeforeBudget = encoder.encode(JSON.stringify(snapshot)).byteLength; + let bytesAfterBudget = bytesBeforeBudget; + while (snapshot.items.length > 0 && bytesAfterBudget > MAX_NOTCH_SNAPSHOT_BYTES) { + snapshot.items.pop(); + snapshot.itemsTruncated = true; + bytesAfterBudget = encoder.encode(JSON.stringify(snapshot)).byteLength; + } + if (snapshot.items.length < projectedItemCount) { + console.warn(`activity.notch_snapshot_truncated ${JSON.stringify({ + reason: "byte_budget", + budgetBytes: MAX_NOTCH_SNAPSHOT_BYTES, + bytesBeforeBudget, + bytesAfterBudget, + projectedItems: projectedItemCount, + publishedItems: snapshot.items.length, + totalItems: ordered.length, + })}`); + } + return snapshot; } export function activityNotchSnapshotSignature( @@ -379,14 +405,16 @@ export function activityToastForTransition( if (!best) return null; const treatment = TOAST_TREATMENT_BY_EVENT[best.eventKind] ?? "info"; + const unclampedTitle = input.hideDetails ? PRIVACY_TOAST_TITLE[best.kind] : best.title; + const unclampedSubtitle = input.hideDetails + ? best.privacyPreview + : sanitizeAttentionPreview(best.preview, MAX_TOAST_SUBTITLE_LENGTH); return { itemId: best.id, eventKind: best.eventKind, treatment, - title: input.hideDetails ? PRIVACY_TOAST_TITLE[best.kind] : best.title, - subtitle: input.hideDetails - ? best.privacyPreview - : sanitizeAttentionPreview(best.preview, MAX_TOAST_SUBTITLE_LENGTH), + title: unclampedTitle.slice(0, MAX_TOAST_TITLE_LENGTH), + subtitle: unclampedSubtitle.slice(0, MAX_TOAST_SUBTITLE_LENGTH), tone: null, durationMs: null, }; @@ -409,7 +437,9 @@ function resetActivityToastState(): void { */ function emitActivityNotchToast(snapshot: AttentionSnapshot): void { const items = Object.values(activityStore.getState().itemsById); - const presentation = readActivityNotchPresentation(); + const presentation = resolveActivityNotchPresentation( + activityStore.getState().preferences, + ); const toast = activityToastForTransition({ items, previousPhases: notchToastPhases, @@ -437,11 +467,15 @@ function emitActivityNotchToast(snapshot: AttentionSnapshot): void { if (now - at >= TOAST_ITEM_COOLDOWN_MS) notchToastCooldownByItem.delete(id); } if (!toast?.itemId) return; - notchLastToastAt = now; - notchToastCooldownByItem.set(toast.itemId, now); - void Promise.resolve( - window.ade?.attentionNotch?.publishToast?.(toast), - ).catch(() => {}); + const notchApi = window.ade?.attentionNotch; + if (typeof notchApi?.publishToast !== "function") return; + void Promise.resolve(notchApi.publishToast(toast)) + .then(() => { + const publishedAt = Date.now(); + notchLastToastAt = publishedAt; + notchToastCooldownByItem.set(toast.itemId!, publishedAt); + }) + .catch(() => {}); } async function refreshActivityNotchSettings( @@ -481,7 +515,11 @@ async function refreshActivityNotchSettings( if (!notchApi) return; await enqueueActivityNotchSettingsUpdate( scope, - activityNotchSettingsFromPreferences(preferences), + activityNotchSettingsFromPreferences( + preferences, + readActivityNotchEnabled(), + resolveActivityNotchPresentation(preferences), + ), ); }) .then(() => { @@ -503,22 +541,24 @@ async function refreshActivityNotchSettings( async function prepareActivityNotchForAccount( scope: ActivityAccountScope, -): Promise { +): Promise { const notchApi = typeof window !== "undefined" ? window.ade?.attentionNotch : null; - if (!notchApi || !isCurrentAccountScope(scope)) return false; + if (!notchApi || !isCurrentAccountScope(scope)) return null; try { // Never let the previous account's privacy/animation/sound choices govern // a new stream. Clear the old snapshot only after native presentation is // private and quiet, then hydrate the new account's preferences. await enqueueActivityNotchSettingsUpdate(scope, failClosedActivityNotchSettings()); - if (!isCurrentAccountScope(scope)) return false; - await publishActivityNotchSnapshot(); + if (!isCurrentAccountScope(scope)) return null; + const snapshot = materializeActivityNotchSnapshot(); + await publishActivityNotchSnapshot(snapshot); + const publishedSignature = activityNotchSnapshotSignature(snapshot); + if (!isCurrentAccountScope(scope)) return null; + if (scope.ownerId) await refreshActivityNotchSettings(scope, true); + return isCurrentAccountScope(scope) ? publishedSignature : null; } catch { - return false; + return null; } - if (!isCurrentAccountScope(scope)) return false; - if (scope.ownerId) await refreshActivityNotchSettings(scope, true); - return isCurrentAccountScope(scope); } function fallbackDeviceIdentity(): { deviceId: string; deviceName: string } { @@ -637,8 +677,31 @@ export function useActivitySync(routeSurfaceVisible: boolean): void { ownerId: accountUserId, }; let lastNotchSignature = ""; + let notchPrepared = false; + let prepareNotchPromise: Promise | null = null; + let notchPublishInFlight = false; + let notchPublishQueued = false; let active = true; let unsubscribe = () => {}; + const prepareNotch = () => { + if ( + notchPrepared + || prepareNotchPromise + || !active + || !isCurrentAccountScope(accountScope) + ) return; + const pending = prepareActivityNotchForAccount(accountScope) + .then((publishedSignature) => { + if (!active || !publishedSignature || !isCurrentAccountScope(accountScope)) return; + notchPrepared = true; + lastNotchSignature = publishedSignature; + publishNotchIfChanged(); + }) + .finally(() => { + if (prepareNotchPromise === pending) prepareNotchPromise = null; + }); + prepareNotchPromise = pending; + }; const publishNotchIfChanged = () => { const snapshot = materializeActivityNotchSnapshot(); // Toasts ride this same pass, and deliberately before the signature @@ -647,14 +710,36 @@ export function useActivitySync(routeSurfaceVisible: boolean): void { emitActivityNotchToast(snapshot); const nextSignature = activityNotchSnapshotSignature(snapshot); if (nextSignature === lastNotchSignature) return; - lastNotchSignature = nextSignature; - void publishActivityNotchSnapshot(snapshot).catch(() => {}); + if (!notchPrepared) { + prepareNotch(); + return; + } + if (notchPublishInFlight) { + notchPublishQueued = true; + return; + } + notchPublishInFlight = true; + void publishActivityNotchSnapshot(snapshot) + .then(() => { + if (active && isCurrentAccountScope(accountScope)) { + lastNotchSignature = nextSignature; + } + }) + .catch(() => { + // Keep the prior signature: the next store update retries this state. + }) + .finally(() => { + notchPublishInFlight = false; + if (notchPublishQueued && active) { + notchPublishQueued = false; + publishNotchIfChanged(); + } + }); }; - void prepareActivityNotchForAccount(accountScope).then((prepared) => { - if (!active || !prepared || !isCurrentAccountScope(accountScope)) return; + if (activityNotchSupported()) { unsubscribe = activityStore.subscribe(publishNotchIfChanged); publishNotchIfChanged(); - }); + } const removeNotchAcknowledgeListener = window.ade?.attentionNotch?.onAcknowledgeRequested((request) => { void acknowledgeActivityItem(request.itemId, request.mode) diff --git a/apps/desktop/src/renderer/components/activity/useProgressiveRows.ts b/apps/desktop/src/renderer/components/activity/useProgressiveRows.ts new file mode 100644 index 000000000..f60b8c83c --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/useProgressiveRows.ts @@ -0,0 +1,16 @@ +import { useCallback, useMemo, useState } from "react"; + +const INITIAL_ROW_BUDGET = 60; +const ROW_BUDGET_STEP = 60; + +/** Keep long Activity columns cheap while exposing the next bounded page. */ +export function useProgressiveRows(rows: readonly T[]) { + const [budget, setBudget] = useState(INITIAL_ROW_BUDGET); + const visibleRows = useMemo(() => rows.slice(0, budget), [budget, rows]); + const hiddenCount = Math.max(0, rows.length - visibleRows.length); + const nextCount = Math.min(hiddenCount, ROW_BUDGET_STEP); + const showMore = useCallback(() => { + setBudget((value) => value + ROW_BUDGET_STEP); + }, []); + return { visibleRows, hiddenCount, nextCount, showMore }; +} diff --git a/apps/desktop/src/renderer/components/settings/ActivitySection.test.tsx b/apps/desktop/src/renderer/components/settings/ActivitySection.test.tsx index 003daff3c..21fafad56 100644 --- a/apps/desktop/src/renderer/components/settings/ActivitySection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/ActivitySection.test.tsx @@ -102,6 +102,22 @@ describe("ActivitySection", () => { }); }); + it("writes the account-synced dock badge scope", async () => { + const { putPreferences } = installAdeMock(); + render(); + const control = await screen.findByRole("combobox", { name: "Dock badge counts" }); + + expect((control as HTMLSelectElement).value).toBe("local"); + fireEvent.change(control, { target: { value: "account" } }); + + await waitFor(() => expect(putPreferences).toHaveBeenCalledWith( + "user-1", + expect.objectContaining({ + account: expect.objectContaining({ dockBadgeScope: "account" }), + }), + )); + }); + it("syncs notch presentation and keeps this Mac's cache in step", async () => { const { putPreferences, updateSettings } = installAdeMock(); render(); diff --git a/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx b/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx index b6bc3e818..8ba1dcc8c 100644 --- a/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx +++ b/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx @@ -18,6 +18,7 @@ import { type AttentionPreferences, } from "../../../shared/types"; import { + activityNotchSupported, activityNotchSettingsFromPreferences, activityPreferencesWithNotchPresentation, normalizeActivityPreferences, @@ -30,7 +31,6 @@ import { } from "../activity/activityNotchLocalSettings"; import { useAccountStatus } from "../../lib/account"; import { useActivityStore } from "../../state/activityStore"; -import { isWebHiddenCapability } from "../../webclient/adapter"; import { SettingsCard, SettingsGroup, SettingsSelect, SettingsToggle } from "./primitives"; import { COLORS, SANS_FONT } from "../lanes/laneDesignTokens"; @@ -63,6 +63,14 @@ const ESCALATION_OPTIONS = [ { value: "300", label: "After 5 minutes" }, ]; +const DOCK_BADGE_SCOPE_OPTIONS: { + value: AttentionPreferences["account"]["dockBadgeScope"]; + label: string; +}[] = [ + { value: "local", label: "This Mac" }, + { value: "account", label: "All machines" }, +]; + const NOTCH_REVEAL_HELP: Record = { minimal: "Keep a tiny status visible; hover or click for a short peek.", hover: "Stay hidden until the pointer reaches the top-edge hot zone.", @@ -75,15 +83,6 @@ export type ActivityMachineOption = { online: boolean; }; -/** - * Whether this window can talk to a notch at all. The web client has no native - * helper, so its notch rows would be switches wired to nothing. - */ -export function activityNotchSupported(): boolean { - if (isWebHiddenCapability("attentionNotch")) return false; - return typeof window !== "undefined" && window.ade?.attentionNotch != null; -} - export type ActivitySettingsModel = ReturnType; /** @@ -468,6 +467,26 @@ export function ActivitySettingsControls({

Account

+ updateAccount({ + dockBadgeScope: event.target.value as AttentionPreferences["account"]["dockBadgeScope"], + })} + > + {DOCK_BADGE_SCOPE_OPTIONS.map((option) => ( + + ))} + + } + /> + + updateAccount({ dockBadgeScope })} + /> + } + /> + + ); } - -export default ActivitySettingsControls; diff --git a/apps/desktop/src/renderer/components/settings/settingsManifest.test.ts b/apps/desktop/src/renderer/components/settings/settingsManifest.test.ts index 5863754d1..aad6a58be 100644 --- a/apps/desktop/src/renderer/components/settings/settingsManifest.test.ts +++ b/apps/desktop/src/renderer/components/settings/settingsManifest.test.ts @@ -131,6 +131,7 @@ describe("settings manifest", () => { expect(searchSettingsEntries("api key").map((e) => e.id)).toContain("secrets.secrets"); expect(searchSettingsEntries("banner").map((e) => e.id)).toContain("lanes-git.rebase-suggestions"); expect(searchSettingsEntries("do not disturb").map((e) => e.id)).toContain("notifications.focus-suppression"); + expect(searchSettingsEntries("all machines").map((e) => e.id)).toContain("activity.dock-badge"); }); it("returns nothing for a blank query rather than every setting", () => { diff --git a/apps/desktop/src/renderer/components/settings/settingsManifest.ts b/apps/desktop/src/renderer/components/settings/settingsManifest.ts index c4e8869c4..be0e29af3 100644 --- a/apps/desktop/src/renderer/components/settings/settingsManifest.ts +++ b/apps/desktop/src/renderer/components/settings/settingsManifest.ts @@ -517,6 +517,15 @@ export const SETTINGS_ENTRIES: readonly SettingEntry[] = [ scope: "machine", group: "Privacy", }, + { + id: "activity.dock-badge", + label: "Dock badge counts", + keywords: ["dock", "badge", "count", "this mac", "all machines", "account"], + tab: "activity", + anchor: "activity-dock-badge", + scope: "machine", + group: "Account", + }, { id: "activity.machines", label: "Notify me about", diff --git a/apps/desktop/src/renderer/state/activityStore.test.ts b/apps/desktop/src/renderer/state/activityStore.test.ts index 5492d67e3..6dfbdcd0c 100644 --- a/apps/desktop/src/renderer/state/activityStore.test.ts +++ b/apps/desktop/src/renderer/state/activityStore.test.ts @@ -8,8 +8,6 @@ import { acknowledgeActivityItem, activityStore, resetActivityStoreForTests, - selectActivityCounts, - selectActivityItems, selectActivityUnseenCount, } from "./activityStore"; @@ -206,38 +204,7 @@ describe("activityStore", () => { }); }); - it("filters global items by view and project scope", () => { - activityStore.setState({ - itemsById: { - live: item("live", "running"), - inbox: item("inbox", "needs_you"), - recent: item("recent", "completed", { - seenAt: "2026-07-28T14:02:00.000Z", - updatedAt: "2026-07-28T14:02:00.000Z", - }), - other: item("other", "running", { - project: { projectId: "other-project", name: "Other" }, - }), - }, - scope: { kind: "project", projectId: "ade", label: "ADE" }, - view: "live", - }); - - expect(selectActivityItems(activityStore.getState()).map((entry) => entry.id)).toEqual([ - "inbox", - "live", - ]); - - activityStore.getState().setView("recent"); - expect( - selectActivityItems( - activityStore.getState(), - Date.parse("2026-07-28T15:00:00.000Z"), - ).map((entry) => entry.id), - ).toEqual(["recent"]); - }); - - it("tracks scoped counts separately from the global unseen badge", () => { + it("tracks the global unseen badge across machines", () => { activityStore.setState({ itemsById: { needs: item("needs", "needs_you"), @@ -253,14 +220,12 @@ describe("activityStore", () => { }, }), }, - scope: { kind: "machine", machineKey: "studio", label: "Studio Mac" }, }); - expect(selectActivityCounts(activityStore.getState()).inbox).toBe(1); expect(selectActivityUnseenCount(activityStore.getState())).toBe(2); }); - it("excludes expired work from views, counts, and the global badge", () => { + it("excludes expired work from the global badge", () => { activityStore.setState({ itemsById: { expired: item("expired", "needs_you", { @@ -270,16 +235,6 @@ describe("activityStore", () => { expiresAt: "2099-01-01T00:00:00.000Z", }), }, - view: "live", - }); - const now = Date.parse("2026-07-28T14:00:00.000Z"); - - expect(selectActivityItems(activityStore.getState(), now).map((entry) => entry.id)).toEqual([ - "current", - ]); - expect(selectActivityCounts(activityStore.getState(), now)).toMatchObject({ - live: 1, - inbox: 0, }); expect(selectActivityUnseenCount(activityStore.getState())).toBe(0); }); diff --git a/apps/desktop/src/renderer/state/activityStore.ts b/apps/desktop/src/renderer/state/activityStore.ts index 65c0ef9e3..c7cd2eaba 100644 --- a/apps/desktop/src/renderer/state/activityStore.ts +++ b/apps/desktop/src/renderer/state/activityStore.ts @@ -2,22 +2,13 @@ import { useStore } from "zustand"; import { createStore } from "zustand/vanilla"; import { - attentionItemIsLive, attentionItemNeedsInbox, - sortAttentionItems, type AttentionItem, type AttentionPreferences, type AttentionSnapshot, type AttentionTombstone, } from "../../shared/types"; -export type ActivityView = "live" | "inbox" | "recent"; - -export type ActivityScope = - | { kind: "all" } - | { kind: "machine"; machineKey: string; label: string } - | { kind: "project"; projectId: string; label: string; machineKey?: string | null }; - export type ActivitySyncStatus = "idle" | "syncing" | "ready" | "error"; type PendingActivityAcknowledgement = { @@ -35,9 +26,6 @@ export type ActivityStoreState = { generatedAt: string | null; itemsById: Record; tombstonesById: Record; - view: ActivityView; - scope: ActivityScope; - selectedItemId: string | null; headerSurfaceVisible: boolean; /** * Last account preferences this window loaded. Null means "not loaded yet", @@ -54,75 +42,18 @@ export type ActivityStoreState = { applySnapshot: (snapshot: AttentionSnapshot) => void; upsertItem: (item: AttentionItem) => void; removeItem: (tombstone: AttentionTombstone) => void; - setView: (view: ActivityView) => void; - setScope: (scope: ActivityScope) => void; - selectItem: (itemId: string | null) => void; setHeaderSurfaceVisible: (visible: boolean) => void; setSyncStatus: (status: ActivitySyncStatus, error?: string | null) => void; markSeen: (itemId: string, seenAt?: string) => void; dismiss: (itemId: string, dismissedAt?: string) => void; }; -const RECENT_WINDOW_MS = 24 * 60 * 60 * 1_000; - -function itemMatchesScope(item: AttentionItem, scope: ActivityScope): boolean { - if (scope.kind === "all") return true; - if (scope.kind === "machine") return item.machine.machineKey === scope.machineKey; - return item.project.projectId === scope.projectId - && (!scope.machineKey || item.machine.machineKey === scope.machineKey); -} - function isExpiredItem(item: AttentionItem, now: number): boolean { if (!item.expiresAt) return false; const expiresAt = Date.parse(item.expiresAt); return Number.isFinite(expiresAt) && expiresAt <= now; } -function isRecentItem(item: AttentionItem, now: number): boolean { - if (item.dismissedAt || isExpiredItem(item, now) || attentionItemIsLive(item)) return false; - const timestamp = Date.parse(item.seenAt ?? item.updatedAt); - if (!Number.isFinite(timestamp)) return false; - return now - timestamp <= RECENT_WINDOW_MS; -} - -function sortRecentItems(items: readonly AttentionItem[]): AttentionItem[] { - return [...items].sort((left, right) => { - const timestamp = Date.parse(right.updatedAt) - Date.parse(left.updatedAt); - if (Number.isFinite(timestamp) && timestamp !== 0) return timestamp; - return left.id.localeCompare(right.id); - }); -} - -export function selectActivityItems( - state: Pick, - now = Date.now(), -): AttentionItem[] { - const scoped = Object.values(state.itemsById).filter( - (item) => itemMatchesScope(item, state.scope) && !isExpiredItem(item, now), - ); - if (state.view === "live") { - return sortAttentionItems(scoped.filter((item) => !item.dismissedAt && attentionItemIsLive(item))); - } - if (state.view === "inbox") { - return sortAttentionItems(scoped.filter(attentionItemNeedsInbox)); - } - return sortRecentItems(scoped.filter((item) => isRecentItem(item, now))); -} - -export function selectActivityCounts( - state: Pick, - now = Date.now(), -): Record { - const scoped = Object.values(state.itemsById).filter( - (item) => itemMatchesScope(item, state.scope) && !isExpiredItem(item, now), - ); - return { - live: scoped.filter((item) => !item.dismissedAt && attentionItemIsLive(item)).length, - inbox: scoped.filter(attentionItemNeedsInbox).length, - recent: scoped.filter((item) => isRecentItem(item, now)).length, - }; -} - /** * Whether Activity surfaces may show agent-authored text. Unloaded preferences * resolve to `false` rather than `true`: hide-details is off by default, and @@ -167,9 +98,6 @@ function createInitialState(): Pick< | "generatedAt" | "itemsById" | "tombstonesById" - | "view" - | "scope" - | "selectedItemId" | "headerSurfaceVisible" | "preferences" | "syncStatus" @@ -186,9 +114,6 @@ function createInitialState(): Pick< generatedAt: null, itemsById: {}, tombstonesById: {}, - view: "live", - scope: { kind: "all" }, - selectedItemId: null, headerSurfaceVisible: false, preferences: null, syncStatus: "idle", @@ -273,9 +198,6 @@ export const activityStore = createStore((set) => ({ syncError: null, pendingAcknowledgements: streamReset ? {} : state.pendingAcknowledgements, acknowledgementErrors: streamReset ? {} : state.acknowledgementErrors, - selectedItemId: state.selectedItemId && itemsById[state.selectedItemId] - ? state.selectedItemId - : null, }; }), upsertItem: (item) => @@ -299,12 +221,8 @@ export const activityStore = createStore((set) => ({ return { itemsById, tombstonesById: { ...state.tombstonesById, [tombstone.id]: tombstone }, - selectedItemId: state.selectedItemId === tombstone.id ? null : state.selectedItemId, }; }), - setView: (view) => set({ view, selectedItemId: null }), - setScope: (scope) => set({ scope, selectedItemId: null }), - selectItem: (selectedItemId) => set({ selectedItemId }), setHeaderSurfaceVisible: (headerSurfaceVisible) => set({ headerSurfaceVisible }), setSyncStatus: (syncStatus, syncError = null) => set({ syncStatus, syncError }), markSeen: (itemId, seenAt = new Date().toISOString()) => @@ -331,7 +249,6 @@ export const activityStore = createStore((set) => ({ dismissedAt, }, }, - selectedItemId: state.selectedItemId === itemId ? null : state.selectedItemId, }; }), })); @@ -432,7 +349,6 @@ export async function acknowledgeActivityItem( ...state.acknowledgementErrors, [itemId]: message, }, - selectedItemId: canRollback ? itemId : state.selectedItemId, }; }); throw error; diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts index fb66346ba..0a4a2247e 100644 --- a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts @@ -66,6 +66,7 @@ describe("createAdeWebAdapter", () => { it("boots before and after a bound project", async () => { const adapter = createAdeWebAdapter(fake.asClient()); + expect("attentionNotch" in adapter.ade).toBe(false); await expect(adapter.ade.app.getProject()).resolves.toBeNull(); await expect(adapter.ade.app.getWindowSession()).resolves.toMatchObject({ windowId: null, @@ -341,10 +342,24 @@ describe("createAdeWebAdapter", () => { isSessionLeaseCurrent: () => true, getAccessToken: vi.fn(async () => "account-token"), } as unknown as BrowserAccountClient; + const { + dockBadgeScope: _legacyDockBadgeScope, + ...legacyAccount + } = DEFAULT_ATTENTION_PREFERENCES.account; + const { + machines: _legacyMachines, + ...legacyPreferences + } = DEFAULT_ATTENTION_PREFERENCES; const fetchMock = vi.fn() .mockResolvedValueOnce(new Response(JSON.stringify({ preferences: DEFAULT_ATTENTION_PREFERENCES, }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + preferences: { + ...legacyPreferences, + account: legacyAccount, + }, + }), { status: 200 })) .mockResolvedValueOnce(new Response(JSON.stringify({ preferences: { ...DEFAULT_ATTENTION_PREFERENCES, @@ -354,6 +369,8 @@ describe("createAdeWebAdapter", () => { vi.stubGlobal("fetch", fetchMock); const adapter = createAdeWebAdapter(fake.asClient(), undefined, accountClient); + await expect(adapter.ade.attention.getPreferences("account-a")) + .resolves.toEqual(DEFAULT_ATTENTION_PREFERENCES); await expect(adapter.ade.attention.getPreferences("account-a")) .resolves.toEqual(DEFAULT_ATTENTION_PREFERENCES); await expect(adapter.ade.attention.getPreferences("account-a")) @@ -363,6 +380,45 @@ describe("createAdeWebAdapter", () => { adapter.dispose(); }); + it("strips device and machine overrides from account preference saves", async () => { + const snapshot: BrowserAccountSnapshot = { + state: "signed_in", + userId: "account-a", + email: "owner@example.test", + name: "Owner", + imageUrl: null, + expiresAt: "2026-07-30T00:00:00.000Z", + machines: [], + relayBaseUrls: ["wss://relay.example"], + message: null, + }; + const accountClient = { + getSnapshot: () => snapshot, + captureSessionLease: () => ({ userId: "account-a", generation: 1 }), + isSessionLeaseCurrent: () => true, + getAccessToken: vi.fn(async () => "account-token"), + } as unknown as BrowserAccountClient; + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { + status: 200, + })); + vi.stubGlobal("fetch", fetchMock); + const adapter = createAdeWebAdapter(fake.asClient(), undefined, accountClient); + + await adapter.ade.attention.putPreferences("account-a", { + ...DEFAULT_ATTENTION_PREFERENCES, + devices: { browser: { notificationsEnabled: false } }, + machines: { studio: { notificationsEnabled: false } }, + }); + + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(JSON.parse(String(init.body))).toEqual({ + account: DEFAULT_ATTENTION_PREFERENCES.account, + projects: DEFAULT_ATTENTION_PREFERENCES.projects, + mutedSessionIds: DEFAULT_ATTENTION_PREFERENCES.mutedSessionIds, + }); + adapter.dispose(); + }); + it("patches one machine's notification mute without rewriting the account document", async () => { const snapshot: BrowserAccountSnapshot = { state: "signed_in", diff --git a/apps/desktop/src/renderer/webclient/adapter/attention.ts b/apps/desktop/src/renderer/webclient/adapter/attention.ts index 01c49d7a9..aeb856626 100644 --- a/apps/desktop/src/renderer/webclient/adapter/attention.ts +++ b/apps/desktop/src/renderer/webclient/adapter/attention.ts @@ -288,12 +288,16 @@ function isPreferenceScope(value: unknown, partial = false): value is AttentionP function parseAttentionPreferences(value: unknown): AttentionPreferences { const candidate = record(value); + const account = record(candidate?.account); + const normalizedAccount = account && account.dockBadgeScope === undefined + ? { ...account, dockBadgeScope: "local" } + : account; const devices = record(candidate?.devices); - const machines = record(candidate?.machines); + const machines = candidate?.machines === undefined ? {} : record(candidate.machines); const projects = record(candidate?.projects); if ( !candidate - || !isPreferenceScope(candidate.account) + || !isPreferenceScope(normalizedAccount) || !devices || !Object.values(devices).every((scope) => isPreferenceScope(scope, true)) || !machines @@ -307,7 +311,11 @@ function parseAttentionPreferences(value: unknown): AttentionPreferences { "Activity preferences were incompatible. Update ADE and retry.", ); } - return candidate as AttentionPreferences; + return { + ...candidate, + account: normalizedAccount, + machines, + } as AttentionPreferences; } function relayBaseUrl(): string { @@ -533,7 +541,11 @@ export function createAttentionNamespace( if (!owner || owner !== accountOwnerId.trim()) { throw new Error("The ADE account changed before Activity settings could be saved."); } - const { devices: _deviceOverrides, ...accountPreferences } = preferences; + const { + devices: _deviceOverrides, + machines: _machineOverrides, + ...accountPreferences + } = preferences; await request( "preference update", "PUT", @@ -544,9 +556,9 @@ export function createAttentionNamespace( /** * Per-machine notification mute. It has its own relay route rather than - * riding the preferences PUT because that PUT strips `devices` and replaces - * the whole document — a partial machine scope written that way would race - * every other tab editing the same preferences. + * riding the preferences PUT because that PUT strips `devices` and + * `machines` before replacing the account document — a partial machine + * scope written that way would race every other tab editing preferences. */ async putMachinePreferences( accountOwnerId: string, diff --git a/apps/desktop/src/renderer/webclient/adapter/index.ts b/apps/desktop/src/renderer/webclient/adapter/index.ts index cce343bd4..5cecf089e 100644 --- a/apps/desktop/src/renderer/webclient/adapter/index.ts +++ b/apps/desktop/src/renderer/webclient/adapter/index.ts @@ -49,9 +49,9 @@ export const WEB_HIDDEN_CAPABILITIES = { transcription: false, automations: false, // The notch is a native macOS helper supervised by the desktop main process. - // On web the namespace never registers, so `withFallbackProxy` resolves it to - // null; naming it here lets settings hide the controls instead of showing - // switches that silently do nothing. + // On web the namespace never registers. `withFallbackProxy` fabricates a + // callable value for ordinary property reads, so callers must use a real + // presence probe (`"attentionNotch" in window.ade`) before doing native work. attentionNotch: false, } as const; diff --git a/apps/desktop/src/shared/activityCatalog.test.ts b/apps/desktop/src/shared/activityCatalog.test.ts index 34ea340d7..a2fcb6b55 100644 --- a/apps/desktop/src/shared/activityCatalog.test.ts +++ b/apps/desktop/src/shared/activityCatalog.test.ts @@ -1,36 +1,14 @@ -import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { ACTIVITY_EVENT_BY_KIND, ACTIVITY_EVENT_CATALOG, - type ActivityEventGroup, } from "./activityCatalog"; import { ATTENTION_EVENT_KINDS, BALANCED_ATTENTION_EVENT_POLICIES, - type AttentionDeliveryPolicy, - type AttentionEventKind, } from "./types/attention"; -type RawActivityEventDescriptor = { - kind: AttentionEventKind; - group: ActivityEventGroup; - defaultPolicy: AttentionDeliveryPolicy; -}; - -const RAW_ACTIVITY_EVENT_KINDS = JSON.parse( - readFileSync(new URL("./activityEventKinds.json", import.meta.url), "utf8"), -) as RawActivityEventDescriptor[]; - describe("Activity event catalog", () => { - it("matches the ordered cross-platform JSON contract", () => { - expect(ACTIVITY_EVENT_CATALOG.map(({ kind, group, defaultPolicy }) => ({ - kind, - group, - defaultPolicy, - }))).toEqual(RAW_ACTIVITY_EVENT_KINDS); - }); - it("covers every Attention event kind exactly once", () => { const kinds = ACTIVITY_EVENT_CATALOG.map((descriptor) => descriptor.kind); expect(kinds).toHaveLength(11); diff --git a/apps/desktop/src/shared/activityEventKinds.json b/apps/desktop/src/shared/activityEventKinds.json deleted file mode 100644 index 411886de3..000000000 --- a/apps/desktop/src/shared/activityEventKinds.json +++ /dev/null @@ -1,13 +0,0 @@ -[ - { "kind": "agent_needs_you", "group": "agents", "defaultPolicy": "notify" }, - { "kind": "agent_failed", "group": "agents", "defaultPolicy": "notify" }, - { "kind": "agent_completed", "group": "agents", "defaultPolicy": "ambient" }, - { "kind": "agent_running", "group": "agents", "defaultPolicy": "ambient" }, - { "kind": "pr_checks_failing", "group": "pull_requests", "defaultPolicy": "notify" }, - { "kind": "pr_review_requested", "group": "pull_requests", "defaultPolicy": "notify" }, - { "kind": "pr_changes_requested", "group": "pull_requests", "defaultPolicy": "notify" }, - { "kind": "pr_merge_ready", "group": "pull_requests", "defaultPolicy": "notify" }, - { "kind": "pr_merged", "group": "pull_requests", "defaultPolicy": "ambient" }, - { "kind": "pr_opened", "group": "pull_requests", "defaultPolicy": "ambient" }, - { "kind": "pr_closed", "group": "pull_requests", "defaultPolicy": "ambient" } -] diff --git a/docs/PRD.md b/docs/PRD.md index 0be167652..31bfaf15d 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -89,7 +89,7 @@ ADE is the control plane. It owns ADE Browser automation for its built-in projec ### Brain, runtime, and clients - [**Remote Runtime**](./features/remote-runtime/README.md) — Remote access to an ADE runtime. Multi-project registry, machine endpoint, login-service install, SSH bootstrap of the cross-platform `ade-` runtime binaries shipped under `apps/desktop/resources/runtime/`. A remote machine's brain is authoritative for its projects. -- [**ADE Code**](./features/ade-code/README.md) — Terminal-native Work chat (Ink + React) inside `apps/ade-cli`. Default attaches to the machine brain and starts it if missing. Same JSON-RPC surface as the desktop app and the iOS controller, including session ask/note/settle lifecycle controls and the account-wide `/attention` pane. +- [**ADE Code**](./features/ade-code/README.md) — Terminal-native Work chat (Ink + React) inside `apps/ade-cli`. Default attaches to the machine brain and starts it if missing. Same JSON-RPC surface as the desktop app and the iOS controller, including session ask/note/settle lifecycle controls and the account-wide Activity pane. - [**Web Client**](./features/web-client/README.md) — Owner-only hosted browser controller. Static Cloudflare Pages SPA, ADE account sign-in, account-directory machine selection, DPoP-bound sync WebSocket transport, no local DB, and account Activity that remains independent of the selected project. ### Work execution diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index 5b6df7317..645906738 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -103,9 +103,9 @@ Point Cursor’s browser inspector at the served page for layout debugging. The `forceEmbedded` and `requireSocket` are mutually exclusive — `connectToAde` rejects the combination. -### Account-wide Attention +### Account-wide Activity -`/attention` is a machine-global right-pane utility, not a view of the selected +`/activity` is a machine-global right-pane utility, not a view of the selected lane or project. A signed-in TUI asks `attention.call/getSnapshot` for the consolidated account stream. The runtime's account coordinator can read the relay independently of the current project, while each item retains its owning @@ -251,7 +251,7 @@ Right pane (open contextual content): | `/session settle [session-id] [outcome]` | Mark a session settled, declaring the settle at the override tier. | | `/session unsettle [session-id]` | Clear a session's declared settle plus any `settled` pin. | | `/session keep-active [session-id]` | Write the `active` settle-override pin, holding the row in the active list even if something later declares a settle on it (e.g. the PR-merge policy). Note that nothing *derives* a settle: a clean CLI exit leaves the row `ended`, never `settled`. | -| `/attention` | Open account-wide Attention in the right pane. Signed-out or degraded mode is labeled as connected-machine-only; `Enter` opens the exact destination and `R` refreshes. | +| `/activity` | Open account-wide Activity in the right pane. `/attention` remains a compatibility alias. Signed-out or degraded mode is labeled as connected-machine-only; `Enter` opens the exact destination and `R` refreshes. | | `/tag ` | Tag the active Claude chat (Claude only). | | `/output-style [style]` | List or select the active Claude output style (Claude only). | | `/plugin [reload\|native args]` | List, reload, or manage Claude plugins (Claude only). | diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index b47abbf17..9c4b11233 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -2611,7 +2611,7 @@ different machine's cached limits. reachable host no longer lists as pending. It never drops while the host is unreachable or the refresh came back empty, so a transient gap cannot erase a genuinely queued message. -- **`AttentionDrawerModel.clearVisibleItems()` persists dismissals +- **`ActivityDrawerModel.dismissVisible(in:)` persists dismissals scoped to the active id set.** Ids are stored under `ade.attention.dismissedItemIDs` and pruned on every rebuild against the live active set, so a chat that re-enters From c8b7d04c2f6e4939bf3b1f0fd51191d438a706eb Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:54:38 -0400 Subject: [PATCH 18/19] =?UTF-8?q?activity(quality):=20re-review=20fixes=20?= =?UTF-8?q?=E2=80=94=20phase-anchored=20roster=20statusSince,=20roster=20s?= =?UTF-8?q?ignal=20tier,=20cap=20backpressure=20restored,=20toast=20burst?= =?UTF-8?q?=20guard,=20prepared-gate,=20dead=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../push/pushPublisherService.test.ts | 70 +++++- .../src/services/push/pushPublisherService.ts | 33 ++- .../activity/useActivitySync.test.tsx | 202 +++++++++++++++++- .../components/activity/useActivitySync.ts | 85 +++++--- .../src/renderer/webclient/adapter/index.ts | 29 --- apps/push-relay/src/attention.ts | 4 +- apps/push-relay/test/attention.test.ts | 18 +- 7 files changed, 359 insertions(+), 82 deletions(-) diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index 95a3fcc57..fb302e417 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -1733,8 +1733,58 @@ describe("createPushPublisherService flush", () => { publisher.dispose(); }); - it("uses one rebuild timestamp for roster rows with invalid activity dates", async () => { - const rebuildAt = Date.parse("2026-08-01T12:00:00.000Z"); + it("keeps roster alert identity stable when activity advances within one status", async () => { + let clock = Date.parse("2026-08-01T12:00:00.000Z"); + const roster = rosterProject(1, "2026-08-01T11:59:00.000Z"); + roster.chats[0]!.status = "awaiting"; + const buildSnapshot = vi.fn(async () => [roster]); + const { publisher } = makeHarness(device, () => clock, { + activityProtocol: 2, + activityRosterProvider: { buildSnapshot }, + }); + + const first = (await publisher.getMachineAttentionSnapshot()).items[0]!; + roster.chats[0]!.lastActivityAt = "2026-08-01T12:00:05.000Z"; + clock += 11_000; + const second = (await publisher.getMachineAttentionSnapshot()).items[0]!; + + expect(buildSnapshot).toHaveBeenCalledTimes(2); + expect(first).toMatchObject({ phase: "needs_you", activityTier: "signal" }); + expect(second.revision).toBeGreaterThan(first.revision); + expect(second.updatedAt).not.toBe(first.updatedAt); + expect(second.statusSince).toBe(first.statusSince); + expect(second.alertFingerprint).toBe(first.alertFingerprint); + publisher.dispose(); + }); + + it("changes roster statusSince whenever a chat re-enters a status", async () => { + let clock = Date.parse("2026-08-01T12:00:00.000Z"); + const roster = rosterProject(1, "2026-08-01T11:59:00.000Z"); + roster.chats[0]!.status = "awaiting"; + const buildSnapshot = vi.fn(async () => [roster]); + const { publisher } = makeHarness(device, () => clock, { + activityProtocol: 2, + activityRosterProvider: { buildSnapshot }, + }); + + const awaiting = (await publisher.getMachineAttentionSnapshot()).items[0]!; + roster.chats[0]!.status = "running"; + clock += 11_000; + const running = (await publisher.getMachineAttentionSnapshot()).items[0]!; + roster.chats[0]!.status = "awaiting"; + clock += 11_000; + const awaitingAgain = (await publisher.getMachineAttentionSnapshot()).items[0]!; + + expect([awaiting.phase, running.phase, awaitingAgain.phase]) + .toEqual(["needs_you", "running", "needs_you"]); + expect(Date.parse(running.statusSince!)).toBeGreaterThan(Date.parse(awaiting.statusSince!)); + expect(Date.parse(awaitingAgain.statusSince!)).toBeGreaterThan(Date.parse(running.statusSince!)); + expect(awaitingAgain.alertFingerprint).not.toBe(awaiting.alertFingerprint); + publisher.dispose(); + }); + + it("anchors invalid roster activity dates once across uncached rebuilds", async () => { + let rebuildAt = Date.parse("2026-08-01T12:00:00.000Z"); const buildSnapshot = vi.fn().mockResolvedValue([ rosterProject(2, "not-an-iso-date"), ]); @@ -1747,12 +1797,20 @@ describe("createPushPublisherService flush", () => { }, ); - const items = (await publisher.getMachineAttentionSnapshot()).items; + const first = (await publisher.getMachineAttentionSnapshot()).items; + rebuildAt += 11_000; + const second = (await publisher.getMachineAttentionSnapshot()).items; - expect(items).toHaveLength(2); - expect(items.map((entry) => entry.revision)).toEqual([rebuildAt, rebuildAt]); - expect(items.map((entry) => entry.updatedAt)) + expect(first).toHaveLength(2); + expect(first.map((entry) => entry.revision)) + .toEqual([rebuildAt - 11_000, rebuildAt - 11_000]); + expect(first.map((entry) => entry.updatedAt)) .toEqual(["2026-08-01T12:00:00.000Z", "2026-08-01T12:00:00.000Z"]); + expect(second.map((entry) => entry.revision)).toEqual([rebuildAt, rebuildAt]); + expect(second.map((entry) => entry.statusSince)) + .toEqual(first.map((entry) => entry.statusSince)); + expect(second.map((entry) => entry.alertFingerprint)) + .toEqual(first.map((entry) => entry.alertFingerprint)); publisher.dispose(); }); diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index f2a90d284..d83f9baa3 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -512,8 +512,17 @@ function rosterAttentionPhase(status: SyncRosterChatStatus): AttentionPhase { } } -function rosterActivityTier(status: SyncRosterChatStatus): "ambient" | "idle" { - return status === "idle" || status === "ended" ? "idle" : "ambient"; +function rosterActivityTier(status: SyncRosterChatStatus): "signal" | "ambient" | "idle" { + switch (status) { + case "awaiting": + case "failed": + return "signal"; + case "running": + return "ambient"; + case "idle": + case "ended": + return "idle"; + } } function prActivityTier(phase: AttentionPhase): "signal" | "ambient" { @@ -572,6 +581,10 @@ export function createPushPublisherService(deps: PushPublisherDeps) { const runs = new Map(); const recentRuns = new Map(); const prActivities = new Map(); + const rosterPhaseAnchors = new Map(); const lastMachineSnapshotItems = new Map(); let lastMachineSnapshotAccountOwnerId: string | null | undefined; let pendingAlerts: PendingAlert[] = []; @@ -838,6 +851,12 @@ export function createPushPublisherService(deps: PushPublisherDeps) { const activityTier = rosterActivityTier(chat.status); const revision = validTimestampMs(chat.lastActivityAt, nowMs); const activityAt = new Date(revision).toISOString(); + const id = `agent:${machineKey}:${chat.id}`; + const existingAnchor = rosterPhaseAnchors.get(id); + const statusSinceAt = existingAnchor?.status === chat.status + ? existingAnchor.statusSinceAt + : Math.max(revision, (existingAnchor?.statusSinceAt ?? -1) + 1); + rosterPhaseAnchors.set(id, { status: chat.status, statusSinceAt }); const provider = providerDisplayName(chat.provider ?? chat.toolType); const subject = provider ?? chat.title?.trim() ?? "Agent"; const preview = sanitizeAttentionPreview( @@ -861,7 +880,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { } return withActivityFingerprints({ contractVersion: ATTENTION_CONTRACT_VERSION, - id: `agent:${machineKey}:${chat.id}`, + id, revision, fingerprint: "", activityTier, @@ -914,7 +933,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { actions, occurredAt: activityAt, updatedAt: activityAt, - statusSince: activityAt, + statusSince: new Date(statusSinceAt).toISOString(), seenAt: null, dismissedAt: null, expiresAt: activityTier === "idle" @@ -924,6 +943,12 @@ export function createPushPublisherService(deps: PushPublisherDeps) { }); }) : []; + if (includeRoster) { + const rosterItemIds = new Set(rosterItems.map((item) => item.id)); + for (const id of rosterPhaseAnchors.keys()) { + if (!rosterItemIds.has(id)) rosterPhaseAnchors.delete(id); + } + } const prItems = [...prActivities.values()].map((pr): AttentionItem => { const scopeKey = pr.scopeKey; diff --git a/apps/desktop/src/renderer/components/activity/useActivitySync.test.tsx b/apps/desktop/src/renderer/components/activity/useActivitySync.test.tsx index 52dede910..71d9d5582 100644 --- a/apps/desktop/src/renderer/components/activity/useActivitySync.test.tsx +++ b/apps/desktop/src/renderer/components/activity/useActivitySync.test.tsx @@ -946,7 +946,7 @@ describe("useActivitySync", () => { expect(publishToast).not.toHaveBeenCalled(); }); - it("clamps toast copy and consumes cooldown only after a successful publish", async () => { + it("clamps toast copy and rolls back cooldown after a failed publish", async () => { window.localStorage.clear(); window.localStorage.setItem("ade:attention:notch-auto-reveal", "true"); const accountStatus = signedInStatus("user-toast-publish"); @@ -1040,6 +1040,145 @@ describe("useActivitySync", () => { expect(publishToast).toHaveBeenCalledTimes(2); }); + it("optimistically rate-limits distinct signal items in one native round trip", async () => { + window.localStorage.clear(); + window.localStorage.setItem("ade:attention:notch-auto-reveal", "true"); + const items = ["first", "second"].map((suffix, index) => ({ + ...liveItem(), + id: `burst-${suffix}`, + revision: index + 1, + fingerprint: `burst-${suffix}:running`, + activityTier: "signal" as const, + destination: { kind: "session" as const, sessionId: `session-${suffix}` }, + })); + let resolveToast: () => void = () => {}; + const publishToast = vi.fn(() => new Promise((resolve) => { + resolveToast = resolve; + })); + const publishSnapshot = vi.fn(async () => undefined); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + attention: { + getSnapshot: vi.fn(async () => readySnapshot(items)), + acknowledge: vi.fn(), + reportPresence: vi.fn(), + getPreferences: vi.fn(), + putPreferences: vi.fn(), + }, + attentionNotch: { + publishSnapshot, + publishToast, + updateSettings: vi.fn(async () => undefined), + onAcknowledgeRequested: vi.fn(() => () => {}), + }, + }, + }); + + render(); + await waitFor(() => expect(publishSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ id: "burst-first" }), + expect.objectContaining({ id: "burst-second" }), + ]), + }), + )); + + const firstTransition = [ + { + ...items[0]!, + revision: 10, + fingerprint: "burst-first:needs-you", + eventKind: "agent_needs_you" as const, + phase: "needs_you" as const, + }, + items[1]!, + ]; + const secondTransition = [ + firstTransition[0]!, + { + ...items[1]!, + revision: 11, + fingerprint: "burst-second:needs-you", + eventKind: "agent_needs_you" as const, + phase: "needs_you" as const, + }, + ]; + act(() => { + activityStore.getState().applySnapshot(readySnapshot(firstTransition, 2)); + activityStore.getState().applySnapshot(readySnapshot(secondTransition, 3)); + }); + await waitFor(() => expect(publishToast).toHaveBeenCalledTimes(1)); + expect(publishToast).toHaveBeenCalledWith(expect.objectContaining({ + itemId: "burst-first", + })); + + resolveToast(); + await act(async () => { + await Promise.resolve(); + }); + }); + + it("does not toast a transition before the notch is prepared", async () => { + window.localStorage.clear(); + window.localStorage.setItem("ade:attention:notch-auto-reveal", "true"); + const initial = { ...liveItem(), activityTier: "signal" as const }; + let resolveSettings: () => void = () => {}; + const updateSettings = vi.fn(() => new Promise((resolve) => { + resolveSettings = resolve; + })); + const publishSnapshot = vi.fn(async () => undefined); + const publishToast = vi.fn(async () => undefined); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + attention: { + getSnapshot: vi.fn(async () => readySnapshot([initial])), + acknowledge: vi.fn(), + reportPresence: vi.fn(), + getPreferences: vi.fn(), + putPreferences: vi.fn(), + }, + attentionNotch: { + publishSnapshot, + publishToast, + updateSettings, + onAcknowledgeRequested: vi.fn(() => () => {}), + }, + }, + }); + + render(); + await waitFor(() => expect(activityStore.getState().itemsById[initial.id]).toBeTruthy()); + await waitFor(() => expect(updateSettings).toHaveBeenCalled()); + act(() => { + activityStore.getState().applySnapshot(readySnapshot([{ + ...initial, + revision: initial.revision + 1, + fingerprint: "account-fingerprint:needs-you", + eventKind: "agent_needs_you", + phase: "needs_you", + }], 2)); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(publishToast).not.toHaveBeenCalled(); + + resolveSettings(); + await waitFor(() => expect(publishSnapshot).toHaveBeenCalled()); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(publishToast).not.toHaveBeenCalled(); + }); + it("retries lazy notch preparation on the next store change", async () => { const publishSnapshot = vi.fn() .mockRejectedValueOnce(new Error("first helper write failed")) @@ -1122,6 +1261,39 @@ describe("Activity renderer-to-notch bridge", () => { }); }); + it("skips UTF-8 byte measurement for an ordinary small snapshot", () => { + const OriginalTextEncoder = globalThis.TextEncoder; + let encoderConstructions = 0; + class CountingTextEncoder extends OriginalTextEncoder { + constructor() { + super(); + encoderConstructions += 1; + } + } + Object.defineProperty(globalThis, "TextEncoder", { + configurable: true, + writable: true, + value: CountingTextEncoder, + }); + try { + activityStore.setState({ + revision: 8, + generatedAt: "2026-07-28T12:00:02.000Z", + itemsById: { [runningItem.id]: runningItem }, + }); + + materializeActivityNotchSnapshot(); + + expect(encoderConstructions).toBe(0); + } finally { + Object.defineProperty(globalThis, "TextEncoder", { + configurable: true, + writable: true, + value: OriginalTextEncoder, + }); + } + }); + it("maps account privacy, celebration, sound, and local presentation settings", () => { expect(activityNotchSettingsFromPreferences({ ...DEFAULT_ATTENTION_PREFERENCES, @@ -1303,7 +1475,33 @@ describe("Activity renderer-to-notch bridge", () => { .toBeLessThanOrEqual(MAX_NOTCH_SNAPSHOT_BYTES); expect(parseAttentionNotchSnapshot(snapshot)).not.toBeNull(); expect(warn).toHaveBeenCalledWith(expect.stringMatching( - /^activity\.notch_snapshot_truncated \{"reason":"byte_budget"/, + /^\[useActivitySync\] activity\.notch_snapshot_truncated \{"reason":"byte_budget"/, + )); + }); + + it("measures potentially oversized non-ASCII snapshots before publishing", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const itemsById: Record = {}; + for (let index = 0; index < MAX_NOTCH_PROJECTION_ITEMS; index += 1) { + const id = `unicode-${String(index).padStart(2, "0")}`; + itemsById[id] = { + ...runningItem, + id, + revision: index + 1, + fingerprint: `${id}:1`, + title: "界".repeat(1_000), + }; + } + activityStore.setState({ itemsById }); + + const snapshot = materializeActivityNotchSnapshot(); + + expect(snapshot.items.length).toBeLessThan(MAX_NOTCH_PROJECTION_ITEMS); + expect(snapshot.itemsTruncated).toBe(true); + expect(new TextEncoder().encode(JSON.stringify(snapshot)).byteLength) + .toBeLessThanOrEqual(MAX_NOTCH_SNAPSHOT_BYTES); + expect(warn).toHaveBeenCalledWith(expect.stringContaining( + "[useActivitySync] activity.notch_snapshot_truncated", )); }); diff --git a/apps/desktop/src/renderer/components/activity/useActivitySync.ts b/apps/desktop/src/renderer/components/activity/useActivitySync.ts index afa7d3b13..3790caa2f 100644 --- a/apps/desktop/src/renderer/components/activity/useActivitySync.ts +++ b/apps/desktop/src/renderer/components/activity/useActivitySync.ts @@ -269,24 +269,32 @@ export function materializeActivityNotchSnapshot(): AttentionSnapshot { counts: activityNotchCounts(allItems), tombstones: [], }; - const encoder = new TextEncoder(); - const bytesBeforeBudget = encoder.encode(JSON.stringify(snapshot)).byteLength; - let bytesAfterBudget = bytesBeforeBudget; - while (snapshot.items.length > 0 && bytesAfterBudget > MAX_NOTCH_SNAPSHOT_BYTES) { - snapshot.items.pop(); - snapshot.itemsTruncated = true; - bytesAfterBudget = encoder.encode(JSON.stringify(snapshot)).byteLength; - } - if (snapshot.items.length < projectedItemCount) { - console.warn(`activity.notch_snapshot_truncated ${JSON.stringify({ - reason: "byte_budget", - budgetBytes: MAX_NOTCH_SNAPSHOT_BYTES, - bytesBeforeBudget, - bytesAfterBudget, - projectedItems: projectedItemCount, - publishedItems: snapshot.items.length, - totalItems: ordered.length, - })}`); + const serializedSnapshot = JSON.stringify(snapshot); + const mightExceedByteBudget = serializedSnapshot.length > MAX_NOTCH_SNAPSHOT_BYTES / 2 + || ( + serializedSnapshot.length > MAX_NOTCH_SNAPSHOT_BYTES / 3 + && /[^\u0000-\u007f]/.test(serializedSnapshot) + ); + if (mightExceedByteBudget) { + const encoder = new TextEncoder(); + const bytesBeforeBudget = encoder.encode(serializedSnapshot).byteLength; + let bytesAfterBudget = bytesBeforeBudget; + while (snapshot.items.length > 0 && bytesAfterBudget > MAX_NOTCH_SNAPSHOT_BYTES) { + snapshot.items.pop(); + snapshot.itemsTruncated = true; + bytesAfterBudget = encoder.encode(JSON.stringify(snapshot)).byteLength; + } + if (snapshot.items.length < projectedItemCount) { + console.warn(`[useActivitySync] activity.notch_snapshot_truncated ${JSON.stringify({ + reason: "byte_budget", + budgetBytes: MAX_NOTCH_SNAPSHOT_BYTES, + bytesBeforeBudget, + bytesAfterBudget, + projectedItems: projectedItemCount, + publishedItems: snapshot.items.length, + totalItems: ordered.length, + })}`); + } } return snapshot; } @@ -469,13 +477,30 @@ function emitActivityNotchToast(snapshot: AttentionSnapshot): void { if (!toast?.itemId) return; const notchApi = window.ade?.attentionNotch; if (typeof notchApi?.publishToast !== "function") return; - void Promise.resolve(notchApi.publishToast(toast)) - .then(() => { - const publishedAt = Date.now(); - notchLastToastAt = publishedAt; - notchToastCooldownByItem.set(toast.itemId!, publishedAt); - }) - .catch(() => {}); + const publishToast = notchApi.publishToast; + const itemId = toast.itemId; + const publishedAt = Date.now(); + const accountGeneration = activityAccountGeneration; + const previousLastToastAt = notchLastToastAt; + const previousItemToastAt = notchToastCooldownByItem.get(itemId); + notchLastToastAt = publishedAt; + notchToastCooldownByItem.set(itemId, publishedAt); + void Promise.resolve() + .then(() => accountGeneration === activityAccountGeneration + ? publishToast(toast) + : undefined) + .catch(() => { + if (accountGeneration !== activityAccountGeneration) return; + if (notchLastToastAt === publishedAt) { + notchLastToastAt = previousLastToastAt; + } + if (notchToastCooldownByItem.get(itemId) !== publishedAt) return; + if (previousItemToastAt === undefined) { + notchToastCooldownByItem.delete(itemId); + } else { + notchToastCooldownByItem.set(itemId, previousItemToastAt); + } + }); } async function refreshActivityNotchSettings( @@ -704,16 +729,16 @@ export function useActivitySync(routeSurfaceVisible: boolean): void { }; const publishNotchIfChanged = () => { const snapshot = materializeActivityNotchSnapshot(); - // Toasts ride this same pass, and deliberately before the signature - // gate: a phase transition is exactly what earns an announcement, and a - // republish-suppressed frame must still be able to carry one. - emitActivityNotchToast(snapshot); const nextSignature = activityNotchSnapshotSignature(snapshot); - if (nextSignature === lastNotchSignature) return; if (!notchPrepared) { prepareNotch(); return; } + // Toasts ride this same pass, and deliberately before the signature + // gate: a phase transition is exactly what earns an announcement, and a + // republish-suppressed frame must still be able to carry one. + emitActivityNotchToast(snapshot); + if (nextSignature === lastNotchSignature) return; if (notchPublishInFlight) { notchPublishQueued = true; return; diff --git a/apps/desktop/src/renderer/webclient/adapter/index.ts b/apps/desktop/src/renderer/webclient/adapter/index.ts index 5cecf089e..7749c87f8 100644 --- a/apps/desktop/src/renderer/webclient/adapter/index.ts +++ b/apps/desktop/src/renderer/webclient/adapter/index.ts @@ -1,5 +1,4 @@ import type { ProjectInfo } from "../../../shared/types"; -import { isWebClientMode } from "../../lib/webClientMode"; import type { SyncMobileProjectSummary } from "../../../shared/types/sync"; import type { AdeSyncClient } from "../sync"; import { BrowserAccountClient } from "../account/client"; @@ -34,34 +33,6 @@ export type AdeWebAdapter = { dispose(): void; }; -export const WEB_HIDDEN_CAPABILITIES = { - revealInFinder: false, - externalEditor: false, - updater: false, - builtInBrowser: false, - appControl: false, - iosSimulator: false, - computerUse: false, - nativeWindowControls: false, - nativeDirectoryPicker: false, - localPathOpen: false, - cursorCloud: false, - transcription: false, - automations: false, - // The notch is a native macOS helper supervised by the desktop main process. - // On web the namespace never registers. `withFallbackProxy` fabricates a - // callable value for ordinary property reads, so callers must use a real - // presence probe (`"attentionNotch" in window.ade`) before doing native work. - attentionNotch: false, -} as const; - -export type WebHiddenCapability = keyof typeof WEB_HIDDEN_CAPABILITIES; - -/** Whether a capability is unavailable because this window is the web client. */ -export function isWebHiddenCapability(capability: WebHiddenCapability): boolean { - return isWebClientMode() && WEB_HIDDEN_CAPABILITIES[capability] === false; -} - const DOMAIN_EVENTS = { lanes: "lanesInvalidated", sessions: "sessionsInvalidated", diff --git a/apps/push-relay/src/attention.ts b/apps/push-relay/src/attention.ts index 062c0ef78..d94057c93 100644 --- a/apps/push-relay/src/attention.ts +++ b/apps/push-relay/src/attention.ts @@ -729,7 +729,7 @@ async function enforceActivityAccountItemCap( overflow, Number(evictionEligibleCountRow?.count ?? 0), ); - if (rowsToRemove === 0) return { itemsTruncated: false, revision: null }; + if (rowsToRemove === 0) return { itemsTruncated: true, revision: null }; const revision = await commitAttentionRevision(env, userId, [ env.DB.prepare(` @@ -2929,7 +2929,7 @@ async function handleSnapshot( where user_id = ? `).bind(userId).first<{ count: number }>(); const itemsTruncated = Number(accountItemCountRow?.count ?? 0) - >= MAX_ACCOUNT_ATTENTION_ITEMS; + > MAX_ACCOUNT_ATTENTION_ITEMS; const links = await env.DB.prepare(` select machine_key, machine_name, last_seen_at from attention_machine_links diff --git a/apps/push-relay/test/attention.test.ts b/apps/push-relay/test/attention.test.ts index aa21f08d3..11bc2beaa 100644 --- a/apps/push-relay/test/attention.test.ts +++ b/apps/push-relay/test/attention.test.ts @@ -1265,9 +1265,9 @@ describe("account Attention contract", () => { activityTier: "ambient", updatedAt: "2026-07-28T08:19:00.000Z", })); - const stale = parse(activityAgentItem({ - sessionId: "stale-signal", - itemId: "stale-signal", + const staleRoster = parse(activityAgentItem({ + sessionId: "stale-roster-signal", + itemId: null, revision: 1, contentFingerprint: "stale-content", alertFingerprint: "stale-alert", @@ -1301,7 +1301,7 @@ describe("account Attention contract", () => { { userId: "account-a", machineKey: MACHINE_KEY, - items: [idle, ambient, stale, fresh], + items: [idle, ambient, staleRoster, fresh], tombstones: [], sealCapacityTombstones: false, rosterEpoch: 1, @@ -1315,7 +1315,7 @@ describe("account Attention contract", () => { APNS_TEAM_ID: "TESTTEAM12", }), "account-a", - [idle, ambient, stale, fresh], + [idle, ambient, staleRoster, fresh], sendPush, ); expect(sendPush).toHaveBeenCalledTimes(1); @@ -1707,7 +1707,7 @@ describe("account Attention contract", () => { } }); - it("caps an account and reports snapshot truncation", async () => { + it("caps an account, reports publish eviction, and keeps exact-cap snapshots honest", async () => { const database = new SqliteD1Database(); const authorization = await machinePublishAuthorization(); vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { @@ -1775,7 +1775,7 @@ describe("account Attention contract", () => { "GET", "/attention/account/snapshot?since=0", )).json() as { itemsTruncated?: boolean }; - expect(snapshot.itemsTruncated).toBe(true); + expect(snapshot.itemsTruncated).toBe(false); } finally { database.close(); } @@ -1880,7 +1880,7 @@ describe("account Attention contract", () => { } }); - it("does not report truncation when an over-cap account has no evictable rows", async () => { + it("reports truncation backpressure when an over-cap account has no evictable rows", async () => { const database = new SqliteD1Database(); const authorization = await machinePublishAuthorization(); vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { @@ -1933,7 +1933,7 @@ describe("account Attention contract", () => { ); const body = await response.json() as { itemsTruncated?: boolean }; expect(response.status).toBe(200); - expect(body.itemsTruncated).toBeUndefined(); + expect(body.itemsTruncated).toBe(true); expect(row(database, ` select count(*) as count from attention_items where user_id = ? `, authorization.userId)?.count).toBe(2_001); From a6900c866123c8e11cb6de2f553153240cb6b752 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:09:14 -0400 Subject: [PATCH 19/19] =?UTF-8?q?activity(test):=20parity=20passes=20?= =?UTF-8?q?=E2=80=94=20registry=20machine-mute=20action,=20TUI=20idle-tier?= =?UTF-8?q?=20filing=20+=20row=20age,=20iOS=20accessibility=20fixes,=20doc?= =?UTF-8?q?s=20protocol-2=20coverage,=20logging=20taxonomy=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- apps/ade-cli/README.md | 11 +- apps/ade-cli/src/cli.ts | 2 +- .../src/services/push/pushPublisherService.ts | 13 +++ .../tuiClient/__tests__/activityPane.test.ts | 29 +++++ .../__tests__/activityPaneView.test.tsx | 45 +++++++- apps/ade-cli/src/tuiClient/activityPane.ts | 18 +++ .../tuiClient/components/ActivityPaneView.tsx | 10 +- .../src/main/services/adeActions/registry.ts | 25 ++++ .../Views/Activity/ActivityBellButton.swift | 2 +- .../Views/Activity/ActivityDrawerSheet.swift | 38 ++++-- apps/ios/ADE/Views/Activity/ActivityRow.swift | 38 +++++- apps/ios/ADE/Views/Hub/HubLiveStrip.swift | 9 +- apps/ios/ADEWidgets/ADELockScreenWidget.swift | 2 + apps/push-relay/README.md | 38 +++++- .../onboarding-and-settings/README.md | 2 +- docs/features/sync-and-multi-device/README.md | 59 +++++++++- .../sync-and-multi-device/ios-companion.md | 41 ++++++- .../push-notifications.md | 108 ++++++++++++++++-- .../features/terminals-and-sessions/README.md | 9 +- docs/logging.md | 5 +- 20 files changed, 458 insertions(+), 46 deletions(-) diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 0516adf6c..187d82ff0 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -182,7 +182,7 @@ The runtime exposes two layers of JSON-RPC methods (`src/multiProjectRpcServer.t ```text ade/initialize ade/initialized ping shutdown exit runtime/info machineInfo.get -account.call +account.call attention.call projects.list projects.add projects.remove projects.touch projects.browseDirectories projects.getDetail projects.getWorkSummary projects.getDefaultParentDir @@ -208,6 +208,13 @@ directory operations. Prefer the typed `ade login`, `ade auth status`, commands; they select the CTO role where credential-bearing operations require it and keep account-machine pairing on the DPoP-bound runtime path. +`attention.call` is the CTO-gated account-wide Activity surface backing `ade +code`'s `/activity` pane (`getSnapshot`, `getMachineSnapshot`, `acknowledge`, +`reportPresence`, `getPreferences`, `putPreferences`). `attention` stays as the +frozen wire identifier for the method, the action domain, and the item ids even +though the product surface is now called Activity. Agents on a desktop endpoint +reach the same operations through `ade actions run attention.`. + `runtimeEvents.subscribe` returns `eventEpoch`, `nextCursor`, `hasMore`, `gap`, and `oldestCursor`; when `gap` is true, the caller's cursor predates the retained buffer and it should refresh state before resuming from `oldestCursor` / `nextCursor`. `personalChats.call` dispatches the machine action registry advertised as @@ -489,7 +496,7 @@ ade storage compress --text # losslessly compress old c ade --role cto storage maintenance --text # run the policy-driven ledger maintenance sweep now (CTO) ade storage actions --text # raw storage service actions (cleanupPreview/cleanup live here) ade actions list --domain chat --text -ade --role cto actions list --domain attention --text # discover account-wide Attention actions +ade --role cto actions list --domain attention --text # discover account-wide Activity actions (domain name is a frozen wire identifier) ade --role cto actions run attention.getSnapshot --input-json '{"since":0}' --json ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts ade actions run pty.resumeSession --arg sessionId=session-id diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 6400130a8..2344ba6fe 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -2306,7 +2306,7 @@ const HELP_BY_COMMAND: Record = { $ ade actions list --text Domain-grouped action catalog $ ade actions list --domain git --text Narrow the catalog $ ade --role cto actions list --domain attention --text - Discover account-wide Attention actions + Discover account-wide Activity actions $ ade --role cto actions run attention.getSnapshot --input-json '{"since":0}' --json Read work across connected machines and projects $ ade actions run --input-json '{"key":"value"}' diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index d83f9baa3..79f3d2d5f 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -9,6 +9,7 @@ import { type AttentionEventKind, type AttentionItem, type AttentionPhase, + type AttentionPreferenceScope, type AttentionPreferences, type AttentionPresence, type AttentionSnapshot, @@ -2793,6 +2794,18 @@ export function createPushPublisherService(deps: PushPublisherDeps) { await deps.relayClient.putAttentionPreferences?.(accountOwnerId, preferences); }, + async putAttentionMachinePreferences( + accountOwnerId: string, + machineKey: string, + preferences: Partial, + ): Promise { + await deps.relayClient.putActivityMachinePreferences?.( + accountOwnerId, + machineKey, + preferences, + ); + }, + dispose, /** diff --git a/apps/ade-cli/src/tuiClient/__tests__/activityPane.test.ts b/apps/ade-cli/src/tuiClient/__tests__/activityPane.test.ts index ad0e98cd3..27a359cb7 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/activityPane.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/activityPane.test.ts @@ -8,6 +8,7 @@ import { acknowledgeActivityItem, activityItemContext, activityItemDeepLink, + activityItemElapsed, activityPaneEntries, buildActivityPaneModel, loadActivitySnapshot, @@ -120,6 +121,34 @@ describe("account-wide Activity pane", () => { expect(model.items.map((entry) => entry.id)).not.toContain("dismissed"); }); + it("files idle-tier roster history as recent instead of counting it as waiting", () => { + const model = buildActivityPaneModel(snapshot([ + item({ id: "needs", phase: "needs_you", eventKind: "agent_needs_you" }), + item({ + id: "ended", + phase: "completed", + eventKind: "agent_completed", + activityTier: "idle", + }), + item({ id: "idle", phase: "stale", activityTier: "idle" }), + ])); + + expect(model.groups.map((group) => group.label)).toEqual(["NEEDS YOU", "RECENT"]); + expect(model.groups.find((group) => group.label === "RECENT")?.items + .map((entry) => entry.id)).toEqual(["idle", "ended"]); + expect(model.waitingCount).toBe(1); + }); + + it("reports how long a row has held its phase, preferring the publisher's anchor", () => { + const now = Date.parse("2026-07-29T02:00:00.000Z"); + expect(activityItemElapsed( + item({ statusSince: "2026-07-29T00:00:00.000Z", updatedAt: "2026-07-29T01:59:00.000Z" }), + now, + )).toBe("2h ago"); + expect(activityItemElapsed(item({ updatedAt: "2026-07-29T01:30:00.000Z" }), now)) + .toBe("30m ago"); + }); + it("reads Activity through the project-independent machine RPC", async () => { const accountSnapshot = snapshot([item()]); const request = vi.fn(async (method: string, params?: unknown) => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/activityPaneView.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/activityPaneView.test.tsx index 93a9d6072..6d8236a86 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/activityPaneView.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/activityPaneView.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "ink-testing-library"; import type { AttentionItem } from "../../../../desktop/src/shared/types/attention"; import { buildActivityPaneModel } from "../activityPane"; @@ -30,6 +30,7 @@ function attentionItem(): AttentionItem { actions: [], occurredAt: "2026-07-29T00:00:00.000Z", updatedAt: "2026-07-29T00:00:00.000Z", + statusSince: "2026-07-29T00:00:00.000Z", seenAt: null, dismissedAt: null, expiresAt: null, @@ -37,6 +38,15 @@ function attentionItem(): AttentionItem { } describe("ActivityPane", () => { + // Rows carry their age, so the frame is only reproducible against a fixed now. + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-29T02:00:00.000Z")); + }); + afterEach(() => { + vi.useRealTimers(); + }); + it("renders scope, urgency, ownership, offline honesty, and keyboard help", () => { const model = buildActivityPaneModel({ contractVersion: 1, @@ -58,7 +68,7 @@ describe("ActivityPane", () => { content={{ kind: "activity", model }} selectedIndex={0} focused - width={52} + width={64} />, ).lastFrame() ?? ""; @@ -67,9 +77,38 @@ describe("ActivityPane", () => { expect(view).toContain("Showing this machine while you retry."); expect(view).toContain("NEEDS YOU"); expect(view).toContain("Codex needs approval"); - expect(view).toContain("ADE · account-attention · Mac Studio"); + expect(view).toContain("ADE · account-attention · Mac Studio · 2h ago"); expect(view).toContain("offline, last known"); expect(view).toContain("Enter opens exact destination"); expect(view).toContain("R refresh"); }); + + it("keeps the age when the pane is too narrow for the whole project trail", () => { + const model = buildActivityPaneModel({ + contractVersion: 1, + scope: "account", + availability: { + state: "ready", + title: "Account Activity", + message: "Live across your ADE account.", + recovery: null, + }, + streamId: "account-1", + revision: 1, + generatedAt: "2026-07-29T00:00:00.000Z", + items: [attentionItem()], + tombstones: [], + }); + const view = render( + , + ).lastFrame() ?? ""; + + expect(view).toContain("2h ago"); + expect(view).not.toContain("account-attention · Mac Studio"); + }); }); diff --git a/apps/ade-cli/src/tuiClient/activityPane.ts b/apps/ade-cli/src/tuiClient/activityPane.ts index a840feae4..76ebd6366 100644 --- a/apps/ade-cli/src/tuiClient/activityPane.ts +++ b/apps/ade-cli/src/tuiClient/activityPane.ts @@ -4,9 +4,11 @@ import type { } from "../../../desktop/src/shared/types/attention"; import { ATTENTION_CONTRACT_VERSION, + activityItemTier, attentionDestinationDeepLink, sortAttentionItems, } from "../../../desktop/src/shared/types/attention"; +import { formatRelativePastTime } from "./relativeTime"; import type { AdeCodeConnection } from "./types"; export type ActivityPaneGroupId = @@ -227,6 +229,12 @@ export async function acknowledgeActivityItem( } function groupForItem(item: AttentionItem): ActivityPaneGroupId { + // Disk-only roster rows are quiet history: an ended chat still carries phase + // `completed` with no seenAt, which would otherwise file every session the + // account has ever finished under DONE, UNREVIEWED and count it as waiting. + // Desktop files the same rows as the ambient tail — see `activitySectionId` + // in apps/desktop/src/renderer/components/activity/activityPriority.ts. + if (activityItemTier(item) === "idle") return "recent"; if (item.phase === "needs_you" || item.phase === "review_requested" || item.phase === "merge_ready") { return "needs-you"; } @@ -304,6 +312,16 @@ export function activityItemDeepLink(item: AttentionItem): string { return attentionDestinationDeepLink(item.destination, item); } +/** + * How long the row has held its current phase. `statusSince` is the publisher's + * phase anchor, so a long-running agent reads as "2h ago" for the phase rather + * than for its last token; publishers older than this build omit it and + * `updatedAt` is the honest fallback. + */ +export function activityItemElapsed(item: AttentionItem, nowMs = Date.now()): string { + return formatRelativePastTime(item.statusSince ?? item.updatedAt, nowMs); +} + export function activityItemContext(item: AttentionItem): string { return [item.project.name, item.laneName, item.machine.name] .filter((value): value is string => Boolean(value?.trim())) diff --git a/apps/ade-cli/src/tuiClient/components/ActivityPaneView.tsx b/apps/ade-cli/src/tuiClient/components/ActivityPaneView.tsx index 4e7c4e1d1..b50e612e5 100644 --- a/apps/ade-cli/src/tuiClient/components/ActivityPaneView.tsx +++ b/apps/ade-cli/src/tuiClient/components/ActivityPaneView.tsx @@ -4,6 +4,7 @@ import { Box, Text } from "ink"; import type { AttentionItem } from "../../../../desktop/src/shared/types/attention"; import { activityItemContext, + activityItemElapsed, activityPaneEntries, } from "../activityPane"; import { theme } from "../theme"; @@ -84,6 +85,13 @@ export function ActivityPaneView({ } const selected = entry.itemIndex === selectedIndex; const context = activityItemContext(entry.item); + // The age is the one fact a row cannot imply, so it keeps its width and + // the project/lane/machine trail truncates around it. + const elapsed = activityItemElapsed(entry.item); + const metaWidth = Math.max(8, inner - 4); + const meta = context + ? `${endTruncate(context, Math.max(4, metaWidth - elapsed.length - 3))} · ${elapsed}` + : elapsed; return ( - {` ${endTruncate(context, Math.max(8, inner - 4))}`} + {` ${endTruncate(meta, metaWidth)}`} {!entry.item.machine.online ? ( diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index f50f762bd..c4a4fd9a6 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -26,6 +26,7 @@ import type { AutomationSaveDraftResult, } from "../../../shared/types/automations"; import type { + AttentionPreferenceScope, AttentionPreferences, AttentionPresence, } from "../../../shared/types/attention"; @@ -204,6 +205,7 @@ export const ADE_ACTION_CTO_ONLY: Partial }", + example: "ade --role cto actions run attention.putMachinePreferences --input-json '{\"accountOwnerId\":\"user_123\",\"machineKey\":\"machine:abc\",\"preferences\":{\"notificationsEnabled\":false}}' --json", + }, }, project_secret: { list: { @@ -1888,6 +1896,23 @@ function buildAttentionDomainService(runtime: AdeRuntime): OpaqueService | null args.preferences, ); }, + putMachinePreferences: (args?: { + accountOwnerId?: unknown; + machineKey?: unknown; + preferences?: unknown; + }) => { + if (typeof args?.machineKey !== "string" || args.machineKey.length === 0) { + throw new Error("A machineKey is required."); + } + if (!args?.preferences || typeof args.preferences !== "object") { + throw new Error("A valid Activity machine preferences payload is required."); + } + return publisher.putAttentionMachinePreferences( + requireCurrentAccountOwner(args.accountOwnerId), + args.machineKey, + args.preferences as Partial, + ); + }, }; } diff --git a/apps/ios/ADE/Views/Activity/ActivityBellButton.swift b/apps/ios/ADE/Views/Activity/ActivityBellButton.swift index 8f388e455..42ecd9626 100644 --- a/apps/ios/ADE/Views/Activity/ActivityBellButton.swift +++ b/apps/ios/ADE/Views/Activity/ActivityBellButton.swift @@ -47,7 +47,7 @@ struct ActivityBellButton: View { .animation(.snappy(duration: 0.2), value: drawer.unreadCount) .accessibilityLabel( hasUnread - ? "Activity, \(drawer.unreadCount) need you" + ? "Activity, \(drawer.unreadCount) \(drawer.unreadCount == 1 ? "item needs" : "items need") you" : "Activity" ) .accessibilityHint("Opens the Activity drawer.") diff --git a/apps/ios/ADE/Views/Activity/ActivityDrawerSheet.swift b/apps/ios/ADE/Views/Activity/ActivityDrawerSheet.swift index d8c15432e..e29ea84b2 100644 --- a/apps/ios/ADE/Views/Activity/ActivityDrawerSheet.swift +++ b/apps/ios/ADE/Views/Activity/ActivityDrawerSheet.swift @@ -180,6 +180,21 @@ struct ActivityDrawerSheet: View { } .tint(ADEColor.accent) } + // Swipe is not an affordance every input method has. Voice Control, + // Switch Control, and direct-touch users with limited mobility get + // the same two actions here. + .contextMenu { + Button { + drawer.markSeen(row.id) + } label: { + Label("Mark seen", systemImage: "checkmark") + } + Button(role: .destructive) { + drawer.dismiss(row.id) + } label: { + Label("Dismiss", systemImage: "xmark") + } + } } } @@ -200,8 +215,9 @@ struct ActivityDrawerSheet: View { return VStack(spacing: 14) { Spacer() Image(systemName: copy.symbol) - .font(.system(size: 30, weight: .regular)) + .font(.system(.largeTitle, design: .rounded).weight(.regular)) .foregroundStyle(copy.tint) + .accessibilityHidden(true) VStack(spacing: 5) { Text(copy.title) .font(.system(.title3, design: .rounded).weight(.semibold)) @@ -211,6 +227,8 @@ struct ActivityDrawerSheet: View { .foregroundStyle(ADEColor.textSecondary) .multilineTextAlignment(.center) } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(copy.title). \(copy.body)") if drawer.source == .none { Button { Task { await accountService.refreshAttentionSnapshot() } @@ -221,6 +239,7 @@ struct ActivityDrawerSheet: View { .padding(.horizontal, 16) .padding(.vertical, 9) .background(ADEColor.accent.opacity(0.14), in: Capsule()) + .frame(minWidth: 44, minHeight: 44) } .buttonStyle(.plain) } @@ -229,8 +248,9 @@ struct ActivityDrawerSheet: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.horizontal, 32) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(copy.title). \(copy.body)") + // `.contain`, not `.combine`: combining here swallowed the "Try again" + // button, which is the only recovery path when the source is unreachable. + .accessibilityElement(children: .contain) } private var emptyCopy: (symbol: String, tint: Color, title: String, body: String) { @@ -310,8 +330,9 @@ private struct ActivityErrorBanner: View { var body: some View { HStack(spacing: 9) { Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 12, weight: .semibold)) + .font(.system(.caption, design: .rounded).weight(.semibold)) .foregroundStyle(ADESharedTheme.warningAmber) + .accessibilityHidden(true) Text(message) .font(.system(.caption, design: .rounded)) .foregroundStyle(ADEColor.textPrimary) @@ -325,7 +346,8 @@ private struct ActivityErrorBanner: View { RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(ADESharedTheme.warningAmber.opacity(0.28), lineWidth: 0.7) ) - .accessibilityElement(children: .combine) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Error. \(message)") } } @@ -475,15 +497,15 @@ private struct ActivityActionLabel: View { var body: some View { HStack(spacing: 5) { Image(systemName: systemImage) - .font(.system(size: 10, weight: .bold)) + .font(.system(.caption2, design: .rounded).weight(.bold)) + .accessibilityHidden(true) Text(title) .font(.system(.caption, design: .rounded).weight(.semibold)) .lineLimit(1) .minimumScaleFactor(0.76) } .foregroundStyle(variant.foreground) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) + .frame(maxWidth: .infinity, minHeight: 44) .padding(.horizontal, 10) .background(variant.background, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay( diff --git a/apps/ios/ADE/Views/Activity/ActivityRow.swift b/apps/ios/ADE/Views/Activity/ActivityRow.swift index f3d71a7ba..3c4be7085 100644 --- a/apps/ios/ADE/Views/Activity/ActivityRow.swift +++ b/apps/ios/ADE/Views/Activity/ActivityRow.swift @@ -24,6 +24,9 @@ struct ActivityRow: View { /// carries the explanation, so the rows only need to stop competing. var dimmed: Bool = false let onOpen: () -> Void + /// The compact card is a fixed width so the strip scrolls predictably; it + /// still has to grow with the text inside it or the title clips at AX sizes. + @ScaledMetric(relativeTo: .footnote) private var compactCardWidth: CGFloat = 208 var body: some View { Button(action: onOpen) { @@ -37,9 +40,18 @@ struct ActivityRow: View { .accessibilityHint(row.isPullRequest ? "Opens the pull request." : "Opens the session.") } + /// Everything the row shows visually, in words. The offline state is carried + /// only by `dimmed`'s opacity and the plan bar/status note are dropped by + /// `.combine`'s label override, so without them VoiceOver hears strictly + /// less than a sighted reader sees. private var accessibilityLabel: String { var parts = [row.title, row.phaseLabel, row.scopeLabel] + if let note = row.statusNote { parts.append(note) } + if let progress = row.planProgress, progress.total > 0 { + parts.append("step \(progress.completed) of \(progress.total)") + } if let model = row.modelLabel { parts.append(model) } + parts.append(row.machineOnline ? "machine online" : "machine offline") return parts.joined(separator: ", ") } @@ -63,6 +75,7 @@ struct ActivityRow: View { .font(.system(.subheadline, design: .rounded).weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) .lineLimit(1) + .minimumScaleFactor(0.8) Spacer(minLength: 6) ActivityStatusLabel(row: row) } @@ -130,7 +143,8 @@ struct ActivityRow: View { .lineLimit(1) } .padding(11) - .frame(width: 208, alignment: .leading) + .frame(minHeight: 44) + .frame(width: compactCardWidth, alignment: .leading) .background(ADEColor.cardBackground.opacity(0.62), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) @@ -245,7 +259,8 @@ struct ActivityMachineChip: View { var body: some View { HStack(spacing: 4) { Image(systemName: online ? "desktopcomputer" : "wifi.slash") - .font(.system(size: 8, weight: .semibold)) + .font(.system(.caption2, design: .rounded).weight(.semibold)) + .accessibilityHidden(true) Text(lastSeenLabel.map { "\(name) · \($0)" } ?? name) .font(.system(.caption2, design: .rounded).weight(.medium)) .lineLimit(1) @@ -254,6 +269,12 @@ struct ActivityMachineChip: View { .padding(.horizontal, 6) .padding(.vertical, 3) .background(ADEColor.surfaceBackground.opacity(online ? 0.7 : 0.45), in: Capsule()) + .accessibilityElement(children: .ignore) + .accessibilityLabel( + [name, online ? "online" : "offline", lastSeenLabel.map { "last seen \($0)" }] + .compactMap { $0 } + .joined(separator: ", ") + ) } } @@ -309,7 +330,10 @@ struct ActivityPlanProgressBar: View { } } .accessibilityElement(children: .combine) - .accessibilityLabel("Plan progress: \(progress.completed) of \(progress.total)") + .accessibilityLabel( + "Plan progress: \(progress.completed) of \(progress.total)" + + (progress.current.flatMap { $0.isEmpty ? nil : ", \($0)" } ?? "") + ) } } @@ -323,8 +347,9 @@ struct ActivityOfflineMachineBanner: View { var body: some View { HStack(spacing: 7) { Image(systemName: "wifi.slash") - .font(.system(size: 10, weight: .semibold)) + .font(.system(.caption2, design: .rounded).weight(.semibold)) .foregroundStyle(ADEColor.textMuted) + .accessibilityHidden(true) Text(lastSeenLabel.map { "\(machineName) · \($0)" } ?? machineName) .font(.system(.caption2, design: .rounded).weight(.semibold)) .foregroundStyle(ADEColor.textMuted) @@ -334,6 +359,9 @@ struct ActivityOfflineMachineBanner: View { .frame(height: 1) } .accessibilityElement(children: .combine) - .accessibilityLabel("\(machineName) is offline. \(lastSeenLabel ?? "")") + .accessibilityLabel( + lastSeenLabel.map { "\(machineName) is offline. Last seen \($0)." } + ?? "\(machineName) is offline." + ) } } diff --git a/apps/ios/ADE/Views/Hub/HubLiveStrip.swift b/apps/ios/ADE/Views/Hub/HubLiveStrip.swift index a193710ba..4215bc0be 100644 --- a/apps/ios/ADE/Views/Hub/HubLiveStrip.swift +++ b/apps/ios/ADE/Views/Hub/HubLiveStrip.swift @@ -26,6 +26,10 @@ struct HubLiveStrip: View { Spacer(minLength: 0) } .padding(.horizontal, 2) + .accessibilityElement(children: .ignore) + .accessibilityLabel( + "Live now, \(rows.count) \(rows.count == 1 ? "session" : "sessions")" + ) ScrollView(.horizontal) { HStack(spacing: 10) { @@ -44,7 +48,10 @@ struct HubLiveStrip: View { } .scrollIndicators(.hidden) } - .accessibilityLabel("Live now, \(rows.count) sessions") + // A container, not a leaf: the header carries the summary and each + // card stays individually reachable. A bare `.accessibilityLabel` + // here would attach to nothing and never be spoken. + .accessibilityElement(children: .contain) } } diff --git a/apps/ios/ADEWidgets/ADELockScreenWidget.swift b/apps/ios/ADEWidgets/ADELockScreenWidget.swift index 56aa8b840..b25bf850e 100644 --- a/apps/ios/ADEWidgets/ADELockScreenWidget.swift +++ b/apps/ios/ADEWidgets/ADELockScreenWidget.swift @@ -633,9 +633,11 @@ private struct LockScreenRectangularView: View { .font(.system(size: 9, weight: .semibold)) .foregroundStyle(activityToneColor(line.tone)) .widgetAccentable() + .accessibilityHidden(true) Text(line.title) .font(.footnote.weight(.semibold)) .lineLimit(1) + .minimumScaleFactor(0.8) .truncationMode(.tail) Spacer(minLength: 4) Text(line.phaseLabel) diff --git a/apps/push-relay/README.md b/apps/push-relay/README.md index 2c3620ab0..0a4b173c7 100644 --- a/apps/push-relay/README.md +++ b/apps/push-relay/README.md @@ -47,15 +47,39 @@ its own (single D1 database, no Durable Objects, no queues). | POST | `/attention/account/ack` | Mark items seen or dismissed across devices | | POST | `/attention/account/presence` | Report foreground/ambient-surface presence for desktop-first escalation | | GET, PUT | `/attention/account/preferences` | Read or replace account notification preferences | +| PATCH | `/attention/account/preferences/devices/:deviceId` | Merge one device's overrides without rewriting the document | +| PATCH | `/attention/account/preferences/machines/:machineKey` | Merge one machine's overrides (this is what "mute this Mac" writes) | | PATCH | `/attention/account/preferences/devices/:deviceId` | Atomically merge one device's preference override without overwriting concurrent account or other-device changes | | PUT, DELETE | `/attention/account/devices/:deviceId` | Register or remove an account APNs destination. JSON must include a positive monotonic `ownershipEpoch`; stale account requests receive `409` with the latest `ownershipEpoch`. Omitting `pushToStartToken` preserves it; `clearPushToStartToken: true` removes it. DELETE retains the ownership epoch so delayed requests cannot reclaim the install. | | PUT, DELETE | `/attention/account/devices/:deviceId/activities/:activityId` | Register or remove an account Live Activity update token | ### Account Activity semantics -- Each brain publishes one bounded full snapshot for its machine. The worker - merges every linked machine into a revisioned account stream, including - tombstones so desktop and iOS converge after removals. +- The worker merges every linked machine into a revisioned account stream, + including tombstones so desktop and iOS converge after removals. +- **Publish protocol 2.** Every publish response carries `protocol: 2` plus the + current `acks`, so a reconnecting brain learns what other devices already + dismissed without waiting for its own read. `POST /machines/:key/attention` + takes a `mode`: `reconcile` (full roster, paged, `final: true` on the last + page), `delta` (changed items only), or `presence` (no items — it holds + presence and lets a due alert retry). Each publish stamps a monotonic + `rosterEpoch`; a reconcile's `final` page seals it, and anything left on an + older epoch for that machine is dropped in one commit. A delta reuses the + epoch and so never implies a deletion. A truncating publish answers + `itemsTruncated` so the brain schedules a fresh reconcile. Publishers that + predate this keep sending full snapshots and still work. +- **Two fingerprints.** `contentFingerprint` is what the row looks like with + progress churn normalized away — an unchanged one skips the write entirely. + `alertFingerprint` is the stable identity of one phase entry and survives the + item being removed and republished, which is what stops a reconnect from + re-alerting. Legacy publishers send neither and fall back to `fingerprint`. +- **Alerting gates.** Only items whose `activityTier` is `signal` may notify, + and only if `updatedAt` is within 15 minutes — so a machine returning from + offline never fires its recovered backlog. Sends are claimed by a short-lived + delivery receipt, then recorded in `attention_alert_log` (account + alert + fingerprint + device, retained 30 days) so a delete-and-republish cannot + re-alert after the 7-day receipt is pruned. Alert payloads also set + `content-available` as an opportunistic background refresh. - Seen and dismissed state belongs to the account, so acknowledging an item on iPhone clears it on desktop and vice versa. - Routine running/progress state remains ambient. Needs-input, failure, @@ -67,9 +91,11 @@ its own (single D1 database, no Durable Objects, no queues). start that never committed after a transient APNs failure. Once a start succeeds, durable state plus the content fingerprint suppress duplicates. - Device-registration preferences are compatibility fallbacks. Account - preferences override them; only an explicit account - `devices[deviceId]` override may supersede the account defaults for one - device. Phone settings use the scoped device PATCH, while account/project + preferences override them; only an explicit account `devices[deviceId]` + override may supersede the account defaults for one device. The document also + carries `project` and `machines` scopes — the latter keyed by machine key, so + muting one Mac silences its items everywhere rather than muting a category on + one phone. Phone settings use the scoped device PATCH, while account/project writes omit and preserve `devices`, so concurrent clients cannot erase one another's policy. - The relay owns one account-wide Live Activity per iPhone. It focuses the diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index fc7328d15..a3751edb9 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -215,7 +215,7 @@ Renderer — settings: resolution, and search all resolve through `settings/settingsManifest.ts`, which is also what generates the Cmd-K entries. The ten tabs are General, Appearance, Agents & Models, - Lanes & Git, Integrations, Activity, Notifications & Sound, Secrets, + Lanes & Git, Integrations, Notifications & Sound, Activity, Secrets, Storage & Diagnostics, and Stats. Every tab id ADE has ever shipped in a URL still resolves via `LEGACY_TAB_ALIASES` (`settingsManifest.test.ts` asserts this); the one exception is diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index a18396cf2..e740da158 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -1189,12 +1189,63 @@ Account Activity and push: desktop account-first read/ack/presence/preferences coordinator. It bypasses the selected project or remote-machine binding and uses the local machine runtime only as an explicitly labeled fallback. +- `apps/ade-cli/src/services/push/activityFingerprint.ts` — the two identities + every item carries. The *content* fingerprint is what the row looks like with + elapsed durations and token/file counters normalized away, so progress churn + does not rewrite account state; the *alert* fingerprint is the stable identity + of one phase entry, so a re-published item cannot re-alert a phone that + already heard about it. - `apps/desktop/src/shared/types/attention.ts` — cross-client item, snapshot, destination, availability, preference, and native-presentation contract. -- `apps/desktop/src/renderer/components/activity/` and - `apps/desktop/src/renderer/state/activityStore.ts` — global header popover, - two-column Activity pane, account-switch/revision-safe mutations, and - renderer-to-native snapshot feed. + `ATTENTION_CONTRACT_VERSION` is the *item* contract; the publish protocol + version is separate (see `push-notifications.md`). +- `apps/desktop/src/shared/activityCatalog.ts` — one table naming every + Activity event: its group (agents / pull requests), its icon key, and its + default delivery policy. Desktop settings, the Activity columns, and the + delivery defaults read this instead of each keeping a private switch. +- `apps/desktop/src/renderer/state/activityStore.ts` — the renderer's account + snapshot, with account-switch and source-revision fences on every mutation. +- `apps/desktop/src/renderer/components/activity/useActivitySync.ts` — the + single account poller, mounted in `AppShell` so the header control and ADE + Notch stay truthful while `/activity` is closed. It also derives the notch + toast stream. +- `apps/desktop/src/renderer/components/activity/HeaderActivityControl.tsx` — + the global-header count and its popover preview of both buckets. +- `apps/desktop/src/renderer/components/activity/ActivityPane.tsx` — the + `/activity` two-column pane, with `ActivitySessionsColumn.tsx` (Needs you / + Working / Done, split per machine and divided where an offline machine's rows + become last-known state), `ActivityInboxColumn.tsx` (PR/CI and other + outcomes), `ActivityFilters.tsx` (machine / chat type / model, every option + derived from the snapshot on screen), and `ActivityDetailSheet.tsx`. +- `apps/desktop/src/renderer/components/activity/ActivityCard.tsx` and + `ActivityCardSkeleton.tsx` — the row and its fixed-height placeholder. The + card deliberately does **not** reuse `terminals/SessionCard`: an Activity row + frequently belongs to another machine, and `SessionCard`'s settle/snooze + controls call this Mac's local session service, where a non-unique session id + could land the mutation on a same-id local session. The status vocabulary is + shared instead through the pure `terminals/SessionStatusLabel.tsx`, extracted + from `SessionStatusSlot` for exactly this reason. Read the comment at the top + of `ActivityCard.tsx` before "simplifying" it. +- `apps/desktop/src/renderer/components/activity/activityPriority.ts` and + `activityPresentation.ts` — section assignment (Needs you / Working / Done) + and the per-item label/tone/glyph derivation. +- `apps/desktop/src/renderer/components/activity/useProgressiveRows.ts` — the + bounded row budget (60, stepped by 60) that keeps long columns cheap. +- `apps/desktop/src/renderer/components/activity/activityNotchLocalSettings.ts` + — this Mac's offline cache of the notch presentation. Account preferences win + when loaded. The three original `ade:attention:notch-*` localStorage keys are + frozen wire for anyone who already made a choice; new settings got new keys. +- `apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.tsx` — + the gear in both the popover and the pane. It mounts + `settings/ActivitySettingsControls.tsx` in its `popover` variant, which + `settings/ActivitySection.tsx` also mounts, so the Settings tab and the + in-surface gear cannot drift. Every row saves on change; there is no Save + button, which the popover it replaced did have. +- `apps/desktop/src/renderer/lib/legacyRoutes.ts` — `LEGACY_ROUTE_ALIASES` + maps `/attention` to `/activity`. ADE's shell matches top-level surfaces with + pathname predicates rather than `` elements, so there is no + router-level redirect to hang a rename on; this is the route-level twin of + `settingsManifest.ts`'s `LEGACY_TAB_ALIASES`. - `apps/desktop/src/renderer/webclient/adapter/attention.ts` — direct browser account-relay reader plus signed-out paired-host fallback through `attention.getMachineSnapshot` / `attention.acknowledgeMachine`. diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 9c4b11233..2d92fc611 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -269,8 +269,22 @@ apps/ios/ │ │ ├── ADEAgentActivityAttributes.swift # account-wide ActivityKit │ │ │ # content-state + exact machine links + │ │ │ # non-PII ownership-epoch fence +│ │ ├── ActivityRowPresentation.swift # pure item → label/tone/glyph/elapsed +│ │ │ # mapper; the iOS mirror of desktop +│ │ │ # sessionStatusPresentation.ts + +│ │ │ # activityPresentation.ts. No SwiftUI — +│ │ │ # tones are tokens. Compiles into the +│ │ │ # widget extension, so iOS 17 only. +│ │ ├── ActivityWidgetPresentation.swift # tone → colour binding and the +│ │ │ # lock-screen ranking, shared by the app and +│ │ │ # the widget so the two cannot describe the +│ │ │ # same session differently. iOS 17 only. │ │ └── AttentionActionIntents.swift # widget actions for approve/deny/restart/retry │ ├── Views/ +│ │ ├── Activity/ # ActivityDrawerSheet (global account-wide +│ │ │ # Sessions/Inbox drawer), ActivityDrawerModel +│ │ │ # (snapshot + local dismissals + acks), +│ │ │ # ActivityRow, ActivityBellButton │ │ ├── Account/ # account choice/sign-in plus the mobile │ │ │ # access gate and connections section │ │ ├── Components/ # ADEDesignSystem (incl. ADEConnectionDot, @@ -294,7 +308,10 @@ apps/ios/ │ │ │ # (HubInlineComposer — inline keyboard │ │ │ # composer, not a modal drawer), │ │ │ # HubScreen+ChatNavigation (chat open + -│ │ │ # cross-project quick look) +│ │ │ # cross-project quick look), +│ │ │ # HubLiveStrip ("Live now" — agents working +│ │ │ # across every account machine, read from +│ │ │ # ActivityDrawerModel; hidden when empty) │ │ ├── PersonalChats/ # Hub-only projectless chat list, │ │ │ # new-chat model composer, and reused │ │ │ # Work transcript destination adapter @@ -380,7 +397,16 @@ apps/ios/ │ │ │ # WorkSessionDestination*, │ │ │ # WorkRootScreen+Selection (multi-select state + │ │ │ # bulk close/archive/restore/delete/export), -│ │ │ # WorkSelectionActionBar, etc. +│ │ │ # WorkSelectionActionBar, +│ │ │ # WorkLaneOrder (pure lane ordering + the +│ │ │ # singleton/headerless rule; the port of +│ │ │ # desktop workLaneOrder.ts and the +│ │ │ # headerlessLaneIds memo. Models manual +│ │ │ # drag and handoff jobs even though iOS +│ │ │ # has neither yet — they are the two rules +│ │ │ # that decide whether a lane keeps its +│ │ │ # header, and dropping them is how a port +│ │ │ # silently loses a rule later), etc. │ │ ├── Linear/ # LinearPaneSheet, issue list/detail screens, │ │ │ # launch config, brand/logo paths, pane store │ │ │ # and toolbar button. Uses existing cto.* read @@ -1377,6 +1403,17 @@ Each row uses the shared item destination and actions: - **CI failing / review requested / merge ready** — exact PR tab navigation. - **Completed / merged** — retained in Recent until seen or dismissed. +Row vocabulary is derived once, in `Shared/ActivityRowPresentation.swift` — a +pure item-to-label/tone/glyph/elapsed mapper with no SwiftUI in it — and the +tone-to-colour binding plus the lock-screen ranking live beside it in +`Shared/ActivityWidgetPresentation.swift`. Both compile into the widget +extension as well as the app, which is what keeps the lock screen from +describing a session in words and colours the app does not use; it also means +both files are pinned to the extension's iOS 17 deployment target. The Hub's +"Live now" strip (`Views/Hub/HubLiveStrip.swift`) is a third reader of the same +model, showing agents working on any account machine and hiding itself entirely +when none are. + The drawer uses the same one-hue-one-meaning contract as the widget and desktop Activity pane. `blocked` is a neutral Working item with an Open action, distinct from the amber `awaitingInput` kind; running uses the shared dotted-circle glyph, and a diff --git a/docs/features/sync-and-multi-device/push-notifications.md b/docs/features/sync-and-multi-device/push-notifications.md index 452dc043d..8e91292f9 100644 --- a/docs/features/sync-and-multi-device/push-notifications.md +++ b/docs/features/sync-and-multi-device/push-notifications.md @@ -65,7 +65,8 @@ The TypeScript source of truth is An `AttentionItem` includes: -- stable `id`, source `revision`, `fingerprint`, occurrence/update/expiry time; +- stable `id`, source `revision`, occurrence/update/expiry time; +- two fingerprints and an activity tier (see below); - kind, event, and phase; - machine and project identity; - optional lane, provider, model, plan progress, and recent activity; @@ -75,8 +76,32 @@ An `AttentionItem` includes: seen, and dismiss; - `seenAt` and `dismissedAt` acknowledgment state. -Contract version 1 limits text, actions, progress counts, snapshots, and -tombstones before data is stored or delivered. Relay validation also enforces: +### Two fingerprints and the activity tier + +An item carries a **content** fingerprint and an **alert** fingerprint, derived +in `apps/ade-cli/src/services/push/activityFingerprint.ts`. They answer two +different questions and are deliberately not the same value: + +- The content fingerprint is *what the row looks like* — identity, phase, lane, + provider, model, title, destination, action ids, plan progress, and the + preview with elapsed durations and token/file counters normalized away. A + running agent whose preview ticks from "12s" to "13s" therefore produces an + unchanged snapshot, and the relay writes nothing. +- The alert fingerprint is *the stable identity of one phase entry* — for a PR, + the item, event, phase, `statusSince`, and PR number. It survives the item + being removed and republished, which is what stops a reconnecting machine + from re-alerting a phone about work it already announced. + +`activityTier` (`signal` / `ambient` / `idle`) is the item's own claim about +whether it is worth interrupting for. Only `signal` items are eligible to +notify. Legacy publishers omit both fingerprints and the tier; the relay falls +back to the single `fingerprint` for each and treats a missing tier as +alertable. + +Contract version 1 (`ATTENTION_CONTRACT_VERSION`) limits text, actions, +progress counts, snapshots, and tombstones before data is stored or delivered. +It versions the *item shape*; the publish protocol is versioned separately (see +"Publish protocol 2" below). Relay validation also enforces: - agent ids/events cannot masquerade as PR ids/events, and vice versa; - the item id and embedded machine identity must match the authenticated @@ -142,6 +167,8 @@ POST /attention/account/ack POST /attention/account/presence GET /attention/account/preferences PUT /attention/account/preferences +PATCH /attention/account/preferences/devices/:deviceId +PATCH /attention/account/preferences/machines/:machineKey PUT /attention/account/devices/:deviceId DELETE /attention/account/devices/:deviceId PUT /attention/account/devices/:deviceId/activities/:activityId @@ -183,6 +210,36 @@ The publisher: - skips duplicate legacy notifications and Live Activities after a successful account publish. +### Publish protocol 2 + +Every publish response carries a `protocol` number, and the publisher records +the highest one the relay has reported. Protocol 2 replaces "always send the +whole machine" with three modes on `POST /machines/:machineKey/attention`: + +| Mode | When | What it sends | +| --- | --- | --- | +| `reconcile` | first publish after start, after an account change, and after any cap shrink | the full roster, paged, with `final: true` on the last page | +| `delta` | ordinary changes | only the items that changed, paged if they exceed one wire page | +| `presence` | the 30 s heartbeat with nothing to say | no items — it exists to hold presence and to let a due alert retry | + +Each publish stamps a monotonic `rosterEpoch`. A `reconcile` run bumps the +epoch, and its `final` page seals it: anything still carrying an older epoch for +that machine is state the machine no longer claims, so it is removed in one +commit rather than by inference from an absent id. A `delta` reuses the current +epoch and therefore never implies a deletion, which is what makes it safe to +send a partial list at all. + +The relay echoes current acknowledgment state (`acks`) on every publish, +including the no-op paths, so a brain that came back from a disconnect learns +what other devices already dismissed without waiting for its own read. If the +account item cap truncates the publish, the response says `itemsTruncated` and +the publisher schedules a fresh reconcile rather than leaving the relay holding +a silently trimmed roster. + +A relay that reports `protocol` below 2 does not understand any of this. The +publisher notices, falls back to the legacy full-snapshot publish, and keeps a +reconcile pending so the first protocol-2 response resynchronizes cleanly. + The paired-machine compatibility publisher tracks Live Activity delivery per phone. A failed start, update, or end retries only that phone while healthy phones continue receiving new content, and relay suppression is keyed per @@ -200,7 +257,10 @@ Balanced defaults: | Review requested / merge ready | Notify | | Completed / merged / opened / closed | Ambient | -Preferences support account defaults plus device and project overrides: +Preferences support account defaults plus device, project, and machine +overrides. The `machines` scope is keyed by machine key and is what "mute this +Mac" writes: it silences one machine's items everywhere rather than muting a +category on one phone. Its size is capped like the other scopes: - event delivery policies; - notifications; @@ -227,10 +287,27 @@ When desktop-first delivery is enabled and a foreground Mac recently reported presence, the relay waits for the configured bounded delay before notifying the phone. The next machine heartbeat escalates an item that remains unseen. -Notification delivery is receipt-deduped per item/device/fingerprint. Quiet -hours, muted sessions, preview privacy, sound, and exact deep links are applied -before APNs fan-out. `needs_you` can use time-sensitive interruption; other -notifying events use active interruption. +Two gates run before any preference is consulted, because they are about +whether the item deserves an interruption at all: + +- **Tier.** An item whose `activityTier` is not `signal` never alerts. +- **Staleness.** An item whose `updatedAt` is more than 15 minutes old never + alerts. This is what makes a reconnect safe: a machine that was offline + republishes its roster, and none of that recovered backlog fires a push. + +Notification delivery is then deduped twice. A short-lived per +item/device/state delivery receipt claims the send, so two concurrent publishes +cannot both notify. Behind it, a durable **alert log** keyed by account + alert +fingerprint + device records what each phone was actually told, and is retained +for 30 days — well past the item's own lifetime. Deleting and republishing an +item therefore cannot re-alert, which the receipt alone could not prevent +because receipts are keyed by item id and pruned at 7 days. + +Quiet hours, muted sessions, preview privacy, sound, and exact deep links are +applied before APNs fan-out. `needs_you` can use time-sensitive interruption; +other notifying events use active interruption. Alert pushes also carry +`content-available`, so the visible alert doubles as a background wake for a +snapshot refresh — foreground polling remains the guaranteed path, not this. ## Desktop Activity @@ -390,6 +467,21 @@ ADE destinations are validated before the desktop navigates. The mobile app stores the account snapshot in the App Group container using the same delta/tombstone/expiry rules as desktop. +A signed-in app polls the account snapshot every 20 s while it is foreground, +and stops on background or sign-out. Each start bumps a generation counter that +the loop rechecks after every sleep, so repeated starts cannot leave two pollers +running and a stopped poller cannot resume after its account changed. This poll +is the guaranteed freshness path; the `content-available` flag on alert pushes +is an opportunistic wake on top of it, not a substitute. + +Acknowledgments made while the relay is unreachable go to an App Group-backed +**pending-ack queue** partitioned by account owner, and drain on the next +successful refresh. Reads normalize duplicate item ids, so a crash between +enqueue and cleanup cannot multiply relay writes. The queue is bounded three +ways — 200 entries per owner, 24 hours of age, and 5 failed attempts per entry — +so an acknowledgment the relay will never accept expires instead of retrying +forever. + The global Activity drawer shows all signed-in machines and projects. Project drawers are lenses over that same account model, not separate notification inboxes. Tapping an item follows its exact destination. Remote items expose only diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 11727b149..b5544fb79 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -296,9 +296,16 @@ Shared types and IPC: clamped to the viewport like `SessionContextMenu`, with no document-level listener. Already-snoozed rows offer **Wake now** instead of the duration list. +- `apps/desktop/src/renderer/components/terminals/SessionStatusLabel.tsx` — + the pure label half of the slot below: shared glyph id to Phosphor icon, tone + class, and the elapsed/countdown text. It was extracted so the account-wide + Activity card can speak the same status vocabulary **without** inheriting the + slot's mutation controls, which act on this Mac's local session service and + would be wrong — sometimes destructively so — on a row that belongs to + another machine. Anything both surfaces must agree on belongs here. - `apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx` — the row's single status surface and no-layout-shift hover/focus action swap. - It maps shared presentation glyph ids to Phosphor icons, ticks active + It renders `SessionStatusLabel` and adds the mutations: it ticks active chat Working/Planning elapsed time from immutable `currentTurnStartedAt`, falling back to last activity for legacy rows, keeps CLI/Stale elapsed time on last activity, ticks idle scheduled-work countdowns, and diff --git a/docs/logging.md b/docs/logging.md index 25cf76b5a..634faea76 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -134,8 +134,9 @@ blocked reasons, retries, or scheduled review scans. The existing `ade_feature_used` limits cap it at 30 accepted events per minute and 140 per UTC day without raising the shared 200-event ceiling. -Opening the account-wide Attention control records the existing -`ade_feature_used` event with `feature: "attention"`, +Opening the account-wide Activity control (renamed from "Attention" in the UI; +the analytics taxonomy deliberately keeps the frozen `attention` keys) records +the existing `ade_feature_used` event with `feature: "attention"`, `action: "header_opened"`, `outcome: "opened"`, and `source: "renderer_route"`. The renderer emits no item, machine, project, session, notification, or error data. A persisted one-hour deduplication key