← Commit history
CHANGELOG.md+20
@@ -3,6 +3,26 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge · GitHub backup: `adom-inc/fusion-bridge` +## 1.9.76 - 2026-08-18 (ralph, dashboard)++- **"Fusion is opening right now" LED (John's ask).** A launch takes 30-90s and the board used to+  show a bare red dot the whole time, so the user could not tell whether anything was happening.+  New pulsing-amber `starting` state, marked SYNCHRONOUSLY when the launch is requested (so the+  very next status already says it, no red flash), with the sentence "Opening Fusion right now -+  launching in the background and waiting for its bridge add-in."+- **A failed launch explains itself** instead of silently reverting to red: the LED carries why+  (add-in never came up / the attempt did not take - seat busy elsewhere or a licensing dialog).+- **The tour finally SHOWS its screenshots.** The dashboard only counted them in a toast; it now+  renders a live gallery, streaming each step's capture as it completes via the bridge's+  stepShot + sinceShot contract (the same one built for ab), click to open full size.+- **Close Fusion auto-escalates and verifies.** When the graceful close is blocked ONLY by the+  bridge's own generated test docs (bridgeDocsOnly), it force-closes for the user, then confirms+  by readiness rather than trusting fusion_kill's flag (which reports failure on closes that+  actually worked).+- **Import part survives a busy add-in**: the open is retried for ~2.5 minutes when another+  session's long verb holds the main thread, instead of returning a rejection.+- **`force:true` now really forces** an auto-green run (the debounce was still swallowing it).+ ## 1.9.74 - 2026-08-18 (ralph, dashboard)  - **The board no longer lies while the bridge is busy.** Driving the guided tour from the UI
bin/fusion-dashboard
⋯ 1 unchanged line ⋯
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.74"+version = "1.9.76" edition = "2021"  [dependencies]
dashboard/src/main.rs+101−10
@@ -79,6 +79,10 @@ fn default_target() -> String {  /// The LED payload: one call per facet, each with state + an AI hint on how to act. fn gather_status(ai_thread: &str, target: &str) -> serde_json::Value {+    gather_status_ex(ai_thread, target, false)+}++fn gather_status_ex(ai_thread: &str, target: &str, launching: bool) -> serde_json::Value {     // PARALLEL, not sequential: each verb is a relay round-trip, and six of them in series took     // 36.7s against a remote box (measured live 2026-08-18) - the Dashboard sat blank that whole     // time. Fan them out and the wall clock is the slowest single call.@@ -124,7 +128,12 @@ fn gather_status(ai_thread: &str, target: &str) -> serde_json::Value {         "bridgeVersion": rd["bridgeVersion"],         "leds": {             "fusionRunning": {"on": running, "label": "Fusion running",-                "_hint": if running {"Fusion is up."} else {"fusion_start launches it in the BACKGROUND (never steals focus)."}},+                // A launch takes ~30-90s. Saying nothing during it (or showing red) leaves the+                // user wondering whether anything is happening at all - so say it plainly.+                "state": if running {"on"} else if launching {"starting"} else {"off"},+                "_hint": if running {"Fusion is up.".to_string()}+                         else if launching {"Opening Fusion right now - launching in the background and waiting for its bridge add-in. This usually takes under a minute.".to_string()}+                         else {"fusion_start launches it in the BACKGROUND (never steals focus).".to_string()}},             "addinReady": {"on": ready, "label": "Bridge add-in", "version": addin,                 // BUSY is not BROKEN (ralph, 2026-08-18): while a long verb runs - a tour, a 3D                 // build - readiness answers not-ready because the add-in's main thread is@@ -171,7 +180,25 @@ fn action(ai_thread: &str, target: &str, name: &str, args: &serde_json::Value) -     let a = args.to_string();     match name {         "start" => bridge(ai_thread, target, "fusion_start", "{}"),-        "stop" => bridge(ai_thread, target, "fusion_stop", "{\"reason\":\"dashboard: user clicked Close Fusion\"}"),+        "stop" => {+            let r = unwrap_data(&bridge(ai_thread, target, "fusion_stop",+                                        "{\"reason\":\"dashboard: user clicked Close Fusion\"}"));+            if r["success"].as_bool() == Some(true) { return r; }+            // DOCTRINE: when the ONLY things blocking the close are the bridge's own throwaway+            // test docs, escalate for the user instead of handing them a failure to resolve.+            if r["bridgeDocsOnly"].as_bool() == Some(true) {+                let _k = unwrap_data(&bridge(ai_thread, target, "fusion_kill", "{}"));+                // VERIFY the outcome, do not trust the verb's flag: fusion_kill reports+                // success:false in shapes where Fusion did in fact close (ralph, 2026-08-18).+                std::thread::sleep(Duration::from_secs(4));+                let gone = !unwrap_data(&bridge(ai_thread, target, "fusion_readiness", "{}"))["running"]+                    .as_bool().unwrap_or(false);+                return serde_json::json!({"success": gone,+                    "escalated": true, "gracefulError": r["error"].clone(),+                    "_hint": "The graceful close was blocked only by the bridge's own generated test documents, so it was force-closed for you - nothing of yours was at risk."});+            }+            r+        }         "signin" => bridge(ai_thread, target, "fusion_signin", &a),         "tour" => bridge(ai_thread, target, "fusion_demo", "{\"mode\":\"tour\"}"),         "tour_progress" => bridge(ai_thread, target, "fusion_demo_progress", &a),@@ -210,8 +237,21 @@ fn import_basic_part(ai_thread: &str, target: &str, args: &serde_json::Value) ->     let send = bridge(ai_thread, target, "send_files",         &serde_json::json!({"filePaths": [lbr.to_string_lossy()], "dest": dest}).to_string());     let name = lbr.file_name().unwrap().to_string_lossy().to_string();-    let open = bridge(ai_thread, target, "fusion_open_lbr",+    // BUSY-RETRY (ralph, 2026-08-18): the add-in serializes on its main thread, so an open+    // issued while another session's long verb is running is REJECTED outright. Waiting is the+    // whole fix - never hand that back to the user as a failure.+    let mut open = bridge(ai_thread, target, "fusion_open_lbr",         &serde_json::json!({"filePath": format!("{}/{}", dest, name)}).to_string());+    for _ in 0..10 {+        let o = unwrap_data(&open);+        let err = o["error"].as_str().unwrap_or("").to_lowercase();+        let busy = o["errorCode"].as_str() == Some("main_thread_busy")+            || err.contains("main thread busy") || err.contains("in progress from another");+        if !busy { break; }+        std::thread::sleep(Duration::from_secs(15));+        open = bridge(ai_thread, target, "fusion_open_lbr",+            &serde_json::json!({"filePath": format!("{}/{}", dest, name)}).to_string());+    }     serde_json::json!({"page": page, "sent": unwrap_data(&send), "opened": unwrap_data(&open),         "_hint": "Wiki part -> Fusion Library Manager, all background. Next steps an AI can run: fusion_show_symbol / fusion_show_footprint {name}, fusion_bind_library_3d for the 3D, fusion_make_test_project for a solo demo board. Render any of them with fusion_state."}) }@@ -256,6 +296,12 @@ struct App {     // calls time out and every field reads false - which used to paint the whole board RED     // while the tour was visibly running (ralph, 2026-08-18). Unknowable is not broken.     last_good: Mutex<std::collections::HashMap<String, serde_json::Value>>,+    // Boxes we are actively launching Fusion on, so the LED can say "opening right now"+    // instead of leaving the user guessing (John, 2026-08-18).+    launching: Mutex<std::collections::HashMap<String, Instant>>,+    // Why the last launch attempt ended, so a failed launch explains itself on the LED instead+    // of silently reverting to a bare red "off" (ralph, 2026-08-18).+    last_launch_note: Mutex<std::collections::HashMap<String, String>>, }  // ── Dashboard-own settings (container-side; bridge prefs stay in the bridge) ──────────────@@ -308,16 +354,31 @@ impl App { // step it can do WITHOUT the human, narrates each one as a toast on the live channel, and // stops honestly at any wall that genuinely needs a person (a password, an unconfigured APS // client id) rather than pretending. All of it background - never steals focus.-fn auto_green(app: &Arc<App>, target: &str) -> bool {+fn auto_green_forced(app: &Arc<App>, target: &str, force: bool) -> bool {     {   // single-flight + 3-minute debounce, PER TARGET: several tabs/reloads must not stack         // launches on the SAME box, but a run for one box must never block another.         let mut running = app.auto_green_running.lock().unwrap();-        if running.contains(target) { return false; }-        if let Some(t) = app.auto_green_last.lock().unwrap().get(target) {-            if t.elapsed() < Duration::from_secs(180) { return false; }+        if running.contains(target) { return false; }      // a run is genuinely in flight+        if !force {+            if let Some(t) = app.auto_green_last.lock().unwrap().get(target) {+                if t.elapsed() < Duration::from_secs(180) { return false; }+            }         }         running.insert(target.to_string());     }+    // Mark "launching" SYNCHRONOUSLY, before the worker thread starts: the endpoint returns+    // immediately and the page's very next status must already say "opening Fusion", not "off"+    // (ralph, 2026-08-18 - a 3-second window where the user saw a red LED after clicking).+    {+        let ai0 = app.ai_thread.clone();+        let t0 = target.to_string();+        let running_now = unwrap_data(&bridge(&ai0, &t0, "fusion_readiness", "{}"))["running"]+            .as_bool().unwrap_or(false);+        if !running_now {+            app.launching.lock().unwrap().insert(t0.clone(), Instant::now());+            app.last_launch_note.lock().unwrap().remove(&t0);+        }+    }     let t = target.to_string();     let ai = app.ai_thread.clone();     let app2 = app.clone();@@ -341,6 +402,8 @@ fn auto_green(app: &Arc<App>, target: &str) -> bool {              // 1. Fusion itself.             if !led(&st, "fusionRunning") {+                app2.launching.lock().unwrap().insert(t.clone(), Instant::now());+                app2.hub.broadcast("state", &gather_status_ex(&ai, &t, true));                 bridge(&ai, &t, "fusion_start", "{}");                 for _ in 0..30 {                     std::thread::sleep(Duration::from_secs(10));@@ -350,7 +413,19 @@ fn auto_green(app: &Arc<App>, target: &str) -> bool {                     if led(&st, "fusionRunning") && st["leds"]["signedIn"]["on"].as_bool() == Some(false)                         && st["leds"]["signedIn"]["state"].as_str() == Some("off") { break; }                 }-                if led(&st, "addinReady") { toast("Fusion is up and the bridge add-in is serving", "ok"); }+                app2.launching.lock().unwrap().remove(&t);+                if led(&st, "addinReady") {+                    app2.last_launch_note.lock().unwrap().remove(&t);+                    toast("Fusion is up and the bridge add-in is serving", "ok");+                } else {+                    let why = if led(&st, "fusionRunning") {+                        "Fusion launched but its bridge add-in never came up - press Start Fusion to retry, or check for a sign-in wall."+                    } else {+                        "The launch attempt did not take (Autodesk seat busy elsewhere, or a licensing dialog). Press Start Fusion to try again."+                    };+                    app2.last_launch_note.lock().unwrap().insert(t.clone(), why.to_string());+                    toast(why, "err");+                }             }              // 2. Fusion is up but the add-in is not serving. TWO causes, both fixable without@@ -450,10 +525,12 @@ fn wait_ready_with_heal(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool {                 std::thread::sleep(Duration::from_secs(4));                 bridge(&ai, target, "fusion_kill", "{}");                 std::thread::sleep(Duration::from_secs(3));+                app.launching.lock().unwrap().insert(target.to_string(), Instant::now());                 bridge(&ai, target, "fusion_start", "{}");             }         }         if is_ready() {+            app.launching.lock().unwrap().remove(target);             app.hub.broadcast("toast", &serde_json::json!({"message": "Fusion is ready - continuing", "kind": "ok"}));             app.hub.broadcast("state", &gather_status(&ai, target));             return true;@@ -467,6 +544,8 @@ fn wait_ready_with_heal(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool {     false } +fn auto_green(app: &Arc<App>, target: &str) -> bool { auto_green_forced(app, target, false) }+ /// The doctrine entry point: make the box ready, never tell the user to do it. fn ensure_ready(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool {     let ai = app.ai_thread.clone();@@ -744,7 +823,17 @@ fn handle_conn(app: &Arc<App>, mut stream: TcpStream) {         ("GET", "/api/status") | ("GET", "/state") => {             let t = qs_target.clone().unwrap_or_else(|| app.tgt());             app.log(format!("status target={}", t));-            let mut st = gather_status(&app.ai_thread, &t);+            let launching = {+                let mut g = app.launching.lock().unwrap();+                g.retain(|_, v| v.elapsed() < Duration::from_secs(240));   // self-expiring+                g.contains_key(&t)+            };+            let mut st = gather_status_ex(&app.ai_thread, &t, launching);+            if !launching && st["leds"]["fusionRunning"]["on"].as_bool() == Some(false) {+                if let Some(note) = app.last_launch_note.lock().unwrap().get(&t) {+                    st["leds"]["fusionRunning"]["_hint"] = serde_json::json!(note);+                }+            }             if st["answered"].as_bool() == Some(true) {                 app.last_good.lock().unwrap().insert(t.clone(), st.clone());             } else if let Some(prev) = app.last_good.lock().unwrap().get(&t).cloned() {@@ -803,7 +892,7 @@ fn handle_conn(app: &Arc<App>, mut stream: TcpStream) {             let t = qs_target.clone().unwrap_or_else(|| app.tgt());             if read_settings()["autoGreenOnLaunch"].as_bool().unwrap_or(true)                 || body["force"].as_bool().unwrap_or(false) {-                let started = auto_green(app, &t);+                let started = auto_green_forced(app, &t, body["force"].as_bool().unwrap_or(false));                 respond_json(&mut stream, &serde_json::json!({"ok": true, "started": started, "target": t,                     "_hint": if started {"Auto-green is running in the BACKGROUND; watch /events for per-step toasts and state pushes."}                              else {"A run for THIS box is already in flight (or finished in the last 3 minutes) - joined it instead of stacking a second launch."}}));@@ -878,6 +967,8 @@ fn serve(port: u16, target: String, ai_thread: String, print_url: bool) {         auto_green_last: Mutex::new(std::collections::HashMap::new()),         staged: Mutex::new(std::collections::HashMap::new()),         last_good: Mutex::new(std::collections::HashMap::new()),+        launching: Mutex::new(std::collections::HashMap::new()),+        last_launch_note: Mutex::new(std::collections::HashMap::new()),     });     if print_url {         // Hydrogen dock await:url contract - the proxied URL a webview can reach.
dashboard/ui/index.html+24−5
@@ -44,6 +44,8 @@ h2{font-family:'Familjen Grotesk',sans-serif;font-size:17px;color:var(--text);ma .led.on{background:var(--green);box-shadow:0 0 8px rgba(63,185,80,.5)} .led.off{background:var(--red)} .led.warn{background:var(--yellow)}+.led.starting{background:var(--yellow);animation:ledpulse 1.1s ease-in-out infinite}+@keyframes ledpulse{0%,100%{opacity:1;box-shadow:0 0 0 0 rgba(210,153,34,.55)}50%{opacity:.55;box-shadow:0 0 10px 3px rgba(210,153,34,.35)}} .led-label{font-weight:500;color:var(--text)} .led-sub{font-size:12.5px;color:var(--text-2);margin-top:6px} .btn{display:inline-flex;align-items:center;gap:8px;min-height:36px;padding:0 14px;border-radius:8px;border:1px solid var(--border);background:var(--elevated);color:var(--text);font:500 14px 'Satoshi',sans-serif;cursor:pointer;transition:background .15s ease,border-color .15s ease,transform .06s ease}@@ -172,6 +174,7 @@ dialog.settings{max-width:560px;overflow:hidden;padding:0}     <div class="btnrow"><button class="btn primary" id="btnTour" data-tip="Walk every surface of a sample part - library, symbol, footprint, 3D chip, and a generated solo board in 2D and 3D - with a live progress bar">Run the tour</button></div>     <div class="bar" id="tourBar"><div></div></div>     <div class="barlabel" id="tourLabel"></div>+    <div class="sgrid" id="tourGallery" style="margin-top:12px"></div>   </div>   <div class="card" style="margin-top:12px">     <div class="led-label">Import a wiki part into Fusion</div>@@ -296,7 +299,7 @@ function renderLeds(st){   const keys=LED_ORDER.filter(k=>leds[k]).concat(Object.keys(leds).filter(k=>!LED_ORDER.includes(k)));   for(const key of keys){     const l=leds[key];-    const cls=l.pending?'warn':((l.state==='dormant'||l.state==='busy')?'warn':(l.on?'on':'off'));+    const cls=l.state==='starting'?'starting':(l.pending?'warn':((l.state==='dormant'||l.state==='busy')?'warn':(l.on?'on':'off')));     const el=document.createElement('div');el.className='card';     el.innerHTML=`<div class="led-row"><span class="led ${cls}"></span><span class="led-label">${l.label}</span></div><div class="led-sub">${l._hint||''}</div>`;     g.appendChild(el);@@ -317,12 +320,28 @@ $('#btnTour').onclick=async()=>{   const s=await act('tour');const d=s.data||s;const id=d.demoId;   if(!id){toast('tour failed to start: '+(d.error||'unknown'),'err');return;}   $('#tourBar').style.display='block';+  const gal=$('#tourGallery');gal.innerHTML='';+  let since=0, shown=0;+  const addShot=(label,b64)=>{+    const c=document.createElement('div');c.className='scard';+    c.innerHTML='<div class="sstate ok" style="margin:0 0 6px">'+(label||'step')+'</div>'++      '<img class="sshot show" alt="'+(label||'')+'" src="data:image/png;base64,'+b64+'">';+    c.querySelector('img').onclick=e=>window.open(e.target.src,'_blank');+    gal.appendChild(c);shown++;+  };   const poll=setInterval(async()=>{-    const p=await act('tour_progress',{demoId:id,imagesAs:'paths'});const q=p.data||p;+    // Stream the EVIDENCE as it happens: stepShot carries one ~640px capture per completed+    // step, and sinceShot windows the gallery array so nothing is re-sent (bridge contract).+    const p=await act('tour_progress',{demoId:id,imagesAs:'base64',sinceShot:since});const q=p.data||p;     $('#tourBar>div').style.width=(q.percent||0)+'%';-    $('#tourLabel').textContent=(q.percent||0)+'% '+(q.stepLabel||'');-    if(q.done){clearInterval(poll);toast(q.failed?('tour failed: '+(q.error||'')):'tour complete: '+(q.shots||[]).length+' views captured',q.failed?'err':'ok');}-  },2000);+    $('#tourLabel').textContent=(q.percent||0)+'% '+(q.stepLabel||'')+(shown?('  -  '+shown+' views captured'):'');+    if(q.stepShot&&q.stepShot.image)addShot(q.stepShot.step||q.stepShot.title,q.stepShot.image);+    else (q.shots||[]).forEach(s=>{if(s.image)addShot(s.label||s.title,s.image);});+    if(typeof q.shotCount==='number')since=q.shotCount;+    if(q.done){clearInterval(poll);+      $('#tourLabel').textContent=(q.failed?'tour stopped: '+(q.error||''):'tour complete')+'  -  '+shown+' views captured';+      toast(q.failed?('tour failed: '+(q.error||'')):'tour complete: '+shown+' views captured',q.failed?'err':'ok');}+  },2500); }; $('#btnImportPart').onclick=async()=>{   const page=$('#partPage').value.trim();
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.74",+  "version": "1.9.76",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {
page.json+11−8
@@ -1,16 +1,20 @@ {   "slug": "fusion-bridge",-  "version": "1.9.74",+  "version": "1.9.76",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {-    "adom/adom-desktop": "*"+    "adom/adom-desktop": "^1.9.0"   },   "scripts": {     "install": "./install.sh",     "uninstall": "./uninstall.sh"   },   "tags": [+    "ab",+    "bridge",+    "fusion",+    "desktop-app",     "fusion360",     "autodesk",     "cad",@@ -70,7 +74,10 @@     "build a basic parts library",     "add a 3d model to a component",     "make a fusion 360 library with 3d",-    "set up the fusion bridge"+    "set up the fusion bridge",+    "fusion dashboard",+    "fusion status page",+    "open the fusion launch page"   ],   "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).",   "brief": "Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.",@@ -99,9 +106,5 @@       "label": "Part volume",       "prompt": "How much material does each printed part in this assembly use?"     }-  ],-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"-  }+  ] }\ No newline at end of file