diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index d7548daa0..5eb790ca0 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -3132,7 +3132,6 @@ struct OperatorLanePopoverView: View { return [ contextMetric(lifecycleMetrics), - toolsMetric(lifecycleMetrics), trackerMetric(lifecycleMetrics), protocolMetric(lifecycleMetrics), ].compactMap { $0 } @@ -3166,31 +3165,6 @@ struct OperatorLanePopoverView: View { ) } - private func toolsMetric(_ metrics: OperatorLifecycleMetrics) -> OperatorTotalMetric? { - var items = [OperatorLaneReadoutItem]() - if metrics.toolCallCount > 0 { - items.append(OperatorLaneReadoutItem(label: "tools", value: formatCompactCount(metrics.toolCallCount))) - } - - if let largestOutput = metrics.largestToolOutputBytes, largestOutput > 0 { - items.append( - OperatorLaneReadoutItem( - label: "max output", - value: formatLargestOutput(bytes: largestOutput) - ) - ) - } - - guard items.isEmpty == false else { - return nil - } - - return OperatorTotalMetric( - title: "Tools", - items: items - ) - } - private func trackerMetric(_ metrics: OperatorLifecycleMetrics) -> OperatorTotalMetric? { guard let bucket = lifecycleBucket(named: "Tracker", in: metrics.buckets) else { return nil diff --git a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift index d4d2005e9..587f49244 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift @@ -231,12 +231,12 @@ final class AccountStore: ObservableObject { activeRunsComplete: payload.activeRunsComplete ?? true, emittedAt: payload.emittedAt ?? Date() ) - liveRunActivity = activity if let operatorSnapshot { self.operatorSnapshot = activity.merging(into: operatorSnapshot) } else { operatorSnapshot = OperatorSnapshotResponse.activeRunsOnly(activeRuns) } + liveRunActivity = activity.shouldPersistAsSnapshotOverlay ? activity : nil operatorSnapshotUpdatedAt = activity.emittedAt default: break diff --git a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift index 3959159e1..b4ba73a81 100644 --- a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift +++ b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift @@ -797,6 +797,10 @@ struct OperatorRunActivitySnapshot: Sendable { let activeRunsComplete: Bool let emittedAt: Date + var shouldPersistAsSnapshotOverlay: Bool { + activeRuns.isEmpty == false || activeRunsComplete == false + } + func merging(into snapshot: OperatorSnapshotResponse) -> OperatorSnapshotResponse { snapshot.mergingRunActivity(activeRuns, activeRunsComplete: activeRunsComplete) } diff --git a/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift b/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift index f3549821d..f9cb1635f 100644 --- a/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift +++ b/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift @@ -866,6 +866,71 @@ final class AccountModelTests: XCTestCase { XCTAssertTrue(store.operatorSnapshot?.activeRuns(for: account).isEmpty ?? false) } + @MainActor + func testCompleteEmptyRunActivityDoesNotKeepClearingNewSnapshots() 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) + + try store.applyOperatorDashboardEvent(dashboardEvent( + type: "snapshot", + payload: """ + { + "snapshotPublishedAtUnixEpoch": 40, + "snapshot": { + "active_runs": [ + { + "run_id": "run-returned", + "issue_identifier": "XY-934", + "account": { + "email": "copy@example.com", + "account_fingerprint": "...123456" + } + } + ] + } + } + """ + )) + + XCTAssertEqual(store.operatorSnapshot?.activeRuns(for: account).map(\.runID), ["run-returned"]) + } + func testOperatorSnapshotWarningSummaryUsesRawWarningToken() throws { let payload = """ { diff --git a/apps/decodex/src/orchestrator/operator_dashboard.html b/apps/decodex/src/orchestrator/operator_dashboard.html index cf56ac393..f3c864d8c 100644 --- a/apps/decodex/src/orchestrator/operator_dashboard.html +++ b/apps/decodex/src/orchestrator/operator_dashboard.html @@ -8606,23 +8606,6 @@

Run History

rows.push({ label: "Context", segments: contextSegments }); } - const toolCalls = lifecycleNumber(lifecycle?.tool_call_count); - const largestOutput = lifecycle?.largest_tool_output_bytes; - const toolSegments = []; - if (toolCalls > 0) { - appendLifecycleMetricSegment(toolSegments, formatCompactCount(toolCalls), "tools"); - } - if (largestOutput != null) { - appendLifecycleMetricSegment( - toolSegments, - formatLargestOutputValue(largestOutput), - "max output", - ); - } - if (toolSegments.length) { - rows.push({ label: "Tools", segments: toolSegments }); - } - const tracker = lifecycleBucket(lifecycle, "Tracker"); if (tracker) { const trackerSegments = []; @@ -11321,17 +11304,37 @@

${escapeHtml(worktree.branch_name)}

}; } - function snapshotWithLiveRunActivity(snapshot) { - if (!snapshot || !dashboardSocketIsOpen()) { - return snapshot; + function dashboardLiveRunActivityHasOverlay({ includeCompletedEmpty = false } = {}) { + if (!dashboardLiveRunActivitySeen) { + return false; } + if (dashboardLiveActiveRuns.length || !dashboardLiveActiveRunsComplete) { + return true; + } + + return includeCompletedEmpty; + } + function clearDashboardLiveRunActivityOverlayIfCompleteEmpty() { if ( - !dashboardLiveRunActivitySeen && - !dashboardLiveActiveRuns.length && - !dashboardLiveAccounts && - !dashboardLiveAccountControl + dashboardLiveRunActivitySeen && + dashboardLiveActiveRunsComplete && + !dashboardLiveActiveRuns.length ) { + dashboardLiveRunActivitySeen = false; + dashboardLiveActiveRuns = []; + dashboardLiveActiveRunsComplete = true; + dashboardLiveAccounts = null; + dashboardLiveAccountControl = null; + } + } + + function snapshotWithLiveRunActivity(snapshot, options = {}) { + if (!snapshot || !dashboardSocketIsOpen()) { + return snapshot; + } + + if (!dashboardLiveRunActivityHasOverlay(options)) { return snapshot; } @@ -11373,13 +11376,16 @@

${escapeHtml(worktree.branch_name)}

error: false, lastEventAt: new Date().toISOString(), }); + clearDashboardLiveRunActivityOverlayIfCompleteEmpty(); return; } lastDashboardRender = { ...lastDashboardRender, - snapshot: snapshotWithLiveRunActivity(lastDashboardRender.snapshot), + snapshot: snapshotWithLiveRunActivity(lastDashboardRender.snapshot, { + includeCompletedEmpty: true, + }), }; updateDashboardStreamState( { @@ -11390,6 +11396,7 @@

${escapeHtml(worktree.branch_name)}

false, ); renderDashboardState(lastDashboardRender); + clearDashboardLiveRunActivityOverlayIfCompleteEmpty(); } function applyDashboardSnapshotEvent(payload) { diff --git a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs index 8ad4f4f68..cecf0b7f1 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs @@ -442,7 +442,6 @@ fn assert_child_lifecycle_contract(response: &str) { "const header = [\"Stage\", \"attempts\", \"inference\", \"input\", \"output\", \"tools\", \"max output\"];", "const alignRight = new Set([1, 2, 3, 4, 5, 6]);", "width: fit-content;\n\t\t\t\tmax-width: 100%;", - "appendLifecycleMetricSegment(\n\t\t\t\t\t\t\ttoolSegments,\n\t\t\t\t\t\t\tformatLargestOutputValue(largestOutput),\n\t\t\t\t\t\t\t\"max output\",", "\"tools\"", "output bytes", "field(\"Large outputs\", childAgentLargeOutputSummary(childAgentActivity(run)))", @@ -470,6 +469,8 @@ fn assert_child_lifecycle_contract(response: &str) { "const titleAttribute = segment.help ? ` title=\"${escapeHtml(segment.help)}\"` : \"\";", "largestOutputHelp(largestOutput, lifecycle?.largest_tool_output_tool)", "formatLargestOutputValue(largestOutput, lifecycle?.largest_tool_output_tool)", + "const toolSegments = [];", + "rows.push({ label: \"Tools\", segments: toolSegments });", ], ); } @@ -1834,7 +1835,12 @@ fn operator_dashboard_run_activity_preserves_snapshot_detail_fields() { assert!(response.contains("let dashboardLiveActiveRunsComplete = true;")); assert!(response.contains("let dashboardLiveAccounts = null;")); assert!(response.contains("let dashboardLiveAccountControl = null;")); - assert!(response.contains("function snapshotWithLiveRunActivity(snapshot)")); + assert!(response + .contains("function dashboardLiveRunActivityHasOverlay({ includeCompletedEmpty = false } = {})")); + assert!(response.contains("function clearDashboardLiveRunActivityOverlayIfCompleteEmpty()")); + assert!(response.contains("return includeCompletedEmpty;")); + assert!(response.contains("function snapshotWithLiveRunActivity(snapshot, options = {})")); + assert!(response.contains("if (!dashboardLiveRunActivityHasOverlay(options))")); assert!(response.contains("\"issue_identifier\"")); assert!(response.contains("\"title\"")); assert!(!response.contains("field(\"Author\",")); @@ -1858,6 +1864,10 @@ fn operator_dashboard_run_activity_preserves_snapshot_detail_fields() { assert!(response.contains("dashboardLiveRunActivitySeen = true;")); assert!(response.contains("dashboardLiveActiveRunsComplete =")); assert!(response.contains("snapshot: snapshotWithLiveRunActivity(payload.snapshot),")); + assert!(response.contains( + "snapshot: snapshotWithLiveRunActivity(lastDashboardRender.snapshot, {\n\t\t\t\t\t\tincludeCompletedEmpty: true,\n\t\t\t\t\t})," + )); + assert!(response.contains("clearDashboardLiveRunActivityOverlayIfCompleteEmpty();")); assert!(response.contains("account_control: accountControl,")); assert!(response.contains("accounts,")); assert!(response.contains("active_runs: mergedActiveRuns,"));