Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Merge pull request #160 from flagon-io/paste-images

Paste, drop or attach an image in any comment box to upload it

syntaqxcommitted Parents96a2c99876aa37Browse files
16 files+2162−14290/16 viewed
+17−2
348348 ## Images
349349
350350 To show an image in a pull request's or a work item's description, or in a
351−comment (a screenshot of a preview, say), upload it and paste the Markdown
352−you get back. From a shell, `g1t attach preview.png` prints
351+comment (a screenshot of a preview, say), put it in the box you are writing
352+in. On the site, paste it, drop the file onto the box, or select **Attach an
353+image** under it (on a phone, that button is the way in). This works in every
354+comment box: comments and reviews, a comment on a line of the change, editing
355+a comment, a pull request's description, and a new pull request or work item.
356+While it uploads, the box shows `![Uploading screenshot.png…]()` where it
357+will go, and you can keep writing; once it is kept, that becomes
358+`![screenshot.png](https://g1tusercontent.com/attachments/…)`. A pasted image
359+with no name of its own is `image.png`. Comment and the other buttons wait
360+until every image has uploaded. If one can't be attached, the box says why
361+under it and takes its placeholder out; nothing you wrote is lost. Pasting
362+text works as before: something copied with its text and a picture of it,
363+such as cells from a spreadsheet, pastes as text. A new work item filed with
364+no repository has nowhere to keep images yet, so they go with it when you
365+open it, and the work item keeps them.
366+
367+Otherwise, upload it and paste the Markdown you get back. From a shell, `g1t attach preview.png` prints
353368 `![preview.png](https://g1tusercontent.com/attachments/…)`, and `--attach
354369 preview.png` on `g1t pr create`, `g1t pr ready`, `g1t pr comment`,
355370 `g1t work new` and `g1t work comment` adds it for you (see the
+4−1
4040
4141 On [Work](/work/), choose **New work item**:
4242
43−1. Give it a **title** (up to 200 characters) and a **description**.
43+1. Give it a **title** (up to 200 characters) and a **description**. Paste
44+ or drop images into the description, or select **Attach an image**
45+ under it; see [images](/guides/pull-requests/#images). Comments take
46+ them the same way.
4447 2. Under **Where it lands**, tick the repositories it should land in, its
4548 targets: none, one or several. It closes once what closes it has landed
4649 in every one, or, with none, once anything does.
+255−0
1+import { ImagePlus, LoaderCircle } from "lucide-react";
2+import {
3+ type ClipboardEvent,
4+ type DragEvent,
5+ type ReactNode,
6+ type RefObject,
7+ createContext,
8+ useCallback,
9+ useContext,
10+ useEffect,
11+ useRef,
12+ useState,
13+} from "react";
14+
15+import {
16+ ATTACHMENT_ACCEPT,
17+ DEFERRED_FILE_FIELD,
18+ DEFERRED_REF_FIELD,
19+ caretAfter,
20+ deferredUrl,
21+ dragsFiles,
22+ imageMarkdown,
23+ imageName,
24+ insertion,
25+ pastedImages,
26+ placeholderFor,
27+ placeholderRange,
28+ refusal,
29+ uploadFailure,
30+} from "../lib/attachments";
31+import { Button } from "./ui/button";
32+
33+/**
34+ * Where the comment boxes inside keep the images put in them: a
35+ * repository (its pull requests, commits, and work items filed from it),
36+ * a work item, or `later` for a work item not open yet with no repository
37+ * to keep them (they go with the form, and the work item keeps them once
38+ * it is open, as `g1t work new --attach` does).
39+ */
40+export type AttachTo = { repo: string } | { workspace: string; number: number } | "later";
41+
42+const AttachContext = createContext<AttachTo | null>(null);
43+
44+/** Comment boxes inside take images, kept by `to` (lib/attachments.ts). */
45+export function AttachImages({ to, children }: { to: AttachTo | null; children: ReactNode }) {
46+ return <AttachContext.Provider value={to}>{children}</AttachContext.Provider>;
47+}
48+
49+function endpoint(to: Exclude<AttachTo, "later">, name: string): string {
50+ const query = new URLSearchParams("repo" in to ? { repo: to.repo } : { workspace: to.workspace, number: String(to.number) });
51+ query.set("name", name);
52+ return `/-/attachments?${query}`;
53+}
54+
55+/**
56+ * Puts `text` in for `start`..`end` and tells the box it changed. At the
57+ * caret (`end`), it keeps the browser's undo when the box is focused;
58+ * elsewhere (`keep`), the person's caret or selection stays where it was
59+ * in what they wrote.
60+ */
61+function put(box: HTMLTextAreaElement, text: string, start: number, end: number, select: "end" | "keep") {
62+ if (select === "end") {
63+ if (document.activeElement === box) {
64+ box.setSelectionRange(start, end);
65+ // Deprecated but still the one way to keep the edit undoable; falls back below.
66+ if (document.execCommand?.("insertText", false, text)) return;
67+ }
68+ box.setRangeText(text, start, end, "end");
69+ } else {
70+ const [from, to, direction] = [box.selectionStart, box.selectionEnd, box.selectionDirection];
71+ box.setRangeText(text, start, end);
72+ box.setSelectionRange(caretAfter(from, start, end, text.length), caretAfter(to, start, end, text.length), direction ?? undefined);
73+ }
74+ box.dispatchEvent(new Event("input", { bubbles: true }));
75+}
76+
77+/**
78+ * Paste, drop or choose images in the comment box `ref`, GitHub's way:
79+ * each puts `![Uploading name…]()` at the caret while it uploads, and that
80+ * placeholder alone becomes `![name](url)`, wherever the person's writing
81+ * has moved it. Its form can't be sent while one is uploading. Nothing
82+ * outside an `<AttachImages>` takes images.
83+ */
84+export function useImageAttach(ref: RefObject<HTMLTextAreaElement | null>) {
85+ const to = useContext(AttachContext);
86+ const [uploading, setUploading] = useState(0);
87+ const [error, setError] = useState<string | null>(null);
88+ const [dragging, setDragging] = useState(false);
89+ // A work item not open yet: its images, by the ref their Markdown holds.
90+ const [later, setLater] = useState<{ ref: string; file: File }[]>([]);
91+ const filesInput = useRef<HTMLInputElement>(null);
92+ const chooser = useRef<HTMLInputElement>(null);
93+
94+ const attach = useCallback(
95+ (files: readonly File[]) => {
96+ const box = ref.current;
97+ if (!box || !to) return;
98+ const refused: string[] = [];
99+ for (const file of files) {
100+ const why = refusal(file);
101+ if (why) {
102+ refused.push(why);
103+ continue;
104+ }
105+ const name = imageName(file);
106+ if (to === "later") {
107+ const id = Math.random().toString(36).slice(2, 10);
108+ const at = insertion(box.value, box.selectionStart, box.selectionEnd, imageMarkdown(name, deferredUrl(id)));
109+ put(box, at.text, at.start, at.end, "end");
110+ setLater((held) => [...held, { ref: id, file }]);
111+ continue;
112+ }
113+ const placeholder = placeholderFor(name, box.value);
114+ const at = insertion(box.value, box.selectionStart, box.selectionEnd, placeholder);
115+ put(box, at.text, at.start, at.end, "end");
116+ setUploading((count) => count + 1);
117+ void (async () => {
118+ let replacement = "";
119+ let failed: string | null = null;
120+ try {
121+ const answer = await fetch(endpoint(to, name), { method: "POST", body: file, headers: { "content-type": file.type } });
122+ const body = (await answer.json().catch(() => null)) as
123+ | { ok: true; markdown: string }
124+ | { ok: false; error: { code: string; message: string } }
125+ | null;
126+ if (body?.ok) replacement = body.markdown;
127+ else failed = uploadFailure(name, body && !body.ok ? body.error : null);
128+ } catch {
129+ failed = `${name} didn't upload. Check your connection and try again.`;
130+ }
131+ const now = ref.current;
132+ const range = now && placeholderRange(now.value, placeholder, replacement);
133+ if (now && range) put(now, replacement, range.start, range.end, "keep");
134+ // Beside any other refusal from the same paste or drop, not over it.
135+ if (failed) setError((shown) => (shown ? `${shown} ${failed}` : failed));
136+ setUploading((count) => count - 1);
137+ })();
138+ }
139+ setError(refused.length ? refused.join(" ") : null);
140+ },
141+ [ref, to],
142+ );
143+
144+ // While an image uploads, the form waits: its buttons are off, and a
145+ // submit some other way (Enter in a field) is held back.
146+ useEffect(() => {
147+ const form = ref.current?.form;
148+ if (!form || uploading === 0) return;
149+ const hold = (event: SubmitEvent) => {
150+ event.preventDefault();
151+ event.stopImmediatePropagation();
152+ setError("Wait for the image to finish uploading, then send it.");
153+ };
154+ form.addEventListener("submit", hold, { capture: true });
155+ const buttons = [...form.querySelectorAll<HTMLButtonElement>("button[type=submit]:not(:disabled)")];
156+ for (const button of buttons) button.disabled = true;
157+ return () => {
158+ form.removeEventListener("submit", hold, { capture: true });
159+ for (const button of buttons) button.disabled = false;
160+ };
161+ }, [ref, uploading]);
162+
163+ // A work item not open yet: the images held go with the form, those
164+ // whose Markdown is still in the box (the action checks again).
165+ useEffect(() => {
166+ const input = filesInput.current;
167+ if (!input || typeof DataTransfer === "undefined") return;
168+ const files = new DataTransfer();
169+ for (const held of later) files.items.add(held.file);
170+ input.files = files.files;
171+ }, [later]);
172+
173+ if (!to) return null;
174+
175+ const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
176+ const data = event.clipboardData;
177+ if (!data) return;
178+ const files = [...data.items].filter((item) => item.kind === "file").map((item) => item.getAsFile()).filter((file): file is File => file != null);
179+ const images = pastedImages({ files, text: data.getData("text/plain") });
180+ if (images.length === 0) return;
181+ event.preventDefault();
182+ attach(images);
183+ };
184+ const onDragOver = (event: DragEvent<HTMLTextAreaElement>) => {
185+ if (!dragsFiles([...event.dataTransfer.types])) return;
186+ event.preventDefault();
187+ event.dataTransfer.dropEffect = "copy";
188+ setDragging(true);
189+ };
190+ const onDragLeave = () => setDragging(false);
191+ const onDrop = (event: DragEvent<HTMLTextAreaElement>) => {
192+ setDragging(false);
193+ if (!dragsFiles([...event.dataTransfer.types])) return;
194+ event.preventDefault();
195+ const box = ref.current;
196+ // Where the drop landed is where the caret is, as the browser placed it on dragover.
197+ box?.focus();
198+ attach([...event.dataTransfer.files]);
199+ };
200+
201+ const footer = (
202+ <div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-faint">
203+ <Button
204+ type="button"
205+ variant="ghost"
206+ size="inline"
207+ className="gap-1 rounded px-1 py-0.5 text-xs font-normal text-faint hover:text-fg max-sm:min-h-9"
208+ onClick={() => chooser.current?.click()}
209+ >
210+ <ImagePlus size={14} aria-hidden="true" />
211+ Attach an image
212+ </Button>
213+ <span className="max-sm:hidden">or paste or drop one in. PNG, JPEG, GIF or WebP, up to 10 MB.</span>
214+ <input
215+ ref={chooser}
216+ type="file"
217+ accept={ATTACHMENT_ACCEPT}
218+ multiple
219+ hidden
220+ tabIndex={-1}
221+ aria-hidden="true"
222+ onChange={(event) => {
223+ const files = [...(event.currentTarget.files ?? [])];
224+ event.currentTarget.value = "";
225+ ref.current?.focus();
226+ attach(files);
227+ }}
228+ />
229+ {to === "later" && (
230+ <>
231+ <input ref={filesInput} type="file" name={DEFERRED_FILE_FIELD} multiple hidden tabIndex={-1} aria-hidden="true" />
232+ {later.map((held) => (
233+ <input key={held.ref} type="hidden" name={DEFERRED_REF_FIELD} value={held.ref} />
234+ ))}
235+ {later.length > 0 && <span>{later.length === 1 ? "The image uploads" : "The images upload"} when you open the work item.</span>}
236+ </>
237+ )}
238+ <span aria-live="polite" className="contents">
239+ {uploading > 0 && (
240+ <span className="flex items-center gap-1 text-muted">
241+ <LoaderCircle size={12} aria-hidden="true" className="animate-spin motion-reduce:animate-none" />
242+ Uploading {uploading === 1 ? "an image" : `${uploading} images`}…
243+ </span>
244+ )}
245+ </span>
246+ {error && (
247+ <p role="alert" className="basis-full text-sm text-danger">
248+ {error}
249+ </p>
250+ )}
251+ </div>
252+ );
253+
254+ return { onPaste, onDragOver, onDragLeave, onDrop, dragging, footer };
255+}
+3−2
3030 withHtml,
3131 } from "../lib/diff";
3232 import { Markdown } from "./markdown";
33−import { EmptyState, SubmitButton, Textarea, TimeAgo } from "./ui";
33+import { MentionTextarea } from "./mention-textarea";
34+import { EmptyState, SubmitButton, TimeAgo } from "./ui";
3435 import { Avatar } from "./ui/avatar";
3536 import { Button } from "./ui/button";
3637 import { Card } from "./ui/card";
177178 <input type="hidden" name="action" value="comment" />
178179 <input type="hidden" name="path" value={path} />
179180 <input type="hidden" name="line" value={line} />
180− <Textarea name="body" rows={3} required autoFocus placeholder={`Comment on line ${line}`} />
181+ <MentionTextarea name="body" rows={3} required autoFocus placeholder={`Comment on line ${line}`} />
181182 <div className="flex gap-2">
182183 <SubmitButton match={{ action: "comment", path, line: String(line) }} pending="Commenting…">
183184 Comment
+77−52
22 import { type ComponentProps, type KeyboardEvent, useId, useRef, useState } from "react";
33
44 import { type MentionMatch, mentionSuggestions } from "../lib/mention";
5+import { cn } from "../lib/cn";
6+import { useImageAttach } from "./attach-images";
57 import { Textarea } from "./ui";
68
79 /**
810 * A comment box that offers to complete the teams in `handles`
911 * (`@acme/backend`): Up and Down move through the suggestions, Tab or Enter
10− * takes one, Escape dismisses them.
12+ * takes one, Escape dismisses them. Inside an `<AttachImages>`, it takes
13+ * images too: pasted, dropped or chosen with Attach an image
14+ * (components/attach-images.tsx).
1115 */
1216 export function MentionTextarea({ handles, ...props }: ComponentProps<"textarea"> & { handles?: readonly string[] }) {
1317 const ref = useRef<HTMLTextAreaElement>(null);
1418 const [match, setMatch] = useState<MentionMatch | null>(null);
1519 const [active, setActive] = useState(0);
1620 const list = useId();
21+ const images = useImageAttach(ref);
1722
1823 const look = () => {
1924 const box = ref.current;
5661
5762 const open = match != null;
5863 return (
59− <div className="relative">
60− <Textarea
61− {...props}
62− ref={ref}
63− role={open ? "combobox" : undefined}
64− aria-autocomplete="list"
65− aria-expanded={open}
66− aria-controls={open ? list : undefined}
67− aria-activedescendant={open ? `${list}-${active}` : undefined}
68− onKeyDown={onKeyDown}
69− onInput={(event) => {
70− look();
71− props.onInput?.(event);
72− }}
73− onClick={(event) => {
74− look();
75− props.onClick?.(event);
76− }}
77− onBlur={(event) => {
78− // After a click on a suggestion has landed.
79− setTimeout(() => setMatch(null), 150);
80− props.onBlur?.(event);
81− }}
82− />
83− {match && (
84− <ul
85− id={list}
86− role="listbox"
87− aria-label="Mentions"
88− className="absolute top-full left-0 z-20 mt-1 max-w-full min-w-44 overflow-hidden rounded-md border border-line bg-raised p-1 shadow-lg shadow-black/30"
89− >
90− {match.options.map((handle, index) => (
91− <li
92− key={handle}
93− id={`${list}-${index}`}
94− role="option"
95− aria-selected={index === active}
96− onMouseDown={(event) => event.preventDefault()}
97− onMouseEnter={() => setActive(index)}
98− onClick={() => complete(handle)}
99− className={`flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 font-mono text-xs text-fg ${
100− index === active ? "bg-line" : ""
101− }`}
102− >
103− <Users size={12} className="shrink-0 text-faint" />
104− <span className="min-w-0 truncate">{handle}</span>
105− {index === active && <span className="ml-auto pl-3 font-sans text-faint">Tab</span>}
106− </li>
107− ))}
108− </ul>
109− )}
64+ <div>
65+ <div className="relative">
66+ <Textarea
67+ {...props}
68+ ref={ref}
69+ role={open ? "combobox" : undefined}
70+ aria-autocomplete="list"
71+ aria-expanded={open}
72+ aria-controls={open ? list : undefined}
73+ aria-activedescendant={open ? `${list}-${active}` : undefined}
74+ onKeyDown={onKeyDown}
75+ onInput={(event) => {
76+ look();
77+ props.onInput?.(event);
78+ }}
79+ onClick={(event) => {
80+ look();
81+ props.onClick?.(event);
82+ }}
83+ onBlur={(event) => {
84+ // After a click on a suggestion has landed.
85+ setTimeout(() => setMatch(null), 150);
86+ props.onBlur?.(event);
87+ }}
88+ onPaste={(event) => {
89+ images?.onPaste(event);
90+ props.onPaste?.(event);
91+ }}
92+ onDragOver={(event) => {
93+ images?.onDragOver(event);
94+ props.onDragOver?.(event);
95+ }}
96+ onDragLeave={(event) => {
97+ images?.onDragLeave();
98+ props.onDragLeave?.(event);
99+ }}
100+ onDrop={(event) => {
101+ images?.onDrop(event);
102+ props.onDrop?.(event);
103+ }}
104+ className={cn(images?.dragging && "border-accent-dim ring-2 ring-accent/25", props.className)}
105+ />
106+ {match && (
107+ <ul
108+ id={list}
109+ role="listbox"
110+ aria-label="Mentions"
111+ className="absolute top-full left-0 z-20 mt-1 max-w-full min-w-44 overflow-hidden rounded-md border border-line bg-raised p-1 shadow-lg shadow-black/30"
112+ >
113+ {match.options.map((handle, index) => (
114+ <li
115+ key={handle}
116+ id={`${list}-${index}`}
117+ role="option"
118+ aria-selected={index === active}
119+ onMouseDown={(event) => event.preventDefault()}
120+ onMouseEnter={() => setActive(index)}
121+ onClick={() => complete(handle)}
122+ className={`flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 font-mono text-xs text-fg ${
123+ index === active ? "bg-line" : ""
124+ }`}
125+ >
126+ <Users size={12} className="shrink-0 text-faint" />
127+ <span className="min-w-0 truncate">{handle}</span>
128+ {index === active && <span className="ml-auto pl-3 font-sans text-faint">Tab</span>}
129+ </li>
130+ ))}
131+ </ul>
132+ )}
133+ </div>
134+ {images?.footer}
110135 </div>
111136 );
112137 }
+112−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import {
5+ MAX_ATTACHMENT_BYTES,
6+ caretAfter,
7+ deferredRefs,
8+ deferredUrl,
9+ dragsFiles,
10+ imageMarkdown,
11+ imageName,
12+ insertion,
13+ pastedImages,
14+ placeholderFor,
15+ refusal,
16+ replacePlaceholder,
17+ resolveDeferred,
18+ uploadFailure,
19+} from "./attachments.ts";
20+
21+const png = (name = "screenshot.png", size = 1000) => ({ name, type: "image/png", size });
22+
23+test("a paste uploads image files only when it has no text", () => {
24+ // A screenshot, or an image copied from a page (its HTML, no text).
25+ assert.deepEqual(pastedImages({ files: [png()], text: "" }), [png()]);
26+ // A spreadsheet's cells or a document's paragraphs, with a picture of themselves.
27+ assert.deepEqual(pastedImages({ files: [png("image.png")], text: "a\tb\n1\t2" }), []);
28+ // Plain text.
29+ assert.deepEqual(pastedImages({ files: [], text: "hello" }), []);
30+ // Files that are not images are left to the browser.
31+ assert.deepEqual(pastedImages({ files: [{ name: "a.pdf", type: "application/pdf", size: 3 }], text: "" }), []);
32+ // Several images, each its own.
33+ assert.equal(pastedImages({ files: [png("a.png"), png("b.png")], text: " " }).length, 2);
34+});
35+
36+test("only a drag that carries files is taken", () => {
37+ assert.equal(dragsFiles(["Files"]), true);
38+ assert.equal(dragsFiles(["text/plain", "text/html"]), false);
39+});
40+
41+test("what is refused before it is sent, in a plain sentence", () => {
42+ assert.equal(refusal(png()), null);
43+ assert.equal(refusal({ name: "big.png", type: "image/png", size: MAX_ATTACHMENT_BYTES + 1 }), "big.png is over 10 MB, so it wasn't attached.");
44+ assert.equal(refusal({ name: "logo.svg", type: "image/svg+xml", size: 10 }), "logo.svg isn't a PNG, JPEG, GIF or WebP image, so it wasn't attached.");
45+ assert.equal(refusal({ name: "x.png", type: "image/png", size: 0 }), "x.png is empty, so it wasn't attached.");
46+ assert.equal(refusal({ name: "a.jpg", type: "image/JPEG", size: 10 }), null);
47+});
48+
49+test("a pasted image with no name is image.png", () => {
50+ assert.equal(imageName({ name: "", type: "image/png", size: 1 }), "image.png");
51+ assert.equal(imageName({ name: "", type: "image/jpeg", size: 1 }), "image.jpg");
52+ assert.equal(imageName({ name: "C:\\shots\\Preview 1.png", type: "image/png", size: 1 }), "Preview 1.png");
53+});
54+
55+test("Markdown escapes the name's brackets", () => {
56+ assert.equal(imageMarkdown("a]b[c\\.png", "https://u/attachments/ab"), "![a\\]b\\[c\\\\.png](https://u/attachments/ab)");
57+});
58+
59+test("each upload has a placeholder of its own", () => {
60+ const first = placeholderFor("image.png", "");
61+ assert.equal(first, "![Uploading image.png…]()");
62+ const second = placeholderFor("image.png", `Look:\n${first}\n`);
63+ assert.equal(second, "![Uploading image.png (2)…]()");
64+ assert.notEqual(placeholderFor("image.png", `${first}${second}`), second);
65+});
66+
67+test("an image goes in on a line of its own", () => {
68+ const md = "![x](u)";
69+ assert.deepEqual(insertion("", 0, 0, md), { text: md, start: 0, end: 0, caret: md.length });
70+ assert.deepEqual(insertion("Before after", 6, 7, md), { text: `\n${md}\n`, start: 6, end: 7, caret: 7 + md.length });
71+ assert.deepEqual(insertion("Line\n", 5, 5, md), { text: md, start: 5, end: 5, caret: 5 + md.length });
72+});
73+
74+test("the placeholder is swapped where it is now, keeping what was written around it", () => {
75+ const placeholder = placeholderFor("shot.png", "");
76+ const typedSince = `Before\n${placeholder}\nAfter, written while it uploaded`;
77+ assert.equal(replacePlaceholder(typedSince, placeholder, "![shot.png](https://u/a)"), "Before\n![shot.png](https://u/a)\nAfter, written while it uploaded");
78+ // A failed upload takes the placeholder and its line out.
79+ assert.equal(replacePlaceholder(typedSince, placeholder, ""), "Before\nAfter, written while it uploaded");
80+ assert.equal(replacePlaceholder(`Text\n${placeholder}`, placeholder, ""), "Text");
81+ // Deleted by the person: nothing changes.
82+ assert.equal(replacePlaceholder("They removed it", placeholder, "![x](u)"), "They removed it");
83+ // Two of the same name: only its own.
84+ const second = placeholderFor("shot.png", placeholder);
85+ assert.equal(replacePlaceholder(`${placeholder}\n${second}`, second, "B"), `${placeholder}\nB`);
86+});
87+
88+test("a refusal from the service reads as a sentence", () => {
89+ assert.equal(uploadFailure("a.png", { code: "too_large", message: "An attachment can be at most 10 MB." }), "a.png is over 10 MB, so it wasn't attached.");
90+ assert.equal(uploadFailure("a.png", { code: "forbidden" }), "You can't attach images here, so a.png wasn't attached.");
91+ assert.equal(uploadFailure("a.png", null), "a.png didn't upload. Try again in a moment.");
92+});
93+
94+test("deferred images get their addresses once the work item is open", () => {
95+ const body = `Steps:\n![a.png](${deferredUrl("k1")})\n![b.png](${deferredUrl("k2")})\nEnd`;
96+ assert.deepEqual([...deferredRefs(body)], ["k1", "k2"]);
97+ const resolved = resolveDeferred(body, new Map([["k1", "https://u/attachments/1"], ["k2", null]]));
98+ assert.equal(resolved, "Steps:\n![a.png](https://u/attachments/1)\nEnd");
99+ // A ref it was not given is left alone.
100+ assert.equal(resolveDeferred(`![c](${deferredUrl("k3")})`, new Map()), "![c](attachment:k3)");
101+});
102+
103+test("the caret stays in the person's writing when a placeholder is swapped", () => {
104+ // "ab[PLACEHOLDER]cd": the placeholder is 2..10, and becomes 20 characters.
105+ assert.equal(caretAfter(1, 2, 10, 20), 1);
106+ assert.equal(caretAfter(2, 2, 10, 20), 2);
107+ // Right at its end, where a paste leaves it: after the image, nothing selected.
108+ assert.equal(caretAfter(10, 2, 10, 20), 22);
109+ assert.equal(caretAfter(12, 2, 10, 20), 24);
110+ // Taken out (a failed upload): after it moves back.
111+ assert.equal(caretAfter(12, 2, 10, 0), 4);
112+});
+183−0
1+/**
2+ * Images pasted, dropped or chosen in a comment box, as GitHub does it: an
3+ * `![Uploading name…]()` placeholder goes in at the caret while the image
4+ * uploads, and is swapped for `![name](url)` when it is kept. The work
5+ * service keeps it (`upload_attachment`, crates/contracts/src/attachments.rs)
6+ * and the usercontent origin serves it. This is the part with no DOM, so it
7+ * can be tested under Node: which pastes upload, the placeholder's text and
8+ * where it goes, and what a refusal says.
9+ */
10+
11+/** As `MAX_ATTACHMENT_BYTES` in the contracts: 10 MB. */
12+export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
13+
14+/** What an attachment may be, as the work service reads it from the bytes. */
15+export const ATTACHMENT_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const;
16+
17+/** What the Attach an image control offers to pick. */
18+export const ATTACHMENT_ACCEPT = ATTACHMENT_TYPES.join(",");
19+
20+/** The form field a box that uploads later (a new work item with no repository) sends its images in. */
21+export const DEFERRED_FILE_FIELD = "attachment";
22+/** With each of those, the reference its Markdown holds: `![name](attachment:<ref>)`. */
23+export const DEFERRED_REF_FIELD = "attachmentRef";
24+
25+/** What an attachment is about to be: as much of a `File` as these need. */
26+export type ImageFile = { name: string; type: string; size: number };
27+
28+/** What the clipboard holds, as much of a `DataTransfer` as the choice needs. */
29+export type Clipboard<T extends ImageFile = ImageFile> = {
30+ /** Its files, from its items of kind `file`. */
31+ files: readonly T[];
32+ /** `getData("text/plain")`. */
33+ text: string;
34+};
35+
36+/**
37+ * The images a paste uploads, or none for the browser to paste as it
38+ * always does. Only when the clipboard holds image files and no plain
39+ * text: a screenshot, or an image copied from a page (which comes with
40+ * its HTML but no text). A spreadsheet's cells or a document's paragraphs
41+ * come with a picture of themselves too, and paste as their text.
42+ */
43+export function pastedImages<T extends ImageFile>(clipboard: Clipboard<T>): T[] {
44+ if (clipboard.text.trim() !== "") return [];
45+ return clipboard.files.filter((file) => file.type.startsWith("image/"));
46+}
47+
48+/** Whether something dragged over the box carries files. */
49+export function dragsFiles(types: readonly string[]): boolean {
50+ return types.includes("Files");
51+}
52+
53+/** Why an image will not be attached, before it is sent; null when it may go. */
54+export function refusal(file: ImageFile): string | null {
55+ if (!(ATTACHMENT_TYPES as readonly string[]).includes(file.type.toLowerCase())) {
56+ return `${file.name || "That file"} isn't a PNG, JPEG, GIF or WebP image, so it wasn't attached.`;
57+ }
58+ if (file.size > MAX_ATTACHMENT_BYTES) return `${file.name || "That image"} is over 10 MB, so it wasn't attached.`;
59+ if (file.size === 0) return `${file.name || "That image"} is empty, so it wasn't attached.`;
60+ return null;
61+}
62+
63+const EXTENSIONS: Record<string, string> = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" };
64+
65+/** A file's name as its alt text: its own, or `image.png` for a pasted one that has none. */
66+export function imageName(file: ImageFile): string {
67+ const own = file.name.split(/[/\\]/).pop()?.replace(/[\u0000-\u001f\u007f]/g, "").trim() ?? "";
68+ return own || `image.${EXTENSIONS[file.type.toLowerCase()] ?? "png"}`;
69+}
70+
71+/** A name as Markdown alt text: its brackets and backslashes escaped, as the contracts' `attachment_markdown`. */
72+export function altText(name: string): string {
73+ return name.replace(/[[\]\\]/g, (c) => `\\${c}`);
74+}
75+
76+/** The Markdown that shows an attachment. */
77+export function imageMarkdown(name: string, url: string): string {
78+ return `![${altText(name)}](${url})`;
79+}
80+
81+/**
82+ * The placeholder for an image while it uploads, one no other in `text`
83+ * is: a second `image.png` is `image.png (2)`, so each is replaced by its
84+ * own upload alone.
85+ */
86+export function placeholderFor(name: string, text: string): string {
87+ for (let n = 1; ; n++) {
88+ const shown = n === 1 ? name : `${name} (${n})`;
89+ const candidate = `![Uploading ${altText(shown)}…]()`;
90+ if (!text.includes(candidate)) return candidate;
91+ }
92+}
93+
94+/** Text put in at `start`..`end`, and where the caret goes after it. */
95+export type Insertion = { text: string; start: number; end: number; caret: number };
96+
97+/**
98+ * How `markdown` goes in at the selection `start`..`end` of `value`: on a
99+ * line of its own, as an image is shown, with a line break added before
100+ * or after it only where the text beside it needs one.
101+ */
102+export function insertion(value: string, start: number, end: number, markdown: string): Insertion {
103+ const before = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
104+ const after = end < value.length && value[end] !== "\n" ? "\n" : "";
105+ const text = `${before}${markdown}${after}`;
106+ return { text, start, end, caret: start + before.length + markdown.length };
107+}
108+
109+/**
110+ * What of `value` to replace to swap `placeholder` for `replacement`,
111+ * found where it is now (the person may have written around it); an
112+ * empty replacement takes the placeholder's line with it when it was
113+ * alone there. Null when it is gone (they deleted it), so nothing they
114+ * wrote is touched.
115+ */
116+export function placeholderRange(value: string, placeholder: string, replacement: string): { start: number; end: number } | null {
117+ let start = value.indexOf(placeholder);
118+ if (start < 0) return null;
119+ let end = start + placeholder.length;
120+ if (replacement === "" && (start === 0 || value[start - 1] === "\n") && value[end] === "\n") end += 1;
121+ else if (replacement === "" && value[start - 1] === "\n" && end === value.length) start -= 1;
122+ return { start, end };
123+}
124+
125+/**
126+ * Where a caret at `at` belongs once `start`..`end` became `length`
127+ * characters: where it was before them, as far along after them, and at
128+ * the end of them from inside (or right at the end of a placeholder, where
129+ * a paste leaves it), so nothing is left selected to type over.
130+ */
131+export function caretAfter(at: number, start: number, end: number, length: number): number {
132+ if (at <= start) return at;
133+ if (at > end) return at + length - (end - start);
134+ return start + length;
135+}
136+
137+/** `value` with `placeholder` replaced by `replacement`, as {@link placeholderRange} finds it. */
138+export function replacePlaceholder(value: string, placeholder: string, replacement: string): string {
139+ const at = placeholderRange(value, placeholder, replacement);
140+ return at ? value.slice(0, at.start) + replacement + value.slice(at.end) : value;
141+}
142+
143+/** A refusal from the upload route (the work service's code and message), as the box says it. */
144+export function uploadFailure(name: string, failure: { code?: string; message?: string } | null): string {
145+ switch (failure?.code) {
146+ case "too_large":
147+ return `${name} is over 10 MB, so it wasn't attached.`;
148+ case "unsupported_type":
149+ return `${name} isn't a PNG, JPEG, GIF or WebP image, so it wasn't attached.`;
150+ case "forbidden":
151+ case "not_found":
152+ case "unauthenticated":
153+ return `You can't attach images here, so ${name} wasn't attached.`;
154+ default:
155+ return failure?.message ? `${name} wasn't attached: ${failure.message}` : `${name} didn't upload. Try again in a moment.`;
156+ }
157+}
158+
159+/**
160+ * The reference a deferred image's Markdown holds until it is uploaded:
161+ * `attachment:<ref>`, a short random word so two never meet.
162+ */
163+export function deferredUrl(ref: string): string {
164+ return `attachment:${ref}`;
165+}
166+
167+/**
168+ * `body` with each deferred image's `attachment:<ref>` swapped for the
169+ * address it was given; one with no address (its upload failed) is taken
170+ * out, image and all.
171+ */
172+export function resolveDeferred(body: string, urls: ReadonlyMap<string, string | null>): string {
173+ return body.replace(/!\[((?:\\.|[^\]\\])*)\]\(attachment:([A-Za-z0-9_-]{1,64})\)\n?/g, (whole, alt: string, ref: string) => {
174+ if (!urls.has(ref)) return whole;
175+ const url = urls.get(ref);
176+ return url ? `![${alt}](${url})${whole.endsWith("\n") ? "\n" : ""}` : "";
177+ });
178+}
179+
180+/** The refs a body still holds, so an image whose Markdown was deleted is not uploaded. */
181+export function deferredRefs(body: string): Set<string> {
182+ return new Set([...body.matchAll(/\]\(attachment:([A-Za-z0-9_-]{1,64})\)/g)].map((match) => match[1]!));
183+}
+2−0
6868 route("-/hovercard/user/:username", "routes/hovercard-user.ts"),
6969 // A person's picture by username, for avatars drawn from a name alone.
7070 route("-/avatar/user/:username", "routes/avatar-user.ts"),
71+ // An image pasted, dropped or chosen in a comment box, kept for its Markdown.
72+ route("-/attachments", "routes/attachments.ts"),
7173 // Live notifications: each tab's feed socket, and the person's
7274 // notification settings as JSON (services/notify).
7375 route("-/live", "routes/notify/live.ts"),
+60−0
1+/**
2+ * An image pasted, dropped or chosen in a comment box (components/attach-images.tsx):
3+ * `POST /-/attachments?repo=<owner>/<name>&name=<file name>`, or
4+ * `?workspace=<slug>&number=<n>` for a work item, with the file as the
5+ * body. The work service checks the viewer may comment there and keeps it
6+ * (`upload_attachment`); the answer is its address on the usercontent
7+ * origin, where it is served, never on the site. An attachment's address is
8+ * 256 random bits, so a private repository's is seen only by whoever is
9+ * given its Markdown.
10+ */
11+import type { Route } from "./+types/attachments";
12+import { addresses } from "../lib/addresses.server";
13+import { MAX_ATTACHMENT_BYTES, imageMarkdown } from "../lib/attachments";
14+import { sniffImage, toBase64 } from "../lib/avatar-upload";
15+import { work } from "../lib/services.server";
16+import { assertSameOrigin, getViewer } from "../lib/session.server";
17+
18+type Answer = { ok: true; url: string; markdown: string; name: string } | { ok: false; error: { code: string; message: string } };
19+
20+const refused = (code: string, message: string, status: number) => Response.json({ ok: false, error: { code, message } } satisfies Answer, { status, headers: { "cache-control": "no-store" } });
21+
22+export async function action({ request, context }: Route.ActionArgs) {
23+ assertSameOrigin(request);
24+ // A fetch, not a page: an answer to say, never the sign-in page.
25+ const user = getViewer(context);
26+ if (!user) return refused("unauthenticated", "Sign in to attach images.", 401);
27+ const url = new URL(request.url);
28+ const repo = (url.searchParams.get("repo") ?? "").split("/");
29+ const workspace = url.searchParams.get("workspace") ?? "";
30+ const number = Number(url.searchParams.get("number"));
31+ const scope =
32+ repo.length === 2 && repo[0] && repo[1]
33+ ? { repo: { namespace: repo[0], name: repo[1] } }
34+ : workspace && Number.isInteger(number) && number > 0
35+ ? { workspace: workspace.toLowerCase(), number }
36+ : null;
37+ if (!scope) return refused("invalid", "Say which repository or work item it is for.", 400);
38+ const length = Number(request.headers.get("content-length") ?? "0");
39+ if (length > MAX_ATTACHMENT_BYTES) return refused("too_large", "An attachment can be at most 10 MB.", 413);
40+ const bytes = new Uint8Array(await request.arrayBuffer());
41+ if (bytes.byteLength === 0) return refused("invalid", "The file is empty.", 400);
42+ if (bytes.byteLength > MAX_ATTACHMENT_BYTES) return refused("too_large", "An attachment can be at most 10 MB.", 413);
43+ // The service reads the type from the bytes again and is the one that decides.
44+ if (!sniffImage(bytes)) return refused("unsupported_type", "An attachment must be a PNG, JPEG, GIF or WebP image.", 415);
45+ const name = url.searchParams.get("name") || "image.png";
46+ try {
47+ const kept = await work.uploadAttachment(user, scope, { name, data: toBase64(bytes) });
48+ if (!kept.ok) {
49+ const status = kept.error.code === "too_large" ? 413 : kept.error.code === "unsupported_type" ? 415 : kept.error.code === "not_found" ? 404 : 403;
50+ return refused(kept.error.code, kept.error.message, status);
51+ }
52+ const at = `${addresses().usercontent}${kept.value.path}`;
53+ return Response.json({ ok: true, url: at, markdown: imageMarkdown(kept.value.name, at), name: kept.value.name } satisfies Answer, {
54+ headers: { "cache-control": "no-store" },
55+ });
56+ } catch (error) {
57+ console.error("attachments: upload", error);
58+ return refused("unavailable", "The image didn't upload. Try again in a moment.", 503);
59+ }
60+}
+131−128
1616 import { CommitAvatars, CommitNames } from "../../components/commit-person";
1717 import { ErrorText, TimeAgo } from "../../components/ui";
1818 import { CommentForm, CommentList } from "../../components/work";
19+import { AttachImages } from "../../components/attach-images";
1920 import { ItemIcon } from "../../components/work-icons";
2021 import { Badge } from "../../components/ui/badge";
2122 import { Button } from "../../components/ui/button";
214215 const base = `/${params.owner}/${params.repo}`;
215216 const { subject, body, trailers } = parseCommitMessage(commit.message);
216217 return (
217− <div>
218− <Card asChild className="overflow-hidden">
219− <section>
220− <div className="p-5">
221− <p className="flex items-center gap-2 text-xs text-faint">
222− <GitCommitHorizontal size={14} />
223− <Link to={`${base}/commits`} className="hover:text-fg">
224− Commit
225− </Link>
226− </p>
227− <h2 className="mt-2 text-xl font-semibold tracking-tight text-balance">
228− <CommitText text={subject} base={base} />
229− </h2>
230− {body && (
231− <p className="mt-3 max-w-3xl text-sm whitespace-pre-wrap text-muted">
232− <CommitText text={body} base={base} />
218+ <AttachImages to={viewer ? { repo: `${params.owner}/${params.repo}` } : null}>
219+ <div>
220+ <Card asChild className="overflow-hidden">
221+ <section>
222+ <div className="p-5">
223+ <p className="flex items-center gap-2 text-xs text-faint">
224+ <GitCommitHorizontal size={14} />
225+ <Link to={`${base}/commits`} className="hover:text-fg">
226+ Commit
227+ </Link>
233228 </p>
234− )}
235− {trailers.length > 0 && (
236− <dl className="mt-3 space-y-0.5 font-mono text-xs text-faint">
237− {trailers.map((trailer) => (
238− <div key={`${trailer.key}:${trailer.value}`}>
239− <dt className="inline">{trailer.key}:</dt> <dd className="inline text-muted">{trailer.value}</dd>
240− </div>
241− ))}
242− </dl>
243− )}
244− </div>
245− <div className="flex flex-wrap items-center gap-x-5 gap-y-3 border-t border-line bg-bg/40 px-5 py-3 text-sm">
246− <span className="flex items-center gap-2">
247− <CommitAvatars commit={commit} size={20} />
248− <span className="font-medium">
249− <CommitNames commit={commit} all />
250− </span>
251− <span className="text-muted">
252− committed <TimeAgo at={commit.authoredAt} />
253− </span>
254− </span>
255− <span className="flex items-center gap-2 text-xs text-muted">
256− {commit.parents.length === 0 ? (
257− "The first commit"
258− ) : (
259− <>
260− {commit.parents.length === 1 ? "Parent" : "Parents"}
261− {commit.parents.map((parent) => (
262− <Link
263− key={parent}
264− to={`${base}/commit/${parent}`}
265− prefetch="intent"
266− className="font-mono text-fg/80 hover:text-accent hover:underline"
267− >
268− {parent.slice(0, 7)}
269− </Link>
229+ <h2 className="mt-2 text-xl font-semibold tracking-tight text-balance">
230+ <CommitText text={subject} base={base} />
231+ </h2>
232+ {body && (
233+ <p className="mt-3 max-w-3xl text-sm whitespace-pre-wrap text-muted">
234+ <CommitText text={body} base={base} />
235+ </p>
236+ )}
237+ {trailers.length > 0 && (
238+ <dl className="mt-3 space-y-0.5 font-mono text-xs text-faint">
239+ {trailers.map((trailer) => (
240+ <div key={`${trailer.key}:${trailer.value}`}>
241+ <dt className="inline">{trailer.key}:</dt> <dd className="inline text-muted">{trailer.value}</dd>
242+ </div>
270243 ))}
271− </>
244+ </dl>
272245 )}
273− </span>
274− <span className="ml-auto flex items-center gap-2">
275− <CommitChecksBadge checks={checks} sha={commit.hash} />
276− <Link
277− to={`${base}/tree/${commit.hash}/`}
278− className="rounded-md px-2 py-1 text-xs text-muted hover:bg-raised hover:text-fg"
279− >
280− Browse files
281− </Link>
282− <CopyHash hash={commit.hash} />
283− </span>
284− </div>
285− <Suspense
286− fallback={
287− <div aria-busy="true" className="flex h-12 items-center gap-3 border-t border-line px-4">
288− <Skeleton className="size-4 rounded-full" />
289− <Skeleton className="h-3 w-64 max-w-[60%]" />
290− </div>
291− }
292− >
293− <Await resolve={pull}>
294− {(pull) =>
295− isFailed(pull) ? (
296− <div className="border-t border-line px-5 py-3">
297− <Unavailable what={pull.what} compact />
298− </div>
299− ) : pull ? (
300− <MergedIn base={base} pull={pull} />
301− ) : null
302− }
303− </Await>
304− </Suspense>
305− {isFailed(closed) ? (
306− <div className="border-t border-line px-5 py-3">
307− <Unavailable what={closed.what} compact />
308246 </div>
247+ <div className="flex flex-wrap items-center gap-x-5 gap-y-3 border-t border-line bg-bg/40 px-5 py-3 text-sm">
248+ <span className="flex items-center gap-2">
249+ <CommitAvatars commit={commit} size={20} />
250+ <span className="font-medium">
251+ <CommitNames commit={commit} all />
252+ </span>
253+ <span className="text-muted">
254+ committed <TimeAgo at={commit.authoredAt} />
255+ </span>
256+ </span>
257+ <span className="flex items-center gap-2 text-xs text-muted">
258+ {commit.parents.length === 0 ? (
259+ "The first commit"
260+ ) : (
261+ <>
262+ {commit.parents.length === 1 ? "Parent" : "Parents"}
263+ {commit.parents.map((parent) => (
264+ <Link
265+ key={parent}
266+ to={`${base}/commit/${parent}`}
267+ prefetch="intent"
268+ className="font-mono text-fg/80 hover:text-accent hover:underline"
269+ >
270+ {parent.slice(0, 7)}
271+ </Link>
272+ ))}
273+ </>
274+ )}
275+ </span>
276+ <span className="ml-auto flex items-center gap-2">
277+ <CommitChecksBadge checks={checks} sha={commit.hash} />
278+ <Link
279+ to={`${base}/tree/${commit.hash}/`}
280+ className="rounded-md px-2 py-1 text-xs text-muted hover:bg-raised hover:text-fg"
281+ >
282+ Browse files
283+ </Link>
284+ <CopyHash hash={commit.hash} />
285+ </span>
286+ </div>
287+ <Suspense
288+ fallback={
289+ <div aria-busy="true" className="flex h-12 items-center gap-3 border-t border-line px-4">
290+ <Skeleton className="size-4 rounded-full" />
291+ <Skeleton className="h-3 w-64 max-w-[60%]" />
292+ </div>
293+ }
294+ >
295+ <Await resolve={pull}>
296+ {(pull) =>
297+ isFailed(pull) ? (
298+ <div className="border-t border-line px-5 py-3">
299+ <Unavailable what={pull.what} compact />
300+ </div>
301+ ) : pull ? (
302+ <MergedIn base={base} pull={pull} />
303+ ) : null
304+ }
305+ </Await>
306+ </Suspense>
307+ {isFailed(closed) ? (
308+ <div className="border-t border-line px-5 py-3">
309+ <Unavailable what={closed.what} compact />
310+ </div>
311+ ) : (
312+ closed.map((item) => <ClosedItemRow key={`${item.workspace}#${item.number}`} item={item} />)
313+ )}
314+ </section>
315+ </Card>
316+ <div className="mt-6">
317+ <DiffView
318+ comparison={comparison}
319+ repo={base}
320+ empty="This commit changes no files."
321+ fileBase={`${base}/blob/${commit.hash}`}
322+ review={{ comments: said.filter((comment) => comment.path), canComment: viewer != null && commentsOn }}
323+ />
324+ </div>
325+ <section aria-labelledby="commit-comments" className="mt-10 space-y-4">
326+ <h3 id="commit-comments" className="flex items-center gap-2 text-sm font-medium">
327+ <MessagesSquare size={15} className="text-faint" />
328+ {general.length === 0 ? "Comments" : `${general.length} ${general.length === 1 ? "comment" : "comments"}`}
329+ {said.length > general.length && (
330+ <span className="font-normal text-faint">
331+ · {said.length - general.length} on lines above
332+ </span>
333+ )}
334+ </h3>
335+ {isFailed(comments) && <Unavailable what={comments.what} />}
336+ <CommentList
337+ comments={general}
338+ base={base}
339+ // Commit comments name their author by username.
340+ viewerId={viewer?.username ?? null}
341+ canModerate={canModerate}
342+ failed={actionData && "comment" in actionData ? { comment: String(actionData.comment), error: actionData.error } : null}
343+ />
344+ {commentsOn ? (
345+ <CommentForm author={viewer?.username ?? null} resetKey={said.length} />
309346 ) : (
310− closed.map((item) => <ClosedItemRow key={`${item.workspace}#${item.number}`} item={item} />)
347+ <Card className="flex items-center gap-2.5 px-4 py-3 text-sm text-muted">
348+ <MessageSquareOff size={15} className="shrink-0 text-faint" />
349+ Comments on commits are turned off in this repository.
350+ </Card>
311351 )}
352+ {actionData?.action === "comment" && <ErrorText>{actionData.error}</ErrorText>}
312353 </section>
313− </Card>
314− <div className="mt-6">
315− <DiffView
316− comparison={comparison}
317− repo={base}
318− empty="This commit changes no files."
319− fileBase={`${base}/blob/${commit.hash}`}
320− review={{ comments: said.filter((comment) => comment.path), canComment: viewer != null && commentsOn }}
321− />
322354 </div>
323− <section aria-labelledby="commit-comments" className="mt-10 space-y-4">
324− <h3 id="commit-comments" className="flex items-center gap-2 text-sm font-medium">
325− <MessagesSquare size={15} className="text-faint" />
326− {general.length === 0 ? "Comments" : `${general.length} ${general.length === 1 ? "comment" : "comments"}`}
327− {said.length > general.length && (
328− <span className="font-normal text-faint">
329− · {said.length - general.length} on lines above
330− </span>
331− )}
332− </h3>
333− {isFailed(comments) && <Unavailable what={comments.what} />}
334− <CommentList
335− comments={general}
336− base={base}
337− // Commit comments name their author by username.
338− viewerId={viewer?.username ?? null}
339− canModerate={canModerate}
340− failed={actionData && "comment" in actionData ? { comment: String(actionData.comment), error: actionData.error } : null}
341− />
342− {commentsOn ? (
343− <CommentForm author={viewer?.username ?? null} resetKey={said.length} />
344− ) : (
345− <Card className="flex items-center gap-2.5 px-4 py-3 text-sm text-muted">
346− <MessageSquareOff size={15} className="shrink-0 text-faint" />
347− Comments on commits are turned off in this repository.
348− </Card>
349− )}
350− {actionData?.action === "comment" && <ErrorText>{actionData.error}</ErrorText>}
351− </section>
352− </div>
355+ </AttachImages>
353356 );
354357 }
+6−3
1313 ErrorText,
1414 Field,
1515 Input,
16− Textarea,
1716 } from "../../components/ui";
1817 import { Card } from "../../components/ui/card";
1918 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
2120 import { repos, work } from "../../lib/services.server";
2221 import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
2322 import { Unavailable } from "../../components/boundary";
23+import { AttachImages } from "../../components/attach-images";
24+import { MentionTextarea } from "../../components/mention-textarea";
2425 import { type Failed, isFailed, part } from "../../lib/partial";
2526
2627 export function meta({ params, ...args }: Route.MetaArgs) {
249250 <Field label="Title">
250251 <Input name="title" required autoFocus maxLength={200} />
251252 </Field>
252− <Field label="Description" hint="What changed and why. Markdown works.">
253− <Textarea name="body" rows={8} />
253+ <Field label="Description" htmlFor="pull-body" hint="What changed and why. Markdown works.">
254+ <AttachImages to={{ repo: self }}>
255+ <MentionTextarea id="pull-body" name="body" rows={8} />
256+ </AttachImages>
254257 </Field>
255258 <Field
256259 label="Work item (optional)"
+835−832
A large diff (1,739 lines) is not shown until you ask for it.
+408−405
2929 import { CheckBadge } from "../../../components/checks";
3030 import { LabelChip, LabelsBox, MilestoneBox } from "../../../components/labels";
3131 import { Assignee, ChangeSize, CommentForm, CommentList, ItemState, PeoplePicker, PersonLink, PullIcon, plainText } from "../../../components/work";
32+import { AttachImages } from "../../../components/attach-images";
3233 import { RoleBadge } from "../../../components/work-items";
3334 import { DetailsDisclosure, detailsSummary } from "../../../components/details-disclosure";
3435 import { DecisionList, IntegrationFailure, ItemWorkPanel, StartSessionDialog, TimelineSession } from "../../../components/coordination";
542543 // On a phone: the title, then the item, its repositories and the
543544 // conversation, then the sidebar as a Details disclosure; beside it
544545 // from lg up. Its main actions sit at the bottom, within a thumb's reach.
545− <div className="grid gap-x-8 gap-y-5 lg:grid-cols-[1fr_19rem] lg:grid-rows-[auto_1fr] lg:gap-y-0">
546− <header className="min-w-0 lg:col-start-1">
547− <h1 className="text-xl font-semibold tracking-tight text-balance wrap-break-word sm:text-2xl">
548− {item.title} <span className="font-normal text-faint">#{item.number}</span>
549− </h1>
550− <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted">
551− <ItemState item={item} />
552− <span className="flex min-w-0 items-center gap-2">
553− <Avatar name={item.author.username} size={18} />
554− <span className="min-w-0">
555− <PersonLink name={item.author.username} className="font-medium text-fg hover:underline" /> opened this{" "}
556− {item.requestedBy && (
557− <>
558− for <PersonLink name={item.requestedBy.username} className="font-medium text-fg-soft hover:underline" />{" "}
559− </>
560− )}
561− <TimeAgo at={item.createdAt} />
546+ <AttachImages to={viewer ? { workspace, number: item.number } : null}>
547+ <div className="grid gap-x-8 gap-y-5 lg:grid-cols-[1fr_19rem] lg:grid-rows-[auto_1fr] lg:gap-y-0">
548+ <header className="min-w-0 lg:col-start-1">
549+ <h1 className="text-xl font-semibold tracking-tight text-balance wrap-break-word sm:text-2xl">
550+ {item.title} <span className="font-normal text-faint">#{item.number}</span>
551+ </h1>
552+ <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted">
553+ <ItemState item={item} />
554+ <span className="flex min-w-0 items-center gap-2">
555+ <Avatar name={item.author.username} size={18} />
556+ <span className="min-w-0">
557+ <PersonLink name={item.author.username} className="font-medium text-fg hover:underline" /> opened this{" "}
558+ {item.requestedBy && (
559+ <>
560+ for <PersonLink name={item.requestedBy.username} className="font-medium text-fg-soft hover:underline" />{" "}
561+ </>
562+ )}
563+ <TimeAgo at={item.createdAt} />
564+ </span>
562565 </span>
563− </span>
564− {item.labels.map((name) => (
565− // Without the workspace's labels, a chip still names its label, in the plain color.
566− <LabelChip key={name} name={name} color={isFailed(labels) ? undefined : labels.find((label) => label.name === name)?.color} />
567− ))}
568− {open && item.agent && <Assignee agent={item.agent} />}
569− </div>
570− </header>
571−
572− <div className="min-w-0 max-lg:order-1 lg:col-start-1">
573− {item.resolutions.length > 0 && (
574− <ul className="space-y-2 lg:mt-5">
575− {item.resolutions.map((landed, index) => (
576− <li key={`${landed.repo}-${landed.pull ?? landed.commit ?? index}`}>
577− <Link
578− to={landed.pull != null ? `/${landed.repo}/pull/${landed.pull}` : `/${landed.repo}/commit/${landed.commit ?? ""}`}
579− className="flex items-start gap-3 rounded-xl border border-merged/40 bg-merged/5 px-4 py-3 text-sm transition-colors hover:border-merged/70"
580− >
581− {landed.pull != null ? <GitMerge size={18} className="mt-0.5 shrink-0 text-merged" /> : <GitCommitHorizontal size={18} className="mt-0.5 shrink-0 text-merged" />}
582− <span className="min-w-0">
583− Landed in <span className="font-mono font-medium">{landed.repo}</span> by{" "}
584− {landed.pull != null ? (
585− <span className="font-medium">#{landed.pull}</span>
586− ) : (
587− <span className="font-mono font-medium">{landed.commit?.slice(0, 7)}</span>
588− )}
589− {landed.pushedBy && <span className="text-muted">, pushed by {landed.pushedBy}</span>}
590− </span>
591− </Link>
592− </li>
566+ {item.labels.map((name) => (
567+ // Without the workspace's labels, a chip still names its label, in the plain color.
568+ <LabelChip key={name} name={name} color={isFailed(labels) ? undefined : labels.find((label) => label.name === name)?.color} />
593569 ))}
594− </ul>
595− )}
596−
597− {item.body ? (
598− <div className={`${item.resolutions.length > 0 ? "mt-5" : "lg:mt-5"} rounded-xl border border-line bg-surface p-4 sm:p-5`}>
599− <Markdown source={item.body} workspace={workspace} />
600− <Reactions reactions={loaderData.reactions} viewer={viewer?.username ?? null} className="mt-4" />
570+ {open && item.agent && <Assignee agent={item.agent} />}
601571 </div>
602− ) : (
603− (loaderData.reactions?.length ?? 0) > 0 && <Reactions reactions={loaderData.reactions} viewer={viewer?.username ?? null} className="mt-3 lg:mt-5" />
604− )}
572+ </header>
605573
606− <section aria-labelledby="repositories" className="mt-8">
607− <div className="flex flex-wrap items-center justify-between gap-2">
608− <h2 id="repositories" className="font-semibold tracking-tight">
609− Repositories
610− </h2>
611− <span className="flex items-center gap-3">
612− {missing.length > 0 && open && (
613− <span className="text-sm text-muted">
614− {missing.length} {missing.length === 1 ? "target" : "targets"} still to land
615− </span>
616− )}
617− {canLink && (
618− <span className="max-sm:hidden">
619− <LinkRepository workspace={workspace} repos={loaderData.repos} linked={item.repos.map((repo) => repo.path.toLowerCase())} error={linkError} />
620− </span>
621− )}
622− </span>
623− </div>
624− {isFailed(loaderData.coordination) && (
625− <div className="mt-2">
626− <Unavailable what="Each repository's pull requests" compact />
627− </div>
574+ <div className="min-w-0 max-lg:order-1 lg:col-start-1">
575+ {item.resolutions.length > 0 && (
576+ <ul className="space-y-2 lg:mt-5">
577+ {item.resolutions.map((landed, index) => (
578+ <li key={`${landed.repo}-${landed.pull ?? landed.commit ?? index}`}>
579+ <Link
580+ to={landed.pull != null ? `/${landed.repo}/pull/${landed.pull}` : `/${landed.repo}/commit/${landed.commit ?? ""}`}
581+ className="flex items-start gap-3 rounded-xl border border-merged/40 bg-merged/5 px-4 py-3 text-sm transition-colors hover:border-merged/70"
582+ >
583+ {landed.pull != null ? <GitMerge size={18} className="mt-0.5 shrink-0 text-merged" /> : <GitCommitHorizontal size={18} className="mt-0.5 shrink-0 text-merged" />}
584+ <span className="min-w-0">
585+ Landed in <span className="font-mono font-medium">{landed.repo}</span> by{" "}
586+ {landed.pull != null ? (
587+ <span className="font-medium">#{landed.pull}</span>
588+ ) : (
589+ <span className="font-mono font-medium">{landed.commit?.slice(0, 7)}</span>
590+ )}
591+ {landed.pushedBy && <span className="text-muted">, pushed by {landed.pushedBy}</span>}
592+ </span>
593+ </Link>
594+ </li>
595+ ))}
596+ </ul>
628597 )}
629− {repoRows.length === 0 ? (
630− <div className="mt-3">
631− <EmptyState title="No repository yet">
632− Filed in the workspace itself. Link the repositories it should land in, and it closes once each of them has.
633− </EmptyState>
598+
599+ {item.body ? (
600+ <div className={`${item.resolutions.length > 0 ? "mt-5" : "lg:mt-5"} rounded-xl border border-line bg-surface p-4 sm:p-5`}>
601+ <Markdown source={item.body} workspace={workspace} />
602+ <Reactions reactions={loaderData.reactions} viewer={viewer?.username ?? null} className="mt-4" />
634603 </div>
635604 ) : (
636− <Card asChild divided className="mt-3">
637− <ul>
638− {repoRows.map(({ repo, pulls: repoPulls }) => (
639− <RepoCard key={repo.repoId} repo={repo} pulls={repoPulls} full={full} canLink={canLink} />
640− ))}
641− </ul>
642− </Card>
605+ (loaderData.reactions?.length ?? 0) > 0 && <Reactions reactions={loaderData.reactions} viewer={viewer?.username ?? null} className="mt-3 lg:mt-5" />
643606 )}
644− {suggestions.length > 0 && open && (
645− <>
646− <h3 className="mt-5 text-sm font-medium">Suggested</h3>
647− <p className="mt-0.5 text-xs text-muted">Repositories it may also need, and why.</p>
648− <Card asChild divided className="mt-2">
607+
608+ <section aria-labelledby="repositories" className="mt-8">
609+ <div className="flex flex-wrap items-center justify-between gap-2">
610+ <h2 id="repositories" className="font-semibold tracking-tight">
611+ Repositories
612+ </h2>
613+ <span className="flex items-center gap-3">
614+ {missing.length > 0 && open && (
615+ <span className="text-sm text-muted">
616+ {missing.length} {missing.length === 1 ? "target" : "targets"} still to land
617+ </span>
618+ )}
619+ {canLink && (
620+ <span className="max-sm:hidden">
621+ <LinkRepository workspace={workspace} repos={loaderData.repos} linked={item.repos.map((repo) => repo.path.toLowerCase())} error={linkError} />
622+ </span>
623+ )}
624+ </span>
625+ </div>
626+ {isFailed(loaderData.coordination) && (
627+ <div className="mt-2">
628+ <Unavailable what="Each repository's pull requests" compact />
629+ </div>
630+ )}
631+ {repoRows.length === 0 ? (
632+ <div className="mt-3">
633+ <EmptyState title="No repository yet">
634+ Filed in the workspace itself. Link the repositories it should land in, and it closes once each of them has.
635+ </EmptyState>
636+ </div>
637+ ) : (
638+ <Card asChild divided className="mt-3">
649639 <ul>
650− {suggestions.map((suggestion) => (
651− <SuggestionLine key={suggestion.repoId} suggestion={suggestion} workspace={workspace} canLink={canLink} />
640+ {repoRows.map(({ repo, pulls: repoPulls }) => (
641+ <RepoCard key={repo.repoId} repo={repo} pulls={repoPulls} full={full} canLink={canLink} />
652642 ))}
653643 </ul>
654644 </Card>
655− </>
656− )}
657− {linkError && (
658− <div className="mt-2">
659− <ErrorText>{linkError}</ErrorText>
645+ )}
646+ {suggestions.length > 0 && open && (
647+ <>
648+ <h3 className="mt-5 text-sm font-medium">Suggested</h3>
649+ <p className="mt-0.5 text-xs text-muted">Repositories it may also need, and why.</p>
650+ <Card asChild divided className="mt-2">
651+ <ul>
652+ {suggestions.map((suggestion) => (
653+ <SuggestionLine key={suggestion.repoId} suggestion={suggestion} workspace={workspace} canLink={canLink} />
654+ ))}
655+ </ul>
656+ </Card>
657+ </>
658+ )}
659+ {linkError && (
660+ <div className="mt-2">
661+ <ErrorText>{linkError}</ErrorText>
662+ </div>
663+ )}
664+ </section>
665+
666+ {pulls.length === 0 && open && (
667+ <div className="mt-6">
668+ <EmptyState title="Nobody has worked on this yet">
669+ Point your own agent at <code className="font-mono">{reference}</code>.
670+ </EmptyState>
660671 </div>
661672 )}
662− </section>
663673
664− {pulls.length === 0 && open && (
665− <div className="mt-6">
666− <EmptyState title="Nobody has worked on this yet">
667− Point your own agent at <code className="font-mono">{reference}</code>.
668− </EmptyState>
669− </div>
670− )}
671−
672− {failure && <IntegrationFailure className="mt-4" failure={failure} base={failureBase} pullHref={pullHref} fixed={resolution?.passed ?? resolution?.fix} />}
674+ {failure && <IntegrationFailure className="mt-4" failure={failure} base={failureBase} pullHref={pullHref} fixed={resolution?.passed ?? resolution?.fix} />}
673675
674− {isFailed(loaderData.coordination) ? (
675− <>
676− <h2 className="mt-10 font-semibold tracking-tight">Decisions</h2>
677− <div className="mt-3">
678− <Unavailable what="Its decisions" />
679− </div>
680− </>
681− ) : (
682− decisions.length > 0 && (
676+ {isFailed(loaderData.coordination) ? (
683677 <>
684− <div className="mt-10 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
685− <h2 className="font-semibold tracking-tight">Decisions</h2>
686− <p className="text-sm text-muted">Why the work is the way it is, and how sure that is</p>
687− </div>
678+ <h2 className="mt-10 font-semibold tracking-tight">Decisions</h2>
688679 <div className="mt-3">
689− <DecisionList decisions={decisions} base={failureBase} pullHref={pullHref} />
680+ <Unavailable what="Its decisions" />
690681 </div>
691682 </>
692− )
693− )}
683+ ) : (
684+ decisions.length > 0 && (
685+ <>
686+ <div className="mt-10 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
687+ <h2 className="font-semibold tracking-tight">Decisions</h2>
688+ <p className="text-sm text-muted">Why the work is the way it is, and how sure that is</p>
689+ </div>
690+ <div className="mt-3">
691+ <DecisionList decisions={decisions} base={failureBase} pullHref={pullHref} />
692+ </div>
693+ </>
694+ )
695+ )}
694696
695− <h2 className="mt-10 font-semibold tracking-tight">Discussion</h2>
696− <div className="mt-3">
697− <div className="space-y-4">
698− {isFailed(loaderData.sessionTrees) && <Unavailable what="Its sessions" compact />}
699− <CommentList
700− comments={comments}
701− workspace={workspace}
702− viewerId={viewer?.id ?? null}
703− viewerName={viewer?.username ?? null}
704− extra={(sessionTrees ?? [])
705− .filter((session) => !session.parentId)
706− .map((session) => ({ id: session.id, at: session.startedAt, node: <TimelineSession session={session} base={sessionBase(session)} /> }))}
707− canModerate={loaderData.canModerate}
708− failed={actionData && "comment" in actionData ? { comment: String(actionData.comment), error: actionData.error } : null}
709− />
710− <div id="comment">
711− <CommentForm author={viewer?.username ?? null} resetKey={comments.length} handles={loaderData.handles} />
712− </div>
713− {canManage && (
714− <Form method="post" className="flex flex-wrap justify-end gap-2 max-sm:hidden">
715− {open ? (
716− <>
717− <SubmitButton variant="outline" name="action" value="close-not-planned" pending="Closing…">
718− Close as not planned
719− </SubmitButton>
720− <SubmitButton variant="outline" name="action" value="close-completed" pending="Closing…">
721− Close work item
697+ <h2 className="mt-10 font-semibold tracking-tight">Discussion</h2>
698+ <div className="mt-3">
699+ <div className="space-y-4">
700+ {isFailed(loaderData.sessionTrees) && <Unavailable what="Its sessions" compact />}
701+ <CommentList
702+ comments={comments}
703+ workspace={workspace}
704+ viewerId={viewer?.id ?? null}
705+ viewerName={viewer?.username ?? null}
706+ extra={(sessionTrees ?? [])
707+ .filter((session) => !session.parentId)
708+ .map((session) => ({ id: session.id, at: session.startedAt, node: <TimelineSession session={session} base={sessionBase(session)} /> }))}
709+ canModerate={loaderData.canModerate}
710+ failed={actionData && "comment" in actionData ? { comment: String(actionData.comment), error: actionData.error } : null}
711+ />
712+ <div id="comment">
713+ <CommentForm author={viewer?.username ?? null} resetKey={comments.length} handles={loaderData.handles} />
714+ </div>
715+ {canManage && (
716+ <Form method="post" className="flex flex-wrap justify-end gap-2 max-sm:hidden">
717+ {open ? (
718+ <>
719+ <SubmitButton variant="outline" name="action" value="close-not-planned" pending="Closing…">
720+ Close as not planned
721+ </SubmitButton>
722+ <SubmitButton variant="outline" name="action" value="close-completed" pending="Closing…">
723+ Close work item
724+ </SubmitButton>
725+ </>
726+ ) : (
727+ <SubmitButton variant="outline" name="action" value="reopen" pending="Reopening…">
728+ Reopen work item
722729 </SubmitButton>
723− </>
724− ) : (
725− <SubmitButton variant="outline" name="action" value="reopen" pending="Reopening…">
726− Reopen work item
727− </SubmitButton>
728− )}
729− </Form>
730− )}
730+ )}
731+ </Form>
732+ )}
733+ </div>
731734 </div>
732− </div>
733− <div className="mt-2">
734− {/* A refused comment edit says so under the comment; the sidebar's own say so there. */}
735− {!(actionData && ("comment" in actionData || sideError)) && <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>}
736− </div>
737−
738− {/* On a phone, its main actions at the bottom, above the tab bar and clear of the keyboard. */}
739− {(canManage || canLink) && (
740− <div className="sticky bottom-(--tabbar-h) z-10 -mx-4 mt-6 flex items-center gap-2 border-t border-line bg-bg/90 px-4 py-3 backdrop-blur in-data-[keyboard=open]:hidden sm:hidden">
741− {canLink && (
742− <div className="min-w-0 grow">
743− <LinkRepository workspace={workspace} repos={loaderData.repos} linked={item.repos.map((repo) => repo.path.toLowerCase())} error={null} wide />
744− </div>
745− )}
746− {canManage && (
747− <Form method="post" className="min-w-0 grow *:h-11 *:w-full">
748− {open ? (
749− <SubmitButton variant="outline" name="action" value="close-completed" pending="Closing…">
750− Close
751− </SubmitButton>
752− ) : (
753− <SubmitButton variant="outline" name="action" value="reopen" pending="Reopening…">
754− Reopen
755− </SubmitButton>
756− )}
757− </Form>
758− )}
735+ <div className="mt-2">
736+ {/* A refused comment edit says so under the comment; the sidebar's own say so there. */}
737+ {!(actionData && ("comment" in actionData || sideError)) && <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>}
759738 </div>
760− )}
761− </div>
762739
763− <DetailsDisclosure
764− className="max-lg:order-2 lg:col-start-2 lg:row-span-2 lg:row-start-1"
765− summary={detailsSummary([
766− [coordination?.claims.length ?? 0, "claim", "claims"],
767− [item.assignees.length, "assignee", "assignees"],
768− [item.labels.length, "label", "labels"],
769− [item.milestone ? 1 : 0, "milestone", "milestones"],
770− ])}
771− >
772− {isFailed(loaderData.coordination) ? (
773− <section>
774− <h3 className="text-sm font-medium">Work</h3>
775− <div className="mt-2">
776− <Unavailable what="Who is on it" compact />
777− </div>
778− </section>
779− ) : (
780− coordination && (
781− <Boundary what="Work" fallback={<Unavailable what="Who is on it" compact />}>
782− <ItemWorkPanel
783− work={coordination}
784− ended={loaderData.endedClaims}
785− base={sessionBase}
786− now={now}
787− canEdit={canManage}
788− error={actionData && "action" in actionData && actionData.action === "blocked-by" ? actionData.error : null}
789− sessions={sessionTrees}
790− start={
791− loaderData.agentSetup && open && workIn ? (
792− <StartSessionDialog
793− setup={loaderData.agentSetup}
794− repo={workIn}
795− error={actionData && "action" in actionData && actionData.action === "start-session" ? actionData.error : null}
796− />
797− ) : undefined
798− }
799− />
800− </Boundary>
801− )
802− )}
803− {isFailed(loaderData.links) ? (
804− <section>
805− <h3 className="text-sm font-medium">From outside g1t</h3>
806− <div className="mt-2">
807− <Unavailable what="Its links" compact />
808− </div>
809− </section>
810− ) : (
811− loaderData.links.length > 0 && (
812− <section>
813− <h3 className="text-sm font-medium">From outside g1t</h3>
814− <ul className="mt-2 space-y-1.5 text-sm">
815− {loaderData.links.map((link) => (
816− <li key={`${link.connectionId}-${link.key}`}>
817− <a
818− href={link.url}
819− target="_blank"
820− rel="noreferrer"
821− className="group flex items-center gap-2 rounded-lg border border-line px-2.5 py-2 transition-colors hover:border-line-strong"
822− >
823− <span className="min-w-0 grow">
824− <span className="block truncate font-medium">
825− {PROVIDERS[link.provider].label} <span className="font-mono text-muted">{link.key}</span>
826− </span>
827− <span className="block text-xs text-faint">
828− {link.count > 1 ? `Seen ${link.count} times, last ` : "Linked "}
829− <TimeAgo at={link.count > 1 ? link.lastSeen : link.firstSeen} />
830− </span>
831− </span>
832− <ExternalLink size={13} className="shrink-0 text-faint group-hover:text-fg" />
833− </a>
834− </li>
835− ))}
836− </ul>
837− </section>
838− )
839− )}
840− <section>
841− <h3 className="text-sm font-medium">Assignees</h3>
842− <ul className="mt-2 space-y-1.5 text-sm">
843− {item.assignees.map((name) => (
844− <li key={name} className="flex items-center gap-2 px-1">
845− <Avatar name={name} size={20} agent={name === "g1t"} />
846− <span className="grow truncate font-mono text-xs">{name === "g1t" ? "g1t" : <PersonLink name={name} className="hover:underline" />}</span>
847− </li>
848− ))}
849− {item.assignees.length === 0 && <li className="px-1 text-xs text-faint">Nobody yet</li>}
850− </ul>
851− {!coordination && item.blockedBy.length > 0 && (
852− <p className="mt-3 text-xs text-muted">
853− Depends on{" "}
854− {item.blockedBy.map((number, index) => (
855− <span key={number}>
856− {index > 0 && ", "}
857− <Link to={itemPath(workspace, number)} className="font-medium text-fg hover:underline">
858− #{number}
859− </Link>
860− </span>
861− ))}
862− {open ? ", which has to land first." : "."}
863− </p>
864− )}
865− {viewer && can.triage && open && (
866− <div className="mt-3 space-y-2">
867− {/* g1t takes it on in a session on the workspace's Claude, as whoever assigned it. */}
868− {loaderData.agentSetup && !item.assignees.includes("g1t") && (
869− <Form method="post">
870− <input type="hidden" name="action" value="assign" />
871− {item.assignees.map((name) => (
872− <input key={name} type="hidden" name="assignee" value={name} />
873− ))}
874− <input type="hidden" name="assignee" value="g1t" />
875− <div className="*:w-full">
876− <SubmitButton variant="outline" name="who" value="g1t" pending="Assigning…">
877− <Bot size={14} />
878− Assign to g1t
740+ {/* On a phone, its main actions at the bottom, above the tab bar and clear of the keyboard. */}
741+ {(canManage || canLink) && (
742+ <div className="sticky bottom-(--tabbar-h) z-10 -mx-4 mt-6 flex items-center gap-2 border-t border-line bg-bg/90 px-4 py-3 backdrop-blur in-data-[keyboard=open]:hidden sm:hidden">
743+ {canLink && (
744+ <div className="min-w-0 grow">
745+ <LinkRepository workspace={workspace} repos={loaderData.repos} linked={item.repos.map((repo) => repo.path.toLowerCase())} error={null} wide />
746+ </div>
747+ )}
748+ {canManage && (
749+ <Form method="post" className="min-w-0 grow *:h-11 *:w-full">
750+ {open ? (
751+ <SubmitButton variant="outline" name="action" value="close-completed" pending="Closing…">
752+ Close
879753 </SubmitButton>
880− </div>
881− </Form>
882− )}
883− {!item.assignees.includes(viewer.username) && (
884− <Form method="post">
885− <input type="hidden" name="action" value="assign" />
886− {item.assignees.map((name) => (
887− <input key={name} type="hidden" name="assignee" value={name} />
888− ))}
889− <input type="hidden" name="assignee" value={viewer.username} />
890− <div className="*:w-full">
891− <SubmitButton variant="outline" name="who" value="self" pending="Assigning…">
892− Assign yourself
754+ ) : (
755+ <SubmitButton variant="outline" name="action" value="reopen" pending="Reopening…">
756+ Reopen
893757 </SubmitButton>
894− </div>
758+ )}
895759 </Form>
896760 )}
897− <details className="group">
898− <summary className="cursor-pointer text-xs text-faint hover:text-fg">Assign people</summary>
899− <Form method="post" className="mt-2 space-y-2" key={item.assignees.join()}>
900− <input type="hidden" name="action" value="assign" />
901− {/* Without the workspace's people the picker still holds who is
902− assigned now, so saving never clears them. */}
903− {isFailed(members) && <Unavailable what="The workspace's people" compact />}
904− <PeoplePicker name="assignee" members={isFailed(members) ? [] : members} chosen={item.assignees} />
905− <SubmitButton variant="outline" name="who" value="picked" pending="Saving…">
906− Save assignees
907− </SubmitButton>
908− </Form>
909− </details>
910761 </div>
911762 )}
912− </section>
763+ </div>
913764
914− {/* Another way to start the work: not once a pull request is doing it. */}
915− {open && !pulls.some((pull) => pull.status === "open" || pull.status === "draft") && (
916− <Card asChild className="p-4">
765+ <DetailsDisclosure
766+ className="max-lg:order-2 lg:col-start-2 lg:row-span-2 lg:row-start-1"
767+ summary={detailsSummary([
768+ [coordination?.claims.length ?? 0, "claim", "claims"],
769+ [item.assignees.length, "assignee", "assignees"],
770+ [item.labels.length, "label", "labels"],
771+ [item.milestone ? 1 : 0, "milestone", "milestones"],
772+ ])}
773+ >
774+ {isFailed(loaderData.coordination) ? (
917775 <section>
918− <h3 className="text-sm font-medium">Bring your own agent</h3>
919− <p className="mt-1 text-xs text-muted">With g1t connected to your agent, ask it to work on this work item.</p>
920− <div className="mt-3">
921− <CopyLine text={reference} />
776+ <h3 className="text-sm font-medium">Work</h3>
777+ <div className="mt-2">
778+ <Unavailable what="Who is on it" compact />
922779 </div>
923− {viewer && item.repos.length > 0 ? (
924− <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4">
925− <input type="hidden" name="action" value="open-pull" />
926− <p className="text-xs text-muted">Or open a draft pull request yourself and get a fork to push to.</p>
927− <SelectField
928− name="repo"
929− aria-label="Repository"
930− defaultValue={workIn ?? item.repos[0]!.path}
931− options={item.repos.map((repo) => ({ value: repo.path, label: `${repo.path} (${ROLE_LABELS[repo.role].toLowerCase()})` }))}
932− />
933− <Input name="agent" placeholder="Who is working, e.g. claude-code" maxLength={60} />
934− <div className="*:w-full">
935− <SubmitButton match={{ action: "open-pull" }} pending="Opening…">
936− Open pull request
780+ </section>
781+ ) : (
782+ coordination && (
783+ <Boundary what="Work" fallback={<Unavailable what="Who is on it" compact />}>
784+ <ItemWorkPanel
785+ work={coordination}
786+ ended={loaderData.endedClaims}
787+ base={sessionBase}
788+ now={now}
789+ canEdit={canManage}
790+ error={actionData && "action" in actionData && actionData.action === "blocked-by" ? actionData.error : null}
791+ sessions={sessionTrees}
792+ start={
793+ loaderData.agentSetup && open && workIn ? (
794+ <StartSessionDialog
795+ setup={loaderData.agentSetup}
796+ repo={workIn}
797+ error={actionData && "action" in actionData && actionData.action === "start-session" ? actionData.error : null}
798+ />
799+ ) : undefined
800+ }
801+ />
802+ </Boundary>
803+ )
804+ )}
805+ {isFailed(loaderData.links) ? (
806+ <section>
807+ <h3 className="text-sm font-medium">From outside g1t</h3>
808+ <div className="mt-2">
809+ <Unavailable what="Its links" compact />
810+ </div>
811+ </section>
812+ ) : (
813+ loaderData.links.length > 0 && (
814+ <section>
815+ <h3 className="text-sm font-medium">From outside g1t</h3>
816+ <ul className="mt-2 space-y-1.5 text-sm">
817+ {loaderData.links.map((link) => (
818+ <li key={`${link.connectionId}-${link.key}`}>
819+ <a
820+ href={link.url}
821+ target="_blank"
822+ rel="noreferrer"
823+ className="group flex items-center gap-2 rounded-lg border border-line px-2.5 py-2 transition-colors hover:border-line-strong"
824+ >
825+ <span className="min-w-0 grow">
826+ <span className="block truncate font-medium">
827+ {PROVIDERS[link.provider].label} <span className="font-mono text-muted">{link.key}</span>
828+ </span>
829+ <span className="block text-xs text-faint">
830+ {link.count > 1 ? `Seen ${link.count} times, last ` : "Linked "}
831+ <TimeAgo at={link.count > 1 ? link.lastSeen : link.firstSeen} />
832+ </span>
833+ </span>
834+ <ExternalLink size={13} className="shrink-0 text-faint group-hover:text-fg" />
835+ </a>
836+ </li>
837+ ))}
838+ </ul>
839+ </section>
840+ )
841+ )}
842+ <section>
843+ <h3 className="text-sm font-medium">Assignees</h3>
844+ <ul className="mt-2 space-y-1.5 text-sm">
845+ {item.assignees.map((name) => (
846+ <li key={name} className="flex items-center gap-2 px-1">
847+ <Avatar name={name} size={20} agent={name === "g1t"} />
848+ <span className="grow truncate font-mono text-xs">{name === "g1t" ? "g1t" : <PersonLink name={name} className="hover:underline" />}</span>
849+ </li>
850+ ))}
851+ {item.assignees.length === 0 && <li className="px-1 text-xs text-faint">Nobody yet</li>}
852+ </ul>
853+ {!coordination && item.blockedBy.length > 0 && (
854+ <p className="mt-3 text-xs text-muted">
855+ Depends on{" "}
856+ {item.blockedBy.map((number, index) => (
857+ <span key={number}>
858+ {index > 0 && ", "}
859+ <Link to={itemPath(workspace, number)} className="font-medium text-fg hover:underline">
860+ #{number}
861+ </Link>
862+ </span>
863+ ))}
864+ {open ? ", which has to land first." : "."}
865+ </p>
866+ )}
867+ {viewer && can.triage && open && (
868+ <div className="mt-3 space-y-2">
869+ {/* g1t takes it on in a session on the workspace's Claude, as whoever assigned it. */}
870+ {loaderData.agentSetup && !item.assignees.includes("g1t") && (
871+ <Form method="post">
872+ <input type="hidden" name="action" value="assign" />
873+ {item.assignees.map((name) => (
874+ <input key={name} type="hidden" name="assignee" value={name} />
875+ ))}
876+ <input type="hidden" name="assignee" value="g1t" />
877+ <div className="*:w-full">
878+ <SubmitButton variant="outline" name="who" value="g1t" pending="Assigning…">
879+ <Bot size={14} />
880+ Assign to g1t
881+ </SubmitButton>
882+ </div>
883+ </Form>
884+ )}
885+ {!item.assignees.includes(viewer.username) && (
886+ <Form method="post">
887+ <input type="hidden" name="action" value="assign" />
888+ {item.assignees.map((name) => (
889+ <input key={name} type="hidden" name="assignee" value={name} />
890+ ))}
891+ <input type="hidden" name="assignee" value={viewer.username} />
892+ <div className="*:w-full">
893+ <SubmitButton variant="outline" name="who" value="self" pending="Assigning…">
894+ Assign yourself
895+ </SubmitButton>
896+ </div>
897+ </Form>
898+ )}
899+ <details className="group">
900+ <summary className="cursor-pointer text-xs text-faint hover:text-fg">Assign people</summary>
901+ <Form method="post" className="mt-2 space-y-2" key={item.assignees.join()}>
902+ <input type="hidden" name="action" value="assign" />
903+ {/* Without the workspace's people the picker still holds who is
904+ assigned now, so saving never clears them. */}
905+ {isFailed(members) && <Unavailable what="The workspace's people" compact />}
906+ <PeoplePicker name="assignee" members={isFailed(members) ? [] : members} chosen={item.assignees} />
907+ <SubmitButton variant="outline" name="who" value="picked" pending="Saving…">
908+ Save assignees
937909 </SubmitButton>
938− </div>
939− {actionData && "action" in actionData && actionData.action === "open-pull" && <ErrorText>{actionData.error}</ErrorText>}
940− {workIn && (
941− <p className="text-xs text-muted">
942− Already pushed a branch?{" "}
943− <Link to={`/${workIn}/pulls/new?item=${item.number}`} className="text-fg underline underline-offset-4">
944− Open a pull request from it
945− </Link>
946− .
910+ </Form>
911+ </details>
912+ </div>
913+ )}
914+ </section>
915+
916+ {/* Another way to start the work: not once a pull request is doing it. */}
917+ {open && !pulls.some((pull) => pull.status === "open" || pull.status === "draft") && (
918+ <Card asChild className="p-4">
919+ <section>
920+ <h3 className="text-sm font-medium">Bring your own agent</h3>
921+ <p className="mt-1 text-xs text-muted">With g1t connected to your agent, ask it to work on this work item.</p>
922+ <div className="mt-3">
923+ <CopyLine text={reference} />
924+ </div>
925+ {viewer && item.repos.length > 0 ? (
926+ <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4">
927+ <input type="hidden" name="action" value="open-pull" />
928+ <p className="text-xs text-muted">Or open a draft pull request yourself and get a fork to push to.</p>
929+ <SelectField
930+ name="repo"
931+ aria-label="Repository"
932+ defaultValue={workIn ?? item.repos[0]!.path}
933+ options={item.repos.map((repo) => ({ value: repo.path, label: `${repo.path} (${ROLE_LABELS[repo.role].toLowerCase()})` }))}
934+ />
935+ <Input name="agent" placeholder="Who is working, e.g. claude-code" maxLength={60} />
936+ <div className="*:w-full">
937+ <SubmitButton match={{ action: "open-pull" }} pending="Opening…">
938+ Open pull request
939+ </SubmitButton>
940+ </div>
941+ {actionData && "action" in actionData && actionData.action === "open-pull" && <ErrorText>{actionData.error}</ErrorText>}
942+ {workIn && (
943+ <p className="text-xs text-muted">
944+ Already pushed a branch?{" "}
945+ <Link to={`/${workIn}/pulls/new?item=${item.number}`} className="text-fg underline underline-offset-4">
946+ Open a pull request from it
947+ </Link>
948+ .
949+ </p>
950+ )}
951+ </Form>
952+ ) : (
953+ !viewer && (
954+ <p className="mt-4 border-t border-line pt-4 text-sm text-muted">
955+ <Link to="/login" className="text-fg underline underline-offset-4">
956+ Sign in
957+ </Link>{" "}
958+ to open a pull request.
947959 </p>
948− )}
949− </Form>
950− ) : (
951− !viewer && (
952− <p className="mt-4 border-t border-line pt-4 text-sm text-muted">
953− <Link to="/login" className="text-fg underline underline-offset-4">
954− Sign in
955− </Link>{" "}
956− to open a pull request.
957− </p>
958− )
959− )}
960− </section>
961− </Card>
962− )}
960+ )
961+ )}
962+ </section>
963+ </Card>
964+ )}
963965
964− {isFailed(labels) ? (
965− <SideUnavailable heading="Labels" what="Labels" />
966− ) : (
967− <LabelsBox
968− labels={labels}
969− chosen={item.labels}
970− canEdit={canManage}
971− canCreate={can.manage_labels}
972− manageUrl={can.manage_labels ? workPath(workspace, "labels") : undefined}
973− />
974− )}
975− {isFailed(loaderData.milestones) ? (
976− <SideUnavailable heading="Milestone" what="Milestones" />
977− ) : (
978− <MilestoneBox milestones={loaderData.milestones} current={item.milestone} canEdit={can.triage} base={workPath(workspace)} />
979− )}
980− {viewer &&
981− (isFailed(loaderData.subscription) ? (
982− <SideUnavailable heading="Notifications" what="Your notification settings" />
966+ {isFailed(labels) ? (
967+ <SideUnavailable heading="Labels" what="Labels" />
968+ ) : (
969+ <LabelsBox
970+ labels={labels}
971+ chosen={item.labels}
972+ canEdit={canManage}
973+ canCreate={can.manage_labels}
974+ manageUrl={can.manage_labels ? workPath(workspace, "labels") : undefined}
975+ />
976+ )}
977+ {isFailed(loaderData.milestones) ? (
978+ <SideUnavailable heading="Milestone" what="Milestones" />
983979 ) : (
984− <SubscriptionBox action={itemPath(workspace, item.number)} number={item.number} kind="item" subscription={loaderData.subscription} />
985− ))}
986− </DetailsDisclosure>
987− </div>
980+ <MilestoneBox milestones={loaderData.milestones} current={item.milestone} canEdit={can.triage} base={workPath(workspace)} />
981+ )}
982+ {viewer &&
983+ (isFailed(loaderData.subscription) ? (
984+ <SideUnavailable heading="Notifications" what="Your notification settings" />
985+ ) : (
986+ <SubscriptionBox action={itemPath(workspace, item.number)} number={item.number} kind="item" subscription={loaderData.subscription} />
987+ ))}
988+ </DetailsDisclosure>
989+ </div>
990+ </AttachImages>
988991 );
989992 }
990993
+43−4
Loading the diff…
+8−0
Loading the diff…
+18−0
Loading the diff…

Comments

Sign in to comment.