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.

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