← Commit history

Publish 1.0.99

John Lauer ·b7f4c78440 ·1mo ago ·parent c953a60
4 files changed +73−22
dashboard/kicad_dashboard.py+31−17
@@ -49,7 +49,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import unquote, urlparse -VERSION = "4.5.0"+VERSION = "4.6.0" AI_THREAD = "kicad-dashboard" DEFAULT_PORT = 8931 UI_DIR = Path(__file__).resolve().parent / "ui"@@ -126,6 +126,35 @@ def _ver_tuple(v):     return tuple(out[:3]) or (0,)  +def _proxy_control(handler, path, body=None, timeout=20):+    """Forward one control-API call VERBATIM: same status, same bytes.++    Both halves matter. Re-serialising the JSON (json.loads then json.dumps)+    changes key order and whitespace, and hardcoding 200 hides an upstream 207+    or 404 from the caller - the module decides what to do from the status and+    the body it actually gets, so a proxy that "helpfully" normalises either is+    lying to it. Non-2xx arrives as HTTPError and is forwarded, not swallowed.+    (Proven with assets/tools/proxy-fidelity.py, which reds a re-serialising+    proxy in its own self-test before it judges yours.)"""+    import urllib.request, urllib.error+    base = _hydrogen_control_url()+    if not base:+        return handler._json({"ok": False, "why": "no control api on this machine"}, 503)+    req = urllib.request.Request(+        base + path, method="POST" if body is not None else "GET",+        data=json.dumps(body).encode("utf-8") if body is not None else None,+        headers={"Content-Type": "application/json"} if body is not None else {})+    try:+        with urllib.request.urlopen(req, timeout=timeout) as r:+            return handler._send(r.status, r.headers.get("Content-Type", "application/json"),+                                 r.read())+    except urllib.error.HTTPError as e:+        return handler._send(e.code, e.headers.get("Content-Type", "application/json"),+                             e.read())+    except Exception as e:+        return handler._json({"ok": False, "why": f"{e.__class__.__name__}: {e}"}, 502)++ def say_send(phrase):     """Send-to-AI (un-parked 2026-08-24 on John's ruling, after the ah build     thread certified the run-prompt crash family dead on the gate-fixed build).@@ -1253,22 +1282,7 @@ class Handler(BaseHTTPRequestHandler):             log(f"viewport {LAST_VIEWPORT.get('innerWidth')}px cols={LAST_VIEWPORT.get('panelCols')}")             return self._json({"ok": True})         if path.endswith("/ai-threads/run-prompt"):-            # Proxy for the shared module's send path. The module owns the-            # contract (one call, new:true, delivered_to / inject three-state);-            # we only carry the bytes to the dynamic control port.-            base = _hydrogen_control_url()-            if not base:-                return self._json({"ok": False, "why": "no AI panel on this machine"}, 503)-            try:-                import urllib.request-                req = urllib.request.Request(-                    base + "/ai-threads/run-prompt",-                    data=json.dumps(body or {}).encode("utf-8"), method="POST",-                    headers={"Content-Type": "application/json"})-                with urllib.request.urlopen(req, timeout=125) as r:-                    return self._send(200, "application/json", r.read())-            except Exception as e:-                return self._json({"ok": False, "why": f"{e.__class__.__name__}: {e}"}, 502)+            return _proxy_control(self, "/ai-threads/run-prompt", body=body or {}, timeout=125)         if path.endswith("/api/say-send"):             phrase = str((body or {}).get("phrase") or "").strip()             if not phrase:
dashboard/ui/newbie-panel.js+40−3
@@ -139,6 +139,35 @@     return safe.replace(re, '<b>$1</b>');   } +  /* ---- copy, with a fallback that does not need the async clipboard ------+   * navigator.clipboard.writeText needs a secure context AND, in several embedded webviews,+   * transient user activation plus a clipboard-write permission that is simply absent. In+   * ah's webview it rejects with NotAllowedError, so the ONLY path the panel had would fail+   * and the user got "Could not reach the clipboard" on a button that looks like it works.+   * The execCommand path is deprecated but synchronous, needs no permission, and works in+   * exactly the contexts the async API refuses. Try the modern one, fall back, and only then+   * report failure. */+  function copyText(text) {+    return new Promise(function (resolve, reject) {+      function legacy() {+        try {+          var ta = document.createElement('textarea');+          ta.value = text;+          ta.setAttribute('readonly', '');+          ta.style.cssText = 'position:fixed;top:0;left:-9999px;opacity:0';+          document.body.appendChild(ta);+          ta.select(); ta.setSelectionRange(0, ta.value.length);+          var ok = document.execCommand && document.execCommand('copy');+          ta.remove();+          ok ? resolve('legacy') : reject(new Error('execCommand refused'));+        } catch (e) { reject(e); }+      }+      if (navigator.clipboard && navigator.clipboard.writeText) {+        navigator.clipboard.writeText(text).then(function () { resolve('async'); }, legacy);+      } else legacy();+    });+  }+   /* ---- toasts ---------------------------------------------------------- */   var toastHost = null;   function toast(msg, kind, ms) {@@ -275,7 +304,7 @@       tipFor(cp, 'Copy this phrase');       cp.addEventListener('click', function (e) {         e.stopPropagation();-        (navigator.clipboard ? navigator.clipboard.writeText(phrase) : Promise.reject())+        copyText(phrase)           .then(function () { toast('Copied. Paste it to your AI.', 'ok'); })           .catch(function () { toast('Could not reach the clipboard. Select the phrase and copy it.', 'err'); });       });@@ -450,8 +479,16 @@      * adopted the pattern I had recommended; parts-search found the documentElement one.) */     var sc = panel.parentElement;     while (sc && sc !== document.body) {+      /* A DECLARED scroller is the pane whether or not it is scrolling RIGHT NOW. Requiring+       * it to overflow was a bug with a nastier shape than the two before it: with+       * scrollbar-gutter:stable (which this pack mandates, so results arriving cause no+       * reflow) the track is reserved even when nothing overflows, so the loop walked PAST+       * the real pane, fell back to documentElement, and reported the right inset over by+       * one scrollbar width. The SAME PAGE passed when populated and failed when empty, and+       * empty is a dashboard's first-load state. Predicted from a code read by parts-search+       * and confirmed here: 16 left / 31 right, exact. */       var so = getComputedStyle(sc).overflowY;-      if ((so === 'auto' || so === 'scroll') && sc.scrollHeight > sc.clientHeight) break;+      if (so === 'auto' || so === 'scroll') break;       sc = sc.parentElement;     }     var paneLeft, paneW;@@ -477,6 +514,6 @@     return { ok: ok, rows: rows };   } -  root.AdomNewbiePanel = { mount: mount, toast: toast, selfCheck: selfCheck, version: '2.5.0',+  root.AdomNewbiePanel = { mount: mount, toast: toast, selfCheck: selfCheck, copyText: copyText, version: '2.8.0',                            TWO_COL_MIN: TWO_COL_MIN, PHRASE_BUDGET: PHRASE_BUDGET }; })(typeof window !== 'undefined' ? window : this);
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "kicad-bridge",-  "version": "1.0.98",+  "version": "1.0.99",   "type": "app",   "description": "Skills for your container so your AI knows how to drive the KiCad bridge. The bridge runtime itself is the release zip; Adom Bridge loads that.",   "tags": [
page.json+1−1
@@ -4,7 +4,7 @@   "slug": "kicad-bridge",   "title": "KiCad - the KiCad Bridge",   "brief": "Reference implementation of the KiCad bridge — multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.",-  "version": "1.0.98",+  "version": "1.0.99",   "tags": [     "kicad",     "pcb",