Compare commits

..
6 changed files with 0 additions and 499 deletions

View file

@ -1,6 +1,5 @@
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";
@ -35,9 +34,6 @@ 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 && <VersionBadge />}
{/* No-op (fetches config, renders nothing) unless the CMMS embed's
ai_assistant module is on for this tenant — see CmmsAiWidget. */}
<CmmsAiWidget />
</>
);
}

View file

@ -1,139 +0,0 @@
// 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<CmmsConfig | null>(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 (
<div id="cmms-ai-widget">
<button
type="button"
aria-label="AI Assistant"
title="AI Assistant"
onClick={() => setOpen((v) => !v)}
style={{
position: "fixed",
right: 16,
bottom: 16,
zIndex: 2147483000,
width: 48,
height: 48,
borderRadius: "50%",
border: "1px solid rgba(0,0,0,0.15)",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#ffffff",
color: "#1e293b",
cursor: "pointer",
padding: 0,
boxShadow: "0 2px 12px rgba(0,0,0,0.3)",
}}
>
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path d="M4 4h16v12H8l-4 4V4z" strokeLinejoin="round" strokeLinecap="round" />
</svg>
</button>
{open && (
<div
style={{
position: "fixed",
right: 16,
bottom: 76,
zIndex: 2147483000,
width: 380,
height: 560,
border: "1px solid rgba(0,0,0,0.15)",
borderRadius: 8,
overflow: "hidden",
boxShadow: "0 6px 32px rgba(0,0,0,0.35)",
background: "#ffffff",
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
flex: "none",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "6px 10px",
background: "#f1f5f9",
color: "#1e293b",
font: "600 13px system-ui, sans-serif",
}}
>
<span>AI Assistant</span>
<button
type="button"
aria-label="Close"
title="Close"
onClick={() => setOpen(false)}
style={{ background: "none", border: "none", color: "inherit", cursor: "pointer", fontSize: 16, padding: "2px 6px" }}
>
&times;
</button>
</div>
<iframe title="AI Assistant" src={src} style={{ flex: 1, width: "100%", border: 0, display: "block", minHeight: 0 }} />
</div>
)}
</div>
);
}

View file

