← Commit history

v1.3.2: Send to AI un-parked with the correct one-call contract; no layout cap

John Lauer ·a853b0473e ·1mo ago ·parent cbfdf76
8 files changed +79−126
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-parts-search"-version = "1.3.1"+version = "1.3.2" dependencies = [  "base64",  "clap",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-parts-search"-version = "1.3.1"+version = "1.3.2" edition = "2021" description = "Parts Search Electronics component search — CLI, backend HTTP service, and Hydrogen webview app" license = "MIT"
VERSION+1−1
@@ -1 +1 @@-1.3.1+1.3.2
package.json+1−40
@@ -38,44 +38,5 @@     "SKILL.md",     "BUILD-SKILL.md",     "CHANGELOG.md"-  ],-  "changelog": "Send to AI un-parked with the correct one-call contract; no layout cap",-  "discovery_triggers": [-    "parts search",-    "find part",-    "source component",-    "source part",-    "compare distributors",-    "compare vendors",-    "Mouser or DigiKey",-    "BOM sourcing",-    "price this part",-    "where can I get",-    "check stock",-    "parts availability",-    "fastest delivery",-    "cheapest part",-    "component search",-    "part lookup",-    "price breaks"-  ],-  "discovery_pitch": "Search Mouser, DigiKey and JLCPCB in parallel with product photos; Mouser-preferred best-source pick. Say 'with parts search' to your AI.",-  "sample_prompts": [-    {-      "label": "source an STM32F103RBT6",-      "prompt": "source an STM32F103RBT6"-    },-    {-      "label": "where can I get an ESP32-S3-WROOM cheape",-      "prompt": "where can I get an ESP32-S3-WROOM cheapest and fastest"-    },-    {-      "label": "compare Mouser, DigiKey and JLCPCB for a",-      "prompt": "compare Mouser, DigiKey and JLCPCB for an LM555"-    },-    {-      "label": "price this BOM across distributors",-      "prompt": "price this BOM across distributors"-    }   ]-}\ No newline at end of file+}
page.json+8−49
@@ -33,25 +33,13 @@   },   "hero": {     "type": "image",-    "path": "screenshots/hero.png"+    "path": "docs/hero.png"   },   "sample_prompts": [-    {-      "label": "source an STM32F103RBT6",-      "prompt": "source an STM32F103RBT6"-    },-    {-      "label": "where can I get an ESP32-S3-WROOM cheape",-      "prompt": "where can I get an ESP32-S3-WROOM cheapest and fastest"-    },-    {-      "label": "compare Mouser, DigiKey and JLCPCB for a",-      "prompt": "compare Mouser, DigiKey and JLCPCB for an LM555"-    },-    {-      "label": "price this BOM across distributors",-      "prompt": "price this BOM across distributors"-    }+    "source an STM32F103RBT6",+    "where can I get an ESP32-S3-WROOM cheapest and fastest",+    "compare Mouser, DigiKey and JLCPCB for an LM555",+    "price this BOM across distributors"   ],   "discovery_triggers": [     "parts search",@@ -66,41 +54,12 @@     "where can I get",     "check stock",     "parts availability",-    "fastest delivery",-    "cheapest part",-    "component search",-    "part lookup",-    "price breaks"+    "fastest delivery"   ],-  "discovery_pitch": "Search Mouser, DigiKey and JLCPCB in parallel with product photos; Mouser-preferred best-source pick. Say 'with parts search' to your AI.",+  "discovery_pitch": "Search Mouser + DigiKey + JLCPCB in parallel from one interface, with product photos and a Mouser-preferred best-source pick.",   "metadata": {     "platform": "linux"   },   "category": "tools",-  "org": "adom",-  "description": "One search box across Mouser, DigiKey and JLCPCB. Parallel queries, side-by-side product photos, and a Mouser-preferred recommendation that reasons around stock and lead time. Dock Bar ready: serve, health, settings, Say it.",-  "dependencies": {-    "adom/adom-mouser": "*",-    "adom/adom-digikey": "*",-    "adom/adom-jlcpcb": "*"-  },-  "needs_sudo": false,-  "scripts": {-    "install": "./install.sh",-    "uninstall": "./uninstall.sh"-  },-  "files": [-    "bin/**",-    "skills/**",-    "docs/icon.svg",-    "dockbar.json",-    "install.sh",-    "uninstall.sh",-    "package.json",-    "README.md",-    "SKILL.md",-    "BUILD-SKILL.md",-    "CHANGELOG.md"-  ],-  "changelog": "Send to AI un-parked with the correct one-call contract; no layout cap"+  "org": "adom" }\ No newline at end of file
publish.sh+7−2
@@ -141,8 +141,13 @@ CHECK_TGZ=$(mktemp) # The registry can serve a just-superseded listing for a few seconds after a # publish (seen on 1.1.3 and 1.2.0); fetch, and retry once before failing. fetch_tarball() { curl -fsSL -H "Authorization: Bearer $TOK" "$REG/api/packages/adom/adom-parts-search/$VERSION/tarball" -o "$CHECK_TGZ"; }-fetch_tarball-if ! tar tzf "$CHECK_TGZ" | grep -q "dockbar\.json"; then sleep 8; fetch_tarball; fi+# The registry can serve a just-superseded listing for a while after a publish+# (observed on 1.1.3, 1.2.0 and 1.3.2); poll up to ~60s before believing it.+for _try in 1 2 3 4 5 6; do+  fetch_tarball+  tar tzf "$CHECK_TGZ" | grep -q "dockbar\.json" && break+  sleep 10+done if tar tzf "$CHECK_TGZ" | grep -q "page\.json"; then rm -f "$CHECK_TGZ"; fail "published tarball still contains page.json"; fi if ! tar tzf "$CHECK_TGZ" | grep -q "dockbar\.json"; then rm -f "$CHECK_TGZ"; fail "published tarball is missing dockbar.json"; fi rm -f "$CHECK_TGZ"
src/app/mod.rs+45−26
@@ -228,44 +228,63 @@ fn cached_status(state: &Arc<AppState>) -> Value { // ---- "Try it": stage a phrase into this app's own AI thread (dock guide recipe) ---- #[allow(dead_code)] fn hget(path: &str) -> Result<Value, String> {-    let agent = ureq::AgentBuilder::new().timeout(Duration::from_millis(2500)).build();+    let agent = ureq::AgentBuilder::new().timeout(Duration::from_millis(8000)).build();     agent.get(&format!("{}{}", hydrogen_ctl(), path)).call().map_err(|e| e.to_string())?.into_json().map_err(|e| e.to_string()) }-fn hpost(path: &str, body: Value) -> Result<(u16, Value), String> {-    let agent = ureq::AgentBuilder::new().timeout(Duration::from_millis(4000)).build();+/// Opening an AI thread drives the editor and legitimately takes 10-30s on a+/// busy workbench (every CDP touch serializes through one gate), so the+/// AI-threads calls get 45s. A short client timeout manufactures phantom+/// failures out of sends that actually worked.+fn hpost_with(path: &str, body: Value, timeout_ms: u64) -> Result<(u16, Value), String> {+    let agent = ureq::AgentBuilder::new().timeout(Duration::from_millis(timeout_ms)).build();     match agent.post(&format!("{}{}", hydrogen_ctl(), path)).send_json(body) {         Ok(r) => { let code = r.status(); Ok((code, r.into_json().unwrap_or(Value::Null))) }         Err(ureq::Error::Status(code, r)) => Ok((code, r.into_json().unwrap_or(Value::Null))),         Err(e) => Err(e.to_string()),     } }-/// Send to AI (John's 2026-08-24 ruling): open a NEW AI thread via the-/// panel's AI-threads API and submit the phrase. New threads ONLY; resuming-/// or typing into existing threads stays parked pending platform-/// certification, so this never calls resume and never targets a census tid.-/// EMERGENCY PARK (dock thread, 2026-08-24): the run-prompt success path-/// crashed the ah dev shell during kicad's cold verify. While parked,-/// /api/say-send answers without touching the control API at all; the UI-/// keeps both buttons and lands on the copy fallback. Un-park by flipping-/// this to false once the main ah thread certifies the fix (the dock-/// thread broadcasts the all-clear).-const SAY_SEND_PARKED: bool = true; +/// The shell's RELEASE version, from /updater/status. Never /buildinfo's+/// build_sha: build.rs caches HD_BUILD_SHA, so a freshly compiled exe can+/// report an older commit (that lie cost a false conclusion on 2026-08-24).+fn shell_version() -> Option<(u32, u32, u32)> {+    let v = hget("/updater/status").ok()?;+    let raw = v.get("updater")?.get("current_version")?.as_str()?;+    let mut it = raw.split('.').map(|p| p.trim().parse::<u32>().unwrap_or(0));+    Some((it.next()?, it.next().unwrap_or(0), it.next().unwrap_or(0)))+}++/// 1.0.142 is the first release carrying the concurrency fix (33ac2074);+/// everything earlier, including the widely-installed 1.0.141, can crash the+/// user's whole shell on a send.+const MIN_SHELL: (u32, u32, u32) = (1, 0, 142);+/// Send to AI. ONE call: /ai-threads/run-prompt with `new: true` opens the+/// thread AND delivers the prompt. The old open-then-target flow could never+/// work, because /ai-threads/new returns no id (a thread has no sid until its+/// first submitted prompt), so `run-prompt {sid}` had nothing to target and+/// every send opened an empty tab. `name`/`tags` stage and attach when the sid+/// appears. Gated on the shell release version; on a pre-fix shell we refuse+/// honestly rather than risk the user's session. fn say_send(phrase: &str) -> Value {     if !say_phrases().iter().any(|p| p == phrase) { return json!({"ok": false, "why": "unknown phrase"}); }-    if SAY_SEND_PARKED {-        return json!({"ok": false, "parked": true, "why": "Send to AI is temporarily parked fleet-wide while a crash in the AI panel is fixed. Copied the phrase instead."});+    match shell_version() {+        Some(v) if v >= MIN_SHELL => {}+        Some((a, b, c)) => return json!({"ok": false, "why": format!("Send to AI needs Hydrogen 1.0.142 or newer (this shell is {}.{}.{}). Copied the phrase instead.", a, b, c)}),+        None => return json!({"ok": false, "why": "The AI panel is not reachable from this workspace. Copied the phrase instead."}),     }-    let created = match hpost("/ai-threads/new", json!({"name": "Parts Search - say it", "tags": [APP_TAG, "dock:adom-parts-search"]})) {-        Ok((code, v)) if (200..300).contains(&code) => v,-        Ok((code, v)) => return json!({"ok": false, "why": format!("the AI panel refused a new thread ({}): {}", code, v)}),-        Err(_) => return json!({"ok": false, "why": "The AI panel is not reachable from this workspace yet. Copied the phrase instead."}),-    };-    let sid = created.get("sid").or_else(|| created.get("id")).cloned().unwrap_or(Value::Null);-    if sid.is_null() { return json!({"ok": false, "why": "the AI panel returned no thread id"}); }-    match hpost("/ai-threads/run-prompt", json!({"sid": sid, "prompt": phrase, "submit": true})) {-        Ok((code, v)) if (200..300).contains(&code) && v.get("ok").and_then(|b| b.as_bool()) != Some(false) => json!({"ok": true, "sid": sid}),-        other => json!({"ok": false, "why": format!("thread created but the prompt was refused: {:?}", other.map(|(c, _)| c))}),+    let body = json!({+        "new": true,+        "prompt": phrase,+        "submit": true,+        "name": "Parts Search - say it",+        "tags": [APP_TAG, "dock:adom-parts-search"],+    });+    match hpost_with("/ai-threads/run-prompt", body, 45_000) {+        Ok((code, v)) if (200..300).contains(&code) && v.get("ok").and_then(|b| b.as_bool()) != Some(false) => {+            json!({"ok": true, "delivered_to": v.get("delivered_to").cloned().unwrap_or(Value::Null)})+        }+        Ok((code, v)) => json!({"ok": false, "why": format!("The AI panel refused the send ({}): {}", code, v.get("error").and_then(|e| e.as_str()).unwrap_or("no reason given"))}),+        Err(_) => json!({"ok": false, "why": "The AI panel did not answer in 45 seconds. Copied the phrase instead."}),     } } 
src/app/ui.html+15−6
@@ -76,10 +76,20 @@ button{font-family:inherit} .brand-pop dd a{font-family:var(--brand-body);font-size:13px}  /* ---- Main column ---- */-main{max-width:1320px;margin:0 auto;padding:16px 16px 40px}+/* Spacing ruling (John, 2026-08-24): the CONTENT REGION owns the page+   padding, not the panel, so the gap under the header is IDENTICAL whether the+   prompts panel is open or closed. Minimum 20px. Nothing above the first child+   may add a top margin, or closing the panel would change the gap. */+/* A dashboard is an APP SURFACE, not an article (adom-ui-design 1.17.0): no+   layout cap, so extra width becomes more room for the vendor columns at any+   size from a 700px webview pane to a maximised 2500px browser. Reading+   measures are capped on the PROSE blocks themselves (.say-sub, .card-desc),+   never on the layout around them. */+main{padding:20px 16px 40px}+main>:first-child{margin-top:0}  /* "Say it" strip: the first thing on the page (dock contract) */-.say{position:relative;background:var(--surface);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:12px;padding:12px 44px 12px 14px;margin-bottom:14px;animation:fadein .25s ease}+.say{margin-top:0;position:relative;background:var(--surface);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:12px;padding:12px 44px 12px 14px;margin-bottom:14px;animation:fadein .25s ease} .say[hidden]{display:none} .say-close{position:absolute;top:8px;right:8px;width:28px;height:28px;color:var(--text-2)} .say-close svg{width:16px;height:16px}@@ -101,12 +111,12 @@ main{max-width:1320px;margin:0 auto;padding:16px 16px 40px} .say-chip .send:hover{background:rgba(0,184,177,.12)} .say-chip .copy:active,.say-chip .send:active{transform:translateY(1px) scale(.96)} .say-chip .copy:focus-visible,.say-chip .send:focus-visible{outline:2px solid var(--accent);outline-offset:1px}-.say-folded{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:10px;padding:7px 14px;margin-bottom:12px;font:13px var(--brand-body);color:var(--text-2)}+.say-folded{margin-top:0;display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:10px;padding:7px 14px;margin-bottom:12px;font:13px var(--brand-body);color:var(--text-2)} .say-folded[hidden]{display:none} .sf-label b{color:var(--text);font:600 13px var(--brand-heading)} .sf-link{background:none;border:none;color:var(--accent);font:500 12.5px var(--brand-body);cursor:pointer;padding:4px 6px;border-radius:6px;transition:background .15s ease} .sf-link:hover{background:var(--elevated)}-.leds{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}+.leds{margin-top:0;display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px} .led-pill{display:inline-flex;align-items:center;gap:7px;background:var(--surface);border:1px solid var(--border);border-radius:999px;padding:4px 12px;font:12.5px var(--brand-body);color:var(--text-body);cursor:pointer;transition:border-color .15s ease,background .15s ease,transform .06s ease} .led-pill:hover{border-color:#4b5563;background:var(--elevated)} .led-pill:active{transform:translateY(1px) scale(.98)}@@ -370,7 +380,7 @@ select.sel:disabled{opacity:.5;cursor:not-allowed} @media (max-width:720px){   .brand .sub,.meta,.vsep{display:none}   .app-header{padding:0 10px;gap:8px}-  main{padding:12px 10px 40px}+  main{padding:20px 10px 40px}   .searchbar{flex-wrap:wrap;padding:8px}   .searchbar input{flex:1 1 100%;order:1}   .sb-controls{order:2;flex:1 1 100%;justify-content:space-between;flex-wrap:wrap;gap:6px}@@ -1004,7 +1014,6 @@ $('#segLimit').addEventListener('click',e=>{const b=e.target.closest('button[dat function bindToggle(id,key,label){$(id).addEventListener('click',()=>{const prev={[key]:settings[key]};saveSettings({[key]:!settings[key]},prev).then(ok=>{if(ok){toast(label+(settings[key]?' on':' off'),'success');if(key==='inStockOnly'){inStock=settings.inStockOnly;paintInStock();}if(key==='showImages'&&lastResults)renderResults(lastResults);if(key.startsWith('vendor'))paintVendorStatus(true);}});});} bindToggle('#tglInStock','inStockOnly','In stock only by default');bindToggle('#tglMouser','vendorMouser','Mouser');bindToggle('#tglDigikey','vendorDigikey','DigiKey');bindToggle('#tglJlcpcb','vendorJlcpcb','JLCPCB'); bindToggle('#tglImages','showImages','Product photos');bindToggle('#tglTaskbar','taskbarProgress','Taskbar progress bar');bindToggle('#tglSay','showSayStrip','Say it strip');-$('#sayClose').addEventListener('click',()=>{const prev={showSayStrip:true};saveSettings({showSayStrip:false},prev).then(ok=>{if(ok)toast('Say it strip hidden. Bring it back any time in Settings.','success',{label:'Undo',run:()=>saveSettings({showSayStrip:true},{showSayStrip:false})});});}); $('#policySet').addEventListener('change',e=>{const v=e.target.value;if(!v||v===settings.policy)return;const prev={policy:settings.policy};saveSettings({policy:v},prev).then(ok=>{if(ok){toast('Policy: '+(v==='cheapest'?'cheapest in stock':'Mouser preferred')+'. Run the search again to re-rank.','success');if(lastResults)renderRecommendation(lastResults.recommendation,lastResults.empty_state,lastResults.policy);}});}); $('#selBox').addEventListener('change',e=>{const prev={taskbarBox:settings.taskbarBox};saveSettings({taskbarBox:e.target.value},prev).then(ok=>{if(ok)toast('Taskbar bar paints on '+(settings.taskbarBox||'the first connected box'),'success');});}); (function(){const btn=$('#btnRestore'),lbl=$('#restoreLabel'),ic=$('#restoreIcon');let armed=false,t=null;