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.

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