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.

g1t.sh
917 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 CircleCheck,
13 CircleDot,
14 CircleX,
15 GitBranch,
16 GitCommitHorizontal,
17 GitMerge,
18 Hand,
19 Layers,
20 Link2,
21 MessageSquare,
22 Pencil,
23 Play,
24 Sparkles,
25 Square,
26 Timer,
27 TriangleAlert,
28} from "lucide-react";
29import { Fragment, type ReactNode, useState } from "react";
30import { Form, Link } from "react-router";
31
32import {
33 AGENT_MODES,
34 type AgentSessionsSetup,
35 type Blocker,
36 type Claim,
37 type Decision,
38 type DecisionConfidence,
39 type Harness,
40 type Pull,
41 type SessionState,
42 type State,
43 type WorkContext,
44 type WorkSession,
45} from "@g1t/contracts";
46
47import {
48 CLAIM_ENDED,
49 CONFIDENCE,
50 DECISION_KINDS,
51 type FeedLine,
52 SESSION_STATES,
53 type SessionNode,
54 checkHref,
55 evidenceOf,
56 expiresIn,
57 harnessName,
58 type LiveGroup,
59 type LiveIssue,
60 liveByIssue,
61 mergeSessions,
62 numberedParts,
63 sessionTitle,
64 sessionTree,
65} from "../lib/coordination";
66import { PLUGIN_INSTALL } from "../lib/agent-setup";
67import { CopyLine, EmptyState, Input, SubmitButton, TimeAgo } from "./ui";
68import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
69import { Avatar } from "./ui/avatar";
70import { Badge, type BadgeTone } from "./ui/badge";
71import { Button } from "./ui/button";
72import { Card } from "./ui/card";
73import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
74import { Field, FieldError, FieldLabel, FieldLegend, FieldSet } from "./ui/field";
75import { Hint } from "./ui/hint";
76import { Label } from "./ui/label";
77import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
78import { RadioGroup, RadioOption } from "./ui/radio-group";
79import { Textarea } from "./ui/textarea";
80import { PersonLink } from "./work";
81import { IssueIcon, PullIcon } from "./work-icons";
82
83/** Text with each `#N` linked to that issue or pull request (the issue's address leads to a pull request too). */
84export function NumberedText({ text, base }: { text: string; base: string }) {
85 return (
86 <>
87 {numberedParts(text).map((part, index) =>
88 typeof part === "number" ? (
89 <Link key={index} to={`${base}/issues/${part}`} className="font-medium text-fg hover:text-accent">
90 #{part}
91 </Link>
92 ) : (
93 <Fragment key={index}>{part}</Fragment>
94 ),
95 )}
96 </>
97 );
98}
99
100/** The harness a session or claim came from. */
101export function HarnessBadge({ harness }: { harness: Harness | null | undefined }) {
102 const name = harnessName(harness);
103 if (!name) return null;
104 return <Badge>{name}</Badge>;
105}
106
107/** Who did it: an agent's avatar when it came through a harness (a person's token, the harness declared), else theirs. */
108export function Actor({ username, harness, size = 20 }: { username: string; harness?: Harness | null; size?: number }) {
109 return <Avatar name={username} size={size} agent={harness != null} />;
110}
111
112const SESSION_TONE: Record<SessionState, BadgeTone> = { active: "success", idle: "warn", ended: "neutral" };
113
114export function SessionStateBadge({ state }: { state: SessionState }) {
115 const { label, hint } = SESSION_STATES[state];
116 return (
117 <Hint label={hint}>
118 <Badge tone={SESSION_TONE[state]}>{label}</Badge>
119 </Hint>
120 );
121}
122
123/** How sure a decision is: claimed, tested and approved read apart at a glance. */
124export function ConfidenceBadge({ confidence }: { confidence: DecisionConfidence }) {
125 const { label, hint, tone } = CONFIDENCE[confidence];
126 const Icon = confidence === "approved" ? CircleCheck : confidence === "tested" ? Layers : MessageSquare;
127 return (
128 <Hint label={hint}>
129 <Badge tone={tone}>
130 <Icon size={11} />
131 {label}
132 </Badge>
133 </Hint>
134 );
135}
136
137/** One claim: who, from which harness, since when, and when it lapses; one no longer active is muted. */
138export function ClaimLine({ claim, now, showIssue, base }: { claim: Claim; now: number; showIssue?: boolean; base: string }) {
139 const active = claim.state === "active";
140 const left = expiresIn(claim, now);
141 return (
142 <div className={active ? "flex items-start gap-2.5" : "flex items-start gap-2.5 opacity-60"}>
143 <Actor username={claim.actor.username} harness={claim.harness} />
144 <div className="min-w-0 grow">
145 <p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm">
146 <PersonLink name={claim.actor.username} className="font-medium hover:underline" />
147 {showIssue ? (
148 <span className="text-muted">
149 claimed{" "}
150 <Link to={`${base}/issues/${claim.issue}`} className="font-medium text-fg hover:text-accent">
151 #{claim.issue}
152 </Link>
153 </span>
154 ) : null}
155 <HarnessBadge harness={claim.harness} />
156 {!active && <Badge>{CLAIM_ENDED[claim.state as Exclude<Claim["state"], "active">]}</Badge>}
157 </p>
158 <p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
159 <span>
160 since <TimeAgo at={claim.createdAt} />
161 </span>
162 {left && (
163 <span className="inline-flex items-center gap-1" suppressHydrationWarning>
164 · <Timer size={11} />
165 {left}
166 </span>
167 )}
168 {!active && claim.state === "handed_off" && <span>· to {claim.handoffTo ?? "anyone"}</span>}
169 </p>
170 {claim.note && <p className="mt-1 text-xs text-fg-soft">{claim.note}</p>}
171 </div>
172 </div>
173 );
174}
175
176/** One harness session: who, which harness, where it stands, when it was last heard from, linked to its page. */
177export function SessionLine({
178 session,
179 base,
180 showWhere = true,
181 showBranch = true,
182}: {
183 session: WorkSession;
184 base: string;
185 /** Its pull request and branch; not where they are already said. */
186 showWhere?: boolean;
187 /** Its branch; not in a narrow sidebar. */
188 showBranch?: boolean;
189}) {
190 return (
191 <div className="flex items-start gap-2.5">
192 <Actor username={session.actor.username} harness={session.harness} />
193 <div className="min-w-0 grow">
194 <p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm">
195 <Link to={`${base}/work/sessions/${session.id}`} prefetch="intent" className="font-medium hover:text-accent">
196 {sessionTitle(session)}
197 </Link>
198 <HarnessBadge harness={session.harness} />
199 <SessionStateBadge state={session.state} />
200 </p>
201 <p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
202 <PersonLink name={session.actor.username} className="hover:text-fg" />
203 <span>
204 · {session.state === "ended" ? "ended" : "last seen"} <TimeAgo at={session.endedAt ?? session.lastSeenAt} />
205 </span>
206 {showWhere && session.pull != null && (
207 <span>
208 · on{" "}
209 <Link to={`${base}/pull/${session.pull}`} className="text-fg hover:text-accent">
210 #{session.pull}
211 </Link>
212 </span>
213 )}
214 {showWhere && showBranch && session.branch && (
215 <span className="inline-flex min-w-0 items-center gap-1 font-mono">
216 · <GitBranch size={11} />
217 <span className="truncate">{session.branch}</span>
218 </span>
219 )}
220 </p>
221 </div>
222 </div>
223 );
224}
225
226/** A subagent in a line: its kind, its task, where it stands, linked to its page. */
227export function SubagentLine({ session, base }: { session: WorkSession; base: string }) {
228 return (
229 <div className="flex min-w-0 items-center gap-1.5 text-xs">
230 <Bot size={12} className="shrink-0 text-faint" />
231 <Link to={`${base}/work/sessions/${session.id}`} prefetch="intent" className="shrink-0 font-medium text-fg hover:text-accent">
232 {session.agentType ?? "Subagent"}
233 </Link>
234 {session.task && (
235 <Hint label={session.task}>
236 <span className="min-w-0 truncate text-muted">{session.task}</span>
237 </Hint>
238 )}
239 <SessionStateBadge state={session.state} />
240 </div>
241 );
242}
243
244/** Subagents under a session, and theirs under them, indented on a rule. */
245function SubagentBranch({ nodes, base }: { nodes: SessionNode[]; base: string }) {
246 return (
247 <ul className="mt-1.5 space-y-1.5 border-l border-line pl-3">
248 {nodes.map((node) => (
249 <li key={node.session.id}>
250 <SubagentLine session={node.session} base={base} />
251 {node.children.length > 0 && <SubagentBranch nodes={node.children} base={base} />}
252 </li>
253 ))}
254 </ul>
255 );
256}
257
258/** Sessions as trees: each one's line, with its subagents in a compact list under it. */
259export function SessionTreeList({
260 nodes,
261 base,
262 showWhere,
263 showBranch,
264 className,
265}: {
266 nodes: SessionNode[];
267 base: string;
268 showWhere?: boolean;
269 showBranch?: boolean;
270 className?: string;
271}) {
272 return (
273 <ul className={className ?? "space-y-3"}>
274 {nodes.map((node) => (
275 <li key={node.session.id}>
276 <SessionLine session={node.session} base={base} showWhere={showWhere} showBranch={showBranch} />
277 {node.children.length > 0 && (
278 <div className="pl-7.5">
279 <SubagentBranch nodes={node.children} base={base} />
280 </div>
281 )}
282 </li>
283 ))}
284 </ul>
285 );
286}
287
288/**
289 * Starting a session on the issue on the workspace's Anthropic account: a
290 * button opening a dialog that asks what kind of work and for anything to
291 * add, posting `start-session` to the issue's action.
292 */
293export function StartSessionDialog({ setup, error }: { setup: AgentSessionsSetup; error?: string | null }) {
294 const [open, setOpen] = useState(Boolean(error));
295 return (
296 <Dialog open={open} onOpenChange={setOpen}>
297 <DialogTrigger asChild>
298 <Button variant="outline" size="xs">
299 <Sparkles size={12} />
300 Start a session
301 </Button>
302 </DialogTrigger>
303 <DialogContent>
304 <Form method="post" className="grid gap-5">
305 <input type="hidden" name="action" value="start-session" />
306 <DialogHeader>
307 <DialogTitle>Start a session on this issue</DialogTitle>
308 <DialogDescription>
309 Claude works on it on {setup.connectionName ? `the ${setup.connectionName} connection` : "your workspace's Anthropic account"}
310 {setup.model ? `, with ${setup.model}` : ""}. It shows on this issue as it goes, with its subagents.
311 </DialogDescription>
312 </DialogHeader>
313 <FieldSet>
314 <FieldLegend className="mb-0">What to do</FieldLegend>
315 <RadioGroup name="mode" defaultValue="implement">
316 {AGENT_MODES.map((mode) => (
317 <RadioOption key={mode.mode} value={mode.mode} label={mode.label} description={mode.hint} />
318 ))}
319 </RadioGroup>
320 </FieldSet>
321 <Field>
322 <FieldLabel htmlFor="start-session-instruction">Anything to add</FieldLabel>
323 <Textarea id="start-session-instruction" name="instruction" rows={3} maxLength={4000} placeholder="Optional: what to keep in mind" />
324 <FieldError>{error}</FieldError>
325 </Field>
326 <DialogFooter>
327 <DialogClose asChild>
328 <Button variant="ghost">Cancel</Button>
329 </DialogClose>
330 <SubmitButton match={{ action: "start-session" }} pending="Starting…">
331 Start
332 </SubmitButton>
333 </DialogFooter>
334 </Form>
335 </DialogContent>
336 </Dialog>
337 );
338}
339
340const KIND_ICON: Record<Decision["kind"], ReactNode> = {
341 proposal: <MessageSquare size={14} />,
342 claim: <MessageSquare size={14} />,
343 integration_failure: <CircleX size={14} className="text-danger" />,
344 integration_passed: <Layers size={14} className="text-success" />,
345 fix: <Link2 size={14} className="text-accent" />,
346 approval: <CircleCheck size={14} className="text-success" />,
347 merge: <GitMerge size={14} className="text-merged" />,
348 note: <MessageSquare size={14} />,
349};
350
351/** The pull requests tested together, in queue order: `#41 + #44`. */
352export function Combination({ numbers, base, culprit }: { numbers: number[]; base: string; culprit?: number | null }) {
353 return (
354 <span className="inline-flex flex-wrap items-center gap-1">
355 {numbers.map((number, index) => (
356 <Fragment key={number}>
357 {index > 0 && <span className="text-faint">+</span>}
358 <Link to={`${base}/pull/${number}`}>
359 <Badge shape="tag" tone={number === culprit ? "danger" : "neutral"} className="font-mono hover:border-line-strong">
360 #{number}
361 </Badge>
362 </Link>
363 </Fragment>
364 ))}
365 </span>
366 );
367}
368
369/** The check a decision rests on, linked where it can be seen. */
370function CheckLink({ check }: { check: { name: string; conclusion: string; url: string | null } }) {
371 const href = checkHref(check.url);
372 const text = (
373 <>
374 <span className="font-mono">{check.name}</span> {check.conclusion}
375 </>
376 );
377 if (!href) return <span>{text}</span>;
378 return href.startsWith("/") ? (
379 <Link to={href} className="text-fg underline-offset-2 hover:underline">
380 {text}
381 </Link>
382 ) : (
383 <a href={href} className="text-fg underline-offset-2 hover:underline" rel="noreferrer" target="_blank">
384 {text}
385 </a>
386 );
387}
388
389/** One decision record: its kind, how sure it is, the sentence, who and when, and what it rests on. */
390export function DecisionLine({ decision, base }: { decision: Decision; base: string }) {
391 const evidence = evidenceOf(decision);
392 return (
393 <div className="flex items-start gap-2.5">
394 <span className="mt-0.5 shrink-0 text-faint">{KIND_ICON[decision.kind]}</span>
395 <div className="min-w-0 grow">
396 <p className="flex flex-wrap items-center gap-1.5 text-xs">
397 <span className="font-medium text-fg">{DECISION_KINDS[decision.kind]}</span>
398 <ConfidenceBadge confidence={decision.confidence} />
399 {decision.pull != null && (
400 <Link to={`${base}/pull/${decision.pull}`} className="text-muted hover:text-fg">
401 #{decision.pull}
402 </Link>
403 )}
404 </p>
405 <p className="mt-1 text-sm wrap-break-word">
406 <NumberedText text={decision.summary} base={base} />
407 </p>
408 <p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
409 <span>{decision.actor ?? "g1t"}</span>
410 <span>
411 · <TimeAgo at={decision.createdAt} />
412 </span>
413 {evidence.combination.length > 1 && (
414 <span className="inline-flex items-center gap-1.5">
415 · tested as <Combination numbers={evidence.combination} base={base} culprit={decision.kind === "integration_failure" ? decision.pull : null} />
416 </span>
417 )}
418 {evidence.check && decision.kind === "integration_failure" && (
419 <span>
420 · <CheckLink check={evidence.check} />
421 </span>
422 )}
423 {evidence.commit && (
424 <span className="inline-flex items-center gap-1 font-mono">
425 · <GitCommitHorizontal size={11} />
426 {evidence.commit.slice(0, 7)}
427 </span>
428 )}
429 </p>
430 </div>
431 </div>
432 );
433}
434
435/** Decision records, as a list in a card. */
436export function DecisionList({ decisions, base }: { decisions: Decision[]; base: string }) {
437 return (
438 <Card asChild divided>
439 <ol>
440 {decisions.map((decision) => (
441 <li key={decision.id} className="px-4 py-3">
442 <DecisionLine decision={decision} base={base} />
443 </li>
444 ))}
445 </ol>
446 </Card>
447 );
448}
449
450/**
451 * A combination that failed in the merge queue: which pull requests were
452 * tested together, the check that failed, which ones it fails with, and
453 * whether it has been answered since.
454 */
455export function IntegrationFailure({
456 failure,
457 base,
458 fixed,
459 branch = "main",
460 className,
461}: {
462 failure: Decision;
463 base: string;
464 /** The branch the combination was built on. */
465 branch?: string;
466 /** A fix or a passing combination since, when there is one. */
467 fixed?: Decision | null;
468 className?: string;
469}) {
470 const evidence = evidenceOf(failure);
471 return (
472 <Alert tone={fixed ? "info" : "danger"} className={className}>
473 <AlertTitle className="flex flex-wrap items-center gap-x-2 gap-y-1 text-fg">
474 {failure.pull != null ? (
475 <span>
476 <TriangleAlert size={15} className={`mr-2 inline align-[-3px] ${fixed ? "text-info" : "text-danger"}`} />
477 <Link to={`${base}/pull/${failure.pull}`} className="hover:underline">
478 #{failure.pull}
479 </Link>{" "}
480 failed in the merge queue {evidence.combination.length > 1 ? "together with others" : "on its own"}
481 </span>
482 ) : (
483 <span>A combination failed in the merge queue</span>
484 )}
485 <ConfidenceBadge confidence={failure.confidence} />
486 <span className="text-xs font-normal text-muted">
487 <TimeAgo at={failure.createdAt} />
488 </span>
489 </AlertTitle>
490 <AlertDescription className="space-y-1.5">
491 {evidence.combination.length > 0 && (
492 <p className="flex flex-wrap items-center gap-1.5 text-xs">
493 Tested as <span className="font-mono text-fg">{branch}</span> <span className="text-faint">+</span>
494 <Combination numbers={evidence.combination} base={base} culprit={failure.pull} />
495 </p>
496 )}
497 {evidence.check && (
498 <p className="text-xs">
499 Failing check: <CheckLink check={evidence.check} />
500 </p>
501 )}
502 <p className="text-xs">
503 {evidence.suspects.length > 0 ? (
504 <>
505 Fails with{" "}
506 <NumberedText text={evidence.suspects.map((n) => `#${n}`).join(", ")} base={base} />
507 {evidence.bisected ? ", narrowed down by building smaller combinations." : "."}
508 </>
509 ) : (
510 "It fails by itself: the change is the cause."
511 )}
512 </p>
513 {fixed && (
514 <p className="text-xs text-fg">
515 <CircleCheck size={13} className="mr-1.5 inline align-[-2px] text-success" />
516 {fixed.kind === "integration_passed" ? "Passed together since" : "Fixed since"}:{" "}
517 <span className="text-muted">
518 <NumberedText text={fixed.summary} base={base} />
519 </span>
520 </p>
521 )}
522 </AlertDescription>
523 </Alert>
524 );
525}
526
527const FEED_ICON: Record<FeedLine["type"], ReactNode> = {
528 "work.claimed": <Hand size={14} />,
529 "work.released": <Hand size={14} />,
530 "work.claim_expired": <Timer size={14} />,
531 "work.handoff_requested": <ArrowRightLeft size={14} />,
532 "session.started": <Play size={14} />,
533 "session.ended": <Square size={14} />,
534 "dependency.changed": <Link2 size={14} />,
535 "overlap.detected": <TriangleAlert size={14} />,
536 "integration.failed": <CircleX size={14} />,
537 "integration.passed": <Layers size={14} />,
538 "decision.recorded": <CircleDot size={14} />,
539};
540
541const FEED_TONE: Record<FeedLine["tone"], string> = {
542 neutral: "text-faint",
543 accent: "text-accent",
544 success: "text-success",
545 warn: "text-warn",
546 danger: "text-danger",
547 info: "text-info",
548};
549
550/** Recent coordination events, newest first, one line each. */
551export function CoordinationFeed({ lines, base }: { lines: FeedLine[]; base: string }) {
552 return (
553 <Card asChild divided>
554 <ol>
555 {lines.map((line) => (
556 <li key={line.id} className="flex items-start gap-3 px-4 py-2.5 text-sm">
557 <span className={`mt-0.5 shrink-0 ${FEED_TONE[line.tone]}`}>{FEED_ICON[line.type]}</span>
558 <p className="min-w-0 grow wrap-break-word text-muted">
559 <PersonLink name={line.who ?? "g1t"} className="font-medium text-fg hover:underline" />
560 {line.harness && <span className="text-faint"> via {harnessName(line.harness)}</span>}{" "}
561 <NumberedText text={line.text} base={base} />
562 {line.session && (
563 <>
564 {" · "}
565 <Link to={`${base}/work/sessions/${line.session}`} className="text-fg hover:text-accent">
566 the session
567 </Link>
568 </>
569 )}
570 </p>
571 <span className="shrink-0 text-xs text-faint">
572 <TimeAgo at={line.time} />
573 </span>
574 </li>
575 ))}
576 </ol>
577 </Card>
578 );
579}
580
581/** An issue's place among others: one it waits on, or one waiting on it. */
582function DependencyLine({ base, number, title, state, detail }: { base: string; number: number; title: string; state: State; detail?: ReactNode }) {
583 return (
584 <li className="flex items-start gap-2">
585 <span className="mt-0.5">
586 <IssueIcon issue={{ state, reason: null }} size={14} />
587 </span>
588 <span className="min-w-0 grow">
589 <Link to={`${base}/issues/${number}`} className="block truncate text-sm hover:text-accent">
590 {title} <span className="text-faint">#{number}</span>
591 </Link>
592 {detail && <span className="block text-xs text-muted">{detail}</span>}
593 </span>
594 </li>
595 );
596}
597
598/** Where a blocker stands, in a few words: landed, being worked on, or waiting. */
599export function blockerDetail(blocker: Blocker): string {
600 if (blocker.pulls.some((pull) => pull.status === "merged")) return "Merged";
601 if (blocker.state === "closed") return "Closed";
602 const open = blocker.pulls.filter((pull) => pull.status === "open" || pull.status === "draft");
603 const who = blocker.claims.map((claim) => claim.actor.username);
604 return [
605 who.length > 0 ? `Claimed by ${who.join(", ")}` : "Not claimed",
606 open.length > 0 ? `${open.length} pull ${open.length === 1 ? "request" : "requests"} open` : null,
607 ]
608 .filter(Boolean)
609 .join(" · ");
610}
611
612/** A part of the Work panel, under a rule. */
613function PanelPart({ title, action, children }: { title: ReactNode; action?: ReactNode; children: ReactNode }) {
614 return (
615 <div className="border-t border-line pt-3">
616 <div className="flex min-h-7 items-center justify-between gap-2">
617 <p className="text-xs font-medium text-muted">{title}</p>
618 {action}
619 </div>
620 <div className="mt-1.5">{children}</div>
621 </div>
622 );
623}
624
625/**
626 * The Work panel of an issue's sidebar: who has claimed it, the sessions on
627 * it, what it waits on and what waits on it, and for those who may, the
628 * blockers to change.
629 */
630export function IssueWorkPanel({
631 work,
632 ended,
633 base,
634 now,
635 canEdit,
636 error,
637 sessions: all,
638 start,
639}: {
640 work: WorkContext;
641 /** Claims on it that ended lately, shown muted. */
642 ended: Claim[];
643 base: string;
644 now: number;
645 canEdit: boolean;
646 error?: string | null;
647 /** The sessions tied to it with their subagents, when they could be read; else its own sessions. */
648 sessions?: WorkSession[] | null;
649 /** Starting a session on it, for those who may. */
650 start?: ReactNode;
651}) {
652 const open = work.issue.state === "open";
653 const trees = sessionTree(mergeSessions(work.sessions, all));
654 const sessions = trees.slice(0, 5);
655 const waiting = work.blockedBy.filter((blocker) => blocker.state === "open").length;
656 return (
657 <Card asChild className="space-y-4 p-4">
658 <section aria-label="Work">
659 <h3 className="flex items-center gap-2 text-sm font-medium">
660 <Activity size={14} className="text-accent" />
661 Work
662 </h3>
663
664 <div className="space-y-3">
665 {work.claims.length === 0 && ended.length === 0 ? (
666 <p className="text-xs text-muted">
667 {open ? (
668 <>
669 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>.
670 </>
671 ) : (
672 "Nobody is working on it."
673 )}
674 </p>
675 ) : (
676 [...work.claims, ...ended.slice(0, 3)].map((claim) => <ClaimLine key={claim.id} claim={claim} now={now} base={base} />)
677 )}
678 {work.claims.length > 1 && (
679 <Alert tone="warn" className="flex items-start gap-1.5 px-2.5 py-1.5 text-xs">
680 <TriangleAlert size={13} className="mt-0.5 shrink-0 text-warn" />
681 {work.claims.length} claims stand on it: they may be doing the same work.
682 </Alert>
683 )}
684 </div>
685
686 {(sessions.length > 0 || start) && (
687 <PanelPart title="Sessions" action={start}>
688 {sessions.length === 0 ? (
689 <p className="text-xs text-faint">None yet.</p>
690 ) : (
691 <SessionTreeList nodes={sessions} base={base} showBranch={false} />
692 )}
693 {trees.length > sessions.length && (
694 <Link to={`${base}/work`} className="mt-2 inline-block text-xs text-muted hover:text-fg">
695 {trees.length - sessions.length} more on the Work page
696 </Link>
697 )}
698 </PanelPart>
699 )}
700
701 <PanelPart
702 title={
703 <>
704 Blocked by
705 {waiting > 0 && <span className="text-warn"> · {waiting} open</span>}
706 </>
707 }
708 action={canEdit ? <BlockedByEditor current={work.issue.blockedBy} /> : undefined}
709 >
710 {work.blockedBy.length === 0 ? (
711 <p className="text-xs text-faint">Nothing. It can start now.</p>
712 ) : (
713 <ul className="space-y-2">
714 {work.blockedBy.map((blocker) => (
715 <DependencyLine key={blocker.number} base={base} number={blocker.number} title={blocker.title} state={blocker.state} detail={blockerDetail(blocker)} />
716 ))}
717 </ul>
718 )}
719 {error && <p className="mt-2 text-xs text-danger">{error}</p>}
720 </PanelPart>
721
722 {work.blocks.length > 0 && (
723 <PanelPart title="Blocks">
724 <ul className="space-y-2">
725 {work.blocks.map((issue) => (
726 <DependencyLine key={issue.number} base={base} number={issue.number} title={issue.title} state={issue.state} />
727 ))}
728 </ul>
729 </PanelPart>
730 )}
731 </section>
732 </Card>
733 );
734}
735
736/** Changing what an issue waits on: the whole list, as numbers. */
737function BlockedByEditor({ current }: { current: number[] }) {
738 return (
739 <Popover>
740 <PopoverTrigger asChild>
741 <Button variant="ghost" size="xs">
742 <Pencil size={12} />
743 Edit
744 </Button>
745 </PopoverTrigger>
746 <PopoverContent align="end">
747 <Form method="post" className="space-y-2.5">
748 <input type="hidden" name="action" value="blocked-by" />
749 <Label htmlFor="blocked-by">Blocked by</Label>
750 <Input id="blocked-by" name="blocked_by" defaultValue={current.map((n) => `#${n}`).join(", ")} placeholder="#12, #14" autoComplete="off" />
751 <p className="text-xs text-muted">Issues of this repository that have to land first. Leave it empty for none.</p>
752 <SubmitButton size="sm" match={{ action: "blocked-by" }} pending="Saving…">
753 Save
754 </SubmitButton>
755 </Form>
756 </PopoverContent>
757 </Popover>
758 );
759}
760
761/** One issue being worked on: its title, then each claim and session on it. */
762export function LiveIssueCard({ group, title, base, now }: { group: LiveIssue; title: string | undefined; base: string; now: number }) {
763 return (
764 <Card asChild divided>
765 <li>
766 <div className="flex items-start gap-2.5 px-4 py-3">
767 <span className="mt-1">
768 <IssueIcon issue={{ state: "open", reason: null }} />
769 </span>
770 <Link to={`${base}/issues/${group.issue}`} prefetch="intent" className="min-w-0 grow font-medium hover:text-accent">
771 {title ?? `Issue #${group.issue}`} <span className="font-normal text-faint">#{group.issue}</span>
772 </Link>
773 {group.claims.length > 1 && <Badge tone="warn">{group.claims.length} claims</Badge>}
774 </div>
775 {group.claims.map((claim) => (
776 <div key={claim.id} className="px-4 py-3">
777 <ClaimLine claim={claim} now={now} base={base} />
778 </div>
779 ))}
780 {group.sessions.map((session) => (
781 <div key={session.id} className="px-4 py-3">
782 <SessionLine session={session} base={base} />
783 </div>
784 ))}
785 </li>
786 </Card>
787 );
788}
789
790/** A pull request a session is on, as Live now names it. */
791export type LivePull = { title: string; status: Pull["status"] };
792
793/** What a group of sessions on no issue is on, as its card's heading: the pull request, the branch, or neither. */
794function LiveGroupHeading({ group, pulls, base }: { group: LiveGroup; pulls: Record<number, LivePull>; base: string }) {
795 if (group.kind === "pull") {
796 const pull = pulls[group.pull];
797 return (
798 <div className="flex items-start gap-2.5 px-4 py-3">
799 <span className="mt-1">
800 <PullIcon status={pull?.status ?? "open"} />
801 </span>
802 <Link to={`${base}/pull/${group.pull}`} prefetch="intent" className="min-w-0 grow font-medium hover:text-accent">
803 {pull?.title ?? `Pull request #${group.pull}`} <span className="font-normal text-faint">#{group.pull}</span>
804 </Link>
805 <span className="mt-0.5 shrink-0 text-xs text-faint">No issue yet</span>
806 </div>
807 );
808 }
809 if (group.kind === "branch") {
810 return (
811 <div className="flex items-center gap-2.5 px-4 py-3">
812 <GitBranch size={16} className="shrink-0 text-muted" />
813 <Link to={`${base}/tree/${encodeURIComponent(group.branch)}`} prefetch="intent" className="min-w-0 truncate font-mono text-sm font-medium hover:text-accent">
814 {group.branch}
815 </Link>
816 <span className="ml-auto shrink-0 text-xs text-faint">No issue yet</span>
817 </div>
818 );
819 }
820 return (
821 <div className="flex items-center gap-2.5 px-4 py-3">
822 <Activity size={16} className="shrink-0 text-faint" />
823 <Hint label="Sessions on no issue, pull request or branch of their own yet, each titled with what it is for. One opens or claims an issue when its work becomes a change.">
824 <span className="text-sm text-muted">No branch yet</span>
825 </Hint>
826 </div>
827 );
828}
829
830/** Sessions on no issue yet that share a pull request or a branch, or share neither: a heading, then each session. */
831export function LiveGroupCard({ group, pulls, base }: { group: LiveGroup; pulls: Record<number, LivePull>; base: string }) {
832 return (
833 <Card asChild divided>
834 <li>
835 <LiveGroupHeading group={group} pulls={pulls} base={base} />
836 {group.sessions.map((session) => (
837 <div key={session.id} className="px-4 py-3">
838 {/* Its pull request and branch are the heading's, unless it has neither of its own. */}
839 <SessionLine session={session} base={base} showWhere={group.kind === "none"} />
840 </div>
841 ))}
842 </li>
843 </Card>
844 );
845}
846
847/**
848 * Who is working right now: each issue with its claims and sessions, the
849 * most recently active first, then sessions on no issue yet, by pull
850 * request, then by branch, then those on neither. `limit` caps the issues
851 * shown, for a page that links to the rest.
852 */
853export function LiveNowList({
854 claims,
855 sessions,
856 titles,
857 pulls = {},
858 defaultBranch,
859 base,
860 now,
861 limit,
862}: {
863 claims: Claim[];
864 sessions: WorkSession[];
865 titles: Record<number, string>;
866 /** The open pull requests' titles and states, for the sessions on one with no issue. */
867 pulls?: Record<number, LivePull>;
868 /** Sessions on it have no branch of their own. */
869 defaultBranch?: string | null;
870 base: string;
871 now: number;
872 limit?: number;
873}) {
874 const live = liveByIssue(claims, sessions, defaultBranch);
875 const issues = limit == null ? live.issues : live.issues.slice(0, limit);
876 return (
877 <ol className="space-y-3">
878 {issues.map((group) => (
879 <LiveIssueCard key={group.issue} group={group} title={titles[group.issue]} base={base} now={now} />
880 ))}
881 {live.groups.map((group) => (
882 <LiveGroupCard
883 key={group.kind === "pull" ? `pull:${group.pull}` : group.kind === "branch" ? `branch:${group.branch}` : "none"}
884 group={group}
885 pulls={pulls}
886 base={base}
887 />
888 ))}
889 </ol>
890 );
891}
892
893/** What to do when nobody is working on a project: bring your own harness. */
894export function StartHere({ title }: { title: string }) {
895 return (
896 <EmptyState title={title}>
897 <div className="mx-auto mt-3 max-w-lg space-y-3 text-left">
898 <p>Install the g1t plugin in Claude Code, and each session shows here as it works:</p>
899 {PLUGIN_INSTALL.split("\n").map((line) => (
900 <CopyLine key={line} text={line} prompt />
901 ))}
902 <p>From any other harness, or by hand, claim an issue with the g1t CLI:</p>
903 <CopyLine text="g1t work claim 12" prompt />
904 <p>
905 <a href="https://docs.g1t.sh/guides/bring-your-own-agent/" className="text-fg underline underline-offset-4">
906 Connect a coding agent
907 </a>{" "}
908 sets up Codex, Cursor and any MCP client;{" "}
909 <a href="https://docs.g1t.sh/guides/sessions/" className="text-fg underline underline-offset-4">
910 Sessions, claims and dependencies
911 </a>{" "}
912 says how it works.
913 </p>
914 </div>
915 </EmptyState>
916 );
917}