SLOPSHOPPER

uploads.sh

Host screenshots, GIFs, recordings, and files on uploads.sh and embed them in GitHub PRs and issues.

newpanebandguardcommandtoast
★ 18v0.4.0Apache-2.0updated 2026-10-09buildinternet/uploads/plugins/claude/uploads
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · uploads
│ ┃ Staged attachments ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing staged for this branch. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /uploads-staged │ ⎿ uploads: Opened the staged attachments pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Staged attachments
Nothing staged for this branch.
README

uploads.sh plugin

Get screenshots, GIFs, screen recordings, and other files into GitHub pull requests and issues from inside your coding agent. The plugin hosts each file on uploads.sh and gives your agent a stable public URL to embed, so a PR can show a before/after or a recording of the bug instead of describing it in prose.

The plugin is one folder (plugins/claude/uploads) in the common agent-plugin layout. .claude-plugin/plugin.json is the manifest, .mcp.json declares the hosted MCP server, skills/ holds the three skills, and hooks/ holds the pre-PR reminder. The same folder serves every listing below.

What it bundles

ComponentInvocationPurpose
github-screenshots skill/uploads:github-screenshotsCapture, host, and embed a visual in a PR or issue
annotate-screenshots skill/uploads:annotate-screenshotsAdd boxes, arrows, labels, and redactions to a capture
uploads-cli skill/uploads:uploads-cliFull reference for the optional uploads CLI
uploads MCP server(tools)Hosted server at https://agents.uploads.sh/mcp
PR screenshot reminder hook(automatic)Advisory nudge before gh pr create on a UI branch with no screenshots
Staged media mod/uploads-stagedClaude Code: staged attachments above the prompt, attached on PR open

The slash names in the table are how Claude Code invokes the skills. Other agents discover the same skills/ directory by layout.

The staged media mod (hooks/register.tsx) is Claude Code only. It needs Claude Code 2.1.287 or later. Older versions load the rest of the plugin and skip the mod. It draws in the terminal and the Desktop app's Code tab. Elsewhere, such as the VS Code extension's chat panel or claude -p, it still attaches files and tells Claude, but shows nothing.

On the Desktop app, the band shows thumbnails of the staged attachments. View or /uploads-staged opens a pane where you can see each one, copy its markdown, remove it, or copy the PR's feed link. The footnote links to your workspace on uploads.sh. Hide hides the band until the staged set changes.

Install

Pick your agent. The same folder serves both listings.

Claude Code

/plugin marketplace add buildinternet/uploads
/plugin install uploads@uploads

uploads is also listed in the Claude directory, where you can add it on claude.ai and in Cowork.

Grok Build

This plugin is submitted to the xAI plugin marketplace as uploads. Once listed, run /plugin in Grok Build, search for uploads.sh, and install it. The marketplace pins a commit of this repo, so updates arrive when the catalog entry is bumped.

Sign in

On first use, the MCP server opens the uploads.sh sign-in and consent screen in your browser. The token it issues can read and write files in the workspace you pick. See <https://uploads.sh/auth.md>.

Hooks

The pre-PR reminder is a PreToolUse hook with matcher Bash. A matcher can only key the tool name, so the hook starts before Bash commands. The script acts only on gh pr create. That filter lives in the uploads CLI, not in the matcher.

The hook command names the script directly:

"${CLAUDE_PLUGIN_ROOT}"/hooks/pre-pr-screenshot.sh

That path is the whole program. It uses ${CLAUDE_PLUGIN_ROOT} and no other variable. Claude Code sets CLAUDE_PLUGIN_ROOT to the installed plugin directory. Grok Build sets GROK_PLUGIN_ROOT to that same directory and also sets CLAUDE_PLUGIN_ROOT. Codex sets PLUGIN_ROOT and also sets CLAUDE_PLUGIN_ROOT. Codex reads the command string and ignores an args array, so the path stays in command.

The script runs the locally installed uploads CLI (uploads hook pre-pr-screenshot). If the CLI is not installed or not signed in, the hook does nothing. Set UPLOADS_HOOK_DISABLE=1 to turn it off.

hooks/register.tsx is a separate Claude Code mod. It draws staged attachments above the prompt. It is not part of the portable hooks contract. It needs Claude Code 2.1.287 or later.

License

Apache-2.0, the same license as this repository (license in .claude-plugin/plugin.json).

Data and network access

The plugin reaches these uploads.sh services:

  • agents.uploads.sh: the hosted MCP server behind the upload, list, metadata, and comment tools. It receives the files you ask your agent to upload, their file names, and metadata such as the repository, branch, and PR number. Uploaded files are served from a public URL on uploads.sh, so anyone with the link can view them.
  • api.uploads.sh: the REST API used by the optional uploads CLI. The skills only send requests here if you install the CLI and your agent runs it.
  • GitHub: when you attach files to a PR or issue, one comment that embeds them is posted or updated. The uploads.sh GitHub App posts it in repositories where you install the app; otherwise the CLI posts it through your local gh login.

When the reminder hook runs (see Hooks), it reads the branch name and changed files from local git and asks the uploads.sh API whether any files are staged for that branch. It uses the CLI's own sign-in. It prints a reminder. It sends nothing if the CLI is missing or not signed in.

The staged media mod also runs the local uploads CLI, plus gh pr view, with their own sign-ins. It reads what is staged for the current branch (with uploads staged) when a session starts and after shell commands that run uploads, switch branches, or open a PR. After gh pr create opens a PR, it looks up the PR's head branch:

  • If the repository is linked to your workspace, the GitHub App attaches the staged files, and the mod only reports it.
  • Otherwise, the mod runs uploads attach --promote for that PR. Turn off the Attach staged files on gh pr create option in /plugin to have it only report what is waiting.

On the Desktop app, the pane also runs uploads delete when you remove an attachment, uploads feed create when you copy the feed link, and gh pr view to find the branch's open PR. Thumbnails are fetched with curl from storage.uploads.sh, resized by Cloudflare's image transform, and kept in the mod's session state.

Every program the mod starts goes through one helper, run in hooks/register.tsx. That helper calls $.process.run with an argument list, so a scan of that line cannot see the program. The helper runs only the commands in this table, each written here as fixed text.

