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,197 linesCodeBlameRaw
1/**
2 * Coordinating work, drawn: claims, harness sessions, dependencies,
3 * integration failures and decision records, as the issue page, the pull
4 * request page, the Work page, a project's overview and the why panel
5 * show them. What each says
6 * is worked out in lib/coordination.ts.
7 */
8import {
9 Activity,
10 ArrowRightLeft,
11 Bot,
12 ChevronRight,
13 CircleCheck,
14 CircleDot,
15 CircleX,
16 GitBranch,
17 GitCommitHorizontal,
18 GitMerge,
19 Hand,
20 Layers,
21 Link2,
22 MessageSquare,
23 MoreHorizontal,
24 Pencil,
25 Play,
26 Sparkles,
27 Square,
28 Timer,
29 TriangleAlert,
30} from "lucide-react";
31import { Fragment, type ReactNode, useEffect, useState } from "react";
32import { Form, Link, useFetcher } from "react-router";
33
34import {
35 AGENT_MODES,
36 type AgentSessionsSetup,
37 type Blocker,
38 type Claim,
39 type Decision,
40 type DecisionConfidence,
41 type Harness,
42 type Pull,
43 type SessionState,
44 type State,
45 type WorkContext,
46 type WorkSession,
47} from "@g1t/contracts";
48
49import {
50 CLAIM_ENDED,
51 CONFIDENCE,
52 DECISION_KINDS,
53 type FeedLine,
54 SESSION_STATES,
55 type SessionNode,
56 checkHref,
57 evidenceOf,
58 expiresIn,
59 harnessName,
60 type LiveGroup,
61 type LiveIssue,
62 awakeAndIdle,
63 liveByIssue,
64 mayEndSession,
65 mergeSessions,
66 numberedParts,
67 type SessionEnder,
68 sessionTitle,
69 sessionTree,
70} from "../lib/coordination";
71import { PLUGIN_INSTALL } from "../lib/agent-setup";
72import { CopyLine, EmptyState, Input, SubmitButton, TimeAgo } from "./ui";
73import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
74import {
75 AlertDialog,
76 AlertDialogCancel,
77 AlertDialogContent,
78 AlertDialogDescription,
79 AlertDialogFooter,
80 AlertDialogHeader,
81 AlertDialogTitle,
82} from "./ui/alert-dialog";
83import { Avatar } from "./ui/avatar";
84import { Badge, type BadgeTone } from "./ui/badge";
85import { Button } from "./ui/button";
86import { Card } from "./ui/card";
87import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
88import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
89import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
90import { Field, FieldError, FieldLabel, FieldLegend, FieldSet } from "./ui/field";
91import { Hint } from "./ui/hint";
92import { Label } from "./ui/label";
93import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
94import { RadioGroup, RadioOption } from "./ui/radio-group";
95import { Textarea } from "./ui/textarea";
96import { Markdown } from "./markdown";
97import { PersonLink, TimelineItem } from "./work";
98import { repoAt } from "../lib/markdown-plugins";
99import { IssueIcon, PullIcon } from "./work-icons";
100
101/** Text with each `#N` linked to that issue or pull request (the issue's address leads to a pull request too). */
102export function NumberedText({ text, base }: { text: string; base: string }) {
103 return (
104 <>
105 {numberedParts(text).map((part, index) =>
106 typeof part === "number" ? (
107 <Link key={index} to={`${base}/issues/${part}`} className="font-medium text-fg hover:text-accent">
108 #{part}
109 </Link>
110 ) : (
111 <Fragment key={index}>{part}</Fragment>
112 ),
113 )}
114 </>
115 );
116}
117
118/** The harness a session or claim came from. */
119export function HarnessBadge({ harness }: { harness: Harness | null | undefined }) {
120 const name = harnessName(harness);
121 if (!name) return null;
122 return <Badge>{name}</Badge>;
123}
124
125/** Who did it: an agent's avatar when it came through a harness (a person's token, the harness declared), else theirs. */
126export function Actor({ username, harness, size = 20 }: { username: string; harness?: Harness | null; size?: number }) {
127 return <Avatar name={username} size={size} agent={harness != null} />;
128}
129
130const SESSION_TONE: Record<SessionState, BadgeTone> = { active: "success", idle: "warn", ended: "neutral" };
131
132export function SessionStateBadge({ state }: { state: SessionState }) {
133 const { label, hint } = SESSION_STATES[state];
134 return (
135 <Hint label={hint}>
136 <Badge tone={SESSION_TONE[state]}>{label}</Badge>
137 </Hint>
138 );
139}
140
141/** How sure a decision is: claimed, tested and approved read apart at a glance. */
142export function ConfidenceBadge({ confidence }: { confidence: DecisionConfidence }) {
143 const { label, hint, tone } = CONFIDENCE[confidence];
144 const Icon = confidence === "approved" ? CircleCheck : confidence === "tested" ? Layers : MessageSquare;
145 return (
146 <Hint label={hint}>
147 <Badge tone={tone}>
148 <Icon size={11} />
149 {label}
150 </Badge>
151 </Hint>
152 );
153}
154
155/** One claim: who, from which harness, since when, and when it lapses; one no longer active is muted. */
156export function ClaimLine({ claim, now, showIssue, base }: { claim: Claim; now: number; showIssue?: boolean; base: string }) {
157 const active = claim.state === "active";
158 const left = expiresIn(claim, now);
159 return (
160 <div className={active ? "flex items-start gap-2.5" : "flex items-start gap-2.5 opacity-60"}>
161 <Actor username={claim.actor.username} harness={claim.harness} />
162 <div className="min-w-0 grow">
163 <p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm">
164 <PersonLink name={claim.actor.username} className="font-medium hover:underline" />
165 {showIssue ? (
166 <span className="text-muted">
167 claimed{" "}
168 <Link to={`${base}/issues/${claim.issue}`} className="font-medium text-fg hover:text-accent">
169 #{claim.issue}
170 </Link>
171 </span>
172 ) : null}
173 <HarnessBadge harness={claim.harness} />
174 {!active && <Badge>{CLAIM_ENDED[claim.state as Exclude<Claim["state"], "active">]}</Badge>}
175 </p>
176 <p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
177 <span>
178 since <TimeAgo at={claim.createdAt} />
179 </span>
180 {left && (
181 <span className="inline-flex items-center gap-1" suppressHydrationWarning>
182 · <Timer size={11} />
183 {left}
184 </span>
185 )}
186 {!active && claim.state === "handed_off" && <span>· to {claim.handoffTo ?? "anyone"}</span>}
187 </p>
188 {claim.note && <p className="mt-1 text-xs text-fg-soft">{claim.note}</p>}
189 </div>
190 </div>
191 );
192}
193
194/**
195 * Asking before a session is ended by hand, for one its harness left
196 * behind (killed, or never told to end): posts `end` to the session's
197 * page, and closes once it has ended, with the page read again.
198 */
199export function EndSessionDialog({
200 session,
201 base,
202 open,
203 onOpenChange,
204}: {
205 session: WorkSession;
206 base: string;
207 open: boolean;
208 onOpenChange: (open: boolean) => void;
209}) {
210 const fetcher = useFetcher<{ error?: string; ended?: boolean } | null>();
211 const busy = fetcher.state !== "idle";
212 const error = !busy ? fetcher.data?.error : undefined;
213 useEffect(() => {
214 if (fetcher.state === "idle" && fetcher.data?.ended) onOpenChange(false);
215 }, [fetcher.state, fetcher.data, onOpenChange]);
216 const subagents = session.subagents ?? 0;
217 return (
218 <AlertDialog open={open} onOpenChange={onOpenChange}>
219 <AlertDialogContent>
220 <fetcher.Form method="post" action={`${base}/work/sessions/${session.id}`} className="grid gap-5">
221 <input type="hidden" name="action" value="end" />
222 <AlertDialogHeader>
223 <AlertDialogTitle>End {sessionTitle(session)}?</AlertDialogTitle>
224 <AlertDialogDescription>
225 For a session its harness left behind: killed, or never told to end. It shows as ended by hand by you
226 {subagents > 0 ? `, and its ${subagents === 1 ? "subagent" : "subagents"} still going end with it` : ""}. Its claims stay until they are
227 released or expire. If its harness is still running, the session comes back the next time it is heard from.
228 </AlertDialogDescription>
229 </AlertDialogHeader>
230 {error && (
231 <Alert tone="danger">
232 <AlertDescription>{error}</AlertDescription>
233 </Alert>
234 )}
235 <AlertDialogFooter>
236 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
237 <SubmitButton variant="destructive" fetcher={fetcher} pending="Ending…">
238 End session
239 </SubmitButton>
240 </AlertDialogFooter>
241 </fetcher.Form>
242 </AlertDialogContent>
243 </AlertDialog>
244 );
245}
246
247/** A session's ⋯ menu in a list, for whoever may end it: End session. */
248function SessionMenu({ session, base }: { session: WorkSession; base: string }) {
249 const [ending, setEnding] = useState(false);
250 return (
251 <>
252 <DropdownMenu>
253 <Hint label="More">
254 <DropdownMenuTrigger asChild>
255 <Button type="button" variant="ghost" size="icon-xs" aria-label={`More for ${sessionTitle(session)}`}>
256 <MoreHorizontal size={15} />
257 </Button>
258 </DropdownMenuTrigger>
259 </Hint>
260 <DropdownMenuContent align="end">
261 <DropdownMenuItem variant="destructive" onSelect={() => setEnding(true)}>
262 <Square />
263 End session
264 </DropdownMenuItem>
265 </DropdownMenuContent>
266 </DropdownMenu>
267 <EndSessionDialog session={session} base={base} open={ending} onOpenChange={setEnding} />
268 </>
269 );
270}
271
272/** One harness session: who, which harness, where it stands, when it was last heard from, linked to its page. */
273export function SessionLine({
274 session,
275 base,
276 showWhere = true,
277 showBranch = true,
278 ender,
279}: {
280 session: WorkSession;
281 base: string;
282 /** Its pull request and branch; not where they are already said. */
283 showWhere?: boolean;
284 /** Its branch; not in a narrow sidebar. */
285 showBranch?: boolean;
286 /** Who is looking, for a menu to end it when they may. */
287 ender?: SessionEnder;
288}) {
289 const endable = ender != null && mayEndSession(session, ender.viewerId, ender.canPush);
290 return (
291 <div className="flex items-start gap-2.5">
292 <Actor username={session.actor.username} harness={session.harness} />
293 <div className="min-w-0 grow">
294 <p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm">
295 <Link to={`${base}/work/sessions/${session.id}`} prefetch="intent" className="font-medium hover:text-accent">
296 {sessionTitle(session)}
297 </Link>
298 <HarnessBadge harness={session.harness} />
299 <SessionStateBadge state={session.state} />
300 </p>
301 <p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
302 <PersonLink name={session.actor.username} className="hover:text-fg" />
303 <span>
304 · {session.state === "ended" ? "ended" : "last seen"} <TimeAgo at={session.endedAt ?? session.lastSeenAt} />
305 </span>
306 {showWhere && session.pull != null && (
307 <span>
308 · on{" "}
309 <Link to={`${base}/pull/${session.pull}`} className="text-fg hover:text-accent">
310 #{session.pull}
311 </Link>
312 </span>
313 )}
314 {showWhere && showBranch && session.branch && (
315 <span className="inline-flex min-w-0 items-center gap-1 font-mono">
316 · <GitBranch size={11} />
317 <span className="truncate">{session.branch}</span>
318 </span>
319 )}
320 </p>
321 </div>
322 {endable && (
323 <span className="-my-0.5 shrink-0">
324 <SessionMenu session={session} base={base} />
325 </span>
326 )}
327 </div>
328 );
329}
330
331/**
332 * A session in an issue's or pull request's conversation, where it
333 * started, as a card like a comment or a review: g1t working for someone
334 * (a session g1t started on the workspace's Claude, when it was assigned
335 * the issue or asked in the thread), or someone working in their own
336 * harness. What it is about, where it stands now, its branch, and what it
337 * reported once it ended, linked to its page.
338 */
339export function TimelineSession({ session, base }: { session: WorkSession; base: string }) {
340 const g1t = session.harness === "claude_managed_agents";
341 const ended = session.state === "ended";
342 return (
343 <TimelineItem
344 id={session.id}
345 author={g1t ? "g1t" : session.actor.username}
346 bot={g1t}
347 at={session.startedAt}
348 action={
349 <>
350 {g1t ? (
351 <span>
352 {ended ? "worked on this for" : "is working on this for"}{" "}
353 <PersonLink name={session.actor.username} className="font-medium text-fg hover:underline">
354 @{session.actor.username}
355 </PersonLink>
356 </span>
357 ) : (
358 <span>{ended ? "worked on this in a session" : "is working on this in a session"}</span>
359 )}
360 <HarnessBadge harness={session.harness} />
361 <SessionStateBadge state={session.state} />
362 </>
363 }
364 aside={
365 <Link
366 to={`${base}/work/sessions/${session.id}`}
367 prefetch="intent"
368 className="inline-flex items-center gap-1 text-xs text-faint hover:text-fg"
369 >
370 <Activity size={12} />
371 View session
372 </Link>
373 }
374 >
375 <Link to={`${base}/work/sessions/${session.id}`} prefetch="intent" className="font-medium hover:text-accent">
376 {sessionTitle(session)}
377 </Link>
378 <p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
379 {ended ? (
380 <span>
381 Ended <TimeAgo at={session.endedAt ?? session.lastSeenAt} />
382 </span>
383 ) : (
384 <span>
385 Last heard from <TimeAgo at={session.lastSeenAt} />
386 </span>
387 )}
388 {session.branch && (
389 <span className="inline-flex min-w-0 items-center gap-1 font-mono">
390 · <GitBranch size={11} />
391 <span className="truncate">{session.branch}</span>
392 </span>
393 )}
394 {session.pull != null && (
395 <span>
396 · on{" "}
397 <Link to={`${base}/pull/${session.pull}`} className="text-fg hover:text-accent">
398 #{session.pull}
399 </Link>
400 </span>
401 )}
402 {(session.subagents ?? 0) > 0 && (
403 <span>
404 · {session.subagents} {session.subagents === 1 ? "subagent" : "subagents"}
405 </span>
406 )}
407 </p>
408 {ended && session.result && (
409 <div className="mt-3 border-t border-line pt-3 text-sm">
410 <Markdown source={session.result} repo={repoAt(base)} />
411 </div>
412 )}
413 </TimelineItem>
414 );
415}
416
417/** A subagent in a line: its kind, its task, where it stands, linked to its page. */
418export function SubagentLine({ session, base }: { session: WorkSession; base: string }) {
419 return (
420 <div className="flex min-w-0 items-center gap-1.5 text-xs">
421 <Bot size={12} className="shrink-0 text-faint" />
422 <Link to={`${base}/work/sessions/${session.id}`} prefetch="intent" className="shrink-0 font-medium text-fg hover:text-accent">
423 {session.agentType ?? "Subagent"}
424 </Link>
425 {session.task && (
426 <Hint label={session.task}>
427 <span className="min-w-0 truncate text-muted">{session.task}</span>
428 </Hint>
429 )}
430 <SessionStateBadge state={session.state} />
431 </div>
432 );
433}
434
435/** Subagents under a session, and theirs under them, indented on a rule. */
436function SubagentBranch({ nodes, base }: { nodes: SessionNode[]; base: string }) {
437 return (
438 <ul className="mt-1.5 space-y-1.5 border-l border-line pl-3">
439 {nodes.map((node) => (
440 <li key={node.session.id}>
441 <SubagentLine session={node.session} base={base} />
442 {node.children.length > 0 && <SubagentBranch nodes={node.children} base={base} />}
443 </li>
444 ))}
445 </ul>
446 );
447}
448
449/** Sessions as trees: each one's line, with its subagents in a compact list under it. */
450export function SessionTreeList({
451 nodes,
452 base,
453 showWhere,
454 showBranch,
455 className,
456 ender,
457}: {
458 nodes: SessionNode[];
459 base: string;
460 showWhere?: boolean;
461 showBranch?: boolean;
462 className?: string;
463 ender?: SessionEnder;
464}) {
465 return (
466 <ul className={className ?? "space-y-3"}>
467 {nodes.map((node) => (
468 <li key={node.session.id}>
469 <SessionLine session={node.session} base={base} showWhere={showWhere} showBranch={showBranch} ender={ender} />
470 {node.children.length > 0 && (
471 <div className="pl-7.5">
472 <SubagentBranch nodes={node.children} base={base} />
473 </div>
474 )}
475 </li>
476 ))}
477 </ul>
478 );
479}
480
481/**
482 * Starting a session on the issue on the workspace's Anthropic account: a
483 * button opening a dialog that asks what kind of work and for anything to
484 * add, posting `start-session` to the issue's action.
485 */
486export function StartSessionDialog({ setup, error }: { setup: AgentSessionsSetup; error?: string | null }) {
487 const [open, setOpen] = useState(Boolean(error));
488 return (
489 <Dialog open={open} onOpenChange={setOpen}>
490 <DialogTrigger asChild>
491 <Button variant="outline" size="xs">
492 <Sparkles size={12} />
493 Start a session
494 </Button>
495 </DialogTrigger>
496 <DialogContent>
497 <Form method="post" className="grid gap-5">
498 <input type="hidden" name="action" value="start-session" />
499 <DialogHeader>
500 <DialogTitle>Start a session on this issue</DialogTitle>
501 <DialogDescription>
502 Claude works on it on {setup.connectionName ? `the ${setup.connectionName} connection` : "your workspace's Anthropic account"}
503 {setup.model ? `, with ${setup.model}` : ""}. It shows on this issue as it goes, with its subagents.
504 </DialogDescription>
505 </DialogHeader>
506 <FieldSet>
507 <FieldLegend className="mb-0">What to do</FieldLegend>
508 <RadioGroup name="mode" defaultValue="implement">
509 {AGENT_MODES.map((mode) => (
510 <RadioOption key={mode.mode} value={mode.mode} label={mode.label} description={mode.hint} />
511 ))}
512 </RadioGroup>
513 </FieldSet>
514 <Field>
515 <FieldLabel htmlFor="start-session-instruction">Anything to add</FieldLabel>
516 <Textarea id="start-session-instruction" name="instruction" rows={3} maxLength={4000} placeholder="Optional: what to keep in mind" />
517 <FieldError>{error}</FieldError>
518 </Field>
519 <DialogFooter>
520 <DialogClose asChild>
521 <Button variant="ghost">Cancel</Button>
522 </DialogClose>
523 <SubmitButton match={{ action: "start-session" }} pending="Starting…">
524 Start
525 </SubmitButton>
526 </DialogFooter>
527 </Form>
528 </DialogContent>
529 </Dialog>
530 );
531}
532
533const KIND_ICON: Record<Decision["kind"], ReactNode> = {
534 proposal: <MessageSquare size={14} />,
535 claim: <MessageSquare size={14} />,
536 integration_failure: <CircleX size={14} className="text-danger" />,
537 integration_passed: <Layers size={14} className="text-success" />,
538 fix: <Link2 size={14} className="text-accent" />,
539 approval: <CircleCheck size={14} className="text-success" />,
540 merge: <GitMerge size={14} className="text-merged" />,
541 note: <MessageSquare size={14} />,
542};
543
544/** The pull requests tested together, in queue order: `#41 + #44`. */
545export function Combination({ numbers, base, culprit }: { numbers: number[]; base: string; culprit?: number | null }) {
546 return (
547 <span className="inline-flex flex-wrap items-center gap-1">
548 {numbers.map((number, index) => (
549 <Fragment key={number}>
550 {index > 0 && <span className="text-faint">+</span>}
551 <Link to={`${base}/pull/${number}`}>
552 <Badge shape="tag" tone={number === culprit ? "danger" : "neutral"} className="font-mono hover:border-line-strong">
553 #{number}
554 </Badge>
555 </Link>
556 </Fragment>
557 ))}
558 </span>
559 );
560}
561
562/** The check a decision rests on, linked where it can be seen. */
563function CheckLink({ check }: { check: { name: string; conclusion: string; url: string | null } }) {
564 const href = checkHref(check.url);
565 const text = (
566 <>
567 <span className="font-mono">{check.name}</span> {check.conclusion}
568 </>
569 );
570 if (!href) return <span>{text}</span>;
571 return href.startsWith("/") ? (
572 <Link to={href} className="text-fg underline-offset-2 hover:underline">
573 {text}
574 </Link>
575 ) : (
576 <a href={href} className="text-fg underline-offset-2 hover:underline" rel="noreferrer" target="_blank">
577 {text}
578 </a>
579 );
580}
581
582/** One decision record: its kind, how sure it is, the sentence, who and when, and what it rests on. */
583export function DecisionLine({ decision, base }: { decision: Decision; base: string }) {
584 const evidence = evidenceOf(decision);
585 return (
586 <div className="flex items-start gap-2.5">
587 <span className="mt-0.5 shrink-0 text-faint">{KIND_ICON[decision.kind]}</span>
588 <div className="min-w-0 grow">
589 <p className="flex flex-wrap items-center gap-1.5 text-xs">
590 <span className="font-medium text-fg">{DECISION_KINDS[decision.kind]}</span>
591 <ConfidenceBadge confidence={decision.confidence} />
592 {decision.pull != null && (
593 <Link to={`${base}/pull/${decision.pull}`} className="text-muted hover:text-fg">
594 #{decision.pull}
595 </Link>
596 )}
597 </p>
598 <p className="mt-1 text-sm wrap-break-word">
599 <NumberedText text={decision.summary} base={base} />
600 </p>
601 <p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
602 <span>{decision.actor ?? "g1t"}</span>
603 <span>
604 · <TimeAgo at={decision.createdAt} />
605 </span>
606 {evidence.combination.length > 1 && (
607 <span className="inline-flex items-center gap-1.5">
608 · tested as <Combination numbers={evidence.combination} base={base} culprit={decision.kind === "integration_failure" ? decision.pull : null} />
609 </span>
610 )}
611 {evidence.check && decision.kind === "integration_failure" && (
612 <span>
613 · <CheckLink check={evidence.check} />
614 </span>
615 )}
616 {evidence.commit && (
617 <span className="inline-flex items-center gap-1 font-mono">
618 · <GitCommitHorizontal size={11} />
619 {evidence.commit.slice(0, 7)}
620 </span>
621 )}
622 </p>
623 </div>
624 </div>
625 );
626}
627
628/** Decision records, as a list in a card. */
629export function DecisionList({ decisions, base }: { decisions: Decision[]; base: string }) {
630 return (
631 <Card asChild divided>
632 <ol>
633 {decisions.map((decision) => (
634 <li key={decision.id} className="px-4 py-3">
635 <DecisionLine decision={decision} base={base} />
636 </li>
637 ))}
638 </ol>
639 </Card>
640 );
641}
642
643/**
644 * A combination that failed in the merge queue: which pull requests were
645 * tested together, the check that failed, which ones it fails with, and
646 * whether it has been answered since.
647 */
648export function IntegrationFailure({
649 failure,
650 base,
651 fixed,
652 branch = "main",
653 className,
654}: {
655 failure: Decision;
656 base: string;
657 /** The branch the combination was built on. */
658 branch?: string;
659 /** A fix or a passing combination since, when there is one. */
660 fixed?: Decision | null;
661 className?: string;
662}) {
663 const evidence = evidenceOf(failure);
664 return (
665 <Alert tone={fixed ? "info" : "danger"} className={className}>
666 <AlertTitle className="flex flex-wrap items-center gap-x-2 gap-y-1 text-fg">
667 {failure.pull != null ? (
668 <span>
669 <TriangleAlert size={15} className={`mr-2 inline align-[-3px] ${fixed ? "text-info" : "text-danger"}`} />
670 <Link to={`${base}/pull/${failure.pull}`} className="hover:underline">
671 #{failure.pull}
672 </Link>{" "}
673 failed in the merge queue {evidence.combination.length > 1 ? "together with others" : "on its own"}
674 </span>
675 ) : (
676 <span>A combination failed in the merge queue</span>
677 )}
678 <ConfidenceBadge confidence={failure.confidence} />
679 <span className="text-xs font-normal text-muted">
680 <TimeAgo at={failure.createdAt} />
681 </span>
682 </AlertTitle>
683 <AlertDescription className="space-y-1.5">
684 {evidence.combination.length > 0 && (
685 <p className="flex flex-wrap items-center gap-1.5 text-xs">
686 Tested as <span className="font-mono text-fg">{branch}</span> <span className="text-faint">+</span>
687 <Combination numbers={evidence.combination} base={base} culprit={failure.pull} />
688 </p>
689 )}
690 {evidence.check && (
691 <p className="text-xs">
692 Failing check: <CheckLink check={evidence.check} />
693 </p>
694 )}
695 <p className="text-xs">
696 {evidence.suspects.length > 0 ? (
697 <>
698 Fails with{" "}
699 <NumberedText text={evidence.suspects.map((n) => `#${n}`).join(", ")} base={base} />
700 {evidence.bisected ? ", narrowed down by building smaller combinations." : "."}
701 </>
702 ) : (
703 "It fails by itself: the change is the cause."
704 )}
705 </p>
706 {fixed && (
707 <p className="text-xs text-fg">
708 <CircleCheck size={13} className="mr-1.5 inline align-[-2px] text-success" />
709 {fixed.kind === "integration_passed" ? "Passed together since" : "Fixed since"}:{" "}
710 <span className="text-muted">
711 <NumberedText text={fixed.summary} base={base} />
712 </span>
713 </p>
714 )}
715 </AlertDescription>
716 </Alert>
717 );
718}
719
720const FEED_ICON: Record<FeedLine["type"], ReactNode> = {
721 "work.claimed": <Hand size={14} />,
722 "work.released": <Hand size={14} />,
723 "work.claim_expired": <Timer size={14} />,
724 "work.handoff_requested": <ArrowRightLeft size={14} />,
725 "session.started": <Play size={14} />,
726 "session.ended": <Square size={14} />,
727 "dependency.changed": <Link2 size={14} />,
728 "overlap.detected": <TriangleAlert size={14} />,
729 "integration.failed": <CircleX size={14} />,
730 "integration.passed": <Layers size={14} />,
731 "decision.recorded": <CircleDot size={14} />,
732};
733
734const FEED_TONE: Record<FeedLine["tone"], string> = {
735 neutral: "text-faint",
736 accent: "text-accent",
737 success: "text-success",
738 warn: "text-warn",
739 danger: "text-danger",
740 info: "text-info",
741};
742
743/** Recent coordination events, newest first, one line each. */
744export function CoordinationFeed({ lines, base }: { lines: FeedLine[]; base: string }) {
745 return (
746 <Card asChild divided>
747 <ol>
748 {lines.map((line) => (
749 <li key={line.id} className="flex items-start gap-3 px-4 py-2.5 text-sm">
750 <span className={`mt-0.5 shrink-0 ${FEED_TONE[line.tone]}`}>{FEED_ICON[line.type]}</span>
751 <p className="min-w-0 grow wrap-break-word text-muted">
752 <PersonLink name={line.who ?? "g1t"} className="font-medium text-fg hover:underline" />
753 {line.harness && <span className="text-faint"> via {harnessName(line.harness)}</span>}{" "}
754 <NumberedText text={line.text} base={base} />
755 {line.session && (
756 <>
757 {" · "}
758 <Link to={`${base}/work/sessions/${line.session}`} className="text-fg hover:text-accent">
759 the session
760 </Link>
761 </>
762 )}
763 </p>
764 <span className="shrink-0 text-xs text-faint">
765 <TimeAgo at={line.time} />
766 </span>
767 </li>
768 ))}
769 </ol>
770 </Card>
771 );
772}
773
774/** An issue's place among others: one it waits on, or one waiting on it. */
775function DependencyLine({ base, number, title, state, detail }: { base: string; number: number; title: string; state: State; detail?: ReactNode }) {
776 return (
777 <li className="flex items-start gap-2">
778 <span className="mt-0.5">
779 <IssueIcon issue={{ state, reason: null }} size={14} />
780 </span>
781 <span className="min-w-0 grow">
782 <Link to={`${base}/issues/${number}`} className="block truncate text-sm hover:text-accent">
783 {title} <span className="text-faint">#{number}</span>
784 </Link>
785 {detail && <span className="block text-xs text-muted">{detail}</span>}
786 </span>
787 </li>
788 );
789}
790
791/** Where a blocker stands, in a few words: landed, being worked on, or waiting. */
792export function blockerDetail(blocker: Blocker): string {
793 if (blocker.pulls.some((pull) => pull.status === "merged")) return "Merged";
794 if (blocker.state === "closed") return "Closed";
795 const open = blocker.pulls.filter((pull) => pull.status === "open" || pull.status === "draft");
796 const who = blocker.claims.map((claim) => claim.actor.username);
797 return [
798 who.length > 0 ? `Claimed by ${who.join(", ")}` : "Not claimed",
799 open.length > 0 ? `${open.length} pull ${open.length === 1 ? "request" : "requests"} open` : null,
800 ]
801 .filter(Boolean)
802 .join(" · ");
803}
804
805/** A part of the Work panel, under a rule. */
806function PanelPart({ title, action, children }: { title: ReactNode; action?: ReactNode; children: ReactNode }) {
807 return (
808 <div className="border-t border-line pt-3">
809 <div className="flex min-h-7 items-center justify-between gap-2">
810 <p className="text-xs font-medium text-muted">{title}</p>
811 {action}
812 </div>
813 <div className="mt-1.5">{children}</div>
814 </div>
815 );
816}
817
818/**
819 * The Work panel of an issue's sidebar: who has claimed it, the sessions on
820 * it, what it waits on and what waits on it, and for those who may, the
821 * blockers to change.
822 */
823export function IssueWorkPanel({
824 work,
825 ended,
826 base,
827 now,
828 canEdit,
829 error,
830 sessions: all,
831 start,
832}: {
833 work: WorkContext;
834 /** Claims on it that ended lately, shown muted. */
835 ended: Claim[];
836 base: string;
837 now: number;
838 canEdit: boolean;
839 error?: string | null;
840 /** The sessions tied to it with their subagents, when they could be read; else its own sessions. */
841 sessions?: WorkSession[] | null;
842 /** Starting a session on it, for those who may. */
843 start?: ReactNode;
844}) {
845 const open = work.issue.state === "open";
846 const trees = sessionTree(mergeSessions(work.sessions, all));
847 const sessions = trees.slice(0, 5);
848 const waiting = work.blockedBy.filter((blocker) => blocker.state === "open").length;
849 return (
850 <Card asChild className="space-y-4 p-4">
851 <section aria-label="Work">
852 <h3 className="flex items-center gap-2 text-sm font-medium">
853 <Activity size={14} className="text-accent" />
854 Work
855 </h3>
856
857 <div className="space-y-3">
858 {work.claims.length === 0 && ended.length === 0 ? (
859 <p className="text-xs text-muted">
860 {open ? (
861 <>
862 Nobody has claimed it. Claim it from your harness, or with <code className="font-mono text-fg-soft">g1t work claim {work.issue.number}</code>.
863 </>
864 ) : (
865 "Nobody is working on it."
866 )}
867 </p>
868 ) : (
869 [...work.claims, ...ended.slice(0, 3)].map((claim) => <ClaimLine key={claim.id} claim={claim} now={now} base={base} />)
870 )}
871 {work.claims.length > 1 && (
872 <Alert tone="warn" className="flex items-start gap-1.5 px-2.5 py-1.5 text-xs">
873 <TriangleAlert size={13} className="mt-0.5 shrink-0 text-warn" />
874 {work.claims.length} claims stand on it: they may be doing the same work.
875 </Alert>
876 )}
877 </div>
878
879 {(sessions.length > 0 || start) && (
880 <PanelPart title="Sessions" action={start}>
881 {sessions.length === 0 ? (
882 <p className="text-xs text-faint">None yet.</p>
883 ) : (
884 <SessionTreeList nodes={sessions} base={base} showBranch={false} />
885 )}
886 {trees.length > sessions.length && (
887 <Link to={`${base}/work`} className="mt-2 inline-block text-xs text-muted hover:text-fg">
888 {trees.length - sessions.length} more on the Work page
889 </Link>
890 )}
891 </PanelPart>
892 )}
893
894 <PanelPart
895 title={
896 <>
897 Blocked by
898 {waiting > 0 && <span className="text-warn"> · {waiting} open</span>}
899 </>
900 }
901 action={canEdit ? <BlockedByEditor current={work.issue.blockedBy} /> : undefined}
902 >
903 {work.blockedBy.length === 0 ? (
904 <p className="text-xs text-faint">Nothing. It can start now.</p>
905 ) : (
906 <ul className="space-y-2">
907 {work.blockedBy.map((blocker) => (
908 <DependencyLine key={blocker.number} base={base} number={blocker.number} title={blocker.title} state={blocker.state} detail={blockerDetail(blocker)} />
909 ))}
910 </ul>
911 )}
912 {error && <p className="mt-2 text-xs text-danger">{error}</p>}
913 </PanelPart>
914
915 {work.blocks.length > 0 && (
916 <PanelPart title="Blocks">
917 <ul className="space-y-2">
918 {work.blocks.map((issue) => (
919 <DependencyLine key={issue.number} base={base} number={issue.number} title={issue.title} state={issue.state} />
920 ))}
921 </ul>
922 </PanelPart>
923 )}
924 </section>
925 </Card>
926 );
927}
928
929/** Changing what an issue waits on: the whole list, as numbers. */
930function BlockedByEditor({ current }: { current: number[] }) {
931 return (
932 <Popover>
933 <PopoverTrigger asChild>
934 <Button variant="ghost" size="xs">
935 <Pencil size={12} />
936 Edit
937 </Button>
938 </PopoverTrigger>
939 <PopoverContent align="end">
940 <Form method="post" className="space-y-2.5">
941 <input type="hidden" name="action" value="blocked-by" />
942 <Label htmlFor="blocked-by">Blocked by</Label>
943 <Input id="blocked-by" name="blocked_by" defaultValue={current.map((n) => `#${n}`).join(", ")} placeholder="#12, #14" autoComplete="off" />
944 <p className="text-xs text-muted">Issues of this repository that have to land first. Leave it empty for none.</p>
945 <SubmitButton size="sm" match={{ action: "blocked-by" }} pending="Saving…">
946 Save
947 </SubmitButton>
948 </Form>
949 </PopoverContent>
950 </Popover>
951 );
952}
953
954/** One issue being worked on: its title, then each claim and session on it. */
955export function LiveIssueCard({
956 group,
957 title,
958 base,
959 now,
960 ender,
961}: {
962 group: LiveIssue;
963 title: string | undefined;
964 base: string;
965 now: number;
966 ender?: SessionEnder;
967}) {
968 return (
969 <Card asChild divided>
970 <li>
971 <div className="flex items-start gap-2.5 px-4 py-3">
972 <span className="mt-1">
973 <IssueIcon issue={{ state: "open", reason: null }} />
974 </span>
975 <Link to={`${base}/issues/${group.issue}`} prefetch="intent" className="min-w-0 grow font-medium hover:text-accent">
976 {title ?? `Issue #${group.issue}`} <span className="font-normal text-faint">#{group.issue}</span>
977 </Link>
978 {group.claims.length > 1 && <Badge tone="warn">{group.claims.length} claims</Badge>}
979 </div>
980 {group.claims.map((claim) => (
981 <div key={claim.id} className="px-4 py-3">
982 <ClaimLine claim={claim} now={now} base={base} />
983 </div>
984 ))}
985 {group.sessions.map((session) => (
986 <div key={session.id} className="px-4 py-3">
987 <SessionLine session={session} base={base} ender={ender} />
988 </div>
989 ))}
990 </li>
991 </Card>
992 );
993}
994
995/** A pull request a session is on, as Live now names it. */
996export type LivePull = { title: string; status: Pull["status"] };
997
998/** What a group of sessions on no issue is on, as its card's heading: the pull request, the branch, or neither. */
999function LiveGroupHeading({
1000 group,
1001 pulls,
1002 base,
1003 defaultBranch,
1004}: {
1005 group: LiveGroup;
1006 pulls: Record<number, LivePull>;
1007 base: string;
1008 defaultBranch?: string | null;
1009}) {
1010 if (group.kind === "pull") {
1011 const pull = pulls[group.pull];
1012 return (
1013 <div className="flex items-start gap-2.5 px-4 py-3">
1014 <span className="mt-1">
1015 <PullIcon status={pull?.status ?? "open"} />
1016 </span>
1017 <Link to={`${base}/pull/${group.pull}`} prefetch="intent" className="min-w-0 grow font-medium hover:text-accent">
1018 {pull?.title ?? `Pull request #${group.pull}`} <span className="font-normal text-faint">#{group.pull}</span>
1019 </Link>
1020 <span className="mt-0.5 shrink-0 text-xs text-faint">No issue yet</span>
1021 </div>
1022 );
1023 }
1024 if (group.kind === "branch") {
1025 return (
1026 <div className="flex items-center gap-2.5 px-4 py-3">
1027 <GitBranch size={16} className="shrink-0 text-muted" />
1028 <Link to={`${base}/tree/${encodeURIComponent(group.branch)}`} prefetch="intent" className="min-w-0 truncate font-mono text-sm font-medium hover:text-accent">
1029 {group.branch}
1030 </Link>
1031 <span className="ml-auto shrink-0 text-xs text-faint">No issue yet</span>
1032 </div>
1033 );
1034 }
1035 // Working on the default branch itself, or somewhere git has not said yet.
1036 const onDefault = defaultBranch != null && group.sessions.some((session) => session.branch === defaultBranch);
1037 return (
1038 <div className="flex items-center gap-2.5 px-4 py-3">
1039 {onDefault ? <GitBranch size={16} className="shrink-0 text-faint" /> : <Activity size={16} className="shrink-0 text-faint" />}
1040 <Hint label="Sessions on no issue, pull request or branch of their own, each titled with what it is for. One opens or claims an issue when its work becomes a change.">
1041 <span className="text-sm text-muted">
1042 {onDefault ? (
1043 <>
1044 On <span className="font-mono">{defaultBranch}</span>
1045 </>
1046 ) : (
1047 "No branch yet"
1048 )}
1049 </span>
1050 </Hint>
1051 </div>
1052 );
1053}
1054
1055/** Sessions on no issue yet that share a pull request or a branch, or share neither: a heading, then each session. */
1056export function LiveGroupCard({
1057 group,
1058 pulls,
1059 base,
1060 defaultBranch,
1061 ender,
1062}: {
1063 group: LiveGroup;
1064 pulls: Record<number, LivePull>;
1065 base: string;
1066 defaultBranch?: string | null;
1067 ender?: SessionEnder;
1068}) {
1069 return (
1070 <Card asChild divided>
1071 <li>
1072 <LiveGroupHeading group={group} pulls={pulls} base={base} defaultBranch={defaultBranch} />
1073 {group.sessions.map((session) => (
1074 <div key={session.id} className="px-4 py-3">
1075 {/* Its pull request and branch are the heading's, unless it has neither of its own. */}
1076 <SessionLine
1077 session={session}
1078 base={base}
1079 showWhere={group.kind === "none"}
1080 showBranch={group.kind === "none" && session.branch !== defaultBranch}
1081 ender={ender}
1082 />
1083 </div>
1084 ))}
1085 </li>
1086 </Card>
1087 );
1088}
1089
1090/**
1091 * Who is working right now: each issue with its claims and sessions, the
1092 * most recently active first, then sessions on no issue yet, by pull
1093 * request, then by branch, then those on neither. `limit` caps the issues
1094 * shown, for a page that links to the rest.
1095 */
1096export function LiveNowList({
1097 claims,
1098 sessions,
1099 titles,
1100 pulls = {},
1101 defaultBranch,
1102 base,
1103 now,
1104 limit,
1105 ender,
1106}: {
1107 claims: Claim[];
1108 sessions: WorkSession[];
1109 titles: Record<number, string>;
1110 /** The open pull requests' titles and states, for the sessions on one with no issue. */
1111 pulls?: Record<number, LivePull>;
1112 /** Sessions on it have no branch of their own. */
1113 defaultBranch?: string | null;
1114 base: string;
1115 now: number;
1116 limit?: number;
1117 /** Who is looking: each session they may end has a menu to end it by hand. */
1118 ender?: SessionEnder;
1119}) {
1120 const { awake, idle } = awakeAndIdle(sessions);
1121 const live = liveByIssue(claims, awake, defaultBranch);
1122 const issues = limit == null ? live.issues : live.issues.slice(0, limit);
1123 return (
1124 <ol className="space-y-3">
1125 {issues.map((group) => (
1126 <LiveIssueCard key={group.issue} group={group} title={titles[group.issue]} base={base} now={now} ender={ender} />
1127 ))}
1128 {live.groups.map((group) => (
1129 <LiveGroupCard
1130 key={group.kind === "pull" ? `pull:${group.pull}` : group.kind === "branch" ? `branch:${group.branch}` : "none"}
1131 group={group}
1132 pulls={pulls}
1133 base={base}
1134 defaultBranch={defaultBranch}
1135 ender={ender}
1136 />
1137 ))}
1138 {idle.length > 0 && <IdleSessions sessions={idle} base={base} ender={ender} />}
1139 </ol>
1140 );
1141}
1142
1143/**
1144 * Sessions not heard from for 15 minutes, folded under Live now: usually
1145 * an open tab, not work going on. Each moves back up when it is heard from,
1146 * and drops off when it times out.
1147 */
1148function IdleSessions({ sessions, base, ender }: { sessions: WorkSession[]; base: string; ender?: SessionEnder }) {
1149 return (
1150 <Card asChild divided>
1151 <li>
1152 <Collapsible>
1153 <CollapsibleTrigger className="w-full px-4 py-2.5">
1154 <ChevronRight size={14} className="shrink-0" />
1155 {sessions.length === 1 ? "1 idle session" : `${sessions.length} idle sessions`}
1156 <span className="ml-auto text-xs text-faint">
1157 last seen <TimeAgo at={sessions[0].lastSeenAt} />
1158 </span>
1159 </CollapsibleTrigger>
1160 <CollapsibleContent className="divide-y divide-line border-t border-line">
1161 {sessions.map((session) => (
1162 <div key={session.id} className="px-4 py-3">
1163 <SessionLine session={session} base={base} ender={ender} />
1164 </div>
1165 ))}
1166 </CollapsibleContent>
1167 </Collapsible>
1168 </li>
1169 </Card>
1170 );
1171}
1172
1173/** What to do when nobody is working on a project: bring your own harness. */
1174export function StartHere({ title }: { title: string }) {
1175 return (
1176 <EmptyState title={title}>
1177 <div className="mx-auto mt-3 max-w-lg space-y-3 text-left">
1178 <p>Install the g1t plugin in Claude Code, and each session shows here as it works:</p>
1179 {PLUGIN_INSTALL.split("\n").map((line) => (
1180 <CopyLine key={line} text={line} prompt />
1181 ))}
1182 <p>From any other harness, or by hand, claim an issue with the g1t CLI:</p>
1183 <CopyLine text="g1t work claim 12" prompt />
1184 <p>
1185 <a href="https://docs.g1t.sh/guides/bring-your-own-agent/" className="text-fg underline underline-offset-4">
1186 Connect a coding agent
1187 </a>{" "}
1188 sets up Codex, Cursor and any MCP client;{" "}
1189 <a href="https://docs.g1t.sh/guides/sessions/" className="text-fg underline underline-offset-4">
1190 Sessions, claims and dependencies
1191 </a>{" "}
1192 says how it works.
1193 </p>
1194 </div>
1195 </EmptyState>
1196 );
1197}