From 7527e163b29949ff8e4167e4eeccbe1b83acf20a Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
Date: Wed, 3 Jun 2026 12:57:41 -0400
Subject: [PATCH 1/5] feat(desktop): add reusable screenshot workflow for agent
 UI verification

Agents working on desktop UI changes had no automated way to capture and
share visual results. Extends screenshot.mjs with --messages injection so
agents can populate the channel timeline before capturing, adds
scripts/post-screenshots.sh for hosting PNGs via a shared orphan branch
on GitHub, wires up a just desktop-screenshot target that handles build
and server lifecycle automatically, and updates AGENTS.md with the full
workflow.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
---
 AGENTS.md                            | 56 +++++++++++++++++++----
 desktop/tests/helpers/screenshot.mjs | 68 ++++++++++++++++++++++++++--
 justfile                             | 13 ++++++
 scripts/post-screenshots.sh          | 62 +++++++++++++++++++++++++
 4 files changed, 187 insertions(+), 12 deletions(-)
 create mode 100755 scripts/post-screenshots.sh

diff --git a/AGENTS.md b/AGENTS.md
index 26cff18332..92d1c666cb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -230,18 +230,58 @@ E2E mock bridge. A standalone screenshot helper at
 tests use (`addInitScript` + `window.__SPROUT_E2E__`) into a CLI tool.
 
 ```bash
-just desktop-build   # build the frontend first
-cd desktop
-node tests/helpers/screenshot.mjs --name home
-node tests/helpers/screenshot.mjs --name channel --route /channels/general
-node tests/helpers/screenshot.mjs --name search --click open-search
-node tests/helpers/screenshot.mjs --name settings --click open-settings
+just desktop-screenshot --name home
+just desktop-screenshot --name channel --route /channels/general
+just desktop-screenshot --name search --click open-search
+just desktop-screenshot --name settings --click open-settings
 ```
 
 Options: `--name` (filename), `--route` (client route), `--click` (data-testid
 or CSS selector), `--wait` (ms, default 2000), `--viewport` (WxH, default
-1280x720), `--outdir` (default `test-results/screenshots`). Screenshots are
-saved as PNGs and the path is printed to stdout.
+1280x720), `--outdir` (default `test-results/screenshots`),
+`--messages` (JSON file path). Screenshots are saved as PNGs and the path is
+printed to stdout. The `just desktop-screenshot` target handles building the
+frontend and starting the preview server automatically.
+
+#### Injecting messages
+
+Use `--messages` to inject content into the channel timeline before screenshotting.
+The JSON file contains an array of messages to inject:
+
+```bash
+cat > /tmp/msgs.json << 'EOF'
+[
+  { "channelName": "general", "content": "```typescript\nconst x: number = 42;\n```" },
+  { "channelName": "general", "content": "plain text message" }
+]
+EOF
+just desktop-screenshot --name code-blocks --messages /tmp/msgs.json
+```
+
+Each message requires `channelName` and `content`; optional fields are `pubkey`
+and `kind`. When `--messages` is provided, the script navigates to the channel
+from the first message (ignoring `--route`), waits for the live subscription,
+injects all messages, then captures. Available mock channels: `general`,
+`random`, `design`, `sales`, `engineering`, `agents`, `watercooler`,
+`announcements`, `alice-tyler`, `bob-tyler`.
+
+#### Posting screenshots to a PR
+
+`scripts/post-screenshots.sh` hosts PNGs on a shared `agent-screenshots` orphan
+branch and posts them as a PR comment:
+
+```bash
+# Take screenshots, then post them
+just desktop-screenshot --name feature-demo --messages /tmp/msgs.json --outdir test-results/screenshots
+./scripts/post-screenshots.sh 803 test-results/screenshots
+
+# Or provide a custom comment body (images are appended)
+./scripts/post-screenshots.sh 803 test-results/screenshots body.md
+```
+
+The orphan branch accumulates images across PRs, namespaced as `pr-<N>/`. Re-runs
+for the same PR overwrite previous images. Delete the branch when no longer
+needed: `git push origin --delete agent-screenshots`.
 
 The Playwright MCP browser (`@playwright/mcp`) is also configured but cannot
 drive the desktop app directly because it evaluates JS after page load — too
