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

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

Showing a screenshot in a comment meant leaving the site for the CLI. Now every comment box takes one the way GitHub's do: paste it, drop the file on the box, or choose it with Attach an image under the box (the way in on a phone). While it uploads, `![Uploading name…]()` sits at the caret and the box stays editable; when it is kept, that placeholder alone becomes `![name](url)`, wherever the writing around it has moved it. A refusal takes the placeholder out and says why under the box. The form's buttons wait while an upload is going, so nothing is posted with a placeholder in it. It is added once, in MentionTextarea, inside an <AttachImages> that says where the images are kept: the repository on a pull request, a commit and a new pull request; the work item on its page. So comments, reviews, line comments, editing a comment, a pull request's description, and new pull requests and work items all get it; the plain Textarea boxes on those pages now use MentionTextarea. A new work item filed with no repository has nowhere to keep images until it is open, so, as `g1t work new --attach` does, they go with the form and the work item keeps them once it is. Closes #159

G1t-Session:
ses_01m575ycxgejntcr3stqnexj6w
syntaqxcommitted Parent5944752Browse files
10 files+1779−14290/10 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 }
+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
11 import { Download, Info } from "lucide-react";
22 import { Form, data, redirect } from "react-router";
33
4−import { PROVIDERS } from "@g1t/contracts";
4+import { PROVIDERS, type User, type WorkItem } from "@g1t/contracts";
55
66 import type { Route } from "./+types/new";
77 import { page } from "../../../lib/meta";
8−import { ErrorText, Field, Input, SubmitButton, Textarea } from "../../../components/ui";
8+import { ErrorText, Field, Input, SubmitButton } from "../../../components/ui";
9+import { AttachImages } from "../../../components/attach-images";
10+import { MentionTextarea } from "../../../components/mention-textarea";
911 import { Card } from "../../../components/ui/card";
1012 import { CheckboxOption } from "../../../components/ui/checkbox";
1113 import { SelectField } from "../../../components/ui/select";
1719 import { isFailed, part } from "../../../lib/partial";
1820 import { itemPath } from "../../../lib/work-items";
1921 import { workspaceProjects } from "../../../lib/workspace-projects.server";
22+import { addresses } from "../../../lib/addresses.server";
23+import { DEFERRED_FILE_FIELD, DEFERRED_REF_FIELD, deferredRefs, resolveDeferred } from "../../../lib/attachments";
24+import { toBase64 } from "../../../lib/avatar-upload";
2025
2126 export function meta({ params, ...args }: Route.MetaArgs) {
2227 return page(args, { title: `New work item · ${params.owner} · g1t` });
9297 milestone: Number(form.get("milestone")) || undefined,
9398 });
9499 if (!result.ok) return { error: result.error.message };
100+ await attachHeld(user, result.value, form);
95101 throw redirect(itemPath(result.value.workspace, result.value.number));
96102 }
97103
104+/**
105+ * Images put in the description of a work item filed with no repository
106+ * (components/attach-images.tsx, `later`): with nothing to keep them until
107+ * it was open, they came with the form as `![name](attachment:<ref>)`.
108+ * Now the work item keeps them, and its description gets their addresses,
109+ * as `g1t work new --attach` does. One that fails to upload is taken out
110+ * of the description; the box checked its type and size before it was sent.
111+ */
112+async function attachHeld(user: User, item: WorkItem, form: FormData) {
113+ const files = form.getAll(DEFERRED_FILE_FIELD);
114+ const refs = form.getAll(DEFERRED_REF_FIELD).map(String);
115+ const body = item.body ?? "";
116+ const wanted = deferredRefs(body);
117+ if (wanted.size === 0) return;
118+ const urls = new Map<string, string | null>();
119+ for (const [index, ref] of refs.entries()) {
120+ const file = files[index];
121+ if (!wanted.has(ref) || !file || typeof file === "string") continue;
122+ const bytes = new Uint8Array(await file.arrayBuffer());
123+ const kept = await work
124+ .uploadAttachment(user, { workspace: item.workspace, number: item.number }, { name: file.name || "image.png", data: toBase64(bytes) })
125+ .catch(() => null);
126+ urls.set(ref, kept?.ok ? `${addresses().usercontent}${kept.value.path}` : null);
127+ }
128+ // Any still not uploaded (its file did not arrive) goes too.
129+ for (const ref of wanted) if (!urls.has(ref)) urls.set(ref, null);
130+ await work.updateWorkItem(user, item.workspace, item.number, { body: resolveDeferred(body, urls) }).catch(() => null);
131+}
132+
98133 export default function NewWorkItem({ loaderData, actionData }: Route.ComponentProps) {
99134 const { origin, workspace } = loaderData;
100135 const names = loaderData.sources.map((source) => PROVIDERS[source].label);
138173 </Form>
139174 </Card>
140175 )}
141− <Form method="post" className="space-y-5">
176+ <Form method="post" encType="multipart/form-data" className="space-y-5">
142177 <input type="hidden" name="origin" value={origin ?? ""} />
143178 <Field label="Title">
144179 <Input
153188 </Field>
154189 <Field
155190 label="Description"
191+ htmlFor="item-body"
156192 hint="What is wrong or wanted, and anything needed to act on it. Whoever works on it, person or agent, works from this text. To say what done looks like, add a list under a “## Definition of done” heading."
157193 >
158− <Textarea name="body" rows={8} autoCapitalize="sentences" />
194+ {/* Filed from a repository, it keeps the images; with none, the work item does, once it is open. */}
195+ <AttachImages to={origin ? { repo: `${workspace}/${origin}` } : "later"}>
196+ <MentionTextarea id="item-body" name="body" rows={8} autoCapitalize="sentences" />
197+ </AttachImages>
159198 </Field>
160199 {isFailed(loaderData.repos) ? (
161200 <Field label="Where it lands">

Comments

Sign in to comment.