diff --git a/.github/pr-assets/quota-activation-advanced.png b/.github/pr-assets/quota-activation-advanced.png new file mode 100644 index 0000000000..074bf4c6b4 Binary files /dev/null and b/.github/pr-assets/quota-activation-advanced.png differ diff --git a/devlog/_plan/260905_unified_quota_activation/010_unified_control.md b/devlog/_plan/260905_unified_quota_activation/010_unified_control.md new file mode 100644 index 0000000000..e82058ffe9 --- /dev/null +++ b/devlog/_plan/260905_unified_quota_activation/010_unified_control.md @@ -0,0 +1,162 @@ +# Unified quota-window activation + +## Loop specification + +C2, one spec-satisfaction PABCD work-phase (`wp1`). Trigger: owner requested removing +the quota activation row from every account card, one all-account/both-window toggle +inside Advanced settings, and a PR with admin merge. DONE is verified UI and merged +dev ancestry. No backend/API contract, credential, worker, real account settings, +release, or service changes. Use existing GitHub credentials only for this repository; +isolated synthetic browser fixture; no paid probes or purchased credits; no token +budget specified; three-hour wall-clock reassessment. Upward escalation: main reclaims +after two distinct failed audit dispatches; downward delegation is read-only audit +and verification only. Memory: this document and the session-bound goalplan. + +## Design read and necessity + +Existing quiet React/Vite developer dashboard with translated copy and native `.toggle` +buttons. Preserve fonts, colors, cards and collapse mechanism; variance 2, motion 1, +density D5. No generated concepts/assets: this is an existing utility settings screen. +Do nothing leaves repetition; deleting the feature loses requested control; configuring +alone cannot change the UI. Reuse `CodexAuthAdvancedSettings`, settings API, quota +availability, existing toggle/card/feedback CSS. New API or global policy is unnecessary. +The action covers currently listed main/added accounts, not future-account inheritance. + +## Concrete changes + +- MODIFY `gui/src/components/CodexAccountPool.tsx`: remove card toggle props and + display-only per-account quota settings merge. Replace per-account write handler + with one all-account operation using the existing `{id, window, enabled}` PUT + shape. Keep shared settings GET for Spark and quota state; block unknown state, + provide retry after read failure, synchronous duplicate-click guard, serialize + per-window writes and reconcile settings with GET after success or failure. + Retain failed batch target (ON or OFF) and expose explicit Retry that recomputes + remaining granular changes toward that same target; a partial OFF retry must never + send ON. Failed/uncertain writes never show success; failed reconciliation leaves disabled + unknown state with retry. Keep busy state until reconciliation finishes. Ignore + stale reads using the mutation revision; abort on unmount/apiBase change and stop + the remaining batch. Reuse `createBoundedFetch` for deadlines. +- NEW `gui/src/components/CodexQuotaAutoRefreshSetting.tsx`: small stateless setting + card inside Advanced. One button, `aria-pressed` false/true/mixed, description that + names all current accounts and both supported windows, actual warmup spending and + pool-mode scope. Disabled while accounts/settings unknown, saving, or no eligible + window and no enabled stale setting; show loading/error/empty/mixed feedback. + Derived window descriptors reuse `quotaAutoRefreshAvailability` (legacy fallback) + and authoritative account availability. Aggregate on iff all available windows + enabled (or only stale enabled settings remain, so OFF stays reachable); mixed + resolves toward ON. OFF clears enabled fiveHour/weekly settings even unavailable. + Preserve server completion markers by issuing only existing granular mutations. +- MODIFY `gui/src/components/codex-account-pool-cards.tsx` and + `gui/src/components/codex-account-pool-main-card.tsx`: remove rendered activation + rows, their now-unused props/import, and the unreferenced controls function after + checking all callers. Account data contract stays intact. +- MODIFY `gui/src/styles.css`: replace dead per-account row styles with minimal + unified setting layout reusing adjacent settings conventions, with mobile wrapping. +- MODIFY all `gui/src/i18n/{locale}.ts`: new unified description, mixed, empty, + loading-failure/partial-failure labels; retain keys still in use. +- MODIFY `gui/tests/codex-account-pool-toast-tone.test.tsx`: integration regressions + using injected controller and mocked API: hidden advanced/no card rows; main + + weekly-only + 5h/weekly + unsupported account coverage; on/off payloads; mixed; + settings load failure/retry; blocked busy double-click; partial PUT failure and + reconciliation failure/retry; stale initial response; legacy data compatibility. + Update `gui/tests/main-account-hard-lock-setting.test.tsx` props only where removed. +- MODIFY `docs-site/src/content/docs/getting-started/how-it-works.mdx`, + `docs-site/src/content/docs/reference/configuration/providers.md`, and + `structure/08_openai-provider-tiers.md`: replace per-card UI instructions with the + advanced bulk control, current supported windows, non-atomic batch/failure behavior. + No new serialized fields/enums: creation/serialization/deserialization unchanged; + existing PUT and GET consumers verified in `config-routes.ts:118,461,551`. + +## Verification and boundaries + +Baseline attempts `bun test tests/gui/quota-bars-rows.test.ts` and GUI focused tests +found missing dependencies in the fresh worktree; install frozen lockfiles before +rerunning. Direct file arguments prove target coverage. Run existing focused files +before and after; add one failing regression before production changes. Runtime quota +tests cover existing settings behavior; no changed runtime code. + +PR-ready commands: root `bun run typecheck`, `bun run test`, `bun run privacy:scan`; +GUI `bun test tests`, `bun run lint`, `bun run lint:i18n`, `bun run build`; docs +`bun install --frozen-lockfile && bun run build`. Script definitions inspected in +package manifests; new commands are pending execution, not claimed as passed. +Do not rerun passing checks against unchanged code. Native browser screenshot and +keyboard click-through use synthetic data on a separate localhost port (not 10100). +Independent read-only A and C audits. One cohesive PR (not a stack); screenshot is +privacy-safe and committed under `.github/pr-assets/`. Fill the repository PR template, +record the owner-authorized admin bypass, verify exact head CI and findings, then +merge with head-match guard and prove `merge-base --is-ancestor` on fetched dev. +No enforcement is introduced; all existing auth/worker gates remain authoritative. + +## Acceptance activation matrix + +| Trigger | Observable result | +| --- | --- | +| Default collapsed Advanced | No per-account activation row; no visible bulk toggle | +| Expand with all off | One accessible control; click enables supported windows for all current accounts | +| All enabled | Click clears fiveHour and weekly flags, including stale unavailable ones | +| Partial existing state | `aria-pressed=mixed`; click enables remaining supported windows | +| No windows/empty account set | Disabled control with explanation, no PUT | +| Delayed/failed GET | No guessed pressed state; retry reloads confirmed settings | +| Double click during delayed PUT | One batch only; busy holds through reconciliation | +| One PUT fails | No success claim; GET reveals actual partial state; explicit Retry retains original ON/OFF intent | +| Reconciliation fails | Unknown disabled state, explicit error and retry | +| Old apiBase GET resolves after switching proxy | Scope/revision guard prevents old state or old remaining writes | +| Legacy account payload | Availability fallback works; unrelated selection-order control still works | + +## Evidence + +Baseline after frozen installs: 13 root quota-row tests and 30 GUI tests passed. + +A1 synthesis: accepted reviewer blocker: mixed defaults ON, so partial OFF must not +use the normal toggle as retry. Root cause was conflating aggregate display with +operation intent. No conflicting requirements; add retained failed target and an +explicit retry path (including a no-ON-writes partial-OFF regression). Initial GET +cannot race a mutation while the control is unknown/disabled, so replace that +unreachable row with old-apiBase GET / in-flight batch cancellation scenarios. + +B verification: failing regression first proved per-card row remained, then passed +with the unified setting. Nine new integration cases plus eight original tests pass. +Lint required extracting shared pure data functions to `gui/src/codex-quota-activation.ts` +(component-only fast refresh) and keying settings snapshots by proxy/read revision +instead of resetting React state inside an effect. No runtime/API shape changes. +The existing large pool component remains the lifecycle owner; no unrelated split. +The single PR keeps the tightly coupled UI, regressions, all locales, and docs together. + +C1 synthesis: accepted reviewer stale-proxy-incarnation blocker. An A/B/A return +could match an old A snapshot before the fresh read, and failed reads lacked the +mutation revision guard. Invalidate the snapshot at the API prop boundary (guarded +React state adjustment), advance its read revision, and guard errors like successes. +Add A/B/A pending+failed GET followed by OFF-batch coverage. Existing auto-switch +controller tests also need accurate settings GET fixtures and selectors scoped to +their own `.codex-auto-switch-card`; no assertions or behavior coverage removed. + +Hosted React Doctor reported only `async-await-in-loop` on the intentional settings +write sequence. Classified false positive: unlike independent reads, writes must not +dispatch the rest of a billable opt-in batch before cancellation. The deferred-write +test proves that switching proxy prevents all unsent writes. Use the existing narrow +documented suppression convention from `IntegrationsOverview.tsx:398`, not a global +rule/config change or a parallel rewrite. No runtime behavior changes in this repair. + +Verified implementation: GUI full suite 1453 pass / 0 fail; focused 49 pass; import- +connected root selection 110 pass / 0 fail; root typecheck, GUI lint/i18n/build, +privacy scan and 425-page docs build passed. Root full suite was interrupted with +exit 143 and is not claimed green; exact-head hosted runtime CI is the landing gate. +Independent reviewer closed partial-OFF and A/B/A findings with PASS. Browser drove +the actual pool component with synthetic data (no live credentials/upstream), including +ON, keyboard OFF, partial failure/retry, mixed and empty states. CSS widths 320, +390, 768, approximately 1024 and 1440 show no horizontal overflow or clipped setting +copy. Light/dark screenshots checked; port 10191 and temporary browser tab torn down. +Delivery PR: #3662; administrative approval bypass explicitly authorized by the owner +and recorded on the PR. No service restart or release belongs to this unit. + +C2 synthesis (hosted Codex review): accepted P2 on transiently unavailable windows. +ON must skip unavailable windows entirely, preserving previously opted-in flags; +only explicit OFF clears those flags. The final readback verifies only the targeted +available windows for ON, but every window for OFF. Updated the mixed-state regression +to preserve the unavailable opt-in and then prove explicit OFF clears it. This corrects +the earlier stale-cleanup interpretation without changing the API or worker. + +C3 copy-only review closure: French now names each account's supported windows and +uses the existing Mode Groupe label; traditional and simplified Chinese explicitly +say each account's own supported windows. This avoids an intersection-of-all-accounts +reading. No behavior or Korean layout changes; validate locale lint and GUI build. diff --git a/docs-site/src/content/docs/getting-started/how-it-works.mdx b/docs-site/src/content/docs/getting-started/how-it-works.mdx index 5fbb60b912..0344037b75 100644 --- a/docs-site/src/content/docs/getting-started/how-it-works.mdx +++ b/docs-site/src/content/docs/getting-started/how-it-works.mdx @@ -40,8 +40,13 @@ account before the request is forwarded upstream. The rule is intentionally spli - **Quota and failure signals feed routing.** The dashboard can force a quota refresh with `GET /api/codex-auth/accounts?refresh=1`; successful upstream responses capture quota headers, 429 puts an account in cooldown, and 401/403 marks it for reauthentication. -- **Idle rolling windows can be activated on time.** Each account card offers default-off 5-hour - and weekly switches only for windows that account actually reports. At reset, opencodex reuses +- **Idle rolling windows can be activated on time.** Under **Advanced settings**, one default-off + automatic activation control switches the supported 5-hour and weekly windows for all current + main and added accounts together. Mixed settings are shown explicitly; enabling applies only + to reported windows, and disabling also clears stale enabled windows. Changes use individual + settings writes: a partial failure is shown after reading back the saved state, and **Retry** + completes the original enable or disable action. Newly added accounts are not opted in automatically. + In Pool mode, at reset, opencodex reuses its minimal non-stored account warmup request through the exact account whose window is due, coalesces simultaneous windows into one request, and durably persists both reset timestamps to prevent duplicate work after restarts. Paused accounts and accounts diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index b349dfd6fd..ab8a154ecb 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -32,7 +32,7 @@ After GUI registration or OAuth login, the confirmation dialog lets you open the | `contextCapValue?` | `number` | `350000` | Default value used by the dashboard context-cap controls. Changing it applies the value to every routed provider — including providers without an existing `providerContextCaps` entry — only when "apply to every routed provider" is toggled on; otherwise each provider keeps its own cap. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by Codex Auth. Secrets live separately in `codex-accounts.json`. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Accounts excluded from Pool selection until resumed, including the main `__main__` account when paused. | -| `codexQuotaAutoRefresh?` | `Record` | `{}` | Per-Codex-login-account opt-in for automatic `fiveHour` and `weekly` window activation in Pool mode, which selects among the main and added accounts; Direct mode uses only the current account and does not run this pool worker. The setting is actionable only when the account's live WHAM payload reports the selected window; absent windows have no dashboard control, and API writes attempting to enable an unavailable window return HTTP 409 (disable writes are accepted so stale settings can be cleared). The Providers/Codex Auth account-pool UI and `/api/settings` manage this field without replacing unrelated settings. At a reported reset time, opencodex sends one minimal non-stored Codex message through that account and persists the activated reset timestamp. This does not apply to API-key providers. | +| `codexQuotaAutoRefresh?` | `Record` | `{}` | Per-Codex-login-account opt-in for automatic `fiveHour` and `weekly` window activation in Pool mode; Direct mode does not run this worker. In Providers/Codex Auth **Advanced settings**, one control enables or disables both supported windows across all current main and added accounts. New accounts are not opted in automatically. Enable skips windows absent from live WHAM data; disable also clears stale enabled windows. The UI reuses granular `/api/settings` writes, reconciles partial failures, and retries the original ON/OFF intent without replacing unrelated settings or completed reset markers. The API still rejects enabling an unavailable window with HTTP 409. At a reported reset time, opencodex sends one minimal non-stored Codex message using that account's quota and persists the activated timestamp. This does not apply to API-key providers. | | `codexAccountNamespaces?` | `Record` | — | Optional map from an arbitrary public model selector to a stored Codex account target. When account-qualified picker rows are enabled, each selector whose target is present adds separate `/` rows to the Codex picker; each row uses only that account. With any selector active, bare native rows are hidden in the picker, but their ids remain routable and listed by raw `/v1/models` unless explicitly disabled. | | `codexAccountPickerEnabled?` | `boolean` | off when the map is empty | Controls whether eligible `codexAccountNamespaces` mappings generate account-qualified Codex picker rows. `true` allows mapped rows to appear. If omitted with a non-empty map, it is treated as enabled for backward compatibility; if the map is empty, it is off. `false` hides generated rows and restores bare native picker rows without deleting mappings or disabling exact `/` routing. | | `activeCodexAccountId?` | `string` | — | Manually selected Pool account for the next request. Selection clears thread affinity; in-flight requests keep captured credentials. | diff --git a/gui/src/codex-quota-activation.ts b/gui/src/codex-quota-activation.ts new file mode 100644 index 0000000000..871e265725 --- /dev/null +++ b/gui/src/codex-quota-activation.ts @@ -0,0 +1,27 @@ +import { quotaAutoRefreshAvailability } from "./codex-quota-utils"; +import type { CodexAccountEntry } from "./hooks/useCodexAccountPool"; + +export type QuotaAutoRefreshSettings = Record; + +export function readQuotaActivationSettings(payload: unknown): QuotaAutoRefreshSettings { + const settings = payload && typeof payload === "object" && "codexQuotaAutoRefresh" in payload + ? payload.codexQuotaAutoRefresh : null; + if (!settings || typeof settings !== "object" || Array.isArray(settings) + || Object.values(settings).some(value => !value || typeof value !== "object" || Array.isArray(value) + || [value.fiveHour, value.weekly].some(flag => flag !== undefined && typeof flag !== "boolean"))) { + throw new Error("Invalid quota activation settings"); + } + return settings as QuotaAutoRefreshSettings; +} + +export function quotaActivationWindows(accounts: CodexAccountEntry[], settings: QuotaAutoRefreshSettings) { + return accounts.flatMap(account => { + const id = account.isMain ? "__main__" : account.id; + const available = account.quotaAutoRefresh ?? quotaAutoRefreshAvailability(account.quota); + return (["fiveHour", "weekly"] as const).map(window => ({ + id, window, + available: available[window === "fiveHour" ? "fiveHourAvailable" : "weeklyAvailable"], + enabled: settings[id]?.[window] === true, + })); + }); +} diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 51cd7f1562..c01bd8b9d2 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useT } from "../i18n/shared"; import { IconPlus } from "../icons"; import { EmptyState, type NoticeTone } from "../ui"; @@ -21,7 +21,9 @@ import { accountNeedsReauth } from "../oauth-health-display"; import { useCopyFeedback } from "./use-copy-feedback"; import { DEFAULT_ACCOUNT_POOL_STRATEGY } from "../account-pool-strategy"; import type { CodexAccountMutationCompletion } from "../codex-account-mutation"; -import { quotaAutoRefreshAvailability } from "../codex-quota-utils"; +import { createBoundedFetch, type BoundedFetch } from "../bounded-fetch"; +import CodexQuotaAutoRefreshSetting from "./CodexQuotaAutoRefreshSetting"; +import { quotaActivationWindows, readQuotaActivationSettings, type QuotaAutoRefreshSettings } from "../codex-quota-activation"; // Single definition lives with the controller that owns this data (WP3). export type { CodexAccountEntry } from "../hooks/useCodexAccountPool"; @@ -29,7 +31,6 @@ import ProviderModelsNotice from "./ProviderModelsNotice"; import { navigateHash } from "../hash-routing"; const DOCTOR_CMD = "ocx doctor"; -type QuotaAutoRefreshSettings = Record; /** * Global ChatGPT / Codex account pool (main + extras), extracted from the Codex @@ -95,9 +96,30 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const [actionFeedbackTone, setActionFeedbackTone] = useState(null); const feedbackTimerRef = useRef | null>(null); const [refreshingQuota, setRefreshingQuota] = useState(false); - const [quotaAutoRefreshBusy, setQuotaAutoRefreshBusy] = useState(null); - const [quotaAutoRefreshSettings, setQuotaAutoRefreshSettings] = useState(null); + const [quotaBusyScope, setQuotaBusyScope] = useState(null); + const [quotaState, setQuotaState] = useState<{ + apiBase: string; revision: number; settings: QuotaAutoRefreshSettings | null; error: boolean; + } | null>(null); const quotaAutoRefreshMutationRevisionRef = useRef(0); + const quotaScopeRef = useRef(null); + const quotaMutationRef = useRef(null); + const [quotaReadRevision, setQuotaReadRevision] = useState(0); + const [quotaOrigin, setQuotaOrigin] = useState(apiBase); + const [quotaFeedback, setQuotaFeedback] = useState<{ apiBase: string; message: string; failed: boolean } | null>(null); + const failedQuotaTarget = useRef<{ apiBase: string; enabled: boolean } | null>(null); + const quotaCurrent = quotaState?.apiBase === apiBase && quotaState.revision === quotaReadRevision ? quotaState : null; + const quotaAutoRefreshSettings = quotaCurrent?.settings ?? null; + const quotaLoadError = quotaCurrent?.error ?? false; + const quotaAutoRefreshBusy = quotaBusyScope === apiBase; + // Adjust the snapshot at the prop boundary, not in an effect: returning to a + // previously visited proxy must not revive its old settings before the new GET. + if (quotaOrigin !== apiBase) { + setQuotaOrigin(apiBase); + setQuotaReadRevision(value => value + 1); + setQuotaState(null); + setQuotaBusyScope(null); + setQuotaFeedback(null); + } // undefined until /api/settings answers: the switch must not render a guessed position and // then visibly correct itself a moment later. const [sparkVisible, setSparkVisible] = useState(undefined); @@ -259,27 +281,66 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban } }; - const toggleQuotaAutoRefresh = async (account: CodexAccountEntry, window: "fiveHour" | "weekly") => { - if (quotaAutoRefreshBusy) return; + const toggleQuotaAutoRefresh = async (enabled: boolean) => { + const scope = quotaScopeRef.current; + if (quotaMutationRef.current || !scope || scope.signal.aborted || quotaAutoRefreshSettings === null || loadState !== "ready") return; + const pending = createBoundedFetch(30_000); + quotaMutationRef.current = pending; quotaAutoRefreshMutationRevisionRef.current += 1; - const enabled = window === "fiveHour" - ? !account.quotaAutoRefresh.fiveHourEnabled - : !account.quotaAutoRefresh.weeklyEnabled; - setQuotaAutoRefreshBusy(`${account.id}:${window}`); + const current = () => quotaScopeRef.current === scope && !scope.signal.aborted; + const windows = quotaActivationWindows(accounts, quotaAutoRefreshSettings); + setQuotaBusyScope(apiBase); + setQuotaFeedback(null); + let failed = false; try { - const response = await fetch(`${apiBase}/api/settings`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ codexQuotaAutoRefresh: { id: account.id, window, enabled } }), - }); - if (!response.ok) throw new Error("save"); - const payload = await response.json() as { codexQuotaAutoRefresh?: QuotaAutoRefreshSettings }; - setQuotaAutoRefreshSettings(payload.codexQuotaAutoRefresh ?? {}); - showActionFeedback(t("codexAuth.quotaAutoRefreshUpdated"), "ok"); - } catch { - showActionFeedback(t("codexAuth.quotaAutoRefreshFailed"), "err"); + for (const target of windows) { + // Missing quota can be transient. ON must not revoke an existing opt-in; + // only an explicit OFF action clears flags for unavailable windows. + if (enabled && !target.available) continue; + const requested = enabled; + if (target.enabled === requested) continue; + if (!current()) return; + if (pending.signal.aborted) { failed = true; break; } + try { + // Ordered field-patches to shared settings; stop unsent writes on proxy + // changes. Parallel dispatch would spend the rest of the batch before + // cancellation can take effect (covered by the deferred-write test). + // react-doctor-disable-next-line react-doctor/async-await-in-loop -- intentional sequential settings mutations + const response = await fetch(`${apiBase}/api/settings`, { + method: "PUT", headers: { "content-type": "application/json" }, signal: pending.signal, + body: JSON.stringify({ codexQuotaAutoRefresh: { id: target.id, window: target.window, enabled: requested } }), + }); + if (!response.ok) throw new Error("save"); + } catch { failed = true; } + } + if (!current()) return; + // Granular writes can partially commit (including a lost response). Read back + // the authoritative map; never claim that a failed batch rolled back. + pending.clear(); + const read = createBoundedFetch(15_000); + quotaMutationRef.current = read; + try { + const response = await fetch(`${apiBase}/api/settings`, { signal: read.signal }); + if (!response.ok) throw new Error("read"); + const saved = readQuotaActivationSettings(await response.json()); + if (!current()) return; + failed ||= windows.some(target => (!enabled || target.available) + && (saved[target.id]?.[target.window] === true) !== enabled); + setQuotaState({ apiBase, revision: quotaReadRevision, settings: saved, error: false }); + } catch { + if (!current()) return; + failed = true; + setQuotaState({ apiBase, revision: quotaReadRevision, settings: null, error: true }); + } finally { read.clear(); } + if (!current()) return; + failedQuotaTarget.current = failed ? { apiBase, enabled } : null; + setQuotaFeedback({ apiBase, message: t(failed ? "codexAuth.quotaAutoRefreshPartial" : "codexAuth.quotaAutoRefreshUpdated"), failed }); } finally { - setQuotaAutoRefreshBusy(null); + pending.clear(); + if (current()) { + quotaMutationRef.current = null; + setQuotaBusyScope(null); + } } }; @@ -287,23 +348,39 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban // AbortController rather than a `cancelled` flag: the in-flight request is actually torn // down on unmount, and the state update lands in a .then() the linter can see is guarded. const abort = new AbortController(); + const read = createBoundedFetch(15_000); + quotaScopeRef.current = abort; const mutationRevision = quotaAutoRefreshMutationRevisionRef.current; - fetch(`${apiBase}/api/settings`, { signal: abort.signal }) - .then(response => (response.ok ? response.json() : null)) + fetch(`${apiBase}/api/settings`, { signal: read.signal }) + .then(response => { if (!response.ok) throw new Error("read"); return response.json(); }) .then((payload: { showCodexSparkQuota?: unknown; codexQuotaAutoRefresh?: QuotaAutoRefreshSettings; } | null) => { - if (abort.signal.aborted || !payload) return; + if (abort.signal.aborted) return; + if (!payload) throw new Error("read"); if (typeof payload.showCodexSparkQuota === "boolean") setSparkVisible(payload.showCodexSparkQuota); if (quotaAutoRefreshMutationRevisionRef.current === mutationRevision) { - setQuotaAutoRefreshSettings(payload.codexQuotaAutoRefresh ?? {}); + setQuotaState({ apiBase, revision: quotaReadRevision, settings: readQuotaActivationSettings(payload), error: false }); + setQuotaBusyScope(null); } }) - // A settings read failure leaves the switch unrendered rather than guessing a position. - .catch(() => {}); - return () => { abort.abort(); }; - }, [apiBase]); + .catch(() => { + if (!abort.signal.aborted && quotaAutoRefreshMutationRevisionRef.current === mutationRevision) { + setQuotaState({ apiBase, revision: quotaReadRevision, settings: null, error: true }); + setQuotaBusyScope(null); + } + }) + .finally(() => read.clear()); + return () => { + abort.abort(); + read.controller.abort(); + read.clear(); + quotaMutationRef.current?.controller.abort(); + quotaMutationRef.current?.clear(); + quotaMutationRef.current = null; + }; + }, [apiBase, quotaReadRevision]); const toggleSpark = async () => { if (sparkBusy || sparkVisible === undefined) return; @@ -377,28 +454,8 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban } }; - const displayAccounts = useMemo(() => accounts.map(account => { - const setting = quotaAutoRefreshSettings?.[account.id]; - const fallback = account.quotaAutoRefresh ?? { - ...quotaAutoRefreshAvailability(account.quota), - fiveHourEnabled: false, - weeklyEnabled: false, - }; - return { - ...account, - quotaAutoRefresh: { - ...fallback, - fiveHourEnabled: quotaAutoRefreshSettings === null - ? fallback.fiveHourEnabled - : setting?.fiveHour === true, - weeklyEnabled: quotaAutoRefreshSettings === null - ? fallback.weeklyEnabled - : setting?.weekly === true, - }, - }; - }), [accounts, quotaAutoRefreshSettings]); - const main = displayAccounts.find(a => a.isMain); - const pool = displayAccounts.filter(a => !a.isMain); + const main = accounts.find(a => a.isMain); + const pool = accounts.filter(a => !a.isMain); const isMainActive = !main?.paused && (!activeId || activeId === "__main__"); const switchActionLabel = t(accountModeState === "direct" ? "codexAuth.prepareForPool" : "codexAuth.setAsNext"); const pauseBusy = pauseUpdatingId !== null || pausingExhausted; @@ -471,8 +528,6 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban onOpenReset={openResetPopup} onCopyDoctor={showDoctorCopy ? copyDoctor : undefined} doctorCopyOutcomeFor={showDoctorCopy ? doctorCopy.outcomeFor : undefined} - quotaAutoRefreshBusy={quotaAutoRefreshBusy} - onToggleQuotaAutoRefresh={(entry, window) => { void toggleQuotaAutoRefresh(entry, window); }} onManageMainHardLock={hasMainHardLockSetting ? manageMainHardLock : undefined} /> @@ -510,8 +565,6 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban onRemove={remove} onCopyDoctor={showDoctorCopy ? copyDoctor : undefined} doctorCopyOutcomeFor={showDoctorCopy ? doctorCopy.outcomeFor : undefined} - quotaAutoRefreshBusy={quotaAutoRefreshBusy} - onToggleQuotaAutoRefresh={(entry, window) => { void toggleQuotaAutoRefresh(entry, window); }} /> )} @@ -528,6 +581,22 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban open={advancedOpen} onToggle={() => setAdvancedOpen(open => !open)} > + { void toggleQuotaAutoRefresh(enabled); }} + onRetry={() => { + if (quotaLoadError || quotaAutoRefreshSettings === null || loadState !== "ready") { + setQuotaReadRevision(value => value + 1); + void load(); + } else if (failedQuotaTarget.current?.apiBase === apiBase) { + void toggleQuotaAutoRefresh(failedQuotaTarget.current.enabled); + } + }} + /> {poolStrategy !== null && ( ; + ready: boolean; + busy: boolean; + loadError: boolean; + feedback: { message: string; failed: boolean } | null; + onToggle(enabled: boolean): void; + onRetry(): void; +}) { + const t = useT(); + const available = windows.filter(window => window.available); + const anyEnabled = windows.some(window => window.enabled); + const enabled = available.length ? available.every(window => window.enabled) : anyEnabled; + const mixed = anyEnabled && !enabled; + const empty = available.length === 0 && !anyEnabled; + const message = busy ? t("common.saving") + : loadError ? t("codexAuth.quotaAutoRefreshLoadFailed") + : !ready ? t("common.loading") + : feedback?.message ?? (empty ? t("codexAuth.quotaAutoRefreshEmpty") + : mixed ? t("codexAuth.quotaAutoRefreshMixed") : ""); + const failed = !busy && (loadError || feedback?.failed); + return ( +
+
+ {t("codexAuth.quotaAutoRefresh")} +
{t("codexAuth.quotaAutoRefreshAllHint")}
+ {message &&
{message}
} +
+
+ {failed && } + +
+
+ ); +} diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx index a50086d462..7c112e7a72 100644 --- a/gui/src/components/codex-account-pool-cards.tsx +++ b/gui/src/components/codex-account-pool-cards.tsx @@ -39,8 +39,6 @@ export function CodexAccountPoolCards({ onRemove, onCopyDoctor, doctorCopyOutcomeFor, - quotaAutoRefreshBusy, - onToggleQuotaAutoRefresh, }: { pool: CodexAccountEntry[]; activeId: string | null; @@ -68,8 +66,6 @@ export function CodexAccountPoolCards({ onRemove: (id: string) => void; onCopyDoctor?: (accountId: string) => void; doctorCopyOutcomeFor?: (accountId: string) => "copied" | "unavailable" | null; - quotaAutoRefreshBusy: string | null; - onToggleQuotaAutoRefresh: (account: CodexAccountEntry, window: "fiveHour" | "weekly") => void; }) { const t = useT(); const isNext = (account: CodexAccountEntry) => !account.paused && activeId === account.id; @@ -207,11 +203,6 @@ export function CodexAccountPoolCards({ t={t} pending={a.quota == null} /> - } ); @@ -220,45 +211,6 @@ export function CodexAccountPoolCards({ ); } -export function CodexQuotaAutoRefreshControls({ - account, - busy, - onToggle, -}: { - account: CodexAccountEntry; - busy: string | null; - onToggle: (account: CodexAccountEntry, window: "fiveHour" | "weekly") => void; -}) { - const t = useT(); - const setting = account.quotaAutoRefresh; - if (!setting?.fiveHourAvailable && !setting?.weeklyAvailable) return null; - const control = (window: "fiveHour" | "weekly", enabled: boolean) => ( - - {t(window === "fiveHour" ? "codexAuth.fiveHour" : "codexAuth.weekly")} - - - ); - return ( -
- - {t("codexAuth.quotaAutoRefresh")} - - {setting.fiveHourAvailable && control("fiveHour", setting.fiveHourEnabled)} - {setting.weeklyAvailable && control("weekly", setting.weeklyEnabled)} -
- ); -} - export function CodexAccountPoolReauthBanner({ onReauth, }: { diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index 86f83179d9..f90756afe0 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -7,7 +7,6 @@ import type { CodexAccountEntry } from "./codex-account-pool-types"; import type { CodexAccountModeState } from "../codex-multi-state"; import type { TFn } from "../i18n/shared"; import type { NoticeTone } from "../ui"; -import { CodexQuotaAutoRefreshControls } from "./codex-account-pool-cards"; import { navigateHash } from "../hash-routing"; import { doctorCopyButtonLabel, @@ -37,8 +36,6 @@ export function CodexAccountPoolMainCard({ onOpenReset, onCopyDoctor, doctorCopyOutcomeFor, - quotaAutoRefreshBusy, - onToggleQuotaAutoRefresh, onManageMainHardLock, }: { t: TFn; @@ -64,8 +61,6 @@ export function CodexAccountPoolMainCard({ onOpenReset: (account: CodexAccountEntry) => void; onCopyDoctor?: (accountId: string) => void; doctorCopyOutcomeFor?: (accountId: string) => "copied" | "unavailable" | null; - quotaAutoRefreshBusy: string | null; - onToggleQuotaAutoRefresh: (account: CodexAccountEntry, window: "fiveHour" | "weekly") => void; onManageMainHardLock?: () => void; }) { const mainFallbackLabel = t("codexAuth.codexApp"); @@ -196,13 +191,6 @@ export function CodexAccountPoolMainCard({ t={t} pending={main != null && main.quota == null} /> - {main && ( - - )} } ); diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 1ee335120d..4d4b79e5d5 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -5,6 +5,11 @@ import type { TKey } from "./en"; * German i18n catalog, generated from en.ts. Must match the `TKey` set (compile-checked). */ export const de: Record = { + "codexAuth.quotaAutoRefreshAllHint": "Schaltet die unterstützten 5-Stunden- und Wochenfenster aller aktuellen Konten gemeinsam um. Im Pool-Modus wird nach jedem Reset eine kleine Anfrage gesendet, die Kontingent verbraucht.", + "codexAuth.quotaAutoRefreshMixed": "Einige Fenster sind aktiviert.", + "codexAuth.quotaAutoRefreshEmpty": "Keine unterstützten Kontingentfenster. Aktualisieren Sie die Kontingente der Konten.", + "codexAuth.quotaAutoRefreshLoadFailed": "Die Aktivierungseinstellungen konnten nicht geladen werden. Bitte erneut versuchen.", + "codexAuth.quotaAutoRefreshPartial": "Einige Einstellungen konnten nicht gespeichert werden. Erneut versuchen, um dieselbe Änderung abzuschließen.", "nav.dashboard": "Übersicht", "uptime.day": "T", "uptime.hour": "Std", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index addd46b549..cf9eb253dd 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -6,6 +6,11 @@ * `{var}` are plain interpolations. */ export const en = { + "codexAuth.quotaAutoRefreshAllHint": "Controls the supported 5-hour and weekly windows for all current accounts together. In Pool mode, a small request is sent after each reset and uses quota.", + "codexAuth.quotaAutoRefreshMixed": "Some windows are enabled.", + "codexAuth.quotaAutoRefreshEmpty": "No supported quota windows. Refresh account quotas to check again.", + "codexAuth.quotaAutoRefreshLoadFailed": "Could not load activation settings. Retry to check their state.", + "codexAuth.quotaAutoRefreshPartial": "Some settings could not be saved. Retry to finish the same change.", // sidebar / nav / common "nav.dashboard": "Dashboard", "uptime.day": "d", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 57b74dbe23..2d90382f1a 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -4,6 +4,11 @@ import type { TKey } from "./en"; * French i18n catalog. Must match the `TKey` set. */ export const fr: Record = { + "codexAuth.quotaAutoRefreshAllHint": "Active ou désactive ensemble, pour tous les comptes actuels, les fenêtres de quota prises en charge par chaque compte : 5 heures et hebdomadaire. En mode Groupe, une petite requête consommant du quota est envoyée après chaque réinitialisation.", + "codexAuth.quotaAutoRefreshMixed": "Certaines fenêtres sont activées.", + "codexAuth.quotaAutoRefreshEmpty": "Aucune fenêtre de quota prise en charge. Actualisez les quotas des comptes.", + "codexAuth.quotaAutoRefreshLoadFailed": "Impossible de charger les paramètres d’activation. Réessayez.", + "codexAuth.quotaAutoRefreshPartial": "Certains paramètres n’ont pas pu être enregistrés. Réessayez pour terminer la même modification.", "nav.dashboard": "Tableau de bord", "uptime.day": "j", "uptime.hour": "h", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index fd15b0aeef..c9d2e9ea4a 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4,6 +4,11 @@ import type { TKey } from "./en"; * Japanese i18n catalog; must match the `TKey` set (compile-checked). */ export const ja: Record = { + "codexAuth.quotaAutoRefreshAllHint": "現在の全アカウントで、対応する5時間・週間枠をまとめて切り替えます。プールモードではリセット後に少量の利用枠を消費するリクエストを送信します。", + "codexAuth.quotaAutoRefreshMixed": "一部の枠が有効です。", + "codexAuth.quotaAutoRefreshEmpty": "対応する利用枠がありません。アカウントの利用枠を更新してください。", + "codexAuth.quotaAutoRefreshLoadFailed": "自動開始設定を取得できませんでした。再試行してください。", + "codexAuth.quotaAutoRefreshPartial": "一部の設定を保存できませんでした。再試行で同じ変更を完了します。", // sidebar / nav / common "nav.dashboard": "ダッシュボード", "uptime.day": "日", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index f320b5785a..d9c983a5fb 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4,6 +4,11 @@ import type { TKey } from "./en"; * Korean i18n catalog; must match the `TKey` set (compile-checked). */ export const ko: Record = { + "codexAuth.quotaAutoRefreshAllHint": "현재 등록된 모든 계정의 5시간·주간 할당량을 한 번에 켜거나 끕니다. 지원하는 창에만 적용하며, 풀 모드에서 리셋 후 소량의 할당량을 쓰는 요청을 보냅니다.", + "codexAuth.quotaAutoRefreshMixed": "일부만 켜져 있습니다.", + "codexAuth.quotaAutoRefreshEmpty": "지원하는 할당량 창이 없습니다. 계정 할당량을 새로고침해 주세요.", + "codexAuth.quotaAutoRefreshLoadFailed": "자동 활성화 설정을 불러오지 못했습니다. 다시 시도해 주세요.", + "codexAuth.quotaAutoRefreshPartial": "일부 설정을 저장하지 못했습니다. 다시 시도하면 같은 작업을 마저 적용합니다.", // sidebar / nav / common "nav.dashboard": "대시보드", "uptime.day": "일", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 6df77b0a79..950cea7a81 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4,6 +4,11 @@ import type { TKey } from "./en"; * Russian i18n catalog; must match the `TKey` set (compile-checked). */ export const ru: Record = { + "codexAuth.quotaAutoRefreshAllHint": "Общее переключение поддерживаемых 5-часовых и недельных окон всех текущих аккаунтов. В режиме пула после сброса отправляется небольшой запрос, расходующий квоту.", + "codexAuth.quotaAutoRefreshMixed": "Включены некоторые окна.", + "codexAuth.quotaAutoRefreshEmpty": "Нет поддерживаемых окон квоты. Обновите квоты аккаунтов.", + "codexAuth.quotaAutoRefreshLoadFailed": "Не удалось загрузить настройки активации. Повторите попытку.", + "codexAuth.quotaAutoRefreshPartial": "Не удалось сохранить часть настроек. Повторите попытку для завершения того же изменения.", // sidebar / nav / common "nav.dashboard": "Дашборд", "uptime.day": "д", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 7b1f913c45..5db3ca23b5 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -5,6 +5,11 @@ import type { TKey } from "./en"; * Turkish i18n catalog. Must match the `TKey` set (compile-checked). */ export const tr: Record = { + "codexAuth.quotaAutoRefreshAllHint": "Mevcut tüm hesapların desteklenen 5 saatlik ve haftalık pencerelerini birlikte açıp kapatır. Havuz modunda her sıfırlamadan sonra az miktarda kota kullanan bir istek gönderilir.", + "codexAuth.quotaAutoRefreshMixed": "Bazı pencereler etkin.", + "codexAuth.quotaAutoRefreshEmpty": "Desteklenen kota penceresi yok. Hesap kotalarını yenileyin.", + "codexAuth.quotaAutoRefreshLoadFailed": "Etkinleştirme ayarları yüklenemedi. Yeniden deneyin.", + "codexAuth.quotaAutoRefreshPartial": "Bazı ayarlar kaydedilemedi. Aynı değişikliği tamamlamak için yeniden deneyin.", // sidebar / nav / common "nav.dashboard": "Gösterge Paneli", "uptime.day": " gün", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index ec764ebda2..06f8e6fa4b 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2,6 +2,11 @@ import type { TKey } from "./en"; /** Traditional Chinese (Taiwan) UI strings — keys must match `en.ts` 1:1. */ export const zhTW: Record = { + "codexAuth.quotaAutoRefreshAllHint": "統一切換目前所有帳戶各自支援的 5 小時與每週額度視窗。在帳戶池模式下,重設後會傳送消耗少量額度的請求。", + "codexAuth.quotaAutoRefreshMixed": "部分視窗已啟用。", + "codexAuth.quotaAutoRefreshEmpty": "沒有支援的額度視窗。請重新整理帳戶額度。", + "codexAuth.quotaAutoRefreshLoadFailed": "無法載入自動啟用設定。請重試。", + "codexAuth.quotaAutoRefreshPartial": "部分設定未能儲存。重試將完成同一項變更。", "nav.dashboard": "儀表板", "nav.startup": "啟動安全", "nav.providers": "供應商", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 2fca1a9e68..315869d88d 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4,6 +4,11 @@ import type { TKey } from "./en"; * Chinese i18n catalog; must match the `TKey` set (compile-checked). */ export const zh: Record = { + "codexAuth.quotaAutoRefreshAllHint": "统一开关当前所有账户各自支持的 5 小时和每周额度窗口。在账户池模式下,重置后会发送消耗少量额度的请求。", + "codexAuth.quotaAutoRefreshMixed": "部分窗口已启用。", + "codexAuth.quotaAutoRefreshEmpty": "没有支持的额度窗口。请刷新账户额度。", + "codexAuth.quotaAutoRefreshLoadFailed": "无法加载自动激活设置。请重试。", + "codexAuth.quotaAutoRefreshPartial": "部分设置未能保存。重试将完成同一项更改。", // sidebar / nav / common "nav.dashboard": "仪表盘", "uptime.day": "天", diff --git a/gui/src/styles.css b/gui/src/styles.css index c21b2a16a8..654d384068 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -1838,17 +1838,11 @@ dialog.modal-overlay::backdrop { margin-bottom: 10px; } -.codex-quota-auto-refresh { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 14px; - padding: 8px 16px 12px; - color: var(--muted); - font-size: var(--text-label); -} -.codex-quota-auto-refresh__label { margin-right: auto; } -.codex-quota-auto-refresh__window { display: inline-flex; align-items: center; gap: 7px; } +.codex-quota-activation { gap: var(--space-4); flex-wrap: wrap; } +.codex-quota-activation__copy { flex: 1 1 240px; min-width: 0; word-break: keep-all; } +.codex-quota-activation__controls { display: flex; align-items: center; gap: var(--space-3); min-height: 44px; } +.codex-quota-activation .is-error { color: var(--red); } +.codex-quota-activation .toggle[aria-pressed="mixed"] .toggle-knob { transform: translateX(8px); } /* Account actions (pause / copy doctor / pause-exhausted): clearer hover than plain btn-ghost on card surface — same raised-hover + faint border as icon/list cues. */ .codex-auth-action-btn:hover:not(:disabled) { diff --git a/gui/tests/codex-account-pool-toast-tone.test.tsx b/gui/tests/codex-account-pool-toast-tone.test.tsx index 8397e98630..5e64bed40a 100644 --- a/gui/tests/codex-account-pool-toast-tone.test.tsx +++ b/gui/tests/codex-account-pool-toast-tone.test.tsx @@ -130,6 +130,7 @@ beforeEach(() => { if (url.pathname.startsWith("/api/codex-auth/")) { return Response.json({ accounts: [], activeCodexAccountId: null, autoSwitchThreshold: 80 }); } + if (url.pathname === "/api/settings") return Response.json({ codexQuotaAutoRefresh: {} }); return Response.json({}); }, }); @@ -153,19 +154,31 @@ afterEach(async () => { await win.happyDOM?.close?.(); }); -async function mountPool(controller?: CodexAccountPoolController) { +async function mountPool(controller?: CodexAccountPoolController, apiBase = "") { const { createRoot } = await import("react-dom/client"); await act(async () => { root = createRoot(host); root.render( - + , ); }); await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); } +test("quota activation is one advanced control, never a row on each account card", async () => { + const controller = makeController(); + controller.accounts = controller.accounts.map(entry => ({ ...entry, + quotaAutoRefresh: { fiveHourAvailable: true, weeklyAvailable: true, fiveHourEnabled: false, weeklyEnabled: false }, + })); + await mountPool(controller); + expect(host.querySelectorAll('.codex-quota-auto-refresh').length).toBe(0); + expect(host.querySelectorAll('#codex-quota-activation').length).toBe(0); + await act(async () => { host.querySelector('.codex-auth-advanced__toggle')!.click(); }); + expect(host.querySelectorAll('#codex-quota-activation .toggle').length).toBe(1); +}); + async function chooseOrder(selectId: string, value: string): Promise { // A default-priority account renders its order select only once its ⋯ disclosure is // open (050): the control is on demand, not wallpaper on every card. @@ -192,6 +205,199 @@ async function chooseOrder(selectId: string, value: string): Promise { }); } +type ActivationWrite = { id: string; window: "fiveHour" | "weekly"; enabled: boolean }; +type ActivationSettings = Record; +function activationController() { + const entry = (id: string, fiveHour: boolean, weekly: boolean): CodexAccountEntry => ({ + ...account, id, isMain: id === "__main__", email: `${id}@example.test`, + quotaAutoRefresh: { fiveHourAvailable: fiveHour, weeklyAvailable: weekly, fiveHourEnabled: false, weeklyEnabled: false }, + }); + return makeController({ accounts: [entry("__main__", false, true), entry("both", true, true), entry("none", false, false)] }); +} +function activationApi(initial: ActivationSettings = {}) { + const fallback = globalThis.fetch; + const state = { settings: structuredClone(initial), writes: [] as ActivationWrite[], + fail: (_write: ActivationWrite) => false, + read: null as null | (() => Promise), + beforeWrite: null as null | (() => Promise), + }; + globalThis.fetch = (async (input, init) => { + if (!String(input).endsWith("/api/settings")) return fallback(input, init); + if (init?.method !== "PUT") return state.read ? state.read() : Response.json({ codexQuotaAutoRefresh: state.settings }); + const write = JSON.parse(String(init.body)).codexQuotaAutoRefresh as ActivationWrite; + state.writes.push(write); + await state.beforeWrite?.(); + if (state.fail(write)) return Response.json({ error: "private server detail" }, { status: 503 }); + state.settings[write.id] = { ...state.settings[write.id], [write.window]: write.enabled }; + return Response.json({ codexQuotaAutoRefresh: state.settings }); + }) as typeof fetch; + return state; +} +async function activationClick(selector: string) { + await act(async () => { host.querySelector(selector)!.click(); }); +} +const activationToggle = () => host.querySelector('#codex-quota-activation .toggle')!; +const activationRetry = '#codex-quota-activation .btn'; +const activationOpen = () => activationClick('.codex-auth-advanced__toggle'); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +test("bulk activation enables and disables all supported current account windows, not unavailable windows", async () => { + const api = activationApi(); + await mountPool(activationController()); + await activationOpen(); + expect(activationToggle().getAttribute("aria-pressed")).toBe("false"); + await activationClick('#codex-quota-activation .toggle'); + expect(api.writes).toEqual([ + { id: "__main__", window: "weekly", enabled: true }, + { id: "both", window: "fiveHour", enabled: true }, + { id: "both", window: "weekly", enabled: true }, + ]); + expect(activationToggle().getAttribute("aria-pressed")).toBe("true"); + api.writes.length = 0; + await activationClick('#codex-quota-activation .toggle'); + expect(api.writes).toEqual([ + { id: "__main__", window: "weekly", enabled: false }, + { id: "both", window: "fiveHour", enabled: false }, + { id: "both", window: "weekly", enabled: false }, + ]); + expect(activationToggle().getAttribute("aria-pressed")).toBe("false"); +}); + +test("mixed activation enables remaining windows without revoking temporarily unavailable opt-ins", async () => { + const api = activationApi({ __main__: { weekly: true }, none: { fiveHour: true } }); + await mountPool(activationController()); await activationOpen(); + expect(activationToggle().getAttribute("aria-pressed")).toBe("mixed"); + await activationClick('#codex-quota-activation .toggle'); + expect(api.writes).toEqual([ + { id: "both", window: "fiveHour", enabled: true }, + { id: "both", window: "weekly", enabled: true }, + ]); + expect(api.settings.none.fiveHour).toBe(true); + expect(activationToggle().getAttribute("aria-pressed")).toBe("true"); + expect(host.textContent).toContain("Automatic window activation updated"); + api.writes.length = 0; + await activationClick('#codex-quota-activation .toggle'); + expect(api.writes).toContainEqual({ id: "none", window: "fiveHour", enabled: false }); + expect(api.writes.every(write => !write.enabled)).toBe(true); +}); + +test("partial OFF retry preserves OFF intent and never re-enables a saved disable", async () => { + const api = activationApi({ __main__: { weekly: true }, both: { fiveHour: true, weekly: true } }); + api.fail = write => write.window === "fiveHour"; + await mountPool(activationController()); await activationOpen(); + await activationClick('#codex-quota-activation .toggle'); + expect(activationToggle().getAttribute("aria-pressed")).toBe("mixed"); + expect(host.textContent).toContain("Some settings could not be saved"); + expect(host.textContent).not.toContain("private server detail"); + api.fail = () => false; api.writes.length = 0; + await activationClick(activationRetry); + expect(api.writes).toEqual([{ id: "both", window: "fiveHour", enabled: false }]); + expect(activationToggle().getAttribute("aria-pressed")).toBe("false"); +}); + +test("settings read failure is unknown and retryable; malformed acknowledgments never imply off", async () => { + const api = activationApi(); api.read = async () => Response.json({ codexQuotaAutoRefresh: { both: { weekly: "false" } } }); + await mountPool(activationController()); await activationOpen(); + expect(activationToggle().disabled).toBe(true); + expect(activationToggle().hasAttribute("aria-pressed")).toBe(false); + expect(host.textContent).toContain("Could not load activation settings"); + api.read = null; + await activationClick(activationRetry); + expect(activationToggle().disabled).toBe(false); +}); + +test("delayed settings and duplicate clicks stay blocked through final reconciliation", async () => { + const api = activationApi(); const initial = deferred(); api.read = () => initial.promise; + await mountPool(activationController()); await activationOpen(); + expect(activationToggle().disabled).toBe(true); + expect(activationToggle().hasAttribute("aria-pressed")).toBe(false); + await act(async () => { initial.resolve(Response.json({ codexQuotaAutoRefresh: {} })); }); + const write = deferred(); api.beforeWrite = () => write.promise; + const final = deferred(); api.read = () => final.promise; + await act(async () => { activationToggle().click(); activationToggle().click(); }); + expect(api.writes.length).toBe(1); + expect(activationToggle().disabled).toBe(true); + await act(async () => { write.resolve(); }); + expect(api.writes.length).toBe(3); + expect(activationToggle().disabled).toBe(true); + await act(async () => { final.resolve(Response.json({ codexQuotaAutoRefresh: api.settings })); }); + expect(activationToggle().disabled).toBe(false); + expect(activationToggle().getAttribute("aria-pressed")).toBe("true"); +}); + +test("lost reconciliation stays unknown and reloads without repeating already saved writes", async () => { + const api = activationApi(); await mountPool(activationController()); await activationOpen(); + api.read = async () => Response.json({}, { status: 503 }); + await activationClick('#codex-quota-activation .toggle'); + expect(activationToggle().disabled).toBe(true); + expect(activationToggle().hasAttribute("aria-pressed")).toBe(false); + const writes = api.writes.length; api.read = null; + await activationClick(activationRetry); + expect(api.writes.length).toBe(writes); + expect(activationToggle().getAttribute("aria-pressed")).toBe("true"); + await activationClick(activationRetry); + expect(api.writes.length).toBe(writes); + expect(host.textContent).toContain("Automatic window activation updated"); +}); + +test("no-window accounts cannot enable, but stale enabled windows can always be disabled", async () => { + const api = activationApi(); const controller = activationController(); controller.accounts = [controller.accounts[2]]; + await mountPool(controller); await activationOpen(); + expect(activationToggle().disabled).toBe(true); + expect(host.textContent).toContain("No supported quota windows"); + // Reloading the same surface with persisted stale settings keeps OFF reachable. + await act(async () => { root!.unmount(); root = null; }); + api.settings = { none: { weekly: true } }; + await mountPool(controller); await activationOpen(); + expect(activationToggle().getAttribute("aria-pressed")).toBe("true"); + await activationClick('#codex-quota-activation .toggle'); + expect(api.writes).toEqual([{ id: "none", window: "weekly", enabled: false }]); +}); + +test("switching apiBase stops remaining old-proxy writes and ignores the old completion", async () => { + const api = activationApi(); const controller = activationController(); + await mountPool(controller, "http://old"); await activationOpen(); + const pending = deferred(); api.beforeWrite = () => pending.promise; + await activationClick('#codex-quota-activation .toggle'); + expect(api.writes.length).toBe(1); + await act(async () => { root!.render(); }); + await act(async () => { pending.resolve(); }); + expect(api.writes.length).toBe(1); + expect(activationToggle().getAttribute("aria-pressed")).toBe("false"); + expect(host.textContent).not.toContain("Automatic window activation updated"); +}); + +test("A to B to A never revives the old A snapshot while its new read is pending or fails", async () => { + const api = activationApi({ __main__: { weekly: true }, both: { fiveHour: true, weekly: true } }); + const controller = activationController(); + await mountPool(controller, "http://a"); await activationOpen(); + expect(activationToggle().getAttribute("aria-pressed")).toBe("true"); + const pendingB = deferred(); api.read = () => pendingB.promise; + await act(async () => { root!.render(); }); + const pendingA = deferred(); api.read = () => pendingA.promise; + await act(async () => { root!.render(); }); + expect(activationToggle().disabled).toBe(true); + expect(activationToggle().hasAttribute("aria-pressed")).toBe(false); + await activationClick('#codex-quota-activation .toggle'); + expect(api.writes.length).toBe(0); + await act(async () => { pendingA.resolve(Response.json({}, { status: 503 })); pendingB.resolve(Response.json({ codexQuotaAutoRefresh: {} })); }); + expect(activationToggle().disabled).toBe(true); + expect(activationToggle().hasAttribute("aria-pressed")).toBe(false); + api.read = null; + await activationClick(activationRetry); + const off = deferred(); api.beforeWrite = () => off.promise; + await activationClick('#codex-quota-activation .toggle'); + expect(activationToggle().disabled).toBe(true); + await act(async () => { off.resolve(); }); + expect(api.writes.length).toBe(3); + expect(api.writes.every(write => !write.enabled)).toBe(true); + expect(activationToggle().getAttribute("aria-pressed")).toBe("false"); +}); + test("a legacy account without quota activation data keeps selection order usable", async () => { expect("quotaAutoRefresh" in legacyAccount).toBe(false); legacyApiPayload = { accounts: [legacyAccount] }; diff --git a/gui/tests/codex-auto-switch-controller.test.tsx b/gui/tests/codex-auto-switch-controller.test.tsx index 95195c3a7a..287ceffdaf 100644 --- a/gui/tests/codex-auto-switch-controller.test.tsx +++ b/gui/tests/codex-auto-switch-controller.test.tsx @@ -139,7 +139,7 @@ async function mountHarness(): Promise { 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") { + if (url.endsWith("/api/settings") && method === "GET") return Response.json({ codexQuotaAutoRefresh: {} }); if (url.endsWith("/api/codex-auth/accounts") && method === "GET") { return Response.json({ accounts: [] }); } // Pool controller + strategy card both GET /active; prefer queued responses for @@ -198,7 +198,7 @@ async function mountHarness(): Promise { container.querySelector('input[aria-label="Usage threshold, percent"]') ); const currentToggle = (): HTMLButtonElement => { - const toggle = container.querySelector("button.toggle[aria-pressed]"); + const toggle = container.querySelector(".codex-auto-switch-card button.toggle[aria-pressed]"); if (!toggle) throw new Error("auto-switch toggle was not rendered"); return toggle; }; @@ -233,7 +233,7 @@ describe("Codex auto-switch controller interactions", () => { value: 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") { + if (url.endsWith("/api/settings") && method === "GET") return Response.json({ codexQuotaAutoRefresh: {} }); if (url.endsWith("/api/codex-auth/accounts") && method === "GET") { return Response.json({ accounts: [] }); } if (url.endsWith("/api/codex-auth/active") && method === "GET") { @@ -301,7 +301,7 @@ describe("Codex auto-switch controller interactions", () => { 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") { + if (url.endsWith("/api/settings") && method === "GET") return Response.json({ codexQuotaAutoRefresh: {} }); if (url.endsWith("/api/codex-auth/accounts") && method === "GET") { return Response.json({ accounts: [] }); } if (url.endsWith("/api/codex-auth/active") && method === "GET") { @@ -346,7 +346,7 @@ describe("Codex auto-switch controller interactions", () => { await flush(); }); - const toggle = container.querySelector("button.toggle[aria-pressed]"); + const toggle = container.querySelector(".codex-auto-switch-card button.toggle[aria-pressed]"); expect(toggle).toBeNull(); expect(writes).toEqual([]); @@ -364,7 +364,7 @@ describe("Codex auto-switch controller interactions", () => { expect(advanced).not.toBeNull(); await act(async () => { advanced!.click(); await flush(); }); - const readyToggle = container.querySelector("button.toggle[aria-pressed]"); + const readyToggle = container.querySelector(".codex-auto-switch-card button.toggle[aria-pressed]"); expect(readyToggle?.disabled).toBe(false); expect(container.querySelector('input[aria-label="Usage threshold, percent"]')?.value).toBe("55"); expect(writes).toEqual([]); diff --git a/gui/tests/main-account-hard-lock-setting.test.tsx b/gui/tests/main-account-hard-lock-setting.test.tsx index 92243d983c..4eb1129c54 100644 --- a/gui/tests/main-account-hard-lock-setting.test.tsx +++ b/gui/tests/main-account-hard-lock-setting.test.tsx @@ -271,7 +271,6 @@ function MainCard({ state }: { state: MainAccountHardLockStatus["state"] }) { return {}} onTogglePause={() => {}} pauseUpdatingId={null} pauseBusy={false} onPriorityChange={() => {}} - quotaAutoRefreshBusy={null} onToggleQuotaAutoRefresh={() => {}} priorityUpdatingId={null} switchingId={null} onOpenReset={() => {}} />; } test.each([ diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index abde56e457..1f838baeb5 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -84,6 +84,12 @@ completion markers nor retry delay; quota reads remain available. Main refresh c shared credential ownership, then prepared credentials and restrictions are rechecked. Lifecycle cleanup uses the dependency-free quota-auto-refresh state leaf, avoiding a reconciliation cycle. +The account-pool dashboard exposes one bulk control under Advanced settings, not per-card +rows. It applies both reported 5-hour and weekly windows to every current main/added account; +new accounts do not inherit opt-in. The existing granular settings API remains authoritative. +UI writes are serialized, followed by a settings read; partial failures preserve the intended +ON/OFF action for explicit retry. OFF also clears unavailable windows with stale enabled flags. + Exact `gpt-reserve` has a separate process-local quota scope. Only global/default and shared ordinary scopes can receive a generic quota-recovery claim; ordinary success cannot clear Reserve. Effective Desktop authless compatibility adds only configured main-selector Reserve catalog rows,