diff --git a/web/standalone/src/App.tsx b/web/standalone/src/App.tsx index 2d550ea..49b27a9 100644 --- a/web/standalone/src/App.tsx +++ b/web/standalone/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect } from "react"; import { Route, Routes, useLocation } from "react-router-dom"; +import { CmmsAiWidget } from "@/components/CmmsAiWidget"; import { VersionBadge } from "@/components/VersionBadge"; import { useChromeHidden } from "@/lib/chrome-visibility"; import { APP_URL } from "@/lib/config"; @@ -34,6 +35,9 @@ export default function App() { {/* Version + source link, bottom-right on every route (home + editor). Keys off the Figma-like hide-UI toggle (hidden is the mobile default). */} {!chromeHidden && } + {/* No-op (fetches config, renders nothing) unless the CMMS embed's + ai_assistant module is on for this tenant — see CmmsAiWidget. */} + ); } diff --git a/web/standalone/src/components/CmmsAiWidget.tsx b/web/standalone/src/components/CmmsAiWidget.tsx new file mode 100644 index 0000000..3aabf5f --- /dev/null +++ b/web/standalone/src/components/CmmsAiWidget.tsx @@ -0,0 +1,139 @@ +// cmms-embed / PCBJam Part 2: floating AI-assistant launcher + panel, for the +// CMMS embed only. Inert (renders nothing) unless `/api/cmms-config` answers +// `aiEnabled: true` — a plain pcbjam.io deploy or a CMMS tenant with the +// module off never fetches or renders anything here beyond the one config +// call. Visual language (position, colors, toggle/panel shape) mirrors +// `render_ai_assistant_editor_embed()` (TT-DB `src/ai_assistant_widget.php`) +// so it reads as the same widget as every other embedded editor; the +// draggable/resizable geometry system that PHP version has is CMMS-side +// static JS this bundle can't import, so this is fixed-position (bottom +// right) — a reasonable v1 given the panel is small relative to the board +// canvas. +import * as React from "react"; +import { fetchCmmsConfig, type CmmsConfig } from "@/lib/cmms-config"; +import { getActiveFilePath, installAiBridge } from "@/wasm/collab/ai-bridge"; +import { setTheme } from "@/lib/theme"; + +export function CmmsAiWidget(): React.ReactElement | null { + const [config, setConfig] = React.useState(null); + const [open, setOpen] = React.useState(false); + + React.useEffect(() => { + let cancelled = false; + // 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; + }; + }, []); + + if (!config?.aiEnabled) return null; + + // Computed at render time (not memoized) so re-opening the panel after + // switching sheets sends the file that's actually open now — a context + // hint only; get-buffer carries the real content regardless. + const params = new URLSearchParams({ + editor: "kicad", + editor_entity_type: config.entityType, + editor_entity_id: config.entityId, + editor_filename: getActiveFilePath() ?? "", + page_context: "PCBJam (KiCad editor)", + }); + const src = `/modules/ai_assistant/widget-frame.php?${params.toString()}`; + + return ( +
+ + {open && ( +
+
+ AI Assistant + +
+