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