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.7.0
16 files changed
+607−140
Cargo.lock+1−1Cargo.toml+1−1README.md+19−6SKILL.md+31−15VERSION+1−1bin/adom-shotloginstall.sh+16package.json+8−2page.json+8−2skills/adom-shotlog-child-windows/SKILL.md+75skills/adom-shotlog-publishing/SKILL.md+71skills/adom-shotlog-surfacing/SKILL.md+116src/html/index.html+26−16src/main.rs+31−11src/open.rs+113−65src/server.rs+90−20Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-shotlog"-version = "0.6.0"+version = "0.7.0" dependencies = [ "axum", "base64",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.6.2"+version = "0.7.0" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
README.md+19−6@@ -41,15 +41,15 @@ adom-shotlog inject -c my-project \ adom-shotlog **always** records the original capture resolution, so the gallery never shows a mystery "what size was this really?" — it shows `1400×788 · orig 1920×1080`. Resizing before inject is expected (oversized images crash AI vision analysis), so most screenshot tools hand back a shrunk copy. adom-shotlog just requires the original size to travel with it — via `--orig-w/--orig-h`, `--orig-file`, `--native`, or `--meta`. A bare inject of a pre-shrunk image is rejected with a hint. -## Captures the popups too (`--meta`)+## Captures the child windows too (`--meta`) -Adom Desktop's `desktop_screenshot_window` returns the primary window **plus an array of owned popups** (autofill dropdowns, confirm bubbles, native dialogs) and coordMap / DPI / screen metadata. Pass the whole response through `--meta`:+Adom Desktop's `desktop_screenshot_window` returns the parent window **plus its child windows** — the owned dropdowns, confirm bubbles, and native dialogs (AD calls these `screenshots[]` / `ownedPopupCount`) — with coordMap / DPI / screen metadata. Pass the whole response through `--meta`: ``` adom-shotlog inject -c checkout -d "checkout form with autofill open" primary.png --meta ad-response.json ``` -The gallery card gets a **+N popups** badge; the zoom modal shows each popup as a viewable thumbnail; and an **ⓘ meta** chip opens a copyable panel with shotId, screenRect, dpiScale, imageToScreen, and screen resolution.+The gallery card gets a **+N child windows** badge; each child window shows inline in a `child windows ▸` row (no zoom); and an **ⓘ meta** chip opens a copyable panel with shotId, screenRect, dpiScale, imageToScreen, and screen resolution. ## Gallery — desktop @@ -61,7 +61,7 @@ Newest-first cards, each with a prominent description, dual-size metadata, sourc - **Channels** — group shots by project, task, or debug session - **Enforced original-resolution tracking** — every shot's true capture size, never lost-- **Owned-popup arrays** — `--meta` ingests Adom Desktop popup captures + coordMap; the viewer reveals each popup + the metadata+- **Child-window arrays** — `--meta` ingests a parent-window capture + its child windows (owned dropdowns/dialogs) + coordMap; the viewer shows each child window inline + the metadata - **Gallery viewer** — newest-first, prominent descriptions, dual-size metadata, tap-to-zoom, mobile layout, tooltips - **Phone-ready** — open the same URL on your phone; the layout adapts - **Live updates + presence LED** — new shots stream in; a green dot shows you're connected@@ -94,7 +94,7 @@ adom-shotlog viewers -c board-review # exit 0 = on-screen, 4 = open but backgr | Command | Description | |---|---|-| `adom-shotlog inject` | Inject a screenshot into a channel (records the original resolution; `--meta` adds popups; reports live viewers) |+| `adom-shotlog inject` | Inject a screenshot into a channel (records the original resolution; `--meta` adds child windows; reports live viewers) | | `adom-shotlog viewers` | Is a human watching? Exit 0/3 + connected surfaces (webview/pup/mobile/browser) | | `adom-shotlog open` | Open the gallery viewer as a Hydrogen webview tab | | `adom-shotlog serve` | Start the HTTP server (port 8820) |@@ -102,11 +102,24 @@ adom-shotlog viewers -c board-review # exit 0 = on-screen, 4 = open but backgr | `adom-shotlog resize` | Resize an image (rarely needed — inject auto-resizes) | | `adom-shotlog install` | Install skill + bash completions + the `shotlog` alias | +## Skill pack (bundled sub-skills)++Installing `adom/adom-shotlog` also deploys these sub-skills (they live in this+repo under `skills/`, not separate wiki pages):++- **adom-shotlog-surfacing** — surfacing a new shot to the human *gently*, per+ surface (webview blink / pup taskbar flash / phone title badge), never stealing+ foreground; plus the Adom Desktop window-discovery + gentle-cue verbs.+- **adom-shotlog-child-windows** — capturing a parent window **plus its child+ windows** (owned dropdowns/dialogs) via `--meta`, and how they render inline.+- **adom-shotlog-publishing** — publishing this package to wiki.adom.inc (org+ ownership, OSS source, hero, and the wiki traps).+ ## Dependencies | Dependency | Required? | Why | |---|---|---| | **adom-cli** | For the viewer | `adom-shotlog open` adds the gallery as a Hydrogen webview tab. |-| **adom-desktop** | Optional | AI agents use it to capture screenshots (and popup arrays) that get injected. Humans can Ctrl+V paste instead. |+| **adom-desktop** | Optional | AI agents use it to capture screenshots (and child-window arrays) that get injected. Humans can Ctrl+V paste instead. | No `adompkg` package dependencies — adom-shotlog is a self-contained Rust binary.
SKILL.md+31−15@@ -1,6 +1,6 @@ --- name: adom-shotlog-description: "Screenshot log viewer and injector. Use when: logging screenshots during debug loops, documenting visual progress, reviewing screenshot history, opening the screenshot log panel, injecting a screenshot into the log, ingesting an Adom Desktop screenshot response with owned popups. Trigger words: adom-shotlog, shotlog, screenshot log, shot log, visual log, screenshot history, log screenshot, open shotlog, screenshot viewer."+description: "Screenshot log viewer and injector. Use when: logging screenshots during debug loops, documenting visual progress, reviewing screenshot history, opening the screenshot log panel, injecting a screenshot into the log, ingesting an Adom Desktop screenshot response with child windows (owned popups). Trigger words: adom-shotlog, shotlog, screenshot log, shot log, visual log, screenshot history, log screenshot, open shotlog, screenshot viewer." --- # adom-shotlog — Screenshot Log@@ -12,6 +12,22 @@ description, source, and **both** the stored and original resolution. Binary: `/usr/local/bin/adom-shotlog` (the `shotlog` command is a back-compat alias). +## This skill pack (main + sub-skills)++This is the **main** skill. Deeper topics are **sub-skills** shipped in the same+`adom/adom-shotlog` package (installed to `~/.claude/skills/`), not separate wiki+pages:++- **[adom-shotlog-surfacing](../adom-shotlog-surfacing/SKILL.md)** — how a new shot+ is surfaced to the human *gently*, per surface (webview blink / pup taskbar flash+ / phone title badge), never stealing foreground; plus the AD window-discovery ++ gentle-cue verbs.+- **[adom-shotlog-child-windows](../adom-shotlog-child-windows/SKILL.md)** —+ capturing a parent window **plus its child windows** (owned dropdowns/dialogs)+ via `--meta`, and how they render inline.+- **[adom-shotlog-publishing](../adom-shotlog-publishing/SKILL.md)** — publishing+ this package to wiki.adom.inc (org ownership, OSS source, hero, the wiki traps).+ ## ⚠ WHY THIS EXISTS: you can't show the human images in a text thread When you run as **Claude Code (VSCode / any text-only thread)** you **CANNOT@@ -69,12 +85,12 @@ the shot. Ways through: none of `--orig-w`+`--orig-h` / `--orig-file` / `--native` / `--meta`, the inject is refused with exit 1 and a hint. -## `--meta` — Adom Desktop popup arrays+## `--meta` — Adom Desktop child-window arrays As of adom-desktop 1.8.177, `desktop_screenshot_window {hwnd}` returns the primary-window image **plus an array of owned popups** (autofill dropdowns, confirm-bubbles, native dialogs), each captured separately, plus coordMap / DPI / screen-metadata. Pass that whole response JSON via `--meta`:+window image **plus its child windows** — the owned dropdowns, confirm bubbles, and+native dialogs — each captured separately, plus coordMap / DPI / screen metadata.+Pass that whole response JSON via `--meta`: ```bash adom-shotlog inject -c CH -d "checkout form with autofill open" primary.png --meta ad-response.json@@ -83,13 +99,13 @@ adom-shotlog inject -c CH -d "checkout form with autofill open" primary.png --me adom-shotlog will: - store the coordMap / DPI / screenRect / screen-resolution as a sidecar (revealed in the viewer's **⌗ capture metadata** panel, copyable as JSON),-- read each popup PNG by its `localSafePath`, save it next to the primary, and- attach it — the card shows a **+N popups** badge and the zoom modal shows each- popup as a viewable thumbnail,+- read each child-window PNG by its `localSafePath`, save it next to the primary,+ and attach it — the card shows a **+N child windows** badge and a `child windows ▸`+ row rendering each child window inline (no zoom), - derive the original resolution from `origWidth/origHeight`. The response shape it reads (extra fields are ignored, all optional except a-primary image + `screenshots[]` for popups):+primary image + `screenshots[]` for child windows): ```json {@@ -103,8 +119,8 @@ primary image + `screenshots[]` for popups): } ``` -Plain single-image injects (no `--meta`) work exactly as before — popups/metadata-are additive.+Plain single-image injects (no `--meta`) work exactly as before — child windows /+metadata are additive. | Flag | Required | Description | |------|----------|-------------|@@ -114,7 +130,7 @@ are additive. | `--orig-file` | for pre-shrunk images | Path to the original full-res image; measured | | `--orig-w` / `--orig-h` | for pre-shrunk images | Original dims (alternative to `--orig-file`) | | `--native` | for unresized small images | Assert FILE is at original resolution |-| `--meta` | optional | Adom Desktop screenshot-response JSON: popups + coordMap + orig dims |+| `--meta` | optional | Adom Desktop screenshot-response JSON: child windows + coordMap + orig dims | | `FILE` | yes | Primary image path (use `-` for stdin) | ## CRITICAL: Always write great descriptions@@ -132,9 +148,9 @@ adom-shotlog open -c my-project # opens the gallery as a Hydrogen webvi ``` - **Gallery**, newest-first. Each card: description, timestamp, size (shown as- `1400×788 · orig 1920×1080` when they differ), KB, source, a **+N popups** badge- when the shot carried owned popups, and an **ⓘ meta** chip when it has capture- metadata. Tap a card to zoom to the full image + popup thumbnails + the copyable+ `1400×788 · orig 1920×1080` when they differ), KB, source, a **+N child windows**+ badge when the capture grabbed child windows, and an **ⓘ meta** chip when it has+ capture metadata. Child windows show inline; tap a card to zoom + see the copyable metadata panel. - **Mobile-ready**, **green LED** = a viewer is connected, **Ctrl+V** pastes a screenshot, **live updates** over WebSocket.
VERSION+1−1@@ -1 +1 @@-0.6.2+0.7.0
bin/adom-shotlog⋯ 1 unchanged line ⋯
install.sh+16@@ -14,4 +14,20 @@ echo "OK: adom-shotlog binary at ~/.local/bin/adom-shotlog (alias: shotlog)" # Deploy the embedded SKILL.md + bash completions + alias. "$HOME/.local/bin/adom-shotlog" install || true +# Deploy the bundled sub-skills to ~/.claude/skills/<name>/:+# adom-shotlog-surfacing, adom-shotlog-child-windows, adom-shotlog-publishing+for name in adom-shotlog-surfacing adom-shotlog-child-windows adom-shotlog-publishing; do+ [ -f "$HERE/skills/$name/SKILL.md" ] || continue+ mkdir -p "$HOME/.claude/skills/$name"+ cp "$HERE/skills/$name/SKILL.md" "$HOME/.claude/skills/$name/SKILL.md"+ echo "OK: skill $name"+done+# Catch any additional sub-skills too.+for d in "$HERE"/skills/*/; do+ [ -f "$d/SKILL.md" ] || continue+ name="$(basename "$d")"+ mkdir -p "$HOME/.claude/skills/$name"+ cp "$d/SKILL.md" "$HOME/.claude/skills/$name/SKILL.md"+done+ echo "OK: adom-shotlog installed. Run 'adom-shotlog serve &' then 'adom-shotlog open -c my-project'."
package.json+8−2@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.6.2",+ "version": "0.7.0", "type": "app", "title": "Adom Shotlog", "description": "See the shots your AI never showed you \u2014 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 + on which surface, and ingests Adom Desktop owned-popup arrays via --meta.",@@ -12,6 +12,7 @@ }, "files": [ "SKILL.md",+ "skills/**", "README.md", "package.json", "install.sh",@@ -42,7 +43,12 @@ "prove it working", "review on phone", "remote control screenshots",- "popup screenshot array"+ "popup screenshot array",+ "shotlog surfacing",+ "gentle notification",+ "skinny panel",+ "dont foreground",+ "taskbar flash" ], "discovery_pitch": "Your AI screenshots its work into channels and you review it live anywhere \u2014 even your phone. Every shot records its original capture resolution, and Adom Desktop popup arrays come through with their coordMap.", "visibility": "public",
page.json+8−2@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.6.2",+ "version": "0.7.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 + on which surface, and ingests Adom Desktop owned-popup arrays via --meta.",@@ -12,6 +12,7 @@ }, "files": [ "SKILL.md",+ "skills/**", "README.md", "package.json", "install.sh",@@ -42,7 +43,12 @@ "prove it working", "review on phone", "remote control screenshots",- "popup screenshot array"+ "popup screenshot array",+ "shotlog surfacing",+ "gentle notification",+ "skinny panel",+ "dont foreground",+ "taskbar flash" ], "discovery_pitch": "Your AI screenshots its work into channels and you review it live anywhere — even your phone. Every shot records its original capture resolution, and Adom Desktop popup arrays come through with their coordMap.", "visibility": "public",
skills/adom-shotlog-child-windows/SKILL.mdadded+75@@ -0,0 +1,75 @@+---+name: adom-shotlog-child-windows+description: "Capture and display a parent window PLUS its CHILD WINDOWS in shotlog — the owned dropdowns, confirm bubbles, and native dialogs that Adom Desktop returns alongside a window shot, ingested via `inject --meta`. Use when: injecting a desktop_screenshot_window response, a captured window had a dropdown/dialog open, showing multi-window captures, or asking how child windows render. Trigger words: child windows, parent window, owned windows, owned popups, ownedPopupCount, window array, desktop_screenshot_window array, --meta, coordMap, dropdown capture, dialog capture, multi-window screenshot."+---++# adom-shotlog — parent window + its CHILD WINDOWS (`--meta`)++A window capture is often **not one image**. A window that has a dropdown, an+autofill list, a confirm bubble, or a native dialog open is really a **parent+window with child windows** — separate top-level windows owned by the parent that+a plain `PrintWindow` of the parent misses entirely.++Adom Desktop surfaces them for you: **`desktop_screenshot_window {hwnd}` returns+the parent image PLUS its child windows** as a `screenshots[]` array, with+`ownedPopupCount` always present (0 = none). Each child window rides back with its+own `hwnd`, `title`, `kind` (`dropdown`/`dialog`/…), `rect`, and `coordMap`. This+is AD's generic "find a window's child windows" — no app-specific verb.++> Terminology: AD's wire fields are named `ownedPopupCount` / `screenshots[]`+> (Win32 "owned windows"), but conceptually — and in the shotlog UI — these are+> the parent window's **child windows**. Don't call them "popups" to the human.++## Inject the parent + child windows with `--meta`++```bash+adom-shotlog inject -c CH -d "checkout form with autofill dropdown open" primary.png --meta ad-response.json+```++`--meta` takes the FULL `desktop_screenshot_window` response. shotlog:+- stores coordMap / DPI / screenRect / screenResolution as a sidecar,+- reads each child-window PNG by its `localSafePath`, saves it next to the primary+ (as `{primary-stem}__popup{N}.png` on disk — a legacy filename, kept for data+ compatibility), and attaches it,+- derives the original resolution from `origWidth/origHeight` (so `--meta` satisfies+ the original-resolution rule on its own).++Response shape it reads (extras ignored; needs a primary image + `screenshots[]`):++```json+{+ "shotId": "shot_abc123", "origWidth": 2400, "origHeight": 1520,+ "width": 1400, "height": 887,+ "coordMap": { "screenRect": {…}, "dpiScale": 2.0, "imageToScreen": {…}, "screenResolution": "2400x1520" },+ "ownedPopupCount": 2,+ "screenshots": [+ { "localSafePath": "/path/child1.png", "hwnd": 204512, "title": "Address autofill", "kind": "dropdown", "coordMap": {…} },+ { "localSafePath": "/path/child2.png", "hwnd": 204880, "title": "Confirm order", "kind": "dialog", "coordMap": {…} }+ ]+}+```++`localSafePath` must be readable from wherever `inject` runs.++## How child windows DISPLAY — all on the card, no zoom++The human never has to zoom. A shot with child windows renders it all inline:+- a **`+N child windows`** badge in the meta row,+- a **`child windows ▸`** row under the primary image, showing **every child window+ in ONE horizontal row** as a thumbnail, each captioned `kind · title · WxH`,+- an **`ⓘ meta`** chip that reveals the coordMap panel (shotId, screenRect,+ dpiScale, imageToScreen, screen resolution) **inline on the card**, copyable.++Child-window PNGs are excluded from recovery as standalone entries — they belong+to their parent shot.++> In a slim right-side panel the primary image can push the `child windows ▸` row+> below the card fold — scroll the card slightly to bring it into view.++## Getting a REAL capture (not a mock)++`desktop_screenshot_window {hwnd}` reports `ownedPopupCount` for any window; the+child windows only populate when a dropdown/dialog is actually open on that window.+So: open the dropdown/dialog, capture the parent hwnd, and pass the response to+`--meta`. Plain single-image injects (no `--meta`) are unchanged — child windows+are additive.
skills/adom-shotlog-publishing/SKILL.mdadded+71@@ -0,0 +1,71 @@+---+name: adom-shotlog-publishing+description: "How to publish adom-shotlog (and apps like it) to wiki.adom.inc with adompkg — org ownership, OSS source, MIT license, hero, and the wiki traps that waste hours (visibility re-privatization, hero caching, binary overwrite, title vs slug). Use when: publishing/republishing adom-shotlog, cutting a release, fixing an unlisted/privatized page, a stale hero, or bundling sub-skills. Trigger words: publish shotlog, adompkg publish, republish, wiki release, hero not updating, page went private, visibility trap, bundle sub-skills, org owned, publish rules."+---++# Publishing adom-shotlog to wiki.adom.inc++The whole app — CLI binary, source, README, main skill, AND sub-skills — ships as+**one package**: `adom/adom-shotlog`. Do NOT create separate wiki pages/repos for+the skills; they live in THIS repo and ride along.++## The publish command++```bash+cd <staging-dir> # holds package.json, bin/, src/, skills/, SKILL.md, README.md, LICENSE, screenshots/+adompkg publish --org adom --public --version <next> --yes+```++- **`--org adom`** → the page is **org-owned** (`/adom/adom-shotlog`), not+ user-owned. (The `author_name` sidebar shows the publishing employee and is+ **immutable via the API** — that's attribution, not ownership.)+- **`--public`** → anyone can install/view.+- **Version must bump every publish** — you cannot republish the same version.+ Keep `Cargo.toml`, `VERSION`, and `package.json` version in lockstep, and rebuild+ so the binary's `--version` matches what the page advertises.++## package.json essentials++- `slug: "adom-shotlog"` (install name) vs `title: "Adom Shotlog"` (display heading).+ They're different on purpose — slug stays kebab, title is human.+- `license: "MIT"` + ship a real **`LICENSE`** file (© Adom Industries) + set+ `license`/`authors` in `Cargo.toml`. Metadata alone isn't enough for OSS.+- `hero: { path: "screenshots/hero.png" }` — REQUIRED; publish refuses without it.+- `bundled: true`, a `tags` array, and `files` **must whitelist everything that+ should be browseable/installed**, including source + sub-skills:+ `["SKILL.md","skills/**","README.md","package.json","install.sh","uninstall.sh","bin/**","screenshots/**","src/**","Cargo.toml","Cargo.lock","build.sh","VERSION","LICENSE"]`+ (Only what's in `files` reaches the git repo / Files tab — omit `src/**` and the+ source is NOT browseable.)+- `install.sh` deploys the binary AND loops `skills/*/` to install every sub-skill.++## The traps (learned the hard way)++1. **Visibility re-privatization.** A `POST /files` or a metadata `PUT` on a public+ org page silently flips `visibility` back to private. **Fix: don't poke files+ piecemeal — do a bumped `adompkg publish --org adom --public`,** which+ re-publicizes. After publishing, **verify anonymously**:+ ```bash+ curl -s -o /dev/null -w "%{http_code}\n" https://wiki.adom.inc/adom/adom-shotlog+ curl -s -o /dev/null -w "%{http_code}\n" https://wiki.adom.inc/api/packages/adom/adom-shotlog/<ver>/manifest+ ```+ Both must be `200`.+2. **Hero caching by URL.** The hero blob URL (`/blob/app/adom-shotlog/screenshots/hero.png`)+ is cached by browser + CDN; overwriting the SAME path serves the stale image.+ You should NOT have to rename the file — the platform should cache-bust by+ content hash. Until it does, if a hero looks stale, confirm the served bytes+ (`curl … | md5sum`) match your new file; it's a cache, not a regression.+ (Logged for Colby — don't normalize the rename-the-file workaround.)+3. **Binary overwrite.** Overwriting a binary at the same repo path can no-op+ (`commit:null`). A version bump + republish sidesteps it.+4. **Overview vs Files lag.** The Overview tab renders a `readme` DB field set from+ the **tarball** README at publish time — a bare `git push` of README updates the+ Files tab but not the Overview. Republish to refresh Overview.++## After EVERY publish++- Verify anon `200` (above).+- **Show it in pup** (public page → pup): open/refresh the page, screenshot, look.+ A `200` proves nothing about what the human sees.+- Retiring an old slug (e.g. the pre-rename `adom/shotlog`)? `adompkg delete+ <owner>/<slug> --confirm` is a **soft, reversible** delete (`adompkg undelete`).+ It breaks `adompkg install <old>`, so mention the migration.
skills/adom-shotlog-surfacing/SKILL.mdadded+116@@ -0,0 +1,116 @@+---+name: adom-shotlog-surfacing+description: "How shotlog should SURFACE a new shot to the human — gently, per surface, without stealing focus. Use when: an AI is injecting shots and deciding how to notify the human, a shot lands unseen, choosing between pup and webview, setting up the preferred slim right-side shotlog panel, or a viewer is backgrounded. Trigger words: shotlog surfacing, don't foreground, taskbar flash, blink the tab, skinny panel, shotlog notification, shotlog update, backgrounded viewer, auto-switch shotlog, shove in my face."+---++# adom-shotlog — surfacing updates (don't shove it in the human's face)++Injecting a shot is not the goal — the human **noticing** it is. But noticing is+not the same as being **interrupted**. The rule for every update:++> **Never steal the human's foreground.** Surface the shot on the surface they're+> already using, as gently as that surface allows.++shotlog handles most of this for you in the server; your job is to pick the right+surface and use the gentle nudge, never a focus-steal.++## The surfaces, and how each should update++### Webview — the preferred layout: a slim right-side panel++The nicest setup: a **~20%-wide webview panel pinned to the right** of the+Hydrogen page. It auto-updates over WebSocket, and you can keep **several shotlog+channels as tabs in that one panel**. Open it that way:++```bash+shotlog open -c my-project --skinny # slim right panel (~20% width)+shotlog open -c other-task --skinny # stacks as another tab in the same panel+shotlog open -c my-project --skinny --ratio 0.25 # custom width fraction+```++On a new shot, the server **blinks that channel's tab** (`hydrogen alert set`) —+a gentle attention cue that auto-scrolls the tab into view **without** stealing+window focus. If the user likes the panel to *jump* to whichever channel just+updated, set `SHOTLOG_WEBVIEW_AUTOSWITCH=1` and the server also activates the tab.++### Pup — gentle orange taskbar flash, never foreground++If the viewer is a **pup** window, do **NOT** raise/foreground it. The gentle+Windows-native cue is the **orange taskbar flash**:++```bash+adom-desktop browser_alert_window '{"sessionId":"<the pup session>"}'+```++`inject` will hint you to do this when a pup viewer is backgrounded. The viewer's+own page title also shows a `● N` unseen-shot count in the pup tab.++### Phone / plain browser — in-page title badge++When the tab is backgrounded, the viewer sets its title to `● N · <channel>`, so+the count of unseen shots shows in the browser/phone tab strip. Nothing to do.++### Nobody watching — force-open fallback++If **no** viewer is connected, the server force-opens a webview as a safety net+(so a shot never lands invisibly). Opt out with `SHOTLOG_NO_FORCE_OPEN=1` for+users who never want anything auto-opened.++## Decision flow for an AI injecting a shot++1. `inject` — read its presence report: how many viewers, which **surfaces**, and+ how many are **on-screen** (visible) vs backgrounded.+2. If a viewer is **on-screen** → done; it's genuinely visible. Don't nudge.+3. If viewers exist but are **backgrounded** → nudge gently for that surface+ (webview: server already blinked; pup: `browser_alert_window`); **never+ foreground**.+4. If **nobody** is watching → open the preferred surface:+ `shotlog open -c <ch> --skinny`, or ask the human to load the URL / open it on+ their phone. Then re-verify.+5. Gate precisely with `shotlog viewers -c <ch>` — exit `0` = on-screen,+ `4` = open but backgrounded, `3` = nobody.++## The exact gentle-cue verbs (Adom Desktop)++Use these to signal an update **without foregrounding**. Never use+`desktop_bring_to_front` / raise / SendInput-click to force a window forward+just to show a shot.++| Surface | Gentle cue | Verb |+|---------|-----------|------|+| Webview tab | Blink the tab (auto-scrolls into view, no focus steal) | `adom-cli hydrogen alert set --name "Shots: <ch>"` — shotlog does this for you |+| Webview (backgrounded editor tab) | System notification | `adom-cli hydrogen notify …` |+| Pup / any window | Orange taskbar flash (FlashWindowEx) | `adom-desktop browser_alert_window {sessionId}` (pup) or `adom-desktop desktop_flash_window {hwnd}` (any window) |+| Any window | Glanceable taskbar status (progress bar + overlay icon) | `adom-desktop desktop_taskbar {hwnd, …}` |++## Finding & targeting the right window (READ-ONLY, no foreground)++You rarely need this for shotlog itself (the server handles surfacing), but when+you must locate a viewer window to flash/screenshot it, these AD verbs do it+without stealing focus — do NOT shell out to PowerShell, AD already exposes it:++| Need | Verb | Notes |+|------|------|-------|+| List all top-level windows | `desktop_list_windows` | each entry: `hwnd`, `title`, `className`, `rect`, `z` (0 = topmost) |+| Resolve an hwnd by title/class | `desktop_find_window {title\|class}` | topmost match wins |+| Parent → child subtree of a window | `desktop_ui_tree {hwnd}` | the "window list with parent/child": nested nodes with `role`, `rect{centerX,centerY}`, `automationId`, `invokable/settable` — each individually targetable in the BACKGROUND. `maxDepth`/`maxNodes`/`scope:"popups"` to narrow. |+| Capture a specific window (even occluded) | `desktop_screenshot_window {hwnd}` | WGC — grabs the window even if it's behind others; won't fire if the window is truly not visible |+| Change window state w/o foregrounding | `desktop_set_window_state` | minimize/maximize/restore without raising |++Parent/child example (real): `desktop_list_windows` gives the top-level Edge+window `hwnd=5769356`; `desktop_ui_tree {hwnd:5769356}` returns its child+subtree (`[Window] → [Pane] → [Pane] …`), each node with a screen-px rect you can+target — no foreground, no cursor move.++## Environment knobs++| Var | Effect |+|-----|--------|+| `SHOTLOG_WEBVIEW_AUTOSWITCH=1` | On update, also **activate** (auto-switch to) the channel's webview tab, not just blink it. |+| `SHOTLOG_NO_FORCE_OPEN=1` | Never auto-open a viewer, even when nobody is watching. |++## The one rule, restated++Gentle by surface: **webview → blink the tab**, **pup → orange taskbar flash**,+**phone/browser → title badge**, **nobody → force-open fallback**. Foregrounding+the human's window is never the answer.
src/html/index.html+26−16@@ -104,14 +104,14 @@ header { .shot-card-img { display: block; width: 100%; height: 220px; flex-shrink: 0; background: #111; object-fit: contain; object-position: center top; transition: opacity .15s; } .shot-card:active .shot-card-img { opacity: .85; }-/* Popup-array badge + nerd info chip on a card */+/* Child-window-array badge + nerd info chip on a card */ .shot-card-meta .badge-pop { background: rgba(0,184,176,.16); color: var(--accent); border: 1px solid rgba(0,184,176,.4); border-radius: 999px; padding: 0 7px; font-weight: 600; cursor: help; } .shot-card-meta .badge-nerd { color: var(--text3); cursor: pointer; border: 1px solid var(--border); border-radius: 4px; padding: 0 5px; user-select: none; } .shot-card-meta .badge-nerd:hover { color: var(--accent); border-color: rgba(0,184,176,.4); } .shot-card-meta .badge-nerd.active { color: var(--accent); border-color: rgba(0,184,176,.5); background: rgba(0,184,176,.10); }-/* Owned-popup row, inline on the card — all array shots in ONE row, no zoom needed */+/* Child-window row, inline on the card — all child windows in ONE row, no zoom needed */ .card-popups { display: flex; gap: 8px; padding: 9px 13px 4px; overflow-x: auto; flex-shrink: 0; } .card-popups .ph { flex: 0 0 auto; align-self: center; font: 600 10px 'Familjen Grotesk', sans-serif; color: var(--text2); white-space: nowrap; padding-right: 2px; }@@ -300,7 +300,7 @@ header { } function imgUrl(e){ return 'shots/'+encodeURIComponent(channel)+'/'+encodeURIComponent(e.filename); } function popUrl(fn){ return 'shots/'+encodeURIComponent(channel)+'/'+encodeURIComponent(fn); }- // Owned popups can be stored as a bare array (current) or wrapped — normalize to an array.+ // Child windows can be stored as a bare array (current) or wrapped — normalize to an array. 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){@@ -314,7 +314,7 @@ header { push('imageToScreen', cm.imageToScreen); push('screen resolution', cm.screenResolution||cm.screen||m.screenResolution); const np=m.ownedPopupCount!==undefined?m.ownedPopupCount:popList(e).length;- push('owned popups', np||undefined);+ push('child windows', np||undefined); return rows; } @@ -368,8 +368,8 @@ header { const sr=document.createElement('span'); sr.className='src'; sr.textContent=e.source; meta.appendChild(sr); } 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+' popup'+(pops.length>1?'s':'');- b.setAttribute('data-tooltip','This shot also captured '+pops.length+' owned popup'+(pops.length>1?'s':'')+' (dropdowns / dialogs) — shown below.');+ const b=document.createElement('span'); b.className='badge-pop'; b.textContent='+'+pops.length+' child window'+(pops.length>1?'s':'');+ b.setAttribute('data-tooltip','This capture also grabbed '+pops.length+' child window'+(pops.length>1?'s':'')+' of the parent window (dropdowns / dialogs) — shown below.'); meta.appendChild(b); } // ⓘ meta chip toggles the inline metadata panel right here on the card. let nerd=null, chip=null;@@ -381,10 +381,10 @@ header { 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)); // image still zooms if you want it c.appendChild(img);- // All owned popups in ONE row, inline — no need to open the shot.+ // All child windows in ONE row, inline — no need to open the shot. if (pops.length){ const row=document.createElement('div'); row.className='card-popups';- const ph=document.createElement('span'); ph.className='ph'; ph.textContent='popups ▸'; row.appendChild(ph);+ const ph=document.createElement('span'); ph.className='ph'; ph.textContent='child windows ▸'; row.appendChild(ph); pops.forEach(p=>{ const cell=document.createElement('div'); cell.className='pc'; const im=document.createElement('img'); im.src=popUrl(p.filename); im.alt=p.title||p.kind||'popup'; im.loading='lazy';@@ -392,8 +392,8 @@ header { cap.textContent=(p.kind?p.kind+' · ':'')+(p.title||p.filename)+(p.w&&p.h?' '+p.w+'×'+p.h:''); cell.appendChild(im); cell.appendChild(cap); cell.addEventListener('click', ev=>{ ev.stopPropagation();- openModal({filename:p.filename, description:p.title||p.kind||'popup', w:p.w, h:p.h, kb:p.kb,- timestamp:e.timestamp, source:'popup', meta:p.coordMap?{coordMap:p.coordMap,shotId:p.shotId}:null}); });+ openModal({filename:p.filename, description:p.title||p.kind||'child window', w:p.w, h:p.h, kb:p.kb,+ timestamp:e.timestamp, source:'child window', meta:p.coordMap?{coordMap:p.coordMap,shotId:p.shotId}:null}); }); row.appendChild(cell); }); c.appendChild(row);@@ -417,13 +417,13 @@ header { if(e.kb){ sep(); add('',e.kb+' KB'); } if(e.source&&e.source!=='cli'){ sep(); add('',e.source); } - // Owned-popup thumbnails — each opens full-size in its own modal entry.+ // Child-window thumbnails — each opens full-size in its own modal entry. const pops=popList(e); mPopups.innerHTML=''; if(pops.length){ mPopups.style.display=''; const h=document.createElement('div'); h.className='pop-h';- h.textContent='Owned popups · '+pops.length+' captured';+ h.textContent='Child windows · '+pops.length+' captured'; mPopups.appendChild(h); const grid=document.createElement('div'); grid.className='pop-grid'; pops.forEach(p=>{@@ -434,8 +434,8 @@ header { cap.textContent=(p.kind?p.kind+' · ':'')+(p.title||p.filename)+(p.w&&p.h?' '+p.w+'×'+p.h:''); cell.appendChild(im); cell.appendChild(cap); cell.addEventListener('click', ev=>{ ev.stopPropagation();- openModal({filename:p.filename, description:p.title||p.kind||'popup', w:p.w, h:p.h, kb:p.kb,- timestamp:e.timestamp, source:'popup', meta:p.coordMap?{coordMap:p.coordMap,shotId:p.shotId}:null}); });+ openModal({filename:p.filename, description:p.title||p.kind||'child window', w:p.w, h:p.h, kb:p.kb,+ timestamp:e.timestamp, source:'child window', meta:p.coordMap?{coordMap:p.coordMap,shotId:p.shotId}:null}); }); grid.appendChild(cell); }); mPopups.appendChild(grid);@@ -485,11 +485,20 @@ header { // ── WebSocket live updates ({type:'entry', entry}) ─────────────────────── let activeWs = null;+ // Gentle "you have unseen shots" cue: while this tab is backgrounded, count new+ // shots and show them in the page title (surfaces in the pup/browser tab strip+ // and phone tab). Cleared when the tab is shown again. Never steals focus.+ let pendingUnseen = 0;+ const baseTitle = 'adom-shotlog · ' + channel;+ function refreshTitle(){ document.title = pendingUnseen > 0 ? '● '+pendingUnseen+' · '+channel : baseTitle; }+ refreshTitle(); // Report on-screen state whenever this tab/window is shown or hidden, so the // server can tell "watching" from "open but backgrounded/minimized". document.addEventListener('visibilitychange', () => {+ const visible = document.visibilityState === 'visible'; try { if (activeWs && activeWs.readyState===1)- activeWs.send(JSON.stringify({type:'vis', visible: document.visibilityState==='visible'})); } catch {}+ activeWs.send(JSON.stringify({type:'vis', visible})); } catch {}+ if (visible) { pendingUnseen = 0; refreshTitle(); } }); function connectWs() { const proto = location.protocol==='https:'?'wss:':'ws:';@@ -508,7 +517,8 @@ header { 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; }+ feed.insertBefore(card(e), feed.firstChild); shotCount++; updateCount(); if(atTop) feed.scrollTop=0;+ if (document.hidden) { pendingUnseen++; refreshTitle(); } } } } catch {} };
src/main.rs+31−11@@ -82,9 +82,10 @@ enum Commands { native: bool, /// Path to an Adom Desktop screenshot-response JSON (from /// desktop_screenshot_window / _screen). shotlog reads coordMap + DPI +- /// origWidth/Height + the screenshots[] OWNED POPUPS (by localSafePath)- /// and stores them all on this entry: the popups become viewable- /// thumbnails and the coordMap/screen metadata a hover panel.+ /// origWidth/Height + the screenshots[] CHILD WINDOWS (owned popups —+ /// dropdowns/dialogs of the parent window, by localSafePath) and stores+ /// them on this entry: the child windows become viewable thumbnails and+ /// the coordMap/screen metadata a hover panel. #[arg(long)] meta: Option<String>, /// PNG file path (use - for stdin)@@ -104,6 +105,14 @@ enum Commands { /// Port of the running shotlog server #[arg(short, long, default_value_t = 8820)] port: u16,+ /// Open in a slim right-side panel (the preferred shotlog layout): a+ /// ~20%-wide auto-updating column. Extra `open --skinny` calls stack+ /// their tabs in the same panel (they auto-switch/blink on update).+ #[arg(long)]+ skinny: bool,+ /// Width of the skinny panel as a fraction of the workspace (default 0.2).+ #[arg(long, default_value_t = 0.2)]+ ratio: f64, }, /// Resize (if needed) and inject a screenshot (like inject but applies 1400px max-width) Paste {@@ -196,7 +205,7 @@ async fn main() { // --meta: ingest an Adom Desktop screenshot-response JSON. Pulls the // coordMap/DPI/screen metadata, the original W×H (so the shot is never- // "orig missing"), and the owned-popup array (reads each popup PNG by+ // "orig missing"), and the child-window array (reads each child PNG by // its localSafePath and attaches it). All additive. let (cap_meta, popups, meta_ow, meta_oh) = match &meta { None => (None, None, None, None),@@ -214,7 +223,7 @@ async fn main() { "origWidth": ad.get("origWidth"), "origHeight": ad.get("origHeight"), "note": ad.get("_popups_note"), });- // Owned popups → read each PNG by localSafePath, base64 it.+ // Child windows (owned popups) → read each PNG by localSafePath, base64 it. let mut pops = Vec::new(); if let Some(arr) = ad.get("screenshots").and_then(|v| v.as_array()) { for s in arr {@@ -230,7 +239,7 @@ async fn main() { "coordMap": s.get("coordMap"), })); }- Err(e) => err(&format!("WARN: popup {path} unreadable ({e}) — skipped")),+ Err(e) => err(&format!("WARN: child-window img {path} unreadable ({e}) — skipped")), } } }@@ -304,9 +313,9 @@ async fn main() { run_inject(&channel, &desc, &source, port, &file, false, orig_w, orig_h, cap_meta, popups); } Commands::Open {- channel, name, icon, port,+ channel, name, icon, port, skinny, ratio, } => {- open::open_webview(&channel, name.as_deref(), icon.as_deref(), port);+ open::open_webview(&channel, name.as_deref(), icon.as_deref(), port, skinny, ratio); } Commands::Paste { channel, desc, source, port, file,@@ -469,12 +478,23 @@ fn run_inject( } hint(cant_show); hint(verify);+ let has_pup = surfaces.iter().any(|s| s == "pup");+ let has_webview = surfaces.iter().any(|s| s == "webview"); if visible == 0 && viewers > 0 {- hint(&format!("Bring the viewer to the FOREGROUND (the pup window / webview tab / phone), then re-check: `shotlog viewers -c {channel}`."));+ // Backgrounded — surface it GENTLY. NEVER force the window to the front.+ if has_webview {+ hint("shotlog blinked the webview tab — the human sees it update in their panel. Do NOT foreground their window.");+ }+ if has_pup {+ hint("Nudge the pup viewer WITHOUT foregrounding it: `adom-desktop browser_alert_window` (gentle orange taskbar flash). Never force the pup window to the front.");+ }+ if !has_webview && !has_pup {+ hint("The viewer is backgrounded — flag it gently (taskbar flash / notification); do NOT steal the human's foreground.");+ } } if viewers == 0 {- hint(&format!("Open it, then RE-CHECK before continuing: `shotlog open -c {channel}` (Hydrogen webview) — or load the viewer in pup / on the phone — then `shotlog viewers -c {channel}`."));- hint("A viewer was force-opened as a fallback, but do NOT assume the human sees it — verify their actual surface.");+ hint(&format!("Open it, then RE-CHECK: `shotlog open -c {channel} --skinny` (preferred: a slim right-side auto-updating panel) — or load it in pup / on the phone — then `shotlog viewers -c {channel}`."));+ hint("A webview was force-opened as a fallback (opt out with SHOTLOG_NO_FORCE_OPEN=1), but do NOT assume the human sees it — verify their surface."); } } } else {
src/open.rs+113−65@@ -1,12 +1,26 @@ use std::process::Command; +const WEBVIEW_PANEL_TYPE: &str = "adom/a1b2c3d4-0031-4000-a000-000000000031";+ /// Open a webview tab in the Adom workspace for a shotlog channel.-pub fn open_webview(channel: &str, display_name: Option<&str>, icon: Option<&str>, port: u16) {+///+/// `skinny` puts it in a slim right-side panel (the preferred shotlog layout):+/// if the workspace already has a right pane, the tab is added there and that+/// split is resized so the shotlog panel is ~`ratio` of the width; if there's+/// only one pane, it's split to create the right panel. Multiple `open --skinny`+/// calls stack their tabs in the same panel.+pub fn open_webview(+ channel: &str,+ display_name: Option<&str>,+ icon: Option<&str>,+ port: u16,+ skinny: bool,+ ratio: f64,+) { let default_name = format!("Shots: {channel}"); let name = display_name.unwrap_or(&default_name); let icon = icon.unwrap_or("mdi:camera-burst"); - // Build the proxy URL from VSCODE_PROXY_URI let proxy_uri = match std::env::var("VSCODE_PROXY_URI") { Ok(uri) => uri.replace("{{port}}", &port.to_string()), Err(_) => {@@ -15,94 +29,128 @@ pub fn open_webview(channel: &str, display_name: Option<&str>, icon: Option<&str 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");+ let initial_state = serde_json::json!({ "url": url }).to_string(); - // Get the workspace layout to find a leaf node- let layout_output = Command::new("adom-cli")+ // Fetch the layout once.+ let layout_json = Command::new("adom-cli") .args(["hydrogen", "workspace", "get"])- .output();+ .output()+ .ok()+ .map(|o| String::from_utf8_lossy(&o.stdout).to_string())+ .unwrap_or_default();+ let layout: serde_json::Value = serde_json::from_str(&layout_json).unwrap_or(serde_json::Value::Null);+ let root = layout.get("root").unwrap_or(&layout); - let leaf_id = match layout_output {- Ok(output) => {- let stdout = String::from_utf8_lossy(&output.stdout);- find_leaf_id(&stdout)- }- Err(e) => {- eprintln!("Failed to get workspace layout: {e}");- None+ // No split yet + skinny → split the single pane to create the right panel.+ if skinny {+ if let Some(leaf_id) = single_leaf_id(root) {+ // first child keeps (1 - ratio); the new right pane is `ratio` wide.+ let first_ratio = (1.0 - ratio).clamp(0.1, 0.9);+ let out = Command::new("adom-cli")+ .args([+ "hydrogen", "workspace", "split",+ "--panel-id", &leaf_id,+ "--direction", "horizontal",+ "--position", "after",+ "--ratio", &format!("{first_ratio}"),+ "--panel-type", WEBVIEW_PANEL_TYPE,+ "--display-name", name,+ "--display-icon", icon,+ "--initial-state", &initial_state,+ ])+ .output();+ report(out, name, channel, &url);+ return; }- };+ } - let Some(leaf_id) = leaf_id else {+ // Otherwise: add the tab to the preferred (right/second) leaf.+ let Some(leaf_id) = find_leaf_id(root) else { eprintln!("Could not find a leaf pane in workspace layout."); eprintln!("URL: {url}"); return; };-- let initial_state = serde_json::json!({ "url": url }).to_string();-- let result = Command::new("adom-cli")+ let out = Command::new("adom-cli") .args([- "hydrogen",- "workspace",- "add-tab",- "--panel-id",- &leaf_id,- "--panel-type",- "adom/a1b2c3d4-0031-4000-a000-000000000031",- "--display-name",- name,- "--display-icon",- icon,- "--initial-state",- &initial_state,+ "hydrogen", "workspace", "add-tab",+ "--panel-id", &leaf_id,+ "--panel-type", WEBVIEW_PANEL_TYPE,+ "--display-name", name,+ "--display-icon", icon,+ "--initial-state", &initial_state, ]) .output();+ report(out, name, channel, &url); - match result {- Ok(output) => {- if output.status.success() {- println!("Opened shotlog viewer: {name}");- println!("Channel: {channel}");- println!("URL: {url}");- } else {- let stderr = String::from_utf8_lossy(&output.stderr);- eprintln!("adom-cli add-tab failed: {stderr}");- }+ // Skinny + an existing split → resize so the shotlog pane is ~`ratio` wide.+ if skinny {+ if let Some((split_id, side)) = find_enclosing_split(root, &leaf_id) {+ // ratio is the FIRST child's fraction. If shotlog is the second+ // (right) child, first gets (1 - ratio); if it's the first, it gets ratio.+ let first_ratio = if side == "second" { 1.0 - ratio } else { ratio };+ let first_ratio = first_ratio.clamp(0.1, 0.9);+ let _ = Command::new("adom-cli")+ .args(["hydrogen", "workspace", "resize", "--split-id", &split_id, "--ratio", &format!("{first_ratio}")])+ .stdout(std::process::Stdio::null())+ .stderr(std::process::Stdio::null())+ .status(); }+ }+}++fn report(out: std::io::Result<std::process::Output>, name: &str, channel: &str, url: &str) {+ match out {+ Ok(output) if output.status.success() => {+ println!("Opened shotlog viewer: {name}");+ println!("Channel: {channel}");+ println!("URL: {url}");+ }+ Ok(output) => eprintln!("adom-cli failed: {}", String::from_utf8_lossy(&output.stderr)), Err(e) => eprintln!("Failed to run adom-cli: {e}"), } } -/// Walk the workspace JSON to find the first leaf node ID.-fn find_leaf_id(json_str: &str) -> Option<String> {- let val: serde_json::Value = serde_json::from_str(json_str).ok()?;- // The layout may be wrapped in a "root" key- if let Some(root) = val.get("root") {- find_leaf_recursive(root)- } else {- find_leaf_recursive(&val)+/// 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") {+ return root.get("id").and_then(|id| id.as_str()).map(String::from); }+ None+}++/// First leaf id, preferring the second (right/bottom) child of a split.+fn find_leaf_id(root: &serde_json::Value) -> Option<String> {+ find_leaf_recursive(root) } fn find_leaf_recursive(val: &serde_json::Value) -> Option<String> {- if let Some(node_type) = val.get("type").and_then(|t| t.as_str()) {- if node_type == "leaf" {- return val.get("id").and_then(|id| id.as_str()).map(|s| s.to_string());- }- if node_type == "split" {- // Try second child first (usually the right/bottom pane where viewers go)- if let Some(second) = val.get("second") {- if let Some(id) = find_leaf_recursive(second) {- return Some(id);- }+ match val.get("type").and_then(|t| t.as_str()) {+ Some("leaf") => val.get("id").and_then(|id| id.as_str()).map(String::from),+ Some("split") => val+ .get("second").and_then(find_leaf_recursive)+ .or_else(|| val.get("first").and_then(find_leaf_recursive)),+ _ => None,+ }+}++/// Find the split directly containing the given leaf, and whether the leaf is its+/// "first" or "second" child.+fn find_enclosing_split(val: &serde_json::Value, leaf_id: &str) -> Option<(String, String)> {+ if val.get("type").and_then(|t| t.as_str()) != Some("split") {+ return None;+ }+ for side in ["first", "second"] {+ if let Some(child) = val.get(side) {+ if child.get("type").and_then(|t| t.as_str()) == Some("leaf")+ && child.get("id").and_then(|i| i.as_str()) == Some(leaf_id)+ {+ let sid = val.get("id").and_then(|i| i.as_str())?.to_string();+ return Some((sid, side.to_string())); }- if let Some(first) = val.get("first") {- if let Some(id) = find_leaf_recursive(first) {- return Some(id);- }+ if let Some(found) = find_enclosing_split(child, leaf_id) {+ return Some(found); } } }
src/server.rs+90−20@@ -181,36 +181,107 @@ The description becomes the filename -- say what you are looking at and why. ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], body) } -/// Force-open the viewer for a channel when a shot lands with nobody watching.-/// In-process replacement for an external fs-watcher: called from the inject-/// path. Debounced per-channel so a burst of injects can't spawn a storm.-fn force_open_viewer(channel: &str, port: u16) {+/// Per-key cooldown so a burst of injects can't spam adom-cli. Returns true if+/// the action for `key` may run now (and stamps it), false if still cooling down.+fn debounce_ok(key: &str, secs: u64) -> bool { use std::sync::Mutex; use std::collections::HashMap; use std::time::Instant; static LAST: Mutex<Option<HashMap<String, Instant>>> = Mutex::new(None);+ let mut guard = LAST.lock().unwrap();+ let map = guard.get_or_insert_with(HashMap::new);+ if let Some(t) = map.get(key) {+ if t.elapsed().as_secs() < secs {+ return false;+ }+ }+ map.insert(key.to_string(), Instant::now());+ true+}++/// Decide how to GENTLY surface a new shot, based on which surface(s) are+/// currently watching. The rule (see the adom-shotlog-surfacing skill): never+/// steal the human's foreground.+/// - **webview** watching → BLINK that channel's tab (the user's skinny+/// right-side panel notices without a focus-steal). `SHOTLOG_WEBVIEW_AUTOSWITCH=1`+/// additionally activates the tab (auto-switch to the updated channel).+/// - **only pup / phone** (no webview) → do NOTHING intrusive here; the viewer's+/// own in-page title/favicon cue flags the backgrounded tab, and the AI is+/// hinted to flash the pup taskbar (`browser_alert_window`) — never foreground.+/// - **nobody** watching → force-open a webview as a fallback, unless+/// `SHOTLOG_NO_FORCE_OPEN` is set.+fn notify_surface(channel: &str, port: u16, surfaces: &[String], viewers: usize) {+ let has_webview = surfaces.iter().any(|s| s == "webview");+ if has_webview {+ blink_webview_tab(channel);+ if env_truthy("SHOTLOG_WEBVIEW_AUTOSWITCH") {+ refresh_webview_tab(channel, port);+ }+ return;+ }+ // Only pup / phone watching → leave their foreground alone (in-page cue + AI hint).+ if viewers == 0 && std::env::var("SHOTLOG_NO_FORCE_OPEN").is_err() {+ force_open_viewer(channel, port);+ }+} +fn env_truthy(k: &str) -> bool {+ matches!(std::env::var(k).as_deref(), Ok("1") | Ok("true") | Ok("yes"))+}++/// Blink the channel's webview tab (non-intrusive attention cue — no focus steal,+/// no reload). Debounced so a burst blinks once.+fn blink_webview_tab(channel: &str) {+ if !debounce_ok(&format!("blink:{channel}"), 3) {+ return;+ }+ let tab = format!("Shots: {channel}");+ std::thread::spawn(move || {+ let _ = std::process::Command::new("adom-cli")+ .args(["hydrogen", "alert", "set", "--name", &tab, "-d", "8000"])+ .stdout(std::process::Stdio::null())+ .stderr(std::process::Stdio::null())+ .status();+ });+}++/// Activate (auto-switch to) the channel's webview tab. Opt-in via+/// SHOTLOG_WEBVIEW_AUTOSWITCH — some users want the panel to jump to whichever+/// shotlog just updated. Debounced to avoid reload-thrash on bursts.+fn refresh_webview_tab(channel: &str, port: u16) {+ if !debounce_ok(&format!("switch:{channel}"), 3) {+ return;+ }+ let Ok(proxy_tmpl) = std::env::var("VSCODE_PROXY_URI") else { return };+ let base = proxy_tmpl.replace("{{port}}", &port.to_string());+ let base = base.trim_end_matches('/').to_string();+ let url = format!("{base}/log/{channel}/?surface=webview");+ let tab = format!("Shots: {channel}");+ std::thread::spawn(move || {+ let _ = std::process::Command::new("adom-cli")+ .args(["hydrogen", "webview", "open-or-refresh", "--name", &tab, "--url", &url])+ .stdout(std::process::Stdio::null())+ .stderr(std::process::Stdio::null())+ .status();+ });+}++/// Force-open the viewer for a channel when a shot lands with NOBODY watching.+/// Fallback only (see notify_surface). Debounced per-channel.+fn force_open_viewer(channel: &str, port: u16) { let proxy_tmpl = match std::env::var("VSCODE_PROXY_URI") { Ok(v) => v, Err(_) => return, // not in an Adom container; nothing to open };-- {- let mut guard = LAST.lock().unwrap();- let map = guard.get_or_insert_with(HashMap::new);- if let Some(t) = map.get(channel) {- if t.elapsed().as_secs() < 60 {- return; // cooldown — opened recently- }- }- map.insert(channel.to_string(), Instant::now());+ if !debounce_ok(&format!("open:{channel}"), 60) {+ return; // opened recently } let base = proxy_tmpl.replace("{{port}}", &port.to_string()); let base = base.trim_end_matches('/').to_string(); // ?surface=webview so presence correctly labels this force-opened viewer. let url = format!("{base}/log/{channel}/?surface=webview");- let tab = format!("shotlog:{channel}");+ let tab = format!("Shots: {channel}"); let icon = format!("data:image/svg+xml;base64,{}", base64::Engine::encode(&base64::engine::general_purpose::STANDARD, ICON_SVG)); // Best-effort; don't block the inject response on it.@@ -341,11 +412,10 @@ async fn post_shots( let visible_viewers = infos.iter().filter(|i| i.visible).count(); let visible_surfaces: Vec<String> = infos.iter().filter(|i| i.visible).map(|i| i.surface.clone()).collect(); - // If nobody is watching this channel, force-open a viewer so the user- // actually sees the shot instead of it landing invisibly in the log.- if viewers == 0 {- force_open_viewer(&req.channel, state.port);- }+ // Gently surface the new shot to whatever surface is watching — never steal+ // the human's foreground. (webview → blink tab; pup/phone → leave it; none →+ // force-open fallback.) See notify_surface + the adom-shotlog-surfacing skill.+ notify_surface(&req.channel, state.port, &surfaces, viewers); Ok(Json(InjectResponse { ok: true,