← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-shotlog"-version = "0.12.1"+version = "0.13.0" dependencies = [  "axum",  "base64",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.12.1"+version = "0.13.0" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
VERSION+1−1
@@ -1 +1 @@-0.12.1+0.13.0
bin/adom-shotlog
⋯ 1 unchanged line ⋯
dev-skills/adom-shotlog-dev/SKILL.md+30
@@ -108,6 +108,36 @@ The viewer is a real UI, held to `~/.claude/skills/adom/ui-implementation-refere - **Zoom uses the real estate**: `max-width: min(96vw, 2200px)`, image `max-height: 84vh` -   never a fixed px cap (useless in both a skinny panel and a big pup window). +## HARD RULE: canvas switches are STAGED and TRACKED - never close blind++The webview<->pup switch (the header button) is a state machine, both directions:++1. `checking` - pup availability (canvas-check) before anything moves.+2. `opening <dest>` - POST /api/canvas-open opens the DESTINATION only.+3. `waiting for <dest>… Ns` - poll /api/viewer-status until the destination+   viewer's WebSocket CONNECTS (surface count rises above the pre-switch+   baseline). That connect is the ONLY acceptable "it's really open" signal -+   a tab existing is NOT enough (the page could be blank/dead).+4. Only after connect: `switched ✓` -> POST /api/canvas-close for the SOURCE.++Failure handling (all verified paths):+- open fails -> source stays, error toast with the broker's detail.+- destination never connects (30s) -> ROLLBACK: close only what we opened,+  source stays, precise toast (laptop asleep? Hydrogen closed?).+- broker unreachable / fetch throws -> nothing moves, restore button.+- destination already connected (dual-surface) -> instant confirm, then the+  source closes: the switch doubles as the dual-surface resolver.+- double-clicks: button disabled for the whole flow.++NEVER regress to fire-and-forget (open + close in parallel): a failed open+then closes the user's ONLY surface. Bit us; the user called it.++Why switches take seconds anyway: every pup verb is a cloud->relay->laptop+round-trip (~1s each) and a fresh Chrome window costs ~3-5s. Keep pup verbs+to the MINIMUM number of round-trips (branch on one list_tabs result; never+try-and-fallback chains, which serialize 3 trips). webview direction is local+Hydrogen = fast (~1-2s).+ ## HARD RULE: the header CSS is a SET - never clobber siblings  We have rebuilt this header repeatedly. The recurring self-inflicted wound: editing
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.12.1",+  "version": "0.13.0",   "type": "app",   "title": "Adom Shotlog",   "description": "See the shots your AI never showed you, and know it actually SAW them. adom-shotlog streams every screenshot the AI gathers into a live gallery (webview/pup/browser/phone), reports whether a human is really watching and on which surface, and ingests Adom Desktop child-window arrays via --meta.",
page.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.12.1",+  "version": "0.13.0",   "type": "app",   "title": "Adom Shotlog",   "description": "See the shots your AI never showed you, and know it actually SAW them. adom-shotlog streams every screenshot the AI gathers into a live gallery (webview/pup/browser/phone), reports whether a human is really watching and on which surface, and ingests Adom Desktop child-window arrays via --meta.",
src/html/index.html+66−14
@@ -611,25 +611,77 @@ header {       : '<div class="tt-title">Show in webview</div>'+         '<div class="tt-p">The slim panel docked in Hydrogen. Closes this pup tab.</div>'+         '<div class="tt-prompt"><span class="lbl">say to your AI</span>show me shotlog in webview</div>';-    const busy=(t)=>{ btn.disabled=true; btn.innerHTML='<span>'+t+'</span>'; };+    const busy=(t)=>{ btn.disabled=true; btn.innerHTML='<span>'+esc(t)+'</span>'; };+    const restore=()=>{ render(); btn.disabled=false; };+    const dest = toPup ? 'pup' : 'webview';+    const src  = toPup ? 'webview' : 'pup';++    // Count how many viewers of a surface are currently connected.+    async function surfaceCount(){+      try { const st=await fetch('api/viewer-status?channel='+encodeURIComponent(channel)).then(r=>r.json());+        return (st.surfaces||[]).filter(x=>x===dest).length; } catch { return -1; }+    }++    // FULL TRACKED SWITCH. Stages: check -> open destination -> WAIT until the+    // destination viewer's WebSocket actually connects (the full signal) ->+    // only then close this (source) surface. Any failure leaves this surface+    // open and reports exactly what happened. Nothing is ever closed blind.     btn.addEventListener('click', async ()=>{-      busy('checking…');+      if(btn.disabled) return;+      btn.disabled=true;+      let opened=false;       try{+        // Stage 0: availability (pup needs Adom Desktop).         if(toPup){+          busy('checking pup…');           const chk=await fetch('api/canvas-check').then(r=>r.json());-          if(!chk.available){-            showToast(chk.hint||'pup is not available on this machine yet.', 9000);-            render(); btn.disabled=false; return;-          }+          if(!chk.available){ showToast(chk.hint||'pup is not available on this machine yet.', 9000); restore(); return; }         }-        busy(toPup?'popping out…':'docking…');-        showToast(toPup ? 'Opening in pup — this tab will close.' : 'Docking to webview — this tab will close.', 5000);-        const r=await fetch('api/canvas-switch',{method:'POST',-          headers:{'Content-Type':'application/json'},-          body:JSON.stringify({channel, to: toPup?'pup':'webview'})}).then(x=>x.json());-        if(!r.ok){ showToast('switch failed: '+(r.error||r.detail||'unknown'), 7000); render(); btn.disabled=false; }-        // on success this surface gets closed by the swap - nothing more to do here-      }catch(e){ showToast('switch failed: '+e.message, 7000); render(); btn.disabled=false; }+        const baseline=await surfaceCount();+        if(baseline<0){ showToast('cannot reach the shotlog broker - switch aborted, nothing changed.', 7000); restore(); return; }++        // Stage 1: open the destination (nothing gets closed yet).+        busy('opening '+dest+'…');+        const op=await fetch('api/canvas-open',{method:'POST',headers:{'Content-Type':'application/json'},+          body:JSON.stringify({channel, to:dest})}).then(r=>r.json());+        if(!op.ok){+          showToast('could not open '+dest+': '+String(op.error||op.detail||'unknown').slice(0,300)+' - this tab stays open.', 10000);+          restore(); return;+        }+        opened=true;++        // Stage 2: wait for the REAL signal - the destination viewer connects+        // its WebSocket to the broker. Poll up to 30s, ticking progress.+        const T0=Date.now(), LIMIT=30000;+        let connected = baseline>0;  // already watching there? instant confirm+        while(!connected && Date.now()-T0<LIMIT){+          busy('waiting for '+dest+'… '+Math.round((Date.now()-T0)/1000)+'s');+          await new Promise(r=>setTimeout(r,800));+          const n=await surfaceCount();+          if(n>baseline) connected=true;+        }+        if(!connected){+          // Rollback: destination never came up. Close ONLY what we just opened+          // (best-effort), keep this surface, tell the human precisely.+          busy('rolling back…');+          try{ await fetch('api/canvas-close',{method:'POST',headers:{'Content-Type':'application/json'},+            body:JSON.stringify({channel, surface:dest})}); }catch{}+          showToast(dest+' opened but never connected (waited 30s) - rolled back, this tab stays open. '+(toPup?'Is the pup window blocked or the laptop asleep?':'Is Hydrogen open?'), 12000);+          restore(); return;+        }++        // Stage 3: destination is LIVE. Now (and only now) close this surface.+        busy('switched ✓');+        showToast(dest+' is live - closing this '+src+' tab.', 4000);+        await fetch('api/canvas-close',{method:'POST',headers:{'Content-Type':'application/json'},+          body:JSON.stringify({channel, surface:src})}).catch(()=>{});+        // If this page is that tab, it is about to die - nothing more to do.+        // If the close missed (renamed tab etc.), re-enable so retry is possible.+        setTimeout(restore, 4000);+      }catch(e){+        showToast('switch failed: '+e.message+(opened?' ('+dest+' may have opened - check before retrying)':' - nothing changed.'), 9000);+        restore();+      }     });   })(); 
src/open.rs+24−24
@@ -188,35 +188,35 @@ pub fn open_pup(channel: &str, port: u16, swap: bool) {         }     }; -    // One shotlog pup window for ALL channels, one tab PER channel (idempotent):-    // if this channel's tab already exists, switch to it; else add a tab; else-    // open the window.+    // One shotlog pup window for ALL channels, one tab PER channel (idempotent).+    // SPEED: each `ad` call is a cloud->laptop round-trip (~1s), so branch off+    // the ONE list_tabs result instead of try-and-fallback chains:+    //   session missing -> open_window (2 trips total)+    //   tab exists      -> switch_tab   (2 trips)+    //   session, no tab -> open_tab     (2 trips)     let needle = format!("/log/{channel}/");     let (listed, list_out) = ad(&["browser_list_tabs", "{\"sessionId\":\"shotlog\"}"]);     let mut opened = false;-    if listed {-        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&list_out) {-            if let Some(tab) = v.get("tabs").and_then(|t| t.as_array()).and_then(|tabs| {+    if !listed || list_out.contains("session_not_found") {+        let win_json = format!("{{\"sessionId\":\"shotlog\",\"profile\":\"shotlog\",\"url\":\"{url}\"}}");+        opened = ad(&["browser_open_window", &win_json]).0;+    } else {+        let existing = serde_json::from_str::<serde_json::Value>(&list_out).ok()+            .and_then(|v| v.get("tabs").and_then(|t| t.as_array()).and_then(|tabs| {                 tabs.iter().find(|t| t.get("url").and_then(|u| u.as_str()).map(|u| u.contains(&needle)).unwrap_or(false))-            }) {-                if let Some(tid) = tab.get("tabId").or_else(|| tab.get("id")).and_then(|x| x.as_str()) {-                    let sw = format!("{{\"sessionId\":\"shotlog\",\"tabId\":\"{tid}\"}}");-                    opened = ad(&["browser_switch_tab", &sw]).0;-                }-            }-        }-    }-    if !opened {-        let tab_json = format!("{{\"sessionId\":\"shotlog\",\"url\":\"{url}\"}}");-        let (ok, out) = ad(&["browser_open_tab", &tab_json]);-        opened = if ok {-            true-        } else if out.contains("session_not_found") || out.contains("not found") {-            let win_json = format!("{{\"sessionId\":\"shotlog\",\"profile\":\"shotlog\",\"url\":\"{url}\"}}");-            ad(&["browser_open_window", &win_json]).0+                    .and_then(|t| t.get("tabId").or_else(|| t.get("id")).and_then(|x| x.as_str()).map(String::from))+            }));+        if let Some(tid) = existing {+            let sw = format!("{{\"sessionId\":\"shotlog\",\"tabId\":\"{tid}\"}}");+            opened = ad(&["browser_switch_tab", &sw]).0;         } else {-            false-        };+            let tab_json = format!("{{\"sessionId\":\"shotlog\",\"url\":\"{url}\"}}");+            let (ok, out) = ad(&["browser_open_tab", &tab_json]);+            opened = ok || (out.contains("session_not_found") && {+                let win_json = format!("{{\"sessionId\":\"shotlog\",\"profile\":\"shotlog\",\"url\":\"{url}\"}}");+                ad(&["browser_open_window", &win_json]).0+            });+        }     }      if !opened {
src/server.rs+66
@@ -77,6 +77,8 @@ pub fn create_router(state: Arc<AppState>) -> Router {         .route("/api/channels", get(get_channels))         .route("/api/canvas-check", get(canvas_check))         .route("/api/canvas-switch", post(canvas_switch))+        .route("/api/canvas-open", post(canvas_open))+        .route("/api/canvas-close", post(canvas_close))         .route("/prompt", get(serve_prompt))         .route("/api/paste", post(post_paste))         .route("/api/open", post(open_in_vscode))@@ -249,6 +251,70 @@ struct CanvasSwitchRequest {     to: String, } +#[derive(Deserialize)]+struct CanvasOpenRequest {+    channel: String,+    to: String,+}++/// STAGE 1 of the tracked switch: open the DESTINATION surface only. The+/// viewer then polls /api/viewer-status until the destination viewer's+/// WebSocket actually CONNECTS (the full signal), and only after that calls+/// /api/canvas-close for the source. Never closes anything itself, so a+/// failure at any stage leaves the user's current surface untouched.+async fn canvas_open(Json(req): Json<CanvasOpenRequest>) -> Json<serde_json::Value> {+    if req.channel.contains("..") || req.channel.contains('/') || req.channel.is_empty() {+        return Json(serde_json::json!({"ok": false, "error": "invalid channel"}));+    }+    if req.to != "pup" && req.to != "webview" {+        return Json(serde_json::json!({"ok": false, "error": "to must be pup|webview"}));+    }+    let v = tokio::task::spawn_blocking(move || {+        let exe = std::env::current_exe().unwrap_or_else(|_| "adom-shotlog".into());+        let flag = if req.to == "pup" { "--pup" } else { "--skinny" };+        match std::process::Command::new(exe).args(["open", "-c", &req.channel, flag]).output() {+            Ok(o) => serde_json::json!({"ok": o.status.success(),+                "detail": format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))}),+            Err(e) => serde_json::json!({"ok": false, "error": e.to_string()}),+        }+    })+    .await+    .unwrap_or_else(|_| serde_json::json!({"ok": false, "error": "spawn failed"}));+    Json(v)+}++#[derive(Deserialize)]+struct CanvasCloseRequest {+    channel: String,+    surface: String,+}++/// STAGE 3 of the tracked switch (or a rollback): close ONE surface of a+/// channel. surface = "webview" closes the Hydrogen tab; "pup" closes the tab+/// in the shared shotlog pup window.+async fn canvas_close(Json(req): Json<CanvasCloseRequest>) -> Json<serde_json::Value> {+    if req.channel.contains("..") || req.channel.contains('/') || req.channel.is_empty() {+        return Json(serde_json::json!({"ok": false, "error": "invalid channel"}));+    }+    let v = tokio::task::spawn_blocking(move || {+        if req.surface == "webview" {+            let ok = std::process::Command::new("adom-cli")+                .args(["hydrogen", "workspace", "remove-tab", "--name", &format!("Shots: {}", req.channel)])+                .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())+                .status().map(|s| s.success()).unwrap_or(false);+            serde_json::json!({"ok": ok, "surface": "webview"})+        } else if req.surface == "pup" {+            let ok = crate::open::close_pup_tab(&req.channel);+            serde_json::json!({"ok": ok, "surface": "pup"})+        } else {+            serde_json::json!({"ok": false, "error": "surface must be webview|pup"})+        }+    })+    .await+    .unwrap_or_else(|_| serde_json::json!({"ok": false, "error": "spawn failed"}));+    Json(v)+}+ /// Switch a channel between canvases from the viewer UI. For SPEED: open the /// DESTINATION surface (blocking, so the human sees it appear), then close the /// SOURCE surface in the BACKGROUND. The old code ran the full `--swap`