From 458b3a3934b67313b8116145d7c28be885df715a Mon Sep 17 00:00:00 2001 From: csa906 Date: Thu, 23 Jul 2026 21:39:38 +0900 Subject: [PATCH 1/8] feat(gui): configure Codex auto-switch threshold --- .../src/content/docs/guides/web-dashboard.md | 2 +- .../content/docs/ja/guides/web-dashboard.md | 4 +- .../content/docs/ko/guides/web-dashboard.md | 4 +- .../content/docs/ru/guides/web-dashboard.md | 2 +- .../docs/zh-cn/guides/web-dashboard.md | 2 +- gui/src/codex-auto-switch.ts | 60 +++++ gui/src/components/CodexAccountPool.tsx | 208 ++++++++++++++++-- gui/src/i18n/de.ts | 9 +- gui/src/i18n/en.ts | 9 +- gui/src/i18n/ja.ts | 9 +- gui/src/i18n/ko.ts | 9 +- gui/src/i18n/ru.ts | 9 +- gui/src/i18n/zh.ts | 9 +- gui/src/styles.css | 12 + gui/tests/codex-account-auto-switch.test.tsx | 144 ++++++++++++ 15 files changed, 454 insertions(+), 38 deletions(-) create mode 100644 gui/src/codex-auto-switch.ts create mode 100644 gui/tests/codex-account-auto-switch.test.tsx diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 5ab85e46f..ab7c9e009 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -34,7 +34,7 @@ bun run dev:gui | **Codex autostart** | Allow an already-installed Codex launcher shim to run `ocx ensure`. This toggle does not install a shim or background service. | | **Providers** | Add, edit, enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. | | **Add provider** | Search registry-backed presets for account login, API-key services, local servers, or a custom endpoint. | -| **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, and configure quota auto-switch and transient-failure failover. | +| **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, enable or disable quota auto-switch, set its 1–100% threshold, and configure transient-failure failover. | | **Subagents** | Feature up to five bare native or namespaced routed models in the `spawn_agent` override list. | | **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose v1/base/v2, and configure the v2 thread limit. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. | | **Logs** | Auto-refresh recent requests with tokens, requested effort, resolved model, provider, status, request id, duration, and error details. | diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 3cb6bd4dd..ae6fe8140 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -34,7 +34,7 @@ bun run dev:gui | **Codex 自動起動** | インストール済み Codex launcher shim に `ocx ensure` の実行を許可します。このトグルは shim やバックグラウンドサービスをインストールしません。 | | **プロバイダー** | プロバイダーを追加、編集、有効化/無効化、削除し、対応する OAuth アカウントプールと API キープールを管理します。 | | **プロバイダー追加** | レジストリベースのプリセットからアカウントログイン、API キーサービス、ローカルサーバー、custom エンドポイントを検索します。 | -| **Codex 認証** | ChatGPT/Codex プールアカウントを追加し、次回セッションアカウントを選び、5 時間 / 週間 / 30 日クォータを更新し、クォータ自動切り替えと一時的失敗フェイルオーバーを設定します。 | +| **Codex 認証** | ChatGPT/Codex プールアカウントを追加し、次回セッションアカウントを選び、5 時間 / 週間 / 30 日クォータを更新し、クォータ自動切り替えのオン/オフと 1~100% のしきい値、一時的失敗フェイルオーバーを設定します。 | | **サブエージェント** | `spawn_agent` オーバーライド一覧にネイティブまたはルーティングモデルを最大 5 つまで優先公開します。 | | **モデル** | ネイティブ GPT とルーティングモデルをオン/オフし、プロバイダー許可リストとコンテキスト上限、v1/base/v2、v2 スレッド数を設定します。 | | **ログ** | トークン、リクエスト強度、実際のモデル、プロバイダー、状態、リクエスト ID、所要時間、エラー詳細を含む最近のリクエストを自動更新します。 | @@ -69,7 +69,7 @@ bun run dev:gui - アカウントを直接選ぶと次の新規 Codex セッションから変わります。すでにアカウントが紐付いた thread はこの手動切り替えだけでは 途中で移動しません。 - Thread affinity がリクエストごとにアカウントが揺れるのを防ぎます。クォータ自動切り替えがオンなら長く - 実行される thread も定期的に再評価します。関連使用量がしきい値を超え、使用量が確実により低い + 実行される thread も定期的に再評価します。関連使用量がしきい値以上で、使用量が確実により低い 健全アカウントがあればそのアカウントに再紐付けできます。 - 新規セッションは使用量が最も低い健全アカウントを選べます。有料プランは既知の 5 時間、週間、30 日 枠のうち最も高い使用率でスコア付けし、Go/Free プランは 30 日枠のみ使います。 diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index 21a0c4c57..7383edd2a 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -34,7 +34,7 @@ bun run dev:gui | **Codex 자동 시작** | 이미 설치된 Codex launcher shim이 `ocx ensure`를 실행하도록 허용합니다. 이 토글은 shim이나 백그라운드 서비스를 설치하지 않습니다. | | **Providers** | 프로바이더를 추가, 편집, 활성화/비활성화, 제거하고, 지원되는 OAuth 계정 풀과 API key 풀을 관리합니다. | | **Add provider** | 레지스트리 기반 프리셋에서 계정 로그인, API key 서비스, 로컬 서버, custom endpoint를 검색합니다. | -| **Codex Auth** | ChatGPT/Codex 풀 계정을 추가하고, 다음 세션 계정을 선택하고, 5시간 / 주간 / 30일 할당량을 갱신하며, 할당량 자동 전환과 일시적 실패 failover를 설정합니다. | +| **Codex Auth** | ChatGPT/Codex 풀 계정을 추가하고, 다음 세션 계정을 선택하고, 5시간 / 주간 / 30일 할당량을 갱신하며, 할당량 자동 전환을 켜거나 끄고 1~100% 임계값과 일시적 실패 failover를 설정합니다. | | **Subagents** | `spawn_agent` override 목록에 네이티브 또는 라우팅 모델을 최대 5개까지 우선 노출합니다. | | **Models** | 네이티브 GPT와 라우팅 모델을 켜고 끄고, 프로바이더 allowlist와 컨텍스트 상한, v1/base/v2, v2 thread 수를 설정합니다. | | **Logs** | 토큰, 요청 강도, 실제 모델, 프로바이더, 상태, 요청 id, 소요 시간, 오류 상세가 포함된 최근 요청을 자동 갱신합니다. | @@ -69,7 +69,7 @@ Dashboard의 **Sub-agent delegation** 선택기는 `injectionModel`과 선택적 - 계정을 직접 고르면 다음 새 Codex 세션부터 바뀝니다. 이미 계정이 묶인 thread는 이 수동 전환만으로 중간에 이동하지 않습니다. - Thread affinity가 요청마다 계정이 흔들리는 일을 막습니다. 할당량 자동 전환이 켜져 있으면 오래 - 실행되는 thread도 주기적으로 다시 평가합니다. 관련 사용량이 임계값을 넘고 사용량이 확실히 더 낮은 + 실행되는 thread도 주기적으로 다시 평가합니다. 관련 사용량이 임계값 이상이고 사용량이 확실히 더 낮은 정상 계정이 있으면 그 계정으로 다시 묶일 수 있습니다. - 새 세션은 사용량이 가장 낮은 정상 계정을 고를 수 있습니다. 유료 플랜은 알려진 5시간, 주간, 30일 창 중 가장 높은 사용률로 점수를 매기고, Go/Free 플랜은 30일 창만 사용합니다. diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 2c831e87e..afab2782e 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -34,7 +34,7 @@ bun run dev:gui | **Автозапуск Codex** | Разрешает уже установленному launcher shim Codex выполнять `ocx ensure`. Переключатель не устанавливает shim или фоновую службу. | | **Providers** | Добавление, редактирование, включение/отключение и удаление провайдеров; управление пулами OAuth-аккаунтов и пулами API-ключей там, где они поддерживаются. | | **Add provider** | Поиск по пресетам из реестра: вход по аккаунту, сервисы с API-ключом, локальные серверы или пользовательская конечная точка. | -| **Codex Auth** | Добавление аккаунтов пула ChatGPT/Codex, выбор аккаунта для следующей сессии, обновление квот 5 ч / недельных / 30-дневных, настройка автопереключения по квоте и failover при временных сбоях. | +| **Codex Auth** | Добавление аккаунтов пула ChatGPT/Codex, выбор аккаунта для следующей сессии, обновление квот 5 ч / недельных / 30-дневных, включение или отключение автопереключения, настройка его порога 1–100% и failover при временных сбоях. | | **Subagents** | Выделение до пяти «голых» нативных или маршрутизируемых моделей с пространством имён в списке переопределений `spawn_agent`. | | **Models** | Включение и отключение нативных GPT и маршрутизируемых моделей, настройка списков разрешённых провайдеров и лимитов контекста, выбор v1/base/v2 и настройка лимита потоков v2. | | **Logs** | Автообновляемый список недавних запросов: токены, запрошенный уровень рассуждений, фактическая модель, провайдер, статус, id запроса, длительность и подробности ошибок. | diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index 2d910dc11..cd45b7d66 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -33,7 +33,7 @@ bun run dev:gui | **Codex 自动启动** | 允许已安装的 Codex launcher shim 运行 `ocx ensure`。此开关不会安装 shim 或后台服务。 | | **Providers** | 添加、编辑、启用/禁用、删除 provider,并在支持时管理 OAuth 账号池和 API key 池。 | | **Add provider** | 搜索 registry preset,选择账号登录、API key 服务、本地服务器或自定义 endpoint。 | -| **Codex Auth** | 添加 ChatGPT/Codex 池账号,选择下一 session 的账号,刷新 5h / 每周 / 30d 配额,并设置配额自动切换和临时故障 failover。 | +| **Codex Auth** | 添加 ChatGPT/Codex 池账号,选择下一 session 的账号,刷新 5h / 每周 / 30d 配额,启用或停用配额自动切换,设置其 1–100% 阈值和临时故障 failover。 | | **Subagents** | 在 `spawn_agent` override 列表中置顶最多五个原生或路由模型。 | | **Models** | 开关原生 GPT 与路由模型,配置 provider allowlist、上下文上限、v1/base/v2 以及 v2 thread 数量。 | | **Logs** | 自动刷新近期请求,显示 token、请求强度、实际模型、provider、状态、request id、耗时和错误详情。 | diff --git a/gui/src/codex-auto-switch.ts b/gui/src/codex-auto-switch.ts new file mode 100644 index 000000000..ce1160456 --- /dev/null +++ b/gui/src/codex-auto-switch.ts @@ -0,0 +1,60 @@ +export const DEFAULT_AUTO_SWITCH_THRESHOLD = 80; + +export type AutoSwitchFetch = (input: string, init: RequestInit) => Promise; + +export function normalizeAutoSwitchThreshold(value: unknown): number { + return typeof value === "number" + && Number.isInteger(value) + && value >= 0 + && value <= 100 + ? value + : DEFAULT_AUTO_SWITCH_THRESHOLD; +} + +export function parseEnabledAutoSwitchThreshold(value: string): number | null { + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) return null; + const threshold = Number(trimmed); + return threshold >= 1 && threshold <= 100 ? threshold : null; +} + +export function nextAutoSwitchThreshold(current: number, lastEnabled: number): number { + if (current > 0) return 0; + return Number.isInteger(lastEnabled) && lastEnabled >= 1 && lastEnabled <= 100 + ? lastEnabled + : DEFAULT_AUTO_SWITCH_THRESHOLD; +} + +/** + * A toggle click keeps focus on the toggle so input blur cannot race a second + * write. If the enabled draft changed, persist it first so a later re-enable + * restores the last successfully saved positive threshold. + */ +export function planAutoSwitchToggleWrites( + current: number, + draft: string, + lastEnabled: number, +): number[] | null { + if (current <= 0) return [nextAutoSwitchThreshold(current, lastEnabled)]; + const parsedDraft = parseEnabledAutoSwitchThreshold(draft); + if (parsedDraft === null) return null; + return parsedDraft === current ? [0] : [parsedDraft, 0]; +} + +export async function putAutoSwitchThreshold( + apiBase: string, + threshold: number, + fetchImpl: AutoSwitchFetch = (input, init) => fetch(input, init), +): Promise { + if (!Number.isInteger(threshold) || threshold < 0 || threshold > 100) return false; + try { + const response = await fetchImpl(`${apiBase}/api/codex-auth/auto-switch`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ threshold }), + }); + return response.ok; + } catch { + return false; + } +} diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index b3d8a3a42..4c61d4846 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -7,6 +7,13 @@ import type { AccountQuota } from "../codex-quota-utils"; import QuotaBars from "./QuotaBars"; import type { ReactNode } from "react"; import type { CodexAccountModeState } from "../codex-multi-state"; +import { + DEFAULT_AUTO_SWITCH_THRESHOLD, + normalizeAutoSwitchThreshold, + parseEnabledAutoSwitchThreshold, + planAutoSwitchToggleWrites, + putAutoSwitchThreshold, +} from "../codex-auto-switch"; export interface CodexAccountEntry { id: string; alias?: string; email: string; plan?: string; isMain: boolean; @@ -14,6 +21,85 @@ export interface CodexAccountEntry { needsReauth?: boolean; } +export function AutoSwitchSetting({ + threshold, + draft, + saving, + onDraftChange, + onEditingChange, + onCommit, + onToggle, +}: { + threshold: number; + draft: string; + saving: boolean; + onDraftChange: (value: string) => void; + onEditingChange: (editing: boolean) => void; + onCommit: () => Promise; + onToggle: () => Promise; +}) { + const t = useT(); + const enabled = threshold > 0; + return ( +
+
+ {t("codexAuth.autoSwitch")} +
+ {enabled + ? t("codexAuth.autoSwitchDesc", { threshold }) + : t("codexAuth.autoSwitchOffDesc")} +
+
+
+ {enabled && ( + + )} + +
+
+ ); +} + /** * Global ChatGPT / Codex account pool (main + extras), extracted from the Codex * Auth page (WP060). `accountModeState` arrives as a prop (the parent owns the @@ -31,7 +117,9 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const t = useT(); const [accounts, setAccounts] = useState([]); const [activeId, setActiveId] = useState(null); - const [autoThreshold, setAutoThreshold] = useState(80); + const [autoThreshold, setAutoThreshold] = useState(DEFAULT_AUTO_SWITCH_THRESHOLD); + const [autoThresholdDraft, setAutoThresholdDraft] = useState(String(DEFAULT_AUTO_SWITCH_THRESHOLD)); + const [savingAutoThreshold, setSavingAutoThreshold] = useState(false); const [confirm, setConfirm] = useState(null); const [showAdd, setShowAdd] = useState(false); const [reauthId, setReauthId] = useState(null); @@ -46,9 +134,14 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading"); const [switchingId, setSwitchingId] = useState(null); const loadGenerationRef = useRef(0); + const lastEnabledAutoThresholdRef = useRef(DEFAULT_AUTO_SWITCH_THRESHOLD); + const autoThresholdEditingRef = useRef(false); + const autoThresholdSavingRef = useRef(false); + const autoThresholdRevisionRef = useRef(0); const load = useCallback(async (refreshQuota = false) => { const generation = ++loadGenerationRef.current; + const autoThresholdRevision = autoThresholdRevisionRef.current; if (!refreshQuota) setLoadState("loading"); try { const [accountsResponse, activeResponse] = await Promise.all([ @@ -60,7 +153,14 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban if (loadGenerationRef.current !== generation) return false; setAccounts(accts.accounts ?? []); setActiveId(active.activeCodexAccountId ?? null); - setAutoThreshold(active.autoSwitchThreshold ?? 80); + if (!autoThresholdSavingRef.current && autoThresholdRevisionRef.current === autoThresholdRevision) { + const threshold = normalizeAutoSwitchThreshold(active.autoSwitchThreshold); + setAutoThreshold(threshold); + if (threshold > 0) lastEnabledAutoThresholdRef.current = threshold; + if (!autoThresholdEditingRef.current) { + setAutoThresholdDraft(String(threshold > 0 ? threshold : lastEnabledAutoThresholdRef.current)); + } + } setLoadState("ready"); return true; } catch { @@ -179,13 +279,81 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban } }; - const toggleAuto = async () => { - const next = autoThreshold > 0 ? 0 : 80; - await fetch(`${apiBase}/api/codex-auth/auto-switch`, { - method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ threshold: next }), - }); - setAutoThreshold(next); + const showAutoThresholdToast = (message: string, error: boolean) => { + setToast(message); + setToastError(error); + setTimeout(() => setToast(""), 5000); + }; + + const saveAutoThreshold = async ( + next: number, + previous = autoThreshold, + showSuccess = true, + ): Promise => { + if (autoThresholdSavingRef.current) return false; + autoThresholdSavingRef.current = true; + autoThresholdEditingRef.current = false; + setSavingAutoThreshold(true); + // Ignore threshold reads that began before or during this write while still + // allowing the same refresh pass to update account and quota data. + autoThresholdRevisionRef.current += 1; + const ok = await putAutoSwitchThreshold(apiBase, next); + autoThresholdRevisionRef.current += 1; + if (ok) { + setAutoThreshold(next); + if (next > 0) lastEnabledAutoThresholdRef.current = next; + setAutoThresholdDraft(String(next > 0 ? next : lastEnabledAutoThresholdRef.current)); + if (showSuccess) showAutoThresholdToast(t("codexAuth.autoSwitchUpdated"), false); + } else { + setAutoThreshold(previous); + setAutoThresholdDraft(String(previous > 0 ? previous : lastEnabledAutoThresholdRef.current)); + showAutoThresholdToast(t("codexAuth.autoSwitchUpdateFailed"), true); + } + autoThresholdSavingRef.current = false; + setSavingAutoThreshold(false); + return ok; + }; + + const rejectAutoThresholdDraft = () => { + autoThresholdEditingRef.current = false; + setAutoThresholdDraft(String(autoThreshold > 0 ? autoThreshold : lastEnabledAutoThresholdRef.current)); + showAutoThresholdToast(t("codexAuth.autoSwitchThresholdInvalid"), true); + }; + + const commitAutoThreshold = async (): Promise => { + autoThresholdEditingRef.current = false; + const next = parseEnabledAutoSwitchThreshold(autoThresholdDraft); + if (next === null) { + rejectAutoThresholdDraft(); + return false; + } + if (next === autoThreshold) { + setAutoThresholdDraft(String(next)); + return true; + } + return saveAutoThreshold(next); + }; + + const toggleAuto = async (): Promise => { + autoThresholdEditingRef.current = false; + const writes = planAutoSwitchToggleWrites( + autoThreshold, + autoThresholdDraft, + lastEnabledAutoThresholdRef.current, + ); + if (writes === null) { + rejectAutoThresholdDraft(); + return false; + } + + let previous = autoThreshold; + for (let index = 0; index < writes.length; index += 1) { + const next = writes[index]!; + const ok = await saveAutoThreshold(next, previous, index === writes.length - 1); + if (!ok) return false; + previous = next; + } + return true; }; const refreshQuotas = async () => { @@ -378,16 +546,18 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban ))} -
-
- {t("codexAuth.autoSwitch")} -
{t("codexAuth.autoSwitchDesc")}
-
- -
+ { + autoThresholdEditingRef.current = true; + setAutoThresholdDraft(value); + }} + onEditingChange={(editing) => { autoThresholdEditingRef.current = editing; }} + onCommit={commitAutoThreshold} + onToggle={toggleAuto} + /> {confirm && (
setConfirm(null)}> diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 45acb0246..3b5ac38d5 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -595,8 +595,13 @@ export const de = { "codexAuth.cancel": "Abbrechen", "codexAuth.switchBack": "Zurück zum Hauptkonto?", "codexAuth.switchBackDesc": "Die nächste Codex-Sitzung verwendet wieder dein App-Login-Konto.", - "codexAuth.autoSwitch": "Auto-Wechsel bei 80%", - "codexAuth.autoSwitchDesc": "Neue Sitzungen wechseln, wenn das entsprechende Kontingent-Fenster den Schwellenwert erreicht; Go/Free nutzen nur 30d", + "codexAuth.autoSwitch": "Automatischer Kontowechsel", + "codexAuth.autoSwitchDesc": "Wechselt ab {threshold} % Auslastung, wenn ein verfügbares Konto eine niedrigere Auslastung hat; Go/Free nutzen nur das 30-Tage-Fenster", + "codexAuth.autoSwitchOffDesc": "Der automatische Kontowechsel ist ausgeschaltet", + "codexAuth.autoSwitchThreshold": "Wechselschwelle", + "codexAuth.autoSwitchThresholdInvalid": "Gib eine ganze Zahl von 1 bis 100 ein", + "codexAuth.autoSwitchUpdated": "Der automatische Kontowechsel wurde aktualisiert", + "codexAuth.autoSwitchUpdateFailed": "Der automatische Kontowechsel konnte nicht aktualisiert werden. Der vorherige Wert bleibt unverändert.", "codexAuth.switched": "{email} wird ab der nächsten Sitzung verwendet", "codexAuth.loadFailed": "Konten konnten nicht geladen werden.", "codexAuth.switchFailed": "Das Konto konnte nicht gewechselt werden. Die vorherige Auswahl bleibt erhalten.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 7e643ddec..5f2ff06d1 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -896,8 +896,13 @@ export const en = { "codexAuth.cancel": "Cancel", "codexAuth.switchBack": "Switch back to Main?", "codexAuth.switchBackDesc": "Next Codex session will use your App login account again.", - "codexAuth.autoSwitch": "Auto-switch at 80%", - "codexAuth.autoSwitchDesc": "New sessions switch when the relevant quota window reaches the threshold; Go/Free use 30d only", + "codexAuth.autoSwitch": "Automatic account switching", + "codexAuth.autoSwitchDesc": "Switches at {threshold}% usage or above when an eligible account has lower usage; Go/Free use 30d only", + "codexAuth.autoSwitchOffDesc": "Automatic account switching is off", + "codexAuth.autoSwitchThreshold": "Switch threshold", + "codexAuth.autoSwitchThresholdInvalid": "Enter a whole number from 1 to 100", + "codexAuth.autoSwitchUpdated": "Automatic account switching updated", + "codexAuth.autoSwitchUpdateFailed": "Automatic account switching could not be updated. The previous value is unchanged.", "codexAuth.switched": "{email} will be used from next session", "codexAuth.loadFailed": "Accounts could not be loaded.", "codexAuth.switchFailed": "The account could not be switched. Your previous selection is unchanged.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 34b7d757b..2dd6ad5cd 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -851,8 +851,13 @@ export const ja: Record = { "codexAuth.cancel": "キャンセル", "codexAuth.switchBack": "メインに戻しますか?", "codexAuth.switchBackDesc": "次回の Codex セッションはアプリログインアカウントを再び使用します。", - "codexAuth.autoSwitch": "80% で自動切り替え", - "codexAuth.autoSwitchDesc": "関連するクォータ枠がしきい値に達すると新規セッションが切り替わります; Go/Free は 30日のみ", + "codexAuth.autoSwitch": "アカウントの自動切り替え", + "codexAuth.autoSwitchDesc": "使用率が {threshold}% 以上で、より使用率の低い利用可能なアカウントがある場合に切り替えます。Go/Free は 30 日枠のみを使用します", + "codexAuth.autoSwitchOffDesc": "アカウントの自動切り替えはオフです", + "codexAuth.autoSwitchThreshold": "切り替えしきい値", + "codexAuth.autoSwitchThresholdInvalid": "1 から 100 までの整数を入力してください", + "codexAuth.autoSwitchUpdated": "アカウントの自動切り替え設定を更新しました", + "codexAuth.autoSwitchUpdateFailed": "アカウントの自動切り替え設定を更新できませんでした。以前の値は変更されていません。", "codexAuth.switched": "{email} は次回セッションから使用されます", "codexAuth.loadFailed": "アカウントを読み込めませんでした。", "codexAuth.switchFailed": "アカウントを切り替えられませんでした。以前の選択はそのままです。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 77e95ff53..b69e6bc24 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -614,8 +614,13 @@ export const ko: Record = { "codexAuth.cancel": "취소", "codexAuth.switchBack": "메인 계정으로 돌아가시겠습니까?", "codexAuth.switchBackDesc": "다음 Codex 세션에서 앱 로그인 계정을 사용합니다.", - "codexAuth.autoSwitch": "80% 초과 시 자동 전환", - "codexAuth.autoSwitchDesc": "새 세션은 해당 플랜의 기준 창이 임계값에 도달하면 전환합니다. Go/Free는 30일만 봅니다", + "codexAuth.autoSwitch": "자동 계정 전환", + "codexAuth.autoSwitchDesc": "사용량이 {threshold}% 이상이고 사용량이 더 낮은 정상 계정이 있으면 전환합니다. Go/Free는 30일만 봅니다", + "codexAuth.autoSwitchOffDesc": "자동 계정 전환이 꺼져 있습니다", + "codexAuth.autoSwitchThreshold": "전환 임계값", + "codexAuth.autoSwitchThresholdInvalid": "1~100 사이의 정수를 입력하세요", + "codexAuth.autoSwitchUpdated": "자동 계정 전환 설정을 저장했습니다", + "codexAuth.autoSwitchUpdateFailed": "자동 계정 전환 설정을 저장하지 못했습니다. 이전 값은 그대로 유지됩니다.", "codexAuth.switched": "다음 세션부터 {email}을(를) 사용합니다", "codexAuth.loadFailed": "계정을 불러오지 못했습니다.", "codexAuth.switchFailed": "계정을 전환하지 못했습니다. 이전 선택은 그대로 유지됩니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 46af542c8..2b8745441 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -896,8 +896,13 @@ export const ru: Record = { "codexAuth.cancel": "Отмена", "codexAuth.switchBack": "Вернуться на основной аккаунт?", "codexAuth.switchBackDesc": "Следующая сессия Codex снова будет использовать ваш аккаунт входа через приложение.", - "codexAuth.autoSwitch": "Автопереключение при 80%", - "codexAuth.autoSwitchDesc": "Новые сессии переключаются, когда соответствующее окно квоты достигает порога; тарифы Go/Free используют только окно 30 дней", + "codexAuth.autoSwitch": "Автоматическое переключение аккаунтов", + "codexAuth.autoSwitchDesc": "Переключает при использовании {threshold}% или выше, если доступен аккаунт с меньшим использованием; тарифы Go/Free используют только окно 30 дней", + "codexAuth.autoSwitchOffDesc": "Автоматическое переключение аккаунтов выключено", + "codexAuth.autoSwitchThreshold": "Порог переключения", + "codexAuth.autoSwitchThresholdInvalid": "Введите целое число от 1 до 100", + "codexAuth.autoSwitchUpdated": "Настройки автоматического переключения обновлены", + "codexAuth.autoSwitchUpdateFailed": "Не удалось обновить настройки автоматического переключения. Предыдущее значение не изменено.", "codexAuth.switched": "{email} будет использоваться со следующей сессии", "codexAuth.loadFailed": "Не удалось загрузить аккаунты.", "codexAuth.switchFailed": "Не удалось переключить аккаунт. Ваш предыдущий выбор не изменён.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 84e6c85f4..df92357f7 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -614,8 +614,13 @@ export const zh: Record = { "codexAuth.cancel": "取消", "codexAuth.switchBack": "切换回主账号?", "codexAuth.switchBackDesc": "下次 Codex 会话将使用应用登录账号。", - "codexAuth.autoSwitch": "超过 80% 时自动切换", - "codexAuth.autoSwitchDesc": "新会话会在相关额度窗口达到阈值时切换;Go/Free 只使用 30天", + "codexAuth.autoSwitch": "自动切换账号", + "codexAuth.autoSwitchDesc": "使用率达到或超过 {threshold}% 且存在使用率更低的可用账号时切换;Go/Free 仅使用 30 天窗口", + "codexAuth.autoSwitchOffDesc": "自动切换账号已关闭", + "codexAuth.autoSwitchThreshold": "切换阈值", + "codexAuth.autoSwitchThresholdInvalid": "请输入 1 到 100 之间的整数", + "codexAuth.autoSwitchUpdated": "自动切换账号设置已更新", + "codexAuth.autoSwitchUpdateFailed": "无法更新自动切换账号设置。之前的值保持不变。", "codexAuth.switched": "下次会话将使用 {email}", "codexAuth.loadFailed": "无法加载账户。", "codexAuth.switchFailed": "无法切换账户。之前的选择保持不变。", diff --git a/gui/src/styles.css b/gui/src/styles.css index a1fc2a858..dbaeb9b01 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -727,6 +727,16 @@ dialog.modal-overlay::backdrop { .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: 1px; left: 1px; transition: left var(--motion-normal), background var(--motion-normal); } .toggle.on .toggle-knob { left: 17px; background: var(--toggle-dot-color); } +.toggle:disabled { cursor: not-allowed; opacity: 0.55; } +.codex-auto-switch-card { gap: 16px; } +.codex-auto-switch-copy { flex: 1 1 auto; min-width: 0; } +.codex-auto-switch-copy .card-sub { padding: 2px 0 0; } +.codex-auto-switch-controls { display: flex; align-items: flex-end; gap: 12px; flex: 0 0 auto; } +.codex-auto-switch-threshold { display: flex; flex-direction: column; align-items: flex-start; margin: 0; } +.codex-auto-switch-threshold .field-label { margin-bottom: 4px; white-space: nowrap; } +.codex-auto-switch-input-wrap { display: flex; align-items: center; gap: 6px; } +.codex-auto-switch-input { width: 84px; text-align: right; font-variant-numeric: tabular-nums; } +.codex-auto-switch-unit { color: var(--muted); font-size: var(--text-control); } .notice-warn { font-size: var(--text-label); line-height: var(--leading-body); padding: 8px 10px; border-radius: var(--radius-sm); background: var(--amber-soft); color: var(--amber); display: flex; align-items: center; gap: 6px; margin-bottom: 12px; } .notice-warn svg { width: 14px; height: 14px; flex-shrink: 0; } @@ -959,6 +969,8 @@ button.prov-account-row.active { cursor: default; } .setting-row > .select-sm { width: 100%; } .api-form-row { align-items: stretch; flex-direction: column; } .api-form-row .btn { width: 100%; min-height: 40px; } + .codex-auto-switch-card { align-items: stretch; flex-direction: column; } + .codex-auto-switch-controls { align-items: flex-end; justify-content: space-between; width: 100%; } .stat-row > .stat { min-width: 100px; } .tbl { min-width: 460px; } /* keep columns readable; .tbl-wrap scrolls horizontally */ .usage-cards { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } diff --git a/gui/tests/codex-account-auto-switch.test.tsx b/gui/tests/codex-account-auto-switch.test.tsx new file mode 100644 index 000000000..f58ab4d95 --- /dev/null +++ b/gui/tests/codex-account-auto-switch.test.tsx @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { + DEFAULT_AUTO_SWITCH_THRESHOLD, + nextAutoSwitchThreshold, + normalizeAutoSwitchThreshold, + parseEnabledAutoSwitchThreshold, + planAutoSwitchToggleWrites, + putAutoSwitchThreshold, + type AutoSwitchFetch, +} from "../src/codex-auto-switch"; +import { AutoSwitchSetting } from "../src/components/CodexAccountPool"; +import { LanguageProvider } from "../src/i18n/provider"; + +let previousLanguage: unknown; + +beforeEach(() => { + previousLanguage = (globalThis.navigator as { language?: unknown } | undefined)?.language; + Object.defineProperty(globalThis.navigator, "language", { + configurable: true, + value: "en-US", + }); +}); + +afterEach(() => { + Object.defineProperty(globalThis.navigator, "language", { + configurable: true, + value: previousLanguage, + }); +}); + +function renderSetting(threshold: number, draft = String(threshold), saving = false): string { + return renderToStaticMarkup( + + {}} + onEditingChange={() => {}} + onCommit={async () => true} + onToggle={async () => true} + /> + , + ); +} + +describe("Codex account auto-switch threshold", () => { + test("accepts enabled integer boundaries and a custom value", () => { + expect(parseEnabledAutoSwitchThreshold("1")).toBe(1); + expect(parseEnabledAutoSwitchThreshold("95")).toBe(95); + expect(parseEnabledAutoSwitchThreshold("100")).toBe(100); + }); + + test("rejects empty, disabled, fractional, negative, and out-of-range drafts", () => { + for (const invalid of ["", "0", "50.5", "-1", "101", "abc"]) { + expect(parseEnabledAutoSwitchThreshold(invalid)).toBeNull(); + } + }); + + test("normalizes malformed server values to the existing default", () => { + expect(normalizeAutoSwitchThreshold(0)).toBe(0); + expect(normalizeAutoSwitchThreshold(95)).toBe(95); + expect(normalizeAutoSwitchThreshold(undefined)).toBe(DEFAULT_AUTO_SWITCH_THRESHOLD); + expect(normalizeAutoSwitchThreshold(101)).toBe(DEFAULT_AUTO_SWITCH_THRESHOLD); + }); + + test("toggle disables with zero and restores the last enabled value", () => { + expect(nextAutoSwitchThreshold(95, 95)).toBe(0); + expect(nextAutoSwitchThreshold(0, 95)).toBe(95); + expect(nextAutoSwitchThreshold(0, 0)).toBe(DEFAULT_AUTO_SWITCH_THRESHOLD); + }); + + test("toggle persists a changed valid draft before disabling", () => { + expect(planAutoSwitchToggleWrites(90, "95", 90)).toEqual([95, 0]); + expect(planAutoSwitchToggleWrites(90, "90", 90)).toEqual([0]); + expect(planAutoSwitchToggleWrites(0, "90", 95)).toEqual([95]); + }); + + test("toggle rejects an invalid enabled draft without writing", () => { + expect(planAutoSwitchToggleWrites(90, "50.5", 90)).toBeNull(); + expect(planAutoSwitchToggleWrites(90, "", 90)).toBeNull(); + }); + + test("renders the persisted custom threshold and inclusive condition", () => { + const html = renderSetting(95); + expect(html).toContain('value="95"'); + expect(html).toContain('min="1"'); + expect(html).toContain('max="100"'); + expect(html).toContain("95% usage or above"); + expect(html).toContain('aria-pressed="true"'); + }); + + test("renders both enabled boundary values", () => { + for (const threshold of [1, 100]) { + const html = renderSetting(threshold); + expect(html).toContain(`value="${threshold}"`); + expect(html).toContain(`${threshold}% usage or above`); + } + }); + + test("renders an explicit off state without an editable threshold", () => { + const html = renderSetting(0, "80"); + expect(html).toContain("Automatic account switching is off"); + expect(html).toContain('aria-pressed="false"'); + expect(html).not.toContain('type="number"'); + }); + + test("disables controls while a threshold write is pending", () => { + const html = renderSetting(80, "80", true); + expect(html).toContain('aria-busy="true"'); + expect(html).toContain('disabled=""'); + }); + + test("writes the requested threshold through the existing management endpoint", async () => { + let request: { input: string; init: RequestInit } | null = null; + const fetchImpl: AutoSwitchFetch = async (input, init) => { + request = { input, init }; + return new Response(null, { status: 204 }); + }; + + expect(await putAutoSwitchThreshold("http://localhost:10100", 95, fetchImpl)).toBe(true); + expect(request?.input).toBe("http://localhost:10100/api/codex-auth/auto-switch"); + expect(request?.init.method).toBe("PUT"); + expect(request?.init.body).toBe(JSON.stringify({ threshold: 95 })); + }); + + test("reports HTTP and network failures without accepting the write", async () => { + const httpFailure: AutoSwitchFetch = async () => new Response(null, { status: 500 }); + const networkFailure: AutoSwitchFetch = async () => { throw new Error("offline"); }; + expect(await putAutoSwitchThreshold("", 95, httpFailure)).toBe(false); + expect(await putAutoSwitchThreshold("", 95, networkFailure)).toBe(false); + }); + + test("does not send an invalid threshold", async () => { + let calls = 0; + const fetchImpl: AutoSwitchFetch = async () => { + calls += 1; + return new Response(null, { status: 204 }); + }; + expect(await putAutoSwitchThreshold("", 101, fetchImpl)).toBe(false); + expect(calls).toBe(0); + }); +}); From 8feb098024d3be11a6c2091fc40a045b1335ed27 Mon Sep 17 00:00:00 2001 From: csa906 Date: Thu, 23 Jul 2026 22:27:04 +0900 Subject: [PATCH 2/8] fix(gui): harden Codex auto-switch threshold UX --- gui/src/codex-auto-switch.ts | 34 +- gui/src/components/CodexAccountPool.tsx | 351 +++++++++++++------ gui/src/i18n/de.ts | 6 +- gui/src/i18n/en.ts | 6 +- gui/src/i18n/ja.ts | 6 +- gui/src/i18n/ko.ts | 6 +- gui/src/i18n/ru.ts | 6 +- gui/src/i18n/zh.ts | 6 +- gui/src/styles.css | 9 +- gui/tests/codex-account-auto-switch.test.tsx | 64 +++- 10 files changed, 356 insertions(+), 138 deletions(-) diff --git a/gui/src/codex-auto-switch.ts b/gui/src/codex-auto-switch.ts index ce1160456..1907c44f8 100644 --- a/gui/src/codex-auto-switch.ts +++ b/gui/src/codex-auto-switch.ts @@ -25,20 +25,40 @@ export function nextAutoSwitchThreshold(current: number, lastEnabled: number): n : DEFAULT_AUTO_SWITCH_THRESHOLD; } +export type AutoSwitchThresholdReadDisposition = "apply" | "defer" | "ignore"; + +export function autoSwitchThresholdReadDisposition( + editing: boolean, + saving: boolean, + startedRevision: number, + currentRevision: number, +): AutoSwitchThresholdReadDisposition { + if (startedRevision !== currentRevision) return "ignore"; + return editing || saving ? "defer" : "apply"; +} + +export interface AutoSwitchTogglePlan { + threshold: number; + lastEnabled: number; +} + /** - * A toggle click keeps focus on the toggle so input blur cannot race a second - * write. If the enabled draft changed, persist it first so a later re-enable - * restores the last successfully saved positive threshold. + * Disabling is one server write. A valid dirty draft becomes the page-lifetime + * restore value, so re-enabling can persist it without a partial two-write + * failure state. */ -export function planAutoSwitchToggleWrites( +export function planAutoSwitchToggleWrite( current: number, draft: string, lastEnabled: number, -): number[] | null { - if (current <= 0) return [nextAutoSwitchThreshold(current, lastEnabled)]; +): AutoSwitchTogglePlan | null { + if (current <= 0) { + const threshold = nextAutoSwitchThreshold(current, lastEnabled); + return { threshold, lastEnabled: threshold }; + } const parsedDraft = parseEnabledAutoSwitchThreshold(draft); if (parsedDraft === null) return null; - return parsedDraft === current ? [0] : [parsedDraft, 0]; + return { threshold: 0, lastEnabled: parsedDraft }; } export async function putAutoSwitchThreshold( diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 4c61d4846..aa25ee5e5 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -9,9 +9,10 @@ import type { ReactNode } from "react"; import type { CodexAccountModeState } from "../codex-multi-state"; import { DEFAULT_AUTO_SWITCH_THRESHOLD, + autoSwitchThresholdReadDisposition, normalizeAutoSwitchThreshold, parseEnabledAutoSwitchThreshold, - planAutoSwitchToggleWrites, + planAutoSwitchToggleWrite, putAutoSwitchThreshold, } from "../codex-auto-switch"; @@ -25,77 +26,127 @@ export function AutoSwitchSetting({ threshold, draft, saving, + loadError, + feedback, onDraftChange, onEditingChange, onCommit, + onCancel, onToggle, + onRetry, }: { - threshold: number; + threshold: number | null; draft: string; saving: boolean; + loadError: boolean; + feedback: { tone: "ok" | "err"; message: string } | null; onDraftChange: (value: string) => void; onEditingChange: (editing: boolean) => void; onCommit: () => Promise; + onCancel: () => void; onToggle: () => Promise; + onRetry: () => void; }) { const t = useT(); - const enabled = threshold > 0; + const ready = threshold !== null; + const enabled = ready && threshold > 0; + const feedbackMessage = saving ? t("common.saving") : feedback?.message ?? ""; + const feedbackTone = saving ? "pending" : feedback?.tone; + const describedBy = feedbackMessage + ? "codex-auto-switch-desc codex-auto-switch-feedback" + : "codex-auto-switch-desc"; return ( -
+
{t("codexAuth.autoSwitch")} -
- {enabled +
+ {!ready + ? loadError ? t("codexAuth.autoSwitchLoadFailed") : t("common.loading") + : enabled ? t("codexAuth.autoSwitchDesc", { threshold }) : t("codexAuth.autoSwitchOffDesc")}
-
- {enabled && ( - - )} - -
+ {enabled && ( + + )} + +
+ ) : loadError ? ( +
+ +
+ ) : null} + {feedbackMessage && ( +
+ {feedbackMessage} +
+ )}
); } @@ -117,9 +168,11 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const t = useT(); const [accounts, setAccounts] = useState([]); const [activeId, setActiveId] = useState(null); - const [autoThreshold, setAutoThreshold] = useState(DEFAULT_AUTO_SWITCH_THRESHOLD); - const [autoThresholdDraft, setAutoThresholdDraft] = useState(String(DEFAULT_AUTO_SWITCH_THRESHOLD)); + const [autoThreshold, setAutoThreshold] = useState(null); + const [autoThresholdDraft, setAutoThresholdDraft] = useState(""); + const [autoThresholdLoadError, setAutoThresholdLoadError] = useState(false); const [savingAutoThreshold, setSavingAutoThreshold] = useState(false); + const [autoThresholdFeedback, setAutoThresholdFeedback] = useState<{ tone: "ok" | "err"; message: string } | null>(null); const [confirm, setConfirm] = useState(null); const [showAdd, setShowAdd] = useState(false); const [reauthId, setReauthId] = useState(null); @@ -134,40 +187,93 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading"); const [switchingId, setSwitchingId] = useState(null); const loadGenerationRef = useRef(0); + const autoThresholdRef = useRef(null); const lastEnabledAutoThresholdRef = useRef(DEFAULT_AUTO_SWITCH_THRESHOLD); const autoThresholdEditingRef = useRef(false); const autoThresholdSavingRef = useRef(false); const autoThresholdRevisionRef = useRef(0); + const pendingAutoThresholdRef = useRef(null); + const autoThresholdFeedbackTimeoutRef = useRef(null); + + const applyAutoThreshold = useCallback((threshold: number) => { + autoThresholdRef.current = threshold; + setAutoThreshold(threshold); + if (threshold > 0) lastEnabledAutoThresholdRef.current = threshold; + setAutoThresholdDraft(String(threshold > 0 ? threshold : lastEnabledAutoThresholdRef.current)); + }, []); + + const queueOrApplyAutoThreshold = useCallback((threshold: number) => { + if (autoThresholdEditingRef.current || autoThresholdSavingRef.current) { + pendingAutoThresholdRef.current = threshold; + return; + } + pendingAutoThresholdRef.current = null; + applyAutoThreshold(threshold); + }, [applyAutoThreshold]); + + const reconcilePendingAutoThreshold = useCallback((): boolean => { + const pending = pendingAutoThresholdRef.current; + if (pending === null) return false; + pendingAutoThresholdRef.current = null; + applyAutoThreshold(pending); + return true; + }, [applyAutoThreshold]); + + useEffect(() => () => { + if (autoThresholdFeedbackTimeoutRef.current !== null) { + window.clearTimeout(autoThresholdFeedbackTimeoutRef.current); + } + }, []); const load = useCallback(async (refreshQuota = false) => { const generation = ++loadGenerationRef.current; const autoThresholdRevision = autoThresholdRevisionRef.current; if (!refreshQuota) setLoadState("loading"); - try { - const [accountsResponse, activeResponse] = await Promise.all([ - fetch(`${apiBase}/api/codex-auth/accounts${refreshQuota ? "?refresh=1" : ""}`), - fetch(`${apiBase}/api/codex-auth/active`), - ]); - if (!accountsResponse.ok || !activeResponse.ok) throw new Error("account load failed"); - const [accts, active] = await Promise.all([accountsResponse.json(), activeResponse.json()]); - if (loadGenerationRef.current !== generation) return false; - setAccounts(accts.accounts ?? []); - setActiveId(active.activeCodexAccountId ?? null); - if (!autoThresholdSavingRef.current && autoThresholdRevisionRef.current === autoThresholdRevision) { - const threshold = normalizeAutoSwitchThreshold(active.autoSwitchThreshold); - setAutoThreshold(threshold); - if (threshold > 0) lastEnabledAutoThresholdRef.current = threshold; - if (!autoThresholdEditingRef.current) { - setAutoThresholdDraft(String(threshold > 0 ? threshold : lastEnabledAutoThresholdRef.current)); + if (autoThresholdRef.current === null) setAutoThresholdLoadError(false); + 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 accts = await response.json(); + if (loadGenerationRef.current === generation) setAccounts(accts.accounts ?? []); + return true; + } catch { + return false; + } + })(); + const activeTask = (async (): Promise => { + try { + const response = await fetch(`${apiBase}/api/codex-auth/active`); + if (!response.ok) throw new Error("active account load failed"); + const active = await response.json(); + if (loadGenerationRef.current === generation) { + setActiveId(active.activeCodexAccountId ?? null); + setAutoThresholdLoadError(false); + const disposition = autoSwitchThresholdReadDisposition( + autoThresholdEditingRef.current, + autoThresholdSavingRef.current, + autoThresholdRevision, + autoThresholdRevisionRef.current, + ); + if (disposition === "defer") { + pendingAutoThresholdRef.current = normalizeAutoSwitchThreshold(active.autoSwitchThreshold); + } else if (disposition === "apply") { + queueOrApplyAutoThreshold(normalizeAutoSwitchThreshold(active.autoSwitchThreshold)); + } + } + return true; + } catch { + if (loadGenerationRef.current === generation && autoThresholdRef.current === null) { + setAutoThresholdLoadError(true); } + return false; } - setLoadState("ready"); - return true; - } catch { - if (loadGenerationRef.current === generation) setLoadState("error"); - return false; - } - }, [apiBase]); + })(); + const [accountsOk, activeOk] = await Promise.all([accountsTask, activeTask]); + if (loadGenerationRef.current !== generation) return false; + setLoadState(accountsOk && activeOk ? "ready" : "error"); + return accountsOk && activeOk; + }, [apiBase, queueOrApplyAutoThreshold]); useEffect(() => { const timeout = window.setTimeout(() => { void load(); @@ -279,81 +385,105 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban } }; - const showAutoThresholdToast = (message: string, error: boolean) => { - setToast(message); - setToastError(error); - setTimeout(() => setToast(""), 5000); + const clearAutoThresholdFeedback = () => { + if (autoThresholdFeedbackTimeoutRef.current !== null) { + window.clearTimeout(autoThresholdFeedbackTimeoutRef.current); + autoThresholdFeedbackTimeoutRef.current = null; + } + setAutoThresholdFeedback(null); + }; + + const showAutoThresholdFeedback = (message: string, error: boolean) => { + if (autoThresholdFeedbackTimeoutRef.current !== null) { + window.clearTimeout(autoThresholdFeedbackTimeoutRef.current); + } + setAutoThresholdFeedback({ tone: error ? "err" : "ok", message }); + autoThresholdFeedbackTimeoutRef.current = window.setTimeout(() => { + setAutoThresholdFeedback(null); + autoThresholdFeedbackTimeoutRef.current = null; + }, 5000); }; const saveAutoThreshold = async ( next: number, - previous = autoThreshold, + previous: number, showSuccess = true, ): Promise => { if (autoThresholdSavingRef.current) return false; autoThresholdSavingRef.current = true; autoThresholdEditingRef.current = false; + clearAutoThresholdFeedback(); setSavingAutoThreshold(true); // Ignore threshold reads that began before or during this write while still // allowing the same refresh pass to update account and quota data. autoThresholdRevisionRef.current += 1; const ok = await putAutoSwitchThreshold(apiBase, next); autoThresholdRevisionRef.current += 1; + autoThresholdSavingRef.current = false; if (ok) { - setAutoThreshold(next); - if (next > 0) lastEnabledAutoThresholdRef.current = next; - setAutoThresholdDraft(String(next > 0 ? next : lastEnabledAutoThresholdRef.current)); - if (showSuccess) showAutoThresholdToast(t("codexAuth.autoSwitchUpdated"), false); + pendingAutoThresholdRef.current = null; + applyAutoThreshold(next); + if (showSuccess) showAutoThresholdFeedback(t("codexAuth.autoSwitchUpdated"), false); } else { - setAutoThreshold(previous); - setAutoThresholdDraft(String(previous > 0 ? previous : lastEnabledAutoThresholdRef.current)); - showAutoThresholdToast(t("codexAuth.autoSwitchUpdateFailed"), true); + if (!reconcilePendingAutoThreshold()) applyAutoThreshold(previous); + showAutoThresholdFeedback(t("codexAuth.autoSwitchUpdateFailed"), true); } - autoThresholdSavingRef.current = false; setSavingAutoThreshold(false); return ok; }; const rejectAutoThresholdDraft = () => { autoThresholdEditingRef.current = false; - setAutoThresholdDraft(String(autoThreshold > 0 ? autoThreshold : lastEnabledAutoThresholdRef.current)); - showAutoThresholdToast(t("codexAuth.autoSwitchThresholdInvalid"), true); + const current = autoThresholdRef.current; + if (!reconcilePendingAutoThreshold() && current !== null) { + setAutoThresholdDraft(String(current > 0 ? current : lastEnabledAutoThresholdRef.current)); + } + showAutoThresholdFeedback(t("codexAuth.autoSwitchThresholdInvalid"), true); + }; + + const cancelAutoThresholdDraft = () => { + autoThresholdEditingRef.current = false; + clearAutoThresholdFeedback(); + const current = autoThresholdRef.current; + if (!reconcilePendingAutoThreshold() && current !== null) { + setAutoThresholdDraft(String(current > 0 ? current : lastEnabledAutoThresholdRef.current)); + } }; const commitAutoThreshold = async (): Promise => { + const current = autoThresholdRef.current; + if (current === null || autoThresholdSavingRef.current) return false; autoThresholdEditingRef.current = false; const next = parseEnabledAutoSwitchThreshold(autoThresholdDraft); if (next === null) { rejectAutoThresholdDraft(); return false; } - if (next === autoThreshold) { - setAutoThresholdDraft(String(next)); + if (next === current) { + if (!reconcilePendingAutoThreshold()) setAutoThresholdDraft(String(next)); return true; } - return saveAutoThreshold(next); + return saveAutoThreshold(next, current); }; const toggleAuto = async (): Promise => { + const current = autoThresholdRef.current; + if (current === null || autoThresholdSavingRef.current) return false; autoThresholdEditingRef.current = false; - const writes = planAutoSwitchToggleWrites( - autoThreshold, + const plan = planAutoSwitchToggleWrite( + current, autoThresholdDraft, lastEnabledAutoThresholdRef.current, ); - if (writes === null) { + if (plan === null) { rejectAutoThresholdDraft(); return false; } - - let previous = autoThreshold; - for (let index = 0; index < writes.length; index += 1) { - const next = writes[index]!; - const ok = await saveAutoThreshold(next, previous, index === writes.length - 1); - if (!ok) return false; - previous = next; - } - return true; + const ok = await saveAutoThreshold(plan.threshold, current); + if (!ok) return false; + lastEnabledAutoThresholdRef.current = plan.lastEnabled; + if (plan.threshold === 0) setAutoThresholdDraft(String(plan.lastEnabled)); + return ok; }; const refreshQuotas = async () => { @@ -480,7 +610,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
{main?.email ?? "Codex App login"}{main?.plan ? ` · ${main.plan}` : ""}
{main?.needsReauth ?
{t("codexAuth.mainTokenExpired")}
- : main?.quota && } + : main?.quota && }
@@ -542,7 +672,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
{a.email}{a.plan ? ` · ${a.plan}` : ""} · {t("prov.accountId")}: {a.id}
{a.needsReauth ?
{t("codexAuth.tokenExpired")}
- : } + : }
))} @@ -550,13 +680,18 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban threshold={autoThreshold} draft={autoThresholdDraft} saving={savingAutoThreshold} + loadError={autoThresholdLoadError} + feedback={autoThresholdFeedback} onDraftChange={(value) => { autoThresholdEditingRef.current = true; + clearAutoThresholdFeedback(); setAutoThresholdDraft(value); }} onEditingChange={(editing) => { autoThresholdEditingRef.current = editing; }} onCommit={commitAutoThreshold} + onCancel={cancelAutoThresholdDraft} onToggle={toggleAuto} + onRetry={() => { void load(); }} /> {confirm && ( diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 3b5ac38d5..958173ab6 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -599,11 +599,13 @@ export const de = { "codexAuth.autoSwitchDesc": "Wechselt ab {threshold} % Auslastung, wenn ein verfügbares Konto eine niedrigere Auslastung hat; Go/Free nutzen nur das 30-Tage-Fenster", "codexAuth.autoSwitchOffDesc": "Der automatische Kontowechsel ist ausgeschaltet", "codexAuth.autoSwitchThreshold": "Wechselschwelle", + "codexAuth.autoSwitchThresholdAria": "Wechselschwelle in Prozent", + "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", - "codexAuth.autoSwitchUpdateFailed": "Der automatische Kontowechsel konnte nicht aktualisiert werden. Der vorherige Wert bleibt unverändert.", + "codexAuth.autoSwitchUpdateFailed": "Die Aktualisierung konnte nicht bestätigt werden. Der zuletzt bestätigte Wert wird angezeigt.", "codexAuth.switched": "{email} wird ab der nächsten Sitzung verwendet", - "codexAuth.loadFailed": "Konten konnten nicht geladen werden.", + "codexAuth.loadFailed": "Die Codex-Kontoeinstellungen konnten nicht geladen werden.", "codexAuth.switchFailed": "Das Konto konnte nicht gewechselt werden. Die vorherige Auswahl bleibt erhalten.", "codexAuth.removeConfirm": "{id} entfernen?", "codexAuth.removeFailed": "Das Konto konnte nicht entfernt werden. Es wurde nichts geändert.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 5f2ff06d1..3ff1426f9 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -900,11 +900,13 @@ export const en = { "codexAuth.autoSwitchDesc": "Switches at {threshold}% usage or above when an eligible account has lower usage; Go/Free use 30d only", "codexAuth.autoSwitchOffDesc": "Automatic account switching is off", "codexAuth.autoSwitchThreshold": "Switch threshold", + "codexAuth.autoSwitchThresholdAria": "Switch threshold, percent", + "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", - "codexAuth.autoSwitchUpdateFailed": "Automatic account switching could not be updated. The previous value is unchanged.", + "codexAuth.autoSwitchUpdateFailed": "The update could not be confirmed. The last confirmed value is shown.", "codexAuth.switched": "{email} will be used from next session", - "codexAuth.loadFailed": "Accounts could not be loaded.", + "codexAuth.loadFailed": "Codex account settings could not be loaded.", "codexAuth.switchFailed": "The account could not be switched. Your previous selection is unchanged.", "codexAuth.removeConfirm": "Remove {id}?", "codexAuth.removeFailed": "The account could not be removed. Nothing was changed.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 2dd6ad5cd..8f92e88f9 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -855,11 +855,13 @@ export const ja: Record = { "codexAuth.autoSwitchDesc": "使用率が {threshold}% 以上で、より使用率の低い利用可能なアカウントがある場合に切り替えます。Go/Free は 30 日枠のみを使用します", "codexAuth.autoSwitchOffDesc": "アカウントの自動切り替えはオフです", "codexAuth.autoSwitchThreshold": "切り替えしきい値", + "codexAuth.autoSwitchThresholdAria": "切り替えしきい値(パーセント)", + "codexAuth.autoSwitchLoadFailed": "アカウントの自動切り替え設定を読み込めませんでした。", "codexAuth.autoSwitchThresholdInvalid": "1 から 100 までの整数を入力してください", "codexAuth.autoSwitchUpdated": "アカウントの自動切り替え設定を更新しました", - "codexAuth.autoSwitchUpdateFailed": "アカウントの自動切り替え設定を更新できませんでした。以前の値は変更されていません。", + "codexAuth.autoSwitchUpdateFailed": "更新を確認できませんでした。最後に確認された値を表示しています。", "codexAuth.switched": "{email} は次回セッションから使用されます", - "codexAuth.loadFailed": "アカウントを読み込めませんでした。", + "codexAuth.loadFailed": "Codex アカウント設定を読み込めませんでした。", "codexAuth.switchFailed": "アカウントを切り替えられませんでした。以前の選択はそのままです。", "codexAuth.removeConfirm": "{id} を削除しますか?", "codexAuth.removeFailed": "アカウントを削除できませんでした。何も変更されていません。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index b69e6bc24..7aedc15e1 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -618,11 +618,13 @@ export const ko: Record = { "codexAuth.autoSwitchDesc": "사용량이 {threshold}% 이상이고 사용량이 더 낮은 정상 계정이 있으면 전환합니다. Go/Free는 30일만 봅니다", "codexAuth.autoSwitchOffDesc": "자동 계정 전환이 꺼져 있습니다", "codexAuth.autoSwitchThreshold": "전환 임계값", + "codexAuth.autoSwitchThresholdAria": "전환 임계값(퍼센트)", + "codexAuth.autoSwitchLoadFailed": "자동 계정 전환 설정을 불러오지 못했습니다.", "codexAuth.autoSwitchThresholdInvalid": "1~100 사이의 정수를 입력하세요", "codexAuth.autoSwitchUpdated": "자동 계정 전환 설정을 저장했습니다", - "codexAuth.autoSwitchUpdateFailed": "자동 계정 전환 설정을 저장하지 못했습니다. 이전 값은 그대로 유지됩니다.", + "codexAuth.autoSwitchUpdateFailed": "자동 계정 전환 설정 변경을 확인하지 못했습니다. 마지막으로 확인된 값을 표시합니다.", "codexAuth.switched": "다음 세션부터 {email}을(를) 사용합니다", - "codexAuth.loadFailed": "계정을 불러오지 못했습니다.", + "codexAuth.loadFailed": "Codex 계정 설정을 불러오지 못했습니다.", "codexAuth.switchFailed": "계정을 전환하지 못했습니다. 이전 선택은 그대로 유지됩니다.", "codexAuth.removeConfirm": "{id}을(를) 삭제하시겠습니까?", "codexAuth.removeFailed": "계정을 제거하지 못했습니다. 변경된 내용은 없습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 2b8745441..d79d2eb4c 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -900,11 +900,13 @@ export const ru: Record = { "codexAuth.autoSwitchDesc": "Переключает при использовании {threshold}% или выше, если доступен аккаунт с меньшим использованием; тарифы Go/Free используют только окно 30 дней", "codexAuth.autoSwitchOffDesc": "Автоматическое переключение аккаунтов выключено", "codexAuth.autoSwitchThreshold": "Порог переключения", + "codexAuth.autoSwitchThresholdAria": "Порог переключения в процентах", + "codexAuth.autoSwitchLoadFailed": "Не удалось загрузить настройку автоматического переключения аккаунтов.", "codexAuth.autoSwitchThresholdInvalid": "Введите целое число от 1 до 100", "codexAuth.autoSwitchUpdated": "Настройки автоматического переключения обновлены", - "codexAuth.autoSwitchUpdateFailed": "Не удалось обновить настройки автоматического переключения. Предыдущее значение не изменено.", + "codexAuth.autoSwitchUpdateFailed": "Не удалось подтвердить обновление. Показано последнее подтверждённое значение.", "codexAuth.switched": "{email} будет использоваться со следующей сессии", - "codexAuth.loadFailed": "Не удалось загрузить аккаунты.", + "codexAuth.loadFailed": "Не удалось загрузить настройки аккаунтов Codex.", "codexAuth.switchFailed": "Не удалось переключить аккаунт. Ваш предыдущий выбор не изменён.", "codexAuth.removeConfirm": "Удалить {id}?", "codexAuth.removeFailed": "Не удалось удалить аккаунт. Ничего не изменено.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index df92357f7..0761521d2 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -618,11 +618,13 @@ export const zh: Record = { "codexAuth.autoSwitchDesc": "使用率达到或超过 {threshold}% 且存在使用率更低的可用账号时切换;Go/Free 仅使用 30 天窗口", "codexAuth.autoSwitchOffDesc": "自动切换账号已关闭", "codexAuth.autoSwitchThreshold": "切换阈值", + "codexAuth.autoSwitchThresholdAria": "切换阈值(百分比)", + "codexAuth.autoSwitchLoadFailed": "无法加载自动切换账号设置。", "codexAuth.autoSwitchThresholdInvalid": "请输入 1 到 100 之间的整数", "codexAuth.autoSwitchUpdated": "自动切换账号设置已更新", - "codexAuth.autoSwitchUpdateFailed": "无法更新自动切换账号设置。之前的值保持不变。", + "codexAuth.autoSwitchUpdateFailed": "无法确认更新。当前显示最后一次确认的值。", "codexAuth.switched": "下次会话将使用 {email}", - "codexAuth.loadFailed": "无法加载账户。", + "codexAuth.loadFailed": "无法加载 Codex 账号设置。", "codexAuth.switchFailed": "无法切换账户。之前的选择保持不变。", "codexAuth.removeConfirm": "删除 {id}?", "codexAuth.removeFailed": "无法移除账户。未进行任何更改。", diff --git a/gui/src/styles.css b/gui/src/styles.css index dbaeb9b01..4c5b527d8 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -728,15 +728,19 @@ dialog.modal-overlay::backdrop { .toggle-knob { width: 16px; height: 16px; background: light-dark(#ffffff, #ececec); border-radius: var(--radius-round); position: absolute; top: 1px; left: 1px; transition: left var(--motion-normal), background var(--motion-normal); } .toggle.on .toggle-knob { left: 17px; background: var(--toggle-dot-color); } .toggle:disabled { cursor: not-allowed; opacity: 0.55; } -.codex-auto-switch-card { gap: 16px; } +.codex-auto-switch-card { gap: 16px; flex-wrap: wrap; } .codex-auto-switch-copy { flex: 1 1 auto; min-width: 0; } .codex-auto-switch-copy .card-sub { padding: 2px 0 0; } -.codex-auto-switch-controls { display: flex; align-items: flex-end; gap: 12px; flex: 0 0 auto; } +.codex-auto-switch-controls { display: flex; align-items: flex-end; gap: 12px; flex: 0 0 auto; margin-left: auto; } +.codex-auto-switch-controls > .toggle { margin-left: auto; } .codex-auto-switch-threshold { display: flex; flex-direction: column; align-items: flex-start; margin: 0; } .codex-auto-switch-threshold .field-label { margin-bottom: 4px; white-space: nowrap; } .codex-auto-switch-input-wrap { display: flex; align-items: center; gap: 6px; } .codex-auto-switch-input { width: 84px; text-align: right; font-variant-numeric: tabular-nums; } +.codex-auto-switch-input[readonly] { cursor: progress; opacity: 0.7; } .codex-auto-switch-unit { color: var(--muted); font-size: var(--text-control); } +.codex-auto-switch-feedback { flex: 1 0 100%; margin-top: -8px; color: var(--muted); font-size: var(--text-label); line-height: var(--leading-body); text-align: right; } +.codex-auto-switch-feedback.is-error { color: var(--red); } .notice-warn { font-size: var(--text-label); line-height: var(--leading-body); padding: 8px 10px; border-radius: var(--radius-sm); background: var(--amber-soft); color: var(--amber); display: flex; align-items: center; gap: 6px; margin-bottom: 12px; } .notice-warn svg { width: 14px; height: 14px; flex-shrink: 0; } @@ -971,6 +975,7 @@ button.prov-account-row.active { cursor: default; } .api-form-row .btn { width: 100%; min-height: 40px; } .codex-auto-switch-card { align-items: stretch; flex-direction: column; } .codex-auto-switch-controls { align-items: flex-end; justify-content: space-between; width: 100%; } + .codex-auto-switch-feedback { margin-top: -6px; text-align: left; } .stat-row > .stat { min-width: 100px; } .tbl { min-width: 460px; } /* keep columns readable; .tbl-wrap scrolls horizontally */ .usage-cards { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } diff --git a/gui/tests/codex-account-auto-switch.test.tsx b/gui/tests/codex-account-auto-switch.test.tsx index f58ab4d95..cb65b79a8 100644 --- a/gui/tests/codex-account-auto-switch.test.tsx +++ b/gui/tests/codex-account-auto-switch.test.tsx @@ -2,10 +2,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import { DEFAULT_AUTO_SWITCH_THRESHOLD, + autoSwitchThresholdReadDisposition, nextAutoSwitchThreshold, normalizeAutoSwitchThreshold, parseEnabledAutoSwitchThreshold, - planAutoSwitchToggleWrites, + planAutoSwitchToggleWrite, putAutoSwitchThreshold, type AutoSwitchFetch, } from "../src/codex-auto-switch"; @@ -29,17 +30,27 @@ afterEach(() => { }); }); -function renderSetting(threshold: number, draft = String(threshold), saving = false): string { +function renderSetting( + threshold: number | null, + draft = threshold === null ? "" : String(threshold), + saving = false, + loadError = false, + feedback: { tone: "ok" | "err"; message: string } | null = null, +): string { return renderToStaticMarkup( {}} onEditingChange={() => {}} onCommit={async () => true} + onCancel={() => {}} onToggle={async () => true} + onRetry={() => {}} /> , ); @@ -71,15 +82,22 @@ describe("Codex account auto-switch threshold", () => { expect(nextAutoSwitchThreshold(0, 0)).toBe(DEFAULT_AUTO_SWITCH_THRESHOLD); }); - test("toggle persists a changed valid draft before disabling", () => { - expect(planAutoSwitchToggleWrites(90, "95", 90)).toEqual([95, 0]); - expect(planAutoSwitchToggleWrites(90, "90", 90)).toEqual([0]); - expect(planAutoSwitchToggleWrites(0, "90", 95)).toEqual([95]); + test("toggle keeps a changed valid draft as the page restore value", () => { + expect(planAutoSwitchToggleWrite(90, "95", 90)).toEqual({ threshold: 0, lastEnabled: 95 }); + expect(planAutoSwitchToggleWrite(90, "90", 90)).toEqual({ threshold: 0, lastEnabled: 90 }); + expect(planAutoSwitchToggleWrite(0, "90", 95)).toEqual({ threshold: 95, lastEnabled: 95 }); }); test("toggle rejects an invalid enabled draft without writing", () => { - expect(planAutoSwitchToggleWrites(90, "50.5", 90)).toBeNull(); - expect(planAutoSwitchToggleWrites(90, "", 90)).toBeNull(); + expect(planAutoSwitchToggleWrite(90, "50.5", 90)).toBeNull(); + expect(planAutoSwitchToggleWrite(90, "", 90)).toBeNull(); + }); + + test("applies, defers, or ignores threshold reads by edit and revision state", () => { + expect(autoSwitchThresholdReadDisposition(false, false, 2, 2)).toBe("apply"); + expect(autoSwitchThresholdReadDisposition(true, false, 2, 2)).toBe("defer"); + expect(autoSwitchThresholdReadDisposition(false, true, 2, 2)).toBe("defer"); + expect(autoSwitchThresholdReadDisposition(false, false, 1, 2)).toBe("ignore"); }); test("renders the persisted custom threshold and inclusive condition", () => { @@ -87,6 +105,7 @@ describe("Codex account auto-switch threshold", () => { expect(html).toContain('value="95"'); expect(html).toContain('min="1"'); expect(html).toContain('max="100"'); + expect(html).toContain('aria-label="Switch threshold, percent"'); expect(html).toContain("95% usage or above"); expect(html).toContain('aria-pressed="true"'); }); @@ -106,12 +125,39 @@ describe("Codex account auto-switch threshold", () => { expect(html).not.toContain('type="number"'); }); - test("disables controls while a threshold write is pending", () => { + 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='); + + const failed = renderSetting(null, "", false, true); + expect(failed).toContain("Automatic switching setting could not be loaded."); + expect(failed).toContain("Retry"); + expect(failed).not.toContain('type="number"'); + }); + + test("keeps the focused input present but read-only while a write is pending", () => { const html = renderSetting(80, "80", true); expect(html).toContain('aria-busy="true"'); + expect(html).toContain('readOnly=""'); + expect(html).toContain("Saving…"); expect(html).toContain('disabled=""'); }); + test("renders contextual success and error feedback", () => { + const success = renderSetting(80, "80", false, false, { tone: "ok", message: "Saved here" }); + expect(success).toContain('role="status"'); + expect(success).toContain("Saved here"); + expect(success).toContain("codex-auto-switch-feedback"); + + const failure = renderSetting(80, "80", false, false, { tone: "err", message: "Fix this value" }); + expect(failure).toContain('role="alert"'); + expect(failure).toContain("Fix this value"); + expect(failure).toContain("is-error"); + }); + test("writes the requested threshold through the existing management endpoint", async () => { let request: { input: string; init: RequestInit } | null = null; const fetchImpl: AutoSwitchFetch = async (input, init) => { From b58c221a0fef704a39428c681ec2d6c70d7c8c46 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 24 Jul 2026 04:38:35 +0900 Subject: [PATCH 3/8] test(gui): cover Codex auto-switch interactions --- .../061_root_suite_investigation.md | 24 ++ gui/src/components/CodexAccountPool.tsx | 340 ++---------------- gui/src/components/CodexAutoSwitchSetting.tsx | 136 +++++++ gui/src/hooks/useCodexAutoSwitch.ts | 230 ++++++++++++ gui/tests/codex-account-auto-switch.test.tsx | 2 +- .../codex-auto-switch-controller.test.tsx | 262 ++++++++++++++ 6 files changed, 678 insertions(+), 316 deletions(-) create mode 100644 gui/src/components/CodexAutoSwitchSetting.tsx create mode 100644 gui/src/hooks/useCodexAutoSwitch.ts create mode 100644 gui/tests/codex-auto-switch-controller.test.tsx diff --git a/devlog/_plan/260724_bugfix_train/061_root_suite_investigation.md b/devlog/_plan/260724_bugfix_train/061_root_suite_investigation.md index 5f6df052b..550cd5d3e 100644 --- a/devlog/_plan/260724_bugfix_train/061_root_suite_investigation.md +++ b/devlog/_plan/260724_bugfix_train/061_root_suite_investigation.md @@ -110,3 +110,27 @@ Cycle 6 may update the contributor branch only when: - two consecutive post-change `bun run test` executions pass; - the focused release-helper run is supporting evidence, not sole closure; and - required Cross-platform CI is green on the exact pushed head. + +## 2026-07-24 — Cycle 6 investigation result + +- Live contributor head at takeover: `5f84301252865dc6f792d3272c2fe0db6d09eb0e`. +- `CYCLE6_BASE_SHA`: `c63589ccfe9e053d92acc029f55be0a809fb6fca` (`origin/dev` after the cycle-start fetch). +- Rebased pre-repair head: `8feb0980`; `git range-diff` mapped both contributor commits exactly (`=`) to `458b3a39` and `8feb0980`. +- Environment: macOS arm64 (`Darwin 25.6.0`), Bun `1.3.14`. +- Evidence directory: `devlog/_plan/260724_bugfix_train/evidence/cycle6-pr337-root/`. +- The contributor's retained public report contains only the aggregate `3780 passed, 4 skipped, 4 failed` and says the four failures were in this five-test file. It does not retain the four failing names, assertion text, stderr, or first-failure line. The four rows below map the report's four failure slots to the four success-path cases (the only four cases that require a zero release-helper exit); this mapping is an evidence limitation, not a recovered original log. + +| Reported test name | First failing log/line | Reproduction matrix | Classification | Causal evidence | Owner path | Disposition | +|---|---|---|---|---|---|---| +| `preflight runs typecheck, test suite, and privacy scan before version bump on main dry-runs` | Original log unavailable; current source line 216 | PR: standalone 3/3 + full 3/3 pass; base: standalone 1/1 + full 2/2 pass | Non-reproduced (unconfirmed flake) | No PR diff hunk reaches release tooling; every required PR/base order passed | `tests/release-helper.test.ts` | No fix; require final two root runs and exact-head CI | +| `preview branch still defaults to preview tag and dry-run dispatch` | Original log unavailable; current source line 250 | PR: standalone 3/3 + full 3/3 pass; base: standalone 1/1 + full 2/2 pass | Non-reproduced (unconfirmed flake) | Same behavior and counts on PR and exact base; no repeatable order/resource trigger | `tests/release-helper.test.ts` | No fix; require final two root runs and exact-head CI | +| `dispatch pins the audited release SHA via expected-sha` | Original log unavailable; current source line 263 | PR: standalone 3/3 + full 3/3 pass; base: standalone 1/1 + full 2/2 pass | Non-reproduced (unconfirmed flake) | Same behavior and counts on PR and exact base; `scripts/release.ts` is unchanged by the PR | `tests/release-helper.test.ts` | No fix; require final two root runs and exact-head CI | +| `aborts before dispatch when the remote branch moved during the CI wait` | Original log unavailable; current source line 275 | PR: standalone 3/3 + full 3/3 pass; base: standalone 1/1 + full 2/2 pass | Non-reproduced (unconfirmed flake) | Same behavior and counts on PR and exact base; no observed child-process/temp-path leak | `tests/release-helper.test.ts` | No fix; require final two root runs and exact-head CI | + +Supporting logs: + +- PR initial/repeats: `pr337-root-initial.log`, `pr337-root-repeat-1.log`, `pr337-root-repeat-2.log` — each `3861 pass / 0 fail`. +- PR standalone: `release-helper-1.log`, `release-helper-2.log`, `release-helper-3.log` — each `5 pass / 0 fail`. +- Exact-base comparison: `baseline-release-helper.log` (`5 pass / 0 fail`), `baseline-root-1.log`, `baseline-root-2.log` (each `3861 pass / 0 fail`). + +Hypothesis disposition: H1 (PR-caused) is rejected because the PR and exact base are identically green and the PR diff has no causal release-helper path. H2 (baseline deterministic) is rejected by both base full-suite passes. H3 (order/concurrency defect) remains unconfirmed because every real-order full suite passed and no shared path, process overlap, environment leak, or collision reproduced. Therefore no root-suite source fix is justified, and the mandatory out-of-map amendment gate is not activated. diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index aa25ee5e5..409800dd9 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -7,14 +7,8 @@ import type { AccountQuota } from "../codex-quota-utils"; import QuotaBars from "./QuotaBars"; import type { ReactNode } from "react"; import type { CodexAccountModeState } from "../codex-multi-state"; -import { - DEFAULT_AUTO_SWITCH_THRESHOLD, - autoSwitchThresholdReadDisposition, - normalizeAutoSwitchThreshold, - parseEnabledAutoSwitchThreshold, - planAutoSwitchToggleWrite, - putAutoSwitchThreshold, -} from "../codex-auto-switch"; +import CodexAutoSwitchSetting from "./CodexAutoSwitchSetting"; +import { useCodexAutoSwitch } from "../hooks/useCodexAutoSwitch"; export interface CodexAccountEntry { id: string; alias?: string; email: string; plan?: string; isMain: boolean; @@ -22,135 +16,6 @@ export interface CodexAccountEntry { needsReauth?: boolean; } -export function AutoSwitchSetting({ - threshold, - draft, - saving, - loadError, - feedback, - onDraftChange, - onEditingChange, - onCommit, - onCancel, - onToggle, - onRetry, -}: { - threshold: number | null; - draft: string; - saving: boolean; - loadError: boolean; - feedback: { tone: "ok" | "err"; message: string } | null; - onDraftChange: (value: string) => void; - onEditingChange: (editing: boolean) => void; - onCommit: () => Promise; - onCancel: () => void; - onToggle: () => Promise; - onRetry: () => void; -}) { - const t = useT(); - const ready = threshold !== null; - const enabled = ready && threshold > 0; - const feedbackMessage = saving ? t("common.saving") : feedback?.message ?? ""; - const feedbackTone = saving ? "pending" : feedback?.tone; - const describedBy = feedbackMessage - ? "codex-auto-switch-desc codex-auto-switch-feedback" - : "codex-auto-switch-desc"; - return ( -
-
- {t("codexAuth.autoSwitch")} -
- {!ready - ? loadError ? t("codexAuth.autoSwitchLoadFailed") : t("common.loading") - : enabled - ? t("codexAuth.autoSwitchDesc", { threshold }) - : t("codexAuth.autoSwitchOffDesc")} -
-
- {ready ? ( -
{ - if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; - onEditingChange(false); - if (enabled && !saving) void onCommit(); - }} - > - {enabled && ( - - )} - -
- ) : loadError ? ( -
- -
- ) : null} - {feedbackMessage && ( -
- {feedbackMessage} -
- )} -
- ); -} - /** * Global ChatGPT / Codex account pool (main + extras), extracted from the Codex * Auth page (WP060). `accountModeState` arrives as a prop (the parent owns the @@ -166,13 +31,14 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban onActiveNeedsReauthChange?: (needs: boolean) => void; }) { const t = useT(); + const autoSwitch = useCodexAutoSwitch(apiBase, { + updated: t("codexAuth.autoSwitchUpdated"), + updateFailed: t("codexAuth.autoSwitchUpdateFailed"), + invalid: t("codexAuth.autoSwitchThresholdInvalid"), + }); + const { beginServerRead, acceptServerRead, rejectServerRead } = autoSwitch; const [accounts, setAccounts] = useState([]); const [activeId, setActiveId] = useState(null); - const [autoThreshold, setAutoThreshold] = useState(null); - const [autoThresholdDraft, setAutoThresholdDraft] = useState(""); - const [autoThresholdLoadError, setAutoThresholdLoadError] = useState(false); - const [savingAutoThreshold, setSavingAutoThreshold] = useState(false); - const [autoThresholdFeedback, setAutoThresholdFeedback] = useState<{ tone: "ok" | "err"; message: string } | null>(null); const [confirm, setConfirm] = useState(null); const [showAdd, setShowAdd] = useState(false); const [reauthId, setReauthId] = useState(null); @@ -187,49 +53,11 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading"); const [switchingId, setSwitchingId] = useState(null); const loadGenerationRef = useRef(0); - const autoThresholdRef = useRef(null); - const lastEnabledAutoThresholdRef = useRef(DEFAULT_AUTO_SWITCH_THRESHOLD); - const autoThresholdEditingRef = useRef(false); - const autoThresholdSavingRef = useRef(false); - const autoThresholdRevisionRef = useRef(0); - const pendingAutoThresholdRef = useRef(null); - const autoThresholdFeedbackTimeoutRef = useRef(null); - - const applyAutoThreshold = useCallback((threshold: number) => { - autoThresholdRef.current = threshold; - setAutoThreshold(threshold); - if (threshold > 0) lastEnabledAutoThresholdRef.current = threshold; - setAutoThresholdDraft(String(threshold > 0 ? threshold : lastEnabledAutoThresholdRef.current)); - }, []); - - const queueOrApplyAutoThreshold = useCallback((threshold: number) => { - if (autoThresholdEditingRef.current || autoThresholdSavingRef.current) { - pendingAutoThresholdRef.current = threshold; - return; - } - pendingAutoThresholdRef.current = null; - applyAutoThreshold(threshold); - }, [applyAutoThreshold]); - - const reconcilePendingAutoThreshold = useCallback((): boolean => { - const pending = pendingAutoThresholdRef.current; - if (pending === null) return false; - pendingAutoThresholdRef.current = null; - applyAutoThreshold(pending); - return true; - }, [applyAutoThreshold]); - - useEffect(() => () => { - if (autoThresholdFeedbackTimeoutRef.current !== null) { - window.clearTimeout(autoThresholdFeedbackTimeoutRef.current); - } - }, []); const load = useCallback(async (refreshQuota = false) => { const generation = ++loadGenerationRef.current; - const autoThresholdRevision = autoThresholdRevisionRef.current; + const autoSwitchReadRevision = beginServerRead(); if (!refreshQuota) setLoadState("loading"); - if (autoThresholdRef.current === null) setAutoThresholdLoadError(false); const accountsTask = (async (): Promise => { try { const response = await fetch(`${apiBase}/api/codex-auth/accounts${refreshQuota ? "?refresh=1" : ""}`); @@ -248,24 +76,11 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const active = await response.json(); if (loadGenerationRef.current === generation) { setActiveId(active.activeCodexAccountId ?? null); - setAutoThresholdLoadError(false); - const disposition = autoSwitchThresholdReadDisposition( - autoThresholdEditingRef.current, - autoThresholdSavingRef.current, - autoThresholdRevision, - autoThresholdRevisionRef.current, - ); - if (disposition === "defer") { - pendingAutoThresholdRef.current = normalizeAutoSwitchThreshold(active.autoSwitchThreshold); - } else if (disposition === "apply") { - queueOrApplyAutoThreshold(normalizeAutoSwitchThreshold(active.autoSwitchThreshold)); - } + acceptServerRead(active.autoSwitchThreshold, autoSwitchReadRevision); } return true; } catch { - if (loadGenerationRef.current === generation && autoThresholdRef.current === null) { - setAutoThresholdLoadError(true); - } + if (loadGenerationRef.current === generation) rejectServerRead(); return false; } })(); @@ -273,7 +88,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban if (loadGenerationRef.current !== generation) return false; setLoadState(accountsOk && activeOk ? "ready" : "error"); return accountsOk && activeOk; - }, [apiBase, queueOrApplyAutoThreshold]); + }, [acceptServerRead, apiBase, beginServerRead, rejectServerRead]); useEffect(() => { const timeout = window.setTimeout(() => { void load(); @@ -385,107 +200,6 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban } }; - const clearAutoThresholdFeedback = () => { - if (autoThresholdFeedbackTimeoutRef.current !== null) { - window.clearTimeout(autoThresholdFeedbackTimeoutRef.current); - autoThresholdFeedbackTimeoutRef.current = null; - } - setAutoThresholdFeedback(null); - }; - - const showAutoThresholdFeedback = (message: string, error: boolean) => { - if (autoThresholdFeedbackTimeoutRef.current !== null) { - window.clearTimeout(autoThresholdFeedbackTimeoutRef.current); - } - setAutoThresholdFeedback({ tone: error ? "err" : "ok", message }); - autoThresholdFeedbackTimeoutRef.current = window.setTimeout(() => { - setAutoThresholdFeedback(null); - autoThresholdFeedbackTimeoutRef.current = null; - }, 5000); - }; - - const saveAutoThreshold = async ( - next: number, - previous: number, - showSuccess = true, - ): Promise => { - if (autoThresholdSavingRef.current) return false; - autoThresholdSavingRef.current = true; - autoThresholdEditingRef.current = false; - clearAutoThresholdFeedback(); - setSavingAutoThreshold(true); - // Ignore threshold reads that began before or during this write while still - // allowing the same refresh pass to update account and quota data. - autoThresholdRevisionRef.current += 1; - const ok = await putAutoSwitchThreshold(apiBase, next); - autoThresholdRevisionRef.current += 1; - autoThresholdSavingRef.current = false; - if (ok) { - pendingAutoThresholdRef.current = null; - applyAutoThreshold(next); - if (showSuccess) showAutoThresholdFeedback(t("codexAuth.autoSwitchUpdated"), false); - } else { - if (!reconcilePendingAutoThreshold()) applyAutoThreshold(previous); - showAutoThresholdFeedback(t("codexAuth.autoSwitchUpdateFailed"), true); - } - setSavingAutoThreshold(false); - return ok; - }; - - const rejectAutoThresholdDraft = () => { - autoThresholdEditingRef.current = false; - const current = autoThresholdRef.current; - if (!reconcilePendingAutoThreshold() && current !== null) { - setAutoThresholdDraft(String(current > 0 ? current : lastEnabledAutoThresholdRef.current)); - } - showAutoThresholdFeedback(t("codexAuth.autoSwitchThresholdInvalid"), true); - }; - - const cancelAutoThresholdDraft = () => { - autoThresholdEditingRef.current = false; - clearAutoThresholdFeedback(); - const current = autoThresholdRef.current; - if (!reconcilePendingAutoThreshold() && current !== null) { - setAutoThresholdDraft(String(current > 0 ? current : lastEnabledAutoThresholdRef.current)); - } - }; - - const commitAutoThreshold = async (): Promise => { - const current = autoThresholdRef.current; - if (current === null || autoThresholdSavingRef.current) return false; - autoThresholdEditingRef.current = false; - const next = parseEnabledAutoSwitchThreshold(autoThresholdDraft); - if (next === null) { - rejectAutoThresholdDraft(); - return false; - } - if (next === current) { - if (!reconcilePendingAutoThreshold()) setAutoThresholdDraft(String(next)); - return true; - } - return saveAutoThreshold(next, current); - }; - - const toggleAuto = async (): Promise => { - const current = autoThresholdRef.current; - if (current === null || autoThresholdSavingRef.current) return false; - autoThresholdEditingRef.current = false; - const plan = planAutoSwitchToggleWrite( - current, - autoThresholdDraft, - lastEnabledAutoThresholdRef.current, - ); - if (plan === null) { - rejectAutoThresholdDraft(); - return false; - } - const ok = await saveAutoThreshold(plan.threshold, current); - if (!ok) return false; - lastEnabledAutoThresholdRef.current = plan.lastEnabled; - if (plan.threshold === 0) setAutoThresholdDraft(String(plan.lastEnabled)); - return ok; - }; - const refreshQuotas = async () => { setRefreshingQuota(true); try { @@ -610,7 +324,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
{main?.email ?? "Codex App login"}{main?.plan ? ` · ${main.plan}` : ""}
{main?.needsReauth ?
{t("codexAuth.mainTokenExpired")}
- : main?.quota && } + : main?.quota && }
@@ -672,25 +386,21 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
{a.email}{a.plan ? ` · ${a.plan}` : ""} · {t("prov.accountId")}: {a.id}
{a.needsReauth ?
{t("codexAuth.tokenExpired")}
- : } + : }
))} - { - autoThresholdEditingRef.current = true; - clearAutoThresholdFeedback(); - setAutoThresholdDraft(value); - }} - onEditingChange={(editing) => { autoThresholdEditingRef.current = editing; }} - onCommit={commitAutoThreshold} - onCancel={cancelAutoThresholdDraft} - onToggle={toggleAuto} + { void load(); }} /> diff --git a/gui/src/components/CodexAutoSwitchSetting.tsx b/gui/src/components/CodexAutoSwitchSetting.tsx new file mode 100644 index 000000000..1eb27da40 --- /dev/null +++ b/gui/src/components/CodexAutoSwitchSetting.tsx @@ -0,0 +1,136 @@ +import { useT } from "../i18n"; + +export type AutoSwitchFeedback = { tone: "ok" | "err"; message: string } | null; + +export interface CodexAutoSwitchSettingProps { + threshold: number | null; + draft: string; + saving: boolean; + loadError: boolean; + feedback: AutoSwitchFeedback; + onDraftChange(value: string): void; + onEditingChange(editing: boolean): void; + onCommit(): Promise; + onCancel(): void; + onToggle(): Promise; + onRetry(): void; +} + +export function CodexAutoSwitchSetting({ + threshold, + draft, + saving, + loadError, + feedback, + onDraftChange, + onEditingChange, + onCommit, + onCancel, + onToggle, + onRetry, +}: CodexAutoSwitchSettingProps) { + const t = useT(); + const ready = threshold !== null; + const enabled = ready && threshold > 0; + const feedbackMessage = saving ? t("common.saving") : feedback?.message ?? ""; + const feedbackTone = saving ? "pending" : feedback?.tone; + const describedBy = feedbackMessage + ? "codex-auto-switch-desc codex-auto-switch-feedback" + : "codex-auto-switch-desc"; + return ( +
+
+ {t("codexAuth.autoSwitch")} +
+ {!ready + ? loadError ? t("codexAuth.autoSwitchLoadFailed") : t("common.loading") + : enabled + ? t("codexAuth.autoSwitchDesc", { threshold }) + : t("codexAuth.autoSwitchOffDesc")} +
+
+ {ready ? ( +
{ + if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; + onEditingChange(false); + if (enabled && !saving) void onCommit(); + }} + > + {enabled && ( + + )} + +
+ ) : loadError ? ( +
+ +
+ ) : null} + {feedbackMessage && ( +
+ {feedbackMessage} +
+ )} +
+ ); +} + +export default CodexAutoSwitchSetting; diff --git a/gui/src/hooks/useCodexAutoSwitch.ts b/gui/src/hooks/useCodexAutoSwitch.ts new file mode 100644 index 000000000..55d724b85 --- /dev/null +++ b/gui/src/hooks/useCodexAutoSwitch.ts @@ -0,0 +1,230 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + DEFAULT_AUTO_SWITCH_THRESHOLD, + autoSwitchThresholdReadDisposition, + normalizeAutoSwitchThreshold, + parseEnabledAutoSwitchThreshold, + planAutoSwitchToggleWrite, + putAutoSwitchThreshold, +} from "../codex-auto-switch"; +import type { AutoSwitchFeedback } from "../components/CodexAutoSwitchSetting"; + +export interface CodexAutoSwitchController { + threshold: number | null; + draft: string; + saving: boolean; + loadError: boolean; + feedback: AutoSwitchFeedback; + beginServerRead(): number; + acceptServerRead(value: unknown, startedRevision: number): void; + rejectServerRead(): void; + setDraft(value: string): void; + setEditing(editing: boolean): void; + commit(): Promise; + cancel(): void; + toggle(): Promise; +} + +export function useCodexAutoSwitch( + apiBase: string, + messages: { + updated: string; + updateFailed: string; + invalid: string; + }, +): CodexAutoSwitchController { + const [threshold, setThreshold] = useState(null); + const [draft, setDraftState] = useState(""); + const [loadError, setLoadError] = useState(false); + const [saving, setSaving] = useState(false); + const [feedback, setFeedback] = useState(null); + const thresholdRef = useRef(null); + const lastEnabledRef = useRef(DEFAULT_AUTO_SWITCH_THRESHOLD); + const editingRef = useRef(false); + const savingRef = useRef(false); + const cancelledDraftRef = useRef(false); + const revisionRef = useRef(0); + const deferredServerValueRef = useRef(null); + const feedbackTimeoutRef = useRef(null); + + const apply = useCallback((next: number) => { + thresholdRef.current = next; + setThreshold(next); + if (next > 0) lastEnabledRef.current = next; + setDraftState(String(next > 0 ? next : lastEnabledRef.current)); + }, []); + + const queueOrApply = useCallback((next: number) => { + if (editingRef.current || savingRef.current) { + deferredServerValueRef.current = next; + return; + } + deferredServerValueRef.current = null; + apply(next); + }, [apply]); + + const reconcileDeferred = useCallback((): boolean => { + const deferred = deferredServerValueRef.current; + if (deferred === null) return false; + deferredServerValueRef.current = null; + apply(deferred); + return true; + }, [apply]); + + const clearFeedback = useCallback(() => { + if (feedbackTimeoutRef.current !== null) { + window.clearTimeout(feedbackTimeoutRef.current); + feedbackTimeoutRef.current = null; + } + setFeedback(null); + }, []); + + const showFeedback = useCallback((message: string, error: boolean) => { + if (feedbackTimeoutRef.current !== null) { + window.clearTimeout(feedbackTimeoutRef.current); + } + setFeedback({ tone: error ? "err" : "ok", message }); + feedbackTimeoutRef.current = window.setTimeout(() => { + setFeedback(null); + feedbackTimeoutRef.current = null; + }, 5000); + }, []); + + useEffect(() => () => { + if (feedbackTimeoutRef.current !== null) { + window.clearTimeout(feedbackTimeoutRef.current); + } + }, []); + + const beginServerRead = useCallback((): number => { + if (thresholdRef.current === null) setLoadError(false); + return revisionRef.current; + }, []); + + const acceptServerRead = useCallback((value: unknown, startedRevision: number) => { + setLoadError(false); + const disposition = autoSwitchThresholdReadDisposition( + editingRef.current, + savingRef.current, + startedRevision, + revisionRef.current, + ); + if (disposition === "defer") { + deferredServerValueRef.current = normalizeAutoSwitchThreshold(value); + } else if (disposition === "apply") { + queueOrApply(normalizeAutoSwitchThreshold(value)); + } + }, [queueOrApply]); + + const rejectServerRead = useCallback(() => { + if (thresholdRef.current === null) setLoadError(true); + }, []); + + const save = useCallback(async ( + next: number, + previous: number, + showSuccess = true, + ): Promise => { + if (savingRef.current) return false; + savingRef.current = true; + editingRef.current = false; + clearFeedback(); + setSaving(true); + revisionRef.current += 1; + const ok = await putAutoSwitchThreshold(apiBase, next); + revisionRef.current += 1; + savingRef.current = false; + if (ok) { + deferredServerValueRef.current = null; + apply(next); + if (showSuccess) showFeedback(messages.updated, false); + } else { + if (!reconcileDeferred()) apply(previous); + showFeedback(messages.updateFailed, true); + } + setSaving(false); + return ok; + }, [apiBase, apply, clearFeedback, messages.updateFailed, messages.updated, reconcileDeferred, showFeedback]); + + const rejectDraft = useCallback(() => { + editingRef.current = false; + const current = thresholdRef.current; + if (!reconcileDeferred() && current !== null) { + setDraftState(String(current > 0 ? current : lastEnabledRef.current)); + } + showFeedback(messages.invalid, true); + }, [messages.invalid, reconcileDeferred, showFeedback]); + + const cancel = useCallback(() => { + editingRef.current = false; + cancelledDraftRef.current = true; + clearFeedback(); + const current = thresholdRef.current; + if (!reconcileDeferred() && current !== null) { + setDraftState(String(current > 0 ? current : lastEnabledRef.current)); + } + }, [clearFeedback, reconcileDeferred]); + + const commit = useCallback(async (): Promise => { + if (cancelledDraftRef.current) { + cancelledDraftRef.current = false; + return true; + } + const current = thresholdRef.current; + if (current === null || savingRef.current) return false; + editingRef.current = false; + const next = parseEnabledAutoSwitchThreshold(draft); + if (next === null) { + rejectDraft(); + return false; + } + if (next === current) { + if (!reconcileDeferred()) setDraftState(String(next)); + return true; + } + return save(next, current); + }, [draft, reconcileDeferred, rejectDraft, save]); + + const toggle = useCallback(async (): Promise => { + const current = thresholdRef.current; + if (current === null || savingRef.current) return false; + editingRef.current = false; + const plan = planAutoSwitchToggleWrite(current, draft, lastEnabledRef.current); + if (plan === null) { + rejectDraft(); + return false; + } + const ok = await save(plan.threshold, current); + if (!ok) return false; + lastEnabledRef.current = plan.lastEnabled; + if (plan.threshold === 0) setDraftState(String(plan.lastEnabled)); + return ok; + }, [draft, rejectDraft, save]); + + const setDraft = useCallback((value: string) => { + editingRef.current = true; + cancelledDraftRef.current = false; + clearFeedback(); + setDraftState(value); + }, [clearFeedback]); + + const setEditing = useCallback((editing: boolean) => { + editingRef.current = editing; + }, []); + + return { + threshold, + draft, + saving, + loadError, + feedback, + beginServerRead, + acceptServerRead, + rejectServerRead, + setDraft, + setEditing, + commit, + cancel, + toggle, + }; +} diff --git a/gui/tests/codex-account-auto-switch.test.tsx b/gui/tests/codex-account-auto-switch.test.tsx index cb65b79a8..7d5c21a1c 100644 --- a/gui/tests/codex-account-auto-switch.test.tsx +++ b/gui/tests/codex-account-auto-switch.test.tsx @@ -10,7 +10,7 @@ import { putAutoSwitchThreshold, type AutoSwitchFetch, } from "../src/codex-auto-switch"; -import { AutoSwitchSetting } from "../src/components/CodexAccountPool"; +import { CodexAutoSwitchSetting as AutoSwitchSetting } from "../src/components/CodexAutoSwitchSetting"; import { LanguageProvider } from "../src/i18n/provider"; let previousLanguage: unknown; diff --git a/gui/tests/codex-auto-switch-controller.test.tsx b/gui/tests/codex-auto-switch-controller.test.tsx new file mode 100644 index 000000000..f0d5f79b1 --- /dev/null +++ b/gui/tests/codex-auto-switch-controller.test.tsx @@ -0,0 +1,262 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import CodexAccountPool from "../src/components/CodexAccountPool"; +import { LanguageProvider } from "../src/i18n/provider"; + +const globals = [ + "document", + "window", + "navigator", + "localStorage", + "fetch", + "IS_REACT_ACT_ENVIRONMENT", +] as const; + +interface Deferred { + promise: Promise; + resolve(value: T): void; + reject(reason?: unknown): void; +} + +interface Harness { + container: HTMLDivElement; + outsideButton: HTMLButtonElement; + root: Root; + input: HTMLInputElement; + writes: number[]; + enqueueActive(response: Promise | Response): void; + enqueuePut(response: Promise | Response): void; + refresh(): void; +} + +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let mountedRoot: Root | null; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function flush(): Promise { + await Promise.resolve(); + await new Promise((resolve) => testWindow.setTimeout(resolve, 0)); + await Promise.resolve(); +} + +function setInputValue(input: HTMLInputElement, value: string): void { + Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")! + .set!.call(input, value); + input.dispatchEvent(new testWindow.Event("input", { bubbles: true })); +} + +function keyDown(input: HTMLInputElement, key: string): void { + input.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key, bubbles: true })); +} + +beforeEach(() => { + previousGlobals = Object.fromEntries( + globals.map((key) => [key, Reflect.get(globalThis, key)]), + ) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + mountedRoot = null; +}); + +afterEach(async () => { + if (mountedRoot) { + await act(async () => { + mountedRoot?.unmount(); + }); + } + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function mountHarness(): Promise { + const activeResponses: Array | Response> = [ + Response.json({ activeCodexAccountId: null, autoSwitchThreshold: 80 }), + ]; + 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) throw new Error("unexpected active-account read"); + return await response; + } + 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"); + const outsideButton = document.createElement("button"); + outsideButton.textContent = "Outside"; + document.body.append(container, outsideButton); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(container); + mountedRoot = root; + await act(async () => { + root.render( + + + , + ); + await flush(); + }); + await act(flush); + + const input = container.querySelector('input[aria-label="Switch threshold, percent"]'); + expect(input).not.toBeNull(); + expect(input?.value).toBe("80"); + expect(input?.readOnly).toBe(false); + expect(refreshCallback).not.toBeNull(); + + return { + container, + outsideButton, + root, + input: input!, + writes, + enqueueActive(response) { + activeResponses.push(response); + }, + enqueuePut(response) { + putResponses.push(response); + }, + refresh() { + if (!refreshCallback) throw new Error("refresh interval was not registered"); + refreshCallback(); + }, + }; +} + +describe("Codex auto-switch controller interactions", () => { + test("Enter then blur issues exactly one write", async () => { + const harness = await mountHarness(); + const write = deferred(); + harness.enqueuePut(write.promise); + + await act(async () => { + harness.input.focus(); + setInputValue(harness.input, "95"); + keyDown(harness.input, "Enter"); + harness.outsideButton.focus(); + await Promise.resolve(); + }); + + expect(harness.writes).toEqual([95]); + await act(async () => { + write.resolve(new Response(null, { status: 204 })); + await flush(); + }); + expect(harness.input.value).toBe("95"); + expect(harness.container.querySelector('[role="status"]')?.textContent).toContain("updated"); + expect(harness.writes).toEqual([95]); + }); + + test("stale 30-second refresh cannot overwrite a successful edit", async () => { + const harness = await mountHarness(); + const staleRead = deferred(); + const write = deferred(); + harness.enqueueActive(staleRead.promise); + harness.enqueuePut(write.promise); + + await act(async () => { + harness.refresh(); + await Promise.resolve(); + harness.input.focus(); + setInputValue(harness.input, "95"); + keyDown(harness.input, "Enter"); + await Promise.resolve(); + }); + expect(harness.writes).toEqual([95]); + + await act(async () => { + write.resolve(new Response(null, { status: 204 })); + await flush(); + staleRead.resolve(Response.json({ activeCodexAccountId: null, autoSwitchThreshold: 80 })); + await flush(); + }); + + expect(harness.input.value).toBe("95"); + expect(harness.container.textContent).toContain("95% usage or above"); + expect(harness.writes).toEqual([95]); + }); + + test("failed write restores the last confirmed value", async () => { + const harness = await mountHarness(); + harness.enqueuePut(new Response(null, { status: 500 })); + + await act(async () => { + harness.input.focus(); + setInputValue(harness.input, "95"); + keyDown(harness.input, "Enter"); + await flush(); + }); + + expect(harness.input.value).toBe("80"); + expect(harness.container.textContent).toContain("80% usage or above"); + expect(harness.container.querySelector('[role="alert"]')?.textContent).toContain("could not be confirmed"); + expect(harness.writes).toEqual([95]); + }); + + test("Escape cancels without writing", async () => { + const harness = await mountHarness(); + + await act(async () => { + harness.input.focus(); + setInputValue(harness.input, "95"); + keyDown(harness.input, "Escape"); + harness.outsideButton.focus(); + await flush(); + }); + + expect(harness.input.value).toBe("80"); + expect(harness.writes).toEqual([]); + expect(harness.container.querySelector('[role="status"]')).toBeNull(); + }); +}); From b2660d37ed213ccd8036477cc28d8c6f785c4dbf Mon Sep 17 00:00:00 2001 From: csa906 Date: Fri, 24 Jul 2026 18:09:06 +0900 Subject: [PATCH 4/8] fix(gui): address auto-switch review feedback --- .../061_root_suite_investigation.md | 22 ++-- gui/src/codex-auto-switch.ts | 12 ++- gui/src/components/CodexAutoSwitchSetting.tsx | 20 +++- gui/src/hooks/useCodexAutoSwitch.ts | 6 +- gui/tests/codex-account-auto-switch.test.tsx | 42 +++++++- .../codex-auto-switch-controller.test.tsx | 100 ++++++++++++++++++ 6 files changed, 180 insertions(+), 22 deletions(-) diff --git a/devlog/_plan/260724_bugfix_train/061_root_suite_investigation.md b/devlog/_plan/260724_bugfix_train/061_root_suite_investigation.md index 550cd5d3e..b450559a6 100644 --- a/devlog/_plan/260724_bugfix_train/061_root_suite_investigation.md +++ b/devlog/_plan/260724_bugfix_train/061_root_suite_investigation.md @@ -118,19 +118,23 @@ Cycle 6 may update the contributor branch only when: - Rebased pre-repair head: `8feb0980`; `git range-diff` mapped both contributor commits exactly (`=`) to `458b3a39` and `8feb0980`. - Environment: macOS arm64 (`Darwin 25.6.0`), Bun `1.3.14`. - Evidence directory: `devlog/_plan/260724_bugfix_train/evidence/cycle6-pr337-root/`. -- The contributor's retained public report contains only the aggregate `3780 passed, 4 skipped, 4 failed` and says the four failures were in this five-test file. It does not retain the four failing names, assertion text, stderr, or first-failure line. The four rows below map the report's four failure slots to the four success-path cases (the only four cases that require a zero release-helper exit); this mapping is an evidence limitation, not a recovered original log. +- The contributor's retained public report contains only the aggregate `3780 passed, 4 skipped, 4 failed` and says the four failures were in this five-test file. It does not retain the four failing names, assertion text, stderr, or first-failure line. The original four failure identities therefore remain unresolved. -| Reported test name | First failing log/line | Reproduction matrix | Classification | Causal evidence | Owner path | Disposition | -|---|---|---|---|---|---|---| -| `preflight runs typecheck, test suite, and privacy scan before version bump on main dry-runs` | Original log unavailable; current source line 216 | PR: standalone 3/3 + full 3/3 pass; base: standalone 1/1 + full 2/2 pass | Non-reproduced (unconfirmed flake) | No PR diff hunk reaches release tooling; every required PR/base order passed | `tests/release-helper.test.ts` | No fix; require final two root runs and exact-head CI | -| `preview branch still defaults to preview tag and dry-run dispatch` | Original log unavailable; current source line 250 | PR: standalone 3/3 + full 3/3 pass; base: standalone 1/1 + full 2/2 pass | Non-reproduced (unconfirmed flake) | Same behavior and counts on PR and exact base; no repeatable order/resource trigger | `tests/release-helper.test.ts` | No fix; require final two root runs and exact-head CI | -| `dispatch pins the audited release SHA via expected-sha` | Original log unavailable; current source line 263 | PR: standalone 3/3 + full 3/3 pass; base: standalone 1/1 + full 2/2 pass | Non-reproduced (unconfirmed flake) | Same behavior and counts on PR and exact base; `scripts/release.ts` is unchanged by the PR | `tests/release-helper.test.ts` | No fix; require final two root runs and exact-head CI | -| `aborts before dispatch when the remote branch moved during the CI wait` | Original log unavailable; current source line 275 | PR: standalone 3/3 + full 3/3 pass; base: standalone 1/1 + full 2/2 pass | Non-reproduced (unconfirmed flake) | Same behavior and counts on PR and exact base; no observed child-process/temp-path leak | `tests/release-helper.test.ts` | No fix; require final two root runs and exact-head CI | +### Inferred current candidates — not recovered failures -Supporting logs: +The four success-path cases below are investigation candidates only because they are the four cases in the current five-test file that require a zero release-helper exit. They are not recovered names for the originally reported failures and cannot receive per-failure classifications without the missing original reporter evidence. + +| Inferred current candidate | Original failing log/line | Current rerun evidence | What the rerun establishes | Owner path | +|---|---|---|---|---| +| `preflight runs typecheck, test suite, and privacy scan before version bump on main dry-runs` | Unavailable; current source line 216 is not an original failure line | PR: standalone 3/3 + full 3/3 pass; base: standalone 1/1 + full 2/2 pass | This current case passed in every recorded PR/base order; it does not identify an original failure | `tests/release-helper.test.ts` | +| `preview branch still defaults to preview tag and dry-run dispatch` | Unavailable; current source line 250 is not an original failure line | PR: standalone 3/3 + full 3/3 pass; base: standalone 1/1 + full 2/2 pass | This current case passed in every recorded PR/base order; it does not identify an original failure | `tests/release-helper.test.ts` | +| `dispatch pins the audited release SHA via expected-sha` | Unavailable; current source line 263 is not an original failure line | PR: standalone 3/3 + full 3/3 pass; base: standalone 1/1 + full 2/2 pass | This current case passed in every recorded PR/base order; it does not identify an original failure | `tests/release-helper.test.ts` | +| `aborts before dispatch when the remote branch moved during the CI wait` | Unavailable; current source line 275 is not an original failure line | PR: standalone 3/3 + full 3/3 pass; base: standalone 1/1 + full 2/2 pass | This current case passed in every recorded PR/base order; it does not identify an original failure | `tests/release-helper.test.ts` | + +### Current green rerun evidence - PR initial/repeats: `pr337-root-initial.log`, `pr337-root-repeat-1.log`, `pr337-root-repeat-2.log` — each `3861 pass / 0 fail`. - PR standalone: `release-helper-1.log`, `release-helper-2.log`, `release-helper-3.log` — each `5 pass / 0 fail`. - Exact-base comparison: `baseline-release-helper.log` (`5 pass / 0 fail`), `baseline-root-1.log`, `baseline-root-2.log` (each `3861 pass / 0 fail`). -Hypothesis disposition: H1 (PR-caused) is rejected because the PR and exact base are identically green and the PR diff has no causal release-helper path. H2 (baseline deterministic) is rejected by both base full-suite passes. H3 (order/concurrency defect) remains unconfirmed because every real-order full suite passed and no shared path, process overlap, environment leak, or collision reproduced. Therefore no root-suite source fix is justified, and the mandatory out-of-map amendment gate is not activated. +The current reruns do not provide evidence for a release-helper source change in this scoped PR: the investigated current cases and full suites were green at the recorded PR and base heads, and no causal PR diff path was found. They do not reconstruct or resolve the original four failures. Their names, assertions, stderr, and first failing lines remain unresolved unless the original CI or test-reporter metadata is recovered. Accordingly, the historical suite-level disposition remains unresolved, and the final exact-head root gates are still required without changing release code or tests. diff --git a/gui/src/codex-auto-switch.ts b/gui/src/codex-auto-switch.ts index 1907c44f8..288d2f5cf 100644 --- a/gui/src/codex-auto-switch.ts +++ b/gui/src/codex-auto-switch.ts @@ -1,5 +1,7 @@ export const DEFAULT_AUTO_SWITCH_THRESHOLD = 80; +const AUTO_SWITCH_PUT_TIMEOUT_MS = 10_000; + export type AutoSwitchFetch = (input: string, init: RequestInit) => Promise; export function normalizeAutoSwitchThreshold(value: unknown): number { @@ -51,20 +53,21 @@ export function planAutoSwitchToggleWrite( current: number, draft: string, lastEnabled: number, -): AutoSwitchTogglePlan | null { +): AutoSwitchTogglePlan { if (current <= 0) { const threshold = nextAutoSwitchThreshold(current, lastEnabled); return { threshold, lastEnabled: threshold }; } - const parsedDraft = parseEnabledAutoSwitchThreshold(draft); - if (parsedDraft === null) return null; - return { threshold: 0, lastEnabled: parsedDraft }; + const restoreThreshold = parseEnabledAutoSwitchThreshold(draft) + ?? nextAutoSwitchThreshold(0, lastEnabled); + return { threshold: 0, lastEnabled: restoreThreshold }; } export async function putAutoSwitchThreshold( apiBase: string, threshold: number, fetchImpl: AutoSwitchFetch = (input, init) => fetch(input, init), + timeoutMs = AUTO_SWITCH_PUT_TIMEOUT_MS, ): Promise { if (!Number.isInteger(threshold) || threshold < 0 || threshold > 100) return false; try { @@ -72,6 +75,7 @@ export async function putAutoSwitchThreshold( method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ threshold }), + signal: AbortSignal.timeout(timeoutMs), }); return response.ok; } catch { diff --git a/gui/src/components/CodexAutoSwitchSetting.tsx b/gui/src/components/CodexAutoSwitchSetting.tsx index 1eb27da40..62fa886bf 100644 --- a/gui/src/components/CodexAutoSwitchSetting.tsx +++ b/gui/src/components/CodexAutoSwitchSetting.tsx @@ -1,3 +1,4 @@ +import { useRef } from "react"; import { useT } from "../i18n"; export type AutoSwitchFeedback = { tone: "ok" | "err"; message: string } | null; @@ -30,6 +31,7 @@ export function CodexAutoSwitchSetting({ onRetry, }: CodexAutoSwitchSettingProps) { const t = useT(); + const togglePointerIntentRef = useRef(false); const ready = threshold !== null; const enabled = ready && threshold > 0; const feedbackMessage = saving ? t("common.saving") : feedback?.message ?? ""; @@ -63,6 +65,10 @@ export function CodexAutoSwitchSetting({ onBlur={(event) => { if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; onEditingChange(false); + if (togglePointerIntentRef.current) { + togglePointerIntentRef.current = false; + return; + } if (enabled && !saving) void onCommit(); }} > @@ -102,7 +108,19 @@ export function CodexAutoSwitchSetting({