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