Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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