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..39f65b2f5 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,19 @@ 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. Prefer AbortSignal.timeout; fall back + // to a manual timer when the browser lacks AbortSignal.any/timeout. const controller = new AbortController(); activeController = controller; - const timeout = setTimeout(() => controller.abort(), 10_000); + let timeoutId: ReturnType | undefined; + const signal = typeof AbortSignal !== "undefined" && "any" in AbortSignal && "timeout" in AbortSignal + ? AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]) + : (() => { + timeoutId = setTimeout(() => controller.abort(), 10_000); + return 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 +138,7 @@ 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 (timeoutId !== undefined) clearTimeout(timeoutId); if (activeController === controller) activeController = null; inFlight = false; } @@ -166,7 +193,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..9a3978f23 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 ( -
    +
  • + ); })} -
+ )} {capped && (

diff --git a/gui/src/components/provider-workspace/ProviderOverview.tsx b/gui/src/components/provider-workspace/ProviderOverview.tsx index e5424488d..ad35ac25d 100644 --- a/gui/src/components/provider-workspace/ProviderOverview.tsx +++ b/gui/src/components/provider-workspace/ProviderOverview.tsx @@ -2,8 +2,8 @@ * ProviderOverview — 2-column layout: left (CONNECTION + Auth summary) / right * (STATS + Notes). Phase 030 of workspace design parity. */ -import { useCallback, useState, type ReactNode } from "react"; -import { useT, useI18n } from "../../i18n"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { useT, useI18n } from "../../i18n/shared"; import { IconAlert, IconCheck } from "../../icons"; import { binProviderStatus, type WorkspaceItem } from "../../provider-workspace/catalog"; import { formatRelativeTime, relativeTimeLabelsFromT, formatRequestCount, formatTokenCount } from "../../provider-workspace/usage"; @@ -196,19 +196,34 @@ function NotesSection({ item, onUpdateProvider }: { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(""); const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const textareaRef = useRef(null); + + useEffect(() => { + if (editing) textareaRef.current?.focus(); + }, [editing]); const save = useCallback(async () => { if (saving || !onUpdateProvider) return; const trimmed = draft.trim(); if (trimmed === (item.note ?? "")) { setEditing(false); + setError(""); return; } setSaving(true); - await onUpdateProvider(item.name, { note: trimmed || undefined }); - setSaving(false); - setEditing(false); - }, [draft, item.name, item.note, onUpdateProvider, saving]); + try { + const result = await onUpdateProvider(item.name, { note: trimmed || undefined }); + if (!result.ok) { + setError(result.error || t("prov.saveFailed")); + return; + } + setError(""); + setEditing(false); + } finally { + setSaving(false); + } + }, [draft, item.name, item.note, onUpdateProvider, saving, t]); if (!editing) { return ( @@ -220,6 +235,7 @@ function NotesSection({ item, onUpdateProvider }: { onClick={() => { if (!onUpdateProvider) return; setDraft(item.note ?? ""); + setError(""); setEditing(true); }} disabled={!onUpdateProvider} @@ -234,18 +250,19 @@ function NotesSection({ item, onUpdateProvider }: {

{t("pws.notes")}