Skip to content
Closed
75 changes: 46 additions & 29 deletions gui/src/components/AccountPoolStrategyControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
ACCOUNT_POOL_STRATEGIES,
type AccountPoolStrategy,
} from "../account-pool-strategy";
import { NumberStepper } from "./NumberStepper";

const STRATEGY_LABEL_KEYS = {
quota: "accountPool.strategyQuota",
Expand All @@ -16,11 +17,19 @@ export interface AccountPoolStrategyControlsProps {
disabled?: boolean;
strategySelectId?: string;
stickyInputId?: string;
/** When true, omit the outer strategy label (parent card already titled). */
hideStrategyLabel?: boolean;
onStrategyChange(strategy: AccountPoolStrategy): void;
onStickyDraftChange(value: string): void;
onStickyCommit(): void;
}

function clampStickyDraft(raw: string, delta: number): string {
const parsed = Number.parseInt(raw, 10);
const base = Number.isFinite(parsed) ? parsed : 1;
return String(Math.min(100, Math.max(1, base + delta)));
}

/**
* Shared strategy select + round-robin sticky limit for Codex / Anthropic account pools.
*/
Expand All @@ -30,15 +39,16 @@ export default function AccountPoolStrategyControls({
disabled = false,
strategySelectId = "account-pool-strategy",
stickyInputId = "account-pool-sticky-limit",
hideStrategyLabel = false,
onStrategyChange,
onStickyDraftChange,
onStickyCommit,
}: AccountPoolStrategyControlsProps) {
const t = useT();
return (
<div style={{ marginTop: 12 }}>
<label className="field" style={{ display: "block" }} htmlFor={strategySelectId}>
<span className="field-label">{t("accountPool.strategy")}</span>
<div className="account-pool-strategy-controls">
<label className="field" htmlFor={strategySelectId}>
{!hideStrategyLabel && <span className="field-label">{t("accountPool.strategy")}</span>}
<select
id={strategySelectId}
className="input"
Expand All @@ -56,34 +66,41 @@ export default function AccountPoolStrategyControls({
))}
</select>
</label>
<div className="card-sub" style={{ marginTop: 4 }}>
{t("accountPool.strategyHint")}
</div>
<div className="card-sub">{t("accountPool.strategyHint")}</div>
{strategy === "round-robin" && (
<label className="field" style={{ display: "block", marginTop: 12 }} htmlFor={stickyInputId}>
<label className="field" htmlFor={stickyInputId}>
<span className="field-label">{t("accountPool.stickyLimit")}</span>
<input
id={stickyInputId}
className="input mono"
type="number"
min={1}
max={100}
step={1}
inputMode="numeric"
value={stickyDraft}
disabled={disabled}
aria-label={t("accountPool.stickyLimitAria")}
onChange={(event) => onStickyDraftChange(event.target.value)}
onBlur={() => onStickyCommit()}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing || disabled) return;
if (event.key === "Enter") {
event.preventDefault();
onStickyCommit();
}
}}
/>
<div className="card-sub" style={{ marginTop: 4 }}>{t("accountPool.stickyLimitHelp")}</div>
<span className="codex-auto-switch-input-wrap">
<input
id={stickyInputId}
className="input mono codex-auto-switch-input"
type="number"
min={1}
max={100}
step={1}
inputMode="numeric"
value={stickyDraft}
disabled={disabled}
aria-label={t("accountPool.stickyLimitAria")}
onChange={(event) => onStickyDraftChange(event.target.value)}
onBlur={() => onStickyCommit()}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing || disabled) return;
if (event.key === "Enter") {
event.preventDefault();
onStickyCommit();
}
}}
/>
<NumberStepper
disabled={disabled}
incrementLabel={t("accountPool.stickyLimitInc")}
decrementLabel={t("accountPool.stickyLimitDec")}
onIncrement={() => onStickyDraftChange(clampStickyDraft(stickyDraft, 1))}
onDecrement={() => onStickyDraftChange(clampStickyDraft(stickyDraft, -1))}
/>
</span>
<div className="card-sub">{t("accountPool.stickyLimitHelp")}</div>
</label>
)}
</div>
Expand Down
7 changes: 5 additions & 2 deletions gui/src/components/CodexAccountPool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
const [toast, setToast] = useState("");
const [toastError, setToastError] = useState(false);
const [refreshingQuota, setRefreshingQuota] = useState(false);
const [refreshFeedback, setRefreshFeedback] = useState<string | null>(null);
const [resetPopup, setResetPopup] = useState<CodexAccountEntry | null>(null);
const [resetConfirm, setResetConfirm] = useState(false);
const [redeeming, setRedeeming] = useState(false);
Expand Down Expand Up @@ -169,10 +170,11 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban

