diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index fecd1936..890cdb77 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import Foundation import SwiftUI @@ -221,10 +222,12 @@ struct AccountPanelView: View { @ObservedObject var loginWindowState: LoginWindowState @Environment(\.colorScheme) private var colorScheme @State private var accountScrollOffset: CGFloat = 0 + @State private var currentTime = Date() @State private var pendingLogout: CodexAccount? @State private var armedLogoutAccountID: String? @State private var logoutArmToken = UUID() @AppStorage("decodex.operator.accountPrivacy") private var accountPrivacy = AccountPrivacy.hiddenValue + private let localClock = Timer.publish(every: 1, on: .main, in: .common).autoconnect() var body: some View { Group { @@ -266,6 +269,9 @@ struct AccountPanelView: View { LoginPanelPresenter(store: store, state: loginWindowState) .frame(width: 0, height: 0) } + .onReceive(localClock) { tick in + currentTime = tick + } } private var panelContent: some View { @@ -284,7 +290,8 @@ struct AccountPanelView: View { if let snapshot = store.operatorSnapshot, snapshot.shouldDisplayInPanel { OperatorStatusStripView( snapshot: snapshot, - updatedAt: store.operatorSnapshotUpdatedAt + updatedAt: store.operatorSnapshotUpdatedAt, + currentTime: currentTime ) } @@ -510,6 +517,7 @@ struct AccountPanelView: View { displayName: displayName(for: account), showsDivider: index < store.accounts.count - 1, isLogoutArmed: armedLogoutAccountID == account.id, + currentTime: currentTime, useInCodex: { Task { await store.useInCodex(account) @@ -737,6 +745,7 @@ struct AccountRowView: View { let displayName: String let showsDivider: Bool let isLogoutArmed: Bool + let currentTime: Date let useInCodex: () -> Void let routeRunsHere: () -> Void let login: () -> Void @@ -839,7 +848,7 @@ struct AccountRowView: View { } if account.hasUsageSummary { - AccountUsageSummaryView(account: account) + AccountUsageSummaryView(account: account, currentTime: currentTime) } } .padding(.vertical, 7) @@ -1867,6 +1876,7 @@ struct AccountPoolUsageMetricView: View { struct AccountUsageSummaryView: View { let account: CodexAccount + let currentTime: Date var body: some View { VStack(spacing: 5) { @@ -1882,7 +1892,8 @@ struct AccountUsageSummaryView: View { dailyAveragePercent: account.sevenDayAveragePercent( forWindowSeconds: account.primaryWindowSeconds ), - tone: account.usageTone(remainingPercent: account.primaryRemainingPercent) + tone: account.usageTone(remainingPercent: account.primaryRemainingPercent), + currentTime: currentTime ) } @@ -1894,7 +1905,8 @@ struct AccountUsageSummaryView: View { dailyAveragePercent: account.sevenDayAveragePercent( forWindowSeconds: account.secondaryWindowSeconds ), - tone: account.usageTone(remainingPercent: account.secondaryRemainingPercent) + tone: account.usageTone(remainingPercent: account.secondaryRemainingPercent), + currentTime: currentTime ) } } @@ -2023,6 +2035,7 @@ struct AccountUsageMeterView: View { let resetAtUnixEpoch: Int? let dailyAveragePercent: Double? let tone: AccountTone + let currentTime: Date @Environment(\.colorScheme) private var colorScheme var body: some View { @@ -2169,7 +2182,7 @@ struct AccountUsageMeterView: View { } private var resetDisplay: UsageResetDisplay { - UsageResetDisplay.make(resetAtUnixEpoch: resetAtUnixEpoch) + UsageResetDisplay.make(resetAtUnixEpoch: resetAtUnixEpoch, now: currentTime) } private var trackColor: Color { @@ -2284,12 +2297,12 @@ private struct UsageGlassTrackView: View { } } -private struct UsageResetDisplay { +struct UsageResetDisplay { let short: String let date: String let accessibility: String - static func make(resetAtUnixEpoch: Int?) -> UsageResetDisplay { + static func make(resetAtUnixEpoch: Int?, now: Date = Date()) -> UsageResetDisplay { guard let seconds = resetAtUnixEpoch, seconds > 0 else { return UsageResetDisplay( short: "-", @@ -2307,9 +2320,9 @@ private struct UsageResetDisplay { ) } - let distanceSeconds = Int(floor(resetAt.timeIntervalSinceNow)) + let distanceSeconds = Int(floor(resetAt.timeIntervalSince(now))) if distanceSeconds <= 0 { - let date = formatResetDate(resetAt) + let date = formatResetDate(resetAt, now: now) return UsageResetDisplay( short: "0m", date: date, @@ -2318,7 +2331,7 @@ private struct UsageResetDisplay { } let short = formatResetDuration(distanceSeconds) - let date = formatResetDate(resetAt) + let date = formatResetDate(resetAt, now: now) return UsageResetDisplay( short: short, date: date, @@ -2347,11 +2360,11 @@ private struct UsageResetDisplay { return "\(minutes)m" } - private static func formatResetDate(_ date: Date) -> String { + private static func formatResetDate(_ date: Date, now: Date) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") let calendar = Calendar(identifier: .gregorian) - formatter.dateFormat = calendar.component(.year, from: date) == calendar.component(.year, from: Date()) + formatter.dateFormat = calendar.component(.year, from: date) == calendar.component(.year, from: now) ? "MMM d HH:mm" : "MMM d yyyy HH:mm" return formatter.string(from: date) @@ -2382,6 +2395,7 @@ struct NoticeView: View { struct OperatorStatusStripView: View { let snapshot: OperatorSnapshotResponse let updatedAt: Date? + let currentTime: Date @Environment(\.colorScheme) private var colorScheme var body: some View { @@ -2464,7 +2478,7 @@ struct OperatorStatusStripView: View { return "WS live" } - let age = max(0, Int(Date().timeIntervalSince(updatedAt).rounded())) + let age = max(0, Int(currentTime.timeIntervalSince(updatedAt).rounded())) if age < 2 { return "live" } diff --git a/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift b/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift index 67d602fa..df6e23aa 100644 --- a/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift +++ b/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift @@ -74,6 +74,24 @@ final class AccountModelTests: XCTestCase { XCTAssertEqual(AccountDisplay.compactEmail("xavier.lau@helixbox.ai"), "xav...lau@helixbox.ai") } + func testUsageResetDisplayUsesInjectedClock() { + let base = Date(timeIntervalSince1970: 1_800_000_000) + let thirteenMinutesLater = Int(base.timeIntervalSince1970) + 780 + + let pending = UsageResetDisplay.make( + resetAtUnixEpoch: thirteenMinutesLater, + now: base + ) + let due = UsageResetDisplay.make( + resetAtUnixEpoch: thirteenMinutesLater, + now: base.addingTimeInterval(781) + ) + + XCTAssertEqual(pending.short, "13m") + XCTAssertEqual(due.short, "0m") + XCTAssertTrue(due.accessibility.contains("reset due now")) + } + func testOperatorSnapshotAssignsCodexAccountRunsToAccountRows() throws { let assignedAccount = makeAccount( status: "available", diff --git a/apps/decodex/src/orchestrator/operator_dashboard.html b/apps/decodex/src/orchestrator/operator_dashboard.html index 4d1db468..0f46128a 100644 --- a/apps/decodex/src/orchestrator/operator_dashboard.html +++ b/apps/decodex/src/orchestrator/operator_dashboard.html @@ -3805,6 +3805,7 @@

Run History

"Val", ]; const DASHBOARD_WEBSOCKET_ENDPOINT = "/dashboard/control"; + const DASHBOARD_LOCAL_CLOCK_INTERVAL_MS = 5_000; const RUN_ATTENTION_IDLE_SECONDS = 60; const RUN_STALE_NO_PROCESS_SECONDS = 300; @@ -3859,6 +3860,7 @@

Run History

let lastDashboardRender = null; let dashboardSocket = null; let dashboardSocketReconnectTimer = null; + let dashboardLocalClockTimer = null; let dashboardLiveActiveRuns = []; let dashboardLiveRunActivitySeen = false; let dashboardLiveActiveRunsComplete = true; @@ -10092,6 +10094,25 @@

${escapeHtml(worktree.branch_name)}

}, 2000); } + function renderDashboardLocalClockTick() { + if (document.hidden || !lastDashboardRender) { + return; + } + + renderDashboardState(lastDashboardRender, { refreshAccounts: false }); + } + + function startDashboardLocalClock() { + if (dashboardLocalClockTimer) { + return; + } + + dashboardLocalClockTimer = window.setInterval( + renderDashboardLocalClockTick, + DASHBOARD_LOCAL_CLOCK_INTERVAL_MS, + ); + } + function dashboardRunFieldHasValue(value) { if (Array.isArray(value)) { return value.length > 0; @@ -10527,13 +10548,16 @@

${escapeHtml(worktree.branch_name)}

snapshot, snapshotError, snapshotPublishedAt, - }) { + }, options = {}) { const readiness = summarizeReadiness(snapshotError, snapshot); const notices = dashboardNotices(readiness, snapshotError, snapshot); const derived = buildDerivedState(snapshot); const reviewItems = reviewLaneItems(derived); + const shouldRefreshAccounts = options.refreshAccounts !== false; - refreshAccountApiSnapshot(); + if (shouldRefreshAccounts) { + refreshAccountApiSnapshot(); + } renderHeader(snapshot, readiness, notices, snapshotPublishedAt, snapshotError); renderFlow(snapshot, derived); renderProjects(snapshot, derived); @@ -10587,6 +10611,7 @@

${escapeHtml(worktree.branch_name)}

}; renderDashboardState(lastDashboardRender); connectDashboardSocket(); + startDashboardLocalClock(); } for (const button of nodes.themeButtons) { @@ -10825,7 +10850,14 @@

${escapeHtml(worktree.branch_name)}

}); document.addEventListener("visibilitychange", () => { - if (!document.hidden && !dashboardSocketIsOpen()) { + if (document.hidden) { + return; + } + + if (lastDashboardRender) { + renderDashboardState(lastDashboardRender); + } + if (!dashboardSocketIsOpen()) { connectDashboardSocket(); } }); diff --git a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs index 06e50fd3..29be372e 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs @@ -1663,7 +1663,12 @@ fn operator_dashboard_uses_websocket_without_http_state_fallback() { assert!(response.contains("connectDashboardSocket();")); assert!(response.contains("function startDashboardStream()")); assert!(response.contains("startDashboardStream();")); - assert!(response.contains("if (!document.hidden && !dashboardSocketIsOpen()) {\n\t\t\t\t\tconnectDashboardSocket();")); + assert!(response.contains("document.addEventListener(\"visibilitychange\", () => {")); + assert!(response.contains("if (document.hidden) {\n\t\t\t\t\treturn;\n\t\t\t\t}")); + assert!(response.contains("if (!dashboardSocketIsOpen()) {\n\t\t\t\t\tconnectDashboardSocket();")); + assert!(response.contains("function renderDashboardLocalClockTick()")); + assert!(response.contains("renderDashboardState(lastDashboardRender, { refreshAccounts: false });")); + assert!(response.contains("const shouldRefreshAccounts = options.refreshAccounts !== false;")); assert!(!response.contains("function scheduleDashboardHttpFallback")); assert!(!response.contains("clearDashboardHttpFallback();")); assert!(!response.contains("requestJson("));