Skip to content
Open
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
106 changes: 106 additions & 0 deletions scripts/test-sync-order-ui.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import fs from "node:fs";

import { chromium } from "playwright-core";

const baseUrl = process.env.CODEX_SWITCHER_UI_URL ?? "http://127.0.0.1:3210";

function findChromeExecutable() {
const candidates = [
process.env.CHROME_PATH,
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
].filter(Boolean);

for (const candidate of candidates) {
if (candidate && fs.existsSync(candidate)) {
return candidate;
}
}

throw new Error("No Chrome/Edge executable found for UI sync-order test");
}

async function waitForRefreshIdle(page) {
await page.waitForFunction(() => {
const button = Array.from(document.querySelectorAll("button")).find((element) => {
const label = element.textContent?.trim();
return label === "Refresh All" || label === "Refreshing...";
});

return button?.textContent?.trim() === "Refresh All" && !button.hasAttribute("disabled");
}, undefined, { timeout: 60000 });
}

async function runRefreshCycle(page) {
await waitForRefreshIdle(page);

const refreshAllButton = page.getByRole("button", { name: "Refresh All" });
await refreshAllButton.click();

await page.waitForFunction(() => {
const button = Array.from(document.querySelectorAll("button")).find((element) => {
const label = element.textContent?.trim();
return label === "Refresh All" || label === "Refreshing...";
});

return button?.textContent?.trim() === "Refreshing..." || button?.hasAttribute("disabled");
}, undefined, { timeout: 5000 }).catch(() => {});

await waitForRefreshIdle(page);
await page.waitForTimeout(500);
}

async function getOtherAccountOrder(page) {
const section = page.locator("section").filter({
has: page.getByText(/Other Accounts \(\d+\)/),
});

await section.first().waitFor();

const cardTitles = section.locator("div.theme-card h3");
const names = await cardTitles.allTextContents();
return names.map((name) => name.trim()).filter(Boolean);
}

const browser = await chromium.launch({
executablePath: findChromeExecutable(),
headless: true,
});

