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,001 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, pullHrefs, 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 // A decision or the merge queue names a pull request by number: link it in the repository that has it, or not at all.
534 const pullHref = pullHrefs(repoRows.map(({ repo, pulls: repoPulls }) => ({ path: repo.path, pulls: repoPulls })));
535 const failureBase = `/${originOf(item.repos)?.path || workspace}`;
536 // Where a pull request or a session would be opened: a target still missing, else where it was filed.
537 const workIn = missing[0] ?? item.repos.find((repo) => repo.role === "target")?.path ?? originOf(item.repos)?.path ?? null;
538 const linkError = actionData && "action" in actionData && actionData.action === "link" ? actionData.error : null;
539 const sideError = actionData && "action" in actionData && ["blocked-by", "start-session", "react", "link", "open-pull"].includes(String(actionData.action));
540
541 return (
542 // On a phone: the title, then the item, its repositories and the
543 // conversation, then the sidebar as a Details disclosure; beside it
544 // from lg up. Its main actions sit at the bottom, within a thumb's reach.
545 <div className="grid gap-x-8 gap-y-5 lg:grid-cols-[1fr_19rem] lg:grid-rows-[auto_1fr] lg:gap-y-0">
546 <header className="min-w-0 lg:col-start-1">
547 <h1 className="text-xl font-semibold tracking-tight text-balance wrap-break-word sm:text-2xl">
548 {item.title} <span className="font-normal text-faint">#{item.number}</span>
549 </h1>
550 <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted">
551 <ItemState item={item} />
552 <span className="flex min-w-0 items-center gap-2">
553 <Avatar name={item.author.username} size={18} />
554 <span className="min-w-0">
555 <PersonLink name={item.author.username} className="font-medium text-fg hover:underline" /> opened this{" "}
556 {item.requestedBy && (
557 <>
558 for <PersonLink name={item.requestedBy.username} className="font-medium text-fg-soft hover:underline" />{" "}
559 </>
560 )}
561 <TimeAgo at={item.createdAt} />
562 </span>
563 </span>
564 {item.labels.map((name) => (
565 // Without the workspace's labels, a chip still names its label, in the plain color.
566 <LabelChip key={name} name={name} color={isFailed(labels) ? undefined : labels.find((label) => label.name === name)?.color} />
567 ))}
568 {open && item.agent && <Assignee agent={item.agent} />}
569 </div>
570 </header>
571
572 <div className="min-w-0 max-lg:order-1 lg:col-start-1">
573 {item.resolutions.length > 0 && (
574 <ul className="space-y-2 lg:mt-5">
575 {item.resolutions.map((landed, index) => (
576 <li key={`${landed.repo}-${landed.pull ?? landed.commit ?? index}`}>
577 <Link
578 to={landed.pull != null ? `/${landed.repo}/pull/${landed.pull}` : `/${landed.repo}/commit/${landed.commit ?? ""}`}
579 className="flex items-start gap-3 rounded-xl border border-merged/40 bg-merged/5 px-4 py-3 text-sm transition-colors hover:border-merged/70"
580 >
581 {landed.pull != null ? <GitMerge size={18} className="mt-0.5 shrink-0 text-merged" /> : <GitCommitHorizontal size={18} className="mt-0.5 shrink-0 text-merged" />}
582 <span className="min-w-0">
583 Landed in <span className="font-mono font-medium">{landed.repo}</span> by{" "}
584 {landed.pull != null ? (
585 <span className="font-medium">#{landed.pull}</span>
586 ) : (
587 <span className="font-mono font-medium">{landed.commit?.slice(0, 7)}</span>
588 )}
589 {landed.pushedBy && <span className="text-muted">, pushed by {landed.pushedBy}</span>}
590 </span>
591 </Link>
592 </li>
593 ))}
594 </ul>
595 )}
596
597 {item.body ? (
598 <div className={`${item.resolutions.length > 0 ? "mt-5" : "lg:mt-5"} rounded-xl border border-line bg-surface p-4 sm:p-5`}>
599 <Markdown source={item.body} workspace={workspace} />
600 <Reactions reactions={loaderData.reactions} viewer={viewer?.username ?? null} className="mt-4" />
601 </div>
602 ) : (
603 (loaderData.reactions?.length ?? 0) > 0 && <Reactions reactions={loaderData.reactions} viewer={viewer?.username ?? null} className="mt-3 lg:mt-5" />
604 )}
605
606 <section aria-labelledby="repositories" className="mt-8">
607 <div className="flex flex-wrap items-center justify-between gap-2">
608 <h2 id="repositories" className="font-semibold tracking-tight">
609 Repositories
610 </h2>
611 <span className="flex items-center gap-3">
612 {missing.length > 0 && open && (
613 <span className="text-sm text-muted">
614 {missing.length} {missing.length === 1 ? "target" : "targets"} still to land
615 </span>
616 )}
617 {canLink && (
618 <span className="max-sm:hidden">
619 <LinkRepository workspace={workspace} repos={loaderData.repos} linked={item.repos.map((repo) => repo.path.toLowerCase())} error={linkError} />
620 </span>
621 )}
622 </span>
623 </div>
624 {isFailed(loaderData.coordination) && (
625 <div className="mt-2">
626 <Unavailable what="Each repository's pull requests" compact />
627 </div>
628 )}
629 {repoRows.length === 0 ? (
630 <div className="mt-3">
631 <EmptyState title="No repository yet">
632 Filed in the workspace itself. Link the repositories it should land in, and it closes once each of them has.
633 </EmptyState>
634 </div>
635 ) : (
636 <Card asChild divided className="mt-3">
637 <ul>
638 {repoRows.map(({ repo, pulls: repoPulls }) => (
639 <RepoCard key={repo.repoId} repo={repo} pulls={repoPulls} full={full} canLink={canLink} />
640 ))}
641 </ul>
642 </Card>
643 )}
644 {suggestions.length > 0 && open && (
645 <>
646 <h3 className="mt-5 text-sm font-medium">Suggested</h3>
647 <p className="mt-0.5 text-xs text-muted">Repositories it may also need, and why.</p>
648 <Card asChild divided className="mt-2">
649 <ul>
650 {suggestions.map((suggestion) => (
651 <SuggestionLine key={suggestion.repoId} suggestion={suggestion} workspace={workspace} canLink={canLink} />
652 ))}
653 </ul>
654 </Card>
655 </>
656 )}
657 {linkError && (
658 <div className="mt-2">
659 <ErrorText>{linkError}</ErrorText>
660 </div>
661 )}
662 </section>
663
664 {pulls.length === 0 && open && (
665 <div className="mt-6">
666 <EmptyState title="Nobody has worked on this yet">
667 Point your own agent at <code className="font-mono">{reference}</code>.
668 </EmptyState>
669 </div>
670 )}
671
672 {failure && <IntegrationFailure className="mt-4" failure={failure} base={failureBase} pullHref={pullHref} fixed={resolution?.passed ?? resolution?.fix} />}
673
674 {isFailed(loaderData.coordination) ? (
675 <>
676 <h2 className="mt-10 font-semibold tracking-tight">Decisions</h2>
677 <div className="mt-3">
678 <Unavailable what="Its decisions" />
679 </div>
680 </>
681 ) : (
682 decisions.length > 0 && (
683 <>
684 <div className="mt-10 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
685 <h2 className="font-semibold tracking-tight">Decisions</h2>
686 <p className="text-sm text-muted">Why the work is the way it is, and how sure that is</p>
687 </div>
688 <div className="mt-3">
689 <DecisionList decisions={decisions} base={failureBase} pullHref={pullHref} />
690 </div>
691 </>
692 )
693 )}
694
695 <h2 className="mt-10 font-semibold tracking-tight">Discussion</h2>
696 <div className="mt-3">
697 <div className="space-y-4">
698 {isFailed(loaderData.sessionTrees) && <Unavailable what="Its sessions" compact />}
699 <CommentList
700 comments={comments}
701 workspace={workspace}
702 viewerId={viewer?.id ?? null}
703 viewerName={viewer?.username ?? null}
704 extra={(sessionTrees ?? [])
705 .filter((session) => !session.parentId)
706 .map((session) => ({ id: session.id, at: session.startedAt, node: <TimelineSession session={session} base={sessionBase(session)} /> }))}
707 canModerate={loaderData.canModerate}
708 failed={actionData && "comment" in actionData ? { comment: String(actionData.comment), error: actionData.error } : null}
709 />
710 <div id="comment">
711 <CommentForm author={viewer?.username ?? null} resetKey={comments.length} handles={loaderData.handles} />
712 </div>
713 {canManage && (
714 <Form method="post" className="flex flex-wrap justify-end gap-2 max-sm:hidden">
715 {open ? (
716 <>
717 <SubmitButton variant="outline" name="action" value="close-not-planned" pending="Closing…">
718 Close as not planned
719 </SubmitButton>
720 <SubmitButton variant="outline" name="action" value="close-completed" pending="Closing…">
721 Close work item
722 </SubmitButton>
723 </>
724 ) : (
725 <SubmitButton variant="outline" name="action" value="reopen" pending="Reopening…">
726 Reopen work item
727 </SubmitButton>
728 )}
729 </Form>
730 )}
731 </div>
732 </div>
733 <div className="mt-2">
734 {/* A refused comment edit says so under the comment; the sidebar's own say so there. */}
735 {!(actionData && ("comment" in actionData || sideError)) && <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>}
736 </div>
737
738 {/* On a phone, its main actions at the bottom, above the tab bar and clear of the keyboard. */}
739 {(canManage || canLink) && (
740 <div className="sticky bottom-(--tabbar-h) z-10 -mx-4 mt-6 flex items-center gap-2 border-t border-line bg-bg/90 px-4 py-3 backdrop-blur in-data-[keyboard=open]:hidden sm:hidden">
741 {canLink && (
742 <div className="min-w-0 grow">
743 <LinkRepository workspace={workspace} repos={loaderData.repos} linked={item.repos.map((repo) => repo.path.toLowerCase())} error={null} wide />
744 </div>
745 )}
746 {canManage && (
747 <Form method="post" className="min-w-0 grow *:h-11 *:w-full">
748 {open ? (
749 <SubmitButton variant="outline" name="action" value="close-completed" pending="Closing…">
750 Close
751 </SubmitButton>
752 ) : (
753 <SubmitButton variant="outline" name="action" value="reopen" pending="Reopening…">
754 Reopen
755 </SubmitButton>
756 )}
757 </Form>
758 )}
759 </div>
760 )}
761 </div>
762
763 <DetailsDisclosure
764 className="max-lg:order-2 lg:col-start-2 lg:row-span-2 lg:row-start-1"
765 summary={detailsSummary([
766 [coordination?.claims.length ?? 0, "claim", "claims"],
767 [item.assignees.length, "assignee", "assignees"],
768 [item.labels.length, "label", "labels"],
769 [item.milestone ? 1 : 0, "milestone", "milestones"],
770 ])}
771 >
772 {isFailed(loaderData.coordination) ? (
773 <section>
774 <h3 className="text-sm font-medium">Work</h3>
775 <div className="mt-2">
776 <Unavailable what="Who is on it" compact />
777 </div>
778 </section>
779 ) : (
780 coordination && (
781 <Boundary what="Work" fallback={<Unavailable what="Who is on it" compact />}>
782 <ItemWorkPanel
783 work={coordination}
784 ended={loaderData.endedClaims}
785 base={sessionBase}
786 now={now}
787 canEdit={canManage}
788 error={actionData && "action" in actionData && actionData.action === "blocked-by" ? actionData.error : null}
789 sessions={sessionTrees}
790 start={
791 loaderData.agentSetup && open && workIn ? (
792 <StartSessionDialog
793 setup={loaderData.agentSetup}
794 repo={workIn}
795 error={actionData && "action" in actionData && actionData.action === "start-session" ? actionData.error : null}
796 />
797 ) : undefined
798 }
799 />
800 </Boundary>
801 )
802 )}
803 {isFailed(loaderData.links) ? (
804 <section>
805 <h3 className="text-sm font-medium">From outside g1t</h3>
806 <div className="mt-2">
807 <Unavailable what="Its links" compact />
808 </div>
809 </section>
810 ) : (
811 loaderData.links.length > 0 && (
812 <section>
813 <h3 className="text-sm font-medium">From outside g1t</h3>
814 <ul className="mt-2 space-y-1.5 text-sm">
815 {loaderData.links.map((link) => (
816 <li key={`${link.connectionId}-${link.key}`}>
817 <a
818 href={link.url}
819 target="_blank"
820 rel="noreferrer"
821 className="group flex items-center gap-2 rounded-lg border border-line px-2.5 py-2 transition-colors hover:border-line-strong"
822 >
823 <span className="min-w-0 grow">
824 <span className="block truncate font-medium">
825 {PROVIDERS[link.provider].label} <span className="font-mono text-muted">{link.key}</span>
826 </span>
827 <span className="block text-xs text-faint">
828 {link.count > 1 ? `Seen ${link.count} times, last ` : "Linked "}
829 <TimeAgo at={link.count > 1 ? link.lastSeen : link.firstSeen} />
830 </span>
831 </span>
832 <ExternalLink size={13} className="shrink-0 text-faint group-hover:text-fg" />
833 </a>
834 </li>
835 ))}
836 </ul>
837 </section>
838 )
839 )}
840 <section>
841 <h3 className="text-sm font-medium">Assignees</h3>
842 <ul className="mt-2 space-y-1.5 text-sm">
843 {item.assignees.map((name) => (
844 <li key={name} className="flex items-center gap-2 px-1">
845 <Avatar name={name} size={20} agent={name === "g1t"} />
846 <span className="grow truncate font-mono text-xs">{name === "g1t" ? "g1t" : <PersonLink name={name} className="hover:underline" />}</span>
847 </li>
848 ))}
849 {item.assignees.length === 0 && <li className="px-1 text-xs text-faint">Nobody yet</li>}
850 </ul>
851 {!coordination && item.blockedBy.length > 0 && (
852 <p className="mt-3 text-xs text-muted">
853 Depends on{" "}
854 {item.blockedBy.map((number, index) => (
855 <span key={number}>
856 {index > 0 && ", "}
857 <Link to={itemPath(workspace, number)} className="font-medium text-fg hover:underline">
858 #{number}
859 </Link>
860 </span>
861 ))}
862 {open ? ", which has to land first." : "."}
863 </p>
864 )}
865 {viewer && can.triage && open && (
866 <div className="mt-3 space-y-2">
867 {/* g1t takes it on in a session on the workspace's Claude, as whoever assigned it. */}
868 {loaderData.agentSetup && !item.assignees.includes("g1t") && (
869 <Form method="post">
870 <input type="hidden" name="action" value="assign" />
871 {item.assignees.map((name) => (
872 <input key={name} type="hidden" name="assignee" value={name} />
873 ))}
874 <input type="hidden" name="assignee" value="g1t" />
875 <div className="*:w-full">
876 <SubmitButton variant="outline" name="who" value="g1t" pending="Assigning…">
877 <Bot size={14} />
878 Assign to g1t
879 </SubmitButton>
880 </div>
881 </Form>
882 )}
883 {!item.assignees.includes(viewer.username) && (
884 <Form method="post">
885 <input type="hidden" name="action" value="assign" />
886 {item.assignees.map((name) => (
887 <input key={name} type="hidden" name="assignee" value={name} />
888 ))}
889 <input type="hidden" name="assignee" value={viewer.username} />
890 <div className="*:w-full">
891 <SubmitButton variant="outline" name="who" value="self" pending="Assigning…">
892 Assign yourself
893 </SubmitButton>
894 </div>
895 </Form>
896 )}
897 <details className="group">
898 <summary className="cursor-pointer text-xs text-faint hover:text-fg">Assign people</summary>
899 <Form method="post" className="mt-2 space-y-2" key={item.assignees.join()}>
900 <input type="hidden" name="action" value="assign" />
901 {/* Without the workspace's people the picker still holds who is
902 assigned now, so saving never clears them. */}
903 {isFailed(members) && <Unavailable what="The workspace's people" compact />}
904 <PeoplePicker name="assignee" members={isFailed(members) ? [] : members} chosen={item.assignees} />
905 <SubmitButton variant="outline" name="who" value="picked" pending="Saving…">
906 Save assignees
907 </SubmitButton>
908 </Form>
909 </details>
910 </div>
911 )}
912 </section>
913
914 {/* Another way to start the work: not once a pull request is doing it. */}
915 {open && !pulls.some((pull) => pull.status === "open" || pull.status === "draft") && (
916 <Card asChild className="p-4">
917 <section>
918 <h3 className="text-sm font-medium">Bring your own agent</h3>
919 <p className="mt-1 text-xs text-muted">With g1t connected to your agent, ask it to work on this work item.</p>
920 <div className="mt-3">
921 <CopyLine text={reference} />
922 </div>
923 {viewer && item.repos.length > 0 ? (
924 <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4">
925 <input type="hidden" name="action" value="open-pull" />
926 <p className="text-xs text-muted">Or open a draft pull request yourself and get a fork to push to.</p>
927 <SelectField
928 name="repo"
929 aria-label="Repository"
930 defaultValue={workIn ?? item.repos[0]!.path}
931 options={item.repos.map((repo) => ({ value: repo.path, label: `${repo.path} (${ROLE_LABELS[repo.role].toLowerCase()})` }))}
932 />
933 <Input name="agent" placeholder="Who is working, e.g. claude-code" maxLength={60} />
934 <div className="*:w-full">
935 <SubmitButton match={{ action: "open-pull" }} pending="Opening…">
936 Open pull request
937 </SubmitButton>
938 </div>
939 {actionData && "action" in actionData && actionData.action === "open-pull" && <ErrorText>{actionData.error}</ErrorText>}
940 {workIn && (
941 <p className="text-xs text-muted">
942 Already pushed a branch?{" "}
943 <Link to={`/${workIn}/pulls/new?item=${item.number}`} className="text-fg underline underline-offset-4">
944 Open a pull request from it
945 </Link>
946 .
947 </p>
948 )}
949 </Form>
950 ) : (
951 !viewer && (
952 <p className="mt-4 border-t border-line pt-4 text-sm text-muted">
953 <Link to="/login" className="text-fg underline underline-offset-4">
954 Sign in
955 </Link>{" "}
956 to open a pull request.
957 </p>
958 )
959 )}
960 </section>
961 </Card>
962 )}
963
964 {isFailed(labels) ? (
965 <SideUnavailable heading="Labels" what="Labels" />
966 ) : (
967 <LabelsBox
968 labels={labels}
969 chosen={item.labels}
970 canEdit={canManage}
971 canCreate={can.manage_labels}
972 manageUrl={can.manage_labels ? workPath(workspace, "labels") : undefined}
973 />
974 )}
975 {isFailed(loaderData.milestones) ? (
976 <SideUnavailable heading="Milestone" what="Milestones" />
977 ) : (
978 <MilestoneBox milestones={loaderData.milestones} current={item.milestone} canEdit={can.triage} base={workPath(workspace)} />
979 )}
980 {viewer &&
981 (isFailed(loaderData.subscription) ? (
982 <SideUnavailable heading="Notifications" what="Your notification settings" />
983 ) : (
984 <SubscriptionBox action={itemPath(workspace, item.number)} number={item.number} kind="item" subscription={loaderData.subscription} />
985 ))}
986 </DetailsDisclosure>
987 </div>
988 );
989}
990
991/** A sidebar section, under its usual heading, that could not be loaded. */
992function SideUnavailable({ heading, what }: { heading: string; what: string }) {
993 return (
994 <section>
995 <h3 className="text-sm font-medium">{heading}</h3>
996 <div className="mt-2">
997 <Unavailable what={what} compact />
998 </div>
999 </section>
1000 );
1001}