ProgramWhy it runsWhat it sends, and where
uploads staged --format jsonSession start, after a matching shell command, and when the session is idleBranch and repository name to the uploads.sh API at api.uploads.sh, using the CLI's saved sign-in
uploads staged --format json --branch <branch> --repo <owner/repo>After gh pr create, to read files staged on the PR's head branchThat branch and repository to the uploads.sh API
uploads attach --promote --pr <n> --repo <owner/repo> --from-branch <branch> --jsonAfter gh pr create, when this workspace should attach the filesPR number, repository, and branch to the uploads.sh API, which posts the PR comment
uploads feed create --repo <owner/repo> --pr <n>You press Copy link in the paneRepository and PR number to the uploads.sh API. The CLI prints the feed URL back to the mod
uploads delete <key>You press Remove in the paneThe file's key to the uploads.sh API
gh pr view --json number,url,stateWith the staged read, to find the open PR for the current branchThe current branch, through your local gh login, to the GitHub API
gh pr view <n> --repo <owner/repo> --json headRefName -q .headRefNameAfter gh pr create, to learn the PR's head branchThe PR number and repository to the GitHub API
`sh -c 'curl -sfL --max-time 15 "$1" \base64' sh <url>`Drawing a thumbnail or a large preview on the Desktop appAn HTTPS GET to storage.uploads.sh. For an image on that host, <url> is Cloudflare's image transform (/cdn-cgi/image/...). The JPEG stays in the mod's session

<branch>, <owner/repo>, <n>, <key>, and <url> are the only parts that change. The words of each command stay as written.

Local data the mod reads is the staged-file list from uploads staged, the open PR from gh pr view, and those thumbnail bytes. It keeps them in the mod's session state.

What the mod reads from the conversation: the text of shell commands Claude runs, so it can spot uploads, a branch switch, or gh pr create, and the output of gh pr create, so it can read the new PR's URL. That text stays on the machine. The values that leave are the ones in the table, and each one leaves through the program in its row.

If the CLI is not installed or not signed in, the mod does nothing.

See the privacy policy and terms.

Updating

Third-party marketplaces don't update automatically by default. To pick up a new version in Claude Code:

/plugin marketplace update uploads
/plugin update uploads@uploads

Grok Build updates when the xAI catalog entry's pinned commit is bumped.

