app
Shotlog - Screenshot Log
Public Made by Adomby adom
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 Bridge child-window arrays via --meta.
← Commit history
Publish 0.11.0
12 files changed
+188−34
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-shotlog"-version = "0.10.0"+version = "0.11.0" dependencies = [ "axum", "base64",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.10.0"+version = "0.11.0" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
README.md+1−1@@ -55,7 +55,7 @@ The layout adapts to the narrow screen, and while the tab is backgrounded the pa ### Pup and plain browser -Pup is the **full-browser canvas**: zooming a card uses nearly the whole screen, so fine text is readable (the skinny webview can never zoom past its panel width). Ask your AI to switch any channel with one command: `shotlog open -c <ch> --pup --swap` (opens a tab in the shared shotlog pup window and closes the webview tab). shotlog leaves your foreground alone; the gentle cue is an automatic **orange taskbar flash** on updates. In any plain browser tab, the same live gallery and unseen-shots title badge apply.+Pup is the **full-browser canvas**: zooming a card uses nearly the whole screen, so fine text is readable (the skinny webview can never zoom past its panel width). Or just click the **`pup ⧉` button in the viewer header**: every webview shows it, and it switches that channel to a full pup window (the pup view shows `webview ◱` to switch back). The button checks whether pup is available first; if Adom Desktop isn't installed it tells you how to get it (pup is a default bridge that auto-installs with Adom Desktop). Your AI can do the same switch with one command: `shotlog open -c <ch> --pup --swap` (opens a tab in the shared shotlog pup window and closes the webview tab). shotlog leaves your foreground alone; the gentle cue is an automatic **orange taskbar flash** on updates. In any plain browser tab, the same live gallery and unseen-shots title badge apply. ## Is a human actually seeing it?
VERSION+1−1@@ -1 +1 @@-0.10.0+0.11.0
bin/adom-shotlog⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.10.0",+ "version": "0.11.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.10.0",+ "version": "0.11.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.",
skills/adom-shotlog-surfacing/SKILL.md+6−1@@ -65,7 +65,12 @@ The human picks the canvas, and they usually want exactly ONE per channel: - **Pup** = the full-browser canvas: zoom uses nearly the whole screen, text is readable. When the human says "show it in pup", they want the BIG view. -Do the switch in ONE command, in code, not by hand-rolling browser calls:+The HUMAN can switch themselves: every viewer header has a canvas toggle+button (`pup ⧉` in a webview, `webview ◱` in pup). It checks pup availability+via the broker (`/api/canvas-check`) and shows install guidance when Adom+Desktop is missing. So if the human asks you to switch, you may also just tell+them about the button. You do the switch in ONE command, never by hand-rolling+browser calls: ```bash shotlog open -c <ch> --pup --swap # open the pup canvas AND close the webview tab
src/html/index.html+46−23@@ -70,33 +70,21 @@ header { color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .subject-meta { color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: 10px; white-space: nowrap; flex-shrink: 0; }-/* Skinny view (e.g. a ~20%-wide side panel): WRAP the header into rows so nothing- truncates — brand on row 1, the (full) channel name + shot count on row 2, the- actions (LED / to-top / clear) on row 3. Fluidly shows everything. */+/* Skinny view (~20% side panel): a tight 2-row grid.+ Row 1: brand (left) + actions (right). Row 2: channel name + count, full width.+ Aligned edges, no dead space - not the loose 3-row stack it used to be. */ @media (max-width: 520px) {- header { height: auto; min-height: 44px; flex-wrap: wrap; align-items: flex-start;- padding: 6px 10px; row-gap: 3px; column-gap: 8px; }+ header { display: grid; grid-template-columns: minmax(0,1fr) auto;+ align-items: center; height: auto; padding: 7px 12px 8px; row-gap: 3px; column-gap: 8px; }+ .brand { grid-row: 1; grid-column: 1; min-width: 0; overflow: hidden; }+ .brand .sep, .brand .app-subtitle { display: none; } /* declutter at this width */ .vsep { display: none; }- .brand { width: 100%; }- .subject { width: 100%; flex: 1 1 100%; }+ .actions { grid-row: 1; grid-column: 2; margin-left: 0; justify-self: end; }+ .subject { grid-row: 2; grid-column: 1 / -1; min-width: 0; } .subject-name { white-space: normal; overflow: visible; text-overflow: clip;- word-break: break-word; line-height: 1.2; }+ word-break: break-word; line-height: 1.25; } .subject-meta { display: inline; }- .brand .sep, .brand .app-subtitle { display: inline; }- .actions { width: 100%; margin-left: 0; justify-content: flex-start; }- .ws-led { order: 0; } }-.actions { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-shrink: 0; }-.hdr-btn { background: transparent; color: var(--text2); border: 1px solid var(--border);- padding: 3px 8px; border-radius: 5px; font-size: 11px; cursor: pointer;- font-family: 'Satoshi', sans-serif; flex-shrink: 0; transition: background .15s, color .15s; }-.hdr-btn:hover { background: var(--border); color: var(--text); }-.hdr-btn.danger { border-color: #f8514955; color: var(--danger); }-.hdr-btn.danger:hover { background: #f8514922; }-.ws-led { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; cursor: help; order: -1;- background: var(--text3); transition: background .4s, box-shadow .4s; }-.ws-led.live { background: #2ea043; box-shadow: 0 0 7px rgba(46,160,67,.7); }- /* App-info popover */ .hover-popover { position: absolute; top: calc(100% + 6px); left: 12px; background: var(--elevated); border: 1px solid var(--border); border-radius: 8px;@@ -244,6 +232,7 @@ header { <span class="subject-meta" id="shotCount"></span> </div> <div class="actions">+ <button class="hdr-btn" id="canvasBtn" style="display:none"></button> <button class="hdr-btn" id="toTopBtn" data-tooltip="Scroll to newest (top)">↑</button> <button class="hdr-btn danger" id="clearBtn" data-tooltip="Delete ALL screenshots in this channel (cannot be undone)">clear</button> <span class="ws-led" id="wsLed" data-tooltip="WebSocket: grey = disconnected, green = live. While green the server knows you're watching and won't force-open a duplicate tab on new shots."></span>@@ -316,7 +305,9 @@ header { })(); // ── Helpers ──────────────────────────────────────────────────────────────- function showToast(msg){ toast.textContent=msg; toast.classList.add('show'); setTimeout(()=>toast.classList.remove('show'),1800); }+ let toastTimer=null;+ function showToast(msg, ms){ toast.textContent=msg; toast.classList.add('show');+ clearTimeout(toastTimer); toastTimer=setTimeout(()=>toast.classList.remove('show'), ms||1800); } function esc(s){ return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } function fmtTime(ts){ const d=new Date(ts), h=d.getHours(), mm=String(d.getMinutes()).padStart(2,'0'); return (h%12||12)+':'+mm+(h<12?'am':'pm'); }@@ -516,6 +507,38 @@ header { entries = Array.isArray(data)?data:[]; renderAll(); feed.scrollTop=0; }).catch(e=>{ feed.innerHTML='<div class="empty">load failed: '+esc(e.message)+'</div>'; }); + // ── Canvas toggle: show this channel in pup (full browser) or webview ─────+ (function initCanvasToggle(){+ const btn=$('canvasBtn'); if(!btn) return;+ const surf=detectSurface();+ if(surf!=='webview' && surf!=='pup') return; // phone/plain browser: no toggle+ const toPup = surf==='webview';+ const label = toPup ? 'pup ⧉' : 'webview ◱';+ btn.style.display='';+ btn.textContent=label;+ btn.setAttribute('data-tooltip', toPup+ ? 'Open this channel in a full pup browser window (zoom uses the whole screen) and close this webview tab.'+ : 'Move this channel back to the slim webview panel in Hydrogen and close this pup tab.');+ btn.addEventListener('click', async ()=>{+ btn.disabled=true; btn.textContent='checking…';+ try{+ if(toPup){+ 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.', 12000);+ btn.textContent=label; btn.disabled=false; return;+ }+ }+ showToast(toPup ? 'Opening in pup… this webview tab will close.' : 'Moving back to webview… this pup tab will close.', 6000);+ 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'), 8000); btn.textContent=label; btn.disabled=false; }+ // on success this surface gets closed by the swap - nothing more to do here+ }catch(e){ showToast('switch failed: '+e.message, 8000); btn.textContent=label; btn.disabled=false; }+ });+ })();+ // ── WebSocket live updates ({type:'entry', entry}) ─────────────────────── let activeWs = null; // Gentle "you have unseen shots" cue: while this tab is backgrounded, count new
src/main.rs+7−2@@ -193,8 +193,8 @@ enum Commands { /// and the broker flashes the window's taskbar orange on every update. #[arg(long)] pup: bool,- /// With --pup: also CLOSE the channel's skinny webview tab, making pup- /// this channel's only canvas (the human asked for the big view).+ /// SWAP canvases: with --pup, also close the channel's webview tab;+ /// without --pup, also close the channel's pup tab. One surface at a time. #[arg(long)] swap: bool, },@@ -416,6 +416,11 @@ async fn main() { open::open_pup(&channel, port, swap); } else { open::open_webview(&channel, name.as_deref(), icon.as_deref(), port, skinny, ratio);+ if swap {+ if open::close_pup_tab(&channel) {+ ok(&format!("closed the pup tab for '{channel}' - webview is now this channel's canvas."));+ }+ } hint("Skinny webview too small for the human to inspect images? `shotlog open -c <ch> --pup` opens the full-browser pup canvas (add --swap to close the webview tab). Zoom there uses nearly the whole screen."); } }
src/open.rs+25@@ -244,3 +244,28 @@ pub fn open_pup(channel: &str, port: u16, swap: bool) { eprintln!("Hint: the webview tab (if open) stays connected and will still pull forward on updates. Pass --swap to close it and make pup the ONLY canvas for this channel."); } }++/// Close this channel's tab in the shared shotlog pup window (if present).+/// Used when swapping a channel back to the webview canvas.+pub fn close_pup_tab(channel: &str) -> bool {+ let ad = |args: &[&str]| -> (bool, String) {+ let mut cmd = Command::new("adom-desktop");+ if let Ok(t) = std::env::var("SHOTLOG_AD_TARGET") {+ cmd.args(["--target", &t]);+ }+ match cmd.args(args).output() {+ Ok(o) => (o.status.success(),+ format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))),+ Err(e) => (false, e.to_string()),+ }+ };+ let needle = format!("/log/{channel}/");+ let (ok, out) = ad(&["browser_list_tabs", "{\"sessionId\":\"shotlog\"}"]);+ if !ok { return false; }+ let Ok(v) = serde_json::from_str::<serde_json::Value>(&out) else { return false };+ let Some(tab) = 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))+ }) else { return false };+ let Some(tid) = tab.get("tabId").or_else(|| tab.get("id")).and_then(|x| x.as_str()) else { return false };+ ad(&["browser_close_tab", &format!("{{\"sessionId\":\"shotlog\",\"tabId\":\"{tid}\"}}")]).0+}
src/server.rs+98−2@@ -75,6 +75,8 @@ pub fn create_router(state: Arc<AppState>) -> Router { .route("/api/shots", post(post_shots)) .route("/api/viewer-status", get(viewer_status)) .route("/api/channels", get(get_channels))+ .route("/api/canvas-check", get(canvas_check))+ .route("/api/canvas-switch", post(canvas_switch)) .route("/prompt", get(serve_prompt)) .route("/api/paste", post(post_paste)) .route("/api/open", post(open_in_vscode))@@ -111,13 +113,21 @@ async fn serve_favicon() -> Response { res } -async fn serve_viewer() -> Html<String> {+async fn serve_viewer() -> Response { let html = INDEX_HTML.replacen( "<head>", "<head><script>document.write('<base href=\"'+((location.pathname.match(/^\\/proxy\\/\\d+\\//)||['/'])[0])+'\">')</script>", 1, );- Html(html)+ // no-cache: the viewer UI must NEVER go stale in a long-lived webview/pup+ // tab - without this, browsers heuristically cached old HTML across+ // deploys and reloads showed a months-old UI.+ let mut res = Html(html).into_response();+ res.headers_mut().insert(+ header::CACHE_CONTROL,+ HeaderValue::from_static("no-cache"),+ );+ res } #[derive(Deserialize)]@@ -181,6 +191,92 @@ async fn get_channels(State(state): State<Arc<AppState>>) -> Json<serde_json::Va Json(serde_json::json!(rows)) } +/// Is the pup canvas available on this machine? Checks the adom-desktop CLI and+/// the pup bridge (browser_readiness), cached for 2 minutes. Powers the viewer's+/// canvas-toggle button, including the "how to install" guidance.+async fn canvas_check() -> Json<serde_json::Value> {+ let v = tokio::task::spawn_blocking(do_canvas_check)+ .await+ .unwrap_or_else(|_| serde_json::json!({"available": false, "reason": "internal"}));+ Json(v)+}++fn do_canvas_check() -> serde_json::Value {+ use std::sync::Mutex;+ use std::time::Instant;+ static CACHE: Mutex<Option<(Instant, serde_json::Value)>> = Mutex::new(None);+ if let Some((t, v)) = CACHE.lock().unwrap().as_ref() {+ if t.elapsed().as_secs() < 120 {+ return v.clone();+ }+ }+ let result = (|| {+ if std::process::Command::new("adom-desktop").arg("--version").output().is_err() {+ return serde_json::json!({"available": false, "reason": "no-adom-desktop",+ "hint": "pup needs Adom Desktop. Install it on your computer from wiki.adom.inc/adom/adom-desktop - the pup bridge is a default bridge that auto-installs right after it."});+ }+ let mut cmd = std::process::Command::new("adom-desktop");+ if let Ok(t) = std::env::var("SHOTLOG_AD_TARGET") {+ cmd.args(["--target", &t]);+ }+ match cmd.args(["browser_readiness", "{}"]).output() {+ Ok(o) => {+ let text = format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));+ if text.contains("ambiguous_target") {+ serde_json::json!({"available": false, "reason": "multiple-desktops",+ "hint": "More than one Adom Desktop is connected. Set SHOTLOG_AD_TARGET=<desktop name> where the broker runs."})+ } else if text.contains("\"ready\": true") || text.contains("\"ready\":true") || text.contains("\"ok\": true") {+ serde_json::json!({"available": true})+ } else if text.contains("No clients") || text.contains("not connected") || text.contains("no desktop") {+ serde_json::json!({"available": false, "reason": "desktop-not-connected",+ "hint": "Adom Desktop is installed but not connected right now. Launch it on your computer and it links to this container automatically."})+ } else {+ serde_json::json!({"available": false, "reason": "pup-not-ready",+ "hint": "Adom Desktop is here but the pup bridge is not ready yet (it auto-installs on first use). Give it a moment, or run: adom-desktop browser_prewarm"})+ }+ }+ Err(e) => serde_json::json!({"available": false, "reason": "probe-failed",+ "hint": format!("Could not probe pup: {e}")}),+ }+ })();+ *CACHE.lock().unwrap() = Some((Instant::now(), result.clone()));+ result+}++#[derive(Deserialize)]+struct CanvasSwitchRequest {+ channel: String,+ to: String,+}++/// Switch a channel between canvases from the viewer UI. Runs the same+/// `open --swap` logic as the CLI (one surface at a time).+async fn canvas_switch(Json(req): Json<CanvasSwitchRequest>) -> 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 mut args: Vec<String> = vec!["open".into(), "-c".into(), req.channel.clone(), "--swap".into()];+ if req.to == "pup" {+ args.push("--pup".into());+ } else {+ args.push("--skinny".into());+ }+ match std::process::Command::new(exe).args(&args).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)+}+ /// Paste-prompt text served at /prompt — guidance for AI agents on how to inject. async fn serve_prompt() -> impl IntoResponse { let body = "\