diff --git a/web/standalone/src/components/CmmsAiWidget.tsx b/web/standalone/src/components/CmmsAiWidget.tsx index a4953f3..3aabf5f 100644 --- a/web/standalone/src/components/CmmsAiWidget.tsx +++ b/web/standalone/src/components/CmmsAiWidget.tsx @@ -20,18 +20,27 @@ export function CmmsAiWidget(): React.ReactElement | null { React.useEffect(() => { let cancelled = false; - fetchCmmsConfig().then((c) => { - if (cancelled) return; - setConfig(c); - if (c.aiEnabled) installAiBridge(); - // Belt-and-braces alongside the `?theme=` hand-off (lib/theme.ts) — that - // param already wins whenever edit.php sets it, so this only matters if - // it's ever absent. Skip if the URL param already ran; setTheme is - // otherwise idempotent (persists + toggles the same `.dark` class). - if (c.dark && !new URLSearchParams(window.location.search).get("theme")) { - setTheme("dark"); - } - }); + // fetchCmmsConfig() never rejects (its own internal .catch() falls back + // to NO_WIDGET) — this .catch() guards the callback body itself instead + // (setConfig/installAiBridge/setTheme), so a mistake here becomes a + // logged no-op, not a silent unhandled rejection with nothing to say why + // the widget didn't appear. + fetchCmmsConfig() + .then((c) => { + if (cancelled) return; + setConfig(c); + if (c.aiEnabled) installAiBridge(); + // Belt-and-braces alongside the `?theme=` hand-off (lib/theme.ts) — + // that param already wins whenever edit.php sets it, so this only + // matters if it's ever absent. Skip if the URL param already ran; + // setTheme is otherwise idempotent (persists + toggles `.dark`). + if (c.dark && !new URLSearchParams(window.location.search).get("theme")) { + setTheme("dark"); + } + }) + .catch((err: unknown) => { + console.error("[cmms-ai-widget] boot failed:", err); + }); return () => { cancelled = true; }; diff --git a/web/standalone/src/lib/cmms-config.ts b/web/standalone/src/lib/cmms-config.ts index 754e157..4ede25a 100644 --- a/web/standalone/src/lib/cmms-config.ts +++ b/web/standalone/src/lib/cmms-config.ts @@ -15,17 +15,22 @@ export interface CmmsConfig { entityId: string; } -function isCmmsConfig(v: unknown): v is CmmsConfig { +// Only `dark` / `aiEnabled` / `aiEditorKind` actually gate behavior — a +// bug found 2026-09-05 in browser verification required all six fields as +// strict strings, so the real server response (which omits `entityType` / +// `entityId`) failed validation and silently fell back to NO_WIDGET despite +// `aiEnabled: true` — the widget never mounted and nothing logged why. +// `csrfToken` / `entityType` / `entityId` are only used to build the iframe's +// query string (harmless as `""`), so a missing one shouldn't disable the +// widget entirely. +function isCmmsConfigCore(v: unknown): v is Pick { if (!v || typeof v !== "object") return false; const c = v as Record; - return ( - typeof c.dark === "boolean" && - typeof c.aiEnabled === "boolean" && - c.aiEditorKind === "kicad" && - typeof c.csrfToken === "string" && - typeof c.entityType === "string" && - typeof c.entityId === "string" - ); + return typeof c.dark === "boolean" && typeof c.aiEnabled === "boolean" && c.aiEditorKind === "kicad"; +} + +function optionalString(v: unknown): string { + return typeof v === "string" ? v : ""; } const NO_WIDGET: CmmsConfig = { @@ -47,7 +52,18 @@ export function fetchCmmsConfig(): Promise { if (!cached) { cached = fetch(`${API_BASE_URL}/api/cmms-config`, { credentials: "include" }) .then((r) => (r.ok ? r.json() : null)) - .then((v) => (isCmmsConfig(v) ? v : NO_WIDGET)) + .then((v): CmmsConfig => { + if (!isCmmsConfigCore(v)) return NO_WIDGET; + const c = v as Record; + return { + dark: v.dark, + aiEnabled: v.aiEnabled, + aiEditorKind: v.aiEditorKind, + csrfToken: optionalString(c.csrfToken), + entityType: optionalString(c.entityType), + entityId: optionalString(c.entityId), + }; + }) .catch(() => NO_WIDGET); } return cached;