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