diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index 6c71185..18d2a9f 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -2879,14 +2879,18 @@ struct OperatorLaneHeaderReadoutView: View { .foregroundStyle(PanelPalette.primaryText(colorScheme).opacity(0.94)) .lineLimit(1) .truncationMode(.tail) - .layoutPriority(1) + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer(minLength: 8) if let project = panelTrimmed(project) { Text(project) .font(PanelFont.laneDetail) .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.82)) .lineLimit(1) + .truncationMode(.middle) .fixedSize(horizontal: true, vertical: false) + .frame(alignment: .trailing) .help(project) } } diff --git a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift index b8d67cd..d475dfd 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift @@ -16,7 +16,7 @@ final class AccountStore: ObservableObject { private let bridge = DecodexAppBridge() private var automaticRefreshTask: Task? private var operatorSnapshotStreamTask: Task? - private var pendingRunActivity: OperatorRunActivitySnapshot? + private var liveRunActivity: OperatorRunActivitySnapshot? deinit { automaticRefreshTask?.cancel() @@ -155,7 +155,7 @@ final class AccountStore: ObservableObject { do { try await connectOperatorSnapshotStream() } catch { - pendingRunActivity = nil + liveRunActivity = nil } do { @@ -204,7 +204,7 @@ final class AccountStore: ObservableObject { return try JSONDecoder().decode(OperatorDashboardSocketEvent.self, from: data) } - private func applyOperatorDashboardEvent(_ event: OperatorDashboardSocketEvent) { + func applyOperatorDashboardEvent(_ event: OperatorDashboardSocketEvent) { guard let payload = event.payload else { return } @@ -215,16 +215,8 @@ final class AccountStore: ObservableObject { return } - let snapshotPublishedAt = payload.snapshotPublishedAt ?? Date() - if let pendingRunActivity, - pendingRunActivity.shouldOverlay(snapshotPublishedAt: snapshotPublishedAt) - { - operatorSnapshot = pendingRunActivity.merging(into: snapshot) - } else { - operatorSnapshot = snapshot - pendingRunActivity = nil - } - operatorSnapshotUpdatedAt = snapshotPublishedAt + operatorSnapshot = liveRunActivity?.merging(into: snapshot) ?? snapshot + operatorSnapshotUpdatedAt = payload.snapshotPublishedAt ?? Date() case "runActivity": guard let activeRuns = payload.activeRuns else { return @@ -235,20 +227,7 @@ final class AccountStore: ObservableObject { activeRunsComplete: payload.activeRunsComplete ?? true, emittedAt: payload.emittedAt ?? Date() ) - if let operatorSnapshotUpdatedAt, - activity.shouldOverlay(snapshotPublishedAt: operatorSnapshotUpdatedAt) == false - { - pendingRunActivity = nil - - return - } - guard activity.shouldApply(to: operatorSnapshot) else { - pendingRunActivity = nil - - return - } - - pendingRunActivity = activity + liveRunActivity = activity if let operatorSnapshot { self.operatorSnapshot = activity.merging(into: operatorSnapshot) } diff --git a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift index f942de6..805f31b 100644 --- a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift +++ b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift @@ -589,28 +589,9 @@ struct OperatorRunActivitySnapshot: Sendable { let activeRunsComplete: Bool let emittedAt: Date - func shouldOverlay(snapshotPublishedAt: Date?) -> Bool { - guard let snapshotPublishedAt else { - return true - } - - return emittedAt > snapshotPublishedAt - } - func merging(into snapshot: OperatorSnapshotResponse) -> OperatorSnapshotResponse { snapshot.mergingRunActivity(activeRuns, activeRunsComplete: activeRunsComplete) } - - func shouldApply(to snapshot: OperatorSnapshotResponse?) -> Bool { - guard let snapshot else { - return true - } - if activeRunsComplete, activeRuns.isEmpty, snapshot.activeRuns.isEmpty == false { - return false - } - - return true - } } struct OperatorChildAgentActivity: Decodable, Sendable { diff --git a/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift b/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift index 6b38e75..3d790bd 100644 --- a/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift +++ b/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift @@ -202,53 +202,56 @@ final class AccountModelTests: XCTestCase { XCTAssertTrue(snapshot.activeRuns(for: poolOnlyAccount).isEmpty) } - func testOperatorRunActivityOverlayDoesNotReplaceNewerSnapshot() throws { + @MainActor + func testOperatorRunActivityUsesStreamOrderOverSnapshotTimestamp() throws { let account = makeAccount( status: "available", email: "copy@example.com", accountFingerprint: "...123456" ) - let snapshotPayload = """ - { - "active_runs": [ - { - "run_id": "run-new", - "issue_identifier": "XY-672", - "account": { - "email": "copy@example.com", - "account_fingerprint": "...123456" - } - } - ] - } - """.data(using: .utf8)! - let activityPayload = """ - { - "activeRuns": [ - { - "run_id": "run-old", - "issue_identifier": "PUB-1147", - "account": { - "email": "copy@example.com", - "account_fingerprint": "...123456" - } - } - ] - } - """.data(using: .utf8)! - - let snapshot = try JSONDecoder().decode(OperatorSnapshotResponse.self, from: snapshotPayload) - let activity = try JSONDecoder() - .decode(OperatorDashboardSocketPayload.self, from: activityPayload) - .activeRuns ?? [] - let overlay = OperatorRunActivitySnapshot( - activeRuns: activity, - activeRunsComplete: true, - emittedAt: Date(timeIntervalSince1970: 10) - ) - - XCTAssertFalse(overlay.shouldOverlay(snapshotPublishedAt: Date(timeIntervalSince1970: 20))) - XCTAssertEqual(snapshot.activeRuns(for: account).map(\.runID), ["run-new"]) + let store = AccountStore() + + try store.applyOperatorDashboardEvent(dashboardEvent( + type: "snapshot", + payload: """ + { + "snapshotPublishedAtUnixEpoch": 20, + "snapshot": { + "active_runs": [ + { + "run_id": "run-new", + "issue_identifier": "XY-672", + "account": { + "email": "copy@example.com", + "account_fingerprint": "...123456" + } + } + ] + } + } + """ + )) + try store.applyOperatorDashboardEvent(dashboardEvent( + type: "runActivity", + payload: """ + { + "emittedAtUnixEpoch": 10, + "activeRunsComplete": true, + "activeRuns": [ + { + "run_id": "run-old", + "issue_identifier": "PUB-1147", + "account": { + "email": "copy@example.com", + "account_fingerprint": "...123456" + } + } + ] + } + """ + )) + + XCTAssertEqual(store.operatorSnapshot?.activeRuns(for: account).map(\.runID), ["run-old"]) } func testPartialRunActivityPreservesSnapshotActiveRuns() throws { @@ -307,7 +310,6 @@ final class AccountModelTests: XCTestCase { ) let merged = overlay.merging(into: snapshot) - XCTAssertTrue(overlay.shouldOverlay(snapshotPublishedAt: Date(timeIntervalSince1970: 20))) XCTAssertEqual(merged.activeRuns.map(\.runID), ["run-689", "run-690"]) XCTAssertEqual(merged.activeRuns(for: account).map(\.runID), ["run-689", "run-690"]) } @@ -401,7 +403,6 @@ final class AccountModelTests: XCTestCase { ) let merged = overlay.merging(into: snapshot) - XCTAssertTrue(overlay.shouldOverlay(snapshotPublishedAt: Date(timeIntervalSince1970: 20))) XCTAssertEqual(merged.activeRuns.map(\.runID), ["run-690"]) XCTAssertEqual(merged.activeRuns(for: account).map(\.runID), ["run-690"]) } @@ -434,30 +435,104 @@ final class AccountModelTests: XCTestCase { ) let merged = overlay.merging(into: snapshot) - XCTAssertTrue(overlay.shouldOverlay(snapshotPublishedAt: Date(timeIntervalSince1970: 20))) XCTAssertTrue(merged.activeRuns(for: account).isEmpty) } - func testCompleteEmptyRunActivityWaitsForSnapshotBeforeClearingVisibleRuns() throws { - let snapshotPayload = """ - { - "active_runs": [ - { - "run_id": "run-old", - "issue_identifier": "XY-672" - } - ] - } - """.data(using: .utf8)! - let snapshot = try JSONDecoder().decode(OperatorSnapshotResponse.self, from: snapshotPayload) - let overlay = OperatorRunActivitySnapshot( - activeRuns: [], - activeRunsComplete: true, - emittedAt: Date(timeIntervalSince1970: 30) + @MainActor + func testLiveRunActivitySurvivesNewerEmptySnapshot() throws { + let account = makeAccount( + status: "available", + email: "copy@example.com", + accountFingerprint: "...123456" ) + let store = AccountStore() + + try store.applyOperatorDashboardEvent(dashboardEvent( + type: "snapshot", + payload: """ + { + "snapshotPublishedAtUnixEpoch": 20, + "snapshot": { + "active_runs": [] + } + } + """ + )) + try store.applyOperatorDashboardEvent(dashboardEvent( + type: "runActivity", + payload: """ + { + "emittedAtUnixEpoch": 30, + "activeRunsComplete": true, + "activeRuns": [ + { + "run_id": "run-live", + "issue_identifier": "XY-672", + "account": { + "email": "copy@example.com", + "account_fingerprint": "...123456" + } + } + ] + } + """ + )) + try store.applyOperatorDashboardEvent(dashboardEvent( + type: "snapshot", + payload: """ + { + "snapshotPublishedAtUnixEpoch": 40, + "snapshot": { + "active_runs": [] + } + } + """ + )) + + XCTAssertEqual(store.operatorSnapshot?.activeRuns(for: account).map(\.runID), ["run-live"]) + } - XCTAssertFalse(overlay.shouldApply(to: snapshot)) - XCTAssertTrue(overlay.shouldApply(to: nil)) + @MainActor + func testCompleteEmptyRunActivityClearsLiveRuns() throws { + let account = makeAccount( + status: "available", + email: "copy@example.com", + accountFingerprint: "...123456" + ) + let store = AccountStore() + + try store.applyOperatorDashboardEvent(dashboardEvent( + type: "snapshot", + payload: """ + { + "snapshotPublishedAtUnixEpoch": 20, + "snapshot": { + "active_runs": [ + { + "run_id": "run-live", + "issue_identifier": "XY-672", + "account": { + "email": "copy@example.com", + "account_fingerprint": "...123456" + } + } + ] + } + } + """ + )) + try store.applyOperatorDashboardEvent(dashboardEvent( + type: "runActivity", + payload: """ + { + "emittedAtUnixEpoch": 30, + "activeRunsComplete": true, + "activeRuns": [] + } + """ + )) + + XCTAssertTrue(store.operatorSnapshot?.activeRuns(for: account).isEmpty ?? false) } func testOperatorSnapshotWarningSummaryUsesRawWarningToken() throws { @@ -472,6 +547,20 @@ final class AccountModelTests: XCTestCase { XCTAssertEqual(snapshot.warningSummary, "external_observer_status_skipped") } + private func dashboardEvent( + type: String, + payload: String + ) throws -> OperatorDashboardSocketEvent { + let data = """ + { + "type": "\(type)", + "payload": \(payload) + } + """.data(using: .utf8)! + + return try JSONDecoder().decode(OperatorDashboardSocketEvent.self, from: data) + } + private func makeAccount( status: String, email: String = "copy@example.com",