app
Parts Search
Public Made by Adomby adom
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.
← Commit history
v1.3.2: Send to AI un-parked with the correct one-call contract; no layout cap
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"- } ]-}+}
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" }
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;