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.

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