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,251 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
248 released 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
396 to={sessionPath(workspaceOf(base), session.id)}
397 prefetch="intent"
398 className="inline-flex items-center gap-1 text-xs text-faint hover:text-fg"
399 >
400 <Activity size={12} />
401 View session
402 </Link>
403 }
404 >
405 <Link to={sessionPath(workspaceOf(base), session.id)} prefetch="intent" className="font-medium hover:text-accent">
406 {sessionTitle(session)}
407 </Link>
408 <p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
409 {ended ? (
410 <span>
411 Ended <TimeAgo at={session.endedAt ?? session.lastSeenAt} />
412 </span>
413 ) : (
414 <span>
415 Last heard from <TimeAgo at={session.lastSeenAt} />
416 </span>
417 )}
418 {session.branch && (
419 <span className="inline-flex min-w-0 items-center gap-1 font-mono">
420 · <GitBranch size={11} />
421 <span className="truncate">{session.branch}</span>
422 </span>
423 )}
424 {session.pull != null && (
425 <span>
426 · on{" "}
427 <Link to={`${base}/pull/${session.pull}`} className="text-fg hover:text-accent">
428 #{session.pull}
429 </Link>
430 </span>
431 )}
432 {(session.subagents ?? 0) > 0 && (
433 <span>
434 · {session.subagents} {session.subagents === 1 ? "subagent" : "subagents"}
435 </span>
436 )}
437 </p>
438 {ended && session.result && (
439 <div className="mt-3 border-t border-line pt-3 text-sm">
440 <Markdown source={session.result} repo={repoAt(base)} />
441 </div>
442 )}
443 </TimelineItem>
444 );
445}
446
447/** A subagent in a line: its kind, its task, where it stands, linked to its page. */
448export function SubagentLine({ session, base }: { session: WorkSession; base: string }) {
449 return (
450 <div className="flex min-w-0 items-center gap-1.5 text-xs">
451 <Bot size={12} className="shrink-0 text-faint" />
452 <Link to={sessionPath(workspaceOf(base), session.id)} prefetch="intent" className="shrink-0 font-medium text-fg hover:text-accent">
453 {session.agentType ?? "Subagent"}
454 </Link>
455 {session.task && (
456 <Hint label={session.task}>
457 <span className="min-w-0 truncate text-muted">{session.task}</span>
458 </Hint>
459 )}
460 <SessionStateBadge state={session.state} />
461 </div>
462 );
463}
464
465/** Subagents under a session, and theirs under them, indented on a rule. */
466function SubagentBranch({ nodes, base }: { nodes: SessionNode[]; base: string }) {
467 return (
468 <ul className="mt-1.5 space-y-1.5 border-l border-line pl-3">
469 {nodes.map((node) => (
470 <li key={node.session.id}>
471 <SubagentLine session={node.session} base={base} />
472 {node.children.length > 0 && <SubagentBranch nodes={node.children} base={base} />}
473 </li>
474 ))}
475 </ul>
476 );
477}
478
479/** Sessions as trees: each one's line, with its subagents in a compact list under it. */
480export function SessionTreeList({
481 nodes,
482 base,
483 showWhere,
484 showBranch,
485 className,
486 ender,
487}: {
488 nodes: SessionNode[];
489 base: SessionBase;
490 showWhere?: boolean;
491 showBranch?: boolean;
492 className?: string;
493 ender?: SessionEnder;
494}) {
495 return (
496 <ul className={className ?? "space-y-3"}>
497 {nodes.map((node) => (
498 <li key={node.session.id}>
499 <SessionLine session={node.session} base={baseOf(base, node.session)} showWhere={showWhere} showBranch={showBranch} ender={ender} />
500 {node.children.length > 0 && (
501 <div className="pl-7.5">
502 <SubagentBranch nodes={node.children} base={baseOf(base, node.session)} />
503 </div>
504 )}
505 </li>
506 ))}
507 </ul>
508 );
509}
510
511/**
512 * Starting a session on the work item on the workspace's Anthropic account: a
513 * button opening a dialog that asks what kind of work and for anything to
514 * add, posting `start-session` to the work item's action.
515 */
516export function StartSessionDialog({ setup, repo, error }: { setup: AgentSessionsSetup; /** The repository it works in, `owner/name`. */ repo?: string; error?: string | null }) {
517 const [open, setOpen] = useState(Boolean(error));
518 return (
519 <Dialog open={open} onOpenChange={setOpen}>
520 <DialogTrigger asChild>
521 <Button variant="outline" size="xs">
522 <Sparkles size={12} />
523 Start a session
524 </Button>
525 </DialogTrigger>
526 <DialogContent>
527 <Form method="post" className="grid gap-5">
528 <input type="hidden" name="action" value="start-session" />
529 {repo && <input type="hidden" name="repo" value={repo} />}
530 <DialogHeader>
531 <DialogTitle>Start a session on this work item</DialogTitle>
532 <DialogDescription>
533 Claude works on it on {setup.connectionName ? `the ${setup.connectionName} connection` : "your workspace's Anthropic account"}
534 {setup.model ? `, with ${setup.model}` : ""}. It shows on this work item as it goes, with its subagents.
535 </DialogDescription>
536 </DialogHeader>
537 <FieldSet>
538 <FieldLegend className="mb-0">What to do</FieldLegend>
539 <RadioGroup name="mode" defaultValue="implement">
540 {AGENT_MODES.map((mode) => (
541 <RadioOption key={mode.mode} value={mode.mode} label={mode.label} description={mode.hint} />
542 ))}
543 </RadioGroup>
544 </FieldSet>
545 <Field>
546 <FieldLabel htmlFor="start-session-instruction">Anything to add</FieldLabel>
547 <Textarea id="start-session-instruction" name="instruction" rows={3} maxLength={4000} placeholder="Optional: what to keep in mind" />
548 <FieldError>{error}</FieldError>
549 </Field>
550 <DialogFooter>
551 <DialogClose asChild>
552 <Button variant="ghost">Cancel</Button>
553 </DialogClose>
554 <SubmitButton match={{ action: "start-session" }} pending="Starting…">
555 Start
556 </SubmitButton>
557 </DialogFooter>
558 </Form>
559 </DialogContent>
560 </Dialog>
561 );
562}
563
564const KIND_ICON: Record<Decision["kind"], ReactNode> = {
565 proposal: <MessageSquare size={14} />,
566 claim: <MessageSquare size={14} />,
567 integration_failure: <CircleX size={14} className="text-danger" />,
568 integration_passed: <Layers size={14} className="text-success" />,
569 fix: <Link2 size={14} className="text-accent" />,
570 approval: <CircleCheck size={14} className="text-success" />,
571 merge: <GitMerge size={14} className="text-merged" />,
572 note: <MessageSquare size={14} />,
573};
574
575/** A pull request's number, linked where it is known; plain text where it is not (`pullHref` said null). */
576function NumberLink({ to, className, children }: { to: string | null; className?: string; children: ReactNode }) {
577 if (!to) return <span className={className}>{children}</span>;
578 return (
579 <Link to={to} className={className}>
580 {children}
581 </Link>
582 );
583}
584
585/** Where pull request `number` is: `pullHref`'s answer when given (a work item's, across its repositories), else under `base`. */
586function pullAt(base: string, pullHref: PullHref | undefined, number: number): string | null {
587 return pullHref ? pullHref(number) : `${base}/pull/${number}`;
588}
589
590/** The pull requests tested together, in queue order: `#41 + #44`. */
591export function Combination({ numbers, base, culprit, pullHref }: { numbers: number[]; base: string; culprit?: number | null; pullHref?: PullHref }) {
592 return (
593 <span className="inline-flex flex-wrap items-center gap-1">
594 {numbers.map((number, index) => (
595 <Fragment key={number}>
596 {index > 0 && <span className="text-faint">+</span>}
597 <NumberLink to={pullAt(base, pullHref, number)}>
598 <Badge shape="tag" tone={number === culprit ? "danger" : "neutral"} className="font-mono hover:border-line-strong">
599 #{number}
600 </Badge>
601 </NumberLink>
602 </Fragment>
603 ))}
604 </span>
605 );
606}
607
608/** The check a decision rests on, linked where it can be seen. */
609function CheckLink({ check }: { check: { name: string; conclusion: string; url: string | null } }) {
610 const href = checkHref(check.url);
611 const text = (
612 <>
613 <span className="font-mono">{check.name}</span> {check.conclusion}
614 </>
615 );
616 if (!href) return <span>{text}</span>;
617 return href.startsWith("/") ? (
618 <Link to={href} className="text-fg underline-offset-2 hover:underline">
619 {text}
620 </Link>
621 ) : (
622 <a href={href} className="text-fg underline-offset-2 hover:underline" rel="noreferrer" target="_blank">
623 {text}
624 </a>
625 );
626}
627
628/** One decision record: its kind, how sure it is, the sentence, who and when, and what it rests on. */
629export function DecisionLine({ decision, base, pullHref }: { decision: Decision; base: string; pullHref?: PullHref }) {
630 const evidence = evidenceOf(decision);
631 return (
632 <div className="flex items-start gap-2.5">
633 <span className="mt-0.5 shrink-0 text-faint">{KIND_ICON[decision.kind]}</span>
634 <div className="min-w-0 grow">
635 <p className="flex flex-wrap items-center gap-1.5 text-xs">
636 <span className="font-medium text-fg">{DECISION_KINDS[decision.kind]}</span>
637 <ConfidenceBadge confidence={decision.confidence} />
638 {decision.pull != null && (
639 <NumberLink to={pullAt(base, pullHref, decision.pull)} className="text-muted hover:text-fg">
640 #{decision.pull}
641 </NumberLink>
642 )}
643 </p>
644 <p className="mt-1 text-sm wrap-break-word">
645 <NumberedText text={decision.summary} base={base} pullHref={pullHref} />
646 </p>
647 <p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
648 <span>{decision.actor ?? "g1t"}</span>
649 <span>
650 · <TimeAgo at={decision.createdAt} />
651 </span>
652 {evidence.combination.length > 1 && (
653 <span className="inline-flex items-center gap-1.5">
654 · tested as{" "}
655 <Combination numbers={evidence.combination} base={base} pullHref={pullHref} culprit={decision.kind === "integration_failure" ? decision.pull : null} />
656 </span>
657 )}
658 {evidence.check && decision.kind === "integration_failure" && (
659 <span>
660 · <CheckLink check={evidence.check} />
661 </span>
662 )}
663 {evidence.commit && (
664 <span className="inline-flex items-center gap-1 font-mono">
665 · <GitCommitHorizontal size={11} />
666 {evidence.commit.slice(0, 7)}
667 </span>
668 )}
669 </p>
670 </div>
671 </div>
672 );
673}
674
675/** Decision records, as a list in a card. */
676export function DecisionList({ decisions, base, pullHref }: { decisions: Decision[]; base: string; pullHref?: PullHref }) {
677 return (
678 <Card asChild divided>
679 <ol>
680 {decisions.map((decision) => (
681 <li key={decision.id} className="px-4 py-3">
682 <DecisionLine decision={decision} base={base} pullHref={pullHref} />
683 </li>
684 ))}
685 </ol>
686 </Card>
687 );
688}
689
690/**
691 * A combination that failed in the merge queue: which pull requests were
692 * tested together, the check that failed, which ones it fails with, and
693 * whether it has been answered since.
694 */
695export function IntegrationFailure({
696 failure,
697 base,
698 fixed,
699 branch = "main",
700 className,
701 pullHref,
702}: {
703 failure: Decision;
704 base: string;
705 /** Where each pull request is, for a work item's, whose pull requests are in several repositories. */
706 pullHref?: PullHref;
707 /** The branch the combination was built on. */
708 branch?: string;
709 /** A fix or a passing combination since, when there is one. */
710 fixed?: Decision | null;
711 className?: string;
712}) {
713 const evidence = evidenceOf(failure);
714 return (
715 <Alert tone={fixed ? "info" : "danger"} className={className}>
716 <AlertTitle className="flex flex-wrap items-center gap-x-2 gap-y-1 text-fg">
717 {failure.pull != null ? (
718 <span>
719 <TriangleAlert size={15} className={`mr-2 inline align-[-3px] ${fixed ? "text-info" : "text-danger"}`} />
720 <NumberLink to={pullAt(base, pullHref, failure.pull)} className="hover:underline">
721 #{failure.pull}
722 </NumberLink>{" "}
723 failed in the merge queue {evidence.combination.length > 1 ? "together with others" : "on its own"}
724 </span>
725 ) : (
726 <span>A combination failed in the merge queue</span>
727 )}
728 <ConfidenceBadge confidence={failure.confidence} />
729 <span className="text-xs font-normal text-muted">
730 <TimeAgo at={failure.createdAt} />
731 </span>
732 </AlertTitle>
733 <AlertDescription className="space-y-1.5">
734 {evidence.combination.length > 0 && (
735 <p className="flex flex-wrap items-center gap-1.5 text-xs">
736 Tested as <span className="font-mono text-fg">{branch}</span> <span className="text-faint">+</span>
737 <Combination numbers={evidence.combination} base={base} pullHref={pullHref} culprit={failure.pull} />
738 </p>
739 )}
740 {evidence.check && (
741 <p className="text-xs">
742 Failing check: <CheckLink check={evidence.check} />
743 </p>
744 )}
745 <p className="text-xs">
746 {evidence.suspects.length > 0 ? (
747 <>
748 Fails with{" "}
749 <NumberedText text={evidence.suspects.map((n) => `#${n}`).join(", ")} base={base} pullHref={pullHref} />
750 {evidence.bisected ? ", narrowed down by building smaller combinations." : "."}
751 </>
752 ) : (
753 "It fails by itself: the change is the cause."
754 )}
755 </p>
756 {fixed && (
757 <p className="text-xs text-fg">
758 <CircleCheck size={13} className="mr-1.5 inline align-[-2px] text-success" />
759 {fixed.kind === "integration_passed" ? "Passed together since" : "Fixed since"}:{" "}
760 <span className="text-muted">
761 <NumberedText text={fixed.summary} base={base} pullHref={pullHref} />
762 </span>
763 </p>
764 )}
765 </AlertDescription>
766 </Alert>
767 );
768}
769
770const FEED_ICON: Record<FeedLine["type"], ReactNode> = {
771 "work.claimed": <Hand size={14} />,
772 "work.released": <Hand size={14} />,
773 "work.claim_expired": <Timer size={14} />,
774 "work.handoff_requested": <ArrowRightLeft size={14} />,
775 "session.started": <Play size={14} />,
776 "session.ended": <Square size={14} />,
777 "dependency.changed": <Link2 size={14} />,
778 "overlap.detected": <TriangleAlert size={14} />,
779 "integration.failed": <CircleX size={14} />,
780 "integration.passed": <Layers size={14} />,
781 "decision.recorded": <CircleDot size={14} />,
782};
783
784const FEED_TONE: Record<FeedLine["tone"], string> = {
785 neutral: "text-faint",
786 accent: "text-accent",
787 success: "text-success",
788 warn: "text-warn",
789 danger: "text-danger",
790 info: "text-info",
791};
792
793/** Recent coordination events, newest first, one line each. */
794export function CoordinationFeed({ lines, base }: { lines: FeedLine[]; base: string }) {
795 return (
796 <Card asChild divided>
797 <ol>
798 {lines.map((line) => (
799 <li key={line.id} className="flex items-start gap-3 px-4 py-2.5 text-sm">
800 <span className={`mt-0.5 shrink-0 ${FEED_TONE[line.tone]}`}>{FEED_ICON[line.type]}</span>
801 <p className="min-w-0 grow wrap-break-word text-muted">
802 <PersonLink name={line.who ?? "g1t"} className="font-medium text-fg hover:underline" />
803 {line.harness && <span className="text-faint"> via {harnessName(line.harness)}</span>}{" "}
804 <NumberedText text={line.text} base={line.repo ? `/${line.repo}` : base} />
805 {line.repo && line.repo !== base.slice(1) && <span className="text-faint"> in {line.repo}</span>}
806 {line.session && (
807 <>
808 {" · "}
809 <Link to={sessionPath(workspaceOf(base), line.session)} className="text-fg hover:text-accent">
810 the session
811 </Link>
812 </>
813 )}
814 </p>
815 <span className="shrink-0 text-xs text-faint">
816 <TimeAgo at={line.time} />
817 </span>
818 </li>
819 ))}
820 </ol>
821 </Card>
822 );
823}
824
825/** A work item's place among others: one it waits on, or one waiting on it. */
826function DependencyLine({ workspace, number, title, state, detail }: { workspace: string; number: number; title: string; state: State; detail?: ReactNode }) {
827 return (
828 <li className="flex items-start gap-2">
829 <span className="mt-0.5">
830 <ItemIcon item={{ state, reason: null }} size={14} />
831 </span>
832 <span className="min-w-0 grow">
833 <Link to={itemPath(workspace, number)} className="block truncate text-sm hover:text-accent">
834 {title} <span className="text-faint">#{number}</span>
835 </Link>
836 {detail && <span className="block text-xs text-muted">{detail}</span>}
837 </span>
838 </li>
839 );
840}
841
842/** Where a blocker stands, in a few words: landed, being worked on, or waiting. */
843export function blockerDetail(blocker: Blocker): string {
844 if (blocker.pulls.some((pull) => pull.status === "merged")) return "Merged";
845 if (blocker.state === "closed") return "Closed";
846 const open = blocker.pulls.filter((pull) => pull.status === "open" || pull.status === "draft");
847 const who = blocker.claims.map((claim) => claim.actor.username);
848 return [
849 who.length > 0 ? `Claimed by ${who.join(", ")}` : "Not claimed",
850 open.length > 0 ? `${open.length} pull ${open.length === 1 ? "request" : "requests"} open` : null,
851 ]
852 .filter(Boolean)
853 .join(" · ");
854}
855
856/** A part of the Work panel, under a rule. */
857function PanelPart({ title, action, children }: { title: ReactNode; action?: ReactNode; children: ReactNode }) {
858 return (
859 <div className="border-t border-line pt-3">
860 <div className="flex min-h-7 items-center justify-between gap-2">
861 <p className="text-xs font-medium text-muted">{title}</p>
862 {action}
863 </div>
864 <div className="mt-1.5">{children}</div>
865 </div>
866 );
867}
868
869/**
870 * The Work panel of a work item's sidebar: who has claimed it, the sessions on
871 * it, what it waits on and what waits on it, and for those who may, the
872 * blockers to change.
873 */
874export function ItemWorkPanel({
875 work,
876 ended,
877 base,
878 now,
879 canEdit,
880 error,
881 sessions: all,
882 start,
883}: {
884 work: WorkContext;
885 /** Claims on it that ended lately, shown muted. */
886 ended: Claim[];
887 /** Where each of its sessions' pages is: under the repository it was recorded in. */
888 base: SessionBase;
889 now: number;
890 canEdit: boolean;
891 error?: string | null;
892 /** The sessions tied to it with their subagents, when they could be read; else its own sessions. */
893 sessions?: WorkSession[] | null;
894 /** Starting a session on it, for those who may. */
895 start?: ReactNode;
896}) {
897 const open = work.item.state === "open";
898 const trees = sessionTree(mergeSessions(work.sessions, all));
899 const sessions = trees.slice(0, 5);
900 const waiting = work.blockedBy.filter((blocker) => blocker.state === "open").length;
901 return (
902 <Card asChild className="space-y-4 p-4">
903 <section aria-label="Work">
904 <h3 className="flex items-center gap-2 text-sm font-medium">
905 <Activity size={14} className="text-accent" />
906 Work
907 </h3>
908
909 <div className="space-y-3">
910 {work.claims.length === 0 && ended.length === 0 ? (
911 <p className="text-xs text-muted">
912 {open ? (
913 <>
914 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>.
915 </>
916 ) : (
917 "Nobody is working on it."
918 )}
919 </p>
920 ) : (
921 [...work.claims, ...ended.slice(0, 3)].map((claim) => <ClaimLine key={claim.id} claim={claim} now={now} />)
922 )}
923 {work.claims.length > 1 && (
924 <Alert tone="warn" className="flex items-start gap-1.5 px-2.5 py-1.5 text-xs">
925 <TriangleAlert size={13} className="mt-0.5 shrink-0 text-warn" />
926 {work.claims.length} claims stand on it: they may be doing the same work.
927 </Alert>
928 )}
929 </div>
930
931 {(sessions.length > 0 || start) && (
932 <PanelPart title="Sessions" action={start}>
933 {sessions.length === 0 ? (
934 <p className="text-xs text-faint">None yet.</p>
935 ) : (
936 <SessionTreeList nodes={sessions} base={base} showBranch={false} />
937 )}
938 {trees.length > sessions.length && (
939 <p className="mt-2 text-xs text-muted">
940 <Link to={sessionsPath(work.item.workspace, { state: "recent", item: String(work.item.number) })} className="hover:text-fg hover:underline">
941 {trees.length - sessions.length} more in Work's Sessions
942 </Link>
943 </p>
944 )}
945 </PanelPart>
946 )}
947
948 <PanelPart
949 title={
950 <>
951 Blocked by
952 {waiting > 0 && <span className="text-warn"> · {waiting} open</span>}
953 </>
954 }
955 action={canEdit ? <BlockedByEditor current={work.item.blockedBy} /> : undefined}
956 >
957 {work.blockedBy.length === 0 ? (
958 <p className="text-xs text-faint">Nothing. It can start now.</p>
959 ) : (
960 <ul className="space-y-2">
961 {work.blockedBy.map((blocker) => (
962 <DependencyLine key={blocker.number} workspace={work.item.workspace} number={blocker.number} title={blocker.title} state={blocker.state} detail={blockerDetail(blocker)} />
963 ))}
964 </ul>
965 )}
966 {error && <p className="mt-2 text-xs text-danger">{error}</p>}
967 </PanelPart>
968
969 {work.blocks.length > 0 && (
970 <PanelPart title="Blocks">
971 <ul className="space-y-2">
972 {work.blocks.map((item) => (
973 <DependencyLine key={item.number} workspace={work.item.workspace} number={item.number} title={item.title} state={item.state} />
974 ))}
975 </ul>
976 </PanelPart>
977 )}
978 </section>
979 </Card>
980 );
981}
982
983/** Changing what a work item waits on: the whole list, as numbers. */
984function BlockedByEditor({ current }: { current: number[] }) {
985 return (
986 <Popover>
987 <PopoverTrigger asChild>
988 <Button variant="ghost" size="xs">
989 <Pencil size={12} />
990 Edit
991 </Button>
992 </PopoverTrigger>
993 <PopoverContent align="end">
994 <Form method="post" className="space-y-2.5">
995 <input type="hidden" name="action" value="blocked-by" />
996 <Label htmlFor="blocked-by">Blocked by</Label>
997 <Input id="blocked-by" name="blocked_by" defaultValue={current.map((n) => `#${n}`).join(", ")} placeholder="#12, #14" autoComplete="off" />
998 <p className="text-xs text-muted">Work items of this workspace that have to land first. Leave it empty for none.</p>
999 <SubmitButton size="sm" match={{ action: "blocked-by" }} pending="Saving…">
1000 Save
1001 </SubmitButton>
1002 </Form>
1003 </PopoverContent>
1004 </Popover>
1005 );
1006}
1007
1008/** One work item being worked on: its title, then each claim and session on it. */
1009export function LiveItemCard({
1010 group,
1011 title,
1012 base,
1013 now,
1014 ender,
1015}: {
1016 group: LiveItem;
1017 title: string | undefined;
1018 base: string;
1019 now: number;
1020 ender?: SessionEnder;
1021}) {
1022 return (
1023 <Card asChild divided>
1024 <li>
1025 <div className="flex items-start gap-2.5 px-4 py-3">
1026 <span className="mt-1">
1027 <ItemIcon item={{ state: "open", reason: null }} />
1028 </span>
1029 <Link to={itemPath(workspaceOf(base), group.item)} prefetch="intent" className="min-w-0 grow font-medium hover:text-accent">
1030 {title ?? `Work item #${group.item}`} <span className="font-normal text-faint">#{group.item}</span>
1031 </Link>
1032 {group.claims.length > 1 && <Badge tone="warn">{group.claims.length} claims</Badge>}
1033 </div>
1034 {group.claims.map((claim) => (
1035 <div key={claim.id} className="px-4 py-3">
1036 <ClaimLine claim={claim} now={now} base={base} />
1037 </div>
1038 ))}
1039 {group.sessions.map((session) => (
1040 <div key={session.id} className="px-4 py-3">
1041 <SessionLine session={session} base={base} ender={ender} />
1042 </div>
1043 ))}
1044 </li>
1045 </Card>
1046 );
1047}
1048
1049/** A pull request a session is on, as Live now names it. */
1050export type LivePull = { title: string; status: Pull["status"] };
1051
1052/** What a group of sessions on no work item is on, as its card's heading: the pull request, the branch, or neither. */
1053function LiveGroupHeading({
1054 group,
1055 pulls,
1056 base,
1057 defaultBranch,
1058}: {
1059 group: LiveGroup;
1060 pulls: Record<number, LivePull>;
1061 base: string;
1062 defaultBranch?: string | null;
1063}) {
1064 if (group.kind === "pull") {
1065 const pull = pulls[group.pull];
1066 return (
1067 <div className="flex items-start gap-2.5 px-4 py-3">
1068 <span className="mt-1">
1069 <PullIcon status={pull?.status ?? "open"} />
1070 </span>
1071 <Link to={`${base}/pull/${group.pull}`} prefetch="intent" className="min-w-0 grow font-medium hover:text-accent">
1072 {pull?.title ?? `Pull request #${group.pull}`} <span className="font-normal text-faint">#{group.pull}</span>
1073 </Link>
1074 <span className="mt-0.5 shrink-0 text-xs text-faint">No work item yet</span>
1075 </div>
1076 );
1077 }
1078 if (group.kind === "branch") {
1079 return (
1080 <div className="flex items-center gap-2.5 px-4 py-3">
1081 <GitBranch size={16} className="shrink-0 text-muted" />
1082 <Link to={`${base}/tree/${encodeURIComponent(group.branch)}`} prefetch="intent" className="min-w-0 truncate font-mono text-sm font-medium hover:text-accent">
1083 {group.branch}
1084 </Link>
1085 <span className="ml-auto shrink-0 text-xs text-faint">No work item yet</span>
1086 </div>
1087 );
1088 }
1089 // Working on the default branch itself, or somewhere git has not said yet.
1090 const onDefault = defaultBranch != null && group.sessions.some((session) => session.branch === defaultBranch);
1091 return (
1092 <div className="flex items-center gap-2.5 px-4 py-3">
1093 {onDefault ? <GitBranch size={16} className="shrink-0 text-faint" /> : <Activity size={16} className="shrink-0 text-faint" />}
1094 <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.">
1095 <span className="text-sm text-muted">
1096 {onDefault ? (
1097 <>
1098 On <span className="font-mono">{defaultBranch}</span>
1099 </>
1100 ) : (
1101 "No branch yet"
1102 )}
1103 </span>
1104 </Hint>
1105 </div>
1106 );
1107}
1108
1109/** Sessions on no work item yet that share a pull request or a branch, or share neither: a heading, then each session. */
1110export function LiveGroupCard({
1111 group,
1112 pulls,
1113 base,
1114 defaultBranch,
1115 ender,
1116}: {
1117 group: LiveGroup;
1118 pulls: Record<number, LivePull>;
1119 base: string;
1120 defaultBranch?: string | null;
1121 ender?: SessionEnder;
1122}) {
1123 return (
1124 <Card asChild divided>
1125 <li>
1126 <LiveGroupHeading group={group} pulls={pulls} base={base} defaultBranch={defaultBranch} />
1127 {group.sessions.map((session) => (
1128 <div key={session.id} className="px-4 py-3">
1129 {/* Its pull request and branch are the heading's, unless it has neither of its own. */}
1130 <SessionLine
1131 session={session}
1132 base={base}
1133 showWhere={group.kind === "none"}
1134 showBranch={group.kind === "none" && session.branch !== defaultBranch}
1135 ender={ender}
1136 />
1137 </div>
1138 ))}
1139 </li>
1140 </Card>
1141 );
1142}
1143
1144/**
1145 * Who is working right now: each work item with its claims and sessions, the
1146 * most recently active first, then sessions on no work item yet, by pull
1147 * request, then by branch, then those on neither. `limit` caps the work items
1148 * shown, for a page that links to the rest.
1149 */
1150export function LiveNowList({
1151 claims,
1152 sessions,
1153 titles,
1154 pulls = {},
1155 defaultBranch,
1156 base,
1157 now,
1158 limit,
1159 ender,
1160}: {
1161 claims: Claim[];
1162 sessions: WorkSession[];
1163 titles: Record<number, string>;
1164 /** The open pull requests' titles and states, for the sessions on one with no work item. */
1165 pulls?: Record<number, LivePull>;
1166 /** Sessions on it have no branch of their own. */
1167 defaultBranch?: string | null;
1168 base: string;
1169 now: number;
1170 limit?: number;
1171 /** Who is looking: each session they may end has a menu to end it by hand. */
1172 ender?: SessionEnder;
1173}) {
1174 const { awake, idle } = awakeAndIdle(sessions);
1175 const live = liveByItem(claims, awake, defaultBranch);
1176 const items = limit == null ? live.items : live.items.slice(0, limit);
1177 return (
1178 <ol className="space-y-3">
1179 {items.map((group) => (
1180 <LiveItemCard key={group.item} group={group} title={titles[group.item]} base={base} now={now} ender={ender} />
1181 ))}
1182 {live.groups.map((group) => (
1183 <LiveGroupCard
1184 key={group.kind === "pull" ? `pull:${group.pull}` : group.kind === "branch" ? `branch:${group.branch}` : "none"}
1185 group={group}
1186 pulls={pulls}
1187 base={base}
1188 defaultBranch={defaultBranch}
1189 ender={ender}
1190 />
1191 ))}
1192 {idle.length > 0 && <IdleSessions sessions={idle} base={base} ender={ender} />}
1193 </ol>
1194 );
1195}
1196
1197/**
1198 * Sessions not heard from for 15 minutes, folded under Live now: usually
1199 * an open tab, not work going on. Each moves back up when it is heard from,
1200 * and drops off when it times out.
1201 */
1202function IdleSessions({ sessions, base, ender }: { sessions: WorkSession[]; base: string; ender?: SessionEnder }) {
1203 return (
1204 <Card asChild divided>
1205 <li>
1206 <Collapsible>
1207 <CollapsibleTrigger className="w-full px-4 py-2.5">
1208 <ChevronRight size={14} className="shrink-0" />
1209 {sessions.length === 1 ? "1 idle session" : `${sessions.length} idle sessions`}
1210 <span className="ml-auto text-xs text-faint">
1211 last seen <TimeAgo at={sessions[0].lastSeenAt} />
1212 </span>
1213 </CollapsibleTrigger>
1214 <CollapsibleContent className="divide-y divide-line border-t border-line">
1215 {sessions.map((session) => (
1216 <div key={session.id} className="px-4 py-3">
1217 <SessionLine session={session} base={base} ender={ender} />
1218 </div>
1219 ))}
1220 </CollapsibleContent>
1221 </Collapsible>
1222 </li>
1223 </Card>
1224 );
1225}
1226
1227/** What to do when nobody is working on a project: bring your own harness. */
1228export function StartHere({ title }: { title: string }) {
1229 return (
1230 <Card className="flex flex-wrap items-center justify-between gap-4 p-5">
1231 <div>
1232 <p className="text-sm font-medium">{title}</p>
1233 <p className="mt-1 text-xs text-muted">Sessions and claimed work items appear here as work happens.</p>
1234 </div>
1235 <Dialog>
1236 <DialogTrigger asChild>
1237 <Button variant="outline" size="sm"><Bot size={14} /> Connect an agent</Button>
1238 </DialogTrigger>
1239 <DialogContent className="max-w-xl">
1240 <DialogHeader>
1241 <DialogTitle>Connect your agent</DialogTitle>
1242 <DialogDescription>Choose your client, connect it to g1t, then ask it to work on this project.</DialogDescription>
1243 </DialogHeader>
1244 <AgentSetup />
1245 <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>
1246 <a href="https://docs.g1t.sh/guides/bring-your-own-agent/" className="text-sm text-accent hover:underline">Connection guide &rarr;</a>
1247 </DialogContent>
1248 </Dialog>
1249 </Card>
1250 );
1251}