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