← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-shotlog"-version = "0.21.1"+version = "0.22.0" dependencies = [  "axum",  "base64",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.21.1"+version = "0.22.0" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
README.md+1−1
@@ -41,7 +41,7 @@ adom-shotlog open -c my-project --skinny        # a ~20% right-side column ```  - It **auto-updates live** over a WebSocket: the moment any thread injects a shot, the card appears at the top of that channel, no refresh, no polling.-- You can keep **several channels as tabs in that one panel**. When a channel updates, the broker pulls that channel's tab to the front with an orange update alert so the newest activity is what you see (targeted by the tab's unique name, so with many shotlogs open in parallel it never touches the wrong one).+- **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`.) - The layout is **fully fluid** at that skinny width: the header wraps to rows, the child-window thumbnails reflow, and the outer page never shows a horizontal scrollbar. 
SKILL.md+3
@@ -95,6 +95,9 @@ Canvas rules the broker enforces (do not fight them): - The skinny panel width comes from the webview_width pref (default 20%);   do not pass --ratio unless the human asked for a specific width. - `shotlog open -c <ch> --pup --swap` / `--skinny --swap` is the switch verb.+- The viewer is ONE single-window app with ITS OWN channel tabs (read-captures+  pinned first). You never open one surface per channel anymore - plain+  `shotlog open -c <ch>` opens/deep-links THE app with that tab active.  ## Inject - one command 
VERSION+1−1
@@ -1 +1 @@-0.21.1+0.22.0
bin/adom-shotlog
⋯ 1 unchanged line ⋯
hooks/shotlog-on-read.sh+24−1
@@ -51,6 +51,29 @@ if [ -n "$TP" ] && [ -f "$TP" ]; then fi [ -z "$THREAD" ] && [ -n "$SID" ] && THREAD="thread ${SID:0:8}" +# WHY the shot exists: the user's current prompt (last-prompt record), pure+# grep/jq - zero AI tokens.+CONTEXT=""+if [ -n "$TP" ] && [ -f "$TP" ]; then+  CONTEXT="$(grep '"type":"last-prompt"' "$TP" 2>/dev/null | tail -1 | jq -r '.lastPrompt // empty' 2>/dev/null | head -c 220)"+fi++# PROVENANCE: infer the capture endpoint from the path bucket, and attach the+# AD sidecar (coordMap, child windows, orig size) when this is an AD capture.+SRC="read-hook"+META_ARGS=""+case "$f" in+  */screenshots/adom-desktop/window/*) SRC="read-hook/ad-window" ;;+  */screenshots/adom-desktop/screen/*) SRC="read-hook/ad-screen" ;;+  */adom-desktop-screenshots/*)        SRC="read-hook/ad-window" ;;+  *pup*|*/browser-shots/*)             SRC="read-hook/pup" ;;+  */recordings/*|*hydrogen*)           SRC="read-hook/hydrogen" ;;+esac+SIDECAR="${f%.safe.png}.json"; [ "$SIDECAR" = "$f" ] && SIDECAR="${f%.png}.json"+if [ -f "$SIDECAR" ] && grep -q "coordMap\|origWidth" "$SIDECAR" 2>/dev/null; then+  META_ARGS="--meta $SIDECAR"+fi+ # Satisfy shotlog's original-resolution contract (a blind pre-shrunk inject is # REJECTED). AD saves resized captures as *.safe.png beside the *.full.png # original - pass the original when it exists; otherwise the file Claude read@@ -68,7 +91,7 @@ esac for attempt in 1 2 3; do   if shotlog inject -c read-captures \     -d "Auto-captured screenshot Claude read for analysis: ${base}" \-    -s read-hook ${THREAD:+--thread "$THREAD"} $ORIG_ARGS "$f" >/dev/null 2>&1; then+    -s "$SRC" ${THREAD:+--thread "$THREAD"} ${CONTEXT:+--context "$CONTEXT"} $META_ARGS $ORIG_ARGS "$f" >/dev/null 2>&1; then     break   fi   sleep 2
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.21.1",+  "version": "0.22.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.21.1",+  "version": "0.22.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+170−15
@@ -143,6 +143,25 @@ header { .hover-popover .pop-actions { margin-top: 9px; padding-top: 9px; border-top: 1px solid var(--border-muted); } .hover-popover .pop-link { color: var(--accent); text-decoration: none; font-family: 'Familjen Grotesk', sans-serif; font-size: 11px; font-weight: 600; } +/* In-app CHANNEL TAB STRIP: shotlog owns its tabs (one window, many channels).+   read-captures pinned first; orange flash + unseen badge on updates. */+.chtabs { display: flex; align-items: center; gap: 2px; padding: 0 8px; height: 32px;+  flex-shrink: 0; background: var(--surface); border-bottom: 1px solid var(--border-muted);+  overflow-x: auto; overflow-y: hidden; }+.chtab { -webkit-appearance: none; appearance: none; display: inline-flex; align-items: center;+  gap: 6px; padding: 0 10px; height: 31px; border: none; background: transparent;+  color: var(--text2); font: 500 12px 'Familjen Grotesk', sans-serif; cursor: pointer;+  white-space: nowrap; border-bottom: 2px solid transparent; flex-shrink: 0; }+.chtab:hover { color: var(--text); }+.chtab.active { color: var(--accent-bright); border-bottom-color: var(--accent); }+.chtab .badge { min-width: 16px; height: 15px; padding: 0 4px; border-radius: 999px;+  background: var(--overlay); color: var(--text2); display: none;+  font: 600 9.5px 'JetBrains Mono', monospace; align-items: center; justify-content: center; }+.chtab.unseen .badge { display: inline-flex; background: rgba(255,140,0,.16);+  color: #ffa657; border: 1px solid rgba(255,140,0,.4); }+@keyframes tabflash { 0%,100% { background: transparent; } 50% { background: rgba(255,140,0,.16); } }+.chtab.flash { animation: tabflash 1s ease-in-out 4; }+ /* Gallery */ .feed { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; -webkit-overflow-scrolling: touch; } .empty { text-align: center; color: var(--text3); padding: 48px 20px; font-size: 13px; line-height: 1.6; }@@ -155,6 +174,13 @@ header { .shot-card-desc { padding: 10px 13px 5px; font-family: 'Satoshi', sans-serif; font-size: 13px;   font-weight: 500; color: var(--text); line-height: 1.4; max-height: 76px; overflow: hidden; flex-shrink: 0; } .shot-card-meta .thr { color: var(--purple); font-style: italic; }+.chip { display: inline-block; padding: 1px 6px; border-radius: 5px; background: var(--overlay);+  border: 1px solid var(--border-muted); font-family: 'JetBrains Mono', monospace;+  font-size: 9.5px; color: var(--text2); cursor: help; }+.chip.ad { color: var(--accent); border-color: rgba(0,184,176,.3); }+.chip.auto { color: var(--text3); }+.m-why { padding: 0 16px 8px; font: italic 12px/1.5 'Satoshi', sans-serif; color: var(--text2); }+.m-why .k { color: var(--text3); font-style: normal; font-family: 'JetBrains Mono', monospace; font-size: 9.5px; margin-right: 6px; } .shot-card-desc.no-desc { color: var(--danger); font-size: 11px; font-weight: 600; max-height: none; } .shot-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; padding: 3px 13px 7px;   font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); }@@ -373,6 +399,8 @@ header {   </div> </header> +<div class="chtabs" id="chtabs" role="tablist" aria-label="Channels"></div>+ <div class="feed" id="feed"><div class="empty">loading…</div></div>  <div class="helper" id="pupHelp" role="dialog" aria-modal="true" aria-label="Pup setup help">@@ -409,6 +437,14 @@ header {       </div>       <button class="toggle" id="prefAlerts" role="switch" aria-checked="true" data-tooltip="Toggle the orange update cues (webview tab highlight + pup taskbar flash) when new shots arrive."></button>     </div>+    <div class="pref-row">+      <div class="pref-info">+        <div class="pref-name">Auto-switch to updated channel</div>+        <div class="pref-desc">When a shot arrives for another channel, jump that channel's tab to the front. Turn off to sit where you are (the tab just flashes orange and shows an unseen badge instead).</div>+        <div class="ai-chip"><span class="lbl">or say to your AI</span>stop shotlog auto-switching tabs</div>+      </div>+      <button class="toggle" id="prefAutoswitch" role="switch" aria-checked="true" data-tooltip="Auto-switch the in-app channel tab when a new shot arrives for it."></button>+    </div>     <div class="pref-row">       <div class="pref-info">         <div class="pref-name">Default canvas</div>@@ -469,12 +505,24 @@ header {   const mPopups = $('mPopups'), mNerd = $('mNerd');    // Channel from URL path /log/<channel>/ (matches Rust route + base href)+  // Single-window app: shotlog owns its channel tabs. The path (or ?channel=)+  // only picks the INITIALLY active tab.   const m = location.pathname.match(/\/log\/([^/]+)\/?/);-  const channel = m ? decodeURIComponent(m[1]) : 'default';+  let channel = (m && decodeURIComponent(m[1]))+    || new URLSearchParams(location.search).get('channel')+    || 'read-captures';   $('channelName').textContent = channel;   document.title = 'shotlog · ' + channel; -  let entries = [];     // full list (ascending by id)+  // channel name -> { entries: null|[], unseen: 0, last: epoch-ms }+  const chans = new Map();+  function ensureChan(name){+    if(!chans.has(name)) chans.set(name, { entries: null, unseen: 0, last: 0 });+    return chans.get(name);+  }+  let APREFS = { update_autoswitch: true };++  let entries = [];     // the ACTIVE channel's list (ascending by id)   let shotCount = 0;    // ── Tooltip system (body-appended, 600ms, cursor-quadrant) ───────────────@@ -537,6 +585,31 @@ header {   function imgUrl(e){ return 'shots/'+encodeURIComponent(channel)+'/'+encodeURIComponent(e.filename); }   function popUrl(fn){ return 'shots/'+encodeURIComponent(channel)+'/'+encodeURIComponent(fn); }   // Child windows can be stored as a bare array (current) or wrapped — normalize to an array.+  // Provenance: map source strings (possibly compound "read-hook/ad-window")+  // to labeled chips explaining WHICH capture endpoint produced the image.+  const SRC_MAP={+    'read-hook':{label:'auto',cls:'auto',tip:'Auto-captured by the read-hook: an AI thread read this image and shotlog copied it here.'},+    'ad-window':{label:'AD·window',cls:'ad',tip:'Adom Desktop window screenshot (desktop_screenshot_window): one app window, captured even when backgrounded; may include child windows (dropdowns/dialogs).'},+    'desktop_screenshot_window':{label:'AD·window',cls:'ad',tip:'Adom Desktop window screenshot (desktop_screenshot_window): one app window, captured even when backgrounded; may include child windows.'},+    'ad-screen':{label:'AD·screen',cls:'ad',tip:'Adom Desktop full-screen screenshot (desktop_screenshot_screen): the whole monitor, popups and all.'},+    'desktop_screenshot_screen':{label:'AD·screen',cls:'ad',tip:'Adom Desktop full-screen screenshot: the whole monitor.'},+    'pup':{label:'pup',cls:'',tip:'Captured by pup (the Puppeteer browser bridge): a real rendered browser page, lossless.'},+    'pup_screenshot':{label:'pup',cls:'',tip:'Captured by pup (the Puppeteer browser bridge): a real rendered browser page, lossless.'},+    'hydrogen':{label:'hydrogen',cls:'',tip:'Captured by Hydrogen\'s internal workspace screenshotter.'},+    'abe':{label:'ABE',cls:'',tip:'Captured by the Adom Browser Extension driving the user\'s real signed-in browser.'},+    'nbrowser':{label:'ABE',cls:'',tip:'Captured by the Adom Browser Extension (nbrowser) in the user\'s real browser.'},+    'hd':{label:'HD',cls:'',tip:'Captured by Hydrogen Desktop\'s built-in screenshot feature.'},+    'hd_shot':{label:'HD',cls:'',tip:'Captured by Hydrogen Desktop\'s built-in screenshot feature.'},+    'crop':{label:'crop',cls:'',tip:'Cropped from a larger original image.'},+    'clipboard':{label:'paste',cls:'',tip:'Pasted from the clipboard by the human (Ctrl+V in the viewer).'},+  };+  function srcChips(e){+    if(!e.source || e.source==='cli') return [];+    return e.source.split('/').map(part=>{+      const m=SRC_MAP[part];+      return m || {label:part, cls:'', tip:'Capture source: '+part};+    });+  }   function popList(e){ const p=e&&e.popups; return Array.isArray(p)?p:(p&&Array.isArray(p.popups)?p.popups:[]); }   // Build a flat readable [key,value] view of the capture metadata for the nerd panel.   function metaRows(e){@@ -600,11 +673,12 @@ header {     if (e.w&&e.h){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s); dimNodes(e,meta); }     if (e.kb){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);       const kb=document.createElement('span'); kb.textContent=e.kb+' KB'; meta.appendChild(kb); }-    if (e.source && e.source!=='cli'){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);-      const sr=document.createElement('span'); sr.className='src'; sr.textContent=e.source; meta.appendChild(sr); }+    srcChips(e).forEach(c=>{ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);+      const ch=document.createElement('span'); ch.className='chip '+(c.cls||''); ch.textContent=c.label;+      ch.setAttribute('data-tooltip', c.tip); meta.appendChild(ch); });     if (e.thread){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);       const th=document.createElement('span'); th.className='thr'; th.textContent=e.thread;-      th.setAttribute('data-tooltip','The AI thread that produced this shot.'); meta.appendChild(th); }+      th.setAttribute('data-tooltip','The AI thread that produced this shot.'+(e.context?'\n\nWorking on: "'+e.context+'"':'')); meta.appendChild(th); }     const pops=popList(e);     if (pops.length){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);       const b=document.createElement('span'); b.className='badge-pop'; b.textContent='+'+pops.length+' child window'+(pops.length>1?'s':'');@@ -647,6 +721,10 @@ header {   function openModal(e) {     mImg.src=imgUrl(e); mImg.alt=e.description||e.filename;     mDesc.textContent=e.description||''; mDesc.style.display=e.description?'':'none';+    let why=document.getElementById('mWhy');+    if(!why){ why=document.createElement('div'); why.id='mWhy'; why.className='m-why'; mDesc.after(why); }+    if(e.context){ why.innerHTML='<span class="k">while working on</span>'+esc('"'+e.context+'"'); why.style.display=''; }+    else { why.style.display='none'; }     mMeta.innerHTML='';     const add=(cls,txt,tip)=>{ const s=document.createElement('span'); if(cls)s.className=cls; s.textContent=txt; if(tip)s.setAttribute('data-tooltip',tip); mMeta.appendChild(s); };     const sep=()=>add('sep','·');@@ -745,9 +823,65 @@ header {     $('channelName')._ttHtml = channelTooltipHtml(t); }    // ── Load ───────────────────────────────────────────────────────────────-  fetch('api/shots?channel='+encodeURIComponent(channel)).then(r=>r.json()).then(data=>{-    entries = Array.isArray(data)?data:[]; renderAll(); feed.scrollTop=0;-  }).catch(e=>{ feed.innerHTML='<div class="empty">load failed: '+esc(e.message)+'</div>'; });+  // ── Tab strip: shotlog-managed channel tabs ───────────────────────────────+  const strip=$('chtabs');+  function tabOrder(){+    const names=[...chans.keys()];+    names.sort((a,b)=>{+      if(a==='read-captures') return -1;+      if(b==='read-captures') return 1;+      return (chans.get(b).last||0)-(chans.get(a).last||0);+    });+    return names;+  }+  function renderTabs(){+    strip.innerHTML='';+    for(const name of tabOrder()){+      const c=chans.get(name);+      const b=document.createElement('button');+      b.className='chtab'+(name===channel?' active':'')+(c.unseen>0?' unseen':'');+      b.setAttribute('role','tab');+      b.setAttribute('data-tooltip', name==='read-captures'+        ? 'The global auto-capture tab: every image any AI thread reads, from all threads intermingled.'+        : 'Channel "'+name+'". Click to view; the badge counts shots you have not seen yet.');+      const nm=document.createElement('span'); nm.textContent=name; b.appendChild(nm);+      const bd=document.createElement('span'); bd.className='badge'; bd.textContent=c.unseen>99?'99+':String(c.unseen); b.appendChild(bd);+      b.addEventListener('click',()=>activate(name));+      strip.appendChild(b);+    }+  }+  function flashTab(name){+    const idx=tabOrder().indexOf(name);+    const el=strip.children[idx];+    if(el){ el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash'); }+  }+  async function activate(name){+    channel=name;+    const c=ensureChan(name);+    $('channelName').textContent=name;+    document.title='shotlog · '+name;+    if(c.entries===null){+      try{ const data=await fetch('api/shots?channel='+encodeURIComponent(name)).then(r=>r.json());+        c.entries=Array.isArray(data)?data:[]; }catch{ c.entries=[]; }+    }+    entries=c.entries;+    c.unseen=0;+    renderAll(); feed.scrollTop=0;+    renderTabs();+  }+  // Boot: seed tabs from the broker's channel overview, then activate.+  (async function bootApp(){+    try{ APREFS=await fetch('api/prefs').then(r=>r.json()); }catch{}+    try{+      const rows=await fetch('api/channels').then(r=>r.json());+      if(Array.isArray(rows)) for(const r of rows){+        const c=ensureChan(r.channel); c.last=(r.last_epoch||0)*1000;+      }+    }catch{}+    ensureChan('read-captures');+    ensureChan(channel);+    await activate(channel);+  })();    // ── Canvas toggle: show this channel in pup (full browser) or webview ─────   (function initCanvasToggle(){@@ -918,6 +1052,8 @@ header {     function renderPrefs(p){       hook.setAttribute('aria-checked', String(!!p.read_hook));       $('prefAlerts').setAttribute('aria-checked', String(p.update_alerts!==false));+      $('prefAutoswitch').setAttribute('aria-checked', String(p.update_autoswitch!==false));+      APREFS=p;       seg.querySelectorAll('button').forEach(b=>b.classList.toggle('on', b.dataset.v===p.default_canvas));       $('prefWidth').value = p.webview_width ?? 20;       $('prefMaxShots').value = p.max_shots ?? 200;@@ -938,6 +1074,10 @@ header {       const now=$('prefAlerts').getAttribute('aria-checked')==='true';       patch({update_alerts: !now}, !now?'orange update flashes ON.':'orange update flashes OFF: tabs still come forward, no flashing.');     });+    $('prefAutoswitch').addEventListener('click',()=>{+      const now=$('prefAutoswitch').getAttribute('aria-checked')==='true';+      patch({update_autoswitch: !now}, !now?'auto-switch ON: updated channels come to the front.':'auto-switch OFF: tabs flash + badge, you stay put.');+    });     seg.addEventListener('click',e=>{       const b=e.target.closest('button[data-v]'); if(!b) return;       patch({default_canvas: b.dataset.v}, 'channels now default to '+b.dataset.v+'.');@@ -978,7 +1118,7 @@ header {   function connectWs() {     const proto = location.protocol==='https:'?'wss:':'ws:';     const baseHref = document.querySelector('base')?.href.replace(location.origin,'') || '/';-    const ws = new WebSocket(`${proto}//${location.host}${baseHref}ws/${encodeURIComponent(channel)}`);+    const ws = new WebSocket(`${proto}//${location.host}${baseHref}ws/_all`);     // Version handshake: the broker announces its version on connect; a page     // from an older deploy reloads ITSELF (once) so no surface ever goes stale.     ws.addEventListener('message', ev => {@@ -1001,12 +1141,27 @@ header {       try {         const msg = JSON.parse(ev.data);         if (msg.type==='entry' && msg.entry) {-          const e = msg.entry, existing = entries.find(x=>x.id===e.id);-          const atTop = feed.scrollTop < 60;-          if (existing) { Object.assign(existing,e); const old=feed.querySelector(`[data-id="${e.id}"]`); if(old) old.replaceWith(card(e)); }-          else { entries.push(e); const empty=feed.querySelector('.empty'); if(empty) feed.innerHTML='';-            feed.insertBefore(card(e), feed.firstChild); shotCount++; updateCount(); if(atTop) feed.scrollTop=0;-            if (document.hidden) { pendingUnseen++; refreshTitle(); } }+          const e = msg.entry, chName = msg.channel || channel;+          const c = ensureChan(chName);+          c.last = Date.now();+          if (c.entries !== null) {+            const existing = c.entries.find(x=>x.id===e.id);+            if (existing) Object.assign(existing, e); else c.entries.push(e);+          }+          if (chName === channel) {+            const atTop = feed.scrollTop < 60;+            const old = feed.querySelector(`[data-id="${e.id}"]`);+            if (old) { old.replaceWith(card(e)); }+            else { const empty=feed.querySelector('.empty'); if(empty) feed.innerHTML='';+              feed.insertBefore(card(e), feed.firstChild); shotCount++; updateCount(); if(atTop) feed.scrollTop=0;+              if (document.hidden) { pendingUnseen++; refreshTitle(); } }+            renderTabs();+          } else if (APREFS.update_autoswitch !== false) {+            // The in-app mirror of pull-to-front: jump to the updated channel.+            activate(chName).then(()=>flashTab(chName));+          } else {+            c.unseen++; renderTabs(); flashTab(chName);+          }         }       } catch {}     };
src/main.rs+9−3
@@ -167,6 +167,10 @@ enum Commands {         /// read-hook fills this automatically from the session transcript.         #[arg(long)]         thread: Option<String>,+        /// WHY the shot exists: the user prompt being served (the read-hook+        /// fills this automatically; pass your task summary when injecting).+        #[arg(long)]+        context: Option<String>,         /// PNG file path (use - for stdin)         file: String,     },@@ -290,7 +294,7 @@ async fn main() {             run_server(port, data_dir).await;         }         Commands::Inject {-            channel, desc, source, port, orig_file, orig_w, orig_h, native, meta, thread,+            channel, desc, source, port, orig_file, orig_w, orig_h, native, meta, thread, context,             file,         } => {             let desc = match desc {@@ -417,7 +421,7 @@ async fn main() {                 }             }; -            run_inject(&channel, &desc, &source, port, &file, false, orig_w, orig_h, cap_meta, popups, thread.as_deref());+            run_inject(&channel, &desc, &source, port, &file, false, orig_w, orig_h, cap_meta, popups, thread.as_deref(), context.as_deref());         }         Commands::Open {             channel, name, icon, port, skinny, ratio, pup, swap, webview,@@ -473,7 +477,7 @@ async fn main() {                     std::process::exit(1);                 }             };-            run_inject(&channel, &desc, &source, port, &file, true, None, None, None, None, None);+            run_inject(&channel, &desc, &source, port, &file, true, None, None, None, None, None, None);         }         Commands::Resize { file, output, width } => {             run_resize(&file, output.as_deref(), width);@@ -575,6 +579,7 @@ fn run_inject(     cap_meta: Option<serde_json::Value>,     popups: Option<Vec<serde_json::Value>>,     thread: Option<&str>,+    context: Option<&str>, ) {     let bytes = if file == "-" {         use std::io::Read;@@ -606,6 +611,7 @@ fn run_inject(             "description": desc,             "source": source,         "thread": thread,+        "context": context,         });         if let (Some(ow), Some(oh)) = (orig_w, orig_h) {             b["orig_w"] = serde_json::json!(ow);
src/open.rs+9−6
@@ -17,7 +17,7 @@ pub fn open_webview(     skinny: bool,     ratio: f64, ) {-    let default_name = format!("Shots: {channel}");+    let default_name = "Shotlog".to_string();     let name = display_name.unwrap_or(&default_name);     let icon = icon.unwrap_or("mdi:camera-burst"); @@ -29,8 +29,9 @@ pub fn open_webview(             format!("http://127.0.0.1:{port}")         }     };-    // ?surface=webview so live-viewer presence labels this correctly.-    let url = format!("{proxy_uri}log/{channel}/?surface=webview");+    // The SINGLE-WINDOW app: /log/ hosts every channel as in-app tabs;+    // ?channel deep-links the initially active tab.+    let url = format!("{proxy_uri}log/?surface=webview&channel={channel}");     let initial_state = serde_json::json!({ "url": url }).to_string();      // Fetch the layout once.@@ -188,7 +189,7 @@ pub fn open_pup(channel: &str, port: u16, swap: bool) {             std::process::exit(1);         }     };-    let url = format!("{proxy_uri}log/{channel}/?surface=pup&session=shotlog");+    let url = format!("{proxy_uri}log/?surface=pup&session=shotlog&channel={channel}");      let ad = |args: &[&str]| -> (bool, String) {         let mut cmd = Command::new("adom-desktop");@@ -210,7 +211,8 @@ pub fn open_pup(channel: &str, port: u16, swap: bool) {     //   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 _ = &channel;+    let needle = "/log/".to_string();     // The bridge's tab REGISTRY can outlive the actual Chrome window (a closed     // window leaves GHOST tab records; verbs then fail "browser is not     // connected"). So: try the cheap path, but ANY failure of switch/open_tab@@ -278,7 +280,8 @@ pub fn close_pup_tab(channel: &str) -> bool {             Err(e) => (false, e.to_string()),         }     };-    let needle = format!("/log/{channel}/");+    let _ = &channel;+    let needle = "/log/".to_string();     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 };
src/server.rs+114−16
@@ -67,8 +67,10 @@ pub fn create_router(state: Arc<AppState>) -> Router {      Router::new()         .route("/", get(root_redirect))+        .route("/log/", get(serve_viewer))         .route("/log/{channel}", get(log_redirect))         .route("/log/{channel}/", get(serve_viewer))+        .route("/ws/_all", get(ws_all_handler))         .route("/favicon.svg", get(serve_favicon))         .route("/log/{channel}/favicon.svg", get(serve_favicon))         .route("/api/shots", get(get_shots))@@ -99,7 +101,7 @@ pub fn create_router(state: Arc<AppState>) -> Router { }  async fn root_redirect() -> Redirect {-    Redirect::temporary("/log/default/")+    Redirect::temporary("/log/") }  async fn log_redirect(Path(channel): Path<String>) -> Redirect {@@ -309,6 +311,7 @@ struct PrefsPatch {     retention_days: Option<u64>,     update_alerts: Option<bool>,     webview_width: Option<u8>,+    update_autoswitch: Option<bool>, }  /// Patch global preferences from the viewer's Preferences dialog.@@ -335,6 +338,9 @@ async fn set_prefs(     if let Some(a) = patch.update_alerts {         p.update_alerts = a;     }+    if let Some(a) = patch.update_autoswitch {+        p.update_autoswitch = a;+    }     if let Some(w) = patch.webview_width {         if !(10..=50).contains(&w) {             return Json(serde_json::json!({"ok": false, "error": "webview_width must be 10-50 (percent)"}));@@ -414,7 +420,8 @@ async fn canvas_close(     let surface = req.surface.clone();     let v = tokio::task::spawn_blocking(move || {         if req.surface == "webview" {-            let name = format!("Shots: {}", req.channel);+            let _ = &req.channel;+            let name = "Shotlog".to_string();             let out = std::process::Command::new("adom-cli")                 .args(["hydrogen", "workspace", "remove-tab", "--name", &name])                 .output();@@ -605,15 +612,9 @@ fn notify_surface(channel: &str, port: u16, infos: &[crate::store::ViewerInfo],             flash_pup_session(&s);         }     }-    // Pup mirror of the webview pull-to-front: switch the shotlog pup window's-    // active tab to this channel so the update is what the user sees. (Tab-    // switch INSIDE our own window - never touches window focus/state.)-    if has_pup && debounce_ok(&format!("pupswitch:{channel}"), 3) {-        let ch = channel.to_string();-        std::thread::spawn(move || {-            crate::open::switch_pup_tab(&ch);-        });-    }+    // In the single-window app, per-channel tab surfacing happens IN-APP+    // (the viewer's own tab strip); no external pup tab switching needed.+    let _ = has_pup;      let has_webview = infos.iter().any(|i| i.surface == "webview");     if has_webview {@@ -706,7 +707,8 @@ fn bring_webview_forward(channel: &str, port: u16, alerts: bool) {         // workspace-tree re-render. (The old approach bounced the tab through         // the user's VS Code pane via move-tab, which re-rendered the whole         // Hydrogen page and interrupted typing. Never do that.)-        let tab = format!("Shots: {channel}");+        let _ = &channel;+        let tab = "Shotlog".to_string();         let activated = std::process::Command::new("adom-cli")             .args(["hydrogen", "workspace", "active-tab", "--name", &tab])             .output()@@ -770,6 +772,9 @@ struct InjectRequest {     /// Which AI thread produced this shot (human-readable title). The read-hook     /// fills this from the session transcript; any injector may pass it.     thread: Option<String>,+    /// WHY the shot exists: the user prompt the thread was serving (the+    /// read-hook extracts it programmatically from the transcript).+    context: Option<String>, }  /// Images larger than this on any edge are auto-resized (Claude crashes >1500px).@@ -866,7 +871,7 @@ async fn post_shots(      let entry = state         .store-        .add_entry(&req.channel, bytes, filename, req.description, source, w, h, orig_w, orig_h, req.meta, popups_stored, req.thread.clone())+        .add_entry(&req.channel, bytes, filename, req.description, source, w, h, orig_w, orig_h, req.meta, popups_stored, req.thread.clone(), req.context.clone())         .await;      // Presence at inject time — the caller uses this to know if the human is@@ -888,9 +893,12 @@ async fn post_shots(     let force_open_on = std::env::var("SHOTLOG_NO_FORCE_OPEN").is_err();     let app_prefs = state.store.app_prefs().await;     let alerts_on = app_prefs.update_alerts;-    let canvas_pref = match state.store.canvas_pref(&req.channel).await {+    let canvas_pref = match state.store.canvas_pref("_app").await {         Some(c) => c,-        None => app_prefs.default_canvas,+        None => match state.store.canvas_pref(&req.channel).await {+            Some(c) => c,+            None => app_prefs.default_canvas,+        },     };     let mut actions: Vec<serde_json::Value> = Vec::new();     if !pup_sessions.is_empty() && alerts_on {@@ -1003,7 +1011,7 @@ async fn post_paste(             &req.channel, bytes, filename, description, "clipboard".into(), w, h,             if was_resized { Some(orig_w) } else { None },             if was_resized { Some(orig_h) } else { None },-            None, None, None,+            None, None, None, None,         )         .await; @@ -1151,6 +1159,96 @@ async fn delete_entry( }  // WebSocket: client connects to /ws/<channel>, receives new entries as JSON text frames+/// The single-window app's WebSocket: EVERY channel's new entries as+/// {type:"entry", channel, entry}. Presence registers under "_all" (an app+/// viewer watches every channel at once).+async fn ws_all_handler(+    ws: WebSocketUpgrade,+    State(state): State<Arc<AppState>>,+) -> impl IntoResponse {+    ws.on_upgrade(move |socket| ws_all_connection(socket, state))+}++async fn ws_all_connection(mut socket: WebSocket, state: Arc<AppState>) {+    let mut rx = state.store.all_tx.subscribe();+    let vid = state.next_viewer_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed);+    let mut conn_surface: String = "browser".into();+    state.store.register_viewer("_all", vid, "browser".into(), true, None).await;+    let _ = socket.send(Message::Text(+        serde_json::json!({"type": "welcome", "version": env!("CARGO_PKG_VERSION")}).to_string().into()+    )).await;+    loop {+        tokio::select! {+            result = rx.recv() => {+                match result {+                    Ok((channel, entry)) => {+                        let json = serde_json::json!({"type": "entry", "channel": channel, "entry": entry});+                        if socket.send(Message::Text(json.to_string().into())).await.is_err() { break; }+                    }+                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,+                    Err(_) => break,+                }+            }+            msg = socket.recv() => {+                match msg {+                    Some(Ok(Message::Close(_))) | None => break,+                    Some(Ok(Message::Text(t))) => {+                        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&t) {+                            match v.get("type").and_then(|x| x.as_str()) {+                                Some("hello") => {+                                    let surface = v.get("surface").and_then(|x| x.as_str()).unwrap_or("browser").to_string();+                                    let visible = v.get("visible").and_then(|x| x.as_bool()).unwrap_or(true);+                                    let session = v.get("session").and_then(|x| x.as_str()).map(String::from);+                                    conn_surface = surface.clone();+                                    state.store.register_viewer("_all", vid, surface, visible, session).await;+                                }+                                Some("vis") => {+                                    let visible = v.get("visible").and_then(|x| x.as_bool()).unwrap_or(true);+                                    state.store.set_viewer_visibility("_all", vid, visible).await;+                                }+                                _ => {}+                            }+                        }+                    }+                    _ => {}+                }+            }+        }+    }+    state.store.unregister_viewer("_all", vid).await;+    // User-closed-pup detection for the SINGLE app window: sets an in-memory+    // app-level note so the next force-open uses webview this session.+    if conn_surface == "pup" {+        let store = state.store.clone();+        tokio::spawn(async move {+            tokio::time::sleep(std::time::Duration::from_secs(8)).await;+            if store.viewer_infos("_all").await.iter().any(|i| i.surface == "pup") { return; }+            let verdict: Option<bool> = tokio::task::spawn_blocking(move || {+                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_list_tabs", "{\"sessionId\":\"shotlog\"}"]).output() {+                    Ok(o) => {+                        let out = String::from_utf8_lossy(&o.stdout).to_string();+                        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&out) {+                            if let Some(tabs) = v.get("tabs").and_then(|t| t.as_array()) {+                                return Some(!tabs.iter().any(|t| t.get("url").and_then(|u| u.as_str())+                                    .map(|u| u.contains("/log/")).unwrap_or(false)));+                            }+                            if out.contains("session_not_found") { return Some(true); }+                        }+                        None+                    }+                    Err(_) => None,+                }+            }).await.unwrap_or(None);+            if verdict == Some(true) && !store.viewer_infos("_all").await.iter().any(|i| i.surface == "pup") {+                store.set_canvas_pref("_app", "webview").await;+                println!("{} DECISION shotlog pup window closed by the user - app note set to webview until restart.", ts());+            }+        });+    }+}+ async fn ws_handler(     ws: WebSocketUpgrade,     Path(channel): Path<String>,
src/store.rs+35−6
@@ -39,6 +39,11 @@ pub struct Entry {     /// metadata, or any injector passing --thread). Human-readable title.     #[serde(skip_serializing_if = "Option::is_none")]     pub thread: Option<String>,+    /// WHY the shot exists: the user's prompt the thread was serving when it+    /// captured/read this image (extracted programmatically from the session+    /// transcript's last-prompt record - zero AI tokens).+    #[serde(skip_serializing_if = "Option::is_none")]+    pub context: Option<String>, }  struct Channel {@@ -108,6 +113,10 @@ pub struct AppPrefs {     /// Width of the skinny webview panel as a percent of the workspace (10-50).     #[serde(default = "default_webview_width")]     pub webview_width: u8,+    /// In the single-window viewer: automatically switch to the channel tab+    /// that just received a shot (vs sit where you are with a badge + flash).+    #[serde(default = "default_true")]+    pub update_autoswitch: bool, } fn default_canvas() -> String { "webview".into() } fn default_true() -> bool { true }@@ -117,7 +126,8 @@ fn default_webview_width() -> u8 { 20 } 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: true, webview_width: default_webview_width() } }+        update_alerts: true, webview_width: default_webview_width(),+        update_autoswitch: true } } }  /// The flag file the read-hook script checks; present = hook disabled.@@ -139,6 +149,9 @@ pub struct ViewerInfo {  pub struct Store {     channels: RwLock<HashMap<String, Channel>>,+    /// Global feed: every new entry from EVERY channel, for the single-window+    /// multi-tab viewer (/ws/_all).+    pub all_tx: broadcast::Sender<(String, Entry)>,     base_dir: PathBuf,     /// Live viewer presence: channel -> (connection id -> viewer info).     /// Surface is what the viewer reported on connect: webview | pup | mobile | browser.@@ -170,8 +183,10 @@ impl Store {         let flag = read_hook_off_flag();         if app_prefs.read_hook { let _ = fs::remove_file(&flag); }         else { let _ = fs::write(&flag, "disabled via shotlog Preferences\n"); }+        let (all_tx, _) = broadcast::channel(256);         let store = Arc::new(Self {             channels: RwLock::new(HashMap::new()),+            all_tx,             base_dir,             presence: RwLock::new(HashMap::new()),             canvas_prefs: RwLock::new(prefs),@@ -235,7 +250,7 @@ impl Store {                  // Read sidecar metadata if it exists                 let meta_path = path.with_extension("json");-                let (description, source, timestamp, orig_w, orig_h, cap_meta, popups, thread) = if let Ok(json_str) = fs::read_to_string(&meta_path) {+                let (description, source, timestamp, orig_w, orig_h, cap_meta, popups, thread, context) = if let Ok(json_str) = fs::read_to_string(&meta_path) {                     if let Ok(v) = serde_json::from_str::<serde_json::Value>(&json_str) {                         let desc = v["description"].as_str().unwrap_or("").to_string();                         let src = v["source"].as_str().unwrap_or("recovered").to_string();@@ -247,12 +262,13 @@ impl Store {                         let cm = if v.get("meta").map(|m| !m.is_null()).unwrap_or(false) { Some(v["meta"].clone()) } else { None };                         let pp = if v.get("popups").map(|p| !p.is_null()).unwrap_or(false) { Some(v["popups"].clone()) } else { None };                         let th = v.get("thread").and_then(|t| t.as_str()).map(String::from);-                        (desc, src, ts, ow, oh, cm, pp, th)+                        let cx = v.get("context").and_then(|t| t.as_str()).map(String::from);+                        (desc, src, ts, ow, oh, cm, pp, th, cx)                     } else {-                        (String::new(), "recovered".into(), modified, None, None, None, None, None)+                        (String::new(), "recovered".into(), modified, None, None, None, None, None, None)                     }                 } else {-                    (String::new(), "recovered".into(), modified, None, None, None, None, None)+                    (String::new(), "recovered".into(), modified, None, None, None, None, None, None)                 };                  let png_meta = read_png_meta(&bytes_raw);@@ -273,6 +289,7 @@ impl Store {                     meta: cap_meta,                     popups,                     thread,+                    context,                 });                 next_id += 1;             }@@ -313,6 +330,7 @@ impl Store {         meta: Option<serde_json::Value>,         popups: Option<serde_json::Value>,         thread: Option<String>,+        context: Option<String>,     ) -> Entry {         let channel_dir = self.base_dir.join(channel_name);         fs::create_dir_all(&channel_dir).ok();@@ -339,6 +357,7 @@ impl Store {         if let Some(m) = &meta { meta_json["meta"] = m.clone(); }         if let Some(p) = &popups { meta_json["popups"] = p.clone(); }         if let Some(t) = &thread { meta_json["thread"] = serde_json::json!(t); }+        if let Some(c) = &context { meta_json["context"] = serde_json::json!(c); }         fs::write(&meta_path, meta_json.to_string()).ok();          let mut channels = self.channels.write().await;@@ -370,6 +389,7 @@ impl Store {             meta,             popups,             thread,+            context,         };          ch.next_id += 1;@@ -385,6 +405,7 @@ impl Store {          // Broadcast to SSE listeners (ignore errors — no receivers is fine)         let _ = ch.tx.send(entry.clone());+        let _ = self.all_tx.send((channel_name.to_string(), entry.clone()));          entry     }@@ -431,9 +452,17 @@ impl Store {     }      /// Full presence for a channel — one entry per connected viewer.+    /// Presence for a channel INCLUDING single-window app viewers (registered+    /// under "_all"), who watch every channel at once.     pub async fn viewer_infos(&self, channel_name: &str) -> Vec<ViewerInfo> {         let p = self.presence.read().await;-        p.get(channel_name).map(|m| m.values().cloned().collect()).unwrap_or_default()+        let mut v: Vec<ViewerInfo> = p.get(channel_name).map(|m| m.values().cloned().collect()).unwrap_or_default();+        if channel_name != "_all" {+            if let Some(m) = p.get("_all") {+                v.extend(m.values().cloned());+            }+        }+        v     }      /// Surfaces of the live viewers for a channel, e.g. ["pup","mobile"].