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