diff --git a/desktop/tests/helpers/screenshot.mjs b/desktop/tests/helpers/screenshot.mjs
index acf2dc62ab..f65e405810 100644
--- a/desktop/tests/helpers/screenshot.mjs
+++ b/desktop/tests/helpers/screenshot.mjs
@@ -16,9 +16,10 @@
 //   --wait <ms>          Milliseconds to wait before capture (default: 2000)
 //   --viewport <WxH>     Viewport dimensions (default: 1280x720)
 //   --outdir <path>      Output directory (default: test-results/screenshots)
+//   --messages <path>    JSON file with messages to inject before capture
 
 import { parseArgs } from "node:util";
-import { existsSync, mkdirSync } from "node:fs";
+import { existsSync, mkdirSync, readFileSync } from "node:fs";
 import { resolve, join } from "node:path";
 import { chromium } from "@playwright/test";
 
@@ -30,6 +31,7 @@ const { values: args } = parseArgs({
     wait: { type: "string", default: "2000" },
     viewport: { type: "string", default: "1280x720" },
     outdir: { type: "string", default: "test-results/screenshots" },
+    messages: { type: "string" },
   },
   strict: true,
 });
@@ -110,9 +112,67 @@ await page.addInitScript(() => {
   window.__SPROUT_E2E_APP_BADGE_COUNT__ = 0;
 });
 