try {
const page = await browser.newPage();
await page.addInitScript(() => {
const originalSetInterval = window.setInterval.bind(window);

window.setInterval = (handler, timeout, ...args) => {
if (timeout === 5000 || timeout === 60000) {
return 0;
}

return originalSetInterval(handler, timeout, ...args);
};
});
await page.goto(baseUrl, { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "Refresh All" }).waitFor();
const samples = [];

await runRefreshCycle(page);

const baselineOrder = await getOtherAccountOrder(page);
samples.push(`baseline: ${baselineOrder.join(" | ")}`);

for (let round = 1; round <= 4; round += 1) {
await runRefreshCycle(page);

const order = await getOtherAccountOrder(page);
samples.push(`round ${round}: ${order.join(" | ")}`);
assert.deepEqual(order, baselineOrder, samples.join("\n"));
}

console.log(samples.join("\n"));
} finally {
await browser.close();
}
15 changes: 8 additions & 7 deletions src-tauri/src/api/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,7 @@ async fn warmup_with_chatgpt_auth(account: &StoredAccount) -> Result<()> {
let fresh_account = ensure_chatgpt_tokens_fresh(account).await?;
let (access_token, chatgpt_account_id) = extract_chatgpt_auth(&fresh_account)?;

let mut response =
send_chatgpt_warmup_request(access_token, chatgpt_account_id, true).await?;
let mut response = send_chatgpt_warmup_request(access_token, chatgpt_account_id, true).await?;
if response.status() == StatusCode::UNAUTHORIZED {
println!(
"[Warmup] Unauthorized for account {}, refreshing token and retrying once",
Expand Down Expand Up @@ -409,20 +408,22 @@ pub async fn refresh_all_usage(accounts: &[StoredAccount]) -> Vec<UsageInfo> {
println!("[Usage] Refreshing usage for {} accounts", accounts.len());

let concurrency = accounts.len().min(10).max(1);
let results: Vec<UsageInfo> = stream::iter(accounts.iter().cloned())
.map(|account| async move {
let mut results: Vec<(usize, UsageInfo)> = stream::iter(accounts.iter().cloned().enumerate())
.map(|(index, account)| async move {
match get_account_usage(&account).await {
Ok(info) => info,
Ok(info) => (index, info),
Err(e) => {
println!("[Usage] Error for {}: {}", account.name, e);
UsageInfo::error(account.id.clone(), e.to_string())
(index, UsageInfo::error(account.id.clone(), e.to_string()))
}
}
})
.buffer_unordered(concurrency)
.collect()
.await;

results.sort_by_key(|(index, _)| *index);

println!("[Usage] Refresh complete");
results
results.into_iter().map(|(_, info)| info).collect()
}
106 changes: 63 additions & 43 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import { invoke } from "@tauri-apps/api/core";
import { open, save } from "@tauri-apps/plugin-dialog";
import { useAccounts } from "./hooks/useAccounts";
import { AccountCard, AddAccountModal, UpdateChecker } from "./components";
import type { CodexProcessInfo } from "./types";
import type { AccountWithUsage, CodexProcessInfo } from "./types";
import {
areOtherAccountsLoading,
getOrderedOtherAccountIds,
type OtherAccountsSort,
} from "./lib/otherAccountsOrder";
import "./App.css";

function App() {
const {
accounts,
Expand Down Expand Up @@ -54,11 +58,11 @@ function App() {
isError: boolean;
} | null>(null);
const [maskedAccounts, setMaskedAccounts] = useState<Set<string>>(new Set());
const [otherAccountsSort, setOtherAccountsSort] = useState<
"deadline_asc" | "deadline_desc" | "remaining_desc" | "remaining_asc"
>("deadline_asc");
const [otherAccountsSort, setOtherAccountsSort] = useState<OtherAccountsSort>("deadline_asc");
const [otherAccountsOrder, setOtherAccountsOrder] = useState<string[]>([]);
const [isActionsMenuOpen, setIsActionsMenuOpen] = useState(false);
const actionsMenuRef = useRef<HTMLDivElement | null>(null);
const appliedOtherAccountsSortRef = useRef<OtherAccountsSort | null>(null);

const toggleMask = (accountId: string) => {
setMaskedAccounts((prev) => {
Expand Down Expand Up @@ -343,48 +347,64 @@ function App() {
const otherAccounts = accounts.filter((a) => !a.is_active);
const hasRunningProcesses = processInfo && processInfo.count > 0;

const sortedOtherAccounts = useMemo(() => {
const getResetDeadline = (resetAt: number | null | undefined) =>
resetAt ?? Number.POSITIVE_INFINITY;
const otherAccountsStructureSignature = useMemo(
() =>
[...otherAccounts]
.map((account) => `${account.id}:${account.name}:${account.is_active ? "1" : "0"}`)
.sort()
.join("|"),
[otherAccounts]
);

const getRemainingPercent = (usedPercent: number | null | undefined) => {
if (usedPercent === null || usedPercent === undefined) {
return Number.NEGATIVE_INFINITY;
}
return Math.max(0, 100 - usedPercent);
};
useEffect(() => {
if (otherAccounts.length === 0) {
setOtherAccountsOrder([]);
return;
}

return [...otherAccounts].sort((a, b) => {
if (otherAccountsSort === "deadline_asc" || otherAccountsSort === "deadline_desc") {
const deadlineDiff =
getResetDeadline(a.usage?.primary_resets_at) -
getResetDeadline(b.usage?.primary_resets_at);
if (deadlineDiff !== 0) {
return otherAccountsSort === "deadline_asc" ? deadlineDiff : -deadlineDiff;
}
const remainingDiff =
getRemainingPercent(b.usage?.primary_used_percent) -
getRemainingPercent(a.usage?.primary_used_percent);
if (remainingDiff !== 0) return remainingDiff;
return a.name.localeCompare(b.name);
}
setOtherAccountsOrder((currentOrder) => {
const currentIds = new Set(otherAccounts.map((account) => account.id));
const retainedIds = currentOrder.filter((id) => currentIds.has(id));
const retainedIdSet = new Set(retainedIds);
const appendedIds = otherAccounts
.map((account) => account.id)
.filter((id) => !retainedIdSet.has(id));

const remainingDiff =
getRemainingPercent(b.usage?.primary_used_percent) -
getRemainingPercent(a.usage?.primary_used_percent);
if (otherAccountsSort === "remaining_desc" && remainingDiff !== 0) {
return remainingDiff;
}
if (otherAccountsSort === "remaining_asc" && remainingDiff !== 0) {
return -remainingDiff;
}
const deadlineDiff =
getResetDeadline(a.usage?.primary_resets_at) -
getResetDeadline(b.usage?.primary_resets_at);
if (deadlineDiff !== 0) return deadlineDiff;
return a.name.localeCompare(b.name);
return [...retainedIds, ...appendedIds];
});
}, [otherAccounts, otherAccountsSort]);
}, [otherAccountsStructureSignature]);

useEffect(() => {
const sortChanged = appliedOtherAccountsSortRef.current !== otherAccountsSort;
const needsInitialOrder = otherAccounts.length > 0 && otherAccountsOrder.length === 0;

if (!sortChanged && !needsInitialOrder) {
return;
}

if (areOtherAccountsLoading(otherAccounts)) {
return;
}

setOtherAccountsOrder(getOrderedOtherAccountIds(otherAccounts, otherAccountsSort));
appliedOtherAccountsSortRef.current = otherAccountsSort;
}, [otherAccounts, otherAccountsOrder.length, otherAccountsSort]);

const sortedOtherAccounts = useMemo(() => {
const accountMap = new Map(otherAccounts.map((account) => [account.id, account]));
const orderedIds =
otherAccountsOrder.length === otherAccounts.length
? otherAccountsOrder
: getOrderedOtherAccountIds(otherAccounts, otherAccountsSort);
const orderedAccounts = orderedIds
.map((id) => accountMap.get(id))
.filter((account): account is AccountWithUsage => Boolean(account));

const orderedIdSet = new Set(orderedAccounts.map((account) => account.id));
const appendedAccounts = otherAccounts.filter((account) => !orderedIdSet.has(account.id));

return [...orderedAccounts, ...appendedAccounts];
}, [otherAccounts, otherAccountsOrder, otherAccountsSort]);

return (
<div className="min-h-screen bg-gray-50">
Expand Down
112 changes: 112 additions & 0 deletions src/lib/otherAccountsOrder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { AccountWithUsage } from "../types";

export type OtherAccountsSort =
| "deadline_asc"
| "deadline_desc"
| "remaining_desc"
| "remaining_asc";

function getResetMinuteBucket(resetAt: number | null | undefined): number {
if (resetAt === null || resetAt === undefined) {
return Number.POSITIVE_INFINITY;
}

// The UI renders reset time at minute precision, so keep sorting at the same
// granularity to avoid apparent random reshuffles caused by second-level jitter.
return Math.floor(resetAt / 60);
}

function getRemainingPercentBucket(usedPercent: number | null | undefined): number {
if (usedPercent === null || usedPercent === undefined) {
return Number.NEGATIVE_INFINITY;
}

return Math.round(Math.max(0, 100 - usedPercent));
}

function compareResetBuckets(
aResetBucket: number,
bResetBucket: number,
sort: "deadline_asc" | "deadline_desc"
): number {
const resetDiff = aResetBucket - bResetBucket;
if (resetDiff === 0) {
return 0;
}

return sort === "deadline_asc" ? resetDiff : -resetDiff;
}

export function areOtherAccountsLoading(accounts: AccountWithUsage[]): boolean {
return accounts.some((account) => account.usageLoading);
}

export function compareOtherAccounts(
a: AccountWithUsage,
b: AccountWithUsage,
sort: OtherAccountsSort
): number {
const aWeeklyResetBucket = getResetMinuteBucket(a.usage?.secondary_resets_at);
const bWeeklyResetBucket = getResetMinuteBucket(b.usage?.secondary_resets_at);
const aPrimaryResetBucket = getResetMinuteBucket(a.usage?.primary_resets_at);
const bPrimaryResetBucket = getResetMinuteBucket(b.usage?.primary_resets_at);
const aRemainingBucket = getRemainingPercentBucket(a.usage?.primary_used_percent);
const bRemainingBucket = getRemainingPercentBucket(b.usage?.primary_used_percent);

if (sort === "deadline_asc" || sort === "deadline_desc") {
const weeklyResetDiff = compareResetBuckets(aWeeklyResetBucket, bWeeklyResetBucket, sort);
if (weeklyResetDiff !== 0) {
return weeklyResetDiff;
}

const primaryResetDiff = compareResetBuckets(aPrimaryResetBucket, bPrimaryResetBucket, sort);
if (primaryResetDiff !== 0) {
return primaryResetDiff;
}

return a.name.localeCompare(b.name);
}

const remainingDiff = bRemainingBucket - aRemainingBucket;
if (sort === "remaining_desc" && remainingDiff !== 0) {
return remainingDiff;
}
if (sort === "remaining_asc" && remainingDiff !== 0) {
return -remainingDiff;
}

const weeklyResetDiff = aWeeklyResetBucket - bWeeklyResetBucket;
if (weeklyResetDiff !== 0) {
return weeklyResetDiff;
}

const primaryResetDiff = aPrimaryResetBucket - bPrimaryResetBucket;
if (primaryResetDiff !== 0) {
return primaryResetDiff;
}

return a.name.localeCompare(b.name);
}

export function buildOtherAccountsSortSignature(accounts: AccountWithUsage[]): string {
return accounts
.map((account) =>
[
account.id,
account.name,
account.is_active ? "1" : "0",
account.usageLoading ? "1" : "0",
getResetMinuteBucket(account.usage?.secondary_resets_at),
getResetMinuteBucket(account.usage?.primary_resets_at),
getRemainingPercentBucket(account.usage?.primary_used_percent),
].join(":")
)
.join("|");
}

export function getOrderedOtherAccountIds(
accounts: AccountWithUsage[],
sort: OtherAccountsSort
): string[] {
return [...accounts].sort((a, b) => compareOtherAccounts(a, b, sort)).map((account) => account.id);
}
Loading