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