const refreshQuotas = async () => {
setRefreshingQuota(true);
setRefreshFeedback(null);
try {
const ok = await load(true);
setToast(t(ok ? "codexAuth.quotaRefreshed" : "codexAuth.quotaRefreshFailed"));
setTimeout(() => setToast(""), 5000);
setRefreshFeedback(t(ok ? "codexAuth.quotaRefreshed" : "codexAuth.quotaRefreshFailed"));
setTimeout(() => setRefreshFeedback(null), 4000);
} finally {
setRefreshingQuota(false);
}
Expand Down Expand Up @@ -225,6 +227,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
t={t}
embedded={embedded}
refreshingQuota={refreshingQuota}
refreshFeedback={refreshFeedback}
onRefresh={() => { void refreshQuotas(); }}
/>

Expand Down
20 changes: 20 additions & 0 deletions gui/src/components/CodexAutoSwitchSetting.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useRef } from "react";
import { useT } from "../i18n/shared";
import { NumberStepper } from "./NumberStepper";

export type AutoSwitchFeedback = { tone: "ok" | "err"; message: string } | null;

Expand All @@ -17,6 +18,12 @@ export interface CodexAutoSwitchSettingProps {
onRetry(): void;
}

function clampThresholdDraft(raw: string, delta: number): string {
const parsed = Number.parseInt(raw, 10);
const base = Number.isFinite(parsed) ? parsed : 1;
return String(Math.min(100, Math.max(1, base + delta)));
}

