+
{error}
)}
diff --git a/gui/src/components/NumberStepper.tsx b/gui/src/components/NumberStepper.tsx
new file mode 100644
index 000000000..a3966644b
--- /dev/null
+++ b/gui/src/components/NumberStepper.tsx
@@ -0,0 +1,45 @@
+import { IconArrowDown, IconArrowUp } from "../icons";
+
+export interface NumberStepperProps {
+ disabled?: boolean;
+ /** Increase the bound value (parent owns parsing / clamping). */
+ onIncrement(): void;
+ /** Decrease the bound value. */
+ onDecrement(): void;
+ incrementLabel: string;
+ decrementLabel: string;
+}
+
+/** Compact up/down pair matching dashboard control chrome (inbound inside input wraps). */
+export function NumberStepper({
+ disabled = false,
+ onIncrement,
+ onDecrement,
+ incrementLabel,
+ decrementLabel,
+}: NumberStepperProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/gui/src/components/QuotaBars.tsx b/gui/src/components/QuotaBars.tsx
index 15a276299..8669deafb 100644
--- a/gui/src/components/QuotaBars.tsx
+++ b/gui/src/components/QuotaBars.tsx
@@ -1,3 +1,4 @@
+import type { CSSProperties } from "react";
import type { Locale, TFn } from "../i18n/shared";
import { useI18n } from "../i18n/shared";
import { IconAlert } from "../icons";
@@ -133,7 +134,11 @@ export function barWidth(percent: number): number {
return Math.max(4, Math.round(clamped));
}
-export default function QuotaBars({ quota, plan, threshold, t, className, layout = "compact" }: {
+function barFillStyle(percent: number): CSSProperties {
+ return { ["--bar-scale" as string]: String(barWidth(percent) / 100) };
+}
+
+export default function QuotaBars({ quota, plan, threshold, t, className, layout = "compact", pending = false }: {
quota: AccountQuota | null;
plan?: string | null;
threshold: number;
@@ -141,10 +146,57 @@ export default function QuotaBars({ quota, plan, threshold, t, className, layout
className?: string;
/** compact = classic one-line rows; stacked = overview cards with clear reset copy */
layout?: "compact" | "stacked";
+ /**
+ * When quota is still null (soft /accounts before WHAM), reserve the compact
+ * bar slot so deferred fill does not shove the page down.
+ */
+ pending?: boolean;
}) {
const { locale } = useI18n();
const rows = buildQuotaRows(quota, plan, t);
- if (rows.length === 0) return null;
+ if (rows.length === 0) {
+ if (!pending) return null;
+ if (layout === "stacked") {
+ return (
+
+ {Array.from({ length: 2 }, (_, index) => (
+
+ ))}
+
{t("common.loading")}
+
+ );
+ }
+ // Always reserve two compact rows until real bars paint — plan-based 1-row
+ // skeletons shrink when Plus/Team quotas arrive with weekly+monthly.
+ return (
+
+ {Array.from({ length: 2 }, (_, index) => (
+
+
+
+
+
+
+
+
+ ))}
+
{t("common.loading")}
+
+ );
+ }
if (layout === "stacked") {
return (
@@ -155,7 +207,7 @@ export default function QuotaBars({ quota, plan, threshold, t, className, layout
);
}
return (
-
+
{rows.map(row => (
{t("codexAuth.resets")}
{reset.day}
{reset.time}
-
+
{t("quota.usedPercent", { pct: Math.round(row.percent) })}
diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx
index 605f3a458..8accb3d87 100644
--- a/gui/src/components/codex-account-pool-cards.tsx
+++ b/gui/src/components/codex-account-pool-cards.tsx
@@ -4,7 +4,7 @@ import { displayAccountId } from "../lib/privacy";
import type { CodexAccountEntry } from "./codex-account-pool-types";
import type { CodexAccountModeState } from "../codex-multi-state";
import QuotaBars from "./QuotaBars";
-import { CodexTicketBadge } from "./codex-account-pool-helpers";
+import { CodexPauseToggleLabel, CodexTicketBadge } from "./codex-account-pool-helpers";
import {
doctorCopyButtonLabel,
formatOAuthHealthLabel,
@@ -66,7 +66,11 @@ export function CodexAccountPoolCards({
{a.alias ?? a.email}
{a.plan && {a.plan}}
- {a.paused && {t("codexAuth.paused")}}
+ {a.paused && (
+
+ {t("codexAuth.paused")}
+
+ )}
onOpenReset(a)} />
{healthLabel && (
{healthLabel}
@@ -89,18 +93,24 @@ export function CodexAccountPoolCards({
)}
{onCopyDoctor && oauthHealthShowsDoctor(healthStatus) && (
-
);
})}
diff --git a/gui/src/components/codex-account-pool-helpers.tsx b/gui/src/components/codex-account-pool-helpers.tsx
index 9c67d1ce8..7567f3b76 100644
--- a/gui/src/components/codex-account-pool-helpers.tsx
+++ b/gui/src/components/codex-account-pool-helpers.tsx
@@ -27,6 +27,15 @@ export function CodexCreditItem({ index, grantedAt, expiresAt, isNext, locale, t
export function CodexTicketBadge({ account, onClick, t }: { account: CodexAccountEntry; onClick: () => void; t: TFn }) {
const credits = account.quota?.resetCredits;
+ // Reserve badge width while WHAM quota is still null so the card-head does not grow
+ // when resetCredits arrives (0 or N). Quota loaded without resetCredits → no badge.
+ if (account.quota == null) {
+ return (
+
+ 0
+
+ );
+ }
if (credits === undefined) return null;
const hasCredits = typeof credits === "number" && credits > 0;
return (
@@ -40,3 +49,27 @@ export function CodexTicketBadge({ account, onClick, t }: { account: CodexAccoun
);
}
+
+/** Equal-width Pause / Resume / Saving label so the button does not grow on toggle. */
+export function CodexPauseToggleLabel({
+ t,
+ paused,
+ saving,
+}: {
+ t: TFn;
+ paused: boolean;
+ saving: boolean;
+}) {
+ const pause = t("codexAuth.pause");
+ const resume = t("codexAuth.resume");
+ const savingLabel = t("common.saving");
+ const visible = saving ? savingLabel : paused ? resume : pause;
+ // Proportional fonts: ch is approximate, but avoids the height bugs of hidden
+ // multi-line measure stacks / ::before sizers.
+ const minCh = Math.max(pause.length, resume.length, savingLabel.length);
+ return (
+
+ {visible}
+
+ );
+}
diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx
index 97fd176e5..c08423b95 100644
--- a/gui/src/components/codex-account-pool-main-card.tsx
+++ b/gui/src/components/codex-account-pool-main-card.tsx
@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
-import { IconLock, IconPause, IconPlay, IconRefresh } from "../icons";
+import { IconLock, IconPause, IconPlay, IconPlus, IconRefresh, IconTicket } from "../icons";
import QuotaBars from "./QuotaBars";
-import { CodexTicketBadge } from "./codex-account-pool-helpers";
+import { CodexPauseToggleLabel, CodexTicketBadge } from "./codex-account-pool-helpers";
import type { CodexAccountEntry } from "./codex-account-pool-types";
import type { CodexAccountModeState } from "../codex-multi-state";
import type { TFn } from "../i18n/shared";
@@ -69,7 +69,11 @@ export function CodexAccountPoolMainCard({
{t("codexAuth.mainAccount")}
{main && onOpenReset({ ...main, id: "__main__" } as CodexAccountEntry)} />}
- {main?.paused && {t("codexAuth.paused")}}
+ {main?.paused && (
+
+ {t("codexAuth.paused")}
+
+ )}
{healthLabel && (
{healthLabel}
)}
@@ -88,19 +92,25 @@ export function CodexAccountPoolMainCard({
)}
{onCopyDoctor && oauthHealthShowsDoctor(main?.health?.status) && (
- onCopyDoctor(mainId)}>
+ onCopyDoctor(mainId)}>
{doctorCopyButtonLabel(t, doctorCopyOutcomeFor?.(mainId))}
)}
{main && (
onTogglePause(mainSwitchEntry)}
disabled={pauseBusy}
+ title={main.paused ? t("codexAuth.pausedHint") : undefined}
+ aria-label={main.paused ? `${t("codexAuth.resume")}. ${t("codexAuth.pausedHint")}` : t("codexAuth.pause")}
>
{main.paused ? : }
- {pauseUpdatingId === "__main__" ? t("common.saving") : t(main.paused ? "codexAuth.resume" : "codexAuth.pause")}
+
)}
{t("codexAuth.appLogin")}
@@ -109,13 +119,20 @@ export function CodexAccountPoolMainCard({
{healthSummary && (
{healthSummary}
)}
- {main?.paused && {t("codexAuth.pausedHint")}
}
{inCooldown && (
{t("pws.healthCooldownHint")}
)}
{showReauth
? {t("codexAuth.mainTokenExpired")}
- : !inCooldown && main?.quota && }
+ : !inCooldown && (
+
+ )}
);
}
@@ -126,6 +143,8 @@ export function CodexAccountPoolPageHead({
refreshingQuota,
pausingExhausted,
pauseBusy,
+ actionFeedback,
+ actionFeedbackTone,
onRefresh,
onPauseExhausted,
}: {
@@ -134,19 +153,28 @@ export function CodexAccountPoolPageHead({
refreshingQuota: boolean;
pausingExhausted: boolean;
pauseBusy?: boolean;
+ actionFeedback?: string | null;
+ actionFeedbackTone?: "ok" | "err" | null;
onRefresh: () => void;
onPauseExhausted: () => void;
}) {
return (
{!embedded &&
{t("nav.codexAuth")}
}
-
+
+
+ {actionFeedback ?? ""}
+
@@ -154,7 +182,7 @@ export function CodexAccountPoolPageHead({
@@ -176,17 +204,57 @@ export function CodexAccountPoolLoadStates({
accountsCount: number;
onRetry: () => void;
}): ReactNode {
- return (
- <>
- {loadState === "loading" && accountsCount === 0 && (
- {t("pws.accountsLoading")}
- )}
- {loadState === "error" && (
-
-
{t("codexAuth.loadFailed")}
-
{t("pws.retryAccounts")}
+ // Cold start with no seeded accounts: mirror the ready layout (main card + pool
+ // section + empty) so Auto-switch / strategy do not jump when real nodes mount.
+ // Caller places this below the stable account-mode banner.
+ if (loadState === "loading" && accountsCount === 0) {
+ return (
+
+ {/* Match ready MainCard chrome (badge + pause + app-login) so head height does not jump. */}
+
+
+
+ {t("codexAuth.mainAccount")}
+
+
+ 0
+
+ {t("codexAuth.nextSession")}
+
+
+ {t("codexAuth.pause")}
+
+ {t("codexAuth.appLogin")}
+
+
+ {/* Strut keeps the sub line-box equal to ready email/plan text; shimmer is visual only. */}
+ {t("codexAuth.appLogin")}
+
+
+
- )}
- >
- );
+
+
{t("codexAuth.accountPool")}
+
+ {/* Same Add control as ready section-sep (inert) so the row height matches. */}
+
+ {t("codexAuth.add")}
+
+
+
+
{t("codexAuth.noPool")}
+
+
{t("pws.accountsLoading")}
+
+ );
+ }
+ if (loadState === "error") {
+ return (
+
+ {t("codexAuth.loadFailed")}
+ {t("pws.retryAccounts")}
+
+ );
+ }
+ return null;
}
diff --git a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx
index efa55d3f8..3882acc65 100644
--- a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx
+++ b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx
@@ -242,8 +242,8 @@ export default function AnthropicAccountPoolSettings({
});
}}
onStickyDraftChange={setStickyDraft}
- onStickyCommit={() => {
- const parsed = parseAccountPoolStickyLimitDraft(stickyDraft);
+ onStickyCommit={(nextDraft) => {
+ const parsed = parseAccountPoolStickyLimitDraft(nextDraft ?? stickyDraft);
if (parsed === null) {
setStickyDraft(String(stickyLimit));
setError(t("accountPool.stickyLimitInvalid"));
diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts
index 3c1d08fd6..1900506bf 100644
--- a/gui/src/hooks/useCodexAccountPool.ts
+++ b/gui/src/hooks/useCodexAccountPool.ts
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
+import { extractAutoSwitchThresholdPayload } from "../codex-auto-switch";
import type { AccountQuota } from "../codex-quota-utils";
import { accountNeedsReauth } from "../oauth-health-display";
@@ -75,14 +76,20 @@ export interface CodexAccountPoolController {
subscribeLoadObserver(observer: CodexAccountLoadObserver): () => void;
/** Last threshold an actual /active read returned; undefined before the first success. */
readLastThreshold(): unknown;
+ /** Full last /active payload (strategy + threshold); undefined before first success. */
+ readLastActive(): unknown;
}
const REFRESH_INTERVAL_MS = 30_000;
+/** In-memory last-good snapshot (not sessionStorage — accounts carry emails/ids). */
+const lastGoodByBase = new Map
();
+
export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccountPoolController {
- const [accounts, setAccounts] = useState([]);
- const [activeId, setActiveId] = useState(null);
- const [loadState, setLoadState] = useState("loading");
+ const seed = lastGoodByBase.get(apiBase);
+ const [accounts, setAccounts] = useState(() => seed?.accounts ?? []);
+ const [activeId, setActiveId] = useState(() => seed?.activeId ?? null);
+ const [loadState, setLoadState] = useState(() => (seed != null ? "ready" : "loading"));
const [switchingId, setSwitchingId] = useState(null);
const [pauseUpdatingId, setPauseUpdatingId] = useState(null);
const [pausingExhausted, setPausingExhausted] = useState(false);
@@ -95,19 +102,35 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
// id back to a value the server had not yet committed when that request was issued.
const pendingActiveIdRef = useRef<{ id: string | null } | null>(null);
const observersRef = useRef>(new Set());
- // Last threshold value an actual /active read returned. Surfaces that mount after a
+ // Last /active payload an actual read returned. Surfaces that mount after a
// load already finished read it to seed their UI instead of waiting a poll interval.
- const lastThresholdRef = useRef<{ value: unknown } | null>(null);
+ const lastActiveRef = useRef<{ value: unknown } | null>(null);
const switchingRef = useRef(null);
+ const hasAccountsRef = useRef(Boolean(seed?.accounts.length));
+ // Distinct from hasAccountsRef: empty successful loads still count as loaded so soft
+ // polls do not flip the UI back to the cold skeleton.
+ const hasLoadedRef = useRef(seed != null);
const pauseMutationRef = useRef<"bulk" | { accountId: string } | null>(null);
const subscribeLoadObserver = useCallback((observer: CodexAccountLoadObserver) => {
observersRef.current.add(observer);
+ // Replay last /active for late subscribers that mount after a load finished.
+ const last = lastActiveRef.current?.value;
+ if (last !== undefined) {
+ const revision = observer.beginActiveRead();
+ observer.acceptActiveRead(last, revision);
+ }
return () => { observersRef.current.delete(observer); };
}, []);
/** Last threshold an actual read returned, or undefined when none has succeeded yet. */
- const readLastThreshold = useCallback(() => lastThresholdRef.current?.value, []);
+ const readLastThreshold = useCallback(
+ () => extractAutoSwitchThresholdPayload(lastActiveRef.current?.value),
+ [],
+ );
+
+ /** Full last /active payload, or undefined when none has succeeded yet. */
+ const readLastActive = useCallback(() => lastActiveRef.current?.value, []);
const load = useCallback(async (refreshQuota = false): Promise => {
const generation = ++loadGenerationRef.current;
@@ -115,14 +138,25 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
const observers = [...observersRef.current];
const revisions = new Map();
for (const observer of observers) revisions.set(observer, observer.beginActiveRead());
- if (!refreshQuota) setLoadState("loading");
+ // Soft refresh when boxes are already on screen — avoid full-page loading flash.
+ if (!refreshQuota && !hasLoadedRef.current) setLoadState("loading");
+
+ let nextAccounts: CodexAccountEntry[] | null = null;
+ let nextActiveId: string | null | undefined;
const accountsTask = (async (): Promise => {
try {
const response = await fetch(`${apiBase}/api/codex-auth/accounts${refreshQuota ? "?refresh=1" : ""}`);
if (!response.ok) throw new Error("account load failed");
const payload = await response.json();
- if (loadGenerationRef.current === generation) setAccounts(payload.accounts ?? []);
+ if (loadGenerationRef.current === generation) {
+ nextAccounts = (payload.accounts ?? []) as CodexAccountEntry[];
+ setAccounts(nextAccounts);
+ hasAccountsRef.current = nextAccounts.length > 0;
+ hasLoadedRef.current = true;
+ // Progressive: paint account/quota boxes as soon as /accounts returns.
+ setLoadState("ready");
+ }
return true;
} catch {
return false;
@@ -141,11 +175,12 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
// Stale read: keep the accepted value and let the next load reconcile.
} else {
pendingActiveIdRef.current = null;
+ nextActiveId = serverActiveId;
setActiveId(serverActiveId);
}
- lastThresholdRef.current = { value: active.autoSwitchThreshold };
+ lastActiveRef.current = { value: active };
for (const observer of observers) {
- observer.acceptActiveRead(active.autoSwitchThreshold, revisions.get(observer)!);
+ observer.acceptActiveRead(active, revisions.get(observer)!);
}
}
return true;
@@ -160,8 +195,20 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
const [accountsOk, activeOk] = await Promise.all([accountsTask, activeTask]);
// A newer load already superseded this one; leave its state in place.
if (loadGenerationRef.current !== generation) return false;
- setLoadState(accountsOk && activeOk ? "ready" : "error");
- return accountsOk && activeOk;
+ if (accountsOk) {
+ setLoadState("ready");
+ hasLoadedRef.current = true;
+ const prior = lastGoodByBase.get(apiBase);
+ lastGoodByBase.set(apiBase, {
+ accounts: nextAccounts ?? prior?.accounts ?? [],
+ activeId: nextActiveId !== undefined ? nextActiveId : (prior?.activeId ?? null),
+ });
+ return activeOk;
+ }
+ // Cold failure only: after a successful load (including empty), keep rows and stay ready
+ // so a soft poll miss does not flash the skeleton / wipe the pool.
+ if (!hasLoadedRef.current) setLoadState("error");
+ return false;
}, [apiBase]);
// Initial load plus background refresh. Owned here so mounting or unmounting a surface
@@ -178,6 +225,20 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
return () => window.clearTimeout(timeout);
}, [enabled, load]);
+ // Soft /accounts returns before background WHAM finishes. Re-poll cheaply until
+ // credentialed rows have quota (or the user navigates away). Keyed on the boolean so
+ // intermediate paints don't reset the timer chain.
+ const needsQuotaFill = accounts.some((account) => account.hasCredential && !account.quota);
+ useEffect(() => {
+ if (!enabled || !needsQuotaFill || pauseCount > 0) return;
+ const timers = [350, 900, 2000].map((delayMs) => (
+ window.setTimeout(() => { void load(false); }, delayMs)
+ ));
+ return () => {
+ for (const timer of timers) window.clearTimeout(timer);
+ };
+ }, [enabled, needsQuotaFill, pauseCount, load]);
+
// Background refresh, suspended while any pause lease is held.
useEffect(() => {
if (!enabled || pauseCount > 0) return;
@@ -352,5 +413,6 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
resumeRefresh,
subscribeLoadObserver,
readLastThreshold,
+ readLastActive,
};
}
diff --git a/gui/src/hooks/useCodexAutoSwitch.ts b/gui/src/hooks/useCodexAutoSwitch.ts
index 079c39952..454f422e5 100644
--- a/gui/src/hooks/useCodexAutoSwitch.ts
+++ b/gui/src/hooks/useCodexAutoSwitch.ts
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import {
DEFAULT_AUTO_SWITCH_THRESHOLD,
autoSwitchThresholdReadDisposition,
+ extractAutoSwitchThresholdPayload,
normalizeAutoSwitchThreshold,
parseEnabledAutoSwitchThreshold,
planAutoSwitchToggleWrite,
@@ -10,8 +11,11 @@ import {
import type { AutoSwitchFeedback } from "../components/CodexAutoSwitchSetting";
export interface CodexAutoSwitchController {
- threshold: number | null;
+ /** Always a number — seeded with the default so the card never waits on /active. */
+ threshold: number;
draft: string;
+ /** False until /active (or hydrate) confirms server state — blocks writes of the seed default. */
+ hydrated: boolean;
saving: boolean;
loadError: boolean;
feedback: AutoSwitchFeedback;
@@ -35,12 +39,15 @@ export function useCodexAutoSwitch(
invalid: string;
},
): CodexAutoSwitchController {
- const [threshold, setThreshold] = useState(null);
- const [draft, setDraftState] = useState("");
+ // Seed immediately — paint chrome with the default, but block writes until /active confirms.
+ const [threshold, setThreshold] = useState(DEFAULT_AUTO_SWITCH_THRESHOLD);
+ const [draft, setDraftState] = useState(String(DEFAULT_AUTO_SWITCH_THRESHOLD));
+ const [hydrated, setHydrated] = useState(false);
const [loadError, setLoadError] = useState(false);
const [saving, setSaving] = useState(false);
const [feedback, setFeedback] = useState(null);
- const thresholdRef = useRef(null);
+ const thresholdRef = useRef(DEFAULT_AUTO_SWITCH_THRESHOLD);
+ const hydratedRef = useRef(false);
const lastEnabledRef = useRef(DEFAULT_AUTO_SWITCH_THRESHOLD);
const editingRef = useRef(false);
const savingRef = useRef(false);
@@ -51,6 +58,8 @@ export function useCodexAutoSwitch(
const apply = useCallback((next: number) => {
thresholdRef.current = next;
+ hydratedRef.current = true;
+ setHydrated(true);
setThreshold(next);
if (next > 0) lastEnabledRef.current = next;
setDraftState(String(next > 0 ? next : lastEnabledRef.current));
@@ -99,12 +108,13 @@ export function useCodexAutoSwitch(
}, []);
const beginServerRead = useCallback((): number => {
- if (thresholdRef.current === null) setLoadError(false);
+ if (!hydratedRef.current) setLoadError(false);
return revisionRef.current;
}, []);
const acceptServerRead = useCallback((value: unknown, startedRevision: number) => {
setLoadError(false);
+ const thresholdValue = extractAutoSwitchThresholdPayload(value);
const disposition = autoSwitchThresholdReadDisposition(
editingRef.current,
savingRef.current,
@@ -112,27 +122,25 @@ export function useCodexAutoSwitch(
revisionRef.current,
);
if (disposition === "defer") {
- deferredServerValueRef.current = normalizeAutoSwitchThreshold(value);
+ deferredServerValueRef.current = normalizeAutoSwitchThreshold(thresholdValue);
} else if (disposition === "apply") {
- queueOrApply(normalizeAutoSwitchThreshold(value));
+ queueOrApply(normalizeAutoSwitchThreshold(thresholdValue));
}
}, [queueOrApply]);
/**
- * Seed the threshold from a value another surface already fetched. Applies ONLY while
+ * Seed from a value another surface already fetched. Applies ONLY while
* uninitialized, so it can never disturb a draft, a pending save, or a newer read.
- * Needed because tabs mount and unmount their panels: a panel that appears after the
- * controller's load finished would otherwise render "Loading" until the next poll.
*/
const hydrateServerValue = useCallback((value: unknown) => {
- if (thresholdRef.current !== null) return;
+ if (hydratedRef.current) return;
if (editingRef.current || savingRef.current) return;
setLoadError(false);
- apply(normalizeAutoSwitchThreshold(value));
+ apply(normalizeAutoSwitchThreshold(extractAutoSwitchThresholdPayload(value)));
}, [apply]);
const rejectServerRead = useCallback(() => {
- if (thresholdRef.current === null) setLoadError(true);
+ if (!hydratedRef.current) setLoadError(true);
}, []);
const save = useCallback(async (
@@ -167,7 +175,7 @@ export function useCodexAutoSwitch(
const rejectDraft = useCallback(() => {
editingRef.current = false;
const current = thresholdRef.current;
- if (!reconcileDeferred() && current !== null) {
+ if (!reconcileDeferred()) {
setDraftState(String(current > 0 ? current : lastEnabledRef.current));
}
showFeedback(messages.invalid, true);
@@ -178,7 +186,7 @@ export function useCodexAutoSwitch(
cancelledDraftRef.current = true;
clearFeedback();
const current = thresholdRef.current;
- if (!reconcileDeferred() && current !== null) {
+ if (!reconcileDeferred()) {
setDraftState(String(current > 0 ? current : lastEnabledRef.current));
}
}, [clearFeedback, reconcileDeferred]);
@@ -188,8 +196,9 @@ export function useCodexAutoSwitch(
cancelledDraftRef.current = false;
return true;
}
+ // Never PUT the paint-time default before /active confirms the real value.
+ if (!hydratedRef.current || savingRef.current) return false;
const current = thresholdRef.current;
- if (current === null || savingRef.current) return false;
editingRef.current = false;
const next = parseEnabledAutoSwitchThreshold(draft);
if (next === null) {
@@ -204,8 +213,8 @@ export function useCodexAutoSwitch(
}, [draft, reconcileDeferred, rejectDraft, save]);
const toggle = useCallback(async (): Promise => {
+ if (!hydratedRef.current || savingRef.current) return false;
const current = thresholdRef.current;
- if (current === null || savingRef.current) return false;
editingRef.current = false;
const plan = planAutoSwitchToggleWrite(current, draft, lastEnabledRef.current);
const ok = await save(plan.threshold, current);
@@ -216,6 +225,7 @@ export function useCodexAutoSwitch(
}, [draft, save]);
const setDraft = useCallback((value: string) => {
+ if (!hydratedRef.current) return;
editingRef.current = true;
cancelledDraftRef.current = false;
clearFeedback();
@@ -234,6 +244,7 @@ export function useCodexAutoSwitch(
return {
threshold,
draft,
+ hydrated,
saving,
loadError,
feedback,
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts
index 0bdf617d1..c45f5c37d 100644
--- a/gui/src/i18n/de.ts
+++ b/gui/src/i18n/de.ts
@@ -30,6 +30,7 @@ export const de: Record = {
"startup.title": "Startsicherheit",
"startup.subtitle": "Prüft, ob Codex opencodex nach einem Neustart erreicht, bevor lokales Proxy-Routing in einer Wiederverbindungsschleife endet.",
"startup.refresh": "Aktualisieren",
+ "startup.backToDashboard": "Zurück zum Dashboard",
"startup.loading": "Startschutz wird geprüft…",
"startup.error": "Startschutz konnte nicht gelesen werden.",
"startup.staleData": "Die aktuelle Prüfung ist fehlgeschlagen. Die Werte unten sind veraltet und kein Nachweis für Schutz.",
@@ -73,8 +74,12 @@ export const de: Record = {
"startup.installedDisabled": "Installiert, aber deaktiviert",
"startup.install": "Installieren",
"startup.installing": "Wird installiert…",
+ "startup.repair": "Reparieren",
+ "startup.repairing": "Wird repariert…",
"startup.serviceInstalled": "Hintergrunddienst wurde erfolgreich installiert.",
+ "startup.serviceRepaired": "Hintergrunddienst wurde erfolgreich repariert.",
"startup.shimInstalled": "Codex-Launcher-Shim wurde erfolgreich installiert.",
+ "startup.shimRepaired": "Codex-Launcher-Shim wurde erfolgreich repariert.",
"startup.installFailed": "Installation fehlgeschlagen:",
"startup.tray.title": "Windows-Infobereich",
"startup.tray.hint": "Installiert ein Anmeldesymbol für Proxy-Start, Stopp, Neustart, Dashboard und Status per Klick.",
@@ -471,6 +476,7 @@ export const de: Record = {
"logs.conversation.excluded": "({unpriced} ohne Preis, {unmetered} ohne Messung vom ~$ ausgenommen)",
"logs.detail.conversation": "Konversation",
"logs.badge.claude": "Claude",
+ "logs.badge.grok": "Grok",
"logs.col.time": "Zeit",
"logs.col.request": "Anfrage",
"logs.col.model": "Modell",
@@ -685,6 +691,8 @@ export const de: Record = {
"codexAuth.autoSwitchOffDesc": "Der automatische Kontowechsel ist ausgeschaltet",
"codexAuth.autoSwitchThreshold": "Wechselschwelle",
"codexAuth.autoSwitchThresholdAria": "Wechselschwelle in Prozent",
+ "codexAuth.autoSwitchThresholdInc": "Wechsel-Schwelle erhöhen",
+ "codexAuth.autoSwitchThresholdDec": "Wechsel-Schwelle verringern",
"codexAuth.autoSwitchLoadFailed": "Die Einstellung für den automatischen Kontowechsel konnte nicht geladen werden.",
"codexAuth.autoSwitchThresholdInvalid": "Gib eine ganze Zahl von 1 bis 100 ein",
"codexAuth.autoSwitchUpdated": "Der automatische Kontowechsel wurde aktualisiert",
@@ -711,6 +719,8 @@ export const de: Record = {
"accountPool.strategyHint": "Gilt nur für neue Sitzungen; bestehende Threads behalten die Kontenaffinität.",
"accountPool.stickyLimit": "Sticky-Erfolge vor Rotation",
"accountPool.stickyLimitAria": "Sticky-Erfolge vor Rotation",
+ "accountPool.stickyLimitInc": "Sticky-Limit erhöhen",
+ "accountPool.stickyLimitDec": "Sticky-Limit verringern",
"accountPool.stickyLimitHelp": "Das gewählte Konto für so viele erfolgreiche neue Sitzungsbindungen behalten, bevor weitergeschaltet wird.",
"accountPool.stickyLimitInvalid": "Gib eine ganze Zahl von 1 bis 100 ein",
"accountPool.strategyLoadFailed": "Rotationsstrategie konnte nicht geladen werden.",
@@ -822,7 +832,12 @@ export const de: Record = {
"api.generate": "Generieren",
"api.generating": "Erstelle…",
"api.activeKeys": "Aktive Schlüssel ({count})",
+ "api.activeKeysLoading": "Aktive Schlüssel",
"api.noKeys": "Noch keine API-Schlüssel. Erstelle oben einen.",
+ "api.copyUrlHint": "Klick um URL zu kopieren",
+ "api.urlCopied": "URL kopiert",
+ "api.copyExampleHint": "Klick um Beispiel zu kopieren",
+ "api.exampleCopied": "Beispiel kopiert",
"api.colName": "Name",
"api.colKey": "Schlüssel",
"api.colCreated": "Erstellt",
@@ -915,16 +930,27 @@ export const de: Record = {
"nav.storage": "Speicher",
"storage.title": "Speicher",
- "storage.subtitle": "Diagnose zur CODEX_HOME-Festplattennutzung. Die Archivbereinigung unten kann älteste archivierte Sitzungen in Quarantäne legen oder dauerhaft löschen — aktive Sitzungen bleiben schreibgeschützt.",
+ "storage.subtitle": "Zeigt, was CODEX_HOME belegt. Die Bereinigung lässt aktive Sitzungen unberührt.",
"storage.loading": "Speicher wird gescannt…",
"storage.empty": "CODEX_HOME ist leer oder fehlt — nichts zu berichten.",
"storage.error": "Speicher-Scan fehlgeschlagen. Prüfe, ob CODEX_HOME auf ein gültiges Verzeichnis zeigt.",
"storage.refresh": "Neu scannen",
+ "storage.rescanned": "Scan abgeschlossen.",
"storage.card.total": "Gesamtgröße",
"storage.card.files": "Dateien",
"storage.card.home": "CODEX_HOME",
+ "storage.snapshot.lastScan": "Letzter Scan",
+ "storage.snapshot.scanning": "Scanne…",
+ "storage.snapshot.unavailable": "Noch kein Scan.",
+ "storage.cleanupCard.title": "Speicher freigeben",
+ "storage.cleanupCard.tabs": "Bereinigungsoptionen",
+ "storage.cleanupCard.tab.policy": "Richtlinie",
+ "storage.cleanupCard.tab.quarantine": "Quarantäne",
+ "storage.cleanup.noArchives": "Keine archivierten Sitzungen zum Bereinigen.",
"storage.section.buckets": "Bereiche",
"storage.section.largest": "Größte Dateien",
+ "storage.workspace.overview": "Übersicht",
+ "storage.workspace.selectBucket": "Wähle einen Bucket aus der Liste, um die Aufschlüsselung zu sehen.",
"storage.col.bucket": "Bereich",
"storage.col.size": "Größe",
"storage.col.files": "Dateien",
@@ -942,7 +968,7 @@ export const de: Record = {
"storage.cleanup.title": "Archivbereinigung",
"storage.cleanup.help": "Entfernt die ältesten archivierten Sitzungen nach Prozentsatz. Aktive Sitzungen werden nie angefasst. Standard ist Quarantäne — Dateien wandern nach CODEX_HOME/.trash.",
"storage.cleanup.slider": "Ältester Archivanteil",
- "storage.cleanup.percent": "Älteste {percent}%",
+ "storage.cleanup.percent": "{percent}%",
"storage.cleanup.preset": "{percent}",
"storage.cleanup.preview": "Vorschau",
"storage.cleanup.confirmTitle": "Archivbereinigung bestätigen",
@@ -950,7 +976,7 @@ export const de: Record = {
"storage.cleanup.moreFiles": "…und {n} weitere",
"storage.cleanup.permanent": "Dauerhaft löschen (ohne Quarantäne)",
"storage.cleanup.permanentWarn": "Dauerhaftes Löschen kann nicht rückgängig gemacht werden.",
- "storage.cleanup.quarantineNote": "Dateien wandern nach .trash unter CODEX_HOME. Du kannst sie im Quarantäne-Abschnitt darunter wiederherstellen.",
+ "storage.cleanup.quarantineNote": "Dateien wandern nach .trash unter CODEX_HOME. Du kannst sie im Tab Quarantäne wiederherstellen.",
"storage.cleanup.cancel": "Abbrechen",
"storage.cleanup.confirmQuarantine": "In Quarantäne",
"storage.cleanup.confirmPermanent": "Dauerhaft löschen",
@@ -1009,10 +1035,17 @@ export const de: Record = {
"storage.policy.invalid": "Ungültige Richtlinienwerte.",
"storage.policy.enabled": "Automatische Bereinigung aktivieren",
"storage.policy.enabledHint": "Standard ist aus. Bei Aktivierung nur nach gewähltem Zeitplan (oder Jetzt ausführen).",
- "storage.policy.threshold": "Auslösen, wenn Archivgröße überschreitet (GiB)",
+ "storage.policy.threshold": "Wenn Archivgröße größer als",
+ "storage.policy.trigger": "Auslöser",
"storage.policy.target": "Bereinigungsziel",
- "storage.policy.targetPercent": "Ältesten Archivanteil entfernen",
- "storage.policy.targetReduce": "Archivgröße reduzieren auf (GiB)",
+ "storage.policy.targetPercent": "Älteste Archive entfernen",
+ "storage.policy.targetReduce": "Archivgröße reduzieren auf",
+ "storage.policy.thresholdInc": "Schwellwert erhöhen",
+ "storage.policy.thresholdDec": "Schwellwert verringern",
+ "storage.policy.percentInc": "Prozent erhöhen",
+ "storage.policy.percentDec": "Prozent verringern",
+ "storage.policy.reduceInc": "Zielgröße erhöhen",
+ "storage.policy.reduceDec": "Zielgröße verringern",
"storage.policy.schedule": "Zeitplan",
"storage.policy.schedule.manual": "Nur manuell",
"storage.policy.schedule.startup": "Beim Proxy-Start",
@@ -1245,6 +1278,7 @@ export const de: Record = {
"pws.dashboard.checkedAgo": "Geprüft {time}",
"pws.dashboard.noQuota": "Keine Kontingentdaten",
"pws.dashboard.noUsage": "Noch keine Nutzungsdaten",
+ "pws.dashboard.noRateLimits": "Noch keine Limit-Daten",
"pws.allProviders": "Anbieterübersicht",
"pws.enabledLabel": "Aktiviert",
"pws.testConnection": "Verbindung testen",
diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts
index 07a75a705..6b81fdc84 100644
--- a/gui/src/i18n/en.ts
+++ b/gui/src/i18n/en.ts
@@ -37,6 +37,7 @@ export const en = {
"startup.title": "Startup safety",
"startup.subtitle": "Verify that Codex can reach opencodex after a restart, before local proxy routing becomes a reconnect loop.",
"startup.refresh": "Refresh",
+ "startup.backToDashboard": "Back to Dashboard",
"startup.loading": "Checking startup protection…",
"startup.error": "Could not read startup protection.",
"startup.staleData": "The latest startup check failed. The values below are stale and must not be treated as proof of protection.",
@@ -80,8 +81,12 @@ export const en = {
"startup.installedDisabled": "Installed but disabled",
"startup.install": "Install",
"startup.installing": "Installing…",
+ "startup.repair": "Repair",
+ "startup.repairing": "Repairing…",
"startup.serviceInstalled": "Background service installed successfully.",
+ "startup.serviceRepaired": "Background service repaired successfully.",
"startup.shimInstalled": "Codex launcher shim installed successfully.",
+ "startup.shimRepaired": "Codex launcher shim repaired successfully.",
"startup.installFailed": "Installation failed:",
"startup.tray.title": "Windows system tray",
"startup.tray.hint": "Install a login tray icon for one-click proxy start, stop, restart, dashboard, and status controls.",
@@ -489,6 +494,7 @@ export const en = {
"logs.conversation.excluded": "({unpriced} unpriced, {unmetered} unmetered excluded from ~$)",
"logs.detail.conversation": "Conversation",
"logs.badge.claude": "Claude",
+ "logs.badge.grok": "Grok",
"logs.col.time": "Time",
"logs.col.request": "Request",
"logs.col.model": "Model",
@@ -628,16 +634,27 @@ export const en = {
"nav.storage": "Storage",
"storage.title": "Storage",
- "storage.subtitle": "Diagnostics for CODEX_HOME disk use. Archived cleanup below can quarantine or permanently delete oldest archived sessions — active sessions stay read-only.",
+ "storage.subtitle": "See what’s using CODEX_HOME. Cleanup never touches active sessions.",
"storage.loading": "Scanning storage…",
"storage.empty": "CODEX_HOME is empty or missing — nothing to report.",
"storage.error": "Storage scan failed. Check that CODEX_HOME points at a valid directory.",
"storage.refresh": "Rescan",
+ "storage.rescanned": "Scan complete.",
"storage.card.total": "Total size",
"storage.card.files": "Files",
"storage.card.home": "CODEX_HOME",
+ "storage.snapshot.lastScan": "Last scan",
+ "storage.snapshot.scanning": "Scanning…",
+ "storage.snapshot.unavailable": "No scan yet.",
+ "storage.cleanupCard.title": "Free up space",
+ "storage.cleanupCard.tabs": "Cleanup options",
+ "storage.cleanupCard.tab.policy": "Policy",
+ "storage.cleanupCard.tab.quarantine": "Quarantine",
+ "storage.cleanup.noArchives": "No archived sessions to clean up.",
"storage.section.buckets": "Buckets",
"storage.section.largest": "Largest files",
+ "storage.workspace.overview": "Overview",
+ "storage.workspace.selectBucket": "Select a bucket from the list to see its breakdown.",
"storage.col.bucket": "Bucket",
"storage.col.size": "Size",
"storage.col.files": "Files",
@@ -655,7 +672,7 @@ export const en = {
"storage.cleanup.title": "Archived cleanup",
"storage.cleanup.help": "Remove the oldest archived sessions by percentage. Active sessions are never touched. Quarantine is the default — files move to CODEX_HOME/.trash.",
"storage.cleanup.slider": "Oldest archived percent",
- "storage.cleanup.percent": "Oldest {percent}%",
+ "storage.cleanup.percent": "{percent}%",
"storage.cleanup.preset": "{percent}",
"storage.cleanup.preview": "Preview",
"storage.cleanup.confirmTitle": "Confirm archived cleanup",
@@ -663,7 +680,7 @@ export const en = {
"storage.cleanup.moreFiles": "…and {n} more",
"storage.cleanup.permanent": "Delete permanently (skip quarantine)",
"storage.cleanup.permanentWarn": "Permanent delete cannot be undone.",
- "storage.cleanup.quarantineNote": "Files move to .trash under CODEX_HOME. You can restore them from the Quarantine section below.",
+ "storage.cleanup.quarantineNote": "Files move to .trash under CODEX_HOME. You can restore them from the Quarantine tab.",
"storage.cleanup.cancel": "Cancel",
"storage.cleanup.confirmQuarantine": "Quarantine",
"storage.cleanup.confirmPermanent": "Delete permanently",
@@ -722,10 +739,17 @@ export const en = {
"storage.policy.invalid": "Invalid policy values.",
"storage.policy.enabled": "Enable auto-cleanup",
"storage.policy.enabledHint": "Default is off. Enabling runs only on the schedule you choose (or Run now).",
- "storage.policy.threshold": "Trigger when archived size exceeds (GiB)",
+ "storage.policy.threshold": "When archived size exceeds",
+ "storage.policy.trigger": "Trigger",
"storage.policy.target": "Cleanup target",
- "storage.policy.targetPercent": "Remove oldest archived percent",
- "storage.policy.targetReduce": "Reduce archived size to (GiB)",
+ "storage.policy.targetPercent": "Remove oldest archived",
+ "storage.policy.targetReduce": "Reduce archived size to",
+ "storage.policy.thresholdInc": "Increase threshold",
+ "storage.policy.thresholdDec": "Decrease threshold",
+ "storage.policy.percentInc": "Increase percent",
+ "storage.policy.percentDec": "Decrease percent",
+ "storage.policy.reduceInc": "Increase reduce-to size",
+ "storage.policy.reduceDec": "Decrease reduce-to size",
"storage.policy.schedule": "Schedule",
"storage.policy.schedule.manual": "Manual only",
"storage.policy.schedule.startup": "On proxy startup",
@@ -982,6 +1006,7 @@ export const en = {
"pws.dashboard.checkedAgo": "Checked {time}",
"pws.dashboard.noQuota": "No quota data",
"pws.dashboard.noUsage": "No usage data yet",
+ "pws.dashboard.noRateLimits": "No rate-limit data yet",
"pws.allProviders": "Provider Overview",
"pws.enabledLabel": "Enabled",
"pws.testConnection": "Test connection",
@@ -1099,6 +1124,8 @@ export const en = {
"codexAuth.autoSwitchOffDesc": "Automatic account switching is off",
"codexAuth.autoSwitchThreshold": "Switch threshold",
"codexAuth.autoSwitchThresholdAria": "Switch threshold, percent",
+ "codexAuth.autoSwitchThresholdInc": "Increase switch threshold",
+ "codexAuth.autoSwitchThresholdDec": "Decrease switch threshold",
"codexAuth.autoSwitchLoadFailed": "Automatic switching setting could not be loaded.",
"codexAuth.autoSwitchThresholdInvalid": "Enter a whole number from 1 to 100",
"codexAuth.autoSwitchUpdated": "Automatic account switching updated",
@@ -1126,6 +1153,8 @@ export const en = {
"accountPool.strategyHint": "Applies to new sessions only; existing threads keep account affinity.",
"accountPool.stickyLimit": "Sticky successes before rotate",
"accountPool.stickyLimitAria": "Sticky successes before rotate",
+ "accountPool.stickyLimitInc": "Increase sticky limit",
+ "accountPool.stickyLimitDec": "Decrease sticky limit",
"accountPool.stickyLimitHelp": "Keep the selected account for this many successful new-session binds before advancing.",
"accountPool.stickyLimitInvalid": "Enter a whole number from 1 to 100",
"accountPool.strategyLoadFailed": "Rotation strategy could not be loaded.",
@@ -1218,7 +1247,12 @@ export const en = {
"api.generate": "Generate",
"api.generating": "Creating…",
"api.activeKeys": "Active keys ({count})",
+ "api.activeKeysLoading": "Active keys",
"api.noKeys": "No API keys yet. Generate one above.",
+ "api.copyUrlHint": "Click to copy URL",
+ "api.urlCopied": "URL copied",
+ "api.copyExampleHint": "Click to copy example",
+ "api.exampleCopied": "Example copied",
"api.colName": "Name",
"api.colKey": "Key",
"api.colCreated": "Created",
diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts
index 1501e2d08..66f95b360 100644
--- a/gui/src/i18n/ja.ts
+++ b/gui/src/i18n/ja.ts
@@ -37,6 +37,7 @@ export const ja: Record = {
"startup.title": "起動安全性",
"startup.subtitle": "再起動後にローカルプロキシへの接続が再接続ループになる前に、Codex が opencodex へ到達できるか確認します。",
"startup.refresh": "更新",
+ "startup.backToDashboard": "ダッシュボードに戻る",
"startup.loading": "起動保護を確認中…",
"startup.error": "起動保護を読み取れませんでした。",
"startup.staleData": "最新の確認に失敗しました。以下は古い値であり、保護の証明にはなりません。",
@@ -80,8 +81,12 @@ export const ja: Record = {
"startup.installedDisabled": "インストール済み・無効",
"startup.install": "インストール",
"startup.installing": "インストール中…",
+ "startup.repair": "修復",
+ "startup.repairing": "修復中…",
"startup.serviceInstalled": "バックグラウンドサービスをインストールしました。",
+ "startup.serviceRepaired": "バックグラウンドサービスを修復しました。",
"startup.shimInstalled": "Codex ランチャー shim をインストールしました。",
+ "startup.shimRepaired": "Codex ランチャー shim を修復しました。",
"startup.installFailed": "インストールに失敗しました:",
"startup.tray.title": "Windows システムトレイ",
"startup.tray.hint": "ログイン時にトレイを起動し、プロキシの開始・停止・再起動・ダッシュボード・状態をクリックで操作します。",
@@ -456,6 +461,7 @@ export const ja: Record = {
"logs.conversation.excluded": "(~$ から価格なし {unpriced} / 未計測 {unmetered} を除外)",
"logs.detail.conversation": "会話",
"logs.badge.claude": "Claude",
+ "logs.badge.grok": "Grok",
"logs.col.time": "時刻",
"logs.col.request": "リクエスト",
"logs.col.model": "モデル",
@@ -595,16 +601,27 @@ export const ja: Record = {
"nav.storage": "ストレージ",
"storage.title": "ストレージ",
- "storage.subtitle": "CODEX_HOME のディスク使用診断。下のアーカイブクリーンアップで古いアーカイブを隔離または完全削除できます — アクティブセッションは読み取り専用のままです。",
+ "storage.subtitle": "CODEX_HOME の使用状況を確認。クリーンアップはアクティブセッションに触れません。",
"storage.loading": "ストレージをスキャン中…",
"storage.empty": "CODEX_HOME が空か存在しません — 報告するものはありません。",
"storage.error": "ストレージのスキャンに失敗しました。CODEX_HOME が有効なディレクトリを指しているか確認してください。",
"storage.refresh": "再スキャン",
+ "storage.rescanned": "スキャンが完了しました。",
"storage.card.total": "合計サイズ",
"storage.card.files": "ファイル",
"storage.card.home": "CODEX_HOME",
+ "storage.snapshot.lastScan": "最終スキャン",
+ "storage.snapshot.scanning": "スキャン中…",
+ "storage.snapshot.unavailable": "まだスキャンがありません。",
+ "storage.cleanupCard.title": "容量を空ける",
+ "storage.cleanupCard.tabs": "クリーンアップオプション",
+ "storage.cleanupCard.tab.policy": "ポリシー",
+ "storage.cleanupCard.tab.quarantine": "隔離",
+ "storage.cleanup.noArchives": "クリーンアップ対象のアーカイブセッションはありません。",
"storage.section.buckets": "バケット",
"storage.section.largest": "最大ファイル",
+ "storage.workspace.overview": "概要",
+ "storage.workspace.selectBucket": "一覧からバケットを選ぶと内訳が表示されます。",
"storage.col.bucket": "バケット",
"storage.col.size": "サイズ",
"storage.col.files": "ファイル",
@@ -622,7 +639,7 @@ export const ja: Record = {
"storage.cleanup.title": "アーカイブのクリーンアップ",
"storage.cleanup.help": "古いアーカイブセッションを割合で削除します。アクティブセッションには触れません。既定は隔離で、ファイルは CODEX_HOME/.trash へ移動します。",
"storage.cleanup.slider": "古いアーカイブの割合",
- "storage.cleanup.percent": "古い {percent}%",
+ "storage.cleanup.percent": "{percent}%",
"storage.cleanup.preset": "{percent}",
"storage.cleanup.preview": "プレビュー",
"storage.cleanup.confirmTitle": "アーカイブクリーンアップの確認",
@@ -630,7 +647,7 @@ export const ja: Record = {
"storage.cleanup.moreFiles": "…ほか {n} 件",
"storage.cleanup.permanent": "完全に削除する(隔離しない)",
"storage.cleanup.permanentWarn": "完全削除は元に戻せません。",
- "storage.cleanup.quarantineNote": "ファイルは CODEX_HOME 下の .trash へ移動します。下の隔離セクションから復元できます。",
+ "storage.cleanup.quarantineNote": "ファイルは CODEX_HOME 下の .trash へ移動します。隔離タブから復元できます。",
"storage.cleanup.cancel": "キャンセル",
"storage.cleanup.confirmQuarantine": "隔離する",
"storage.cleanup.confirmPermanent": "完全に削除",
@@ -689,10 +706,17 @@ export const ja: Record = {
"storage.policy.invalid": "方針の値が無効です。",
"storage.policy.enabled": "自動クリーンアップを有効化",
"storage.policy.enabledHint": "既定はオフです。有効にすると選択したスケジュール(または今すぐ実行)でのみ動きます。",
- "storage.policy.threshold": "アーカイブサイズが超えたら実行(GiB)",
+ "storage.policy.threshold": "アーカイブサイズが超えたら",
+ "storage.policy.trigger": "トリガー",
"storage.policy.target": "クリーンアップ目標",
- "storage.policy.targetPercent": "古いアーカイブの割合を削除",
- "storage.policy.targetReduce": "アーカイブを次のサイズまで縮小(GiB)",
+ "storage.policy.targetPercent": "古いアーカイブを削除",
+ "storage.policy.targetReduce": "アーカイブを次のサイズまで縮小",
+ "storage.policy.thresholdInc": "しきい値を上げる",
+ "storage.policy.thresholdDec": "しきい値を下げる",
+ "storage.policy.percentInc": "パーセントを上げる",
+ "storage.policy.percentDec": "パーセントを下げる",
+ "storage.policy.reduceInc": "削減目標を上げる",
+ "storage.policy.reduceDec": "削減目標を下げる",
"storage.policy.schedule": "スケジュール",
"storage.policy.schedule.manual": "手動のみ",
"storage.policy.schedule.startup": "プロキシ起動時",
@@ -939,6 +963,7 @@ export const ja: Record = {
"pws.dashboard.checkedAgo": "{time} に確認",
"pws.dashboard.noQuota": "クォータデータなし",
"pws.dashboard.noUsage": "まだ使用量データはありません",
+ "pws.dashboard.noRateLimits": "まだレート制限データはありません",
"pws.allProviders": "プロバイダー概要",
"pws.enabledLabel": "有効",
"pws.testConnection": "接続テスト",
@@ -1056,6 +1081,8 @@ export const ja: Record = {
"codexAuth.autoSwitchOffDesc": "アカウントの自動切り替えはオフです",
"codexAuth.autoSwitchThreshold": "切り替えしきい値",
"codexAuth.autoSwitchThresholdAria": "切り替えしきい値(パーセント)",
+ "codexAuth.autoSwitchThresholdInc": "切替しきい値を上げる",
+ "codexAuth.autoSwitchThresholdDec": "切替しきい値を下げる",
"codexAuth.autoSwitchLoadFailed": "アカウントの自動切り替え設定を読み込めませんでした。",
"codexAuth.autoSwitchThresholdInvalid": "1 から 100 までの整数を入力してください",
"codexAuth.autoSwitchUpdated": "アカウントの自動切り替え設定を更新しました",
@@ -1082,6 +1109,8 @@ export const ja: Record = {
"accountPool.strategyHint": "新規セッションにのみ適用されます。既存スレッドはアカウント親和性を維持します。",
"accountPool.stickyLimit": "ローテーション前の固定成功数",
"accountPool.stickyLimitAria": "ローテーション前の固定成功数",
+ "accountPool.stickyLimitInc": "スティッキー上限を上げる",
+ "accountPool.stickyLimitDec": "スティッキー上限を下げる",
"accountPool.stickyLimitHelp": "次へ進む前に、選んだアカウントをこの回数の成功した新規セッション紐付け分保持します。",
"accountPool.stickyLimitInvalid": "1 から 100 までの整数を入力してください",
"accountPool.strategyLoadFailed": "ローテーション戦略を読み込めませんでした。",
@@ -1198,7 +1227,12 @@ export const ja: Record = {
"api.generate": "生成",
"api.generating": "作成中…",
"api.activeKeys": "アクティブなキー ({count})",
+ "api.activeKeysLoading": "有効なキー",
"api.noKeys": "まだ API キーがありません。上で生成してください。",
+ "api.copyUrlHint": "クリックして URL をコピー",
+ "api.urlCopied": "URL をコピーしました",
+ "api.copyExampleHint": "クリックして例をコピー",
+ "api.exampleCopied": "例をコピーしました",
"api.colName": "名前",
"api.colKey": "キー",
"api.colCreated": "作成日",
diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts
index b2589b6f0..d81b0ece7 100644
--- a/gui/src/i18n/ko.ts
+++ b/gui/src/i18n/ko.ts
@@ -32,6 +32,7 @@ export const ko: Record = {
"startup.title": "시작 안전성",
"startup.subtitle": "재부팅 후 로컬 프록시 라우팅이 재연결 반복으로 이어지기 전에 Codex가 opencodex에 연결될 수 있는지 확인합니다.",
"startup.refresh": "새로고침",
+ "startup.backToDashboard": "대시보드로 돌아가기",
"startup.loading": "시작 보호 상태 확인 중…",
"startup.error": "시작 보호 상태를 읽지 못했습니다.",
"startup.staleData": "최신 시작 상태 확인에 실패했습니다. 아래 값은 이전 결과이며 보호 증거로 사용하면 안 됩니다.",
@@ -75,8 +76,12 @@ export const ko: Record = {
"startup.installedDisabled": "설치됐지만 꺼짐",
"startup.install": "설치하기",
"startup.installing": "설치 중…",
+ "startup.repair": "복구",
+ "startup.repairing": "복구 중…",
"startup.serviceInstalled": "백그라운드 서비스를 설치했습니다.",
+ "startup.serviceRepaired": "백그라운드 서비스를 복구했습니다.",
"startup.shimInstalled": "Codex launcher shim을 설치했습니다.",
+ "startup.shimRepaired": "Codex launcher shim을 복구했습니다.",
"startup.installFailed": "설치하지 못했습니다:",
"startup.tray.title": "Windows 시스템 트레이",
"startup.tray.hint": "로그인할 때 트레이 아이콘을 띄우고 프록시 시작·중지·재시작·대시보드·상태를 클릭으로 제어합니다.",
@@ -483,6 +488,7 @@ export const ko: Record = {
"logs.conversation.excluded": "(~$에서 가격 없음 {unpriced}건, 미측정 {unmetered}건 제외)",
"logs.detail.conversation": "대화",
"logs.badge.claude": "Claude",
+ "logs.badge.grok": "Grok",
"logs.col.time": "시간",
"logs.col.request": "요청",
"logs.col.model": "모델",
@@ -702,6 +708,8 @@ export const ko: Record = {
"codexAuth.autoSwitchOffDesc": "자동 계정 전환이 꺼져 있습니다",
"codexAuth.autoSwitchThreshold": "전환 임계값",
"codexAuth.autoSwitchThresholdAria": "전환 임계값(퍼센트)",
+ "codexAuth.autoSwitchThresholdInc": "전환 임계값 증가",
+ "codexAuth.autoSwitchThresholdDec": "전환 임계값 감소",
"codexAuth.autoSwitchLoadFailed": "자동 계정 전환 설정을 불러오지 못했습니다.",
"codexAuth.autoSwitchThresholdInvalid": "1~100 사이의 정수를 입력하세요",
"codexAuth.autoSwitchUpdated": "자동 계정 전환 설정을 저장했습니다",
@@ -728,6 +736,8 @@ export const ko: Record = {
"accountPool.strategyHint": "새 세션에만 적용됩니다. 기존 스레드는 계정 어피니티를 유지합니다.",
"accountPool.stickyLimit": "회전 전 sticky 성공 횟수",
"accountPool.stickyLimitAria": "회전 전 sticky 성공 횟수",
+ "accountPool.stickyLimitInc": "스티키 한도 증가",
+ "accountPool.stickyLimitDec": "스티키 한도 감소",
"accountPool.stickyLimitHelp": "다음으로 진행하기 전에 선택된 계정을 이 횟수의 성공한 새 세션 바인딩 동안 유지합니다.",
"accountPool.stickyLimitInvalid": "1에서 100 사이의 정수를 입력하세요",
"accountPool.strategyLoadFailed": "로테이션 전략을 불러오지 못했습니다.",
@@ -842,7 +852,12 @@ export const ko: Record = {
"api.generate": "생성",
"api.generating": "생성 중…",
"api.activeKeys": "활성 키 ({count})",
+ "api.activeKeysLoading": "활성 키",
"api.noKeys": "아직 API 키가 없습니다. 위에서 하나 생성하세요.",
+ "api.copyUrlHint": "클릭하여 URL 복사",
+ "api.urlCopied": "URL 복사됨",
+ "api.copyExampleHint": "클릭하여 예제 복사",
+ "api.exampleCopied": "예제 복사됨",
"api.colName": "이름",
"api.colKey": "키",
"api.colCreated": "생성일",
@@ -935,16 +950,27 @@ export const ko: Record = {
"nav.storage": "저장소",
"storage.title": "저장소",
- "storage.subtitle": "CODEX_HOME 디스크 사용 진단. 아래 보관 정리로 가장 오래된 보관 세션을 격리하거나 영구 삭제할 수 있습니다 — 활성 세션은 읽기 전용입니다.",
+ "storage.subtitle": "CODEX_HOME 사용량을 확인합니다. 정리는 활성 세션을 건드리지 않습니다.",
"storage.loading": "저장소 스캔 중…",
"storage.empty": "CODEX_HOME이 비어 있거나 없습니다 — 표시할 내용이 없습니다.",
"storage.error": "저장소 스캔에 실패했습니다. CODEX_HOME이 올바른 디렉터리를 가리키는지 확인하세요.",
"storage.refresh": "다시 스캔",
+ "storage.rescanned": "스캔이 완료되었습니다.",
"storage.card.total": "전체 크기",
"storage.card.files": "파일 수",
"storage.card.home": "CODEX_HOME",
+ "storage.snapshot.lastScan": "마지막 스캔",
+ "storage.snapshot.scanning": "스캔 중…",
+ "storage.snapshot.unavailable": "아직 스캔 없음.",
+ "storage.cleanupCard.title": "공간 확보",
+ "storage.cleanupCard.tabs": "정리 옵션",
+ "storage.cleanupCard.tab.policy": "정책",
+ "storage.cleanupCard.tab.quarantine": "격리",
+ "storage.cleanup.noArchives": "정리할 보관 세션이 없습니다.",
"storage.section.buckets": "버킷",
"storage.section.largest": "가장 큰 파일",
+ "storage.workspace.overview": "개요",
+ "storage.workspace.selectBucket": "목록에서 버킷을 선택하면 세부 내역을 볼 수 있습니다.",
"storage.col.bucket": "버킷",
"storage.col.size": "크기",
"storage.col.files": "파일",
@@ -962,7 +988,7 @@ export const ko: Record = {
"storage.cleanup.title": "보관 정리",
"storage.cleanup.help": "가장 오래된 보관 세션을 비율로 제거합니다. 활성 세션은 건드리지 않습니다. 기본은 격리이며 파일은 CODEX_HOME/.trash로 이동합니다.",
"storage.cleanup.slider": "오래된 보관 비율",
- "storage.cleanup.percent": "오래된 {percent}%",
+ "storage.cleanup.percent": "{percent}%",
"storage.cleanup.preset": "{percent}",
"storage.cleanup.preview": "미리보기",
"storage.cleanup.confirmTitle": "보관 정리 확인",
@@ -970,7 +996,7 @@ export const ko: Record = {
"storage.cleanup.moreFiles": "…외 {n}개",
"storage.cleanup.permanent": "영구 삭제(격리 건너뛰기)",
"storage.cleanup.permanentWarn": "영구 삭제는 되돌릴 수 없습니다.",
- "storage.cleanup.quarantineNote": "파일은 CODEX_HOME 아래 .trash로 이동합니다. 아래 격리 섹션에서 복원할 수 있습니다.",
+ "storage.cleanup.quarantineNote": "파일은 CODEX_HOME 아래 .trash로 이동합니다. 격리 탭에서 복원할 수 있습니다.",
"storage.cleanup.cancel": "취소",
"storage.cleanup.confirmQuarantine": "격리",
"storage.cleanup.confirmPermanent": "영구 삭제",
@@ -1029,10 +1055,17 @@ export const ko: Record = {
"storage.policy.invalid": "정책 값이 올바르지 않습니다.",
"storage.policy.enabled": "자동 정리 사용",
"storage.policy.enabledHint": "기본은 꺼짐입니다. 켜면 선택한 일정(또는 지금 실행)에만 동작합니다.",
- "storage.policy.threshold": "보관 용량이 초과하면 실행 (GiB)",
+ "storage.policy.threshold": "보관 용량이 초과하면",
+ "storage.policy.trigger": "트리거",
"storage.policy.target": "정리 목표",
- "storage.policy.targetPercent": "가장 오래된 보관 비율 제거",
- "storage.policy.targetReduce": "보관 용량을 다음까지 줄이기 (GiB)",
+ "storage.policy.targetPercent": "가장 오래된 보관 제거",
+ "storage.policy.targetReduce": "보관 용량을 다음까지 줄이기",
+ "storage.policy.thresholdInc": "임계값 증가",
+ "storage.policy.thresholdDec": "임계값 감소",
+ "storage.policy.percentInc": "퍼센트 증가",
+ "storage.policy.percentDec": "퍼센트 감소",
+ "storage.policy.reduceInc": "축소 목표 증가",
+ "storage.policy.reduceDec": "축소 목표 감소",
"storage.policy.schedule": "일정",
"storage.policy.schedule.manual": "수동만",
"storage.policy.schedule.startup": "프록시 시작 시",
@@ -1265,6 +1298,7 @@ export const ko: Record = {
"pws.dashboard.checkedAgo": "{time} 전 확인",
"pws.dashboard.noQuota": "할당량 데이터 없음",
"pws.dashboard.noUsage": "아직 사용 데이터 없음",
+ "pws.dashboard.noRateLimits": "아직 한도 데이터 없음",
"pws.allProviders": "프로바이더 개요",
"pws.enabledLabel": "활성화",
"pws.testConnection": "연결 테스트",
diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts
index 9a21fcae7..8c2eda35e 100644
--- a/gui/src/i18n/ru.ts
+++ b/gui/src/i18n/ru.ts
@@ -37,6 +37,7 @@ export const ru: Record = {
"startup.title": "Безопасность запуска",
"startup.subtitle": "Проверьте, сможет ли Codex подключиться к opencodex после перезагрузки, прежде чем локальный прокси вызовет бесконечное переподключение.",
"startup.refresh": "Обновить",
+ "startup.backToDashboard": "Назад к панели",
"startup.loading": "Проверка защиты запуска…",
"startup.error": "Не удалось прочитать состояние защиты запуска.",
"startup.staleData": "Последняя проверка не удалась. Значения ниже устарели и не подтверждают защиту.",
@@ -80,8 +81,12 @@ export const ru: Record = {
"startup.installedDisabled": "Установлен, но отключён",
"startup.install": "Установить",
"startup.installing": "Установка…",
+ "startup.repair": "Исправить",
+ "startup.repairing": "Исправление…",
"startup.serviceInstalled": "Фоновая служба успешно установлена.",
+ "startup.serviceRepaired": "Фоновая служба успешно исправлена.",
"startup.shimInstalled": "Launcher shim Codex успешно установлен.",
+ "startup.shimRepaired": "Launcher shim Codex успешно исправлен.",
"startup.installFailed": "Не удалось установить:",
"startup.tray.title": "Системный трей Windows",
"startup.tray.hint": "Запускает значок при входе для управления запуском, остановкой, перезапуском, панелью и состоянием прокси.",
@@ -488,6 +493,7 @@ export const ru: Record = {
"logs.conversation.excluded": "(из ~$ исключены {unpriced} без цены, {unmetered} без учёта)",
"logs.detail.conversation": "Диалог",
"logs.badge.claude": "Claude",
+ "logs.badge.grok": "Grok",
"logs.col.time": "Время",
"logs.col.request": "Запрос",
"logs.col.model": "Модель",
@@ -627,16 +633,27 @@ export const ru: Record = {
"nav.storage": "Хранилище",
"storage.title": "Хранилище",
- "storage.subtitle": "Диагностика диска CODEX_HOME. Ниже — очистка архива: карантин или безвозвратное удаление старых архивных сессий; активные сессии остаются только для чтения.",
+ "storage.subtitle": "Смотрите, что занимает CODEX_HOME. Очистка не затрагивает активные сессии.",
"storage.loading": "Сканирование хранилища…",
"storage.empty": "CODEX_HOME пуст или отсутствует — показывать нечего.",
"storage.error": "Не удалось просканировать хранилище. Убедитесь, что CODEX_HOME указывает на корректный каталог.",
"storage.refresh": "Пересканировать",
+ "storage.rescanned": "Сканирование завершено.",
"storage.card.total": "Общий размер",
"storage.card.files": "Файлы",
"storage.card.home": "CODEX_HOME",
+ "storage.snapshot.lastScan": "Последний скан",
+ "storage.snapshot.scanning": "Сканирование…",
+ "storage.snapshot.unavailable": "Сканирования ещё не было.",
+ "storage.cleanupCard.title": "Освободить место",
+ "storage.cleanupCard.tabs": "Параметры очистки",
+ "storage.cleanupCard.tab.policy": "Политика",
+ "storage.cleanupCard.tab.quarantine": "Карантин",
+ "storage.cleanup.noArchives": "Нет архивных сессий для очистки.",
"storage.section.buckets": "Категории",
"storage.section.largest": "Крупнейшие файлы",
+ "storage.workspace.overview": "Обзор",
+ "storage.workspace.selectBucket": "Выберите сегмент в списке, чтобы увидеть разбивку.",
"storage.col.bucket": "Категория",
"storage.col.size": "Размер",
"storage.col.files": "Файлы",
@@ -654,7 +671,7 @@ export const ru: Record = {
"storage.cleanup.title": "Очистка архива",
"storage.cleanup.help": "Удаляет самые старые архивные сессии по проценту. Активные сессии не затрагиваются. По умолчанию — карантин: файлы перемещаются в CODEX_HOME/.trash.",
"storage.cleanup.slider": "Доля самых старых архивов",
- "storage.cleanup.percent": "Самые старые {percent}%",
+ "storage.cleanup.percent": "{percent}%",
"storage.cleanup.preset": "{percent}",
"storage.cleanup.preview": "Предпросмотр",
"storage.cleanup.confirmTitle": "Подтвердить очистку архива",
@@ -662,7 +679,7 @@ export const ru: Record = {
"storage.cleanup.moreFiles": "…и ещё {n}",
"storage.cleanup.permanent": "Удалить навсегда (без карантина)",
"storage.cleanup.permanentWarn": "Безвозвратное удаление нельзя отменить.",
- "storage.cleanup.quarantineNote": "Файлы перемещаются в .trash под CODEX_HOME. Восстановить можно в разделе «Карантин» ниже.",
+ "storage.cleanup.quarantineNote": "Файлы перемещаются в .trash под CODEX_HOME. Восстановить можно на вкладке «Карантин».",
"storage.cleanup.cancel": "Отмена",
"storage.cleanup.confirmQuarantine": "В карантин",
"storage.cleanup.confirmPermanent": "Удалить навсегда",
@@ -721,10 +738,17 @@ export const ru: Record = {
"storage.policy.invalid": "Недопустимые значения политики.",
"storage.policy.enabled": "Включить автоочистку",
"storage.policy.enabledHint": "По умолчанию выкл. При включении работает только по выбранному расписанию (или «Запустить сейчас»).",
- "storage.policy.threshold": "Запуск, если размер архива больше (ГиБ)",
+ "storage.policy.threshold": "Когда размер архива больше",
+ "storage.policy.trigger": "Триггер",
"storage.policy.target": "Цель очистки",
- "storage.policy.targetPercent": "Удалить процент самых старых архивов",
- "storage.policy.targetReduce": "Уменьшить архив до (ГиБ)",
+ "storage.policy.targetPercent": "Удалить самые старые архивы",
+ "storage.policy.targetReduce": "Уменьшить архив до",
+ "storage.policy.thresholdInc": "Увеличить порог",
+ "storage.policy.thresholdDec": "Уменьшить порог",
+ "storage.policy.percentInc": "Увеличить процент",
+ "storage.policy.percentDec": "Уменьшить процент",
+ "storage.policy.reduceInc": "Увеличить целевой размер",
+ "storage.policy.reduceDec": "Уменьшить целевой размер",
"storage.policy.schedule": "Расписание",
"storage.policy.schedule.manual": "Только вручную",
"storage.policy.schedule.startup": "При запуске прокси",
@@ -981,6 +1005,7 @@ export const ru: Record = {
"pws.dashboard.checkedAgo": "Проверено {time}",
"pws.dashboard.noQuota": "Нет данных о квоте",
"pws.dashboard.noUsage": "Данных об использовании пока нет",
+ "pws.dashboard.noRateLimits": "Данных о лимитах пока нет",
"pws.allProviders": "Обзор провайдеров",
"pws.enabledLabel": "Включён",
"pws.testConnection": "Проверить подключение",
@@ -1098,6 +1123,8 @@ export const ru: Record = {
"codexAuth.autoSwitchOffDesc": "Автоматическое переключение аккаунтов выключено",
"codexAuth.autoSwitchThreshold": "Порог переключения",
"codexAuth.autoSwitchThresholdAria": "Порог переключения в процентах",
+ "codexAuth.autoSwitchThresholdInc": "Увеличить порог переключения",
+ "codexAuth.autoSwitchThresholdDec": "Уменьшить порог переключения",
"codexAuth.autoSwitchLoadFailed": "Не удалось загрузить настройку автоматического переключения аккаунтов.",
"codexAuth.autoSwitchThresholdInvalid": "Введите целое число от 1 до 100",
"codexAuth.autoSwitchUpdated": "Настройки автоматического переключения обновлены",
@@ -1124,6 +1151,8 @@ export const ru: Record = {
"accountPool.strategyHint": "Применяется только к новым сессиям; существующие треды сохраняют привязку к аккаунту.",
"accountPool.stickyLimit": "Успешных запросов до ротации",
"accountPool.stickyLimitAria": "Успешных запросов до ротации",
+ "accountPool.stickyLimitInc": "Увеличить sticky-лимит",
+ "accountPool.stickyLimitDec": "Уменьшить sticky-лимит",
"accountPool.stickyLimitHelp": "Удерживать выбранный аккаунт на указанное число успешных привязок новых сессий, прежде чем перейти дальше.",
"accountPool.stickyLimitInvalid": "Введите целое число от 1 до 100",
"accountPool.strategyLoadFailed": "Не удалось загрузить стратегию ротации.",
@@ -1240,7 +1269,12 @@ export const ru: Record = {
"api.generate": "Сгенерировать",
"api.generating": "Создание…",
"api.activeKeys": "Активные ключи ({count})",
+ "api.activeKeysLoading": "Активные ключи",
"api.noKeys": "API-ключей пока нет. Сгенерируйте ключ выше.",
+ "api.copyUrlHint": "Нажмите, чтобы скопировать URL",
+ "api.urlCopied": "URL скопирован",
+ "api.copyExampleHint": "Нажмите, чтобы скопировать пример",
+ "api.exampleCopied": "Пример скопирован",
"api.colName": "Имя",
"api.colKey": "Ключ",
"api.colCreated": "Создан",
diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts
index 1ccd0f8a0..626c70076 100644
--- a/gui/src/i18n/zh.ts
+++ b/gui/src/i18n/zh.ts
@@ -32,6 +32,7 @@ export const zh: Record = {
"startup.title": "启动安全",
"startup.subtitle": "检查重启后 Codex 是否仍能连接 opencodex,避免本地代理路由陷入重复重连。",
"startup.refresh": "刷新",
+ "startup.backToDashboard": "返回仪表盘",
"startup.loading": "正在检查启动保护…",
"startup.error": "无法读取启动保护状态。",
"startup.staleData": "最新启动检查失败。以下数据已过期,不能视为已受保护的证明。",
@@ -75,8 +76,12 @@ export const zh: Record = {
"startup.installedDisabled": "已安装但禁用",
"startup.install": "安装",
"startup.installing": "正在安装…",
+ "startup.repair": "修复",
+ "startup.repairing": "正在修复…",
"startup.serviceInstalled": "后台服务安装成功。",
+ "startup.serviceRepaired": "后台服务修复成功。",
"startup.shimInstalled": "Codex 启动器 shim 安装成功。",
+ "startup.shimRepaired": "Codex 启动器 shim 修复成功。",
"startup.installFailed": "安装失败:",
"startup.tray.title": "Windows 系统托盘",
"startup.tray.hint": "登录时启动托盘图标,一键控制代理启动、停止、重启、面板和状态。",
@@ -483,6 +488,7 @@ export const zh: Record = {
"logs.conversation.excluded": "(~$ 已排除 {unpriced} 条无定价、{unmetered} 条无计量)",
"logs.detail.conversation": "会话",
"logs.badge.claude": "Claude",
+ "logs.badge.grok": "Grok",
"logs.col.time": "时间",
"logs.col.request": "请求",
"logs.col.model": "模型",
@@ -702,6 +708,8 @@ export const zh: Record = {
"codexAuth.autoSwitchOffDesc": "自动切换账号已关闭",
"codexAuth.autoSwitchThreshold": "切换阈值",
"codexAuth.autoSwitchThresholdAria": "切换阈值(百分比)",
+ "codexAuth.autoSwitchThresholdInc": "提高切换阈值",
+ "codexAuth.autoSwitchThresholdDec": "降低切换阈值",
"codexAuth.autoSwitchLoadFailed": "无法加载自动切换账号设置。",
"codexAuth.autoSwitchThresholdInvalid": "请输入 1 到 100 之间的整数",
"codexAuth.autoSwitchUpdated": "自动切换账号设置已更新",
@@ -728,6 +736,8 @@ export const zh: Record = {
"accountPool.strategyHint": "仅适用于新会话;现有线程保持账号亲和性。",
"accountPool.stickyLimit": "轮换前的粘性成功次数",
"accountPool.stickyLimitAria": "轮换前的粘性成功次数",
+ "accountPool.stickyLimitInc": "提高粘性上限",
+ "accountPool.stickyLimitDec": "降低粘性上限",
"accountPool.stickyLimitHelp": "在推进到下一个账号之前,将所选账号保留这么多次成功的新会话绑定。",
"accountPool.stickyLimitInvalid": "请输入 1 到 100 之间的整数",
"accountPool.strategyLoadFailed": "无法加载轮换策略。",
@@ -842,7 +852,12 @@ export const zh: Record = {
"api.generate": "生成",
"api.generating": "创建中…",
"api.activeKeys": "活跃密钥({count})",
+ "api.activeKeysLoading": "有效密钥",
"api.noKeys": "还没有 API 密钥。请在上方生成一个。",
+ "api.copyUrlHint": "点击复制 URL",
+ "api.urlCopied": "已复制 URL",
+ "api.copyExampleHint": "点击复制示例",
+ "api.exampleCopied": "已复制示例",
"api.colName": "名称",
"api.colKey": "密钥",
"api.colCreated": "创建时间",
@@ -935,16 +950,27 @@ export const zh: Record = {
"nav.storage": "存储",
"storage.title": "存储",
- "storage.subtitle": "CODEX_HOME 磁盘占用诊断。下方归档清理可将最旧归档会话隔离或永久删除——活动会话保持只读。",
+ "storage.subtitle": "查看 CODEX_HOME 占用。清理不会动到活动会话。",
"storage.loading": "正在扫描存储…",
"storage.empty": "CODEX_HOME 为空或不存在——没有可显示的内容。",
"storage.error": "存储扫描失败。请检查 CODEX_HOME 是否指向有效目录。",
"storage.refresh": "重新扫描",
+ "storage.rescanned": "扫描完成。",
"storage.card.total": "总大小",
"storage.card.files": "文件数",
"storage.card.home": "CODEX_HOME",
+ "storage.snapshot.lastScan": "上次扫描",
+ "storage.snapshot.scanning": "扫描中…",
+ "storage.snapshot.unavailable": "尚无扫描。",
+ "storage.cleanupCard.title": "释放空间",
+ "storage.cleanupCard.tabs": "清理选项",
+ "storage.cleanupCard.tab.policy": "策略",
+ "storage.cleanupCard.tab.quarantine": "隔离区",
+ "storage.cleanup.noArchives": "没有可清理的归档会话。",
"storage.section.buckets": "分类",
"storage.section.largest": "最大文件",
+ "storage.workspace.overview": "概览",
+ "storage.workspace.selectBucket": "从列表中选择一个存储桶以查看明细。",
"storage.col.bucket": "分类",
"storage.col.size": "大小",
"storage.col.files": "文件",
@@ -962,7 +988,7 @@ export const zh: Record = {
"storage.cleanup.title": "归档清理",
"storage.cleanup.help": "按百分比移除最旧的归档会话。不会触碰活动会话。默认隔离——文件移至 CODEX_HOME/.trash。",
"storage.cleanup.slider": "最旧归档百分比",
- "storage.cleanup.percent": "最旧 {percent}%",
+ "storage.cleanup.percent": "{percent}%",
"storage.cleanup.preset": "{percent}",
"storage.cleanup.preview": "预览",
"storage.cleanup.confirmTitle": "确认归档清理",
@@ -970,7 +996,7 @@ export const zh: Record = {
"storage.cleanup.moreFiles": "…以及另外 {n} 个",
"storage.cleanup.permanent": "永久删除(跳过隔离)",
"storage.cleanup.permanentWarn": "永久删除无法撤销。",
- "storage.cleanup.quarantineNote": "文件会移到 CODEX_HOME 下的 .trash。可在下方「隔离区」恢复。",
+ "storage.cleanup.quarantineNote": "文件会移到 CODEX_HOME 下的 .trash。可在「隔离区」标签页恢复。",
"storage.cleanup.cancel": "取消",
"storage.cleanup.confirmQuarantine": "隔离",
"storage.cleanup.confirmPermanent": "永久删除",
@@ -1029,10 +1055,17 @@ export const zh: Record = {
"storage.policy.invalid": "策略值无效。",
"storage.policy.enabled": "启用自动清理",
"storage.policy.enabledHint": "默认关闭。启用后仅按所选计划(或立即运行)执行。",
- "storage.policy.threshold": "归档大小超过时触发(GiB)",
+ "storage.policy.threshold": "当归档大小超过",
+ "storage.policy.trigger": "触发条件",
"storage.policy.target": "清理目标",
- "storage.policy.targetPercent": "删除最旧归档百分比",
- "storage.policy.targetReduce": "将归档缩小至(GiB)",
+ "storage.policy.targetPercent": "删除最旧归档",
+ "storage.policy.targetReduce": "将归档缩小至",
+ "storage.policy.thresholdInc": "提高阈值",
+ "storage.policy.thresholdDec": "降低阈值",
+ "storage.policy.percentInc": "提高百分比",
+ "storage.policy.percentDec": "降低百分比",
+ "storage.policy.reduceInc": "提高缩减目标",
+ "storage.policy.reduceDec": "降低缩减目标",
"storage.policy.schedule": "计划",
"storage.policy.schedule.manual": "仅手动",
"storage.policy.schedule.startup": "代理启动时",
@@ -1265,6 +1298,7 @@ export const zh: Record = {
"pws.dashboard.checkedAgo": "{time} 前检查",
"pws.dashboard.noQuota": "无配额数据",
"pws.dashboard.noUsage": "暂无使用数据",
+ "pws.dashboard.noRateLimits": "暂无速率限制数据",
"pws.allProviders": "提供商概览",
"pws.enabledLabel": "已启用",
"pws.testConnection": "测试连接",
diff --git a/gui/src/pages/CodexAuth.tsx b/gui/src/pages/CodexAuth.tsx
index 246cf5e5f..1ecc36d86 100644
--- a/gui/src/pages/CodexAuth.tsx
+++ b/gui/src/pages/CodexAuth.tsx
@@ -3,6 +3,7 @@ import { useT } from "../i18n/shared";
import CodexAccountPool from "../components/CodexAccountPool";
import { codexAccountModeState, type CodexAccountModeState } from "../codex-multi-state";
import { ensureOpenAiProvider, openAiAccountProviderState, OpenAiEnableError } from "../provider-payload";
+import { readSessionListCache, writeSessionListCache } from "../session-list-cache";
export type OpenAiAccountBannerState = CodexAccountModeState | "invalid" | null;
@@ -17,17 +18,33 @@ export function OpenAiAccountModeBanner({
}) {
const t = useT();
return (
-
+
{t("codexAuth.accountModeTitle")}
- {state === "pool" && {t("codexAuth.accountModePool")}}
- {state === "direct" && {t("codexAuth.accountModeDirect")}}
+ {state === null ? (
+
+ {t("codexAuth.accountModePool")}
+
+ ) : state === "pool" ? (
+ {t("codexAuth.accountModePool")}
+ ) : state === "direct" ? (
+ {t("codexAuth.accountModeDirect")}
+ ) : null}
+ {/*
+ Reserve the description line while config is still unknown so the pool
+ section below does not jump when /api/config arrives.
+ */}
+ {state === null && (
+
+
+
+ )}
{state === "pool" && (
-
{t("codexAuth.accountModePoolDesc")}
+
{t("codexAuth.accountModePoolDesc")}
)}
{state === "direct" && (
-
+
{t("codexAuth.accountModeDirectDesc")} {t("codexAuth.openProviders")}
)}
@@ -40,7 +57,7 @@ export function OpenAiAccountModeBanner({
)}
{state === "invalid" && (
-
+
{t("codexAuth.openaiMissing")} {t("codexAuth.openProviders")}
)}
@@ -68,6 +85,11 @@ function openaiProviderFromConfig(config: unknown): {
};
}
+type CachedMode = {
+ bannerState: OpenAiAccountBannerState;
+ accountModeState: CodexAccountModeState | null;
+};
+
/**
* Codex Auth page — a thin wrapper around CodexAccountPool (WP060 extraction).
* The page owns the /api/config fetch feeding the account-mode banner and
@@ -75,8 +97,12 @@ function openaiProviderFromConfig(config: unknown): {
*/
export default function CodexAuth({ apiBase }: { apiBase: string }) {
const t = useT();
- const [bannerState, setBannerState] = useState
(null);
- const [accountModeState, setAccountModeState] = useState(null);
+ const configCacheKey = `ocx.codex-auth.config.v1:${apiBase}`;
+ const cached = readSessionListCache(configCacheKey);
+ const [bannerState, setBannerState] = useState(() => cached?.bannerState ?? null);
+ const [accountModeState, setAccountModeState] = useState(
+ () => cached?.accountModeState ?? null,
+ );
const [enableBusy, setEnableBusy] = useState(false);
const [enableError, setEnableError] = useState("");
@@ -89,17 +115,19 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) {
if (providerState === "absent" || providerState === "disabled" || providerState === "invalid") {
setBannerState(providerState);
// Non-canonical / missing rows are not a live Codex account mode.
- setAccountModeState(providerState === "disabled" ? "disabled" : "absent");
+ const mode = providerState === "disabled" ? "disabled" as const : "absent" as const;
+ setAccountModeState(mode);
+ writeSessionListCache(configCacheKey, { bannerState: providerState, accountModeState: mode });
return;
}
const mode = codexAccountModeState(config);
setBannerState(mode);
setAccountModeState(mode);
+ writeSessionListCache(configCacheKey, { bannerState: mode, accountModeState: mode });
} catch {
- setBannerState(null);
- setAccountModeState(null);
+ // Keep last-good banner on transient config failures.
}
- }, [apiBase]);
+ }, [apiBase, configCacheKey]);
useEffect(() => {
const timeout = window.setTimeout(() => { void loadMode(); }, 0);
diff --git a/gui/src/session-list-cache.ts b/gui/src/session-list-cache.ts
new file mode 100644
index 000000000..e970f8334
--- /dev/null
+++ b/gui/src/session-list-cache.ts
@@ -0,0 +1,30 @@
+/**
+ * SessionStorage helpers for non-secret GUI list/summary shapes (SWR seeds).
+ * Never store API keys, tokens, or credentials here — XSS can read sessionStorage.
+ */
+
+export function readSessionListCache(key: string): T | null {
+ try {
+ const raw = sessionStorage.getItem(key);
+ if (!raw) return null;
+ return JSON.parse(raw) as T;
+ } catch {
+ return null;
+ }
+}
+
+export function writeSessionListCache(key: string, value: unknown): void {
+ try {
+ sessionStorage.setItem(key, JSON.stringify(value));
+ } catch {
+ /* private mode / quota */
+ }
+}
+
+export function clearSessionListCache(key: string): void {
+ try {
+ sessionStorage.removeItem(key);
+ } catch {
+ /* ignore */
+ }
+}
diff --git a/gui/src/styles.css b/gui/src/styles.css
index 29a04a56d..cce56b98c 100644
--- a/gui/src/styles.css
+++ b/gui/src/styles.css
@@ -825,9 +825,32 @@ dialog.modal-overlay::backdrop {
.quota-reset-time { font-size: var(--text-caption); color: var(--muted); white-space: nowrap; }
.quota-reset-time { font-family: var(--font-code); }
.bar { height: 5px; background: var(--raised); border-radius: var(--radius-2xs); overflow: hidden; min-width: 0; }
-.bar-fill { height: 100%; border-radius: var(--radius-2xs); transition: width var(--motion-normal); }
+.bar-fill {
+ height: 100%;
+ width: 100%;
+ border-radius: var(--radius-2xs);
+ transform: scaleX(var(--bar-scale, 0));
+ transform-origin: left center;
+ transition: transform var(--motion-normal);
+}
.bar-green { background: var(--green); }
.bar-amber { background: linear-gradient(90deg, var(--green), var(--amber)); }
+.quota-row--skeleton { min-height: 18px; }
+.quota-skel {
+ display: inline-block;
+ min-height: 8px;
+ border-radius: var(--radius-2xs);
+ background: color-mix(in srgb, var(--muted) 22%, transparent);
+}
+.quota-skel--label { width: 34px; }
+.quota-skel--reset { width: 34px; }
+.quota-skel--day { width: 36px; }
+.quota-skel--time { width: 38px; }
+.quota-skel--bar { min-height: 5px; width: 100%; }
+.quota-skel--val { width: 30px; }
+.openai-account-mode-banner__badge-slot--pending {
+ visibility: hidden;
+}
.toggle { width: var(--toggle-w); height: var(--toggle-h); border-radius: var(--radius-pill); background: var(--toggle-off-bg); border: 1px solid var(--border); cursor: pointer; position: relative; transition: background var(--motion-normal), border-color var(--motion-normal); flex-shrink: 0; }
.toggle.on { background: var(--toggle-on-bg); border-color: var(--toggle-on-bg); }
.toggle-knob { width: 16px; height: 16px; background: light-dark(#ffffff, #ececec); border-radius: var(--radius-round); position: absolute; top: 50%; left: 1px; transform: translateY(-50%); transition: left var(--motion-normal), background var(--motion-normal); }
diff --git a/gui/src/styles/provider-quota.css b/gui/src/styles/provider-quota.css
index 4b116d366..b11ed3d45 100644
--- a/gui/src/styles/provider-quota.css
+++ b/gui/src/styles/provider-quota.css
@@ -15,10 +15,16 @@
gap: 3px;
}
-.quota-stacked {
+.quota-stacked,
+.quota-stacked--pending {
display: flex;
flex-direction: column;
gap: 10px;
+ min-height: 64px;
+}
+
+.quota-stacked-row--skeleton .quota-stacked-bar-row {
+ min-height: 18px;
}
.quota-stacked-row {
diff --git a/gui/src/ui.tsx b/gui/src/ui.tsx
index 9477ba79a..e03d4b3b8 100644
--- a/gui/src/ui.tsx
+++ b/gui/src/ui.tsx
@@ -25,7 +25,7 @@ export function Notice({ tone, children }: { tone: "ok" | "err"; children: React
export interface SelectOption { value: string; label: React.ReactNode }
-export function Select({ value, options, onChange, disabled, label, style, align, placement, dropdownStyle, portal = true }: {
+export function Select({ value, options, onChange, disabled, label, style, align, placement, dropdownStyle, portal = true, id }: {
value: string;
options: SelectOption[];
onChange: (value: string) => void;
@@ -37,6 +37,8 @@ export function Select({ value, options, onChange, disabled, label, style, align
dropdownStyle?: CSSProperties;
/** When true (default), menu is portaled and flips above the trigger if it would leave the viewport. */
portal?: boolean;
+ /** Optional id on the trigger button (tests / labels target `#codex-pool-strategy`). */
+ id?: string;
}) {
const listboxId = useId();
const [open, setOpen] = useState(false);
@@ -204,6 +206,7 @@ export function Select({ value, options, onChange, disabled, label, style, align
{
/>
,
);
+ // Custom Select only paints the active label until opened (sidecar DNA).
expect(quota).toContain("Quota");
- expect(quota).toContain("Round-robin");
- expect(quota).toContain("Fill-first");
+ expect(quota).toContain("select-trigger");
expect(quota).toContain("Applies to new sessions only");
expect(quota).not.toContain("Sticky successes before rotate");
@@ -172,6 +172,7 @@ describe("AccountPoolStrategyControls", () => {
/>
,
);
+ expect(rr).toContain("Round-robin");
expect(rr).toContain("Sticky successes before rotate");
expect(rr).toContain('value="2"');
});
@@ -218,6 +219,74 @@ describe("CodexPoolStrategySetting optimistic strategy select", () => {
await teardownDom();
});
+ function strategyTrigger(host: ParentNode): HTMLButtonElement {
+ const el = host.querySelector("#codex-pool-strategy");
+ if (!el) throw new Error("strategy select missing");
+ return el;
+ }
+
+ async function pickStrategy(host: ParentNode, label: string): Promise {
+ await act(async () => {
+ strategyTrigger(host).click();
+ await flush();
+ });
+ const option = Array.from(testWindow.document.querySelectorAll('[role="option"]'))
+ .find((el) => (el.textContent ?? "").includes(label));
+ if (!option) throw new Error(`strategy option missing: ${label}`);
+ await act(async () => {
+ option.click();
+ await flush();
+ });
+ }
+
+ test("keeps strategy controls disabled until /active hydrates", async () => {
+ const active = deferred();
+ const puts: unknown[] = [];
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url.endsWith("/api/codex-auth/active") && (!init || init.method === undefined)) {
+ return active.promise;
+ }
+ if (url.endsWith("/api/codex-auth/pool-strategy") && init?.method === "PUT") {
+ puts.push(init.body ? JSON.parse(String(init.body)) : null);
+ return new Response(JSON.stringify({
+ ok: true,
+ accountPoolStrategy: "round-robin",
+ accountPoolStickyLimit: 1,
+ }), { status: 200 });
+ }
+ throw new Error(`unexpected fetch: ${url} ${init?.method ?? "GET"}`);
+ }) as typeof fetch;
+
+ const host = testWindow.document.createElement("div");
+ testWindow.document.body.appendChild(host as never);
+ const { createRoot } = await import("react-dom/client");
+ await act(async () => {
+ mountedRoot = createRoot(host);
+ mountedRoot.render(
+
+
+ ,
+ );
+ });
+ await act(async () => { await flush(); });
+
+ expect(strategyTrigger(host).disabled).toBe(true);
+ expect(puts).toEqual([]);
+
+ await act(async () => {
+ active.resolve(new Response(JSON.stringify({
+ accountPoolStrategy: "fill-first",
+ accountPoolStickyLimit: 3,
+ }), { status: 200 }));
+ await flush();
+ });
+
+ expect(strategyTrigger(host).disabled).toBe(false);
+ expect(strategyTrigger(host).textContent).toContain("Fill-first");
+ expect(puts).toEqual([]);
+ });
+
test("updates visible strategy before save completes", async () => {
const put = deferred();
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -247,20 +316,12 @@ describe("CodexPoolStrategySetting optimistic strategy select", () => {
});
await act(async () => { await flush(); });
- const select = () => host.querySelector("#codex-pool-strategy");
- expect(select()?.value).toBe("quota");
+ expect(strategyTrigger(host).textContent).toContain("Quota");
- await act(async () => {
- const el = select();
- if (!el) throw new Error("strategy select missing");
- Object.getOwnPropertyDescriptor(testWindow.HTMLSelectElement.prototype, "value")!
- .set!.call(el, "round-robin");
- el.dispatchEvent(new testWindow.Event("change", { bubbles: true }));
- await flush();
- });
+ await pickStrategy(host, "Round-robin");
- expect(select()?.value).toBe("round-robin");
- expect(select()?.disabled).toBe(true);
+ expect(strategyTrigger(host).textContent).toContain("Round-robin");
+ expect(strategyTrigger(host).disabled).toBe(true);
await act(async () => {
put.resolve(new Response(JSON.stringify({
@@ -271,8 +332,8 @@ describe("CodexPoolStrategySetting optimistic strategy select", () => {
await flush();
});
- expect(select()?.value).toBe("round-robin");
- expect(select()?.disabled).toBe(false);
+ expect(strategyTrigger(host).textContent).toContain("Round-robin");
+ expect(strategyTrigger(host).disabled).toBe(false);
expect(host.textContent).toContain("Sticky successes before rotate");
});
@@ -304,18 +365,81 @@ describe("CodexPoolStrategySetting optimistic strategy select", () => {
});
await act(async () => { await flush(); });
- const select = () => host.querySelector("#codex-pool-strategy");
+ await pickStrategy(host, "Fill-first");
+
+ expect(strategyTrigger(host).textContent).toContain("Quota");
+ expect(host.textContent).toContain("Rotation strategy could not be saved");
+ });
+
+ test("ignores stale shared /active reads that started before a successful save", async () => {
+ type Observer = {
+ beginActiveRead(): number;
+ acceptActiveRead(value: unknown, startedRevision: number): void;
+ rejectActiveRead(): void;
+ };
+ let observer: Observer | null = null;
+ const put = deferred();
+
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url.endsWith("/api/codex-auth/pool-strategy") && init?.method === "PUT") {
+ return put.promise;
+ }
+ throw new Error(`unexpected fetch: ${url} ${init?.method ?? "GET"}`);
+ }) as typeof fetch;
+
+ const host = testWindow.document.createElement("div");
+ testWindow.document.body.appendChild(host as never);
+ const { createRoot } = await import("react-dom/client");
await act(async () => {
- const el = select();
- if (!el) throw new Error("strategy select missing");
- Object.getOwnPropertyDescriptor(testWindow.HTMLSelectElement.prototype, "value")!
- .set!.call(el, "fill-first");
- el.dispatchEvent(new testWindow.Event("change", { bubbles: true }));
+ mountedRoot = createRoot(host);
+ mountedRoot.render(
+
+ {
+ observer = next;
+ return () => { observer = null; };
+ }}
+ />
+ ,
+ );
+ });
+ await act(async () => { await flush(); });
+
+ expect(observer).not.toBeNull();
+ const startedBeforeSave = observer!.beginActiveRead();
+ await act(async () => {
+ observer!.acceptActiveRead({
+ accountPoolStrategy: "quota",
+ accountPoolStickyLimit: 1,
+ }, startedBeforeSave);
await flush();
});
+ expect(strategyTrigger(host).textContent).toContain("Quota");
- expect(select()?.value).toBe("quota");
- expect(host.textContent).toContain("Rotation strategy could not be saved");
+ await pickStrategy(host, "Round-robin");
+ expect(strategyTrigger(host).textContent).toContain("Round-robin");
+
+ await act(async () => {
+ // Stale poll that began before the PUT must not clobber the optimistic value.
+ observer!.acceptActiveRead({
+ accountPoolStrategy: "quota",
+ accountPoolStickyLimit: 1,
+ }, startedBeforeSave);
+ await flush();
+ });
+ expect(strategyTrigger(host).textContent).toContain("Round-robin");
+
+ await act(async () => {
+ put.resolve(new Response(JSON.stringify({
+ ok: true,
+ accountPoolStrategy: "round-robin",
+ accountPoolStickyLimit: 1,
+ }), { status: 200 }));
+ await flush();
+ });
+ expect(strategyTrigger(host).textContent).toContain("Round-robin");
});
test("renders the card title once, without a duplicate field label", async () => {
diff --git a/gui/tests/codex-account-auto-switch.test.tsx b/gui/tests/codex-account-auto-switch.test.tsx
index 771bfdb68..cc4548c75 100644
--- a/gui/tests/codex-account-auto-switch.test.tsx
+++ b/gui/tests/codex-account-auto-switch.test.tsx
@@ -31,8 +31,8 @@ afterEach(() => {
});
function renderSetting(
- threshold: number | null,
- draft = threshold === null ? "" : String(threshold),
+ threshold: number,
+ draft = String(threshold > 0 ? threshold : 80),
saving = false,
loadError = false,
feedback: { tone: "ok" | "err"; message: string } | null = null,
@@ -133,17 +133,45 @@ describe("Codex account auto-switch threshold", () => {
expect(html).not.toContain('type="number"');
});
- test("does not expose an actionable placeholder before hydration", () => {
- const loading = renderSetting(null);
- expect(loading).toContain("Loading…");
- expect(loading).toContain('aria-busy="true"');
- expect(loading).not.toContain('type="number"');
- expect(loading).not.toContain('aria-pressed=');
+ test("paints controls immediately with the default threshold (no loading placeholder)", () => {
+ const html = renderSetting(DEFAULT_AUTO_SWITCH_THRESHOLD);
+ expect(html).toContain('type="number"');
+ expect(html).toContain('aria-pressed="true"');
+ expect(html).not.toContain("Loading…");
+ expect(html).not.toContain('aria-busy="true"');
+ });
+
+ test("blocks interaction until hydrated without hiding chrome", () => {
+ const html = renderToStaticMarkup(
+
+ {}}
+ onEditingChange={() => {}}
+ onCommit={async () => true}
+ onCancel={() => {}}
+ onToggle={async () => true}
+ onRetry={() => {}}
+ />
+ ,
+ );
+ expect(html).toContain('type="number"');
+ expect(html).toContain('aria-busy="true"');
+ expect(html).toContain('readOnly=""');
+ expect(html).toContain('disabled=""');
+ expect(html).not.toContain("Loading…");
+ });
- const failed = renderSetting(null, "", false, true);
+ test("surfaces load failure without hiding the toggle", () => {
+ const failed = renderSetting(DEFAULT_AUTO_SWITCH_THRESHOLD, String(DEFAULT_AUTO_SWITCH_THRESHOLD), false, true);
expect(failed).toContain("Automatic switching setting could not be loaded.");
expect(failed).toContain("Retry");
- expect(failed).not.toContain('type="number"');
+ expect(failed).toContain('aria-pressed="true"');
});
test("keeps the focused input present but read-only while a write is pending", () => {
diff --git a/gui/tests/codex-auto-switch-controller.test.tsx b/gui/tests/codex-auto-switch-controller.test.tsx
index 4c37b5c1b..9f95eda94 100644
--- a/gui/tests/codex-auto-switch-controller.test.tsx
+++ b/gui/tests/codex-auto-switch-controller.test.tsx
@@ -216,6 +216,100 @@ async function mountHarness(): Promise {
}
describe("Codex auto-switch controller interactions", () => {
+ test("blocks writes while /active is still pending", async () => {
+ const active = deferred();
+ const activeResponses: Array | Response> = [active.promise];
+ const putResponses: Array | Response> = [];
+ const writes: number[] = [];
+ let refreshCallback: (() => void) | null = null;
+
+ Object.defineProperty(testWindow, "setInterval", {
+ configurable: true,
+ value: (callback: () => void, delay?: number) => {
+ if (delay === 30_000) refreshCallback = callback;
+ return 1;
+ },
+ });
+ Object.defineProperty(testWindow, "clearInterval", {
+ configurable: true,
+ value: () => {},
+ });
+
+ const fetchRouter = async (input: string | URL | Request, init?: RequestInit): Promise => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? (input instanceof Request ? input.method : "GET");
+ if (url.endsWith("/api/codex-auth/accounts") && method === "GET") {
+ return Response.json({ accounts: [] });
+ }
+ if (url.endsWith("/api/codex-auth/active") && method === "GET") {
+ const response = activeResponses.shift();
+ if (response) return await response;
+ return Response.json({
+ activeCodexAccountId: null,
+ autoSwitchThreshold: 55,
+ accountPoolStrategy: "quota",
+ accountPoolStickyLimit: 1,
+ });
+ }
+ if (url.endsWith("/api/codex-auth/pool-strategy") && (method === "PUT" || method === "PATCH")) {
+ return Response.json({
+ ok: true,
+ accountPoolStrategy: "quota",
+ accountPoolStickyLimit: 1,
+ });
+ }
+ if (url.endsWith("/api/codex-auth/auto-switch") && method === "PUT") {
+ const body = JSON.parse(String(init?.body)) as { threshold: number };
+ writes.push(body.threshold);
+ const response = putResponses.shift();
+ if (!response) throw new Error("unexpected auto-switch write");
+ return await response;
+ }
+ throw new Error(`unexpected fetch: ${method} ${url}`);
+ };
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: fetchRouter });
+
+ const container = document.createElement("div");
+ document.body.append(container);
+ const { createRoot } = await import("react-dom/client");
+ const root = createRoot(container);
+ mountedRoot = root;
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ await flush();
+ });
+
+ const toggle = container.querySelector("button.toggle[aria-pressed]");
+ expect(toggle).not.toBeNull();
+ expect(toggle?.disabled).toBe(true);
+
+ await act(async () => {
+ toggle?.click();
+ await flush();
+ });
+ expect(writes).toEqual([]);
+
+ await act(async () => {
+ active.resolve(Response.json({
+ activeCodexAccountId: null,
+ autoSwitchThreshold: 55,
+ accountPoolStrategy: "quota",
+ accountPoolStickyLimit: 1,
+ }));
+ await flush();
+ });
+
+ const readyToggle = container.querySelector("button.toggle[aria-pressed]");
+ expect(readyToggle?.disabled).toBe(false);
+ expect(container.querySelector('input[aria-label="Switch threshold, percent"]')?.value).toBe("55");
+ expect(writes).toEqual([]);
+ expect(refreshCallback).not.toBeNull();
+ });
+
test("Enter then blur issues exactly one write", async () => {
const harness = await mountHarness();
const write = deferred();
@@ -235,7 +329,7 @@ describe("Codex auto-switch controller interactions", () => {
await flush();
});
expect(harness.input.value).toBe("95");
- expect(harness.container.querySelector('[role="status"]')?.textContent).toContain("updated");
+ expect(harness.container.querySelector("#codex-auto-switch-feedback")?.textContent).toContain("updated");
expect(harness.writes).toEqual([95]);
});
@@ -298,7 +392,7 @@ describe("Codex auto-switch controller interactions", () => {
expect(harness.input.value).toBe("80");
expect(harness.writes).toEqual([]);
- expect(harness.container.querySelector('[role="status"]')).toBeNull();
+ expect(harness.container.querySelector("#codex-auto-switch-feedback")).toBeNull();
});
test("pointer toggle disables a dirty valid draft before blur can commit it", async () => {
diff --git a/gui/tests/session-list-cache.test.ts b/gui/tests/session-list-cache.test.ts
new file mode 100644
index 000000000..226f23d5e
--- /dev/null
+++ b/gui/tests/session-list-cache.test.ts
@@ -0,0 +1,50 @@
+import { afterEach, expect, test } from "bun:test";
+import { Window } from "happy-dom";
+import {
+ clearSessionListCache,
+ readSessionListCache,
+ writeSessionListCache,
+} from "../src/session-list-cache";
+
+const globals = ["document", "window", "navigator", "sessionStorage"] as const;
+let previousGlobals: Record<(typeof globals)[number], unknown>;
+let testWindow: Window;
+
+function install() {
+ previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals;
+ testWindow = new Window({ url: "http://localhost/" });
+ Object.defineProperties(globalThis, {
+ document: { configurable: true, value: testWindow.document },
+ window: { configurable: true, value: testWindow },
+ navigator: { configurable: true, value: testWindow.navigator },
+ sessionStorage: { configurable: true, value: testWindow.sessionStorage },
+ });
+ sessionStorage.clear();
+}
+
+function restore() {
+ testWindow.close();
+ for (const key of globals) {
+ Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] });
+ }
+}
+
+afterEach(restore);
+
+test("session list cache round-trips non-secret JSON shapes", () => {
+ install();
+ writeSessionListCache("ocx.test", { range: "30d", surface: "claude", n: 3 });
+ expect(readSessionListCache<{ range: string; surface: string; n: number }>("ocx.test")).toEqual({
+ range: "30d",
+ surface: "claude",
+ n: 3,
+ });
+ clearSessionListCache("ocx.test");
+ expect(readSessionListCache("ocx.test")).toBeNull();
+});
+
+test("session list cache returns null for corrupt JSON", () => {
+ install();
+ sessionStorage.setItem("ocx.bad", "{not-json");
+ expect(readSessionListCache("ocx.bad")).toBeNull();
+});
diff --git a/src/codex/quota.ts b/src/codex/quota.ts
index 5fc147116..78a3a5f5e 100644
--- a/src/codex/quota.ts
+++ b/src/codex/quota.ts
@@ -1,3 +1,7 @@
+import { existsSync, readFileSync, unlinkSync } from "node:fs";
+import { join } from "node:path";
+import { atomicWriteFile, getConfigDir } from "../config";
+
export type StoredAccountQuota = {
weeklyPercent?: number;
monthlyPercent?: number;
@@ -7,6 +11,20 @@ export type StoredAccountQuota = {
updatedAt: number;
};
+/** Disk snapshot under OPENCODEX_HOME — usage percents only (no emails/tokens). */
+const QUOTA_CACHE_FILENAME = "codex-quota-cache.json";
+/** Keep last-known bars across restarts; WHAM still refreshes on TTL in live/prime paths. */
+const QUOTA_DISK_MAX_AGE_MS = 6 * 60 * 60_000;
+const QUOTA_PERSIST_DEBOUNCE_MS = 250;
+
+type QuotaDiskFile = {
+ version: 1;
+ quotas: Record;
+};
+
+let diskHydrated = false;
+let persistTimer: ReturnType | null = null;
+
export type WhamUsageResponse = {
email?: string | null;
plan_type?: string | null;
@@ -120,6 +138,7 @@ export function setAccountQuotaFromParsed(
if (existing?.monthlyResetAt !== undefined) next.monthlyResetAt = existing.monthlyResetAt;
next.resetCredits = quota.resetCredits;
accountQuota.set(accountId, next);
+ schedulePersistAccountQuotas();
return;
}
@@ -145,6 +164,7 @@ export function setAccountQuotaFromParsed(
else if (existing?.resetCredits !== undefined) next.resetCredits = existing.resetCredits;
accountQuota.set(accountId, next);
+ schedulePersistAccountQuotas();
}
export function parseUpstreamQuotaHeaders(headers: Headers): Omit | null {
@@ -235,19 +255,74 @@ export function updateAccountQuota(
if (resetCredits !== undefined) quota.resetCredits = resetCredits;
accountQuota.set(accountId, quota);
+ schedulePersistAccountQuotas();
+}
+
+function hydrateAccountQuotasFromDisk(): void {
+ if (diskHydrated) return;
+ diskHydrated = true;
+ try {
+ const path = join(getConfigDir(), QUOTA_CACHE_FILENAME);
+ if (!existsSync(path)) return;
+ const raw = readFileSync(path, "utf8");
+ const parsed = JSON.parse(raw) as QuotaDiskFile;
+ if (!parsed || parsed.version !== 1 || !parsed.quotas || typeof parsed.quotas !== "object") return;
+ const now = Date.now();
+ for (const [accountId, quota] of Object.entries(parsed.quotas)) {
+ if (!quota || typeof quota !== "object" || typeof quota.updatedAt !== "number") continue;
+ if (now - quota.updatedAt > QUOTA_DISK_MAX_AGE_MS) continue;
+ if (!accountQuota.has(accountId)) accountQuota.set(accountId, quota);
+ }
+ } catch {
+ // Corrupt/missing cache must never block routing or the dashboard.
+ }
+}
+
+function schedulePersistAccountQuotas(): void {
+ if (persistTimer) clearTimeout(persistTimer);
+ persistTimer = setTimeout(() => {
+ persistTimer = null;
+ try {
+ const quotas: Record = {};
+ for (const [accountId, quota] of accountQuota.entries()) {
+ quotas[accountId] = quota;
+ }
+ const body: QuotaDiskFile = { version: 1, quotas };
+ atomicWriteFile(join(getConfigDir(), QUOTA_CACHE_FILENAME), `${JSON.stringify(body)}\n`);
+ } catch {
+ // Best-effort persistence only.
+ }
+ }, QUOTA_PERSIST_DEBOUNCE_MS);
}
export function getAccountQuota(accountId: string): StoredAccountQuota | null {
+ hydrateAccountQuotasFromDisk();
return accountQuota.get(accountId) ?? null;
}
export function listAccountQuotas(): IterableIterator<[string, StoredAccountQuota]> {
+ hydrateAccountQuotasFromDisk();
return accountQuota.entries();
}
export function clearAccountQuota(accountId?: string): void {
- if (accountId) accountQuota.delete(accountId);
- else accountQuota.clear();
+ if (accountId) {
+ accountQuota.delete(accountId);
+ schedulePersistAccountQuotas();
+ return;
+ }
+ accountQuota.clear();
+ diskHydrated = false;
+ if (persistTimer) {
+ clearTimeout(persistTimer);
+ persistTimer = null;
+ }
+ try {
+ const path = join(getConfigDir(), QUOTA_CACHE_FILENAME);
+ if (existsSync(path)) unlinkSync(path);
+ } catch {
+ // Best-effort; memory is already cleared.
+ }
}
export function parseUsageQuota(data: WhamUsageResponse): Omit | null {