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.29.2
11 files changed
+130−30
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-shotlog"-version = "0.29.1"+version = "0.29.2" dependencies = [ "axum", "base64",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.29.1"+version = "0.29.2" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
README.md+2−1@@ -48,6 +48,7 @@ adom-shotlog open -c my-project --skinny # a ~20% right-side column - **One panel, shotlog's own tabs**: the viewer is a single app that manages a tab per channel itself (read-captures pinned first as the global auto-capture feed). New shots flash their tab orange, show an unseen badge, and (by default) auto-switch to the updated channel - all inside the app, no workspace tab juggling. - If a shot lands in a channel with **nobody watching**, the broker auto-opens the slim 20% webview for it, so a shot is never logged invisibly. (Opt out with `SHOTLOG_NO_FORCE_OPEN=1`.) - **Newest first, lightly**: the shots at the top load first; shots below the fold load as you scroll to them, and shots that stay several screens away are unloaded again after 20 seconds, so a 200-shot log never holds 200 decoded images. Card heights are reserved up front, so nothing shifts while that happens.+- **Delete one shot**: hover a card and click the trash at the right end of its ref bar, then click again to confirm (it turns red and asks "delete?", and disarms by itself after 4 seconds). The image and every file it owns are removed, and every open shotlog drops the card. The clear button remains the way to wipe everything. - **One click zooms**: click any thumbnail and it fills the window (in Hydrogen that is a 90% lightbox; in pup or a browser it is true fullscreen). The next click anywhere, the dark margin around the image, Esc, or the ✕ takes you straight back to the list. The ⛶ icon appears when you hover a thumbnail as a reminder that a click zooms. - **Own column (Settings, off by default)**: if you live in webviews, shotlog can open as its OWN narrow third column at the right edge (10% of the screen by default) instead of joining your webview pane's tabs. Your webview pane keeps its width; the editor gives up the space. See Settings below. - The **clear** button wipes EVERY channel (all the per-thread tabs that build up) and reports exactly what came back: `cleared N shots across M threads - freed X GB of disk`. Safe for running AIs: a channel auto-recreates the moment any thread injects to it again.@@ -117,7 +118,7 @@ The viewer has a **Settings** dialog: the outline cog at the right end of the he |---|---|---| | **Auto-capture what AIs read** (read-hook) | ON | Auto-inject every image any AI thread reads into the shared `read-captures` channel. Off = only deliberate injects show. | | **Update alerts** (orange flashes) | OFF | The orange cues when new shots arrive: the webview tab highlight, the in-app channel-tab flash, and the pup taskbar flash. Tabs still come forward and unseen badges still show; turn on if you want the flashing. |-| **Bring the webview tab forward** | ON | When a shot lands and the Shotlog webview sits behind another tab in its Hydrogen pane, pull it to the front. Turn it off if you live in webviews: you stay on the tab you were reading; the webview still receives every shot (read-hook captures included) in the background and is fully current the moment you switch to it. |+| **Bring the webview tab forward** | OFF | Off: when a shot lands the Shotlog tab stays where it is; the webview still receives every shot (read-hook captures included) in the background and is fully current the moment you switch to it, and nothing you are reading or recording gets covered. On: a new shot pulls the Shotlog tab to the front of its pane, except when another webview is the active tab there (it only badges). | | **Auto-switch to updated channel** | ON | When a shot arrives for another channel, jump that channel's tab to the front. Off = the tab just flashes + shows an unseen badge; you stay put. | | **Compact cards (image-first)** | ON | The gallery shows mostly thumbnails: info text hides behind the ⓘ chip on each image (hover to reveal); the ⛶ fullscreen button stays on every thumbnail. Off = classic text-above-image cards. | | **Default canvas** | webview | Where shotlog auto-opens a channel when nobody's watching: `webview` or `pup`. |
SKILL.md+4−2@@ -334,7 +334,9 @@ Read a channel BACK before you talk about it: `adom-shotlog shots -c <channel>` - The thumbnail carries no overlay text (the ref sits ABOVE it) because humans screenshot thumbnails to send back to you. - Settings > Own column (off by default) opens shotlog as its own ~10% third column at the right edge instead of joining the webview pane; never resize the human's webview pane yourself. -## The webview tab pull is a setting (0.28.6)+## The webview tab pull is a setting, default OFF (0.29.2) -Settings > Notifications > "Bring the webview tab forward" (default ON) is what pulls the Shotlog Hydrogen tab to the front when a shot lands. A human who lives in webviews may turn it OFF: then the webview still receives every shot in the background, `shotlog viewers` can report a connected-but-backgrounded viewer (exit 4), and that is fine: they see everything when they switch to the tab. Never "fix" that by opening or foregrounding anything yourself.+Settings > Notifications > "Bring the webview tab forward" (default OFF since 0.29.2) is what pulls the Shotlog Hydrogen tab to the front when a shot lands. By default it stays put: the webview still receives every shot in the background, `shotlog viewers` can report a connected-but-backgrounded viewer (exit 4), and that is fine: the human sees everything when they switch to the tab. Even when ON it never covers another webview that is active in the same pane. Never "fix" a backgrounded viewer by opening or foregrounding anything yourself.++A human can delete one shot from the viewer (trash on the card's ref bar). A deleted ref is gone: `shots` no longer lists it, and you should not refer to it.
VERSION+1−1@@ -1 +1 @@-0.29.1+0.29.2
bin/adom-shotlog⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.29.1",+ "version": "0.29.2", "type": "app", "title": "Shotlog - Screenshot Log", "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 Bridge child-window arrays via --meta.",
page.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.29.1",+ "version": "0.29.2", "type": "app", "title": "Shotlog - Screenshot Log", "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 Bridge child-window arrays via --meta.",
src/html/index.html+49−5@@ -268,6 +268,16 @@ body.compact .card-popups .ph { padding-top: 4px; } .card-refbar .rb-copy:hover { color: #fff; background: rgba(0,184,176,.30); border-color: var(--accent-bright); } .card-refbar .rb-copy.done { opacity: 1; color: var(--accent-bright); border-color: rgba(0,184,176,.5); } @media (hover: none) { .card-refbar .rb-copy { opacity: .8; } }+/* delete (trash): hover-revealed, pushed to the far right, red when armed */+.card-refbar .rb-del { margin-left: auto; padding: 2px 5px; border-radius: 5px; cursor: pointer; flex-shrink: 0;+ display: inline-flex; align-items: center; justify-content: center; font: inherit; line-height: 1.5;+ color: var(--text3); background: transparent; border: 1px solid transparent; opacity: 0; transition: opacity .12s, color .12s, border-color .12s, background .12s; }+.card-refbar .rb-del svg { width: 13px; height: 13px; display: block; }+.shot-card:hover .card-refbar .rb-del, .card-refbar .rb-del:focus-visible, .card-refbar .rb-del.armed { opacity: 1; }+.card-refbar .rb-del:hover { color: var(--danger); border-color: rgba(248,81,73,.45); background: rgba(248,81,73,.09); }+.card-refbar .rb-del.armed { color: #fff; background: var(--danger); border-color: var(--danger); font-weight: 700; }+.card-refbar .rb-del .lbl { font-size: 10.5px; padding: 0 2px; }+@media (hover: none) { .card-refbar .rb-del { opacity: .8; } } .card-refbar .rb-tag { color: var(--accent-bright); background: rgba(0,184,176,.12); border: 1px solid rgba(0,184,176,.45); border-radius: 5px; padding: 0 6px; max-width: 70%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* caption pill (gold): an annotated shot carries a longer caption from the@@ -773,9 +783,9 @@ body:not(.compact) .card-refbar .rb-cap { display: none; } </div> <div class="set-row"> <h4 class="set-name" title="webview_bring_forward"><span class="prefix">Notifications: </span><span class="title">Bring the webview tab forward</span></h4>- <p class="set-desc">When a shot lands and the Shotlog webview is open but behind another tab in its pane, pull it to the front (default). Turn it off if you live in webviews and do not want to be pulled away: you stay on whatever tab you were reading, and the webview still receives every shot (including auto-captures from the read-hook) in the background, so it is fully up to date the moment you switch to it. This is about the Hydrogen tab; the switch above is about channel tabs inside shotlog.</p>- <div class="set-ctl"><button class="toggle" id="prefBringFwd" role="switch" aria-checked="true" aria-label="Bring the webview tab forward" data-tooltip="On (default): every new shot pulls the Shotlog tab to the front of its pane. Off: the tab stays where it is; shots keep arriving in the background."><span class="toggle-track"><span class="toggle-thumb"></span></span></button></div>- <div class="set-foot"><span class="default-hint">Default: On</span><span class="ai-chip"><span class="lbl">or say to your AI</span>stop shotlog pulling its webview tab to the front</span></div>+ <p class="set-desc">Off (default): when a shot lands the Shotlog tab stays where it is; the webview still receives every shot (including auto-captures from the read-hook) in the background, so it is fully up to date the moment you switch to it, and nothing you are reading or recording gets covered. On: a new shot pulls the Shotlog tab to the front of its pane, except when another webview is the active tab there (then it only badges). This is about the Hydrogen tab; the switch above is about channel tabs inside shotlog.</p>+ <div class="set-ctl"><button class="toggle" id="prefBringFwd" role="switch" aria-checked="false" aria-label="Bring the webview tab forward" data-tooltip="Off (default): the Shotlog tab stays where it is when shots arrive. On: a new shot pulls the Shotlog tab to the front of its pane, unless another webview is active there."><span class="toggle-track"><span class="toggle-thumb"></span></span></button></div>+ <div class="set-foot"><span class="default-hint">Default: Off</span><span class="ai-chip"><span class="lbl">or say to your AI</span>stop shotlog pulling its webview tab to the front</span></div> </div> </section> <section class="settings-card" id="cardGallery">@@ -1005,6 +1015,7 @@ body:not(.compact) .card-refbar .rb-cap { display: none; } 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + '<rect x="9" y="9" width="12" height="12" rx="2"/>' + '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';+ const ICON_TRASH = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>'; const ICON_DONE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ' + 'stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + '<polyline points="20 6 9 17 4 12"/></svg>';@@ -1311,6 +1322,23 @@ body:not(.compact) .card-refbar .rb-cap { display: none; } } else { legacyCopy(txt, done); } }); rb.appendChild(cp); }+ // DELETE one shot (John, 2026-10-21): hover-revealed trash at the far+ // right of the ref bar; two clicks (the button itself becomes the+ // confirmation, red, disarms after 4s), then the shot and its files are+ // gone on the broker and every open viewer drops the card.+ { const del=document.createElement('button'); del.className='rb-del';+ del.innerHTML=ICON_TRASH; del.setAttribute('aria-label','Delete shot #'+e.id);+ del.setAttribute('data-tooltip','Delete shot #'+e.id+' (click twice: the second click confirms). Removes the image and its files for good.');+ let armT=null;+ const disarm=()=>{ del.classList.remove('armed'); del.innerHTML=ICON_TRASH; del.removeAttribute('data-armed'); clearTimeout(armT); };+ del.addEventListener('click', ev=>{ ev.stopPropagation();+ if(!del.dataset.armed){ del.dataset.armed='1'; del.classList.add('armed'); del.innerHTML='<span class="lbl">delete?</span>'; armT=setTimeout(disarm,4000); return; }+ disarm(); del.disabled=true;+ fetch('api/delete?channel='+encodeURIComponent(channel)+'&filename='+encodeURIComponent(e.filename),{method:'DELETE'})+ .then(r=>r.json()).then(r=>{ if(r.ok){ removeCard(channel, e.id); showToast('deleted shot #'+e.id+(r.bytes_freed?' ('+Math.max(1,Math.round(r.bytes_freed/1024))+' KB freed)':''), 4000); }+ else { del.disabled=false; showToast('could not delete: '+(r.error||'unknown'), 6000); } })+ .catch(()=>{ del.disabled=false; showToast('could not reach the broker', 4000); }); });+ rb.appendChild(del); } // The caption pill comes AFTER the copy button (Ray, 2026-10-16): the // button copies "#id handle", so it sits beside them, not past the caption. if (e.auto_description && e.description) {@@ -1547,6 +1575,18 @@ body:not(.compact) .card-refbar .rb-cap { display: none; } '<div class="tt-prompt"><span class="lbl">say to your AI</span>show me your progress in shotlog as you go</div>'+ cnt; }+ // Drop one shot from local state and the feed (own delete, or another+ // viewer's, via the ctrl channel). Idempotent.+ function removeCard(chName, id){+ const c = chans.get(chName);+ if (c && c.entries) { const i=c.entries.findIndex(x=>x.id===id); if(i>=0) c.entries.splice(i,1); }+ if (chName === channel) {+ const el = feed.querySelector(`[data-id="${id}"]`);+ if (el) { el.remove(); shotCount=Math.max(0, shotCount-1); updateCount(); }+ if (!feed.querySelector('.shot-card')) { feed.innerHTML='<div class="empty">No screenshots yet in '+esc(channel)+'</div>'; }+ }+ renderTabs();+ } function updateCount(){ const t = shotCount? shotCount+(shotCount===1?' shot':' shots'):''; $('shotCount').textContent = t; // Surface the count in the channel-name tooltip too, since .subject-meta is@@ -1878,8 +1918,8 @@ body:not(.compact) .card-refbar .rb-cap { display: none; } // Live status in every card title (recomputed truth, never a cached claim). const st=(id,txt,ok)=>{ const el=$(id); if(!el) return; el.textContent=txt; el.classList.toggle('ok', !!ok); }; st('stHook', p.read_hook!==false ? 'active' : 'off', p.read_hook!==false);- $('prefBringFwd').setAttribute('aria-checked', String(p.webview_bring_forward!==false));- st('stNotify', (p.update_alerts===true?'flashes on':'flashes off')+' · '+(p.update_autoswitch!==false?'auto-switch on':'auto-switch off')+' · '+(p.webview_bring_forward!==false?'tab comes forward':'tab stays put'), false);+ $('prefBringFwd').setAttribute('aria-checked', String(p.webview_bring_forward===true));+ st('stNotify', (p.update_alerts===true?'flashes on':'flashes off')+' · '+(p.update_autoswitch!==false?'auto-switch on':'auto-switch off')+' · '+(p.webview_bring_forward===true?'tab comes forward':'tab stays put'), false); st('stGallery', p.compact!==false ? 'compact' : 'classic', false); st('stCanvas', p.default_canvas||'webview', false); st('stLayout', own ? ('own column, '+(p.own_column_width??10)+'% wide') : ('shared pane'+(p.webview_autosize===true?', auto-sized':'')), false);@@ -2051,6 +2091,10 @@ body:not(.compact) .card-refbar .rb-cap { display: none; } document.body.classList.toggle('compact', APREFS.compact!==false); return; }+ if (msg.type==='deleted' && msg.channel) {+ removeCard(msg.channel, msg.id);+ return;+ } if (msg.type==='cleared-all') { // Someone wiped everything. If it was THIS page's clear button, it // already wiped locally and showed the detailed shots/threads/size
src/server.rs+48−12@@ -1046,6 +1046,35 @@ fn flash_pup_session(session: &str) { /// shotlog tabs are never cross-touched), then `move-tab` to its own pane: /// Hydrogen auto-activates it (surfaces to top) and `--alert` paints the ORANGE /// update highlight on the tab. Falls back to create-or-refresh if not found.+/// Is a DIFFERENT webview tab the active tab in the pane that holds `tab`?+/// (false on any lookup failure, so the raise behaves as before.)+fn another_webview_active_in_pane(tab: &str) -> bool {+ const WEBVIEW_PANEL_TYPE: &str = "adom/a1b2c3d4-0031-4000-a000-000000000031";+ let found = std::process::Command::new("adom-cli")+ .args(["hydrogen", "workspace", "find-tab", tab]).output().ok()+ .and_then(|o| serde_json::from_slice::<serde_json::Value>(&o.stdout).ok());+ let Some(my_id) = found.as_ref().and_then(|v| v.get("tabId").and_then(|t| t.as_str())).map(String::from) else { return false };+ let Some(layout) = std::process::Command::new("adom-cli")+ .args(["hydrogen", "workspace", "get"]).output().ok()+ .and_then(|o| serde_json::from_slice::<serde_json::Value>(&o.stdout).ok()) else { return false };+ fn walk(v: &serde_json::Value, my_id: &str, wv: &str) -> Option<bool> {+ if v.get("type").and_then(|t| t.as_str()) == Some("leaf") {+ let tabs = v.get("tabs").and_then(|t| t.as_array())?;+ if !tabs.iter().any(|t| t.get("id").and_then(|i| i.as_str()) == Some(my_id)) { return None; }+ let idx = v.get("activeTabIndex").and_then(|i| i.as_u64()).unwrap_or(0) as usize;+ let active = tabs.get(idx)?;+ let is_me = active.get("id").and_then(|i| i.as_str()) == Some(my_id);+ let is_webview = active.get("panelType").and_then(|p| p.as_str()) == Some(wv);+ return Some(!is_me && is_webview);+ }+ for k in ["root", "layout", "data", "first", "second"] {+ if let Some(c) = v.get(k) { if let Some(r) = walk(c, my_id, wv) { return Some(r); } }+ }+ None+ }+ walk(&layout, &my_id, WEBVIEW_PANEL_TYPE).unwrap_or(false)+}+ fn bring_webview_forward(channel: &str, port: u16, alerts: bool, bring_forward: bool) { if !bring_forward && !alerts { return; // nothing to do: the background webview already has the shot@@ -1065,7 +1094,11 @@ fn bring_webview_forward(channel: &str, port: u16, alerts: bool, bring_forward: // Setting "Bring the webview tab forward" (default OFF): only when ON // does the Shotlog tab get pulled to the front of its pane. OFF leaves // the human where they are; the webview still receives every shot.- let activated = if bring_forward {+ // Even when ON: never steal the active tab from ANOTHER WEBVIEW in the+ // same pane (wiki issue #9: a dashboard being screen-recorded was+ // covered by Shotlog on every image read). Badge instead. Raising over+ // a non-webview tab (editor, terminal) is still fine.+ let activated = if bring_forward && !another_webview_active_in_pane(&tab) { std::process::Command::new("adom-cli") .args(["hydrogen", "workspace", "active-tab", "--name", &tab]) .output()@@ -1584,17 +1617,20 @@ async fn delete_entry( None => return Json(serde_json::json!({ "ok": false, "error": "missing filename" })), }; - // Delete the PNG and JSON sidecar from disk- let channel_dir = state.store.base_dir().join(&channel);- let png_path = channel_dir.join(&filename);- let json_path = png_path.with_extension("json");- std::fs::remove_file(&png_path).ok();- std::fs::remove_file(&json_path).ok();-- // Remove from in-memory entries- state.store.delete_entry(&channel, &filename).await;-- Json(serde_json::json!({ "ok": true, "deleted": filename }))+ if channel.contains("..") || channel.contains('/') || filename.contains("..") || filename.contains('/') {+ return Json(serde_json::json!({ "ok": false, "error": "invalid path" }));+ }+ // One shot, gone for good: entry + every file it owns. Every open viewer+ // drops the card via the ctrl channel.+ match state.store.delete_entry(&channel, &filename).await {+ Some((entry, bytes)) => {+ println!("{} DECISION deleted shot #{} '{}' from '{}' ({} KB)", ts(), entry.id, filename, channel, bytes / 1024);+ let frame = serde_json::json!({"type": "deleted", "channel": channel, "id": entry.id, "filename": filename});+ let _ = state.store.ctrl_tx.send(frame.to_string());+ Json(serde_json::json!({ "ok": true, "deleted": filename, "id": entry.id, "bytes_freed": bytes }))+ }+ None => Json(serde_json::json!({ "ok": false, "error": "no such shot" })),+ } } // WebSocket: client connects to /ws/<channel>, receives new entries as JSON text frames
src/store.rs+22−5@@ -153,7 +153,11 @@ pub struct AppPrefs { /// webview keeps receiving every shot in the background and shows them all /// the moment they switch to it. The orange tab alert (update_alerts) /// applies either way.- #[serde(default = "default_true")]+ /// Default OFF since 0.29.2 (wiki issue #9, John 2026-10-20: the raise+ /// hijacked a night-long screen recording by covering the dashboard it was+ /// filming). ON raises the Shotlog tab unless another webview is the+ /// active tab in the same pane; then it only badges.+ #[serde(default = "default_false")] pub webview_bring_forward: bool, /// OWN COLUMN (default off): when the workspace already has a webview pane, /// shotlog opens as its OWN third column at the right edge, `own_column_width`@@ -183,7 +187,7 @@ impl Default for AppPrefs { fn default() -> Self { Self { default_canvas: default_canvas(), read_hook: true, max_shots: default_max_shots(), retention_days: default_retention_days(), update_alerts: false, webview_width: default_webview_width(),- update_autoswitch: true, webview_autosize: false, webview_bring_forward: true, webview_own_column: false, own_column_width: 10, compact: true } }+ update_autoswitch: true, webview_autosize: false, webview_bring_forward: false, webview_own_column: false, own_column_width: 10, compact: true } } } /// The flag file the read-hook script checks; present = hook disabled.@@ -664,11 +668,24 @@ impl Store { } /// Delete a single entry by filename.- pub async fn delete_entry(&self, channel_name: &str, filename: &str) {+ /// Delete ONE shot: its entry and every file it owns (PNG, sidecar, the+ /// __full pair, child-window PNGs). Returns (the removed entry, bytes freed).+ pub async fn delete_entry(&self, channel_name: &str, filename: &str) -> Option<(Entry, u64)> { let mut channels = self.channels.write().await;- if let Some(ch) = channels.get_mut(channel_name) {- ch.entries.retain(|e| e.filename != filename);+ let ch = channels.get_mut(channel_name)?;+ let idx = ch.entries.iter().position(|e| e.filename == filename)?;+ let entry = ch.entries.remove(idx);+ let p = std::path::Path::new(&entry.path);+ let mut owned: Vec<std::path::PathBuf> = vec![p.to_path_buf(), p.with_extension("json")];+ if let Some(dir) = p.parent() {+ if let Some(f) = &entry.full { owned.push(dir.join(f)); }+ if let Some(arr) = entry.popups.as_ref().and_then(|v| v.as_array()) {+ for c in arr { if let Some(f) = c.get("filename").and_then(|x| x.as_str()) { owned.push(dir.join(f)); } }+ } }+ let bytes: u64 = owned.iter().filter_map(|f| fs::metadata(f).ok()).map(|m| m.len()).sum();+ delete_entry_files(&entry);+ Some((entry, bytes)) } /// Clear in-memory entries for a channel (log only, files stay on disk).