@ -47,7 +47,6 @@ 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,
@ -421,7 +420,6 @@ export function WasmTool({
localSelectionRef.current?.destroy();
localSelectionRef.current = null;
setPanelDoc(null);
registerActiveCollabDoc(null);
commentsRef.current?.destroy();
commentsRef.current = null;
followRef.current?.destroy();
@ -1221,7 +1219,6 @@ 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,
@ -1276,7 +1273,6 @@ 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

View file

@ -1,70 +0,0 @@
// 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;
}
// 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<CmmsConfig, "dark" | "aiEnabled" | "aiEditorKind"> {
if (!v || typeof v !== "object") return false;
const c = v as Record<string, unknown>;
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 = {
dark: false,
aiEnabled: false,
aiEditorKind: "kicad",
csrfToken: "",
entityType: "",
entityId: "",
};
let cached: Promise<CmmsConfig> | 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<CmmsConfig> {
if (!cached) {
cached = fetch(`${API_BASE_URL}/api/cmms-config`, { credentials: "include" })
.then((r) => (r.ok ? r.json() : null))
.then((v): CmmsConfig => {
if (!isCmmsConfigCore(v)) return NO_WIDGET;
const c = v as Record<string, unknown>;
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;
}

View file

@ -1,104 +0,0 @@
import { describe, expect, it } from "vitest";
import * as Y from "yjs";
import { docToFile, docToY, kicadLibSymbolsMap, fileToDoc, yToDoc } from "@pcbjam/shared";
import { applyToActiveBuffer, readActiveBuffer, registerActiveCollabDoc } from "./ai-bridge";
// A symbol instance is only renderable with its `(lib_symbols …)` definition.
// `docDelta` (the apply path's item differ) only diffs uuid-bearing items, and a
// `(symbol "Device:R" …)` lib def has no uuid — so an AI proposal that places a
// part the sheet has never carried used to land the instance with no definition,
// and KiCad drew nothing. `applyToActiveBuffer` now upserts the proposal's lib
// defs into `kdoc_libsymbols` alongside the item delta.
const WIRE1 = "22222222-0000-0000-0000-000000000001";
const SYM1 = "44444444-0000-0000-0000-000000000001";
const SCH_WITHOUT_SYMBOL = `(kicad_sch
\t(version 20250114)
\t(generator "eeschema")
\t(generator_version "9.0")
\t(uuid "11111111-1111-1111-1111-111111111111")
\t(paper "A4")
\t(lib_symbols)
\t(wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "${WIRE1}"))
\t(sheet_instances (path "/" (page "1")))
)
`;
const SCH_WITH_SYMBOL = `(kicad_sch
\t(version 20250114)
\t(generator "eeschema")
\t(generator_version "9.0")
\t(uuid "11111111-1111-1111-1111-111111111111")
\t(paper "A4")
\t(lib_symbols
\t\t(symbol "Device:R" (pin_numbers (hide yes)) (pin_names (offset 0)) (exclude_from_sim no) (in_bom yes) (on_board yes)
\t\t\t(property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27))))
\t\t\t(property "Value" "R" (at 0 0 90) (effects (font (size 1.27 1.27))))
\t\t\t(symbol "R_0_1"
\t\t\t\t(rectangle (start -1.016 -2.54) (end 1.016 2.54) (stroke (width 0.254) (type default)) (fill (type none)))
\t\t\t)
\t\t\t(symbol "R_1_1"
\t\t\t\t(pin passive line (at 0 3.81 270) (length 1.27) (name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
\t\t\t\t(pin passive line (at 0 -3.81 90) (length 1.27) (name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))
\t\t\t)
\t\t)
\t)
\t(wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "${WIRE1}"))
\t(symbol (lib_id "Device:R") (at 63.5 63.5 0) (unit 1) (exclude_from_sim no) (in_bom yes) (on_board yes) (dnp no)
\t\t(uuid "${SYM1}")
\t\t(property "Reference" "R1" (at 66.04 62.23 0) (effects (font (size 1.27 1.27)) (justify left)))
\t\t(property "Value" "10k" (at 66.04 64.77 0) (effects (font (size 1.27 1.27)) (justify left)))
\t\t(pin "1" (uuid "44444444-0000-0000-0000-0000000000a1"))
\t\t(pin "2" (uuid "44444444-0000-0000-0000-0000000000a2"))
\t\t(instances (project "rt" (path "/11111111-1111-1111-1111-111111111111" (reference "R1") (unit 1))))
\t)
\t(sheet_instances (path "/" (page "1")))
)
`;
function seed(src: string): Y.Doc {
const ydoc = new Y.Doc();
docToY(fileToDoc(src), ydoc);
return ydoc;
}
describe("ai-bridge applyToActiveBuffer", () => {
it("carries a newly-placed symbol's (lib_symbols) definition, not just the instance", () => {
const ydoc = seed(SCH_WITHOUT_SYMBOL);
registerActiveCollabDoc(ydoc, "board.kicad_sch");
try {
expect(kicadLibSymbolsMap(ydoc).get("Device:R")).toBeUndefined();
applyToActiveBuffer(SCH_WITH_SYMBOL);
const def = kicadLibSymbolsMap(ydoc).get("Device:R");
expect(def).toBeDefined();
expect(def).toContain("R_0_1");
// and the instance item itself landed via the normal item delta
const out = docToFile(yToDoc(ydoc));
expect(out).toContain(SYM1);
expect(out).toContain('(lib_id "Device:R")');
} finally {
registerActiveCollabDoc(null);
}
});
it("is a no-op on lib_symbols when the proposal adds no new definitions", () => {
const ydoc = seed(SCH_WITH_SYMBOL);
registerActiveCollabDoc(ydoc, "board.kicad_sch");
try {
const before = kicadLibSymbolsMap(ydoc).get("Device:R");
expect(before).toBeDefined();
// same doc, wire nudged — no new parts
applyToActiveBuffer(SCH_WITH_SYMBOL.replace("101.6 50.8", "127 50.8"));
expect(kicadLibSymbolsMap(ydoc).get("Device:R")).toBe(before);
expect(readActiveBuffer()).toContain("127 50.8");
} finally {
registerActiveCollabDoc(null);
}
});
});

View file

@ -1,178 +0,0 @@
// 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,
libSymbolsFromLayout,
upsertLibSymbolsToY,
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);
// `docDelta` only diffs uuid-bearing items — a `(lib_symbols …)` def has no
// uuid, so a proposal that places a symbol the sheet has never carried adds
// the instance item but drops its library definition, leaving KiCad an
// instance pointing at an undefined symbol (renders nothing). Push the
// proposal's lib defs into the kdoc_libsymbols map FIRST (upsert by lib id,
// never deletes — the server always splices into the existing block, so this
// is existing ∪ additions), so the items observer's `deltaToItemsWire` can
// prefix each new instance with its `(lib_symbols …)` on the way to the
// editor — exactly what `kicad-binding.ts`'s own DOWN path does with
// `upsertLibSymbolsToY(doc, defs, ORIGIN)`. Deliberately NOT `syncLayoutToY`:
// that also reconciles non-item layout heads (root `uuid`, `sheet_instances`,
// `paper`…) wholesale-LWW from the proposal, which would corrupt the sheet's
// identity if the server's spliced preamble deviates at all.
upsertLibSymbolsToY(activeDoc, libSymbolsFromLayout(next.layout, next.items), APPLY_ORIGIN);
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<string, unknown>): 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<HTMLIFrameElement>("#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);
}
}
});
}