-const url = args.route === "/" ? BASE_URL : `${BASE_URL}/#${args.route}`;
-await page.goto(url);
-await page.waitForTimeout(waitMs);
+if (args.messages) {
+  let messages;
+  try {
+    messages = JSON.parse(readFileSync(resolve(args.messages), "utf8"));
+  } catch (err) {
+    console.error(`Failed to read messages file: ${err.message}`);
+    await browser.close();
+    process.exit(1);
+  }
+
+  if (
+    !Array.isArray(messages) ||
+    messages.length === 0 ||
+    messages.some(
+      (m) => typeof m.channelName !== "string" || typeof m.content !== "string",
+    )
+  ) {
+    console.error(
+      "messages file must be a non-empty array of { channelName: string, content: string, pubkey?: string, kind?: number }",
+    );
+    await browser.close();
+    process.exit(1);
+  }
+
+  const channelName = messages[0].channelName;
+
+  await page.goto(BASE_URL);
+  await page.waitForSelector(`[data-testid="channel-${channelName}"]`, {
+    timeout: 10000,
+  });
+  await page.click(`[data-testid="channel-${channelName}"]`);
+
+  await page.waitForFunction(
+    (name) =>
+      window.__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
+        channelName: name,
+      }) ?? false,
+    channelName,
+    { timeout: 10000 },
+  );
+
+  for (const msg of messages) {
+    await page.evaluate(
+      (m) => {
+        window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.(m);
+      },
+      {
+        channelName: msg.channelName,
+        content: msg.content,
+        pubkey: msg.pubkey ?? DEFAULT_MOCK_PUBKEY,
+        kind: msg.kind,
+      },
+    );
+  }
+
+  await page.waitForTimeout(waitMs);
+} else {
+  const url = args.route === "/" ? BASE_URL : `${BASE_URL}/#${args.route}`;
+  await page.goto(url);
+  await page.waitForTimeout(waitMs);
+}
 
 if (args.click) {
   const selector = args.click.startsWith("[")
diff --git a/justfile b/justfile
index e6b97880b0..d7ff64c35b 100644
--- a/justfile
+++ b/justfile
@@ -198,6 +198,19 @@ desktop-e2e-smoke:
 desktop-e2e-integration: _ensure-migrations
     cd {{desktop_dir}} && pnpm test:e2e:integration
 
+# Take desktop screenshots using the mock bridge
+desktop-screenshot *ARGS:
+    #!/usr/bin/env bash
+    set -euo pipefail
+    cd {{desktop_dir}}
+    [[ -d dist ]] || { cd ..; just desktop-build; cd {{desktop_dir}}; }
+    if ! curl -sf http://127.0.0.1:4173/ >/dev/null 2>&1; then
+        python3 -m http.server 4173 -d dist >/dev/null 2>&1 &
+        trap "kill $! 2>/dev/null || true" EXIT
+        for i in $(seq 1 20); do curl -sf http://127.0.0.1:4173/ >/dev/null && break; sleep 0.5; done
+    fi
+    node tests/helpers/screenshot.mjs {{ARGS}}
+
 # Mesh-compute e2e: the CI-safe layers (relay mesh signaling invariants + Playwright UI)
 mesh-e2e:
     cargo test -p sprout-relay mesh_signaling
diff --git a/scripts/post-screenshots.sh b/scripts/post-screenshots.sh
new file mode 100755
index 0000000000..2d36b34207
--- /dev/null
+++ b/scripts/post-screenshots.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ $# -lt 2 ]]; then
+  echo "Usage: $0 <pr-number> <png-dir> [comment-body-file]" >&2
+  exit 1
+fi
+
+PR="$1"
+PNG_DIR="$2"
+BODY_FILE="${3:-}"
+
+BRANCH="agent-screenshots"
+REPO="block/sprout"
+RAW_BASE="https://raw.githubusercontent.com/${REPO}/refs/heads/${BRANCH}"
+
+mapfile -t PNGS < <(find "$PNG_DIR" -maxdepth 1 -name "*.png" -type f | sort)
+if [[ ${#PNGS[@]} -eq 0 ]]; then
+  echo "error: no PNGs found in $PNG_DIR" >&2
+  exit 1
+fi
+
+EXISTING_ENTRIES=""
+if git fetch origin "refs/heads/${BRANCH}:refs/remotes/origin/${BRANCH}" 2>/dev/null; then
+  EXISTING_ENTRIES=$(git ls-tree "origin/${BRANCH}" | grep -v "	pr-${PR}/" || true)
+fi
+
+NEW_ENTRIES=""
+IMAGE_URLS=()
+for PNG in "${PNGS[@]}"; do
+  FILENAME=$(basename "$PNG")
+  BLOB=$(git hash-object -w "$PNG")
+  TREE_PATH="pr-${PR}/${FILENAME}"
+  NEW_ENTRIES+="100644 blob ${BLOB}	${TREE_PATH}"$'\n'
+  IMAGE_URLS+=("${RAW_BASE}/${TREE_PATH}")
+done
+
+COMBINED=$(printf '%s\n' "$EXISTING_ENTRIES" "$NEW_ENTRIES" | grep -v '^$')
+TREE=$(echo "$COMBINED" | git mktree)
+
+COMMIT=$(git commit-tree "$TREE" -m "screenshots: PR #${PR}")
+git push --force origin "${COMMIT}:refs/heads/${BRANCH}"
+
+if [[ -n "$BODY_FILE" ]]; then
+  COMMENT_BODY=$(cat "$BODY_FILE")
+  COMMENT_BODY+=$'\n\n'
+  for URL in "${IMAGE_URLS[@]}"; do
+    FILENAME=$(basename "$URL")
+    NAME="${FILENAME%.png}"
+    COMMENT_BODY+="![${NAME}](${URL})"$'\n\n'
+  done
+else
+  COMMENT_BODY="## Screenshots"$'\n\n'
+  for URL in "${IMAGE_URLS[@]}"; do
+    FILENAME=$(basename "$URL")
+    NAME="${FILENAME%.png}"
+    COMMENT_BODY+="![${NAME}](${URL})"$'\n\n'
+  done
+fi
+
+gh pr comment "$PR" --repo "$REPO" --body "$COMMENT_BODY"
+echo "Posted ${#PNGS[@]} screenshot(s) to PR #${PR}"

From 16d21d2e2b58ff070a88b219c8916aeedf6b5ba6 Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
Date: Wed, 3 Jun 2026 13:01:58 -0400
Subject: [PATCH 2/5] fix: address code review findings for screenshot workflow

Fixes all issues from the crossfire review (3 Claude specialists +
Codex + Gemini):

- Replace slashed tree paths with flat pr-N-- namespace so git mktree
  accepts them (was failing on every invocation)
- Add PR number validation, channelName format check, and multi-channel
  rejection with clear error messages
- Wrap screenshot.mjs main logic in try/finally for browser cleanup
- Warn when --messages and --route are both provided
- Use --force-with-lease instead of --force for push safety
- Replace literal tabs with printf/explicit $'\t' for robustness
- Deduplicate image URL markdown loop in post-screenshots.sh
- Always rebuild frontend in just desktop-screenshot for freshness
---
 AGENTS.md                            |  19 ++--
 desktop/tests/helpers/screenshot.mjs | 157 +++++++++++++++------------
 justfile                             |   3 +-
 scripts/post-screenshots.sh          |  35 +++---
 4 files changed, 120 insertions(+), 94 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index 92d1c666cb..7d43f8ba27 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -259,11 +259,12 @@ just desktop-screenshot --name code-blocks --messages /tmp/msgs.json
 ```
 
 Each message requires `channelName` and `content`; optional fields are `pubkey`
-and `kind`. When `--messages` is provided, the script navigates to the channel
-from the first message (ignoring `--route`), waits for the live subscription,
-injects all messages, then captures. Available mock channels: `general`,
-`random`, `design`, `sales`, `engineering`, `agents`, `watercooler`,
-`announcements`, `alice-tyler`, `bob-tyler`.
+and `kind`. All messages must target the same channel. When `--messages` is
+provided, the script navigates to the channel from the first message (ignoring
+`--route`), waits for the live subscription, injects all messages, then
+captures. Channel names must match `[a-z0-9-]+`. Available mock channels:
+`general`, `random`, `design`, `sales`, `engineering`, `agents`,
+`watercooler`, `announcements`, `alice-tyler`, `bob-tyler`.
 
 #### Posting screenshots to a PR
 
@@ -279,9 +280,11 @@ just desktop-screenshot --name feature-demo --messages /tmp/msgs.json --outdir t
 ./scripts/post-screenshots.sh 803 test-results/screenshots body.md
 ```
 
-The orphan branch accumulates images across PRs, namespaced as `pr-<N>/`. Re-runs
-for the same PR overwrite previous images. Delete the branch when no longer
-needed: `git push origin --delete agent-screenshots`.
+The orphan branch accumulates images across PRs, namespaced as `pr-<N>--`. Re-runs
+for the same PR overwrite previous images. Concurrent runs for different PRs may
+conflict (the push uses `--force-with-lease` and will fail if the branch was
+updated since fetch; retry if needed). Delete the branch when no longer needed:
+`git push origin --delete agent-screenshots`.
 
 The Playwright MCP browser (`@playwright/mcp`) is also configured but cannot
 drive the desktop app directly because it evaluates JS after page load — too
diff --git a/desktop/tests/helpers/screenshot.mjs b/desktop/tests/helpers/screenshot.mjs
index f65e405810..b6bcc1ef67 100644
--- a/desktop/tests/helpers/screenshot.mjs
+++ b/desktop/tests/helpers/screenshot.mjs
@@ -112,78 +112,101 @@ await page.addInitScript(() => {
   window.__SPROUT_E2E_APP_BADGE_COUNT__ = 0;
 });
 
-if (args.messages) {
-  let messages;
-  try {
-    messages = JSON.parse(readFileSync(resolve(args.messages), "utf8"));
-  } catch (err) {
-    console.error(`Failed to read messages file: ${err.message}`);
-    await browser.close();
-    process.exit(1);
-  }
+try {
+  if (args.messages) {
+    if (args.route !== "/") {
+      console.warn("warning: --route is ignored when --messages is provided");
+    }
 
-  if (
-    !Array.isArray(messages) ||
-    messages.length === 0 ||
-    messages.some(
-      (m) => typeof m.channelName !== "string" || typeof m.content !== "string",
-    )
-  ) {
-    console.error(
-      "messages file must be a non-empty array of { channelName: string, content: string, pubkey?: string, kind?: number }",
-    );
-    await browser.close();
-    process.exit(1);
-  }
+    let messages;
+    try {
+      messages = JSON.parse(readFileSync(resolve(args.messages), "utf8"));
+    } catch (err) {
+      console.error(`Failed to read messages file: ${err.message}`);
+      process.exitCode = 1;
+      throw err;
+    }
 
-  const channelName = messages[0].channelName;
+    if (
+      !Array.isArray(messages) ||
+      messages.length === 0 ||
+      messages.some(
+        (m) =>
+          typeof m.channelName !== "string" || typeof m.content !== "string",
+      )
+    ) {
+      const msg =
+        "messages file must be a non-empty array of { channelName: string, content: string, pubkey?: string, kind?: number }";
+      console.error(msg);
+      process.exitCode = 1;
+      throw new Error(msg);
+    }
 
-  await page.goto(BASE_URL);
-  await page.waitForSelector(`[data-testid="channel-${channelName}"]`, {
-    timeout: 10000,
-  });
-  await page.click(`[data-testid="channel-${channelName}"]`);
-
-  await page.waitForFunction(
-    (name) =>
-      window.__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
-        channelName: name,
-      }) ?? false,
-    channelName,
-    { timeout: 10000 },
-  );
-
-  for (const msg of messages) {
-    await page.evaluate(
-      (m) => {
-        window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.(m);
-      },
-      {
-        channelName: msg.channelName,
-        content: msg.content,
-        pubkey: msg.pubkey ?? DEFAULT_MOCK_PUBKEY,
-        kind: msg.kind,
-      },
+    const channels = new Set(messages.map((m) => m.channelName));
+    if (channels.size > 1) {
+      const msg =
+        "All messages must target the same channelName for a single screenshot";
+      console.error(msg);
+      process.exitCode = 1;
+      throw new Error(msg);
+    }
+
+    const channelName = messages[0].channelName;
+
+    if (!/^[a-z0-9-]+$/.test(channelName)) {
+      const msg = `Invalid channel name: ${channelName}`;
+      console.error(msg);
+      process.exitCode = 1;
+      throw new Error(msg);
+    }
+
+    await page.goto(BASE_URL);
+    await page.waitForSelector(`[data-testid="channel-${channelName}"]`, {
+      timeout: 10000,
+    });
+    await page.click(`[data-testid="channel-${channelName}"]`);
+
+    await page.waitForFunction(
+      (name) =>
+        window.__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
+          channelName: name,
+        }) ?? false,
+      channelName,
+      { timeout: 10000 },
     );
-  }
 
-  await page.waitForTimeout(waitMs);
-} else {
-  const url = args.route === "/" ? BASE_URL : `${BASE_URL}/#${args.route}`;
-  await page.goto(url);
-  await page.waitForTimeout(waitMs);
-}
+    for (const msg of messages) {
+      await page.evaluate(
+        (m) => {
+          window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.(m);
+        },
+        {
+          channelName: msg.channelName,
+          content: msg.content,
+          pubkey: msg.pubkey ?? DEFAULT_MOCK_PUBKEY,
+          kind: msg.kind,
+        },
+      );
+    }
 
-if (args.click) {
-  const selector = args.click.startsWith("[")
-    ? args.click
-    : `[data-testid="${args.click}"]`;
-  await page.click(selector);
-  await page.waitForTimeout(500);
-}
+    await page.waitForTimeout(waitMs);
+  } else {
+    const url = args.route === "/" ? BASE_URL : `${BASE_URL}/#${args.route}`;
+    await page.goto(url);
+    await page.waitForTimeout(waitMs);
+  }
 
-const filepath = join(outdir, `${args.name}.png`);
-await page.screenshot({ path: filepath });
-console.log(filepath);
+  if (args.click) {
+    const selector = args.click.startsWith("[")
+      ? args.click
+      : `[data-testid="${args.click}"]`;
+    await page.click(selector);
+    await page.waitForTimeout(500);
+  }
 
-await browser.close();
+  const filepath = join(outdir, `${args.name}.png`);
+  await page.screenshot({ path: filepath });
+  console.log(filepath);
+} finally {
+  await browser.close();
+}
diff --git a/justfile b/justfile
index d7ff64c35b..babbc9eeeb 100644
--- a/justfile
+++ b/justfile
@@ -202,8 +202,7 @@ desktop-e2e-integration: _ensure-migrations
 desktop-screenshot *ARGS:
     #!/usr/bin/env bash
     set -euo pipefail
-    cd {{desktop_dir}}
-    [[ -d dist ]] || { cd ..; just desktop-build; cd {{desktop_dir}}; }
+    cd ..; just desktop-build; cd {{desktop_dir}}
     if ! curl -sf http://127.0.0.1:4173/ >/dev/null 2>&1; then
         python3 -m http.server 4173 -d dist >/dev/null 2>&1 &
         trap "kill $! 2>/dev/null || true" EXIT
diff --git a/scripts/post-screenshots.sh b/scripts/post-screenshots.sh
index 2d36b34207..f72a39743f 100755
--- a/scripts/post-screenshots.sh
+++ b/scripts/post-screenshots.sh
@@ -10,6 +10,11 @@ PR="$1"
 PNG_DIR="$2"
 BODY_FILE="${3:-}"
 
+if ! [[ "$PR" =~ ^[0-9]+$ ]]; then
+  echo "error: PR number must be a positive integer" >&2
+  exit 1
+fi
+
 BRANCH="agent-screenshots"
 REPO="block/sprout"
 RAW_BASE="https://raw.githubusercontent.com/${REPO}/refs/heads/${BRANCH}"
@@ -22,7 +27,7 @@ fi
 
 EXISTING_ENTRIES=""
 if git fetch origin "refs/heads/${BRANCH}:refs/remotes/origin/${BRANCH}" 2>/dev/null; then
-  EXISTING_ENTRIES=$(git ls-tree "origin/${BRANCH}" | grep -v "	pr-${PR}/" || true)
+  EXISTING_ENTRIES=$(git ls-tree "origin/${BRANCH}" | grep -v $'\t'"pr-${PR}--" || true)
 fi
 
 NEW_ENTRIES=""
@@ -30,8 +35,8 @@ IMAGE_URLS=()
 for PNG in "${PNGS[@]}"; do
   FILENAME=$(basename "$PNG")
   BLOB=$(git hash-object -w "$PNG")
-  TREE_PATH="pr-${PR}/${FILENAME}"
-  NEW_ENTRIES+="100644 blob ${BLOB}	${TREE_PATH}"$'\n'
+  TREE_PATH="pr-${PR}--${FILENAME}"
+  NEW_ENTRIES+=$(printf '100644 blob %s\t%s\n' "$BLOB" "$TREE_PATH")
   IMAGE_URLS+=("${RAW_BASE}/${TREE_PATH}")
 done
 
@@ -39,23 +44,19 @@ COMBINED=$(printf '%s\n' "$EXISTING_ENTRIES" "$NEW_ENTRIES" | grep -v '^$')
 TREE=$(echo "$COMBINED" | git mktree)
 
 COMMIT=$(git commit-tree "$TREE" -m "screenshots: PR #${PR}")
-git push --force origin "${COMMIT}:refs/heads/${BRANCH}"
+git push --force-with-lease origin "${COMMIT}:refs/heads/${BRANCH}"
+
+IMAGES_SECTION=""
+for URL in "${IMAGE_URLS[@]}"; do
+  FILENAME=$(basename "$URL")
+  NAME="${FILENAME%.png}"
+  IMAGES_SECTION+="![${NAME}](${URL})"$'\n\n'
+done
 
 if [[ -n "$BODY_FILE" ]]; then
-  COMMENT_BODY=$(cat "$BODY_FILE")
-  COMMENT_BODY+=$'\n\n'
-  for URL in "${IMAGE_URLS[@]}"; do
-    FILENAME=$(basename "$URL")
-    NAME="${FILENAME%.png}"
-    COMMENT_BODY+="![${NAME}](${URL})"$'\n\n'
-  done
+  COMMENT_BODY="$(cat "$BODY_FILE")"$'\n\n'"${IMAGES_SECTION}"
 else
-  COMMENT_BODY="## Screenshots"$'\n\n'
-  for URL in "${IMAGE_URLS[@]}"; do
-    FILENAME=$(basename "$URL")
-    NAME="${FILENAME%.png}"
-    COMMENT_BODY+="![${NAME}](${URL})"$'\n\n'
-  done
+  COMMENT_BODY="## Screenshots"$'\n\n'"${IMAGES_SECTION}"
 fi
 
 gh pr comment "$PR" --repo "$REPO" --body "$COMMENT_BODY"

From bec3022e318ea4de8f666c9bd6e4770e7719b2b4 Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
Date: Wed, 3 Jun 2026 14:05:19 -0400
Subject: [PATCH 3/5] fix: resolve bugs found during E2E testing of screenshot
 workflow

The justfile `desktop-screenshot` recipe used `cd ..; just desktop-build`
which navigated above the worktree root, breaking the subsequent
`cd desktop`. The post-screenshots.sh script had two issues: command
substitution stripped trailing newlines from printf, concatenating tree
entries into a single malformed filename; and the grep filter didn't
account for git quoting filenames that contain special characters.
---
 justfile                    | 3 ++-
 scripts/post-screenshots.sh | 4 ++--
 2 files changed, 4 insertions(+), 3 deletions(-)

diff --git a/justfile b/justfile
index babbc9eeeb..8424efbcbc 100644
--- a/justfile
+++ b/justfile
@@ -202,7 +202,8 @@ desktop-e2e-integration: _ensure-migrations
 desktop-screenshot *ARGS:
     #!/usr/bin/env bash
     set -euo pipefail
-    cd ..; just desktop-build; cd {{desktop_dir}}
+    just desktop-build
+    cd {{desktop_dir}}
     if ! curl -sf http://127.0.0.1:4173/ >/dev/null 2>&1; then
         python3 -m http.server 4173 -d dist >/dev/null 2>&1 &
         trap "kill $! 2>/dev/null || true" EXIT
diff --git a/scripts/post-screenshots.sh b/scripts/post-screenshots.sh
index f72a39743f..903b2fe45c 100755
--- a/scripts/post-screenshots.sh
+++ b/scripts/post-screenshots.sh
@@ -27,7 +27,7 @@ fi
 
 EXISTING_ENTRIES=""
 if git fetch origin "refs/heads/${BRANCH}:refs/remotes/origin/${BRANCH}" 2>/dev/null; then
-  EXISTING_ENTRIES=$(git ls-tree "origin/${BRANCH}" | grep -v $'\t'"pr-${PR}--" || true)
+  EXISTING_ENTRIES=$(git ls-tree "origin/${BRANCH}" | grep -v $'\t'"\"\\{0,1\\}pr-${PR}--" || true)
 fi
 
 NEW_ENTRIES=""
@@ -36,7 +36,7 @@ for PNG in "${PNGS[@]}"; do
   FILENAME=$(basename "$PNG")
   BLOB=$(git hash-object -w "$PNG")
   TREE_PATH="pr-${PR}--${FILENAME}"
-  NEW_ENTRIES+=$(printf '100644 blob %s\t%s\n' "$BLOB" "$TREE_PATH")
+  NEW_ENTRIES+="$(printf '100644 blob %s\t%s' "$BLOB" "$TREE_PATH")"$'\n'
   IMAGE_URLS+=("${RAW_BASE}/${TREE_PATH}")
 done
 

From 144de545940e42d369bd9129064e6baa4d46618d Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
Date: Wed, 3 Jun 2026 15:11:37 -0400
Subject: [PATCH 4/5] refactor: namespace screenshot branches per developer

Derive GitHub username via `gh api user` and push to
`agent-screenshots/<username>` instead of a shared branch. Eliminates
cross-developer contention on `--force-with-lease` pushes.
---
 AGENTS.md                   | 12 ++++++------
 scripts/post-screenshots.sh |  3 ++-
 2 files changed, 8 insertions(+), 7 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index 7d43f8ba27..13b6eb6232 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -268,8 +268,8 @@ captures. Channel names must match `[a-z0-9-]+`. Available mock channels:
 
 #### Posting screenshots to a PR
 
-`scripts/post-screenshots.sh` hosts PNGs on a shared `agent-screenshots` orphan
-branch and posts them as a PR comment:
+`scripts/post-screenshots.sh` hosts PNGs on a per-developer orphan branch
+(`agent-screenshots/<github-username>`) and posts them as a PR comment:
 
 ```bash
 # Take screenshots, then post them
@@ -281,10 +281,10 @@ just desktop-screenshot --name feature-demo --messages /tmp/msgs.json --outdir t
 ```
 
 The orphan branch accumulates images across PRs, namespaced as `pr-<N>--`. Re-runs
-for the same PR overwrite previous images. Concurrent runs for different PRs may
-conflict (the push uses `--force-with-lease` and will fail if the branch was
-updated since fetch; retry if needed). Delete the branch when no longer needed:
-`git push origin --delete agent-screenshots`.
+for the same PR overwrite previous images. Concurrent runs by the same developer's
+agents may conflict (the push uses `--force-with-lease`; retry if needed). Delete
+the branch when no longer needed:
+`git push origin --delete agent-screenshots/<username>`.
 
 The Playwright MCP browser (`@playwright/mcp`) is also configured but cannot
 drive the desktop app directly because it evaluates JS after page load — too
diff --git a/scripts/post-screenshots.sh b/scripts/post-screenshots.sh
index 903b2fe45c..0b354c0ed6 100755
--- a/scripts/post-screenshots.sh
+++ b/scripts/post-screenshots.sh
@@ -15,7 +15,8 @@ if ! [[ "$PR" =~ ^[0-9]+$ ]]; then
   exit 1
 fi
 
-BRANCH="agent-screenshots"
+GH_USER=$(gh api user --jq .login)
+BRANCH="agent-screenshots/${GH_USER}"
 REPO="block/sprout"
 RAW_BASE="https://raw.githubusercontent.com/${REPO}/refs/heads/${BRANCH}"
 

From a2658650b070f5e4b497f3c3cf1fc5427f2296e7 Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
Date: Wed, 3 Jun 2026 15:24:15 -0400
Subject: [PATCH 5/5] docs: condense AGENTS.md screenshot section

Trim from 67 to 46 lines by removing subheaders, heredoc scaffolding,
the Playwright MCP browser note, and verbose explanations. All flags,
JSON schema, mock channel list, and post-screenshots usage preserved.
---
 AGENTS.md | 55 +++++++++++++++++--------------------------------------
 1 file changed, 17 insertions(+), 38 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index 13b6eb6232..f263d6a57f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -224,10 +224,9 @@ See [TESTING.md](TESTING.md) for the full multi-agent E2E guide.
 
 ### Desktop Screenshots (Playwright)
 
-The desktop app is a Tauri app that cannot render in a plain browser without the
-E2E mock bridge. A standalone screenshot helper at
-`desktop/tests/helpers/screenshot.mjs` wraps the same mock bridge setup the E2E
-tests use (`addInitScript` + `window.__SPROUT_E2E__`) into a CLI tool.
+The desktop app requires the E2E mock bridge to render — it cannot run in a plain
+browser. Use `just desktop-screenshot` to capture screenshots (builds frontend,
+starts preview server, runs Playwright automatically):
 
 ```bash
 just desktop-screenshot --name home
@@ -239,57 +238,37 @@ just desktop-screenshot --name settings --click open-settings
 Options: `--name` (filename), `--route` (client route), `--click` (data-testid
 or CSS selector), `--wait` (ms, default 2000), `--viewport` (WxH, default
 1280x720), `--outdir` (default `test-results/screenshots`),
-`--messages` (JSON file path). Screenshots are saved as PNGs and the path is
-printed to stdout. The `just desktop-screenshot` target handles building the
-frontend and starting the preview server automatically.
+`--messages` (JSON file path). Output is a PNG path on stdout.
 
-#### Injecting messages
-
-Use `--messages` to inject content into the channel timeline before screenshotting.
-The JSON file contains an array of messages to inject:
+Use `--messages` to inject content into a channel before capture. The JSON file
+is an array of `{ channelName, content, pubkey?, kind? }` objects — all must
+target the same channel (`[a-z0-9-]+`). When provided, `--route` is ignored.
 
 ```bash
-cat > /tmp/msgs.json << 'EOF'
+just desktop-screenshot --name code-blocks --messages /tmp/msgs.json
+```
+
+```json
 [
-  { "channelName": "general", "content": "```typescript\nconst x: number = 42;\n```" },
+  { "channelName": "general", "content": "```typescript\nconst x = 42;\n```" },
   { "channelName": "general", "content": "plain text message" }
 ]
-EOF
-just desktop-screenshot --name code-blocks --messages /tmp/msgs.json
 ```
 
-Each message requires `channelName` and `content`; optional fields are `pubkey`
-and `kind`. All messages must target the same channel. When `--messages` is
-provided, the script navigates to the channel from the first message (ignoring
-`--route`), waits for the live subscription, injects all messages, then
-captures. Channel names must match `[a-z0-9-]+`. Available mock channels:
-`general`, `random`, `design`, `sales`, `engineering`, `agents`,
-`watercooler`, `announcements`, `alice-tyler`, `bob-tyler`.
-
-#### Posting screenshots to a PR
+Available mock channels: `general`, `random`, `design`, `sales`, `engineering`,
+`agents`, `watercooler`, `announcements`, `alice-tyler`, `bob-tyler`.
 
 `scripts/post-screenshots.sh` hosts PNGs on a per-developer orphan branch
-(`agent-screenshots/<github-username>`) and posts them as a PR comment:
+(`agent-screenshots/<github-username>`) and posts a PR comment:
 
 ```bash
-# Take screenshots, then post them
-just desktop-screenshot --name feature-demo --messages /tmp/msgs.json --outdir test-results/screenshots
 ./scripts/post-screenshots.sh 803 test-results/screenshots
-
-# Or provide a custom comment body (images are appended)
-./scripts/post-screenshots.sh 803 test-results/screenshots body.md
+./scripts/post-screenshots.sh 803 test-results/screenshots body.md  # custom body prepended
 ```
 
-The orphan branch accumulates images across PRs, namespaced as `pr-<N>--`. Re-runs
-for the same PR overwrite previous images. Concurrent runs by the same developer's
-agents may conflict (the push uses `--force-with-lease`; retry if needed). Delete
-the branch when no longer needed:
+Re-runs for the same PR overwrite previous images. Cleanup:
 `git push origin --delete agent-screenshots/<username>`.
 
-The Playwright MCP browser (`@playwright/mcp`) is also configured but cannot
-drive the desktop app directly because it evaluates JS after page load — too
-late for the mock bridge. Use the MCP browser for non-Tauri pages only.
-
 ---
 
 ## Common Gotchas