Source 2 files
hooks/register.tsx 1082 lines
1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { Attached, BandRow, Binding, FileRef, Staged } from "../types";
5
6const staged = atom({ plugin: "uploads", key: "staged" } as const, null);
7const attached = atom({ plugin: "uploads", key: "attached" } as const, null);
8const isExpanded = atom({ plugin: "uploads", key: "isExpanded" } as const, false);
9// Desktop: JPEG data URIs of the staged images (storage URL -> URI; "" = failed).
10const thumbs = atom({ plugin: "uploads", key: "thumbs" } as const, {});
11const large = atom({ plugin: "uploads", key: "large" } as const, {});
12// The row the pane's detail view shows; null shows the grid.
13const preview = atom({ plugin: "uploads", key: "preview" } as const, null);
14// The open PR for the session branch, for the pane's feed link.
15const openPr = atom({ plugin: "uploads", key: "openPr" } as const, null);
16const feedUrl = atom({ plugin: "uploads", key: "feedUrl" } as const, null);
17// The staged set the desktop band is hidden for; a change shows it again.
18const dismissed = atom({ plugin: "uploads", key: "dismissed" } as const, null);
19
20const PANE = "uploads-staged";
21const PANE_TITLE = "Staged attachments";
22
23// Bash commands that can change what is staged for the branch, or which branch it is.
24const REFRESH_AFTER =
25  /\buploads\s+(attach|put|screenshot|shot|delete)\b|\bgit\s+(checkout|switch|branch\s+-m)\b|\bgh\s+pr\s+create\b/;
26const PR_CREATE = /\bgh\s+pr\s+create\b/;
27const PR_URL = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/g;
28const IDLE_REFRESH_MS = 2 * 60 * 1000;
29// Long enough for the GitHub App's webhook to promote the files after a PR opens.
30const AFTER_PR_REFRESH_MS = 15_000;
31
32const BINDINGS: readonly Binding[] = ["self", "none", "other", "unknown"];
33
34// Before/after pairing, mirroring the attachments comment (comment-render's
35// pairAttachments): images only; same `path` metadata with one `state=before`
36// and one `state=after`, else filename stems that differ only by the token.
37// The band shows each pair as one row, so a missing half shows as a gap.
38const IMAGE_EXT = /\.(png|jpe?g|gif|webp|avif)$/i;
39const STEM_TOKEN = /(^|[-_.])(before|after)($|[-_.])/i;
40
41type RawFile = {
42  key?: unknown;
43  filename: string;
44  state?: unknown;
45  path?: unknown;
46  url?: unknown;
47  size?: unknown;
48  stagedAt?: unknown;
49};
50type Role = "before" | "after";
51
52/** The uploads.sh file page (preview and details) for a storage URL; else the URL itself. */
53const filePage = (url: string): string => {
54  const match = /^https:\/\/storage\.uploads\.sh\/(.+)$/.exec(url);
55  return match ? `https://uploads.sh/f/${match[1]}` : url;
56};
57
58const fileRef = (file: RawFile): FileRef => ({
59  key: typeof file.key === "string" ? file.key : file.filename,
60  name: file.filename,
61  url: typeof file.url === "string" ? filePage(file.url) : null,
62  src: typeof file.url === "string" ? file.url : null,
63  size: typeof file.size === "number" ? file.size : null,
64  stagedAt: typeof file.stagedAt === "string" ? file.stagedAt : null,
65});
66
67/** A file's before/after role and the key its other half shares, or null for neither. */
68const roleOf = (file: RawFile): { role: Role; key: string; label: string } | null => {
69  if (!IMAGE_EXT.test(file.filename)) {
70    return null;
71  }
72  const dot = file.filename.lastIndexOf(".");
73  const stem = file.filename.slice(0, dot);
74  const meta = typeof file.state === "string" ? file.state.trim().toLowerCase() : "";
75  const path = typeof file.path === "string" ? file.path.trim() : "";
76  const token = STEM_TOKEN.exec(stem);
77  const role: Role | null =
78    meta === "before" || meta === "after" ? meta : token ? (token[2]!.toLowerCase() as Role) : null;
79  if (role === null) {
80    return null;
81  }
82  // Only a file with nothing to pair on stands alone under its own name.
83  const solo = { role, key: `solo:${file.filename}`, label: stem };
84  if (path && path !== "/") {
85    return meta === role ? { role, key: `path:${path}`, label: path } : solo;
86  }
87  if (!token) {
88    return solo;
89  }
90  const end = token.index + token[1]!.length + token[2]!.length;
91  const base =
92    token[1]!.length > 0
93      ? stem.slice(0, token.index) + stem.slice(end)
94      : stem.slice(end + token[3]!.length);
95  return {
96    role,
97    key: `stem:${base.toLowerCase()}${file.filename.slice(dot).toLowerCase()}`,
98    label: base,
99  };
100};
101
102/** The band's rows in staged order: a pair (either half may be missing), or a plain file. */
103const toRows = (files: readonly RawFile[]): BandRow[] => {
104  const order: (string | RawFile)[] = [];
105  const groups = new Map<string, { label: string; before: RawFile[]; after: RawFile[] }>();
106  for (const file of files) {
107    const found = roleOf(file);
108    if (found === null) {
109      order.push(file);
110      continue;
111    }
112    let group = groups.get(found.key);
113    if (!group) {
114      group = { label: found.label, before: [], after: [] };
115      groups.set(found.key, group);
116      order.push(found.key);
117    }
118    group[found.role].push(file);
119  }
120  return order.flatMap((entry): BandRow[] => {
121    if (typeof entry !== "string") {
122      return [{ kind: "file", file: fileRef(entry) }];
123    }
124    const { label, before, after } = groups.get(entry)!;
125    // More than one of a side is ambiguous, as in the comment: no pairing, one row each.
126    if (before.length > 1 || after.length > 1) {
127      return [
128        ...before.map((f): BandRow => ({ kind: "pair", label, before: fileRef(f), after: null })),
129        ...after.map((f): BandRow => ({ kind: "pair", label, before: null, after: fileRef(f) })),
130      ];
131    }
132    return [
133      {
134        kind: "pair",
135        label,
136        before: before[0] ? fileRef(before[0]) : null,
137        after: after[0] ? fileRef(after[0]) : null,
138      },
139    ];
140  });
141};
142
143/** How many staged files a row stands for. */
144const filesIn = (row: BandRow): number =>
145  row.kind === "file" ? 1 : (row.before ? 1 : 0) + (row.after ? 1 : 0);
146
147// The terminal's expanded band lists at most this many rows.
148const MAX_ROWS = 8;
149
150const formatSize = (bytes: number | null): string =>
151  bytes === null ? "" : bytes < 1024 ? `${bytes} B` : `${Math.round(bytes / 1024)} KB`;
152
153const parseStaged = (stdout: string): Staged | null => {
154  try {
155    const doc = JSON.parse(stdout);
156    if (
157      typeof doc?.repo !== "string" ||
158      typeof doc?.branch !== "string" ||
159      !Array.isArray(doc?.files)
160    ) {
161      return null;
162    }
163    const state = doc.binding?.state;
164    const files: RawFile[] = doc.files.filter(
165      (f: { filename?: unknown }) => typeof f?.filename === "string",
166    );
167    return {
168      repo: doc.repo,
169      branch: doc.branch,
170      count: doc.files.length,
171      rows: toRows(files),
172      binding: BINDINGS.includes(state) ? state : "unknown",
173      autoAttach: doc.binding?.autoAttach === true,
174    };
175  } catch {
176    return null;
177  }
178};
179
180/**
181 * What `uploads attach --promote --json` promoted and skipped, or null when it
182 * printed nothing usable (the server call failed, or the output isn't JSON).
183 */
184const parsePromotion = (stdout: string | null): { promoted: number; skipped: number } | null => {
185  try {
186    const promotion = JSON.parse(stdout ?? "")?.promotion;
187    if (!Array.isArray(promotion?.promoted) || !Array.isArray(promotion?.skipped)) {
188      return null;
189    }
190    return { promoted: promotion.promoted.length, skipped: promotion.skipped.length };
191  } catch {
192    return null;
193  }
194};
195
196/** The last PR URL in `gh pr create` output, which prints it on success. */
197const parsePrUrl = (text: string | undefined): { repo: string; pr: number } | null => {
198  const matches = [...(text ?? "").matchAll(PR_URL)];
199  const [, repo, pr] = matches.at(-1) ?? [];
200  return repo && pr ? { repo, pr: Number(pr) } : null;
201};
202
203// The uploads.sh accent (#c27eff) mixed half and half with the site's muted
204// text (#b3b3ad): a faded purple that marks the row without shouting.
205const BRAND = "#bb98d6";
206// An unpaired before or after: a missing half the reader should notice.
207const WARN = "#e5b567";
208
209const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
210const toBase64 = (bytes: Uint8Array): string => {
211  let out = "";
212  for (let i = 0; i < bytes.length; i += 3) {
213    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
214    out += BASE64[(n >> 18) & 63]! + BASE64[(n >> 12) & 63]!;
215    out += i + 1 < bytes.length ? BASE64[(n >> 6) & 63]! : "=";
216    out += i + 2 < bytes.length ? BASE64[n & 63]! : "=";
217  }
218  return out;
219};
220
221// The favicon's mark: three stacked pixel chevrons fading downward, drawn as
222// 16×16 RGBA from favicon.svg's 32-unit grid (one pixel per two units).
223const MARK = (() => {
224  const size = 16;
225  const pixels = new Uint8Array(size * size * 4);
226  const blocks = [
227    [7, 2],
228    [5, 3],
229    [9, 3],
230    [3, 4],
231    [11, 4],
232  ] as const;
233  const rows = [
234    [0, 255],
235    [4, 140],
236    [8, 71],
237  ] as const;
238  for (const [dy, alpha] of rows) {
239    for (const [x, y] of blocks) {
240      for (const [i, j] of [
241        [0, 0],
242        [1, 0],
243        [0, 1],
244        [1, 1],
245      ] as const) {
246        pixels.set([0xbb, 0x98, 0xd6, alpha], ((y + dy + j) * size + x + i) * 4);
247      }
248    }
249  }
250  return { rgba: toBase64(pixels), width: size, height: size };
251})();
252
253const attachments = (n: number) => (n === 1 ? "1 attachment" : `${n} attachments`);
254
255type Line = { text: string; warn: boolean };
256
257/** What the staged attachments are waiting for, by the repo's link to a workspace. */
258const headline = (view: Staged): Line => {
259  const n = attachments(view.count);
260  if (view.binding === "other") {
261    return { text: `${n} won't attach: repo linked to another workspace`, warn: true };
262  }
263  if (view.binding === "none") {
264    return { text: `${n} waiting for a PR · link the repo to auto-attach`, warn: false };
265  }
266  return { text: `${n} waiting for a PR`, warn: false };
267};
268
269/** The band's one line for the session branch, or null to show nothing. */
270const bandLine = (view: Staged, done: Attached | null): Line | null => {
271  if (view.count > 0) {
272    return headline(view);
273  }
274  if (done?.branch !== view.branch || done.repo !== view.repo) {
275    return null;
276  }
277  const n = attachments(done.count);
278  return {
279    text:
280      done.via === "app"
281        ? `${n} sent to PR #${done.pr} via the uploads-sh bot`
282        : `${n} added to PR #${done.pr}`,
283    warn: false,
284  };
285};
286
287// ---- desktop thumbnails and the pane ------------------------------------------
288
289const STORAGE = "https://storage.uploads.sh/";
290
291/** The workspace page for the staged files, read off their storage URLs; null off uploads.sh storage. */
292const workspacePage = (rows: readonly BandRow[]): string | null => {
293  for (const row of rows) {
294    for (const f of filesOf(row)) {
295      const ws = f.src?.startsWith(STORAGE) ? f.src.slice(STORAGE.length).split("/")[0] : "";
296      if (ws) return `https://uploads.sh/account/workspaces/${encodeURIComponent(ws)}`;
297    }
298  }
299  return null;
300};
301
302/** The files in a row, before first. */
303const filesOf = (row: BandRow): FileRef[] =>
304  row.kind === "file" ? [row.file] : [row.before, row.after].filter((f): f is FileRef => !!f);
305
306/** Identifies a staged set: the desktop band hides for it and returns when it changes. */
307const setKey = (view: Staged): string =>
308  `${view.repo}#${view.branch}#${(Array.isArray(view.rows) ? view.rows : [])
309    .flatMap((row) => filesOf(row).map((f) => f.key))
310    .join(",")}`;
311
312// A resized JPEG of a storage image, through Cloudflare's image transform.
313const transformed = (src: string, width: number, quality: number): string =>
314  src.startsWith(STORAGE) && !src.toLowerCase().endsWith(".svg")
315    ? `${STORAGE}cdn-cgi/image/width=${width},fit=scale-down,format=jpeg,quality=${quality}/${src.slice(STORAGE.length)}`
316    : src;
317
318const embedUrl = (src: string): string =>
319  src.startsWith(STORAGE) ? `https://embed.uploads.sh/${src.slice(STORAGE.length)}` : src;
320
321// A JPEG of `src` as a data URI: the desktop draws Svg as an isolated image, so
322// it can't load a remote URL. Null when the fetch fails or the result is too big.
323async function fetchUri(
324  $: EngineInterface,
325  src: string,
326  width: number,
327  quality: number,
328): Promise<string | null> {
329  const b64 = await run(
330    $,
331    ["sh", "-c", 'curl -sfL --max-time 15 "$1" | base64', "sh", transformed(src, width, quality)],
332    20_000,
333  );
334  const body = b64?.replace(/\s+/g, "") ?? "";
335  return body && body.length <= 120_000 ? `data:image/jpeg;base64,${body}` : null;
336}
337
338const imageFiles = (rows: readonly BandRow[]): FileRef[] =>
339  rows.flatMap(filesOf).filter((f) => !!f.src && IMAGE_EXT.test(f.name));
340
341// Fetches thumbnails not yet cached; a failure is remembered as "" (drawn as a file card).
342async function loadThumbs($: EngineInterface, rows: readonly BandRow[]): Promise<void> {
343  for (const file of imageFiles(rows)) {
344    if (file.src! in (await read($, thumbs))) {
345      continue;
346    }
347    const uri = await fetchUri($, file.src!, 360, 70);
348    await update($, thumbs, (t) => ({ ...t, [file.src!]: uri ?? "" }));
349  }
350}
351
352async function loadLarge($: EngineInterface, row: BandRow): Promise<void> {
353  for (const file of imageFiles([row])) {
354    if (file.src! in (await read($, large))) {
355      continue;
356    }
357    const uri = (await fetchUri($, file.src!, 1100, 72)) ?? (await fetchUri($, file.src!, 760, 60));
358    if (uri) {
359      await update($, large, (t) => ({ ...t, [file.src!]: uri }));
360    }
361  }
362}
363
364const esc = (s: string): string =>
365  s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
366
367// The mark as vector art, for the desktop band: the same chevrons as MARK.
368const MARK_SVG = (() => {
369  const blocks = [
370    [7, 2],
371    [5, 3],
372    [9, 3],
373    [3, 4],
374    [11, 4],
375  ] as const;
376  const rows = [
377    [0, 1],
378    [4, 0.55],
379    [8, 0.28],
380  ] as const;
381  const rects = rows
382    .flatMap(([dy, a]) =>
383      blocks.map(
384        ([x, y]) =>
385          `<rect x="${x}" y="${y + dy}" width="2" height="2" fill="${BRAND}" fill-opacity="${a}"/>`,
386      ),
387    )
388    .join("");
389  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">${rects}</svg>`;
390})();
391
392// `uri`: a data URI to draw; null while loading; "" when there is no inline
393// preview (not an image, or the thumbnail failed), drawn as a file card.
394type Slot = { uri: string | null; chip: string | null; ext: string };
395
396const extOf = (name: string): string => {
397  const dot = name.lastIndexOf(".");
398  return dot > 0
399    ? name
400        .slice(dot + 1)
401        .toUpperCase()
402        .slice(0, 5)
403    : "FILE";
404};
405
406// A file card: a page glyph with a folded corner and the extension under it.
407const fileCard = (x: number, w: number, height: number, ext: string, path: string): string => {
408  const g = Math.min(height * 0.34, w * 0.3);
409  const gx = x + w / 2 - g * 0.4;
410  const gy = height / 2 - g * 0.75;
411  const f = g * 0.28;
412  const small = height < 40;
413  const glyph = small
414    ? ""
415    : `<path d="M${gx},${gy} h${g * 0.8 - f} l${f},${f} v${g - f} h${-g * 0.8} Z M${gx + g * 0.8 - f},${gy} v${f} h${f}" fill="none" stroke="#8889" stroke-width="1.5" stroke-linejoin="round"/>`;
416  const label = `<text x="${x + w / 2}" y="${small ? height / 2 + 3.5 : gy + g + 18}" text-anchor="middle" font-size="${small ? 9 : 12}" font-weight="600" letter-spacing="0.5" font-family="system-ui,-apple-system,sans-serif" fill="#888">${esc(ext)}</text>`;
417  return `<path d="${path}" fill="#8881"/>${glyph}${label}`;
418};
419
420// Images side by side with a hairline gap, each clipped to a rounded frame, so a
421// pair reads as one card. A chip names each half when `chips` is set.
422const tileSvg = (slots: readonly Slot[], w: number, height: number, chips: boolean): string => {
423  const gap = 2;
424  const total = slots.length * w + (slots.length - 1) * gap;
425  const r = height > 40 ? 6 : 3;
426  const body = slots
427    .map((s, i) => {
428      const x = i * (w + gap);
429      const left = i === 0;
430      const right = i === slots.length - 1;
431      // Outer corners rounded, inner edges square.
432      const path =
433        `M${x + (left ? r : 0)},0 H${x + w - (right ? r : 0)} ` +
434        (right
435          ? `A${r},${r} 0 0 1 ${x + w},${r} V${height - r} A${r},${r} 0 0 1 ${x + w - r},${height} `
436          : `V${height} `) +
437        `H${x + (left ? r : 0)} ` +
438        (left ? `A${r},${r} 0 0 1 ${x},${height - r} V${r} A${r},${r} 0 0 1 ${x + r},0 Z` : `V0 Z`);
439      const img =
440        s.uri === ""
441          ? fileCard(x, w, height, s.ext, path)
442          : s.uri
443            ? `<image href="${esc(s.uri)}" x="${x}" y="0" width="${w}" height="${height}" preserveAspectRatio="xMidYMin slice" clip-path="url(#k${i})"/>`
444            : `<path d="${path}" fill="#8882"/>`;
445      const chip =
446        chips && s.chip
447          ? `<rect x="${x + 6}" y="${height - 22}" width="${s.chip.length * 6.4 + 12}" height="16" rx="8" fill="#000b"/>` +
448            `<text x="${x + 12}" y="${height - 10.5}" font-size="10" font-family="system-ui,-apple-system,sans-serif" font-weight="600" fill="#fff">${s.chip}</text>`
449          : "";
450      return `<clipPath id="k${i}"><path d="${path}"/></clipPath>${img}<path d="${path}" fill="none" stroke="#8884"/>${chip}`;
451    })
452    .join("");
453  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${total} ${height}" width="${total}" height="${height}">${body}</svg>`;
454};
455
456const roleName = (row: BandRow, file: FileRef): "Before" | "After" | null =>
457  row.kind === "pair" ? (file === row.before ? "Before" : "After") : null;
458
459const slotsOf = (row: BandRow, t: Record<string, string>): Slot[] =>
460  filesOf(row).map((f) => ({
461    uri: !f.src || !IMAGE_EXT.test(f.name) ? "" : (t[f.src] ?? null),
462    chip: roleName(row, f),
463    ext: extOf(f.name),
464  }));
465
466const labelOf = (row: BandRow): string => (row.kind === "file" ? row.file.name : row.label);
467
468// Shortens the middle of a name to `max` characters, keeping the extension end.
469const fit = (name: string, max: number): string => {
470  if (name.length <= max) {
471    return name;
472  }
473  const tail = Math.min(8, Math.floor((max - 1) / 2));
474  return `${name.slice(0, max - 1 - tail)}…${name.slice(name.length - tail)}`;
475};
476// Characters that fit beside the staged time under a tile of this many images.
477const captionRoom = (row: BandRow): number => (filesOf(row).length > 1 ? 40 : 17);
478
479// When the row's newest file was staged, as "5m ago".
480const agoOf = (row: BandRow, now: number): string => {
481  const times = filesOf(row)
482    .map((f) => (f.stagedAt ? Date.parse(f.stagedAt) : NaN))
483    .filter((n) => !Number.isNaN(n));
484  if (times.length === 0) {
485    return "";
486  }
487  const s = Math.max(0, Math.round((now - Math.max(...times)) / 1000));
488  if (s < 60) {
489    return "just now";
490  }
491  if (s < 3600) {
492    return `${Math.floor(s / 60)}m ago`;
493  }
494  return s < 86400 ? `${Math.floor(s / 3600)}h ago` : `${Math.floor(s / 86400)}d ago`;
495};
496
497const markdownOf = (rows: readonly BandRow[]): string =>
498  rows
499    .flatMap((row) =>
500      filesOf(row).map((f) => {
501        const role = roleName(row, f);
502        const alt = role ? `${labelOf(row)} (${role.toLowerCase()})` : labelOf(row);
503        return f.src ? `![${alt}](${embedUrl(f.src)})` : "";
504      }),
505    )
506    .filter(Boolean)
507    .join("\n");
508
509const BAND_TILES = 4;
510const TILE_W = 168;
511const TILE_H = 105;
512
513async function openPane($: EngineInterface, title = PANE_TITLE): Promise<void> {
514  await $.ui.open({ id: PANE, title });
515}
516
517// Shows the pane's grid, fetching the thumbnails it will draw.
518async function showGrid($: EngineInterface): Promise<void> {
519  await update($, preview, () => null);
520  await openPane($);
521  const view = await read($, staged);
522  if (view && Array.isArray(view.rows)) {
523    void loadThumbs($, view.rows).catch(() => undefined);
524  }
525}
526
527// The pane swaps its grid for one row, large; "All attachments" returns to the grid.
528async function showRow($: EngineInterface, row: BandRow): Promise<void> {
529  await update($, preview, () => row);
530  await openPane($, labelOf(row));
531  void loadLarge($, row).catch(() => undefined);
532}
533
534async function removeRow($: EngineInterface, row: BandRow): Promise<void> {
535  for (const file of filesOf(row)) {
536    await run($, ["uploads", "delete", file.key], 30_000);
537  }
538  await refreshBand($);
539}
540
541async function copyText(
542  $: EngineInterface,
543  surface: Parameters<EngineInterface["ui"]["copy"]>[0]["surface"],
544  text: string,
545  what: string,
546): Promise<void> {
547  await $.ui.copy({ text, surface });
548  $.ui.toast(`Copied ${what}`);
549}
550
551// The PR's live feed link: created on first use through the CLI, then kept.
552async function copyFeedLink(
553  $: EngineInterface,
554  surface: Parameters<EngineInterface["ui"]["copy"]>[0]["surface"],
555  view: Staged,
556  pr: { number: number },
557): Promise<void> {
558  let url = await read($, feedUrl);
559  if (!url) {
560    const out = await run(
561      $,
562      ["uploads", "feed", "create", "--repo", view.repo, "--pr", String(pr.number)],
563      30_000,
564    );
565    url = out?.trim().split("\n")[0] || null;
566    if (!url) {
567      $.ui.toast("Couldn't get the PR feed link");
568      return;
569    }
570    await update($, feedUrl, () => url);
571  }
572  await copyText($, surface, url, "the PR feed link");
573}
574
575let lastRefresh = 0;
576
577// Runs a command; null when it can't start, times out, or exits non-zero.
578async function run(
579  $: EngineInterface,
580  argv: readonly string[],
581  timeoutMs: number,
582): Promise<string | null> {
583  try {
584    const ran = await $.process.run(argv, { timeoutMs });
585    return ran.exitCode === 0 ? ran.stdout : null;
586  } catch {
587    return null;
588  }
589}
590
591// Reads staged files through the CLI; a missing CLI, a directory outside a
592// git repo, or a signed-out CLI all read as null. With no `scope` the CLI
593// resolves the session directory's branch and repo.
594async function fetchStaged(
595  $: EngineInterface,
596  scope: { branch: string; repo: string } | null,
597): Promise<Staged | null> {
598  const argv = ["uploads", "staged", "--format", "json"];
599  if (scope) {
600    argv.push("--branch", scope.branch, "--repo", scope.repo);
601  }
602  const stdout = await run($, argv, 15_000);
603  return stdout === null ? null : parseStaged(stdout);
604}
605
606// The open PR for the session directory's branch, for the pane's feed link.
607async function fetchOpenPr($: EngineInterface): Promise<{ number: number; url: string } | null> {
608  const stdout = await run($, ["gh", "pr", "view", "--json", "number,url,state"], 15_000);
609  try {
610    const doc = JSON.parse(stdout ?? "");
611    return typeof doc?.number === "number" && doc.state === "OPEN"
612      ? { number: doc.number, url: String(doc.url) }
613      : null;
614  } catch {
615    return null;
616  }
617}
618
619// Refreshes what the band shows: the session directory's branch.
620async function refreshBand($: EngineInterface): Promise<void> {
621  const view = await fetchStaged($, null);
622  await update($, staged, () => view);
623  const pr = view && view.count > 0 ? await fetchOpenPr($) : null;
624  if ((await read($, openPr))?.number !== pr?.number) {
625    await update($, feedUrl, () => null);
626  }
627  await update($, openPr, () => pr);
628  if (view) {
629    await loadThumbs($, view.rows.slice(0, BAND_TILES));
630  }
631}
632
633// Schedules a band refresh outside the current dispatch, so no tool result waits on it.
634async function kick($: EngineInterface, ms = 0): Promise<void> {
635  lastRefresh = await $.clock.now();
636  $.clock.after(ms, () => void refreshBand($).catch(() => undefined));
637}
638
639// The PR's head branch. The Bash call may have run in another directory
640// (a `cd`, or a subagent's worktree), so the session's branch can't stand in.
641async function prHead($: EngineInterface, repo: string, pr: number): Promise<string | null> {
642  const argv = [
643    "gh",
644    "pr",
645    "view",
646    String(pr),
647    "--repo",
648    repo,
649    "--json",
650    "headRefName",
651    "-q",
652    ".headRefName",
653  ];
654  const head = (await run($, argv, 15_000))?.trim();
655  return head ? head : null;
656}
657
658type Outcome = {
659  toast: string;
660  context?: string;
661  record?: Omit<Attached, "pr" | "repo" | "branch">;
662};
663
664export const register: Register = (on, options) => {
665  const autoPromote = options.autoPromote !== false;
666
667  on("session.start", async ($, e, next) => {
668    await $.command.register({
669      name: "uploads-staged",
670      description: "Show the attachments staged on uploads.sh for this branch",
671    });
672    await kick($);
673    return next(e);
674  });
675
676  on("command.run", { command: "uploads-staged" }, async ($) => {
677    await refreshBand($);
678    await showGrid($);
679    return { text: "Opened the staged attachments pane." };
680  });
681
682  on("turn.complete", async ($, e, next) => {
683    if ((await $.clock.now()) - lastRefresh > IDLE_REFRESH_MS) {
684      await kick($);
685    }
686    return next(e);
687  });
688
689  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
690    const ran = await next(e);
691    if (!REFRESH_AFTER.test(e.command)) {
692      return ran;
693    }
694    if (!PR_CREATE.test(e.command) || ran.deny !== undefined || ran.isError === true) {
695      await kick($);
696      return ran;
697    }
698
699    const target = parsePrUrl(ran.text);
700    await kick($, AFTER_PR_REFRESH_MS);
701    if (!target) {
702      return ran;
703    }
704    const head = await prHead($, target.repo, target.pr);
705    if (!head) {
706      return ran;
707    }
708
709    // What the band last saw for this branch, before the GitHub App could act.
710    const before = await read($, staged);
711    const prior = before?.repo === target.repo && before.branch === head ? before.count : 0;
712    const view = await fetchStaged($, { branch: head, repo: target.repo });
713    if (!view) {
714      return ran;
715    }
716
717    const pr = `PR #${target.pr}`;
718    const promoteArgv = [
719      "uploads",
720      "attach",
721      "--promote",
722      "--pr",
723      String(target.pr),
724      "--repo",
725      target.repo,
726      "--from-branch",
727      head,
728    ];
729    const command = promoteArgv.join(" ");
730    const files = attachments(view.count);
731
732    let outcome: Outcome | null = null;
733    if (view.count === 0) {
734      // The App's webhook already promoted them between the PR opening and this read.
735      if (prior > 0 && view.autoAttach) {
736        outcome = {
737          toast: `uploads: ${attachments(prior)} added to ${pr} by the uploads-sh bot`,
738          context: `uploads: the GitHub App added the ${attachments(prior)} for ${head} to ${pr}. Don't re-upload them.`,
739          record: { count: prior, via: "app" },
740        };
741      }
742    } else if (view.binding === "other") {
743      outcome = {
744        toast: `uploads: ${files} won't attach to ${pr}: this repo is linked to another workspace`,
745      };
746    } else if (view.autoAttach) {
747      outcome = {
748        toast: `uploads: ${files} will attach to ${pr} via the uploads-sh bot`,
749        context: `uploads: the GitHub App attaches the ${files} for ${head} to ${pr}. Don't re-upload them.`,
750        record: { count: view.count, via: "app" },
751      };
752    } else if (!autoPromote) {
753      outcome = {
754        toast: `uploads: ${files} waiting for ${pr}; run ${command}`,
755        context: `uploads: ${files} for ${head} are staged but not attached to ${pr}. To attach them, run \`${command}\`.`,
756      };
757    } else {
758      // The command exits 0 even when the server call failed, so trust only
759      // the `promotion` it reports: what moved, and what it skipped.
760      const result = parsePromotion(await run($, [...promoteArgv, "--json"], 60_000));
761      if (result === null || result.promoted === 0) {
762        outcome = {
763          toast: `uploads: couldn't add ${files} to ${pr}; run uploads attach --promote`,
764          context: `uploads: ${files} for ${head} did not attach to ${pr}. Run \`${command}\`.`,
765        };
766      } else if (result.skipped > 0) {
767        const moved = attachments(result.promoted);
768        outcome = {
769          toast: `uploads: added ${moved} to ${pr}; ${result.skipped} skipped`,
770          context: `uploads: added ${moved} for ${head} to ${pr}; ${result.skipped} ${result.skipped === 1 ? "was" : "were"} skipped. Run \`${command}\` to retry them.`,
771          record: { count: result.promoted, via: "cli" },
772        };
773      } else {
774        outcome = {
775          toast: `uploads: added ${attachments(result.promoted)} to ${pr}`,
776          context: `uploads: added ${attachments(result.promoted)} for ${head} to ${pr}'s attachments comment. Don't re-upload them.`,
777          record: { count: result.promoted, via: "cli" },
778        };
779        // Nothing is left staged for the head; the scheduled refresh confirms it.
780        await update($, staged, (s) =>
781          s?.repo === target.repo && s.branch === head ? { ...s, count: 0, rows: [] } : s,
782        );
783      }
784    }
785    if (outcome === null) {
786      return ran;
787    }
788
789    $.ui.toast(outcome.toast);
790    const { record } = outcome;
791    if (record) {
792      await update(
793        $,
794        attached,
795        (): Attached => ({ ...record, repo: target.repo, branch: head, pr: target.pr }),
796      );
797    }
798    return outcome.context ? { ...ran, context: [...(ran.context ?? []), outcome.context] } : ran;
799  });
800
801  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
802    const view = await read($, staged);
803    if (e.props.hasSurvey || view === null) {
804      return next(e);
805    }
806    const line = bandLine(view, await read($, attached));
807    if (line === null) {
808      return next(e);
809    }
810    // State saved by an older version of this module has no `rows`.
811    const rows = Array.isArray(view.rows) ? view.rows : [];
812
813    if (e.surface === "desktop") {
814      const { Box, Button, Svg, Text } = $.ui.resolve(e);
815      // Hidden until the staged set changes.
816      if (view.count > 0 && (await read($, dismissed)) === setKey(view)) {
817        return next(e);
818      }
819      const t = await read($, thumbs);
820      return (
821        <Box flexDirection="row" alignItems="center" justifyContent="space-between" gap={1}>
822          <Box flexDirection="row" alignItems="center" gap={1}>
823            <Svg source={MARK_SVG} alt="uploads" width={14} height={14} />
824            <Text color={BRAND} bold>
825              uploads
826            </Text>
827            <Text color={line.warn ? WARN : undefined} dimColor={!line.warn}>
828              {line.text}
829            </Text>
830          </Box>
831          {view.count > 0 && (
832            <Box flexDirection="row" alignItems="center" gap={1}>
833              {rows.slice(0, BAND_TILES).map((row, i) => (
834                <Svg
835                  key={`tile-${i}`}
836                  source={tileSvg(slotsOf(row, t), 40, 25, false)}
837                  alt={labelOf(row)}
838                  height={25}
839                />
840              ))}
841              {rows.length > BAND_TILES && <Text dimColor>+{rows.length - BAND_TILES}</Text>}
842              <Button key="view" label="View" plain dimColor onPress={() => void showGrid($)} />
843              <Button
844                key="hide"
845                label="Hide"
846                role="dismiss"
847                onPress={() => void update($, dismissed, () => setKey(view))}
848              />
849            </Box>
850          )}
851        </Box>
852      );
853    }
854
855    const els = $.ui.resolve(e);
856    const { Box, Button, Link, Text } = els;
857    const Image = "Image" in els ? els.Image : null;
858    const canExpand = rows.length > 0;
859    const expanded = canExpand && (await read($, isExpanded));
860    // The header row takes one line, and a "+N more" row may take another.
861    const shown = expanded
862      ? rows.slice(0, Math.min(MAX_ROWS, Math.max(1, e.props.maxRows - 2)))
863      : [];
864    const more = view.count - shown.reduce((sum, row) => sum + filesIn(row), 0);
865
866    // A file as a link named by `label`, or plain text when the CLI gave no URL.
867    const fileLink = (file: FileRef, label: string) =>
868      file.url ? <Link href={file.url} label={label} /> : <Text>{label}</Text>;
869    // One side of a pair: a link to that file, or an amber dash where it's missing.
870    const half = (file: FileRef | null, role: "before" | "after") =>
871      file ? fileLink(file, role) : <Text color={WARN}>—</Text>;
872
873    // The engine's own `[-]` at the row's end collapses the band, so it needs no hide control.
874    return (
875      <Box flexDirection="column">
876        <Box>
877          {Image ? (
878            <Image key="mark" source={MARK} columns={2} rows={1} alt="⇡" />
879          ) : (
880            <Text color={BRAND}>⇡</Text>
881          )}
882          <Text color={BRAND} bold>
883            {" uploads "}
884          </Text>
885          {canExpand && (
886            <Button
887              key="files"
888              plain
889              label={expanded ? "▾" : "▸"}
890              onPress={() => update($, isExpanded, (open) => !open)}
891            />
892          )}
893          <Text color={line.warn ? WARN : undefined} dimColor={!line.warn}>
894            {" "}
895            {line.text}
896          </Text>
897        </Box>
898        {shown.map((row, i) =>
899          row.kind === "file" ? (
900            <Box key={`file-${i}`}>
901              <Text dimColor>{"  · "}</Text>
902              {fileLink(row.file, row.file.name)}
903              <Text dimColor> {formatSize(row.file.size)}</Text>
904            </Box>
905          ) : (
906            <Box key={`pair-${i}`}>
907              <Text dimColor>{"  · "}</Text>
908              <Text>{row.label} </Text>
909              {half(row.before, "before")}
910              <Text dimColor>{" → "}</Text>
911              {half(row.after, "after")}
912              {/* A lone half's size; a full pair needs none. */}
913              {(row.before === null) !== (row.after === null) && (
914                <Text dimColor> {formatSize((row.before ?? row.after)!.size)}</Text>
915              )}
916            </Box>
917          ),
918        )}
919        {expanded && more > 0 && <Text dimColor>{`  + ${more} more (uploads staged)`}</Text>}
920      </Box>
921    );
922  });
923
924  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
925    const els = $.ui.resolve(e);
926    const { Box, Button, Link, Text } = els;
927    const Svg = "Svg" in els ? els.Svg : null;
928    const desktop = e.surface === "desktop" && Svg !== null;
929    const chosen = await read($, preview);
930
931    // One row, large, in the same pane as the grid.
932    if (chosen) {
933      const big = await read($, large);
934      const small = await read($, thumbs);
935      return (
936        <Box flexDirection="column" gap={1}>
937          <Box flexDirection="row" alignItems="center" justifyContent="space-between" gap={1}>
938            <Button
939              key="back"
940              label="← All attachments"
941              plain
942              dimColor
943              onPress={() => void showGrid($)}
944            />
945            <Box flexDirection="row" alignItems="center" gap={1}>
946              <Button
947                key="copy-row"
948                label="Copy markdown"
949                onPress={(p) => void copyText($, p.surface, markdownOf([chosen]), "markdown")}
950              />
951              <Button
952                key="remove"
953                label="Remove"
954                onPress={async () => {
955                  await removeRow($, chosen);
956                  await showGrid($);
957                }}
958              />
959            </Box>
960          </Box>
961          <Text bold>{labelOf(chosen)}</Text>
962          {filesOf(chosen).map((f) => {
963            const link = f.url ? <Link href={f.url} label="Open on uploads.sh" /> : null;
964            const previewable = !!f.src && IMAGE_EXT.test(f.name) && small[f.src] !== "";
965            if (!previewable) {
966              return (
967                <Box key={f.key} flexDirection="column" gap={1}>
968                  {desktop && (
969                    <Svg
970                      source={tileSvg(
971                        [{ uri: "", chip: null, ext: extOf(f.name) }],
972                        480,
973                        200,
974                        false,
975                      )}
976                      alt={f.name}
977                    />
978                  )}
979                  <Box flexDirection="row" alignItems="center" gap={1}>
980                    <Text dimColor>{f.name} can't be previewed here.</Text>
981                    {link}
982                  </Box>
983                </Box>
984              );
985            }
986            const uri = big[f.src!] ?? small[f.src!] ?? null;
987            const role = roleName(chosen, f);
988            return (
989              <Box key={f.key} flexDirection="column">
990                <Box flexDirection="row" alignItems="center" gap={1}>
991                  {role ? <Text bold>{role}</Text> : null}
992                  {link}
993                </Box>
994                {desktop && (
995                  <Svg
996                    source={tileSvg([{ uri, chip: null, ext: extOf(f.name) }], 1000, 625, false)}
997                    alt={f.name}
998                  />
999                )}
1000              </Box>
1001            );
1002          })}
1003        </Box>
1004      );
1005    }
1006
1007    const view = await read($, staged);
1008    const rows = view && Array.isArray(view.rows) ? view.rows : [];
1009    if (!view || view.count === 0) {
1010      return <Text dimColor>Nothing staged for this branch.</Text>;
1011    }
1012    const t = await read($, thumbs);
1013    const pr = await read($, openPr);
1014    const feed = await read($, feedUrl);
1015    const line = headline(view);
1016    const now = await $.clock.now();
1017
1018    return (
1019      <Box flexDirection="column" gap={1}>
1020        {/* Header: where these go, and the links worth sharing. */}
1021        <Box flexDirection="row" alignItems="center" gap={1}>
1022          <Box flexDirection="column" flexGrow={1}>
1023            <Text bold color={line.warn ? WARN : undefined}>
1024              {line.text}
1025            </Text>
1026            <Text dimColor>
1027              {view.repo} · {view.branch}
1028            </Text>
1029          </Box>
1030          {pr ? (
1031            <Button
1032              key="feed"
1033              label="Copy link"
1034              variant="primary"
1035              onPress={(p) => void copyFeedLink($, p.surface, view, pr)}
1036            />
1037          ) : null}
1038          <Button
1039            key="copy-all"
1040            label="Copy markdown"
1041            onPress={(p) =>
1042              void copyText($, p.surface, markdownOf(rows), "markdown for all attachments")
1043            }
1044          />
1045        </Box>
1046        {pr && feed ? <Link href={feed} label={feed} /> : null}
1047
1048        {/* Tiles: a pair is one card, before and after side by side. */}
1049        <Box flexDirection="row" flexWrap="wrap" gap={2}>
1050          {rows.map((row, i) => (
1051            <Box key={`tile-${i}`} flexDirection="column" gap={0}>
1052              {desktop && (
1053                <Svg source={tileSvg(slotsOf(row, t), TILE_W, TILE_H, true)} alt={labelOf(row)} />
1054              )}
1055              <Box flexDirection="row" alignItems="center" gap={1}>
1056                <Button
1057                  key={`name-${i}`}
1058                  label={fit(labelOf(row), captionRoom(row))}
1059                  plain
1060                  onPress={() => void showRow($, row)}
1061                />
1062                <Text dimColor>{agoOf(row, now)}</Text>
1063              </Box>
1064            </Box>
1065          ))}
1066        </Box>
1067        <Box flexDirection="column" marginTop={2} gap={0}>
1068          <Box flexDirection="row">
1069            {workspacePage(rows) ? (
1070              <Link href={workspacePage(rows)!} label="Hosted on uploads.sh" />
1071            ) : (
1072              <Text dimColor>Hosted on uploads.sh</Text>
1073            )}
1074            <Text dimColor>, not committed to the repo.</Text>
1075          </Box>
1076          <Text dimColor>They will appear in a comment on the pull request once opened.</Text>
1077        </Box>
1078      </Box>
1079    );
1080  });
1081};
1082
types/index.d.ts 68 lines
1export type Binding = "self" | "none" | "other" | "unknown";
2
3/** One staged file as the expanded band links it. */
4export type FileRef = {
5  /** The full object key, what `uploads delete` takes. */
6  key: string;
7  name: string;
8  /** The uploads.sh file page, or null when the CLI gave no URL. */
9  url: string | null;
10  /** The storage URL, a thumbnail's source; null when the CLI gave no URL. */
11  src: string | null;
12  size: number | null;
13  /** ISO time the file was staged, when the CLI reported it. */
14  stagedAt: string | null;
15};
16
17/**
18 * One row of the expanded band: a before/after pair under its shared name
19 * (by the attachments comment's pairing rule; either half may be missing),
20 * or a file that is neither.
21 */
22export type BandRow =
23  | { kind: "pair"; label: string; before: FileRef | null; after: FileRef | null }
24  | { kind: "file"; file: FileRef };
25
26/** What `uploads staged --format json` reported for one branch. */
27export type Staged = {
28  repo: string;
29  branch: string;
30  count: number;
31  /** Every staged file's row; `count` is the number of files. */
32  rows: BandRow[];
33  binding: Binding;
34  autoAttach: boolean;
35};
36
37/** The last PR this session opened that had staged files waiting for it. */
38export type Attached = {
39  repo: string;
40  branch: string;
41  pr: number;
42  count: number;
43  via: "app" | "cli";
44};
45
46declare module "claude-code" {
47  interface PluginState {
48    uploads: {
49      staged: Staged | null;
50      attached: Attached | null;
51      /** Whether the band lists the staged files under its header. */
52      isExpanded: boolean;
53      /** Desktop: storage URL to a small JPEG data URI; "" when the fetch failed. */
54      thumbs: Record<string, string>;
55      /** Desktop: storage URL to a larger JPEG data URI for the pane's detail view. */
56      large: Record<string, string>;
57      /** The row the pane's detail view shows; null shows the grid. */
58      preview: BandRow | null;
59      /** The open PR for the session branch, if any. */
60      openPr: { number: number; url: string } | null;
61      /** The PR's live feed URL once created. */
62      feedUrl: string | null;
63      /** The staged set the desktop band is hidden for; a change shows it again. */
64      dismissed: string | null;
65    };
66  }
67}
68