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..a4953f3
--- /dev/null
+++ b/web/standalone/src/components/CmmsAiWidget.tsx
@@ -0,0 +1,130 @@
+// 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().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");
+ }
+ });
+ 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
+
+
+
+
+ )}
+
+ );
+}
diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx
index 643db7a..d1e4af0 100644
--- a/web/standalone/src/components/WasmTool.tsx
+++ b/web/standalone/src/components/WasmTool.tsx
@@ -47,6 +47,7 @@ import { startFilesWatch, type FilesWatchHandle } from "@/wasm/collab/files-watc
import { dump as dumpTrace, mark } from "@/wasm/load-trace";
import { errorMessage, isTerminalError } from "@/wasm/terminal-error";
import { registerSaveHook, type SaveBlock, type SaveBytes } from "@/wasm/save-flow";
+import { registerActiveCollabDoc } from "@/wasm/collab/ai-bridge";
import type {
KicadCollabHandle,
KicadDocSession,
@@ -420,6 +421,7 @@ export function WasmTool({
localSelectionRef.current?.destroy();
localSelectionRef.current = null;
setPanelDoc(null);
+ registerActiveCollabDoc(null);
commentsRef.current?.destroy();
commentsRef.current = null;
followRef.current?.destroy();
@@ -1219,6 +1221,7 @@ export function WasmTool({
startPresence(activeRoom?.provider, activeRoom?.sheetPath, activeRoom?.doc);
startComments(activeRoom?.doc);
setPanelDoc(activeRoom?.doc ?? null);
+ registerActiveCollabDoc(activeRoom?.doc ?? null, activeRoom?.sheetPath);
if (activeRoom && !readOnly) {
driftRef.current = startDriftDetection({
doc: activeRoom.doc,
@@ -1273,6 +1276,7 @@ export function WasmTool({
startPresence(collabHandle?.provider, undefined, collabHandle?.doc);
startComments(collabHandle?.doc);
setPanelDoc(collabHandle?.doc ?? null);
+ registerActiveCollabDoc(collabHandle?.doc ?? null, targetPath);
// Live sibling mirror (project-sync 0001 bug 3): keep the schematic
// files a PCB session syncs from fresh in MEMFS, instead of the
// one-shot boot snapshot. Same opt-out as the room collab; read-only
diff --git a/web/standalone/src/lib/cmms-config.ts b/web/standalone/src/lib/cmms-config.ts
new file mode 100644
index 0000000..754e157
--- /dev/null
+++ b/web/standalone/src/lib/cmms-config.ts
@@ -0,0 +1,54 @@
+// cmms-embed / PCBJam Part 2: fetch the AI-widget config the CMMS side
+// exposes at boot. Mirrors the existing `/api/me` identity call (same-origin,
+// `credentials: "include"` for the session cookie) — see lib/api.ts.
+//
+// Absent route, non-2xx, or malformed JSON all mean "no AI widget" (a plain
+// pcbjam.io deploy, or a CMMS build where the module is off) — never throws,
+// never blocks boot.
+import { API_BASE_URL } from "@/lib/config";
+export interface CmmsConfig {
+ dark: boolean;
+ aiEnabled: boolean;
+ aiEditorKind: "kicad";
+ csrfToken: string;
+ entityType: string;
+ entityId: string;
+}
+
+function isCmmsConfig(v: unknown): v is CmmsConfig {
+ 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"
+ );
+}
+
+const NO_WIDGET: CmmsConfig = {
+ dark: false,
+ aiEnabled: false,
+ aiEditorKind: "kicad",
+ csrfToken: "",
+ entityType: "",
+ entityId: "",
+};
+
+let cached: Promise | null = null;
+
+/** Fetched once per page load; every caller shares the same in-flight/settled
+ * promise. Same API base as the rest of the app (`VITE_API_BASE_URL`,
+ * `/pcb-project-api.php` in the CMMS build) — the route lives alongside
+ * `/api/me` on that same PHP endpoint. */
+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))
+ .catch(() => NO_WIDGET);
+ }
+ return cached;
+}
diff --git a/web/standalone/src/wasm/collab/ai-bridge.ts b/web/standalone/src/wasm/collab/ai-bridge.ts
new file mode 100644
index 0000000..85a46cd
--- /dev/null
+++ b/web/standalone/src/wasm/collab/ai-bridge.ts
@@ -0,0 +1,162 @@
+// cmms-embed / PCBJam Part 2: AI chat editor-buffer bridge.
+//
+// Same `cmms-ai-editor-*` postMessage protocol as the cad-editor and
+// pcb-retrace bridges (see TT-DB docs/vendored/{cad-editor,pcb-retrace}.md):
+// the chat is a nested iframe, this top-level page is the "editor" side.
+//
+// get-buffer: reads the ACTIVE sheet's live Y.Doc (not last-saved-to-MEMFS —
+// this reflects in-progress edits, including a peer's) via `yToDoc` +
+// `docToFile`, both pure/read-only.
+//
+// apply: deliberately does NOT reuse `KicadBinding.seed()`. `seed()` is a
+// boot-time/reconnect-only function (three branches: editor-matches-doc,
+// empty-room file-seed, adopt-existing-room) with its own empty/hollow-doc
+// edge cases (see kicad-binding.ts) — none of those branches is "a live,
+// already-populated doc gets replaced with unrelated new content", and this
+// file's git history (sibling-restage, paste-collision, ysync 0001-0008) is
+// almost entirely bugfixes in exactly that state-transition space. Instead,
+// this treats the AI's proposed file exactly like a REMOTE PEER'S EDIT
+// arriving: diff the two `KicadDoc`s (`docDelta`) and push the result through
+// `applyDeltaToY` with an origin that does not match the binding's internal
+// `ORIGIN` object, so the normal item-observer chain (the most exercised path
+// in this codebase — every multi-user co-edit goes through it) picks it up
+// and pushes it into the live WASM editor, the same as any other client's
+// change would. Known limitation: `docDelta` only diffs items, not
+// layout/preamble (page size, title block) — matches its own doc comment.
+import {
+ applyDeltaToY,
+ docDelta,
+ docToFile,
+ fileToDoc,
+ yToDoc,
+ type KicadDoc,
+} from "@pcbjam/shared";
+import type * as Y from "yjs";
+import { clog, cwarn } from "@/wasm/collab/debug";
+
+const APPLY_ORIGIN = { external: "cmms-ai-assistant" };
+
+// The active sheet's Y.Doc — registered by WasmTool.tsx at each of its three
+// `setPanelDoc(...)` call sites (single-room collabHandle, multi-sheet
+// onActiveChange, and teardown-to-null), the same signal it already uses as
+// "which doc does the SheetPanel show right now". A module-level singleton
+// rather than prop-drilled: the widget mounts in App.tsx, several route
+// levels above WasmTool, matching the ambient `window.kicadCollab` /
+// `window.CMMS` cross-boundary pattern the other editor bridges already use.
+let activeDoc: Y.Doc | null = null;
+let activeFilePath: string | undefined;
+
+export function registerActiveCollabDoc(doc: Y.Doc | null, filePath?: string): void {
+ activeDoc = doc;
+ activeFilePath = doc ? filePath : undefined;
+}
+
+/** The active sheet's path, for the widget iframe's `editor_filename` param —
+ * a context hint only, not load-bearing (get-buffer carries the real
+ * content). `undefined` before any sheet is open. */
+export function getActiveFilePath(): string | undefined {
+ return activeFilePath;
+}
+
+export class AiBridgeNotReadyError extends Error {
+ constructor() {
+ super("no active KiCad sheet to read/write");
+ this.name = "AiBridgeNotReadyError";
+ }
+}
+
+/** Live S-expr text of the currently active sheet — read-only, never touches
+ * the Y.Doc. Safe to call any time a sheet is open. */
+export function readActiveBuffer(): string {
+ if (!activeDoc) throw new AiBridgeNotReadyError();
+ const doc: KicadDoc = yToDoc(activeDoc);
+ return docToFile(doc);
+}
+
+/** Diff `newSource` against the active sheet's current doc and push the
+ * result in as though a peer had made the edit. Throws on a source that
+ * doesn't parse as a KiCad s-expr document (`fileToDoc`) — the caller
+ * should surface that as a rejected proposal, not a crash. */
+export function applyToActiveBuffer(newSource: string): void {
+ if (!activeDoc) throw new AiBridgeNotReadyError();
+ const prev = yToDoc(activeDoc);
+ const next = fileToDoc(newSource);
+ const delta = docDelta(prev, next);
+ applyDeltaToY(activeDoc, delta, APPLY_ORIGIN);
+}
+
+interface GetBufferMessage {
+ type: "cmms-ai-editor-get-buffer";
+}
+interface ApplyMessage {
+ type: "cmms-ai-editor-apply";
+ editor: "kicad";
+ new_source: string;
+}
+
+function isGetBufferMessage(d: unknown): d is GetBufferMessage {
+ return !!d && typeof d === "object" && (d as { type?: unknown }).type === "cmms-ai-editor-get-buffer";
+}
+function isApplyMessage(d: unknown): d is ApplyMessage {
+ return (
+ !!d &&
+ typeof d === "object" &&
+ (d as { type?: unknown }).type === "cmms-ai-editor-apply" &&
+ (d as { editor?: unknown }).editor === "kicad" &&
+ typeof (d as { new_source?: unknown }).new_source === "string"
+ );
+}
+
+/** Reply to the chat iframe. Prefer `evt.source`; a DOM fallback covers the
+ * cross-realm-Window case cad-editor's history flagged (see its bridge). */
+function replyToChat(evtSource: MessageEventSource | null, msg: Record): void {
+ let posted = false;
+ try {
+ if (evtSource && "postMessage" in evtSource) {
+ (evtSource as Window).postMessage(msg, window.location.origin);
+ posted = true;
+ }
+ } catch {
+ /* fall through to the DOM-query fallback */
+ }
+ if (!posted) {
+ try {
+ const f = document.querySelector("#cmms-ai-widget iframe");
+ if (f?.contentWindow) {
+ f.contentWindow.postMessage(msg, window.location.origin);
+ posted = true;
+ }
+ } catch {
+ /* nothing left to try */
+ }
+ }
+ clog("ai-bridge: reply", msg.type, posted ? "sent" : "DROPPED");
+}
+
+let installed = false;
+
+/** Install the window `message` listener once per page load. Idempotent —
+ * App.tsx mounts the widget once, but this guards against remounts. */
+export function installAiBridge(): void {
+ if (installed) return;
+ installed = true;
+ window.addEventListener("message", (evt: MessageEvent) => {
+ if (evt.origin !== window.location.origin) return;
+ const d: unknown = evt.data;
+ if (isGetBufferMessage(d)) {
+ let buffer: string | null = null;
+ try {
+ buffer = readActiveBuffer();
+ } catch (err) {
+ cwarn("ai-bridge: get-buffer failed", err);
+ }
+ replyToChat(evt.source, { type: "cmms-ai-editor-buffer", buffer });
+ } else if (isApplyMessage(d)) {
+ try {
+ applyToActiveBuffer(d.new_source);
+ } catch (err) {
+ cwarn("ai-bridge: apply failed", err);
+ }
+ }
+ });
+}