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

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.
| Component | Invocation | Purpose |
|---|---|---|
| github-screenshots skill | /uploads:github-screenshots | Capture, host, and embed a visual in a PR or issue |
| annotate-screenshots skill | /uploads:annotate-screenshots | Add boxes, arrows, labels, and redactions to a capture |
| uploads-cli skill | /uploads:uploads-cli | Full 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-staged | Claude 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.
Pick your agent. The same folder serves both listings.
/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.
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.
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>.
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.
Apache-2.0, the same license as this repository (license in .claude-plugin/plugin.json).
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.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:
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.
| Program | Why it runs | What it sends, and where | |
|---|---|---|---|
uploads staged --format json | Session start, after a matching shell command, and when the session is idle | Branch 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 branch | That branch and repository to the uploads.sh API | |
uploads attach --promote --pr <n> --repo <owner/repo> --from-branch <branch> --json | After gh pr create, when this workspace should attach the files | PR 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 pane | Repository 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 pane | The file's key to the uploads.sh API | |
gh pr view --json number,url,state | With the staged read, to find the open PR for the current branch | The current branch, through your local gh login, to the GitHub API | |
gh pr view <n> --repo <owner/repo> --json headRefName -q .headRefName | After gh pr create, to learn the PR's head branch | The 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 app | An 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.
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.
hooks/register.tsx 1082 lines1import { 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
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 ? `})` : "";
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};
1082types/index.d.ts 68 lines1export 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