export function CodexAutoSwitchSetting({
threshold,
draft,
Expand Down Expand Up @@ -102,6 +109,19 @@ export function CodexAutoSwitchSetting({
}}
/>
<span className="codex-auto-switch-unit" aria-hidden="true">%</span>
<NumberStepper
disabled={saving}
incrementLabel={t("codexAuth.autoSwitchThresholdInc")}
decrementLabel={t("codexAuth.autoSwitchThresholdDec")}
onIncrement={() => {
onEditingChange(true);
onDraftChange(clampThresholdDraft(draft, 1));
}}
onDecrement={() => {
onEditingChange(true);
onDraftChange(clampThresholdDraft(draft, -1));
}}
/>
</span>
</label>
)}
Expand Down
5 changes: 3 additions & 2 deletions gui/src/components/CodexPoolStrategySetting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,9 @@ export default function CodexPoolStrategySetting({ apiBase }: { apiBase: string
const loading = !ready && !loadError;

return (
<div className="card" style={{ marginTop: 16 }} aria-busy={loading || saving}>
<div className="card account-pool-strategy-card" aria-busy={loading || saving}>
<strong>{t("accountPool.strategy")}</strong>
<div className="card-sub" style={{ marginTop: 4 }} role={loadError ? "alert" : undefined}>
<div className="card-sub" role={loadError ? "alert" : undefined}>
{loadError
? t("accountPool.strategyLoadFailed")
: loading
Expand All @@ -120,6 +120,7 @@ export default function CodexPoolStrategySetting({ apiBase }: { apiBase: string
strategy={strategy}
stickyDraft={stickyDraft}
disabled={saving}
hideStrategyLabel
strategySelectId="codex-pool-strategy"
stickyInputId="codex-pool-sticky-limit"
onStrategyChange={(next) => {
Expand Down
43 changes: 43 additions & 0 deletions gui/src/components/NumberStepper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { IconChevron } from "../icons";

export interface NumberStepperProps {
disabled?: boolean;
/** Increase the bound value (parent owns parsing / clamping). */
onIncrement(): void;
/** Decrease the bound value. */
onDecrement(): void;
incrementLabel: string;
decrementLabel: string;
}

/** Compact up/down chevron pair matching dashboard control chrome. */
export function NumberStepper({
disabled = false,
onIncrement,
onDecrement,
incrementLabel,
decrementLabel,
}: NumberStepperProps) {
return (
<div className="ocx-stepper" role="group">
<button
type="button"
className="ocx-stepper__btn"
disabled={disabled}
aria-label={incrementLabel}
onClick={onIncrement}
>
<IconChevron width={10} height={10} aria-hidden="true" style={{ transform: "rotate(-90deg)" }} />
</button>
<button
type="button"
className="ocx-stepper__btn"
disabled={disabled}
aria-label={decrementLabel}
onClick={onDecrement}
>
<IconChevron width={10} height={10} aria-hidden="true" style={{ transform: "rotate(90deg)" }} />
</button>
</div>
);
}
49 changes: 32 additions & 17 deletions gui/src/components/codex-account-pool-main-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,20 +102,27 @@ export function CodexAccountPoolPageHead({
t,
embedded,
refreshingQuota,
refreshFeedback,
onRefresh,
}: {
t: TFn;
embedded: boolean;
refreshingQuota: boolean;
refreshFeedback?: string | null;
onRefresh: () => void;
}) {
if (embedded) return null;
return (
<div className="page-head">
<div className="page-head codex-auth-page-head">
<h2 className="page-title">{t("nav.codexAuth")}</h2>
<button type="button" className="btn btn-sm btn-ghost" onClick={onRefresh} disabled={refreshingQuota}>
<IconRefresh width={14} /> {refreshingQuota ? t("codexAuth.refreshingQuota") : t("codexAuth.refreshQuota")}
</button>
<div className="codex-auth-page-head__actions">
<span className="codex-auth-page-head__feedback" role="status" aria-live="polite">
{refreshFeedback ?? ""}
</span>
<button type="button" className="btn btn-sm btn-ghost" onClick={onRefresh} disabled={refreshingQuota}>
<IconRefresh width={14} /> {refreshingQuota ? t("codexAuth.refreshingQuota") : t("codexAuth.refreshQuota")}
</button>
</div>
</div>
);
}
Expand All @@ -131,17 +138,25 @@ export function CodexAccountPoolLoadStates({
accountsCount: number;
onRetry: () => void;
}): ReactNode {
return (
<>
{loadState === "loading" && accountsCount === 0 && (
<div className="pwi-auth-state" role="status">{t("pws.accountsLoading")}</div>
)}
{loadState === "error" && (
<div className="pwi-auth-state pwi-auth-state--error" role="alert">
<span>{t("codexAuth.loadFailed")}</span>
<button type="button" className="btn btn-ghost btn-sm" onClick={onRetry}>{t("pws.retryAccounts")}</button>
</div>
)}
</>
);
// Initial load with no accounts yet: keep a reserved skeleton strip (no top-of-page
// banner that pushes the rest of the page down). Refresh-with-data stays silent here —
// the cards keep showing last-good rows while the header button shows busy state.
if (loadState === "loading" && accountsCount === 0) {
return (
<div className="codex-auth-load-skeleton" role="status" aria-live="polite">
<div className="codex-auth-load-skeleton__card" />
<div className="codex-auth-load-skeleton__card" />
<span className="sr-only">{t("pws.accountsLoading")}</span>
</div>
);
}
if (loadState === "error") {
return (
<div className="pwi-auth-state pwi-auth-state--error" role="alert">
<span>{t("codexAuth.loadFailed")}</span>
<button type="button" className="btn btn-ghost btn-sm" onClick={onRetry}>{t("pws.retryAccounts")}</button>
</div>
);
}
return null;
}
10 changes: 10 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const de: Record<TKey, string> = {
"startup.title": "Startsicherheit",
"startup.subtitle": "Prüft, ob Codex opencodex nach einem Neustart erreicht, bevor lokales Proxy-Routing in einer Wiederverbindungsschleife endet.",
"startup.refresh": "Aktualisieren",
"startup.backToDashboard": "Zurück zum Dashboard",
"startup.loading": "Startschutz wird geprüft…",
"startup.error": "Startschutz konnte nicht gelesen werden.",
"startup.staleData": "Die aktuelle Prüfung ist fehlgeschlagen. Die Werte unten sind veraltet und kein Nachweis für Schutz.",
Expand Down Expand Up @@ -73,8 +74,12 @@ export const de: Record<TKey, string> = {
"startup.installedDisabled": "Installiert, aber deaktiviert",
"startup.install": "Installieren",
"startup.installing": "Wird installiert…",
"startup.repair": "Reparieren",
"startup.repairing": "Wird repariert…",
"startup.serviceInstalled": "Hintergrunddienst wurde erfolgreich installiert.",
"startup.serviceRepaired": "Hintergrunddienst wurde erfolgreich repariert.",
"startup.shimInstalled": "Codex-Launcher-Shim wurde erfolgreich installiert.",
"startup.shimRepaired": "Codex-Launcher-Shim wurde erfolgreich repariert.",
"startup.installFailed": "Installation fehlgeschlagen:",
"startup.tray.title": "Windows-Infobereich",
"startup.tray.hint": "Installiert ein Anmeldesymbol für Proxy-Start, Stopp, Neustart, Dashboard und Status per Klick.",
Expand Down Expand Up @@ -671,6 +676,8 @@ export const de: Record<TKey, string> = {
"codexAuth.autoSwitchOffDesc": "Der automatische Kontowechsel ist ausgeschaltet",
"codexAuth.autoSwitchThreshold": "Wechselschwelle",
"codexAuth.autoSwitchThresholdAria": "Wechselschwelle in Prozent",
"codexAuth.autoSwitchThresholdInc": "Wechsel-Schwelle erhöhen",
"codexAuth.autoSwitchThresholdDec": "Wechsel-Schwelle verringern",
"codexAuth.autoSwitchLoadFailed": "Die Einstellung für den automatischen Kontowechsel konnte nicht geladen werden.",
"codexAuth.autoSwitchThresholdInvalid": "Gib eine ganze Zahl von 1 bis 100 ein",
"codexAuth.autoSwitchUpdated": "Der automatische Kontowechsel wurde aktualisiert",
Expand All @@ -697,6 +704,8 @@ export const de: Record<TKey, string> = {
"accountPool.strategyHint": "Gilt nur für neue Sitzungen; bestehende Threads behalten die Kontenaffinität.",
"accountPool.stickyLimit": "Sticky-Erfolge vor Rotation",
"accountPool.stickyLimitAria": "Sticky-Erfolge vor Rotation",
"accountPool.stickyLimitInc": "Sticky-Limit erhöhen",
"accountPool.stickyLimitDec": "Sticky-Limit verringern",
"accountPool.stickyLimitHelp": "Das gewählte Konto für so viele erfolgreiche neue Sitzungsbindungen behalten, bevor weitergeschaltet wird.",
"accountPool.stickyLimitInvalid": "Gib eine ganze Zahl von 1 bis 100 ein",
"accountPool.strategyLoadFailed": "Rotationsstrategie konnte nicht geladen werden.",
Expand Down Expand Up @@ -808,6 +817,7 @@ export const de: Record<TKey, string> = {
"api.generate": "Generieren",
"api.generating": "Erstelle…",
"api.activeKeys": "Aktive Schlüssel ({count})",
"api.activeKeysLoading": "Aktive Schlüssel",
"api.noKeys": "Noch keine API-Schlüssel. Erstelle oben einen.",
"api.colName": "Name",
"api.colKey": "Schlüssel",
Expand Down
Loading
Loading