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.

990 linesCodeBlameRaw
1import { Bot, Check, CircleDashed, ExternalLink, GitCommitHorizontal, GitMerge, Lightbulb, Plus, X } from "lucide-react";
2import { useState } from "react";
3import { Form, Link, data, redirect } from "react-router";
4
5import {
6 AGENT_MODES,
7 type ItemRepo,
8 type LinkRole,
9 type Pull,
10 PROVIDERS,
11 REACTIONS,
12 type SuggestedTarget,
13 type WorkPull,
14 type WorkRepo,
15 missingTargets,
16 workOwner,
17} from "@g1t/contracts";
18
19import type { Route } from "./+types/item";
20import { excerpt, page } from "../../../lib/meta";
21import { Markdown } from "../../../components/markdown";
22import { CopyLine, EmptyState, ErrorText, Input, SubmitButton, TimeAgo } from "../../../components/ui";
23import { Avatar } from "../../../components/ui/avatar";
24import { Button } from "../../../components/ui/button";
25import { Card } from "../../../components/ui/card";
26import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../../../components/ui/dialog";
27import { Hint } from "../../../components/ui/hint";
28import { SelectField } from "../../../components/ui/select";
29import { CheckBadge } from "../../../components/checks";
30import { LabelChip, LabelsBox, MilestoneBox } from "../../../components/labels";
31import { Assignee, ChangeSize, CommentForm, CommentList, ItemState, PeoplePicker, PersonLink, PullIcon, plainText } from "../../../components/work";
32import { RoleBadge } from "../../../components/work-items";
33import { DetailsDisclosure, detailsSummary } from "../../../components/details-disclosure";
34import { DecisionList, IntegrationFailure, ItemWorkPanel, StartSessionDialog, TimelineSession } from "../../../components/coordination";
35import { Reactions } from "../../../components/reactions";
36import { SubscriptionBox } from "../../../components/notifications";
37import { subscriptionFromForm } from "../../../lib/inbox";
38import { Boundary, Unavailable } from "../../../components/boundary";
39import { notFound } from "../../../lib/not-found.server";
40import { openedBy } from "../../../lib/opened-by";
41import { agentSessions, identity, inbox, integrations, repos, work } from "../../../lib/services.server";
42import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../../lib/session.server";
43import { useRefreshWhile } from "../../../lib/refresh";
44import { isFailed, part } from "../../../lib/partial";
45import { parseBlockedBy, resolutionOf } from "../../../lib/coordination";
46import { refPath } from "../../../lib/refs.server";
47import { itemAbilities } from "../../../lib/work-access";
48import { LINK_ROLES, ROLE_LABELS, itemPath, originOf, repoFilter, shortPath, workPath } from "../../../lib/work-items";
49import { workspaceProjects } from "../../../lib/workspace-projects.server";
50
51export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
52 const item = loaderData?.item;
53 const title = item ? `${item.title} · Work item #${item.number} · ` : "";
54 const state = item?.state === "open" ? "Open" : item?.reason === "not_planned" ? "Closed as not planned" : "Closed";
55 const body = excerpt(item?.body);
56 return page(args, {
57 title: `${title}${params.owner} · g1t`,
58 description: item
59 ? `${state} work item #${item.number} in ${params.owner}, opened by ${item.author.username}${item.requestedBy ? ` for ${item.requestedBy.username}` : ""}.${body ? ` ${body}` : ""}`
60 : null,
61 // The card shows the title and the state.
62 version: item ? [item.title, item.state, item.reason] : undefined,
63 type: "article",
64 });
65}
66
67/** `owner/name` as a repository path. */
68function pathOf(path: string): { namespace: string; name: string } {
69 const [namespace = "", name = ""] = path.split("/");
70 return { namespace, name };
71}
72
73export async function loader({ params, context }: Route.LoaderArgs) {
74 const viewer = getViewer(context);
75 const workspace = params.owner.toLowerCase();
76 const number = Number(params.number);
77 if (!Number.isInteger(number) || number < 1) throw notFound("page");
78 const member = roleIn(viewer, workspace) != null;
79 // The item is what the page is about: without it there is no page.
80 // Everything beside it is a part (lib/partial.ts), which says it could
81 // not be loaded in its own place when its service fails.
82 const [found, labels, milestones, members, teams, coordination, ended, agentSetup] = await Promise.all([
83 work.getWorkItem(workspace, number, viewer),
84 part(work.listLabels(workspace, viewer), "Labels"),
85 // Open ones to choose from, and the one it is in, with its progress.
86 part(work.listMilestones(workspace, viewer), "Milestones"),
87 // A member picks assignees from the workspace's people.
88 member ? part(identity.listMembers(workspace, viewer), "The workspace's people") : null,
89 // The teams a member can mention, for the comment box: only a
90 // suggestion, so the box quietly does without them.
91 member ? identity.listTeams(viewer, workspace).catch(() => null) : null,
92 // Its repositories with their pull requests and what landed where, who
93 // is on it, its sessions, what it waits on, and what it may also need
94 // (services/work `get_work`).
95 part(work.getWork(workspace, number, viewer), "Work"),
96 // Its claims that ended, shown muted under the active ones: history
97 // the panel can do without when it cannot be read.
98 Promise.all(
99 (["released", "expired", "handed_off"] as const).map((state) => work.listClaims(workspace, viewer, { state, item: number }).catch(() => null)),
100 ),
101 // Whether a member can start a session on the workspace's Anthropic
102 // account: the button is simply not offered when this cannot be read.
103 member ? agentSessions.setup(workspace, viewer).catch(() => null) : null,
104 ]);
105 if (!found.ok) {
106 // Work items and pull requests share the workspace's numbers: this may be a pull request.
107 const to = await refPath(viewer, { workspace }, number);
108 if (to && to !== itemPath(workspace, number)) throw redirect(to);
109 throw notFound("page");
110 }
111 const { item } = found.value;
112 const origin = originOf(item.repos);
113 // Who may do what follows the item: the better of their role in the
114 // workspace and on its origin repository (lib/work-access.ts).
115 const [originRepo, links, subscription, sessionTrees] = await Promise.all([
116 origin ? repos.getById(origin.repoId, viewer).catch(() => null) : null,
117 // What it is tied to outside g1t. Shown only once the item is known visible.
118 part(integrations.links(workspace, number), "Links from outside g1t"),
119 // Whether the viewer hears of it, for the sidebar's Notifications.
120 viewer ? part(inbox.subscription(viewer, { itemId: item.id, number }), "Notifications") : null,
121 // Every session tied to it in its repositories, with their subagents, for the Work panel's trees.
122 part(
123 Promise.all(
124 item.repos.slice(0, 6).map((linked) => work.listWorkSessions(pathOf(linked.path), viewer, { item: number, subagents: true })),
125 ).then((lists) => lists.flatMap((list) => (list.ok ? list.value : []))),
126 "Sessions",
127 ),
128 ]);
129 const ref = originRepo?.ok ? { id: originRepo.value.id, namespace: originRepo.value.namespace, isPrivate: originRepo.value.isPrivate } : null;
130 const can = itemAbilities(viewer, workspace, ref);
131 // The repositories that can be linked, for whoever may link them.
132 const projects = can.triage ? await part(workspaceProjects(workspace, viewer), "Repositories") : null;
133 return {
134 repos:
135 projects == null || isFailed(projects) || !projects.ok
136 ? null
137 : projects.value.filter((p) => p.workspace.toLowerCase() === workspace).map((p) => p.slug).sort(),
138 ...found.value,
139 workspace,
140 viewer,
141 member,
142 labels: isFailed(labels) ? labels : labels.ok ? labels.value : [],
143 milestones: isFailed(milestones) ? milestones : milestones.ok ? milestones.value : [],
144 links,
145 subscription,
146 coordination: isFailed(coordination) ? coordination : coordination.ok ? coordination.value : null,
147 sessionTrees,
148 // Only when it can start: the button is not shown otherwise.
149 agentSetup: agentSetup?.ok && agentSetup.value.available ? agentSetup.value : null,
150 endedClaims: ended
151 .flatMap((claims) => (claims?.ok ? claims.value : []))
152 .sort((a, b) => Date.parse(b.endedAt ?? b.createdAt) - Date.parse(a.endedAt ?? a.createdAt)),
153 members: members == null ? [] : isFailed(members) ? members : members.ok ? members.value.map((m) => m.username) : [],
154 handles: teams?.ok ? teams.value.map((team) => `@${team.workspace}/${team.slug}`) : [],
155 // The author can close and reopen their own work item, and whoever g1t's
156 // agent filed one for, that one; Triage and up, anyone's.
157 canManage: viewer != null && (viewer.id === workOwner(item).id || can.triage),
158 // Maintain and up edit and delete anyone's comments; everyone, their own.
159 canModerate: can.manage_settings,
160 can,
161 };
162}
163
164/** The repository a form names, by path, if it is one the item is linked to (or, to link, one of the workspace). */
165function formRepo(form: FormData, workspace: string): { namespace: string; name: string } | null {
166 return repoFilter(workspace, String(form.get("repo") ?? "").trim());
167}
168
169export async function action({ request, params, context }: Route.ActionArgs) {
170 assertSameOrigin(request);
171 const user = requireUser(context, request);
172 const workspace = params.owner.toLowerCase();
173 const number = Number(params.number);
174 const thread = { workspace, number };
175 const form = await request.formData();
176 const intent = String(form.get("action") ?? "");
177 // Subscribing to it, from the sidebar's Notifications (components/notifications.tsx).
178 const choice = subscriptionFromForm(form);
179 if (choice) {
180 const found = await work.getWorkItem(workspace, number, user);
181 if (!found.ok) return data({ error: "That work item could not be found." }, { status: 404 });
182 const subscription = await inbox.subscribe(user, { itemId: found.value.item.id, number }, choice.subscribed, choice.ignored).catch(() => null);
183 return subscription ? { error: null, subscription } : data({ error: "That did not save. Try again in a moment." }, { status: 503 });
184 }
185 // Who may do each is checked by the work service, against the item's
186 // workspace and origin; what it refuses says why.
187 switch (intent) {
188 case "open-pull": {
189 const repo = formRepo(form, workspace);
190 if (!repo) return { error: "Choose the repository to open it in.", action: intent };
191 const result = await work.openPull(user, repo, { item: number, agent: String(form.get("agent") ?? ""), runtime: "external" });
192 if (!result.ok) return { error: result.error.message, action: intent };
193 throw redirect(`/${repo.namespace}/${repo.name}/pull/${result.value.number}`);
194 }
195 // A session on the workspace's Anthropic account, in one of its
196 // repositories, which the integrations service checks the person may
197 // start; then its page.
198 case "start-session": {
199 const repo = formRepo(form, workspace);
200 if (!repo) return { error: "Link a repository for it to work in first.", action: intent };
201 const mode = AGENT_MODES.find((one) => one.mode === form.get("mode"))?.mode ?? "implement";
202 const instruction = String(form.get("instruction") ?? "").trim();
203 const result = await agentSessions.start(user, repo, { item: number, mode, instruction: instruction || undefined, handoff: false }).catch(() => null);
204 if (!result) return { error: "The session could not be started. Try again in a moment.", action: intent };
205 if (!result.ok) return { error: result.error.message, action: intent };
206 const started = result.value;
207 const to = sitePath(started.sessionUrl, request.url) ?? (started.session ? `/${repo.namespace}/${repo.name}/sessions/${started.session.id}` : null);
208 if (!to) return { error: "No session was started.", action: intent };
209 throw redirect(to);
210 }
211 case "comment": {
212 const result = await work.addComment(user, thread, { body: String(form.get("body") ?? "") });
213 return result.ok ? null : { error: result.error.message };
214 }
215 // A reaction to the item or one of its comments, or taking one back.
216 case "react": {
217 const content = REACTIONS.find((one) => one.content === form.get("content"))?.content;
218 if (!content) return { error: "Pick a reaction.", action: intent };
219 const result = await work.react(user, thread, {
220 commentId: String(form.get("comment") ?? "") || undefined,
221 content,
222 remove: form.get("remove") === "1",
223 });
224 return result.ok ? null : { error: result.error.message, action: intent };
225 }
226 case "assign": {
227 const result = await work.updateWorkItem(user, workspace, number, {
228 assignees: [...form.getAll("assignee").map(String), ...String(form.get("others") ?? "").split(/[\s,]+/)].filter(Boolean),
229 });
230 return result.ok ? null : { error: result.error.message };
231 }
232 case "labels": {
233 const result = await work.setLabels(user, thread, form.getAll("label").map(String));
234 return result.ok ? null : { error: result.error.message, action: intent };
235 }
236 case "blocked-by": {
237 const blockedBy = parseBlockedBy(String(form.get("blocked_by") ?? ""));
238 if (!blockedBy) return { error: "List work item numbers, such as #12, #14.", action: intent };
239 const result = await work.updateWorkItem(user, workspace, number, { blocked_by: blockedBy });
240 return result.ok ? null : { error: result.error.message, action: intent };
241 }
242 case "milestone": {
243 const result = await work.updateWorkItem(user, workspace, number, { milestone: Number(form.get("milestone")) || 0 });
244 return result.ok ? null : { error: result.error.message, action: intent };
245 }
246 // Linking a repository in a role (a suggestion's Add as target is one), or unlinking one.
247 case "link":
248 case "unlink": {
249 const repo = formRepo(form, workspace);
250 if (!repo) return { error: "Choose a repository of the workspace.", action: "link" };
251 const role = LINK_ROLES.find((one) => one === form.get("role")) ?? "target";
252 const result =
253 intent === "link" ? await work.linkWorkItem(user, workspace, number, repo, role) : await work.unlinkWorkItem(user, workspace, number, repo);
254 return result.ok ? { linked: intent } : { error: result.error.message, action: "link" };
255 }
256 case "reopen": {
257 const result = await work.reopenWorkItem(user, workspace, number);
258 return result.ok ? null : { error: result.error.message };
259 }
260 // A comment's own text, changed or taken away: its author's to do, or
261 // a maintainer's, which work checks.
262 case "edit-comment":
263 case "delete-comment": {
264 const id = String(form.get("comment") ?? "");
265 const result =
266 intent === "edit-comment"
267 ? await work.editComment(user, { workspace }, id, String(form.get("body") ?? ""))
268 : await work.deleteComment(user, { workspace }, id);
269 return result.ok ? null : { error: result.error.message, action: intent, comment: id };
270 }
271 case "close-completed":
272 case "close-not-planned": {
273 const result = await work.closeWorkItem(user, workspace, number, intent === "close-not-planned" ? "not_planned" : "completed");
274 return result.ok ? null : { error: result.error.message };
275 }
276 }
277 throw data(null, { status: 400 });
278}
279
280/** The path of a page of this site an address names, to redirect to; null for none or another site's. */
281function sitePath(url: string | null | undefined, here: string): string | null {
282 if (!url) return null;
283 try {
284 const parsed = new URL(url, here);
285 const own = parsed.origin === new URL(here).origin || parsed.hostname === "g1t.sh";
286 return own ? parsed.pathname + parsed.search : null;
287 } catch {
288 return null;
289 }
290}
291
292/** What became of a pull request, in a few words. */
293function outcome(pull: { status: Pull["status"] } & Partial<Pick<Pull, "mergedBy" | "supersededBy">>): string {
294 if (pull.status === "merged") return pull.mergedBy ? `Merged by ${pull.mergedBy}` : "Merged";
295 if (pull.supersededBy != null) return `Closed · #${pull.supersededBy} was merged instead`;
296 if (pull.status === "closed") return "Closed without merging";
297 if (pull.status === "draft") return "Draft · in progress";
298 return "Ready for review";
299}
300
301/** One of a repository's pull requests for the item, as a line under the repository. */
302function RepoPullLine({ repo, pull, full }: { repo: string; pull: WorkPull; full?: Pull }) {
303 const merged = pull.status === "merged";
304 return (
305 <li>
306 <Link
307 to={`/${repo}/pull/${pull.number}`}
308 prefetch="intent"
309 className={`flex min-w-0 items-start gap-2.5 rounded-lg px-2 py-2 transition-colors hover:bg-raised ${pull.status === "closed" ? "opacity-70" : ""}`}
310 >
311 <span className="mt-0.5">
312 <PullIcon status={pull.status} size={15} />
313 </span>
314 <span className="min-w-0 grow">
315 <span className="block text-sm font-medium wrap-break-word">
316 {pull.title} <span className="font-normal text-faint">#{pull.number}</span>
317 </span>
318 <span className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted">
319 <span className={merged ? "font-medium text-merged" : ""}>{outcome({ status: pull.status, mergedBy: full?.mergedBy, supersededBy: full?.supersededBy })}</span>
320 <span>· {pull.author.username}</span>
321 {full && <ChangeSize files={full.files} />}
322 <CheckBadge status={pull.checkStatus} />
323 {pull.queue && <span>· in the merge queue, {pull.queue.position === 1 ? "next" : `#${pull.queue.position}`}</span>}
324 </span>
325 </span>
326 </Link>
327 </li>
328 );
329}
330
331/**
332 * One repository the item is linked to: its path and role, whether what
333 * closes the item has landed there (or, for a target, that it is still
334 * missing), and its pull requests for it. With Triage, the cross unlinks
335 * it (never its origin).
336 */
337function RepoCard({ repo, pulls, full, canLink }: { repo: ItemRepo; pulls: WorkPull[]; full: Map<string, Pull>; canLink: boolean }) {
338 const missing = repo.role === "target" && !repo.landed;
339 return (
340 <li className="px-3 py-3 sm:px-4">
341 <div className="flex min-w-0 items-center gap-2">
342 <Link to={`/${repo.path}`} className="min-w-0 truncate font-mono text-sm font-medium hover:text-accent">
343 {repo.path}
344 </Link>
345 <RoleBadge role={repo.role} />
346 {repo.legacyNumber != null && (
347 <Hint label={`It was ${repo.path}#${repo.legacyNumber} before work items, and that still leads here.`}>
348 <span className="shrink-0 text-xs text-faint">was #{repo.legacyNumber}</span>
349 </Hint>
350 )}
351 <span className="ml-auto flex shrink-0 items-center gap-1">
352 {repo.landed ? (
353 <span className="inline-flex items-center gap-1 text-xs font-medium text-merged">
354 <Check size={13} />
355 Landed
356 </span>
357 ) : missing ? (
358 <span className="inline-flex items-center gap-1 text-xs text-warn">
359 <CircleDashed size={13} />
360 Missing
361 </span>
362 ) : null}
363 {canLink && repo.role !== "origin" && (
364 <Form method="post">
365 <input type="hidden" name="action" value="unlink" />
366 <input type="hidden" name="repo" value={repo.path} />
367 <Hint label={`Unlink ${repo.path}`}>
368 <Button type="submit" variant="ghost" size="icon-xs" aria-label={`Unlink ${repo.path}`} className="max-sm:size-9">
369 <X size={14} />
370 </Button>
371 </Hint>
372 </Form>
373 )}
374 </span>
375 </div>
376 {pulls.length > 0 ? (
377 <ul className="mt-1.5 -mx-2 space-y-0.5">
378 {pulls.map((pull) => (
379 <RepoPullLine key={pull.number} repo={repo.path} pull={pull} full={full.get(`${repo.repoId}#${pull.number}`)} />
380 ))}
381 </ul>
382 ) : (
383 <p className="mt-1 text-xs text-faint">{repo.role === "touched" ? "Touched by a commit." : "No pull request for it here yet."}</p>
384 )}
385 </li>
386 );
387}
388
389/** Link a repository: one of the workspace's, in a role. A sheet from the bottom on a phone. */
390function LinkRepository({
391 workspace,
392 repos,
393 linked,
394 error,
395 wide = false,
396}: {
397 workspace: string;
398 /** The workspace's repositories by name; null when they could not be read. */
399 repos: string[] | null;
400 linked: string[];
401 error?: string | null;
402 wide?: boolean;
403}) {
404 const [open, setOpen] = useState(Boolean(error));
405 const choices = (repos ?? []).filter((name) => !linked.includes(`${workspace}/${name}`.toLowerCase()));
406 return (
407 <Dialog open={open} onOpenChange={setOpen}>
408 <DialogTrigger asChild>
409 <Button variant="outline" size="sm" className={wide ? "h-11 w-full" : undefined}>
410 <Plus size={14} />
411 Link a repository
412 </Button>
413 </DialogTrigger>
414 <DialogContent>
415 <Form method="post" className="grid gap-5" onSubmit={() => setOpen(false)}>
416 <input type="hidden" name="action" value="link" />
417 <DialogHeader>
418 <DialogTitle>Link a repository</DialogTitle>
419 <DialogDescription>
420 A target is where the work is meant to land: the item closes once every target has landed. Touched marks one it reached
421 without having to land there.
422 </DialogDescription>
423 </DialogHeader>
424 {repos == null ? (
425 <Unavailable what="The workspace's repositories" compact />
426 ) : (
427 <SelectField
428 name="repo"
429 aria-label="Repository"
430 defaultValue={choices[0] ?? ""}
431 options={choices.length > 0 ? choices.map((name) => ({ value: name, label: name })) : [{ value: "", label: "No other repository to link" }]}
432 />
433 )}
434 <SelectField
435 name="role"
436 aria-label="Role"
437 defaultValue="target"
438 options={(["target", "touched"] as LinkRole[]).map((role) => ({ value: role, label: ROLE_LABELS[role] }))}
439 />
440 {error && <ErrorText>{error}</ErrorText>}
441 <DialogFooter>
442 <SubmitButton match={{ action: "link" }} pending="Linking…" className="max-sm:h-11">
443 Link
444 </SubmitButton>
445 </DialogFooter>
446 </Form>
447 </DialogContent>
448 </Dialog>
449 );
450}
451
452/** A repository the work may also belong in, with why, and Add as target. */
453function SuggestionLine({ suggestion, workspace, canLink }: { suggestion: SuggestedTarget; workspace: string; canLink: boolean }) {
454 return (
455 <li className="flex items-start gap-3 px-3 py-3 sm:px-4">
456 <Lightbulb size={15} className="mt-0.5 shrink-0 text-faint" />
457 <span className="min-w-0 grow">
458 <Link to={`/${suggestion.path}`} className="font-mono text-sm font-medium hover:text-accent">
459 {shortPath(suggestion.path, workspace)}
460 </Link>
461 <span className="mt-0.5 block text-xs text-muted">{suggestion.why}</span>
462 </span>
463 {canLink && (
464 <Form method="post" className="shrink-0">
465 <input type="hidden" name="action" value="link" />
466 <input type="hidden" name="repo" value={suggestion.path} />
467 <input type="hidden" name="role" value="target" />
468 <SubmitButton variant="outline" size="sm" match={{ action: "link", repo: suggestion.path }} pending="Adding…" className="max-sm:h-10">
469 Add as target
470 </SubmitButton>
471 </Form>
472 )}
473 </li>
474 );
475}
476
477export default function WorkItemPage({ loaderData, actionData }: Route.ComponentProps) {
478 const { item, pulls, comments, viewer, labels, members, canManage, can, workspace } = loaderData;
479 // Its Work, when it could be read; a failure says so in its places.
480 const coordination = isFailed(loaderData.coordination) ? null : loaderData.coordination;
481 const sessionTrees = isFailed(loaderData.sessionTrees) ? null : loaderData.sessionTrees;
482 // Follow pull requests at work without a manual reload.
483 useRefreshWhile(pulls.some((pull) => pull.status === "draft" || pull.checkStatus === "queued" || pull.checkStatus === "running"));
484 // Claims lapse and sessions go idle with no event to say so: looked at again now and then.
485 useRefreshWhile((coordination?.claims.length ?? 0) > 0 || (coordination?.sessions.some((session) => session.state !== "ended") ?? false), 30_000);
486 const now = Date.now();
487
488 const open = item.state === "open";
489 const reference = `${workspace}#${item.number}`;
490 const canLink = can.triage;
491 // Each repository with its pull requests for the item: from its work
492 // context, else from the item's own pull requests.
493 const bySlug = new Map(item.repos.map((repo) => [repo.repoId, repo.path]));
494 const full = new Map(pulls.map((pull) => [`${pull.repoId}#${pull.number}`, pull]));
495 const repoRows: { repo: ItemRepo; pulls: WorkPull[] }[] = coordination
496 ? coordination.repos.map((repo: WorkRepo) => ({ repo, pulls: repo.pulls }))
497 : item.repos.map((repo) => ({
498 repo,
499 pulls: pulls
500 .filter((pull) => pull.repoId === repo.repoId)
501 .map((pull) => ({
502 repo: repo.path,
503 number: pull.number,
504 title: pull.title,
505 status: pull.status,
506 branch: pull.branch,
507 headCommit: pull.headCommit,
508 author: { id: pull.author.id, username: pull.author.username },
509 checkStatus: pull.checkStatus,
510 requiredChecks: [],
511 mergeable: "unknown" as const,
512 overlaps: [],
513 queue: null,
514 })),
515 }));
516 const missing = coordination?.missing ?? missingTargets(item.repos);
517 const suggestions = (coordination?.suggestedTargets ?? []).filter((one) => !item.repos.some((repo) => repo.repoId === one.repoId));
518 // Where each session's page is: under the repository it was recorded in.
519 const sessionBase = (session: { repoId: string }) => `/${bySlug.get(session.repoId) ?? item.repos[0]?.path ?? workspace}`;
520 // Its decisions as a story, oldest first; the failure that still stands, with what answered it.
521 const decisions = coordination ? [...coordination.decisions].reverse() : [];
522 const failure = coordination?.lastIntegrationFailure ?? null;
523 const resolution = failure ? resolutionOf(coordination!.decisions, failure) : null;
524 const failureBase = failure ? `/${item.repos[0]?.path ?? workspace}` : "";
525 // Where a pull request or a session would be opened: a target still missing, else where it was filed.
526 const workIn = missing[0] ?? item.repos.find((repo) => repo.role === "target")?.path ?? originOf(item.repos)?.path ?? null;
527 const linkError = actionData && "action" in actionData && actionData.action === "link" ? actionData.error : null;
528 const sideError = actionData && "action" in actionData && ["blocked-by", "start-session", "react", "link", "open-pull"].includes(String(actionData.action));
529
530 return (
531 // On a phone: the title, then the item, its repositories and the
532 // conversation, then the sidebar as a Details disclosure; beside it
533 // from lg up. Its main actions sit at the bottom, within a thumb's reach.
534 <div className="grid gap-x-8 gap-y-5 lg:grid-cols-[1fr_19rem] lg:grid-rows-[auto_1fr] lg:gap-y-0">
535 <header className="min-w-0 lg:col-start-1">
536 <h1 className="text-xl font-semibold tracking-tight text-balance wrap-break-word sm:text-2xl">
537 {item.title} <span className="font-normal text-faint">#{item.number}</span>
538 </h1>
539 <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted">
540 <ItemState item={item} />
541 <span className="flex min-w-0 items-center gap-2">
542 <Avatar name={item.author.username} size={18} />
543 <span className="min-w-0">
544 <PersonLink name={item.author.username} className="font-medium text-fg hover:underline" /> opened this{" "}
545 {item.requestedBy && (
546 <>
547 for <PersonLink name={item.requestedBy.username} className="font-medium text-fg-soft hover:underline" />{" "}
548 </>
549 )}
550 <TimeAgo at={item.createdAt} />
551 </span>
552 </span>
553 {item.labels.map((name) => (
554 // Without the workspace's labels, a chip still names its label, in the plain color.
555 <LabelChip key={name} name={name} color={isFailed(labels) ? undefined : labels.find((label) => label.name === name)?.color} />
556 ))}
557 {open && item.agent && <Assignee agent={item.agent} />}
558 </div>
559 </header>
560
561 <div className="min-w-0 max-lg:order-1 lg:col-start-1">
562 {item.resolutions.length > 0 && (
563 <ul className="space-y-2 lg:mt-5">
564 {item.resolutions.map((landed, index) => (
565 <li key={`${landed.repo}-${landed.pull ?? landed.commit ?? index}`}>
566 <Link
567 to={landed.pull != null ? `/${landed.repo}/pull/${landed.pull}` : `/${landed.repo}/commit/${landed.commit ?? ""}`}
568 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"
569 >
570 {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" />}
571 <span className="min-w-0">
572 Landed in <span className="font-mono font-medium">{landed.repo}</span> by{" "}
573 {landed.pull != null ? (
574 <span className="font-medium">#{landed.pull}</span>
575 ) : (
576 <span className="font-mono font-medium">{landed.commit?.slice(0, 7)}</span>
577 )}
578 {landed.pushedBy && <span className="text-muted">, pushed by {landed.pushedBy}</span>}
579 </span>
580 </Link>
581 </li>
582 ))}
583 </ul>
584 )}
585
586 {item.body ? (
587 <div className={`${item.resolutions.length > 0 ? "mt-5" : "lg:mt-5"} rounded-xl border border-line bg-surface p-4 sm:p-5`}>
588 <Markdown source={item.body} workspace={workspace} />
589 <Reactions reactions={loaderData.reactions} viewer={viewer?.username ?? null} className="mt-4" />
590 </div>
591 ) : (
592 (loaderData.reactions?.length ?? 0) > 0 && <Reactions reactions={loaderData.reactions} viewer={viewer?.username ?? null} className="mt-3 lg:mt-5" />
593 )}
594
595 <section aria-labelledby="repositories" className="mt-8">
596 <div className="flex flex-wrap items-center justify-between gap-2">
597 <h2 id="repositories" className="font-semibold tracking-tight">
598 Repositories
599 </h2>
600 <span className="flex items-center gap-3">
601 {missing.length > 0 && open && (
602 <span className="text-sm text-muted">
603 {missing.length} {missing.length === 1 ? "target" : "targets"} still to land
604 </span>
605 )}
606 {canLink && (
607 <span className="max-sm:hidden">
608 <LinkRepository workspace={workspace} repos={loaderData.repos} linked={item.repos.map((repo) => repo.path.toLowerCase())} error={linkError} />
609 </span>
610 )}
611 </span>
612 </div>
613 {isFailed(loaderData.coordination) && (
614 <div className="mt-2">
615 <Unavailable what="Each repository's pull requests" compact />
616 </div>
617 )}
618 {repoRows.length === 0 ? (
619 <div className="mt-3">
620 <EmptyState title="No repository yet">
621 Filed in the workspace itself. Link the repositories it should land in, and it closes once each of them has.
622 </EmptyState>
623 </div>
624 ) : (
625 <Card asChild divided className="mt-3">
626 <ul>
627 {repoRows.map(({ repo, pulls: repoPulls }) => (
628 <RepoCard key={repo.repoId} repo={repo} pulls={repoPulls} full={full} canLink={canLink} />
629 ))}
630 </ul>
631 </Card>
632 )}
633 {suggestions.length > 0 && open && (
634 <>
635 <h3 className="mt-5 text-sm font-medium">Suggested</h3>
636 <p className="mt-0.5 text-xs text-muted">Repositories it may also need, and why.</p>
637 <Card asChild divided className="mt-2">
638 <ul>
639 {suggestions.map((suggestion) => (
640 <SuggestionLine key={suggestion.repoId} suggestion={suggestion} workspace={workspace} canLink={canLink} />
641 ))}
642 </ul>
643 </Card>
644 </>
645 )}
646 {linkError && (
647 <div className="mt-2">
648 <ErrorText>{linkError}</ErrorText>
649 </div>
650 )}
651 </section>
652
653 {pulls.length === 0 && open && (
654 <div className="mt-6">
655 <EmptyState title="Nobody has worked on this yet">
656 Point your own agent at <code className="font-mono">{reference}</code>.
657 </EmptyState>
658 </div>
659 )}
660
661 {failure && <IntegrationFailure className="mt-4" failure={failure} base={failureBase} fixed={resolution?.passed ?? resolution?.fix} />}
662
663 {isFailed(loaderData.coordination) ? (
664 <>
665 <h2 className="mt-10 font-semibold tracking-tight">Decisions</h2>
666 <div className="mt-3">
667 <Unavailable what="Its decisions" />
668 </div>
669 </>
670 ) : (
671 decisions.length > 0 && (
672 <>
673 <div className="mt-10 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
674 <h2 className="font-semibold tracking-tight">Decisions</h2>
675 <p className="text-sm text-muted">Why the work is the way it is, and how sure that is</p>
676 </div>
677 <div className="mt-3">
678 <DecisionList decisions={decisions} base={failureBase || `/${item.repos[0]?.path ?? workspace}`} />
679 </div>
680 </>
681 )
682 )}
683
684 <h2 className="mt-10 font-semibold tracking-tight">Discussion</h2>
685 <div className="mt-3">
686 <div className="space-y-4">
687 {isFailed(loaderData.sessionTrees) && <Unavailable what="Its sessions" compact />}
688 <CommentList
689 comments={comments}
690 workspace={workspace}
691 viewerId={viewer?.id ?? null}
692 viewerName={viewer?.username ?? null}
693 extra={(sessionTrees ?? [])
694 .filter((session) => !session.parentId)
695 .map((session) => ({ id: session.id, at: session.startedAt, node: <TimelineSession session={session} base={sessionBase(session)} /> }))}
696 canModerate={loaderData.canModerate}
697 failed={actionData && "comment" in actionData ? { comment: String(actionData.comment), error: actionData.error } : null}
698 />
699 <div id="comment">
700 <CommentForm author={viewer?.username ?? null} resetKey={comments.length} handles={loaderData.handles} />
701 </div>
702 {canManage && (
703 <Form method="post" className="flex flex-wrap justify-end gap-2 max-sm:hidden">
704 {open ? (
705 <>
706 <SubmitButton variant="outline" name="action" value="close-not-planned" pending="Closing…">
707 Close as not planned
708 </SubmitButton>
709 <SubmitButton variant="outline" name="action" value="close-completed" pending="Closing…">
710 Close work item
711 </SubmitButton>
712 </>
713 ) : (
714 <SubmitButton variant="outline" name="action" value="reopen" pending="Reopening…">
715 Reopen work item
716 </SubmitButton>
717 )}
718 </Form>
719 )}
720 </div>
721 </div>
722 <div className="mt-2">
723 {/* A refused comment edit says so under the comment; the sidebar's own say so there. */}
724 {!(actionData && ("comment" in actionData || sideError)) && <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>}
725 </div>
726
727 {/* On a phone, its main actions at the bottom, above the tab bar and clear of the keyboard. */}
728 {(canManage || canLink) && (
729 <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">
730 {canLink && (
731 <div className="min-w-0 grow">
732 <LinkRepository workspace={workspace} repos={loaderData.repos} linked={item.repos.map((repo) => repo.path.toLowerCase())} error={null} wide />
733 </div>
734 )}
735 {canManage && (
736 <Form method="post" className="min-w-0 grow *:h-11 *:w-full">
737 {open ? (
738 <SubmitButton variant="outline" name="action" value="close-completed" pending="Closing…">
739 Close
740 </SubmitButton>
741 ) : (
742 <SubmitButton variant="outline" name="action" value="reopen" pending="Reopening…">
743 Reopen
744 </SubmitButton>
745 )}
746 </Form>
747 )}
748 </div>
749 )}
750 </div>
751
752 <DetailsDisclosure
753 className="max-lg:order-2 lg:col-start-2 lg:row-span-2 lg:row-start-1"
754 summary={detailsSummary([
755 [coordination?.claims.length ?? 0, "claim", "claims"],
756 [item.assignees.length, "assignee", "assignees"],
757 [item.labels.length, "label", "labels"],
758 [item.milestone ? 1 : 0, "milestone", "milestones"],
759 ])}
760 >
761 {isFailed(loaderData.coordination) ? (
762 <section>
763 <h3 className="text-sm font-medium">Work</h3>
764 <div className="mt-2">
765 <Unavailable what="Who is on it" compact />
766 </div>
767 </section>
768 ) : (
769 coordination && (
770 <Boundary what="Work" fallback={<Unavailable what="Who is on it" compact />}>
771 <ItemWorkPanel
772 work={coordination}
773 ended={loaderData.endedClaims}
774 base={sessionBase}
775 now={now}
776 canEdit={canManage}
777 error={actionData && "action" in actionData && actionData.action === "blocked-by" ? actionData.error : null}
778 sessions={sessionTrees}
779 start={
780 loaderData.agentSetup && open && workIn ? (
781 <StartSessionDialog
782 setup={loaderData.agentSetup}
783 repo={workIn}
784 error={actionData && "action" in actionData && actionData.action === "start-session" ? actionData.error : null}
785 />
786 ) : undefined
787 }
788 />
789 </Boundary>
790 )
791 )}
792 {isFailed(loaderData.links) ? (
793 <section>
794 <h3 className="text-sm font-medium">From outside g1t</h3>
795 <div className="mt-2">
796 <Unavailable what="Its links" compact />
797 </div>
798 </section>
799 ) : (
800 loaderData.links.length > 0 && (
801 <section>
802 <h3 className="text-sm font-medium">From outside g1t</h3>
803 <ul className="mt-2 space-y-1.5 text-sm">
804 {loaderData.links.map((link) => (
805 <li key={`${link.connectionId}-${link.key}`}>
806 <a
807 href={link.url}
808 target="_blank"
809 rel="noreferrer"
810 className="group flex items-center gap-2 rounded-lg border border-line px-2.5 py-2 transition-colors hover:border-line-strong"
811 >
812 <span className="min-w-0 grow">
813 <span className="block truncate font-medium">
814 {PROVIDERS[link.provider].label} <span className="font-mono text-muted">{link.key}</span>
815 </span>
816 <span className="block text-xs text-faint">
817 {link.count > 1 ? `Seen ${link.count} times, last ` : "Linked "}
818 <TimeAgo at={link.count > 1 ? link.lastSeen : link.firstSeen} />
819 </span>
820 </span>
821 <ExternalLink size={13} className="shrink-0 text-faint group-hover:text-fg" />
822 </a>
823 </li>
824 ))}
825 </ul>
826 </section>
827 )
828 )}
829 <section>
830 <h3 className="text-sm font-medium">Assignees</h3>
831 <ul className="mt-2 space-y-1.5 text-sm">
832 {item.assignees.map((name) => (
833 <li key={name} className="flex items-center gap-2 px-1">
834 <Avatar name={name} size={20} agent={name === "g1t"} />
835 <span className="grow truncate font-mono text-xs">{name === "g1t" ? "g1t" : <PersonLink name={name} className="hover:underline" />}</span>
836 </li>
837 ))}
838 {item.assignees.length === 0 && <li className="px-1 text-xs text-faint">Nobody yet</li>}
839 </ul>
840 {!coordination && item.blockedBy.length > 0 && (
841 <p className="mt-3 text-xs text-muted">
842 Depends on{" "}
843 {item.blockedBy.map((number, index) => (
844 <span key={number}>
845 {index > 0 && ", "}
846 <Link to={itemPath(workspace, number)} className="font-medium text-fg hover:underline">
847 #{number}
848 </Link>
849 </span>
850 ))}
851 {open ? ", which has to land first." : "."}
852 </p>
853 )}
854 {viewer && can.triage && open && (
855 <div className="mt-3 space-y-2">
856 {/* g1t takes it on in a session on the workspace's Claude, as whoever assigned it. */}
857 {loaderData.agentSetup && !item.assignees.includes("g1t") && (
858 <Form method="post">
859 <input type="hidden" name="action" value="assign" />
860 {item.assignees.map((name) => (
861 <input key={name} type="hidden" name="assignee" value={name} />
862 ))}
863 <input type="hidden" name="assignee" value="g1t" />
864 <div className="*:w-full">
865 <SubmitButton variant="outline" name="who" value="g1t" pending="Assigning…">
866 <Bot size={14} />
867 Assign to g1t
868 </SubmitButton>
869 </div>
870 </Form>
871 )}
872 {!item.assignees.includes(viewer.username) && (
873 <Form method="post">
874 <input type="hidden" name="action" value="assign" />
875 {item.assignees.map((name) => (
876 <input key={name} type="hidden" name="assignee" value={name} />
877 ))}
878 <input type="hidden" name="assignee" value={viewer.username} />
879 <div className="*:w-full">
880 <SubmitButton variant="outline" name="who" value="self" pending="Assigning…">
881 Assign yourself
882 </SubmitButton>
883 </div>
884 </Form>
885 )}
886 <details className="group">
887 <summary className="cursor-pointer text-xs text-faint hover:text-fg">Assign people</summary>
888 <Form method="post" className="mt-2 space-y-2" key={item.assignees.join()}>
889 <input type="hidden" name="action" value="assign" />
890 {/* Without the workspace's people the picker still holds who is
891 assigned now, so saving never clears them. */}
892 {isFailed(members) && <Unavailable what="The workspace's people" compact />}
893 <PeoplePicker name="assignee" members={isFailed(members) ? [] : members} chosen={item.assignees} />
894 <SubmitButton variant="outline" name="who" value="picked" pending="Saving…">
895 Save assignees
896 </SubmitButton>
897 </Form>
898 </details>
899 </div>
900 )}
901 </section>
902
903 {/* Another way to start the work: not once a pull request is doing it. */}
904 {open && !pulls.some((pull) => pull.status === "open" || pull.status === "draft") && (
905 <Card asChild className="p-4">
906 <section>
907 <h3 className="text-sm font-medium">Bring your own agent</h3>
908 <p className="mt-1 text-xs text-muted">With g1t connected to your agent, ask it to work on this work item.</p>
909 <div className="mt-3">
910 <CopyLine text={reference} />
911 </div>
912 {viewer && item.repos.length > 0 ? (
913 <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4">
914 <input type="hidden" name="action" value="open-pull" />
915 <p className="text-xs text-muted">Or open a draft pull request yourself and get a fork to push to.</p>
916 <SelectField
917 name="repo"
918 aria-label="Repository"
919 defaultValue={workIn ?? item.repos[0]!.path}
920 options={item.repos.map((repo) => ({ value: repo.path, label: `${repo.path} (${ROLE_LABELS[repo.role].toLowerCase()})` }))}
921 />
922 <Input name="agent" placeholder="Who is working, e.g. claude-code" maxLength={60} />
923 <div className="*:w-full">
924 <SubmitButton match={{ action: "open-pull" }} pending="Opening…">
925 Open pull request
926 </SubmitButton>
927 </div>
928 {actionData && "action" in actionData && actionData.action === "open-pull" && <ErrorText>{actionData.error}</ErrorText>}
929 {workIn && (
930 <p className="text-xs text-muted">
931 Already pushed a branch?{" "}
932 <Link to={`/${workIn}/pulls/new?item=${item.number}`} className="text-fg underline underline-offset-4">
933 Open a pull request from it
934 </Link>
935 .
936 </p>
937 )}
938 </Form>
939 ) : (
940 !viewer && (
941 <p className="mt-4 border-t border-line pt-4 text-sm text-muted">
942 <Link to="/login" className="text-fg underline underline-offset-4">
943 Sign in
944 </Link>{" "}
945 to open a pull request.
946 </p>
947 )
948 )}
949 </section>
950 </Card>
951 )}
952
953 {isFailed(labels) ? (
954 <SideUnavailable heading="Labels" what="Labels" />
955 ) : (
956 <LabelsBox
957 labels={labels}
958 chosen={item.labels}
959 canEdit={canManage}
960 canCreate={can.manage_labels}
961 manageUrl={can.manage_labels ? workPath(workspace, "labels") : undefined}
962 />
963 )}
964 {isFailed(loaderData.milestones) ? (
965 <SideUnavailable heading="Milestone" what="Milestones" />
966 ) : (
967 <MilestoneBox milestones={loaderData.milestones} current={item.milestone} canEdit={can.triage} base={workPath(workspace)} />
968 )}
969 {viewer &&
970 (isFailed(loaderData.subscription) ? (
971 <SideUnavailable heading="Notifications" what="Your notification settings" />
972 ) : (
973 <SubscriptionBox action={itemPath(workspace, item.number)} number={item.number} kind="item" subscription={loaderData.subscription} />
974 ))}
975 </DetailsDisclosure>
976 </div>
977 );
978}
979
980/** A sidebar section, under its usual heading, that could not be loaded. */
981function SideUnavailable({ heading, what }: { heading: string; what: string }) {
982 return (
983 <section>
984 <h3 className="text-sm font-medium">{heading}</h3>
985 <div className="mt-2">
986 <Unavailable what={what} compact />
987 </div>
988 </section>
989 );
990}