Compare commits

..
Author SHA1 Message Date
dc2b1b5b19 cmms-embed: ai-bridge apply — carry newly-placed symbols' (lib_symbols) defs
Some checks failed
wasm-build.yml / cmms-embed: ai-bridge apply — carry newly-placed symbols' (lib_symbols) defs (push) Failing after 0s
wasm-build.yml / cmms-embed: ai-bridge apply — carry newly-placed symbols' (lib_symbols) defs (release) Failing after 0s
release / meta (push) Has been cancelled
release / build (push) Has been cancelled
release / publish-wasm (push) Has been cancelled
release / deploy-demo (push) Has been cancelled
release / deploy-editor (push) Has been cancelled
release / Build all tools + KiCad e2e (Ubicloud) (push) Has been cancelled
The cmms-ai-editor-apply handler diffed the proposed sheet with docDelta and
pushed the item delta through applyDeltaToY. docDelta only diffs uuid-bearing
items; a (lib_symbols ...) definition has no uuid, so an AI proposal that
places a part the sheet has never carried landed the instance with no library
definition — KiCad drew an instance pointing at an undefined symbol (nothing).

applyToActiveBuffer now upserts the proposal's lib defs into kdoc_libsymbols
(upsert by lib id, never deletes — the CMMS's kicad_splice_elements always
splices into the existing block, so the proposal is existing ∪ additions) before
the item delta, mirroring kicad-binding.ts's own DOWN path
(upsertLibSymbolsToY(doc, defs, ORIGIN)). Deliberately not syncLayoutToY, which
would also LWW-reconcile non-item layout heads (root uuid, sheet_instances,
paper) from the proposal.

New ai-bridge.test.ts covers it (fails without the upsert: the placed symbol's
definition never reaches kdoc_libsymbols).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 14:51:35 +10:00
213de59f94 cmms-embed: fix cmms-config validation rejecting the real server response
Some checks failed
wasm-build.yml / cmms-embed: fix cmms-config validation rejecting the real server response (push) Failing after 0s
wasm-build.yml / cmms-embed: fix cmms-config validation rejecting the real server response (release) Failing after 0s
release / meta (push) Has been cancelled
release / build (push) Has been cancelled
release / publish-wasm (push) Has been cancelled
release / deploy-demo (push) Has been cancelled
release / deploy-editor (push) Has been cancelled
release / Build all tools + KiCad e2e (Ubicloud) (push) Has been cancelled
Browser-verified bug (2026-09-05, reported via the prod-side session):
/api/cmms-config now returns 200 with aiEnabled:true, but the widget still
never mounted. Root cause: isCmmsConfig() required entityType/entityId as
strict strings, but the actual response omits them entirely - the whole
payload failed validation and silently fell back to NO_WIDGET, with nothing
logged to say why.

