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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 27 additions & 13 deletions apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import Combine
import Foundation
import SwiftUI

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
)
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -839,7 +848,7 @@ struct AccountRowView: View {
}

if account.hasUsageSummary {
AccountUsageSummaryView(account: account)
AccountUsageSummaryView(account: account, currentTime: currentTime)
}
}
.padding(.vertical, 7)
Expand Down Expand Up @@ -1867,6 +1876,7 @@ struct AccountPoolUsageMetricView: View {

struct AccountUsageSummaryView: View {
let account: CodexAccount
let currentTime: Date

var body: some View {
VStack(spacing: 5) {
Expand All @@ -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
)
}

Expand All @@ -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
)
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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: "-",
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
}
Expand Down
18 changes: 18 additions & 0 deletions apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 35 additions & 3 deletions apps/decodex/src/orchestrator/operator_dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -3805,6 +3805,7 @@ <h2 id="recent-title">Run History</h2>
"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;

Expand Down Expand Up @@ -3859,6 +3860,7 @@ <h2 id="recent-title">Run History</h2>
let lastDashboardRender = null;
let dashboardSocket = null;
let dashboardSocketReconnectTimer = null;
let dashboardLocalClockTimer = null;
let dashboardLiveActiveRuns = [];
let dashboardLiveRunActivitySeen = false;
let dashboardLiveActiveRunsComplete = true;
Expand Down Expand Up @@ -10092,6 +10094,25 @@ <h4>${escapeHtml(worktree.branch_name)}</h4>
}, 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;
Expand Down Expand Up @@ -10527,13 +10548,16 @@ <h4>${escapeHtml(worktree.branch_name)}</h4>
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);
Expand Down Expand Up @@ -10587,6 +10611,7 @@ <h4>${escapeHtml(worktree.branch_name)}</h4>
};
renderDashboardState(lastDashboardRender);
connectDashboardSocket();
startDashboardLocalClock();
}

for (const button of nodes.themeButtons) {
Expand Down Expand Up @@ -10825,7 +10850,14 @@ <h4>${escapeHtml(worktree.branch_name)}</h4>
});

document.addEventListener("visibilitychange", () => {
if (!document.hidden && !dashboardSocketIsOpen()) {
if (document.hidden) {
return;
}

if (lastDashboardRender) {
renderDashboardState(lastDashboardRender);
}
if (!dashboardSocketIsOpen()) {
connectDashboardSocket();
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("));
Expand Down