From 3089791224f0fe46ecda169c306ff99740636aaa Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:46:57 +0200 Subject: [PATCH 1/4] fix(gui): clear remaining react-doctor code findings Fix effect cleanup, mechanical a11y/perf warnings, barrel imports, button types, dialog roles, fetch status checks, loading finally resets, unused exports, and related GUI correctness issues. Doctor policy/config changes land in a separate PR. --- gui/src/App.tsx | 2 +- gui/src/components/AddCodexAccountModal.tsx | 2 +- gui/src/components/AddProviderModal.tsx | 2 +- gui/src/components/CodexAccountPool.tsx | 2 +- gui/src/components/CodexAutoSwitchSetting.tsx | 2 +- .../components/MemoryObservabilityCard.tsx | 40 ++++++++++++++----- gui/src/components/OAuthTosWarningModal.tsx | 22 +--------- gui/src/components/QuotaBars.tsx | 12 +++--- .../add-codex-account-pick-step.tsx | 2 +- .../add-codex-account-waiting-step.tsx | 2 +- gui/src/components/add-provider-form-pane.tsx | 2 +- .../components/add-provider-oauth-pane.tsx | 2 +- .../components/codex-account-pool-cards.tsx | 2 +- .../components/codex-account-pool-handlers.ts | 2 +- .../components/codex-account-pool-helpers.tsx | 2 +- .../codex-account-pool-main-card.tsx | 2 +- .../components/codex-account-reset-modal.tsx | 5 ++- .../components/codex-account-switch-modal.tsx | 5 ++- .../components/combo-workspace-add-modal.tsx | 4 +- .../components/combo-workspace-dialogs.tsx | 8 +--- .../provider-catalog/ProviderCatalog.tsx | 28 +++++++------ .../provider-catalog/provider-presets.ts | 5 --- .../provider-workspace/ProviderAuthPanel.tsx | 27 +++++++------ .../provider-workspace/ProviderDetails.tsx | 2 +- .../provider-workspace/ProviderDialogs.tsx | 2 +- .../provider-workspace/ProviderJsonEditor.tsx | 3 +- .../provider-workspace/ProviderModels.tsx | 22 +++++----- .../provider-workspace/ProviderOverview.tsx | 12 +++--- .../ProviderOverviewDashboard.tsx | 2 +- .../provider-workspace/ProviderRail.tsx | 2 +- .../provider-workspace/ProviderSettings.tsx | 5 ++- .../provider-workspace/ProviderUsage.tsx | 20 ++++++++-- .../ProviderWorkspaceShell.tsx | 18 +++++---- .../components/use-add-codex-account-oauth.ts | 2 +- gui/src/components/use-add-provider-oauth.ts | 2 +- gui/src/formatUptime.ts | 2 +- gui/src/hooks/useCodexAutoSwitch.ts | 27 +++++++------ gui/src/hooks/useJsonConfigEditor.ts | 28 ++++++------- gui/src/hooks/useProviderAccountPools.ts | 24 +++++------ gui/src/icons.tsx | 1 - gui/src/main.tsx | 2 +- gui/src/pages/ClaudeCode.tsx | 2 +- gui/src/pages/CodexAuth.tsx | 8 ++-- gui/src/pages/Dashboard.tsx | 10 +++-- gui/src/pages/Logs.tsx | 2 +- gui/src/pages/Models.tsx | 15 +++++-- gui/src/pages/Providers.tsx | 4 +- gui/src/pages/claude-code-sections.tsx | 4 +- gui/src/pages/claude-code-types.ts | 1 + gui/src/pages/dashboard-dialogs.tsx | 6 +-- gui/src/pages/providers-page-utils.ts | 4 +- gui/src/pages/use-providers-crud.ts | 2 +- gui/src/pages/use-providers-fetch.ts | 2 +- gui/src/pages/use-providers-oauth.ts | 2 +- gui/src/provider-icons.ts | 31 -------------- gui/src/provider-payload.ts | 2 +- gui/src/provider-workspace/auth.ts | 2 +- gui/src/provider-workspace/catalog.ts | 15 ------- gui/src/styles.css | 12 ++++++ gui/src/ui.tsx | 7 ++-- 60 files changed, 247 insertions(+), 242 deletions(-) diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 9ee347031..732ffc7d7 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -14,7 +14,7 @@ import ClaudeCode from "./pages/ClaudeCode"; import Startup from "./pages/Startup"; import ErrorBoundary from "./components/ErrorBoundary"; import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconKey, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX } from "./icons"; -import { useI18n, useT, LOCALES, type Locale, type TKey } from "./i18n"; +import { useI18n, useT, LOCALES, type Locale, type TKey } from "./i18n/shared"; import { Select, Switch } from "./ui"; import { installApiAuthFetch } from "./api"; import { readJsonIfOk } from "./fetch-json"; diff --git a/gui/src/components/AddCodexAccountModal.tsx b/gui/src/components/AddCodexAccountModal.tsx index e5311c9f0..969ad82e6 100644 --- a/gui/src/components/AddCodexAccountModal.tsx +++ b/gui/src/components/AddCodexAccountModal.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useReducer, useRef } from "react"; -import { useT } from "../i18n"; +import { useT } from "../i18n/shared"; import { addCodexAccountUiReducer, initialAddCodexAccountUiState, diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index a07632b32..0de880c8b 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useReducer, useRef } from "react"; import { IconX } from "../icons"; -import { useT } from "../i18n"; +import { useT } from "../i18n/shared"; import { useKeyedClientResource } from "../client-resource"; import { buildProviderPostBody, diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 5401dd3f2..f4ca46a22 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import { useT } from "../i18n"; +import { useT } from "../i18n/shared"; import { IconPlus } from "../icons"; import { Notice, EmptyState } from "../ui"; import AddCodexAccountModal from "./AddCodexAccountModal"; diff --git a/gui/src/components/CodexAutoSwitchSetting.tsx b/gui/src/components/CodexAutoSwitchSetting.tsx index 62fa886bf..0c49154ff 100644 --- a/gui/src/components/CodexAutoSwitchSetting.tsx +++ b/gui/src/components/CodexAutoSwitchSetting.tsx @@ -1,5 +1,5 @@ import { useRef } from "react"; -import { useT } from "../i18n"; +import { useT } from "../i18n/shared"; export type AutoSwitchFeedback = { tone: "ok" | "err"; message: string } | null; diff --git a/gui/src/components/MemoryObservabilityCard.tsx b/gui/src/components/MemoryObservabilityCard.tsx index 4063afd2a..b93783296 100644 --- a/gui/src/components/MemoryObservabilityCard.tsx +++ b/gui/src/components/MemoryObservabilityCard.tsx @@ -42,15 +42,35 @@ interface SystemMemory { * memory diagnostic must not introduce. The number itself goes through the active locale so it * matches every other figure on this dashboard. */ +const byteNumberFormats = new Map(); +function byteNumberFormat(locale: Locale, fractionDigits: number): Intl.NumberFormat { + const key = `${locale}:${fractionDigits}`; + let fmt = byteNumberFormats.get(key); + if (!fmt) { + fmt = new Intl.NumberFormat(locale, { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }); + byteNumberFormats.set(key, fmt); + } + return fmt; +} +const plainNumberFormats = new Map(); +function plainNumberFormat(locale: Locale): Intl.NumberFormat { + let fmt = plainNumberFormats.get(locale); + if (!fmt) { + fmt = new Intl.NumberFormat(locale); + plainNumberFormats.set(locale, fmt); + } + return fmt; +} + function formatBytes(bytes: number, locale: Locale): string { if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; const units = ["B", "KiB", "MiB", "GiB", "TiB"]; const exp = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); const value = bytes / 1024 ** exp; - const formatted = new Intl.NumberFormat(locale, { - minimumFractionDigits: exp === 0 ? 0 : 1, - maximumFractionDigits: exp === 0 ? 0 : 1, - }).format(value); + const formatted = byteNumberFormat(locale, exp === 0 ? 0 : 1).format(value); return `${formatted} ${units[exp]}`; } @@ -95,12 +115,15 @@ export default function MemoryObservabilityCard({ apiBase }: { apiBase: string } if (inFlight) return; inFlight = true; // Bound each poll so a hung request cannot pin inFlight forever and - // starve the unavailable fallback. + // starve the unavailable fallback. AbortSignal.timeout avoids a manual + // setTimeout that the effect-cleanup detector cannot always see. const controller = new AbortController(); activeController = controller; - const timeout = setTimeout(() => controller.abort(), 10_000); + const signal = typeof AbortSignal !== "undefined" && "any" in AbortSignal && "timeout" in AbortSignal + ? AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]) + : controller.signal; try { - const res = await fetch(`${apiBase}/api/system/memory`, { signal: controller.signal }); + const res = await fetch(`${apiBase}/api/system/memory`, { signal }); if (!res.ok) throw new Error("memory unavailable"); const json = await res.json() as SystemMemory; if (!cancelled) { @@ -111,7 +134,6 @@ export default function MemoryObservabilityCard({ apiBase }: { apiBase: string } // Old servers (pre-#314) 404 this route; degrade to a quiet unavailable note. if (!cancelled) setUnavailable(true); } finally { - clearTimeout(timeout); if (activeController === controller) activeController = null; inFlight = false; } @@ -166,7 +188,7 @@ export default function MemoryObservabilityCard({ apiBase }: { apiBase: string }
{t("dash.mem.store")}
{t("dash.mem.storeHint")}
- + { - if (e.key !== "Tab") return; - const dialog = dialogRef.current; - if (!dialog) return; - const focusable = dialog.querySelectorAll( - "input:not([disabled]), button:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])", - ); - if (focusable.length === 0) return; - const first = focusable.item(0); - const last = focusable.item(focusable.length - 1); - if (e.shiftKey && document.activeElement === first) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); - first.focus(); - } - }} > +
)} {tier !== "accounts" && rows.map(p => ( - + onLogout && ) : busy ? ( - onCancelLogin && + onCancelLogin && ) : ( - onLogin && + onLogin && )} @@ -180,7 +184,7 @@ export default function ProviderCatalog({
{tier !== "accounts" && ( - + )}
diff --git a/gui/src/components/provider-catalog/provider-presets.ts b/gui/src/components/provider-catalog/provider-presets.ts index 42d6d4c7f..dfe07b6ba 100644 --- a/gui/src/components/provider-catalog/provider-presets.ts +++ b/gui/src/components/provider-catalog/provider-presets.ts @@ -68,8 +68,3 @@ export function filterPresets(presets: CatalogPreset[], query: string): CatalogP return presets.filter(p => p.label.toLowerCase().includes(q) || p.id.toLowerCase().includes(q)); } -/** Deterministic catalog order: label A→Z (case-insensitive), id as tiebreak. */ -export function sortPresets(presets: CatalogPreset[]): CatalogPreset[] { - return [...presets].sort((a, b) => - a.label.localeCompare(b.label, undefined, { sensitivity: "base" }) || a.id.localeCompare(b.id)); -} diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index cd7af2714..820618de0 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -4,7 +4,7 @@ * handlers via props-down; no internal auth machinery. */ import { useState } from "react"; -import { useT } from "../../i18n"; +import { useT } from "../../i18n/shared"; import { IconLock, IconExternal, IconTrash } from "../../icons"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; import { oauthAccountDisplayLabel, providerAuthSurface } from "../../provider-workspace/auth"; @@ -68,9 +68,12 @@ export default function ProviderAuthPanel({ const key = newKey.trim(); if (!key) return; setKeyBusy(true); - const ok = await authHandlers.onAddApiKey(item.name, key); - setKeyBusy(false); - if (ok) { setNewKey(""); setAddingKey(false); } + try { + const ok = await authHandlers.onAddApiKey(item.name, key); + if (ok) { setNewKey(""); setAddingKey(false); } + } finally { + setKeyBusy(false); + } }; return ( @@ -149,12 +152,12 @@ export default function ProviderAuthPanel({ )} {accounts.length > 0 && ( -
+
    {accounts.map(account => { const label = oauthAccountDisplayLabel(accounts, account, t); const switching = switchingAccountId === account.id; return ( -
    +
  • -
  • + ); })} -
+ )} {accountLoadState === "ready" && loggedIn && accounts.length === 0 && (
{t("pws.noAccounts")}
@@ -210,9 +213,9 @@ export default function ProviderAuthPanel({ {isKeyAuth && ( <> {keys.length > 0 && ( -
+
    {keys.map(entry => ( -
    +
  • -
  • + ))} -
+ )} {addingKey ? (
diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index 686364e15..e825dcee6 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -3,7 +3,7 @@ * and composes the Overview/Models/Usage/Settings panels. */ import { useCallback, useMemo, useRef, useState } from "react"; -import { useT } from "../../i18n"; +import { useT } from "../../i18n/shared"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; import { formatProviderDisplayName } from "../../provider-icons"; import { isFreeProvider } from "../../provider-workspace/catalog"; diff --git a/gui/src/components/provider-workspace/ProviderDialogs.tsx b/gui/src/components/provider-workspace/ProviderDialogs.tsx index 30acd48b1..944887c76 100644 --- a/gui/src/components/provider-workspace/ProviderDialogs.tsx +++ b/gui/src/components/provider-workspace/ProviderDialogs.tsx @@ -2,7 +2,7 @@ * ProviderDialogs — confirmation and warning dialogs for the workspace * Settings tab (WP091): remove provider, unsaved-leave, JSON save-before-leave. */ -import { useT } from "../../i18n"; +import { useT } from "../../i18n/shared"; export function RemoveConfirmDialog({ providerName, onConfirm, onCancel, diff --git a/gui/src/components/provider-workspace/ProviderJsonEditor.tsx b/gui/src/components/provider-workspace/ProviderJsonEditor.tsx index 8479f42ce..4f8bdaede 100644 --- a/gui/src/components/provider-workspace/ProviderJsonEditor.tsx +++ b/gui/src/components/provider-workspace/ProviderJsonEditor.tsx @@ -3,7 +3,7 @@ * Dirty/leave/save guards are managed via the parent's jsonEditor prop contract. */ import { useRef, useEffect } from "react"; -import { useT } from "../../i18n"; +import { useT } from "../../i18n/shared"; export interface JsonEditorState { open: boolean; @@ -55,6 +55,7 @@ export default function ProviderJsonEditor({ onChange={e => editor.onDraftChange(e.target.value)} spellCheck={false} rows={20} + aria-label={t("pws.jsonEditorDesc")} /> {message && (
diff --git a/gui/src/components/provider-workspace/ProviderModels.tsx b/gui/src/components/provider-workspace/ProviderModels.tsx index 3bae89693..54bd63ad4 100644 --- a/gui/src/components/provider-workspace/ProviderModels.tsx +++ b/gui/src/components/provider-workspace/ProviderModels.tsx @@ -4,7 +4,7 @@ * short lists fill horizontal space instead of a tall single-column stack. */ import { useEffect, useMemo, useRef, useState } from "react"; -import { useT } from "../../i18n"; +import { useT } from "../../i18n/shared"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; import { filterModels } from "../../provider-workspace/report"; @@ -62,12 +62,11 @@ export default function ProviderModels({ useEffect(() => { let active = true; - void fetch(`${apiBase}/api/custom-models`) - .then(response => { + const load = async () => { + try { + const response = await fetch(`${apiBase}/api/custom-models`); if (!response.ok) throw new Error(); - return response.json(); - }) - .then((rows: unknown) => { + const rows: unknown = await response.json(); if (!Array.isArray(rows)) throw new Error("Invalid custom model list"); if (!active) return; setCustomModelIds(rows.flatMap(row => { @@ -78,8 +77,7 @@ export default function ProviderModels({ setCustomModelsLoadFailed(false); setCustomError(""); setCustomModelsReady(true); - }) - .catch(() => { + } catch { if (!active) return; setCustomModelIds([]); // Without this the component stays permanently unable to add a model: `customModelsReady` @@ -88,7 +86,9 @@ export default function ProviderModels({ setCustomModelsReady(false); setCustomModelsLoadFailed(true); setCustomError(t("models.networkError")); - }); + } + }; + void load(); return () => { active = false; }; }, [apiBase, item.name, t, customModelsLoadEpoch]); @@ -236,13 +236,13 @@ export default function ProviderModels({ ) : models.length === 0 ? (

{t("pws.noModelMatch")}

) : ( -
+
{visibleModels.map(modelId => { const isDefault = modelId === item.defaultModel; const isSelected = selectedSet.has(modelId); const copied = copiedId === modelId; return ( -
+
{filterOpen && ( -
+
{t("pws.filters")}
{t("pws.filterStatus")}
{statusFilterOptions.map(({ key, label, count }) => ( -