Fix: only dark/aiEnabled/aiEditorKind actually gate anything - validate
just those, and default csrfToken/entityType/entityId to "" when absent
(they only feed the iframe's query string, harmless empty). Also added a
.catch() + console.error on the widget's own boot effect, so a future
mistake here logs instead of silently no-op'ing.

Verified against the exact captured response body
({"dark":true,"aiEnabled":true,"aiEditorKind":"kicad","csrfToken":"..."}):
now resolves aiEnabled:true. tsc --noEmit clean, vitest 469/469 passing.

Also investigated the separately-reported "Uncaught Error: undefined
self-hosted:8:1" console error: built the pre-patch source
(0146488) and the patched source with an identical toolchain/base and
diffed the eager main chunk - same file list, same
wasm/collab/index.ts dynamic/static-import warning, ~4KB size delta
matching only this patch's own new code. yjs/Y.Doc was ALREADY present
in the pre-patch eager main chunk (grepped the unpatched build output
directly), so this patch did not newly pull collab/yjs code into eager
load - whatever's throwing predates this patch's chunk composition. Not
provable further without a live browser (not available in this repo's
env, see CLAUDE.md) - not a fix, just contrary evidence to "this patch's
widget code caused it" for the prod-side session to browser-verify.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 21:44:13 +10:00
bf1e7a512e cmms-embed: kicad AI chat editor-buffer bridge (PCBJam Part 2)
Some checks failed
wasm-build.yml / cmms-embed: kicad AI chat editor-buffer bridge (PCBJam Part 2) (push) Failing after 0s
wasm-build.yml / cmms-embed: kicad AI chat editor-buffer bridge (PCBJam Part 2) (release) Failing after 0s
release / meta (push) Has been cancelled
release / build (push) Has been cancelled
release / publish-wasm (push) Has been cancelled
release / deploy-demo (push) Has been cancelled
release / deploy-editor (push) Has been cancelled
release / Build all tools + KiCad e2e (Ubicloud) (push) Has been cancelled
web/standalone only, no C++/wasm/shared changes:

- lib/cmms-config.ts (new): fetch /api/cmms-config once on boot
  (dark, aiEnabled, aiEditorKind, csrfToken, entityType, entityId).
  Absent route / non-2xx / malformed JSON all mean "no widget" - never
  throws, never blocks boot. Same API base + credentials:"include" as
  the existing /api/me call.
- components/CmmsAiWidget.tsx (new): floating launcher + panel, inert
  unless aiEnabled. Mounted once in App.tsx alongside VersionBadge.
  Mirrors render_ai_assistant_editor_embed()'s markup/CSS shape
  (position, colors) but fixed-position - the draggable/resizable
  geometry system is CMMS-side static JS this bundle can't import.
  Follows `dark` from the config fetch, belt-and-braces alongside the
  existing `?theme=` hand-off (which already wins whenever set).
- wasm/collab/ai-bridge.ts (new): the cmms-ai-editor-* postMessage
  protocol (same shape as the cad-editor / pcb-retrace bridges).
  get-buffer: yToDoc + docToFile on the active sheet's live Y.Doc
  (read-only, reflects in-progress edits including a peer's - not
  last-saved-to-MEMFS). apply: docDelta + applyDeltaToY, treating the
  AI's proposed file as though a peer had made the edit, through the
  same item-observer path every real co-edit already takes - NOT a
  reuse of KicadBinding.seed(), which is a boot-time/reconnect-only
  function (three branches, each with its own empty/hollow-doc edge
  cases) that doesn't have a branch for "live populated doc gets
  replaced with unrelated content", and this subsystem's git history
  (sibling-restage, paste-collision, ysync 0001-0008) is almost
  entirely bugfixes in exactly that state-transition space.
- WasmTool.tsx: registerActiveCollabDoc(doc, filePath) mirrored onto
  each of the three existing setPanelDoc(...) call sites (single-room
  collabHandle, multi-sheet onActiveChange, teardown) - reuses the
  existing "which doc does the SheetPanel show right now" signal
  rather than adding new state.

Known gaps, flagged not silently skipped:
- docDelta only diffs items, not layout/preamble (page size, title
  block) - matches its own doc comment; an AI edit to those won't
  apply. Items (symbols/footprints/wires) are the expected AI edit
  surface for now.
- No client-side read-only-viewer gate on apply - a tab opened as a
  read-only viewer has no local-edit path, but this pushes the AI's
  delta into the Y.Doc the same as a remote peer would, which a
  viewer's binding was never checked against. Write authorization is
  expected to live server-side (the AI tool implementation deciding
  whether to call write_editor_buffer at all, matching the
  cad_generation_enabled gate on the cad-editor bridge) - worth an
  explicit look before this ships broadly.

Verified: tsc --noEmit clean, vitest run 469/469 passing, vite build
succeeds. No kicad/wxwidgets changes - the existing wasm/ build
(vendor-766e2f4-20260904-r4-debug) is unaffected; this is a dist-only
release.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 20:21:17 +10:00
014648807f cmms-embed: VITE_LIBS_SOURCE=static (not off) — install the window.kicadLibs bridge
Some checks failed
wasm-build.yml / cmms-embed: VITE_LIBS_SOURCE=static (not off) — install the window.kicadLibs bridge (push) Failing after 0s
wasm-build.yml / cmms-embed: VITE_LIBS_SOURCE=static (not off) — install the window.kicadLibs bridge (release) Failing after 0s
release / meta (push) Has been cancelled
release / build (push) Has been cancelled
release / publish-wasm (push) Has been cancelled
release / deploy-demo (push) Has been cancelled
release / deploy-editor (push) Has been cancelled
release / Build all tools + KiCad e2e (Ubicloud) (push) Has been cancelled
off leaves libsSource null, so boot.ts never calls installLibsProvider and
window.kicadLibs is never installed. A C++ libs-bridge call on schematic open
(park site K1, sch_io_pcbjam_lib) then hits undefined -> throws inside a
suspending import -> SuspendError -> abort. static installs the bridge (2
built-in example symbols; answers not-found gracefully otherwise), no backend.

Standalone-only change; dist/ rebuild, no wasm rebuild.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 10:13:09 +10:00
1014881551 cmms-embed: add SOURCE.txt (provenance + GPLv3 corresponding-source pointer)
Some checks failed
wasm-build.yml / cmms-embed: add SOURCE.txt (provenance + GPLv3 corresponding-source pointer) (push) Failing after 0s
wasm-build.yml / cmms-embed: add SOURCE.txt (provenance + GPLv3 corresponding-source pointer) (release) Failing after 0s
release / meta (push) Has been cancelled
release / build (push) Has been cancelled
release / publish-wasm (push) Has been cancelled
release / deploy-demo (push) Has been cancelled
release / deploy-editor (push) Has been cancelled
release / Build all tools + KiCad e2e (Ubicloud) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 22:52:42 +10:00
84dd40702c cmms-embed: sub-path mount support (/vendor/pcbjam/)
Serve the standalone editor under a CMMS sub-path with no change to
@pcbjam/shared or the wasm/C++ side. No-op for a root deploy (BASE_URL='/').

- main.tsx: <BrowserRouter basename={import.meta.env.BASE_URL}>
- lib/base-path.ts (new): stripBase() for raw location.pathname reads,
  hardNav() for the cross-document window.location.assign() navs (the
  process-global wasm runtime means a tool switch / File->Quit is a full
  page load, and @pcbjam/shared's projectPath() is origin-absolute)
- config.ts currentScope(): stripBase() before splitting for the scope segment
- WasmTool.tsx quit-path: stripBase() for the projects/libs segment test
- tool-navigation.ts (x2), NewFileDialog.tsx, ProjectView.tsx, quit-hook.ts:
  window.location.assign(projectPath(...)) -> hardNav(...)
- .env.production: VITE_API_BASE_URL=/pcb-project-api.php, PROJECT_SOURCE=remote,
  DOC_SOURCE=api, LIBS_SOURCE=off, WASM_ROOT=/vendor/pcbjam/wasm,
  YJS_PROVIDER=broadcastchannel; APP_URL/USER/SCOPE unset

Build: vite build --base=/vendor/pcbjam/

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 22:49:31 +10:00
28c545ff6e build: host networking (no Docker bridge/NAT on the build host)
Some checks failed
wasm-build.yml / build: host networking (no Docker bridge/NAT on the build host) (push) Failing after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 19:02:02 +10:00
16 changed files with 704 additions and 8 deletions

81
SOURCE.txt Normal file
View file

@ -0,0 +1,81 @@
PCBJam — self-hosted fork (CMMS embed)
======================================
This repository is a self-hosted fork of PCBJam (browser-based KiCad compiled to
WebAssembly), maintained for embedding the editor in the tas-tech.net CMMS
(TT-DB) at /vendor/pcbjam/.
Upstream
--------
https://github.com/emergence-engineering/pcbjam
tracked here as git remote `upstream`.
Forked at upstream commit:
766e2f409fa8b098555226479742ec694709ea48 (branch main, 2026-09-01)
Branches
--------
main pristine mirror of upstream @ 766e2f4 (no local changes)
build/host-networking docker/docker-compose.yml: host networking on the build
host (its dockerd runs bridge:none + iptables:false for
a VPN killswitch, so the image build + compile container
use the host network namespace). 1 commit (28c545f).
cmms-embed build/host-networking + sub-path mount support so the
standalone editor serves under /vendor/pcbjam/ with no
change to KiCad/wxWidgets or @pcbjam/shared. See the
commit message for the file-by-file breakdown. A pure
no-op when built without `--base` (root deploy).
Submodules
----------
Pinned by the superproject gitlinks (nothing in scripts/ overrides them —
`KICAD_COMMIT` in scripts/common/versions.sh is unused/informational):
kicad 56678639eea1b3c0358f384fa47ef7fab78bd09b
upstream https://github.com/PCBJam/kicad-source-mirror (branch wasm-port)
wxwidgets 4e6cc5a441e46ac07b242caa3adacfd8e7c7ec50
upstream https://github.com/PCBJam/wxWidgets (branch wasm-port)
web/pcbjam-shared a4984c626d847bd07ce66148d6ec96fde8ee8e42
upstream https://github.com/PCBJam/pcbjam-shared (branch main)
.gitmodules still points at PCBJam's GitHub forks — these are NOT mirrored into
Forgejo. A checkout needs `git submodule update --init --recursive` against GitHub.
Toolchain / dependency pins (scripts/common/versions.sh)
-------------------------------------------------------
Emscripten SDK 6.0.6 (installed from emsdk inside docker/Dockerfile;
base image ubuntu:22.04)
KiCad 8.99
OpenCASCADE 7.8.0
wxWidgets 3.3.1
Boost 1.84.0
Protobuf 3.21.12
Python 3.11.5
ngspice 46
FreeType 2.13.2
HarfBuzz 8.3.0
Cairo 1.18.0
Pixman 0.42.2
Zstd 1.5.5
GLM 0.9.9.8
(every download in versions.sh also carries a SHA256 pin)
Build
-----
KiCad WASM: ./docker/build.sh kicad_editor -j 4 --build-deps (debug)
./docker/build.sh kicad_editor -j 4 --full --release (ship)
Editor UI: cd web && pnpm install --frozen-lockfile
pnpm --filter @pcbjam/standalone exec vite build --base=/vendor/pcbjam/
(env: web/standalone/.env.production on the cmms-embed branch)
License
-------
KiCad and wxWidgets are GPLv3. PCBJam's own code carries its upstream licenses
(see LICENSE and the per-package headers; @pcbjam/shared is MIT). This fork is
distributed under the same terms.
The corresponding source for any deployed WASM build IS this repository at the
commit the build was made from, together with the submodule revisions its
gitlinks pin (listed above). Repository:
https://repo.tas-tech.net/AI_Assistant/pcbjam

View file

@ -3,6 +3,9 @@ services:
build:
context: ..
dockerfile: docker/Dockerfile
# Build host runs dockerd with bridge:none + iptables:false (PIA killswitch,
# no Docker NAT). Image-build RUN steps (emsdk download) use the host netns.
network: host
args:
# Single source of truth: scripts/common/versions.sh, exported into the env by the scripts
# that drive compose (docker/build.sh, docker/shell.sh). No default -> the Dockerfile fails
@ -10,6 +13,10 @@ services:
EMSCRIPTEN_VERSION: ${EMSCRIPTEN_VERSION:?source scripts/common/versions.sh before docker compose}
# Container name is auto-generated with project prefix (set in build.sh)
# The compile container shares the host network namespace so it inherits the
# PIA killswitch directly — the build host's dockerd has no bridge/NAT.
network_mode: host
# Resource limits. Defaults are sized for a dev Mac (Docker Desktop VM).
# CI overrides via env: the 32-core Hetzner runner sets KICAD_DOCKER_CPUS=$(nproc)
# and KICAD_DOCKER_MEM=110G — with the 10-CPU default, run 27226030304 compiled

View file

@ -0,0 +1,33 @@
# CMMS embed (cmms-embed branch). Consumed by `vite build` (mode=production).
# Pair with `vite build --base=/vendor/pcbjam/`. All values are public (they ship
# in the bundle) — no secrets here.
# The PHP adapter. Relative so it resolves against the CMMS origin. The adapter
# handles PATH_INFO, so /api/scopes/... and /api/me land as
# /pcb-project-api.php/api/scopes/... and /pcb-project-api.php/api/me.
VITE_API_BASE_URL=/pcb-project-api.php
# Projects come from the REST adapter above; file bytes fetched + saves PUT back.
VITE_PROJECT_SOURCE=remote
VITE_DOC_SOURCE=api
# No library backend for v1. `static` (not `off`): `off` leaves `libsSource` null so boot.ts
# never calls installLibsProvider → `window.kicadLibs` is never installed → any C++ path that
# resolves a symbol/footprint through the libs bridge on schematic open (park site K1,
# sch_io_pcbjam_lib) calls a method on undefined → throws inside a suspending import →
# SuspendError → abort. `static` installs the bridge backed by 2 built-in example symbols; it
# answers "not found" gracefully for everything else. No backend, no manifest, self-contained.
VITE_LIBS_SOURCE=static
# Flat WASM layout, served static by Apache under the mount (no per-tool subdir,
# VITE_WASM_MANIFEST intentionally unset). Files: kicad_editor.{js,wasm}, wx.js,
# wx-dom.js, images.tar.gz.
VITE_WASM_ROOT=/vendor/pcbjam/wasm
# Cross-tab only; no collab server.
VITE_YJS_PROVIDER=broadcastchannel
# VITE_APP_URL deliberately UNSET — with it set, every non-editor route would
# redirect away from the mount (lib/redirect.ts).
# VITE_USER / VITE_SCOPE deliberately UNSET — identity comes from
# /pcb-project-api.php/api/me, scope from the URL path segment after the mount.

View file

@ -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 && <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

@ -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<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

@ -7,6 +7,7 @@ import {
} from "@pcbjam/shared";
import { Loader2 } from "lucide-react";
import { uploadFileBytes } from "@/lib/api";
import { hardNav } from "@/lib/base-path";
import { currentScope } from "@/lib/config";
import { localProjectStore } from "@/lib/project-source";
import { useLocalProjects } from "@/lib/api";
@ -124,7 +125,7 @@ export function NewFileDialog({
// A home-created project is browser-local (@local scope); an in-project new
// file keeps the current scope. The tool is inferred from the file's ext.
const scope = homeMode ? LOCAL_SCOPE : currentScope();
window.location.assign(projectPath(scope, slug, finalName));
hardNav(projectPath(scope, slug, finalName));
} catch (e) {
setBusy(false);
setError(e instanceof Error ? e.message : String(e));

View file

@ -20,6 +20,7 @@ import {
yjsProviderConfig,
type DocSource,
} from "@/lib/config";
import { stripBase } from "@/lib/base-path";
import { redirectTargetFor } from "@/lib/redirect";
import { loadSessionIdentity, seedSessionIdentity } from "@/lib/session-identity";
import { useThemeValue } from "@/lib/theme";
@ -46,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,
@ -419,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();
@ -629,7 +632,10 @@ export function WasmTool({
// non-editor surfaces: on a backed deploy (APP_URL set) they belong to the
// management app, so quit goes straight there (one hop instead of letting
// App.tsx's 0006 redirect bounce it).
const segments = win.location.pathname.split("/").filter(Boolean);
// stripBase: segments are relative to the mount, so segments[1] is
// "projects"/"libs" even under /vendor/pcbjam/. exitPath stays app-absolute
// (no mount prefix) — installQuitHook's hardNav() re-adds it.
const segments = stripBase(win.location.pathname).split("/").filter(Boolean);
const exitPath =
segments[1] === "libs" ? "/" : projectPath(currentScope(), slug);
const removeQuitHook = installQuitHook(win, {
@ -1215,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,
@ -1269,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

View file

@ -6,6 +6,8 @@
// the page itself unloads (app.cpp UnloadCallback), so the dispatcher latches
// off as soon as any unload/navigation is under way.
import { hardNav } from "@/lib/base-path";
let activeQuitHook: (() => void) | undefined;
let quitHandled = false;
@ -93,7 +95,9 @@ export function installQuitHook(
// wasm stack has unwound.
setTimeout(() => {
opts.log(`[quit] editor closed — going to ${opts.exitUrl}`);
win.location.assign(opts.exitUrl);
// hardNav: re-add the CMMS mount prefix to the app-absolute exit path
// (a full APP_URL redirect target passes through untouched).
hardNav(opts.exitUrl, win);
}, 0);
};

View file

@ -7,6 +7,7 @@ import {
toolSchema,
type Tool,
} from "@pcbjam/shared";
import { hardNav } from "@/lib/base-path";
import { currentScope } from "@/lib/config";
import { defaultFileName, newFileTemplate, withExtension } from "@/lib/new-file";
import { memfsProjectDir } from "@/wasm/constants";
@ -175,7 +176,7 @@ export function installToolNavigationHook(
await createFile(relPath, bytes);
opts.log(`[nav] created missing ${nextTool} file ${relPath} -> ${url}`);
markDeliberateNavigation();
win.location.assign(url);
hardNav(url, win);
} catch (e) {
pendingCreate = null;
opts.log(
@ -196,7 +197,7 @@ export function installToolNavigationHook(
opts.log(`[nav] ${rawToolName} ${rawFileName || "(no file)"} -> ${url}`);
markDeliberateNavigation();
win.location.assign(url);
hardNav(url, win);
return true;
};

View file

@ -0,0 +1,57 @@
/**
* Sub-path mount support (cmms-embed fork).
*
* The CMMS serves this SPA under `/vendor/pcbjam/`, built with
* `vite build --base=/vendor/pcbjam/` — so `import.meta.env.BASE_URL` is that
* prefix (always a leading + trailing slash; "/" for a root deploy).
*
* `<BrowserRouter basename={import.meta.env.BASE_URL}>` already makes
* `<Link>` / `navigate()` / `useLocation()` mount-relative. This module covers
* the two things the router can't:
* 1. code that reads `window.location.pathname` raw (currentScope, WasmTool's
* quit-path), and
* 2. the hard cross-document `window.location.assign()` navigations — the
* WASM runtime is process-global, so a tool switch / File→Quit / new file
* is a full page load, not a router transition, and the app paths handed
* to it (`@pcbjam/shared` `projectPath()` etc.) are origin-absolute.
*
* Nothing here touches `@pcbjam/shared` — it also compiles for the backend,
* where `import.meta` does not exist.
*/
/** `import.meta.env.BASE_URL` without its trailing slash ("" for a root deploy). */
export const BASE_PREFIX = import.meta.env.BASE_URL.replace(/\/$/, "");
/**
* `pathname` with the mount prefix removed; the result always starts with "/".
* A pathname outside the mount (or a root deploy) is returned unchanged.
*/
export function stripBase(pathname: string): string {
if (
BASE_PREFIX &&
(pathname === BASE_PREFIX || pathname.startsWith(`${BASE_PREFIX}/`))
) {
const rest = pathname.slice(BASE_PREFIX.length);
return rest === "" ? "/" : rest;
}
return pathname;
}
/**
* `window.location.assign()` to an app-absolute path (e.g. from
* `projectPath()`), re-adding the mount prefix. A full URL (has a scheme or is
* protocol-relative) passes through untouched — so an `APP_URL` redirect target
* is never corrupted.
*
* `win` is injectable for the same reason the tool-navigation / quit hooks take
* a window: test seams and the wx-port's frame window.
*/
export function hardNav(
appPath: string,
win: { location: { assign(url: string): void } } = window,
): void {
const url = /^([a-z][a-z0-9+.-]*:)?\/\//i.test(appPath)
? appPath
: BASE_PREFIX + appPath;
win.location.assign(url);
}

View file

@ -0,0 +1,70 @@
// 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

@ -133,6 +133,7 @@ export const LOCAL_PROJECTS_ENABLED =
import.meta.env.VITE_LOCAL_PROJECTS === "idb";
import { colorForUser, type PresenceUser } from "@pcbjam/shared";
import { stripBase } from "@/lib/base-path";
import { sessionIdentity } from "@/lib/session-identity";
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
import { cdnLibsSource } from "@/wasm/libs/cdn-source";
@ -282,7 +283,11 @@ export const PRESENCE_TUNER_ENABLED = import.meta.env.VITE_PRESENCE_TUNER === "1
*/
export function currentScope(): string {
if (typeof window !== "undefined") {
const seg = window.location.pathname.split("/").filter(Boolean)[0];
// stripBase: under the CMMS sub-path mount the scope is the first segment
// AFTER /vendor/pcbjam/, not "vendor". No-op for a root deploy.
const seg = stripBase(window.location.pathname)
.split("/")
.filter(Boolean)[0];
if (seg && seg !== "projects" && seg !== "libs") {
return decodeURIComponent(seg);
}

View file

@ -38,7 +38,10 @@ const queryClient = new QueryClient({
// instantiate exactly once per navigation.
ReactDOM.createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
{/* basename = the vite `base` (import.meta.env.BASE_URL) so the CMMS
sub-path mount (/vendor/pcbjam/) is transparent to <Link>/navigate();
the hard location.assign() navs use hardNav() (lib/base-path.ts). */}
<BrowserRouter basename={import.meta.env.BASE_URL}>
<App />
</BrowserRouter>
</QueryClientProvider>,

View file

@ -17,6 +17,7 @@ import {
Trash2,
} from "lucide-react";
import { fetchFileBytes, useProject, useSourceDescriptor } from "@/lib/api";
import { hardNav } from "@/lib/base-path";
import { downloadBytes } from "@/lib/download";
import { localProjectStore } from "@/lib/project-source";
import { zipFiles } from "@/lib/zip";
@ -108,7 +109,7 @@ export function ProjectView() {
// scoped with a full reload; document tools create a templated file first.
const launchTool = (tool: Tool) => {
if (FILELESS_TOOLS.has(tool)) {
window.location.assign(projectToolPath(scope, slug, tool));
hardNav(projectToolPath(scope, slug, tool));
} else {
setNewFileTool(tool);
}

View file

@ -0,0 +1,104 @@
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

@ -0,0 +1,178 @@
// 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);
}
}
});
}