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.28.0
15 files changed
+357−36
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-shotlog"-version = "0.27.3"+version = "0.28.0" dependencies = [ "axum", "base64",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.27.3"+version = "0.28.0" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
README.md+19−5@@ -45,9 +45,21 @@ 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. - **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`.)+- **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. - 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. +### Refer to a shot by name++Every card shows its **ref** (`#42`) in a slim strip above the thumbnail (never on the image, so a screenshot of the thumbnail stays clean). Say "#42 is the one" to your AI and it knows exactly which shot you mean. An AI iterating on variants can also give each shot a **handle**:++```+adom-shotlog inject -c hero -d "hero variant G, tighter crop" --tag heroG variant-g.png+```++The handle shows beside the ref (`#42 heroG`), and the inject reply hands the AI its ref back (`Ref: #42, handle "heroG"`), so "heroG is the one to use" is unambiguous on both sides.+ ### Phone Ask your AI to `shotlog the whole thing`, and it hands you one URL. Open that URL **on your phone** and watch every step stream in as it happens, then scroll back through the whole run whenever you want.@@ -58,7 +70,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). 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 a `Webview` button to switch back). The button checks whether pup is available first; if Adom Bridge isn't installed it tells you how to get it (pup is a default bridge that auto-installs with Adom Bridge). 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.+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). On any viewport 900px or wider the thumbnails themselves take the card's full width at natural aspect (capped at one screen height), so a wide pup window is not wasted on margins. 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 a `Webview` button to switch back). The button checks whether pup is available first; if Adom Bridge isn't installed it tells you how to get it (pup is a default bridge that auto-installs with Adom Bridge). 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? @@ -105,8 +117,10 @@ The viewer has a **Settings** dialog (the gear in the header). Every setting is | **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`. |-| **Auto-size the webview panel** | OFF | ON re-forces the panel to your width on every open; OFF (default) leaves the column at whatever width you dragged it to (the width below only seeds a brand-new panel). |-| **Webview panel width** | 20% | The width (10-50%) used when shotlog first creates the slim webview panel. |+| **Own column (third column at the right edge)** | OFF | OFF: shotlog is a tab inside your existing webview pane, sharing it with your other webviews. ON: when a webview pane already exists, shotlog opens as its OWN narrow third column at the far right, and your webview pane keeps exactly its width (the editor gives up the space). The dialog shows a real screenshot of the layout. For people who live in webviews and still want shotlog in the corner of their eye. |+| **Own column width** | 10% | Width of shotlog's own column (5-30% of the screen). Only used with Own column ON. |+| **Auto-size the webview panel** | OFF | ON re-forces the shared webview pane to your width on every open (rude if you use webviews a lot, hence off); OFF (default) leaves the pane at whatever width you dragged it to (the width below only seeds a brand-new pane). Not used with Own column. |+| **Webview panel width** | 20% | The width (10-50%) used when shotlog first creates the shared webview pane. Not used with Own column. | | **Keep per channel** | 200 shots | The janitor keeps only the newest N shots per channel on disk; older files are deleted as new ones arrive. Env `SHOTLOG_MAX_SHOTS` overrides. | | **Prune idle channels after** | 30 days | Channels with no new shots for this many days are deleted entirely (checked at startup + every 6h). `0` = never. Env `SHOTLOG_RETENTION_DAYS` overrides. | @@ -114,8 +128,8 @@ The viewer has a **Settings** dialog (the gear in the header). Every setting is | Command | Description | |---|---|-| `adom-shotlog inject` | Inject a screenshot into a channel (records the original resolution; `--meta` adds child windows; reports whether a human is watching) |-| `adom-shotlog open` | Open the gallery as a Hydrogen webview tab (`--skinny` for the 20% right panel) |+| `adom-shotlog inject` | Inject a screenshot into a channel (records the original resolution; `--meta` adds child windows; `--tag heroG` names the shot; reports its ref and whether a human is watching) |+| `adom-shotlog open` | Open the gallery as a Hydrogen webview tab (`--skinny` for the slim right panel; exit 2 = no pane found, exit 1 = adom-cli or bridge failure, with the reason printed) | | `adom-shotlog viewers` | Is a human watching a channel? Exit 0 / 4 / 3, plus connected surfaces | | `adom-shotlog channels` | Overview of every channel: shots, last activity, and who is watching | | `adom-shotlog serve` | Start the HTTP server / broker (port 8820) |
SKILL.md+16@@ -298,3 +298,19 @@ adom-shotlog install # install binary + this skill + completions + ## Channel naming Use the project or task name: `board-review`, `hd-setup-steps`, `debug-session`.++## Handles: let the human NAME a shot++Every shot has a ref (`#42`), shown above its thumbnail, and the inject reply hands it back (`Ref: #42`). When you iterate on variants (hero images, layouts, color passes), give each shot a handle so the human can point at one without describing it:++```+adom-shotlog inject -c hero -d "hero variant G, tighter crop" --tag heroG variant-g.png+```++Then "heroG is the one" or "#42 is the one" is unambiguous. Refer to shots by their ref in your own replies too ("compare #41 and #42").++## Layout rules the viewer follows (so you do not fight them)++- ONE click on a thumbnail fills the window; the next click, Esc, or the margin comes back. There is no modal step any more.+- 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.
VERSION+1−1@@ -1 +1 @@-0.27.3+0.28.0
bin/adom-shotlog⋯ 1 unchanged line ⋯
dev-skills/adom-shotlog-dev/SKILL.md+11@@ -441,3 +441,14 @@ like a tooltip (shadow + full border to read as floating). The card's overflow:hidden clip is escaped with `.shot-card:has(.card-info.show) { overflow: visible }` (:has is fine - every shotlog surface is Chromium). Content is bounded anyway (context truncates at capture), so no cap needed.++## Rules added 2026-09-02 (wiki issues #1/#2, Ray's feedback, John's ah layout)++- `open --webview` MUST fail loudly: exit 2 when no pane exists (print the `workspace get` output), exit 1 on any adom-cli failure (print stdout+stderr), retry once on pane-id churn. Exit 0 on failure made the Webview button spin 30s and reset (issue #1). The pup path prints `Bridge said: ...` (an `ambiguous_target` reply means set SHOTLOG_AD_TARGET).+- Wide canvas (>=900px): `.shot-card-img` is `height:auto; max-height:100vh` so a 1400px shot fills a pup card instead of sitting at ~800px in margins (issue #2). Narrow panels keep the compact caps.+- A tooltip may NEVER float over a revealed overlay: the ⓘ chip has no data-tooltip, `show()` bails when the card's `.card-info.show` exists, and revealing calls `window._hideTip()` (Ray: "the tooltip obscures the popover").+- Refs and handles: every Entry has `id` (the ref) and optional `tag` (`inject --tag`). The ref/handle strip sits ABOVE the thumbnail (`.card-refbar`), never on the image (humans screenshot thumbnails as feedback). The inject reply prints `Ref: #id` (+ handle).+- Thumbnail click = fill-window on EVERY surface (no modal step). Any click inside the lightbox exits (`#lightbox` has a 4vh/4vw padding margin because in Hydrogen the host's 90% box is all we own). ⛶ is hover-only.+- Own column: prefs `webview_own_column` (default false) + `own_column_width` (default 10, 5-30). In `open_webview`, when ON and no Shotlog tab exists (`tab_exists` via `workspace find-tab`, which returns exit 0 + an error envelope on a miss), split the RIGHT-MOST leaf with ratio leaf_frac/(leaf_frac+own) so the pane keeps its width, then `grow_path` resizes horizontal ancestors so the LEFT sibling (editor) pays. Verify the math with the fake adom-cli shim (scratchpad `shim/`): expect split 0.615 + root 0.84 -> 0.74 for a 10% column beside a 16% pane.+- Settings illustrations are REAL screenshots (John): `src/assets/own-column.png` is a crop of Hydrogen web (Edge, AdomLapper) after the column was created for real; captured with `adom-bridge --target AdomLapper desktop_screenshot_window {hwnd}` (the `.full.png` sibling is the sharp one), served at `/own-column.png` via include_bytes. Never ship a mockup.+- ah and Hydrogen web keep SEPARATE workspace layouts: `adom-cli hydrogen workspace get` from this container edits the hydrogen.adom.inc workspace (what Edge shows), not ah's local layout on the laptop.
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.27.3",+ "version": "0.28.0", "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.27.3",+ "version": "0.28.0", "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/assets/own-column.pngadded⋯ 1 unchanged line ⋯
src/html/index.html+98−10@@ -212,7 +212,10 @@ body.compact .shot-card-img { height: auto; max-height: 420px; object-fit: conta .shot-card-img, body.compact .shot-card-img { height: auto; max-height: 100vh; object-fit: contain; } } /* his most-used control stays visible, not hover-revealed */-body.compact .card-fill-btn { opacity: .85; }+/* ⛶ is HOVER-ONLY everywhere (John): a click already fills the window, the+ icon just tells you so when you hover the thumbnail. */+body.compact .card-fill-btn { opacity: 0; }+body.compact .shot-card:hover .card-fill-btn, body.compact .card-fill-btn:focus-visible { opacity: 1; } /* the ⓘ reveal chip, top-left over the image (mirrors the ⛶ top-right) */ body.compact .card-info-hot { display: inline-flex; position: absolute; top: 10px; left: 10px; width: 32px; height: 32px; align-items: center; justify-content: center;@@ -233,6 +236,17 @@ body.compact .card-popups .ph { padding-top: 4px; } .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.ref { font-family: 'JetBrains Mono', monospace; color: var(--text3); }+.chip.tag { color: var(--accent-bright); border-color: rgba(0,184,176,.5); background: rgba(0,184,176,.12); font-weight: 600; }+/* ref/handle strip ABOVE the thumbnail (not on it, so a screenshot of the+ thumbnail stays clean): one slim mono line, the NAME of the shot for+ talking to your AI. */+.card-refbar { display: flex; align-items: center; gap: 8px; padding: 3px 10px 2px; min-height: 16px;+ font: 600 10.5px/1.3 'JetBrains Mono', monospace; color: var(--text3); }+.card-refbar .rb-ref { letter-spacing: .2px; }+.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; }+.shot-card:not(.has-refbar) .card-refbar { } .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; }@@ -346,8 +360,15 @@ body.compact .card-popups .ph { padding-top: 4px; } that escapes the skinny panel; in pup/browser it's true fullscreen; on a phone or where the API is missing, the .open class alone makes it a full fixed-overlay (graceful CSS fallback, no API needed). */-#lightbox { position: fixed; inset: 0; z-index: 300; display: none;- align-items: center; justify-content: center; background: rgba(0,0,0,.95); }+#lightbox { position: fixed; inset: 0; z-index: 300; display: none; cursor: zoom-out;+ align-items: center; justify-content: center; background: rgba(0,0,0,.95);+ /* A dismiss MARGIN inside our own rectangle: in Adom Hydrogen the host+ grants a 90%-of-window lightbox and the area OUTSIDE it is host chrome+ our clicks never reach. So the image sits at ~92% of that box and the+ dark border around it is ours - a click there (or on the image) exits. */+ padding: 4vh 4vw; box-sizing: border-box; }+#lightbox .lb-hint { position: absolute; left: 50%; bottom: 10px; transform: translateX(-50%); font: 11px/1.4 'Satoshi', sans-serif;+ color: rgba(255,255,255,.5); pointer-events: none; white-space: nowrap; } #lightbox.open { display: flex; } /* Fill ALL available space: scale the image UP as well as down until it hits the edges on its constraining axis (object-fit: contain keeps aspect ratio,@@ -464,6 +485,12 @@ body.compact .card-popups .ph { padding-top: 4px; } .pref-row { display: flex; align-items: center; gap: 14px; padding: 11px 0; border-top: 1px solid var(--border-muted); } .pref-row:first-of-type { border-top: none; }+.pref-illus { display: block; width: 100%; max-width: 520px; margin: 8px 0 6px; border-radius: 8px; border: 1px solid var(--border); }+/* Rows that do not apply in the current mode (shared pane vs own column) */+.pref-row.na { opacity: .45; }+.pref-row.na .toggle, .pref-row.na .num-wrap { pointer-events: none; }+.pref-row.na .pref-name::after { content: ' (not used with Own column)'; font-weight: 400; color: var(--text3); font-size: 11px; }+#rowOwnCol.na .pref-name::after { content: ''; } .pref-info { flex: 1; min-width: 0; } .pref-name { font: 600 13px 'Familjen Grotesk', sans-serif; color: var(--text); } .pref-desc { font: 12px/1.45 'Satoshi', sans-serif; color: var(--text2); margin-top: 2px; }@@ -633,7 +660,23 @@ body.compact .card-popups .ph { padding-top: 4px; } <button data-v="pup">Pup</button> </div> </div>- <div class="pref-row">+ <div class="pref-row" id="rowOwnCol">+ <div class="pref-info">+ <div class="pref-name">Own column (third column at the right edge)</div>+ <div class="pref-desc">OFF (default): shotlog is a tab inside your existing webview pane, sharing it with your other webviews. ON: when you already have a webview pane, shotlog opens as its OWN narrow third column at the right edge, the width below, so it is always visible while your webviews keep their pane and their width. shotlog never resizes your webview pane in this mode. Turn it on if you live in webviews and still want shotlog in the corner of your eye.</div>+ <img class="pref-illus" src="own-column.png" alt="Illustration: editor on the left, your webviews in the middle pane, shotlog as its own narrow column at the right edge" data-tooltip="The layout with Own column ON: your webviews keep their pane; shotlog is the narrow third column at the far right.">+ <div class="ai-chip"><span class="lbl">or say to your AI</span>open shotlog as its own 10% column on the right</div>+ </div>+ <div class="pref-control">+ <button class="toggle" id="prefOwnCol" role="switch" aria-checked="false" data-tooltip="ON: shotlog gets its own narrow column at the right edge (your webview pane is untouched). OFF: shotlog is a tab in your webview pane."></button>+ <span class="num-wrap" data-tooltip="Width of shotlog's own column, percent of the screen (5-30).">+ <button class="num-step" data-target="prefOwnWidth" data-dir="-1" aria-label="decrease" tabindex="-1">−</button>+ <input class="num-input" id="prefOwnWidth" type="number" min="5" max="30" step="5"><span class="unit">%</span>+ <button class="num-step" data-target="prefOwnWidth" data-dir="1" aria-label="increase" tabindex="-1">+</button>+ </span>+ </div>+ </div>+ <div class="pref-row" id="rowAutosize"> <div class="pref-info"> <div class="pref-name">Auto-size the webview panel</div> <div class="pref-desc">When ON, shotlog re-forces the panel to the width below every time it opens the webview. When OFF (default), shotlog leaves the column at whatever width YOU last dragged it to; the width below only seeds a brand-new panel.</div>@@ -641,7 +684,7 @@ body.compact .card-popups .ph { padding-top: 4px; } </div> <button class="toggle" id="prefAutosize" role="switch" aria-checked="false" data-tooltip="ON: snap the webview column to your preferred width on every open. OFF (default): respect the width you set manually."></button> </div>- <div class="pref-row">+ <div class="pref-row" id="rowWidth"> <div class="pref-info"> <div class="pref-name">Webview panel width</div> <div class="pref-desc">The width shotlog uses when it first CREATES the slim webview panel, as a percent of your workspace (10-50). With auto-size OFF (default) this only seeds a new panel and never resizes an existing one; with it ON, every open snaps back to this.</div>@@ -726,7 +769,7 @@ body.compact .card-popups .ph { padding-top: 4px; } <div class="m-popups" id="mPopups" style="display:none"></div> <div id="mNerd"></div> </div></div>-<div id="lightbox"><img id="lbImg" alt=""><button class="lb-close" id="lbClose" aria-label="Exit fill window">✕</button></div>+<div id="lightbox"><img id="lbImg" alt=""><button class="lb-close" id="lbClose" aria-label="Exit fill window">✕</button><div class="lb-hint">click anywhere or Esc to go back</div></div> <div class="paste-overlay" id="pasteOverlay"><div class="paste-box" id="pasteMsg"></div></div> <script>@@ -775,6 +818,9 @@ body.compact .card-popups .ph { padding-top: 4px; } const tip = $('global-tooltip'); let cx=0, cy=0, timer=null; document.addEventListener('mousemove', e => { cx=e.clientX; cy=e.clientY; }, true); function show(el) {+ // A card whose info overlay is REVEALED (compact mode ⓘ) is being read:+ // no tooltip may float over it (Ray: "the tooltip obscures the popover").+ if (el.closest && el.closest('.shot-card')?.querySelector('.card-info.show')) return; // Rich (authored, trusted) HTML via el._ttHtml; else plain text. Dynamic // values interpolated into _ttHtml are escaped by the builders below. if (el._ttHtml) { tip.innerHTML = el._ttHtml; }@@ -793,6 +839,7 @@ body.compact .card-popups .ph { padding-top: 4px; } } function hide(){ if(timer){clearTimeout(timer);timer=null;} tip.classList.remove('shown'); setTimeout(()=>{ if(!tip.classList.contains('shown')) tip.classList.remove('visible'); },180); }+ window._hideTip = hide; document.addEventListener('mouseover', e => { const el=e.target.closest('[data-tooltip]'); if(el){ if(timer)clearTimeout(timer); timer=setTimeout(()=>show(el),600); } else hide(); }, true); document.addEventListener('mouseout', e => { if(!e.relatedTarget?.closest('[data-tooltip]')) hide(); }, true);@@ -947,6 +994,16 @@ body.compact .card-popups .ph { padding-top: 4px; } const meta=document.createElement('div'); meta.className='shot-card-meta'; const ts=document.createElement('span'); ts.className='ts'; ts.textContent=fmtDate(e.timestamp); ts.setAttribute('data-tooltip', new Date(e.timestamp).toLocaleString()); meta.appendChild(ts);+ // The REF (#id) and optional HANDLE (--tag): how a human points an AI at+ // exactly this shot ("#42 is the one" / "heroG is the one").+ { const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);+ const ref=document.createElement('span'); ref.className='chip ref'; ref.textContent='#'+e.id;+ ref.setAttribute('data-tooltip','Shot ref #'+e.id+'. Say "#'+e.id+'" to your AI to mean exactly this shot.'+(e.tag?'':' (An AI can also give a shot a handle: shotlog inject --tag heroG.)'));+ meta.appendChild(ref);+ if (e.tag){ const s2=document.createElement('span'); s2.className='sep'; s2.textContent='·'; meta.appendChild(s2);+ const tg=document.createElement('span'); tg.className='chip tag'; tg.textContent=e.tag;+ tg.setAttribute('data-tooltip','Handle "'+e.tag+'" given by the AI that injected this shot (--tag). Say "'+e.tag+'" to mean exactly this shot.');+ meta.appendChild(tg); } } 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); }@@ -977,15 +1034,32 @@ body.compact .card-popups .ph { padding-top: 4px; } c.appendChild(info); const imgWrap=document.createElement('div'); imgWrap.className='card-img-wrap'; const img=document.createElement('img'); img.className='shot-card-img'; img.src=imgUrl(e); img.alt=e.description||e.filename;- img.addEventListener('click', ()=>openModal(e)); // 1st click still opens the modal (unchanged)+ // ONE click on a thumbnail goes STRAIGHT to fill-window on every surface+ // (John: the old 3-layer stack, modal -> fullscreen -> unmax, is gone).+ // The next click anywhere (image or margin), or Esc, comes straight back+ // to the normal list. The modal stays only for child-window thumbnails.+ img.addEventListener('click', ()=>fillWindow(imgUrl(e), e.description||e.filename)); imgWrap.appendChild(img);+ // REF/HANDLE strip ABOVE the thumbnail (never ON it: humans screenshot a+ // thumbnail to send it back to the AI, so the image itself stays clean).+ // Slim, always visible, even in compact mode where the info is hidden.+ { const rb=document.createElement('div'); rb.className='card-refbar';+ const rn=document.createElement('span'); rn.className='rb-ref'; rn.textContent='#'+e.id;+ rn.setAttribute('data-tooltip','Shot ref #'+e.id+': say "#'+e.id+'" to your AI to mean exactly this shot.'+(e.tag?'':' (An AI can also give a shot a handle: shotlog inject --tag heroG.)'));+ rb.appendChild(rn);+ if (e.tag){ const tg=document.createElement('span'); tg.className='rb-tag'; tg.textContent=e.tag;+ tg.setAttribute('data-tooltip','Handle "'+e.tag+'" given by the AI that injected this shot (--tag). Say "'+e.tag+'" to mean exactly this shot.');+ rb.appendChild(tg); }+ c.appendChild(rb); } // info (hidden in compact) -> refbar -> imgWrap (appended below) // COMPACT mode's reveal chip: hover (or tap, on touch) the ⓘ on the image // to overlay the full info block; it hides again when the pointer leaves // the card. Hidden entirely in normal mode (the info is already visible). const infoHot=document.createElement('button'); infoHot.className='card-info-hot'; infoHot.textContent='ⓘ'; infoHot.setAttribute('aria-label','Show shot info');- infoHot.setAttribute('data-tooltip','Hover to reveal this shot\'s info: the headline, time, sizes, and provenance chips. (Compact mode hides them so images get the room.)');- infoHot.addEventListener('mouseenter', ()=>info.classList.add('show'));+ // No data-tooltip on the ⓘ: its hover REVEALS the info overlay, and a+ // tooltip 600ms later landed right on top of what the human was reading.+ infoHot.title='';+ infoHot.addEventListener('mouseenter', ()=>{ info.classList.add('show'); window._hideTip&&window._hideTip(); }); infoHot.addEventListener('click', ev=>{ ev.stopPropagation(); info.classList.toggle('show'); }); c.addEventListener('mouseleave', ()=>info.classList.remove('show')); imgWrap.appendChild(infoHot);@@ -1151,7 +1225,9 @@ body.compact .card-popups .ph { padding-top: 4px; } if (backFromFill) { backFromFill = false; mImg.style.cursor = ''; closeModal(); } else fillWindow(mImg.src, mImg.alt); }); $('mFillBtn').addEventListener('click', e => { e.stopPropagation(); fillWindow(mImg.src, mImg.alt); });- lightbox.addEventListener('click', e => { if (e.target === lightbox || e.target === lbImg) exitFill(); });+ // ANY click inside the lightbox (image, the dark margin, anything) exits:+ // the next click after zooming always takes you back to the list.+ lightbox.addEventListener('click', e => { e.stopPropagation(); exitFill(); }); $('lbClose').addEventListener('click', e => { e.stopPropagation(); exitFill(); }); document.addEventListener('keydown', e => {@@ -1513,6 +1589,13 @@ body.compact .card-popups .ph { padding-top: 4px; } APREFS=p; seg.querySelectorAll('button').forEach(b=>b.classList.toggle('on', b.dataset.v===p.default_canvas)); $('prefWidth').value = p.webview_width ?? 20;+ // Own column vs shared pane: the two width settings are alternates.+ const own = p.webview_own_column===true;+ $('prefOwnCol').setAttribute('aria-checked', String(own));+ $('prefOwnWidth').value = p.own_column_width ?? 10;+ $('prefOwnWidth').closest('.pref-control').classList.toggle('off', !own);+ $('rowAutosize').classList.toggle('na', own);+ $('rowWidth').classList.toggle('na', own); // Keep per channel: max_shots 0 = OFF (unlimited). const mk = p.max_shots ?? 200; if(mk>0) lastKeep=mk; $('prefKeepToggle').setAttribute('aria-checked', String(mk>0));@@ -1547,6 +1630,10 @@ body.compact .card-popups .ph { padding-top: 4px; } const now=$('prefAutosize').getAttribute('aria-checked')==='true'; patch({webview_autosize: !now}, !now?'auto-size ON: the webview column snaps to your width on every open.':'auto-size OFF: shotlog leaves the webview column at the width you set.'); });+ $('prefOwnCol').addEventListener('click',()=>{+ const now=$('prefOwnCol').getAttribute('aria-checked')==='true';+ patch({webview_own_column: !now}, !now?'own column ON: the next time shotlog opens with no Shotlog tab, it takes its own column at the right edge (your webview pane is untouched).':'own column OFF: shotlog joins your webview pane as a tab.');+ }); $('prefCompact').addEventListener('click',()=>{ const now=$('prefCompact').getAttribute('aria-checked')==='true'; patch({compact: !now}, !now?'compact ON: image-first cards - hover the ⓘ on a thumbnail for its info.':'compact OFF: classic cards with the info text always visible.');@@ -1565,6 +1652,7 @@ body.compact .card-popups .ph { padding-top: 4px; } el.addEventListener('keydown', e=>{ if(e.key==='Enter'){ e.preventDefault(); commit(); el.blur(); } }); } wireNum('prefWidth','webview_width', v=>'webview panel width is now '+v+'% for new opens.');+ wireNum('prefOwnWidth','own_column_width', v=>'shotlog\'s own column will be '+v+'% of the screen when it is next created.'); wireNum('prefMaxShots','max_shots', v=>'keeping the newest '+v+' shots per channel.'); wireNum('prefRetention','retention_days', v=> v===0?'idle channels are never pruned.':'idle channels prune after '+v+' days.'); // Up/down steppers: adjust the target input by its step, clamp, commit.
src/main.rs+31−4@@ -171,6 +171,11 @@ enum Commands { /// fills this automatically; pass your task summary when injecting). #[arg(long)] context: Option<String>,+ /// A short HANDLE the human can refer to this exact shot by, e.g.+ /// --tag heroG. Shown on the card and the thumbnail. Use it whenever you+ /// are iterating variants so the human can say "heroG is the one".+ #[arg(long)]+ tag: Option<String>, /// PNG file path (use - for stdin) file: String, },@@ -295,7 +300,7 @@ async fn main() { } Commands::Inject { channel, desc, source, port, orig_file, orig_w, orig_h, native, meta, thread, context,- file,+ tag, file, } => { let desc = match desc { Some(d) if d.len() > 5 => d,@@ -422,7 +427,7 @@ async fn main() { } }; - run_inject(&channel, &desc, &source, port, &file, false, orig_w, orig_h, cap_meta, popups, thread.as_deref(), context.as_deref(), orig_path_keep.as_deref());+ run_inject(&channel, &desc, &source, port, &file, false, orig_w, orig_h, cap_meta, popups, thread.as_deref(), context.as_deref(), orig_path_keep.as_deref(), tag.as_deref()); } Commands::Open { channel, name, icon, port, skinny, ratio, pup, swap, webview,@@ -480,7 +485,14 @@ async fn main() { let autosize = ratio_flag_set || prefs.as_ref() .and_then(|v| v.get("webview_autosize").and_then(|w| w.as_bool())) .unwrap_or(false);- open::open_webview(&channel, name.as_deref(), icon.as_deref(), port, true, ratio, autosize);+ // OWN COLUMN (Settings): shotlog as its own third column at the+ // right edge, never resizing the human's webview pane.+ let own = prefs.as_ref().and_then(|v| {+ if v.get("webview_own_column").and_then(|b| b.as_bool()).unwrap_or(false) {+ Some((v.get("own_column_width").and_then(|w| w.as_u64()).unwrap_or(10) as f64 / 100.0).clamp(0.05, 0.30))+ } else { None }+ });+ open::open_webview(&channel, name.as_deref(), icon.as_deref(), port, true, ratio, autosize, own); if swap { if open::close_pup_tab(&channel) { ok(&format!("closed the pup tab for '{channel}' - webview is now this channel's canvas."));@@ -505,7 +517,7 @@ async fn main() { std::process::exit(1); } };- run_inject(&channel, &desc, &source, port, &file, true, None, None, None, None, None, None, None);+ run_inject(&channel, &desc, &source, port, &file, true, None, None, None, None, None, None, None, None); } Commands::Resize { file, output, width } => { run_resize(&file, output.as_deref(), width);@@ -613,6 +625,7 @@ fn run_inject( thread: Option<&str>, context: Option<&str>, orig_path: Option<&str>,+ tag: Option<&str>, ) { let bytes = if file == "-" { use std::io::Read;@@ -645,6 +658,7 @@ fn run_inject( "source": source, "thread": thread, "context": context,+ "tag": tag, "orig_path": orig_path.map(|p| std::fs::canonicalize(p).map(|c| c.to_string_lossy().to_string()).unwrap_or_else(|_| p.to_string())), }); if let (Some(ow), Some(oh)) = (orig_w, orig_h) {@@ -675,6 +689,19 @@ fn run_inject( val["path"].as_str().unwrap_or("?"), )); // shotlog resized a full-res image itself and recorded the original.+ // The REF is how a human points the AI at exactly this shot+ // ("use #42" / "heroG is the one"), so always hand it back.+ if let Some(id) = val.get("id").and_then(|v| v.as_u64()) {+ match tag.map(str::trim).filter(|t| !t.is_empty()) {+ Some(t) => ok(&format!("Ref: #{id}, handle \"{t}\" (the human can say \"{t}\" or \"#{id}\" to mean this shot)")),+ None => {+ ok(&format!("Ref: #{id} (the human can say \"#{id}\" to mean this shot)"));+ if !source.starts_with("read-hook") {+ hint("Iterating on variants? Give each shot a handle: --tag heroG. The human then just says \"heroG is the one\".");+ }+ }+ }+ } if let (Some(ow), Some(oh)) = (ow, oh) { if orig_w.is_none() { hint(&format!("Auto-resized from {ow}x{oh} → {w}x{h}; original size recorded."));
src/open.rs+121@@ -20,6 +20,10 @@ pub fn open_webview( // width when CREATING the panel (initial split) and never resize an existing // one, so the human's manual column width sticks. Default off (see AppPrefs). autosize: bool,+ // Some(width ratio of the SCREEN): the human wants shotlog as its OWN third+ // column at the right edge (Settings > Own column). None: join the right+ // pane's tabs as before.+ own: Option<f64>, ) { let default_name = "Shotlog".to_string(); let name = display_name.unwrap_or(&default_name);@@ -75,6 +79,59 @@ pub fn open_webview( } } + // OWN COLUMN: the workspace is already split and there is no Shotlog tab+ // yet. Instead of piling shotlog into the right pane's tab strip, split+ // the RIGHT-MOST leaf again so shotlog gets its own column at `ratio` of+ // the SCREEN (a third column, "always off to the side": John + Ray). An+ // existing Shotlog tab is refreshed in place, wherever the human put it.+ if let (true, Some(own_ratio), false) = (skinny, own, tab_exists(name)) {+ if let Some(leaf_id) = find_leaf_id(root) {+ let leaf_frac = leaf_fraction(root, &leaf_id, 1.0).unwrap_or(1.0).max(0.05);+ // The new pane must be `own_ratio` of the SCREEN = own_ratio/leaf_frac of the leaf.+ let want = (own_ratio / leaf_frac).clamp(0.05, 0.9);+ let first_ratio = (1.0 - want).clamp(0.1, 0.95);+ eprintln!("Hint: Settings > Own column is ON and there is no Shotlog tab yet - giving shotlog its OWN column ({:.0}% of the screen) at the right edge. Your webview pane is not resized.", own_ratio * 100.0);+ // Split the right-most leaf so its OLD content keeps its absolute+ // width (leaf_frac) and shotlog gets `own_ratio`: inside the new+ // split that is leaf_frac : own_ratio ...+ let inner_first = (leaf_frac / (leaf_frac + own_ratio)).clamp(0.1, 0.95);+ let _ = (want, first_ratio);+ let out = Command::new("adom-cli")+ .args([+ "hydrogen", "workspace", "split",+ "--panel-id", &leaf_id,+ "--direction", "horizontal",+ "--position", "after",+ "--ratio", &format!("{inner_first}"),+ "--panel-type", WEBVIEW_PANEL_TYPE,+ "--display-name", name,+ "--display-icon", icon,+ "--initial-state", &initial_state,+ ])+ .output();+ let ok = out.as_ref().map(|o| o.status.success()).unwrap_or(false);+ if ok {+ // ... and the EDITOR side pays for it: every horizontal ancestor+ // where that leaf sits on the right gives `own_ratio` of the+ // screen from its LEFT child, so the human's webview pane keeps+ // exactly the width it had. (A leaf on the left of its parent+ // grows that parent instead, and the growth propagates up.)+ let (adjust, unpaid) = grow_path(root, &leaf_id, own_ratio, 1.0).unwrap_or((Vec::new(), true));+ for (sid, r) in &adjust {+ let _ = Command::new("adom-cli")+ .args(["hydrogen", "workspace", "resize", "--split-id", sid, "--ratio", &format!("{r}")])+ .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())+ .status();+ }+ if unpaid && adjust.is_empty() {+ eprintln!("Hint: the right-most pane had no left neighbor to take width from, so the column came out of that pane.");+ }+ }+ report(out, name, channel, &url);+ return;+ }+ }+ // Otherwise: add the tab to the preferred (right/second) leaf. let Some(leaf_id) = find_leaf_id(root) else { // A hard failure the broker/UI can SEE (exit 2): before, this returned@@ -187,6 +244,70 @@ fn surface_tab(name: &str) { .status(); } +/// Does a workspace tab with this display name already exist? (`find-tab`+/// prints the tab JSON on a hit and an {"error":...} envelope on a miss,+/// exit 0 both ways, so match on the parsed name.)+fn tab_exists(name: &str) -> bool {+ Command::new("adom-cli")+ .args(["hydrogen", "workspace", "find-tab", name])+ .output()+ .ok()+ .and_then(|o| serde_json::from_slice::<serde_json::Value>(&o.stdout).ok())+ .map(|v| v.get("name").and_then(|n| n.as_str()) == Some(name))+ .unwrap_or(false)+}++/// The fraction of the SCREEN WIDTH a leaf occupies: walk down to it,+/// multiplying the horizontal split ratios on the way (vertical splits do not+/// change width). `acc` starts at 1.0 for the root.+fn leaf_fraction(val: &serde_json::Value, leaf_id: &str, acc: f64) -> Option<f64> {+ match val.get("type").and_then(|t| t.as_str()) {+ Some("leaf") => (val.get("id").and_then(|i| i.as_str()) == Some(leaf_id)).then_some(acc),+ Some("split") => {+ let horiz = val.get("direction").and_then(|d| d.as_str()).unwrap_or("horizontal") == "horizontal";+ let r = val.get("ratio").and_then(|r| r.as_f64()).unwrap_or(0.5).clamp(0.05, 0.95);+ let (f1, f2) = if horiz { (acc * r, acc * (1.0 - r)) } else { (acc, acc) };+ val.get("first").and_then(|c| leaf_fraction(c, leaf_id, f1))+ .or_else(|| val.get("second").and_then(|c| leaf_fraction(c, leaf_id, f2)))+ }+ _ => None,+ }+}++/// Ancestor resizes needed so the subtree at `leaf_id` grows by `own` of the+/// screen width, paid for by LEFT siblings on the way up. Returns (split_id,+/// new_ratio) pairs from the leaf upward plus whether growth is still unpaid+/// at this level (true at the root means nobody could pay).+fn grow_path(val: &serde_json::Value, leaf_id: &str, own: f64, width: f64) -> Option<(Vec<(String, f64)>, bool)> {+ match val.get("type").and_then(|t| t.as_str()) {+ Some("leaf") => (val.get("id").and_then(|i| i.as_str()) == Some(leaf_id)).then(|| (Vec::new(), true)),+ Some("split") => {+ let horiz = val.get("direction").and_then(|d| d.as_str()).unwrap_or("horizontal") == "horizontal";+ let r = val.get("ratio").and_then(|r| r.as_f64()).unwrap_or(0.5).clamp(0.05, 0.95);+ let sid = val.get("id").and_then(|i| i.as_str()).unwrap_or("").to_string();+ let (w1, w2) = if horiz { (width * r, width * (1.0 - r)) } else { (width, width) };+ if let Some((mut v, need)) = val.get("first").and_then(|c| grow_path(c, leaf_id, own, w1)) {+ if need && horiz && width > 0.0 {+ // Our left child grows: this split grows too (unpaid here).+ v.push((sid, ((r * width + own) / width).clamp(0.05, 0.95)));+ return Some((v, true));+ }+ return Some((v, need));+ }+ if let Some((mut v, need)) = val.get("second").and_then(|c| grow_path(c, leaf_id, own, w2)) {+ if need && horiz && width > 0.0 {+ // Our right child grows: the LEFT child pays. Paid in full.+ v.push((sid, ((r * width - own) / width).clamp(0.05, 0.95)));+ return Some((v, false));+ }+ return Some((v, need));+ }+ None+ }+ _ => None,+ }+}+ /// The id of the single leaf, iff the layout is exactly one leaf (no split). fn single_leaf_id(root: &serde_json::Value) -> Option<String> { if root.get("type").and_then(|t| t.as_str()) == Some("leaf") {
src/server.rs+36−11@@ -154,6 +154,7 @@ pub fn create_router(state: Arc<AppState>) -> Router { .route("/log/{channel}/", get(serve_viewer)) .route("/ws/_all", get(ws_all_handler)) .route("/favicon.svg", get(serve_favicon))+ .route("/own-column.png", get(serve_own_column_png)) // The single-window app lives at /log/ — without this exact route the // page's relative favicon.svg 308s into the channel handler and the // browser gets HTML instead of an icon (= no favicon at all).@@ -461,7 +462,7 @@ async fn post_show( }); let _ = state.store.ctrl_tx.send(frame.to_string()); println!("{} DECISION show '{}' consolidated into the open {desired} window ({} viewer(s)) - no new surface.", ts(), req.channel, infos.len());- notify_surface(&req.channel, state.port, &infos, &prefs.default_canvas, prefs.update_alerts, prefs.webview_width as f64 / 100.0, prefs.webview_autosize);+ notify_surface(&req.channel, state.port, &infos, &prefs.default_canvas, prefs.update_alerts, prefs.webview_width as f64 / 100.0, prefs.webview_autosize, own_column(&prefs)); Json(serde_json::json!({ "ok": true, "consolidated": true, "viewers": infos.len(), "surfaces": surfaces, }))@@ -525,6 +526,8 @@ struct PrefsPatch { webview_width: Option<u8>, update_autoswitch: Option<bool>, webview_autosize: Option<bool>,+ webview_own_column: Option<bool>,+ own_column_width: Option<u8>, compact: Option<bool>, } @@ -568,6 +571,15 @@ async fn set_prefs( if let Some(a) = patch.webview_autosize { p.webview_autosize = a; }+ if let Some(o) = patch.webview_own_column {+ p.webview_own_column = o;+ }+ if let Some(w) = patch.own_column_width {+ if !(5..=30).contains(&w) {+ return Json(serde_json::json!({"ok": false, "error": "own_column_width must be 5-30 (percent of the screen)"}));+ }+ p.own_column_width = w;+ } if let Some(a) = patch.compact { p.compact = a; }@@ -755,6 +767,17 @@ async fn canvas_switch( Json(v) } +/// Some(width ratio) when the human wants shotlog as its OWN third column.+fn own_column(p: &crate::store::AppPrefs) -> Option<f64> {+ if p.webview_own_column { Some((p.own_column_width as f64 / 100.0).clamp(0.05, 0.30)) } else { None }+}++/// The Settings-dialog illustration of the "own column" layout.+const OWN_COL_PNG: &[u8] = include_bytes!("assets/own-column.png");+async fn serve_own_column_png() -> impl IntoResponse {+ ([(axum::http::header::CONTENT_TYPE, "image/png"), (axum::http::header::CACHE_CONTROL, "public, max-age=86400")], OWN_COL_PNG)+}+ /// Paste-prompt text served at /prompt — guidance for AI agents on how to inject. async fn serve_prompt() -> impl IntoResponse { let body = "\@@ -832,7 +855,7 @@ fn debounce_ok(key: &str, secs: u64) -> bool { /// hinted to flash the pup taskbar. /// - **nobody** watching → force-open a slim ~20% webview, unless /// `SHOTLOG_NO_FORCE_OPEN` is set.-fn notify_surface(channel: &str, port: u16, infos: &[crate::store::ViewerInfo], pref: &str, alerts: bool, ratio: f64, autosize: bool) {+fn notify_surface(channel: &str, port: u16, infos: &[crate::store::ViewerInfo], pref: &str, alerts: bool, ratio: f64, autosize: bool, own: Option<f64>) { // Pup viewers get an automatic ORANGE TASKBAR FLASH (FlashWindowEx) on their // hosting window — a gentle "update" cue, never a foreground steal. Works when // the viewer URL carried ?session=<pup sessionId> (reported in the WS hello).@@ -870,9 +893,9 @@ fn notify_surface(channel: &str, port: u16, infos: &[crate::store::ViewerInfo], // a pup channel whose viewer blipped must NOT get a webview shoved at it. println!("{} DECISION force-open '{channel}' canvas={pref} (nobody watching)", ts()); if pref == "pup" {- force_open_pup(channel, ratio, autosize);+ force_open_pup(channel, ratio, autosize, own); } else {- force_open_viewer(channel, port, ratio, autosize);+ force_open_viewer(channel, port, ratio, autosize, own); } } }@@ -880,7 +903,7 @@ fn notify_surface(channel: &str, port: u16, infos: &[crate::store::ViewerInfo], /// Force-open the PUP canvas for a channel (its chosen canvas) when a shot /// lands with nobody watching. Runs via a self-subprocess (open_pup can exit). /// If pup genuinely cannot open, falls back to a webview - never invisible.-fn force_open_pup(channel: &str, fallback_ratio: f64, autosize: bool) {+fn force_open_pup(channel: &str, fallback_ratio: f64, autosize: bool, own: Option<f64>) { // Debounce longer than the whole retry window so attempts never overlap. if !debounce_ok(&format!("openpup:{channel}"), 240) { return;@@ -913,7 +936,7 @@ fn force_open_pup(channel: &str, fallback_ratio: f64, autosize: bool) { } println!("{} WARN: pup never came up for '{channel}' - falling back to a webview so the shots aren't invisible (no choice recorded).", ts()); set_notice("Tried to open pup (your default) - it was unreachable, so I fell back to this webview so shots stay visible. I'll keep retrying and move you back to pup automatically once it's up.");- crate::open::open_webview(&channel, None, None, 8820, true, fallback_ratio, autosize);+ crate::open::open_webview(&channel, None, None, 8820, true, fallback_ratio, autosize, own); }); } @@ -1034,7 +1057,7 @@ fn bring_webview_forward(channel: &str, port: u16, alerts: bool) { /// ~20%-wide right-side panel so cross-thread shots auto-appear without taking /// over the workspace — and CRUCIALLY passes a --panel-id (via open_webview) so /// the tab is actually CREATED, not silently skipped.-fn force_open_viewer(channel: &str, port: u16, ratio: f64, autosize: bool) {+fn force_open_viewer(channel: &str, port: u16, ratio: f64, autosize: bool, own: Option<f64>) { if std::env::var("VSCODE_PROXY_URI").is_err() { return; // not in an Adom container; nothing to open }@@ -1044,7 +1067,7 @@ fn force_open_viewer(channel: &str, port: u16, ratio: f64, autosize: bool) { let channel = channel.to_string(); // Best-effort; don't block the inject response on the adom-cli round-trip. std::thread::spawn(move || {- crate::open::open_webview(&channel, None, None, port, true, ratio, autosize);+ crate::open::open_webview(&channel, None, None, port, true, ratio, autosize, own); }); } @@ -1074,6 +1097,8 @@ struct InjectRequest { /// Absolute path of the ORIGINAL full-res image (when the caller passed /// --orig-file): the broker stores a copy beside the shot as {stem}__full.png. orig_path: Option<String>,+ /// Short human-referable handle for this shot (e.g. "heroG"), from --tag.+ tag: Option<String>, } /// Images larger than this on any edge are auto-resized (Claude crashes >1500px).@@ -1170,7 +1195,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(), req.context.clone(), req.orig_path.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(), req.orig_path.clone(), req.tag.clone().map(|t| t.trim().chars().take(40).collect::<String>()).filter(|t| !t.is_empty())) .await; // Presence at inject time — the caller uses this to know if the human is@@ -1215,7 +1240,7 @@ async fn post_shots( } let surfacing = Some(serde_json::json!({"actions": actions})); let ratio = (app_prefs.webview_width as f64 / 100.0).clamp(0.10, 0.50);- notify_surface(&req.channel, state.port, &infos, &canvas_pref, alerts_on, ratio, app_prefs.webview_autosize);+ notify_surface(&req.channel, state.port, &infos, &canvas_pref, alerts_on, ratio, app_prefs.webview_autosize, own_column(&app_prefs)); // Progressive mini-skill hints — taught at the moment they're relevant, then // quiet (hint_gate caps how many times each lesson repeats).@@ -1332,7 +1357,7 @@ async fn post_paste( &req.channel, bytes, filename, description, source, w, h, if was_resized { Some(orig_w) } else { None }, if was_resized { Some(orig_h) } else { None },- None, None, req.thread.clone(), req.context.clone(), None,+ None, None, req.thread.clone(), req.context.clone(), None, None, ) .await;
src/store.rs+20−1@@ -44,6 +44,11 @@ pub struct Entry { /// transcript's last-prompt record - zero AI tokens). #[serde(skip_serializing_if = "Option::is_none")] pub context: Option<String>,+ /// A short HANDLE the human can refer to this exact shot by ("heroG",+ /// "v3-dark"). Set by the injector (--tag); shown as a chip on the card and+ /// as a badge on the thumbnail. Every shot also has its numeric ref (#id).+ #[serde(skip_serializing_if = "Option::is_none")]+ pub tag: Option<String>, /// Filename of the stored FULL-RESOLUTION copy ({stem}__full.png) when the /// injector provided the original (safe/full pairs). Viewable via the zoom. #[serde(skip_serializing_if = "Option::is_none")]@@ -132,6 +137,16 @@ pub struct AppPrefs { /// open snaps the column back to the pref width. #[serde(default = "default_false")] pub webview_autosize: 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`+ /// percent of the screen, instead of joining that pane's tabs. Never resizes+ /// the human's webview pane (the "rude" part of auto-size); shotlog stays+ /// visible beside it. John's preferred ah layout; most users keep it off.+ #[serde(default = "default_false")]+ pub webview_own_column: bool,+ /// Width of shotlog's own column, percent of the screen (5-30). Default 10.+ #[serde(default = "default_own_column_width")]+ pub own_column_width: u8, /// COMPACT cards (the default): the gallery is image-first - the info text /// (headline, description, meta chips) hides behind a hover chip so ~90% /// of a skinny webview shows thumbnails. Off = the classic text-above-image@@ -145,11 +160,12 @@ fn default_false() -> bool { false } fn default_max_shots() -> usize { 200 } fn default_retention_days() -> u64 { 30 } fn default_webview_width() -> u8 { 20 }+fn default_own_column_width() -> u8 { 10 } 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, compact: true } }+ update_autoswitch: true, webview_autosize: false, webview_own_column: false, own_column_width: 10, compact: true } } } /// The flag file the read-hook script checks; present = hook disabled.@@ -322,6 +338,7 @@ impl Store { popups, thread, context,+ tag: None, full, }); next_id += 1;@@ -366,6 +383,7 @@ impl Store { thread: Option<String>, context: Option<String>, orig_path: Option<String>,+ tag: Option<String>, ) -> Entry { let channel_dir = self.base_dir.join(channel_name); fs::create_dir_all(&channel_dir).ok();@@ -437,6 +455,7 @@ impl Store { popups, thread, context,+ tag, full, };