Skip to content

Commit

Merge branch 'worktree-agent-aa68cf6ecc03dffaf'

# Conflicts: # apps/web/app/routes/repo/queue.tsx

syntaqxcommitted Parents5c03ddd7e004bbBrowse files
27 files+2247−2890/27 viewed
+7−0
239239 A combined state that passes is recorded too: an `integration_passed`
240240 decision and the `integration.passed` event, with `pull` and `combination`.
241241
242+On the site, the pull request shows the failure above its checks until it
243+passes in the queue or merges: the pull requests tested together, with the
244+one that answers for it marked, the check that failed (linked to its run),
245+what it fails with, and the fix once one is recorded. Its issue shows the
246+same under its pull requests, and the repository's
247+[Work page](/guides/sessions/#on-the-site) lists it among what happened.
248+
242249 ### Getting back in the queue
243250
244251 The merge stays asked for. Push a fix to the pull request, starting from
+33−0
2121 What git and g1t record (commits, pull requests, checks, merges) is the
2222 truth; sessions are the account of how it got there.
2323
24+## On the site
25+
26+Everything here shows on g1t.sh as it happens, without reloading.
27+
28+- **The Work page**, `g1t.sh/<workspace>/<repo>/work`, a tab beside
29+ **Pull requests**, **Merge queue** and **Sessions**:
30+ - **Live now**: the active claims and the active and idle sessions,
31+ grouped by the issue they are on, with sessions on no issue yet
32+ after them. An issue with more than one claim is marked.
33+ - **Merge queue**: what is waiting to land and what is being tested,
34+ as on the [Merge queue](/guides/merge-queue/#the-merge-queue-page) page.
35+ - **What happened**: the latest 30 coordination events, from claims to
36+ what the merge queue found when it combined changes.
37+ - **Just landed**: the latest merges, each with a link to the session
38+ behind it.
39+
40+ With nothing going on, it says how to start: install the
41+ [Claude Code plugin](/guides/claude-code-plugin/), or run
42+ `g1t work claim <number>` from any harness.
43+- **An issue's Work panel**, at the top of its sidebar: its claims (who,
44+ from which harness, since when and when each lapses; ones released,
45+ expired or handed off lately are shown dimmed), the sessions on it with
46+ their state and when each was last heard from, what it is blocked by
47+ and where each of those stands, and the issues it blocks. Its author
48+ and anyone with the Triage role change what it is blocked by with
49+ **Edit**. Under its pull requests, the issue shows the latest
50+ combination that failed in the merge queue, and its
51+ [decision records](/guides/why-blame/#decision-records), each marked
52+ **Claimed**, **Tested** or **Approved**.
53+- **A session's page**, opened from any of these: who ran it, in which
54+ harness, the issue, pull request, branch and commit it is on, and what
55+ it recorded.
56+
2457 ## Picking up an issue
2558
2659 Before working on an issue, load its whole context in one call: `issue`
+10−0
7979 - the commit that last changed it;
8080 - the pull request it arrived in, and who or what wrote it and merged it;
8181 - the issue that asked for it, with its description;
82+- how it was decided: the [decision records](#decision-records) on the
83+ issue and every pull request for it, oldest first, each marked
84+ **Claimed**, **Tested** or **Approved**. A combination that failed in
85+ the merge queue is shown first, with the pull requests tested together,
86+ the check that failed and the fix that answered it; then the
87+ proposals, the fix, the approval and the merge;
8288 - when a coding agent wrote it, the agent's own account of the change and the
8389 commands it ran, taken from its session. The steps shown are from the
8490 work that produced that commit: the agent's messages, and the tool calls
155161 attributed to whoever recorded it, carries
156162 the issue the pull request is for, and is published as `decision.recorded`.
157163
164+A pull request's **Session** tab lists its decisions above what was
165+recorded, with the harness sessions working on it; an issue lists its own
166+and its pull requests' under them.
167+
158168 Read them with the `pull_request` tool's `list_decisions` action, with
159169 `GET /repos/{owner}/{name}/pulls/{number}/decisions`, or for an issue and
160170 all its pull requests with
+36−0
11 import {
22 ArrowUpRight,
33 CircleDot,
4+ Scale,
45 GitCommitHorizontal,
56 GitPullRequest,
67 MessagesSquare,
1617 import type { loader as whyLoader } from "../routes/repo/why";
1718 import { repoAt } from "../lib/markdown-plugins";
1819 import { CommitAvatars, CommitNames } from "./commit-person";
20+import { DecisionLine, IntegrationFailure } from "./coordination";
21+import { resolutionOf } from "../lib/coordination";
1922 import { Markdown } from "./markdown";
2023 import { TimeAgo } from "./ui";
2124 import { Button } from "./ui/button";
296299 </section>
297300 )}
298301
302+ {current.decisions.length > 0 && <WhyDecisions decisions={current.decisions} base={base} />}
303+
299304 {current.steps.length > 0 && current.pull && (
300305 <section className="p-4">
301306 <p className="flex items-center gap-1.5 text-xs text-faint">
345350 </div>
346351 );
347352 }
353+
354+/**
355+ * The decisions behind the line, oldest first: each proposal, the
356+ * combination that failed in the merge queue and the fix that answered it,
357+ * the approval and the merge, each with how sure it is.
358+ */
359+function WhyDecisions({ decisions, base }: { decisions: WhyData["decisions"]; base: string }) {
360+ const failures = decisions.filter((decision) => decision.kind === "integration_failure");
361+ const failure = failures[failures.length - 1] ?? null;
362+ const answered = failure ? resolutionOf(decisions, failure) : null;
363+ return (
364+ <section className="p-4">
365+ <p className="flex items-center gap-1.5 text-xs text-faint">
366+ <Scale size={13} />
367+ How it was decided
368+ </p>
369+ {failure && (
370+ <IntegrationFailure className="mt-3" failure={failure} base={base} fixed={answered?.fix ?? answered?.passed} />
371+ )}
372+ <ol className="mt-3 space-y-3">
373+ {decisions
374+ .filter((decision) => decision !== failure)
375+ .map((decision) => (
376+ <li key={decision.id}>
377+ <DecisionLine decision={decision} base={base} />
378+ </li>
379+ ))}
380+ </ol>
381+ </section>
382+ );
383+}
+614−0
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+ */
7+import {
8+ Activity,
9+ ArrowRightLeft,
10+ CircleCheck,
11+ CircleDot,
12+ CircleX,
13+ GitBranch,
14+ GitCommitHorizontal,
15+ GitMerge,
16+ Hand,
17+ Layers,
18+ Link2,
19+ MessageSquare,
20+ Pencil,
21+ Play,
22+ Square,
23+ Timer,
24+ TriangleAlert,
25+} from "lucide-react";
26+import { Fragment, type ReactNode } from "react";
27+import { Form, Link } from "react-router";
28+
29+import type {
30+ Blocker,
31+ Claim,
32+ Decision,
33+ DecisionConfidence,
34+ Harness,
35+ SessionState,
36+ State,
37+ WorkContext,
38+ WorkSession,
39+} from "@g1t/contracts";
40+
41+import {
42+ CLAIM_ENDED,
43+ CONFIDENCE,
44+ DECISION_KINDS,
45+ type FeedLine,
46+ SESSION_STATES,
47+ checkHref,
48+ evidenceOf,
49+ expiresIn,
50+ harnessName,
51+ numberedParts,
52+} from "../lib/coordination";
53+import { Input, SubmitButton, TimeAgo } from "./ui";
54+import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
55+import { Avatar } from "./ui/avatar";
56+import { Badge, type BadgeTone } from "./ui/badge";
57+import { Button } from "./ui/button";
58+import { Card } from "./ui/card";
59+import { Hint } from "./ui/hint";
60+import { Label } from "./ui/label";
61+import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
62+import { PersonLink } from "./work";
63+import { IssueIcon } from "./work-icons";
64+
65+/** Text with each `#N` linked to that issue or pull request (the issue's address leads to a pull request too). */
66+export function NumberedText({ text, base }: { text: string; base: string }) {
67+ return (
68+ <>
69+ {numberedParts(text).map((part, index) =>
70+ typeof part === "number" ? (
71+ <Link key={index} to={`${base}/issues/${part}`} className="font-medium text-fg hover:text-accent">
72+ #{part}
73+ </Link>
74+ ) : (
75+ <Fragment key={index}>{part}</Fragment>
76+ ),
77+ )}
78+ </>
79+ );
80+}
81+
82+/** The harness a session or claim came from. */
83+export function HarnessBadge({ harness }: { harness: Harness | null | undefined }) {
84+ const name = harnessName(harness);
85+ if (!name) return null;
86+ return <Badge>{name}</Badge>;
87+}
88+
89+/** Who did it: an agent's avatar when it came through a harness (a person's token, the harness declared), else theirs. */
90+export function Actor({ username, harness, size = 20 }: { username: string; harness?: Harness | null; size?: number }) {
91+ return <Avatar name={username} size={size} agent={harness != null} />;
92+}
93+
94+const SESSION_TONE: Record<SessionState, BadgeTone> = { active: "success", idle: "warn", ended: "neutral" };
95+
96+export function SessionStateBadge({ state }: { state: SessionState }) {
97+ const { label, hint } = SESSION_STATES[state];
98+ return (
99+ <Hint label={hint}>
100+ <Badge tone={SESSION_TONE[state]}>{label}</Badge>
101+ </Hint>
102+ );
103+}
104+
105+/** How sure a decision is: claimed, tested and approved read apart at a glance. */
106+export function ConfidenceBadge({ confidence }: { confidence: DecisionConfidence }) {
107+ const { label, hint, tone } = CONFIDENCE[confidence];
108+ const Icon = confidence === "approved" ? CircleCheck : confidence === "tested" ? Layers : MessageSquare;
109+ return (
110+ <Hint label={hint}>
111+ <Badge tone={tone}>
112+ <Icon size={11} />
113+ {label}
114+ </Badge>
115+ </Hint>
116+ );
117+}
118+
119+/** One claim: who, from which harness, since when, and when it lapses; one no longer active is muted. */
120+export function ClaimLine({ claim, now, showIssue, base }: { claim: Claim; now: number; showIssue?: boolean; base: string }) {
121+ const active = claim.state === "active";
122+ const left = expiresIn(claim, now);
123+ return (
124+ <div className={active ? "flex items-start gap-2.5" : "flex items-start gap-2.5 opacity-60"}>
125+ <Actor username={claim.actor.username} harness={claim.harness} />
126+ <div className="min-w-0 grow">
127+ <p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm">
128+ <PersonLink name={claim.actor.username} className="font-medium hover:underline" />
129+ {showIssue ? (
130+ <span className="text-muted">
131+ claimed{" "}
132+ <Link to={`${base}/issues/${claim.issue}`} className="font-medium text-fg hover:text-accent">
133+ #{claim.issue}
134+ </Link>
135+ </span>
136+ ) : null}
137+ <HarnessBadge harness={claim.harness} />
138+ {!active && <Badge>{CLAIM_ENDED[claim.state as Exclude<Claim["state"], "active">]}</Badge>}
139+ </p>
140+ <p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
141+ <span>
142+ since <TimeAgo at={claim.createdAt} />
143+ </span>
144+ {left && (
145+ <span className="inline-flex items-center gap-1" suppressHydrationWarning>
146+ · <Timer size={11} />
147+ {left}
148+ </span>
149+ )}
150+ {!active && claim.state === "handed_off" && <span>· to {claim.handoffTo ?? "anyone"}</span>}
151+ </p>
152+ {claim.note && <p className="mt-1 text-xs text-fg-soft">{claim.note}</p>}
153+ </div>
154+ </div>
155+ );
156+}
157+
158+/** One harness session: who, which harness, where it stands, when it was last heard from, linked to its page. */
159+export function SessionLine({
160+ session,
161+ base,
162+ showWhere = true,
163+ showBranch = true,
164+}: {
165+ session: WorkSession;
166+ base: string;
167+ /** Its pull request and branch; not where they are already said. */
168+ showWhere?: boolean;
169+ /** Its branch; not in a narrow sidebar. */
170+ showBranch?: boolean;
171+}) {
172+ return (
173+ <div className="flex items-start gap-2.5">
174+ <Actor username={session.actor.username} harness={session.harness} />
175+ <div className="min-w-0 grow">
176+ <p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm">
177+ <Link to={`${base}/work/sessions/${session.id}`} prefetch="intent" className="font-medium hover:text-accent">
178+ {session.title ?? `${session.actor.username}'s session`}
179+ </Link>
180+ <HarnessBadge harness={session.harness} />
181+ <SessionStateBadge state={session.state} />
182+ </p>
183+ <p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
184+ <PersonLink name={session.actor.username} className="hover:text-fg" />
185+ <span>
186+ · {session.state === "ended" ? "ended" : "last seen"} <TimeAgo at={session.endedAt ?? session.lastSeenAt} />
187+ </span>
188+ {showWhere && session.pull != null && (
189+ <span>
190+ · on{" "}
191+ <Link to={`${base}/pull/${session.pull}`} className="text-fg hover:text-accent">
192+ #{session.pull}
193+ </Link>
194+ </span>
195+ )}
196+ {showWhere && showBranch && session.branch && (
197+ <span className="inline-flex min-w-0 items-center gap-1 font-mono">
198+ · <GitBranch size={11} />
199+ <span className="truncate">{session.branch}</span>
200+ </span>
201+ )}
202+ </p>
203+ </div>
204+ </div>
205+ );
206+}
207+
208+const KIND_ICON: Record<Decision["kind"], ReactNode> = {
209+ proposal: <MessageSquare size={14} />,
210+ claim: <MessageSquare size={14} />,
211+ integration_failure: <CircleX size={14} className="text-danger" />,
212+ integration_passed: <Layers size={14} className="text-success" />,
213+ fix: <Link2 size={14} className="text-accent" />,
214+ approval: <CircleCheck size={14} className="text-success" />,
215+ merge: <GitMerge size={14} className="text-merged" />,
216+ note: <MessageSquare size={14} />,
217+};
218+
219+/** The pull requests tested together, in queue order: `#41 + #44`. */
220+export function Combination({ numbers, base, culprit }: { numbers: number[]; base: string; culprit?: number | null }) {
221+ return (
222+ <span className="inline-flex flex-wrap items-center gap-1">
223+ {numbers.map((number, index) => (
224+ <Fragment key={number}>
225+ {index > 0 && <span className="text-faint">+</span>}
226+ <Link to={`${base}/pull/${number}`}>
227+ <Badge shape="tag" tone={number === culprit ? "danger" : "neutral"} className="font-mono hover:border-line-strong">
228+ #{number}
229+ </Badge>
230+ </Link>
231+ </Fragment>
232+ ))}
233+ </span>
234+ );
235+}
236+
237+/** The check a decision rests on, linked where it can be seen. */
238+function CheckLink({ check }: { check: { name: string; conclusion: string; url: string | null } }) {
239+ const href = checkHref(check.url);
240+ const text = (
241+ <>
242+ <span className="font-mono">{check.name}</span> {check.conclusion}
243+ </>
244+ );
245+ if (!href) return <span>{text}</span>;
246+ return href.startsWith("/") ? (
247+ <Link to={href} className="text-fg underline-offset-2 hover:underline">
248+ {text}
249+ </Link>
250+ ) : (
251+ <a href={href} className="text-fg underline-offset-2 hover:underline" rel="noreferrer" target="_blank">
252+ {text}
253+ </a>
254+ );
255+}
256+
257+/** One decision record: its kind, how sure it is, the sentence, who and when, and what it rests on. */
258+export function DecisionLine({ decision, base }: { decision: Decision; base: string }) {
259+ const evidence = evidenceOf(decision);
260+ return (
261+ <div className="flex items-start gap-2.5">
262+ <span className="mt-0.5 shrink-0 text-faint">{KIND_ICON[decision.kind]}</span>
263+ <div className="min-w-0 grow">
264+ <p className="flex flex-wrap items-center gap-1.5 text-xs">
265+ <span className="font-medium text-fg">{DECISION_KINDS[decision.kind]}</span>
266+ <ConfidenceBadge confidence={decision.confidence} />
267+ {decision.pull != null && (
268+ <Link to={`${base}/pull/${decision.pull}`} className="text-muted hover:text-fg">
269+ #{decision.pull}
270+ </Link>
271+ )}
272+ </p>
273+ <p className="mt-1 text-sm wrap-break-word">
274+ <NumberedText text={decision.summary} base={base} />
275+ </p>
276+ <p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
277+ <span>{decision.actor ?? "g1t"}</span>
278+ <span>
279+ · <TimeAgo at={decision.createdAt} />
280+ </span>
281+ {evidence.combination.length > 1 && (
282+ <span className="inline-flex items-center gap-1.5">
283+ · tested as <Combination numbers={evidence.combination} base={base} culprit={decision.kind === "integration_failure" ? decision.pull : null} />
284+ </span>
285+ )}
286+ {evidence.check && decision.kind === "integration_failure" && (
287+ <span>
288+ · <CheckLink check={evidence.check} />
289+ </span>
290+ )}
291+ {evidence.commit && (
292+ <span className="inline-flex items-center gap-1 font-mono">
293+ · <GitCommitHorizontal size={11} />
294+ {evidence.commit.slice(0, 7)}
295+ </span>
296+ )}
297+ </p>
298+ </div>
299+ </div>
300+ );
301+}
302+
303+/** Decision records, as a list in a card. */
304+export function DecisionList({ decisions, base }: { decisions: Decision[]; base: string }) {
305+ return (
306+ <Card asChild divided>
307+ <ol>
308+ {decisions.map((decision) => (
309+ <li key={decision.id} className="px-4 py-3">
310+ <DecisionLine decision={decision} base={base} />
311+ </li>
312+ ))}
313+ </ol>
314+ </Card>
315+ );
316+}
317+
318+/**
319+ * A combination that failed in the merge queue: which pull requests were
320+ * tested together, the check that failed, which ones it fails with, and
321+ * whether it has been answered since.
322+ */
323+export function IntegrationFailure({
324+ failure,
325+ base,
326+ fixed,
327+ branch = "main",
328+ className,
329+}: {
330+ failure: Decision;
331+ base: string;
332+ /** The branch the combination was built on. */
333+ branch?: string;
334+ /** A fix or a passing combination since, when there is one. */
335+ fixed?: Decision | null;
336+ className?: string;
337+}) {
338+ const evidence = evidenceOf(failure);
339+ return (
340+ <Alert tone={fixed ? "info" : "danger"} className={className}>
341+ <AlertTitle className="flex flex-wrap items-center gap-x-2 gap-y-1 text-fg">
342+ {failure.pull != null ? (
343+ <span>
344+ <TriangleAlert size={15} className={`mr-2 inline align-[-3px] ${fixed ? "text-info" : "text-danger"}`} />
345+ <Link to={`${base}/pull/${failure.pull}`} className="hover:underline">
346+ #{failure.pull}
347+ </Link>{" "}
348+ failed in the merge queue {evidence.combination.length > 1 ? "together with others" : "on its own"}
349+ </span>
350+ ) : (
351+ <span>A combination failed in the merge queue</span>
352+ )}
353+ <ConfidenceBadge confidence={failure.confidence} />
354+ <span className="text-xs font-normal text-muted">
355+ <TimeAgo at={failure.createdAt} />
356+ </span>
357+ </AlertTitle>
358+ <AlertDescription className="space-y-1.5">
359+ {evidence.combination.length > 0 && (
360+ <p className="flex flex-wrap items-center gap-1.5 text-xs">
361+ Tested as <span className="font-mono text-fg">{branch}</span> <span className="text-faint">+</span>
362+ <Combination numbers={evidence.combination} base={base} culprit={failure.pull} />
363+ </p>
364+ )}
365+ {evidence.check && (
366+ <p className="text-xs">
367+ Failing check: <CheckLink check={evidence.check} />
368+ </p>
369+ )}
370+ <p className="text-xs">
371+ {evidence.suspects.length > 0 ? (
372+ <>
373+ Fails with{" "}
374+ <NumberedText text={evidence.suspects.map((n) => `#${n}`).join(", ")} base={base} />
375+ {evidence.bisected ? ", narrowed down by building smaller combinations." : "."}
376+ </>
377+ ) : (
378+ "It fails by itself: the change is the cause."
379+ )}
380+ </p>
381+ {fixed && (
382+ <p className="text-xs text-fg">
383+ <CircleCheck size={13} className="mr-1.5 inline align-[-2px] text-success" />
384+ {fixed.kind === "integration_passed" ? "Passed together since" : "Fixed since"}:{" "}
385+ <span className="text-muted">
386+ <NumberedText text={fixed.summary} base={base} />
387+ </span>
388+ </p>
389+ )}
390+ </AlertDescription>
391+ </Alert>
392+ );
393+}
394+
395+const FEED_ICON: Record<FeedLine["type"], ReactNode> = {
396+ "work.claimed": <Hand size={14} />,
397+ "work.released": <Hand size={14} />,
398+ "work.claim_expired": <Timer size={14} />,
399+ "work.handoff_requested": <ArrowRightLeft size={14} />,
400+ "session.started": <Play size={14} />,
401+ "session.ended": <Square size={14} />,
402+ "dependency.changed": <Link2 size={14} />,
403+ "overlap.detected": <TriangleAlert size={14} />,
404+ "integration.failed": <CircleX size={14} />,
405+ "integration.passed": <Layers size={14} />,
406+ "decision.recorded": <CircleDot size={14} />,
407+};
408+
409+const FEED_TONE: Record<FeedLine["tone"], string> = {
410+ neutral: "text-faint",
411+ accent: "text-accent",
412+ success: "text-success",
413+ warn: "text-warn",
414+ danger: "text-danger",
415+ info: "text-info",
416+};
417+
418+/** Recent coordination events, newest first, one line each. */
419+export function CoordinationFeed({ lines, base }: { lines: FeedLine[]; base: string }) {
420+ return (
421+ <Card asChild divided>
422+ <ol>
423+ {lines.map((line) => (
424+ <li key={line.id} className="flex items-start gap-3 px-4 py-2.5 text-sm">
425+ <span className={`mt-0.5 shrink-0 ${FEED_TONE[line.tone]}`}>{FEED_ICON[line.type]}</span>
426+ <p className="min-w-0 grow wrap-break-word text-muted">
427+ <PersonLink name={line.who ?? "g1t"} className="font-medium text-fg hover:underline" />
428+ {line.harness && <span className="text-faint"> via {harnessName(line.harness)}</span>}{" "}
429+ <NumberedText text={line.text} base={base} />
430+ </p>
431+ <span className="shrink-0 text-xs text-faint">
432+ <TimeAgo at={line.time} />
433+ </span>
434+ </li>
435+ ))}
436+ </ol>
437+ </Card>
438+ );
439+}
440+
441+/** An issue's place among others: one it waits on, or one waiting on it. */
442+function DependencyLine({ base, number, title, state, detail }: { base: string; number: number; title: string; state: State; detail?: ReactNode }) {
443+ return (
444+ <li className="flex items-start gap-2">
445+ <span className="mt-0.5">
446+ <IssueIcon issue={{ state, reason: null }} size={14} />
447+ </span>
448+ <span className="min-w-0 grow">
449+ <Link to={`${base}/issues/${number}`} className="block truncate text-sm hover:text-accent">
450+ {title} <span className="text-faint">#{number}</span>
451+ </Link>
452+ {detail && <span className="block text-xs text-muted">{detail}</span>}
453+ </span>
454+ </li>
455+ );
456+}
457+
458+/** Where a blocker stands, in a few words: landed, being worked on, or waiting. */
459+export function blockerDetail(blocker: Blocker): string {
460+ if (blocker.pulls.some((pull) => pull.status === "merged")) return "Merged";
461+ if (blocker.state === "closed") return "Closed";
462+ const open = blocker.pulls.filter((pull) => pull.status === "open" || pull.status === "draft");
463+ const who = blocker.claims.map((claim) => claim.actor.username);
464+ return [
465+ who.length > 0 ? `Claimed by ${who.join(", ")}` : "Not claimed",
466+ open.length > 0 ? `${open.length} pull ${open.length === 1 ? "request" : "requests"} open` : null,
467+ ]
468+ .filter(Boolean)
469+ .join(" · ");
470+}
471+
472+/** A part of the Work panel, under a rule. */
473+function PanelPart({ title, action, children }: { title: ReactNode; action?: ReactNode; children: ReactNode }) {
474+ return (
475+ <div className="border-t border-line pt-3">
476+ <div className="flex min-h-7 items-center justify-between gap-2">
477+ <p className="text-xs font-medium text-muted">{title}</p>
478+ {action}
479+ </div>
480+ <div className="mt-1.5">{children}</div>
481+ </div>
482+ );
483+}
484+
485+/**
486+ * The Work panel of an issue's sidebar: who has claimed it, the sessions on
487+ * it, what it waits on and what waits on it, and for those who may, the
488+ * blockers to change.
489+ */
490+export function IssueWorkPanel({
491+ work,
492+ ended,
493+ base,
494+ now,
495+ canEdit,
496+ error,
497+}: {
498+ work: WorkContext;
499+ /** Claims on it that ended lately, shown muted. */
500+ ended: Claim[];
501+ base: string;
502+ now: number;
503+ canEdit: boolean;
504+ error?: string | null;
505+}) {
506+ const open = work.issue.state === "open";
507+ const sessions = work.sessions.slice(0, 5);
508+ const waiting = work.blockedBy.filter((blocker) => blocker.state === "open").length;
509+ return (
510+ <Card asChild className="space-y-4 p-4">
511+ <section aria-label="Work">
512+ <h3 className="flex items-center gap-2 text-sm font-medium">
513+ <Activity size={14} className="text-accent" />
514+ Work
515+ </h3>
516+
517+ <div className="space-y-3">
518+ {work.claims.length === 0 && ended.length === 0 ? (
519+ <p className="text-xs text-muted">
520+ {open ? (
521+ <>
522+ 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>.
523+ </>
524+ ) : (
525+ "Nobody is working on it."
526+ )}
527+ </p>
528+ ) : (
529+ [...work.claims, ...ended.slice(0, 3)].map((claim) => <ClaimLine key={claim.id} claim={claim} now={now} base={base} />)
530+ )}
531+ {work.claims.length > 1 && (
532+ <Alert tone="warn" className="flex items-start gap-1.5 px-2.5 py-1.5 text-xs">
533+ <TriangleAlert size={13} className="mt-0.5 shrink-0 text-warn" />
534+ {work.claims.length} claims stand on it: they may be doing the same work.
535+ </Alert>
536+ )}
537+ </div>
538+
539+ {sessions.length > 0 && (
540+ <PanelPart title="Sessions">
541+ <ul className="space-y-3">
542+ {sessions.map((session) => (
543+ <li key={session.id}>
544+ <SessionLine session={session} base={base} showBranch={false} />
545+ </li>
546+ ))}
547+ </ul>
548+ {work.sessions.length > sessions.length && (
549+ <Link to={`${base}/work`} className="mt-2 inline-block text-xs text-muted hover:text-fg">
550+ {work.sessions.length - sessions.length} more on the Work page
551+ </Link>
552+ )}
553+ </PanelPart>
554+ )}
555+
556+ <PanelPart
557+ title={
558+ <>
559+ Blocked by
560+ {waiting > 0 && <span className="text-warn"> · {waiting} open</span>}
561+ </>
562+ }
563+ action={canEdit ? <BlockedByEditor current={work.issue.blockedBy} /> : undefined}
564+ >
565+ {work.blockedBy.length === 0 ? (
566+ <p className="text-xs text-faint">Nothing. It can start now.</p>
567+ ) : (
568+ <ul className="space-y-2">
569+ {work.blockedBy.map((blocker) => (
570+ <DependencyLine key={blocker.number} base={base} number={blocker.number} title={blocker.title} state={blocker.state} detail={blockerDetail(blocker)} />
571+ ))}
572+ </ul>
573+ )}
574+ {error && <p className="mt-2 text-xs text-danger">{error}</p>}
575+ </PanelPart>
576+
577+ {work.blocks.length > 0 && (
578+ <PanelPart title="Blocks">
579+ <ul className="space-y-2">
580+ {work.blocks.map((issue) => (
581+ <DependencyLine key={issue.number} base={base} number={issue.number} title={issue.title} state={issue.state} />
582+ ))}
583+ </ul>
584+ </PanelPart>
585+ )}
586+ </section>
587+ </Card>
588+ );
589+}
590+
591+/** Changing what an issue waits on: the whole list, as numbers. */
592+function BlockedByEditor({ current }: { current: number[] }) {
593+ return (
594+ <Popover>
595+ <PopoverTrigger asChild>
596+ <Button variant="ghost" size="xs">
597+ <Pencil size={12} />
598+ Edit
599+ </Button>
600+ </PopoverTrigger>
601+ <PopoverContent align="end">
602+ <Form method="post" className="space-y-2.5">
603+ <input type="hidden" name="action" value="blocked-by" />
604+ <Label htmlFor="blocked-by">Blocked by</Label>
605+ <Input id="blocked-by" name="blocked_by" defaultValue={current.map((n) => `#${n}`).join(", ")} placeholder="#12, #14" autoComplete="off" />
606+ <p className="text-xs text-muted">Issues of this repository that have to land first. Leave it empty for none.</p>
607+ <SubmitButton size="sm" match={{ action: "blocked-by" }} pending="Saving…">
608+ Save
609+ </SubmitButton>
610+ </Form>
611+ </PopoverContent>
612+ </Popover>
613+ );
614+}
+192−0
1+/**
2+ * The merge queue as a train: the default branch, then each entry in
3+ * order with the combination it is tested as and how its checks stand.
4+ * The Merge queue page shows it whole; the Work page shows what is moving.
5+ */
6+import { Check, CircleDashed, GitCommitHorizontal, GitMerge, Loader2, Minus, X } from "lucide-react";
7+import { type ReactNode, Suspense } from "react";
8+import { Await, Link } from "react-router";
9+
10+import type { QueueEntry, QueueState } from "@g1t/contracts";
11+
12+import { TimeAgo } from "./ui";
13+import { Avatar } from "./ui/avatar";
14+import { Badge, type BadgeTone } from "./ui/badge";
15+import { Card } from "./ui/card";
16+
17+/** A merge_group workflow run on a combined state. */
18+export type GroupRun = { id: string; name: string };
19+
20+const STATE: Record<QueueState, { label: string; tone: BadgeTone; icon: ReactNode }> = {
21+ waiting: { label: "Waiting", tone: "neutral", icon: <CircleDashed size={13} /> },
22+ testing: { label: "Testing", tone: "accent", icon: <Loader2 size={13} className="animate-spin" /> },
23+ passed: { label: "Passed", tone: "success", icon: <Check size={13} /> },
24+ failed: { label: "Failed", tone: "danger", icon: <X size={13} /> },
25+ landed: { label: "Landed", tone: "merged", icon: <GitMerge size={13} /> },
26+ removed: { label: "Removed", tone: "neutral", icon: <Minus size={13} /> },
27+};
28+
29+export function QueueStateBadge({ state }: { state: QueueState }) {
30+ const { label, tone, icon } = STATE[state];
31+ return (
32+ <Badge tone={tone} size="md" className="gap-1.5">
33+ {icon}
34+ {label}
35+ </Badge>
36+ );
37+}
38+
39+/** `main + #12 + #14`: the state an entry is being tested in. */
40+export function TestedAs({ entry, branch }: { entry: QueueEntry; branch: string }) {
41+ return (
42+ <span className="flex flex-wrap items-center gap-1 font-mono text-xs text-muted">
43+ <Badge shape="tag">{branch}</Badge>
44+ {[...entry.ahead, entry.number].map((number) => (
45+ <span key={number} className="flex items-center gap-1">
46+ <span className="text-faint">+</span>
47+ <Badge shape="tag" tone={number === entry.number ? "accent" : "neutral"}>
48+ #{number}
49+ </Badge>
50+ </span>
51+ ))}
52+ </span>
53+ );
54+}
55+
56+/**
57+ * How an entry's checks went, linked to the merge_group run on its combined
58+ * state; with several runs, each is named and linked after the count.
59+ */
60+function GroupChecks({ entry, base, runs }: { entry: QueueEntry; base: string; runs: GroupRun[] }) {
61+ const ran = entry.results.length;
62+ const passed = entry.results.filter((result) => result.passed).length;
63+ const [first, ...more] = runs;
64+ const tone = passed === ran ? "text-success" : "text-danger";
65+ const count = ran > 0 ? `${passed}/${ran} checks passed` : first ? "merge_group run" : null;
66+ if (!count) return null;
67+ return (
68+ <span className="flex flex-wrap items-center gap-x-2">
69+ {first ? (
70+ <Link to={`${base}/actions/runs/${first.id}`} className={`underline-offset-2 hover:underline ${ran > 0 ? tone : "text-muted"}`}>
71+ {count}
72+ {more.length > 0 && ` · ${first.name}`}
73+ </Link>
74+ ) : (
75+ <span className={tone}>{count}</span>
76+ )}
77+ {more.map((run) => (
78+ <Link key={run.id} to={`${base}/actions/runs/${run.id}`} className="text-muted underline-offset-2 hover:text-fg hover:underline">
79+ {run.name}
80+ </Link>
81+ ))}
82+ </span>
83+ );
84+}
85+
86+const NO_RUNS: Promise<Record<string, GroupRun[]>> = Promise.resolve({});
87+
88+/** Where an entry sits on the train's rail. */
89+const STOP: Record<QueueState, string> = {
90+ waiting: "border-line-strong bg-bg",
91+ testing: "animate-pulse border-accent bg-accent/30",
92+ passed: "border-success bg-success",
93+ landed: "border-success bg-success",
94+ failed: "border-danger bg-danger",
95+ removed: "border-line-strong bg-bg",
96+};
97+
98+export function QueueEntryCard({
99+ entry,
100+ base,
101+ branch,
102+ position,
103+ groupRuns = NO_RUNS,
104+}: {
105+ entry: QueueEntry;
106+ base: string;
107+ branch: string;
108+ position?: number;
109+ /** The merge_group runs on each combined state, by commit. */
110+ groupRuns?: Promise<Record<string, GroupRun[]>>;
111+}) {
112+ const runsOf = (found: Record<string, GroupRun[]>) => (entry.combinedCommit ? (found[entry.combinedCommit] ?? []) : []);
113+ return (
114+ <li className="relative pl-10">
115+ <span className={`absolute top-4 left-3.25 size-3 rounded-full border-2 ${STOP[entry.state]}`} />
116+ <Card className="p-4 transition-colors hover:border-line-strong">
117+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
118+ {position != null && <span className="font-mono text-xs text-faint">{position}</span>}
119+ <Link to={`${base}/pull/${entry.number}`} prefetch="intent" className="min-w-0 grow font-medium hover:text-accent">
120+ {entry.title} <span className="font-normal text-faint">#{entry.number}</span>
121+ </Link>
122+ <QueueStateBadge state={entry.state} />
123+ </div>
124+ <div className="mt-2.5 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted">
125+ {(entry.state === "testing" || entry.state === "passed" || entry.state === "failed") && <TestedAs entry={entry} branch={branch} />}
126+ <Suspense fallback={<GroupChecks entry={entry} base={base} runs={[]} />}>
127+ <Await resolve={groupRuns} errorElement={<GroupChecks entry={entry} base={base} runs={[]} />}>
128+ {(found) => <GroupChecks entry={entry} base={base} runs={runsOf(found)} />}
129+ </Await>
130+ </Suspense>
131+ <span className="flex items-center gap-1.5">
132+ <Avatar name={entry.agent} image={null} size={14} />
133+ {entry.agent}
134+ </span>
135+ <span>
136+ queued by {entry.enqueuedBy} <TimeAgo at={entry.createdAt} />
137+ </span>
138+ {entry.combinedCommit && (
139+ <span className="flex items-center gap-1 font-mono">
140+ <GitCommitHorizontal size={12} />
141+ {entry.combinedCommit.slice(0, 7)}
142+ </span>
143+ )}
144+ </div>
145+ {entry.error && <p className="mt-2.5 text-xs text-danger">{entry.error}</p>}
146+ {entry.state === "failed" &&
147+ entry.results
148+ .filter((result) => !result.passed)
149+ .slice(0, 2)
150+ .map((result) => (
151+ <details key={result.command} className="mt-2">
152+ <summary className="cursor-pointer font-mono text-xs text-danger">{result.command}</summary>
153+ <pre className="mt-1.5 max-h-48 overflow-auto rounded-lg bg-bg p-3 text-xs text-muted">
154+ <code>{result.output}</code>
155+ </pre>
156+ </details>
157+ ))}
158+ </Card>
159+ </li>
160+ );
161+}
162+
163+/** The train: the default branch at the commit the front is built on, then each entry in order. */
164+export function QueueTrain({
165+ active,
166+ base,
167+ branch,
168+ groupRuns,
169+ empty = "Nothing is waiting. Merged pull requests appear here.",
170+}: {
171+ active: QueueEntry[];
172+ base: string;
173+ branch: string;
174+ groupRuns?: Promise<Record<string, GroupRun[]>>;
175+ empty?: ReactNode;
176+}) {
177+ return (
178+ <ol className="relative space-y-3 before:absolute before:top-0 before:bottom-4 before:left-4.5 before:w-px before:bg-line">
179+ <li className="relative flex items-center gap-2 pl-10 text-xs text-muted">
180+ <span className="absolute left-2.75 flex size-4 items-center justify-center rounded-full bg-raised ring-1 ring-line-strong">
181+ <GitCommitHorizontal size={10} />
182+ </span>
183+ <span className="font-mono text-fg">{branch}</span>
184+ {active[0]?.baseCommit && <span className="font-mono">at {active[0].baseCommit.slice(0, 7)}</span>}
185+ </li>
186+ {active.map((entry, index) => (
187+ <QueueEntryCard key={entry.id} entry={entry} base={base} branch={branch} position={index + 1} groupRuns={groupRuns} />
188+ ))}
189+ {active.length === 0 && <li className="pl-10 text-sm text-muted">{empty}</li>}
190+ </ol>
191+ );
192+}
+84−0
1+/**
2+ * What a session recorded, step by step: prompts folded, messages in full,
3+ * tool calls in a line each and their results folded. The pull request's
4+ * Sessions page and a harness session's page both list them.
5+ */
6+import { MessageSquare, StickyNote, Terminal, Wrench } from "lucide-react";
7+
8+import type { SessionEntry } from "@g1t/contracts";
9+
10+import { TimeAgo } from "./ui";
11+import { Card } from "./ui/card";
12+import { Hint } from "./ui/hint";
13+
14+function Entry({ entry }: { entry: SessionEntry }) {
15+ const at = (
16+ <span className="shrink-0 text-xs text-faint">
17+ <TimeAgo at={entry.at} />
18+ </span>
19+ );
20+ if (entry.kind === "prompt") {
21+ return (
22+ <li className="px-4 py-3">
23+ <details>
24+ <summary className="flex cursor-pointer items-center gap-2 text-sm font-medium">
25+ <Terminal size={14} className="text-accent" />
26+ <span className="grow">Prompt</span>
27+ {at}
28+ </summary>
29+ <pre className="mt-2 max-h-96 overflow-auto rounded-lg bg-bg p-3 text-xs whitespace-pre-wrap text-muted">{entry.text}</pre>
30+ </details>
31+ </li>
32+ );
33+ }
34+ if (entry.kind === "message") {
35+ return (
36+ <li className="flex gap-2.5 px-4 py-3 text-sm">
37+ <MessageSquare size={14} className="mt-0.5 shrink-0 text-faint" />
38+ <p className="min-w-0 grow whitespace-pre-wrap wrap-break-word">{entry.text}</p>
39+ {at}
40+ </li>
41+ );
42+ }
43+ if (entry.kind === "tool_call") {
44+ return (
45+ <li className="flex gap-2.5 px-4 py-2 text-xs">
46+ <Wrench size={13} className="mt-0.5 shrink-0 text-faint" />
47+ <span className="shrink-0 font-medium text-muted">{entry.tool}</span>
48+ <Hint label={entry.text}>
49+ <code className="min-w-0 grow truncate font-mono text-fg/85">{entry.text}</code>
50+ </Hint>
51+ </li>
52+ );
53+ }
54+ if (entry.kind === "tool_result") {
55+ return (
56+ <li className="px-4 py-1.5 pl-10">
57+ <details>
58+ <summary className="cursor-pointer text-xs text-faint">Result</summary>
59+ <pre className="mt-1.5 max-h-72 overflow-auto rounded-lg bg-bg p-3 text-xs text-muted">{entry.text}</pre>
60+ </details>
61+ </li>
62+ );
63+ }
64+ return (
65+ <li className="flex gap-2.5 px-4 py-2.5 text-xs text-muted">
66+ <StickyNote size={13} className="mt-0.5 shrink-0 text-faint" />
67+ <span className="min-w-0 grow">{entry.text}</span>
68+ {at}
69+ </li>
70+ );
71+}
72+
73+/** A session's entries in a card, oldest first. */
74+export function SessionEntries({ entries }: { entries: SessionEntry[] }) {
75+ return (
76+ <Card asChild divided>
77+ <ol>
78+ {entries.map((entry) => (
79+ <Entry key={entry.seq} entry={entry} />
80+ ))}
81+ </ol>
82+ </Card>
83+ );
84+}
+2−1
814814 </SidebarLink>
815815 )}
816816 {shows.has("pulls") && (
817− <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/queue`, `${base}/sessions`]} icon={<GitPullRequest size={15} />} count={repo.pulls}>
817+ <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/work`, `${base}/queue`, `${base}/sessions`]} icon={<GitPullRequest size={15} />} count={repo.pulls}>
818818 Pull requests
819819 </SidebarLink>
820820 )}
14181418 observability: "Observability",
14191419 insights: "Insights",
14201420 sessions: "Sessions",
1421+ work: "Work",
14211422 chat: "Chat",
14221423 artifacts: "Artifacts",
14231424 home: "Home",
+8−4
77 // tone says how much it matters. `sm` (the default) is the 11px label beside
88 // a title or in a table; `md` the 12px pill in a list of properties.
99
10−export const badgeVariants = cva("inline-flex shrink-0 items-center gap-1 rounded-full border whitespace-nowrap", {
10+export const badgeVariants = cva("inline-flex shrink-0 items-center gap-1 border whitespace-nowrap", {
1111 variants: {
1212 tone: {
1313 neutral: "border-line text-muted",
2222 sm: "px-2 py-px text-[0.6875rem] font-medium",
2323 md: "px-2 py-0.5 text-xs",
2424 },
25+ shape: {
26+ pill: "rounded-full",
27+ tag: "rounded",
28+ },
2529 },
26− defaultVariants: { tone: "neutral", size: "sm" },
30+ defaultVariants: { tone: "neutral", size: "sm", shape: "pill" },
2731 });
2832
2933 export type BadgeTone = NonNullable<VariantProps<typeof badgeVariants>["tone"]>;
3034
31−export function Badge({ tone, size, className, ...props }: ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
32− return <span data-slot="badge" className={cn(badgeVariants({ tone, size }), className)} {...props} />;
35+export function Badge({ tone, size, shape, className, ...props }: ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
36+ return <span data-slot="badge" className={cn(badgeVariants({ tone, size, shape }), className)} {...props} />;
3337 }
+1−1
3838 "packages", "patterns", "people", "personal-access-tokens", "pins", "policies", "profile", "projects", "pull",
3939 "pulls", "queue", "releases", "repositories", "reset", "rules", "runners", "runs", "search", "secrets", "security",
4040 "security-log", "settings", "soon", "spend", "tags", "teams", "today", "tokens", "tree", "two-factor", "u", "usage",
41− "verify", "webhooks", "workspace", "workspaces",
41+ "verify", "webhooks", "work", "workspace", "workspaces",
4242 ]);
4343
4444 export function isPublicPath(pathname: string): boolean {
+1−1
110110 export const PROJECT_PAGE_LINKS: Record<ProjectPage, { label: string; path: string; also: string[]; soon?: boolean }> = {
111111 code: { label: "Code", path: "code", also: ["tree", "blob", "commits", "commit", "branches", "tags", "releases", "compare", "memory"] },
112112 issues: { label: "Issues", path: "issues", also: ["milestones", "labels"] },
113− pulls: { label: "Pull requests", path: "pulls", also: ["pull", "queue", "sessions"] },
113+ pulls: { label: "Pull requests", path: "pulls", also: ["pull", "work", "queue", "sessions"] },
114114 actions: { label: "Workflows", path: "actions", also: [] },
115115 deployments: { label: "Deployments", path: "deployments", also: [] },
116116 observability: { label: "Observability", path: "observability", also: [] },
+182−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { Claim, Decision, G1tEvent, WorkSession } from "@g1t/contracts";
5+
6+import {
7+ checkHref,
8+ evidenceOf,
9+ expiresIn,
10+ feedLine,
11+ harnessName,
12+ liveByIssue,
13+ numberedParts,
14+ parseBlockedBy,
15+ resolutionOf,
16+ span,
17+ standingFailure,
18+} from "./coordination.ts";
19+
20+const ana = { id: "usr_ana", username: "ana" };
21+const bo = { id: "usr_bo", username: "bo" };
22+
23+function claim(issue: number, over: Partial<Claim> = {}): Claim {
24+ return {
25+ id: `clm_${issue}_${over.actor?.username ?? "ana"}`,
26+ repoId: "rep_1",
27+ issue,
28+ sessionId: null,
29+ actor: ana,
30+ harness: "claude_code",
31+ note: null,
32+ state: "active",
33+ createdAt: "2026-10-12T10:00:00Z",
34+ expiresAt: "2026-10-12T10:30:00Z",
35+ endedAt: null,
36+ endReason: null,
37+ handoffTo: null,
38+ ...over,
39+ };
40+}
41+
42+function session(id: string, over: Partial<WorkSession> = {}): WorkSession {
43+ return {
44+ id,
45+ repoId: "rep_1",
46+ issue: null,
47+ pull: null,
48+ branch: null,
49+ actor: bo,
50+ harness: "codex",
51+ harnessSessionId: null,
52+ title: null,
53+ state: "active",
54+ headCommit: null,
55+ startedAt: "2026-10-12T10:00:00Z",
56+ lastSeenAt: "2026-10-12T10:05:00Z",
57+ endedAt: null,
58+ endReason: null,
59+ ...over,
60+ };
61+}
62+
63+function decision(kind: Decision["kind"], at: string, over: Partial<Decision> = {}): Decision {
64+ return {
65+ id: `dec_${kind}_${at}`,
66+ issue: 2,
67+ pull: 5,
68+ sessionId: null,
69+ actor: null,
70+ kind,
71+ confidence: "tested",
72+ summary: kind,
73+ evidence: {},
74+ createdAt: at,
75+ ...over,
76+ };
77+}
78+
79+const event = (type: string, data: unknown): G1tEvent =>
80+ ({ id: `evt_${type}`, type, source: "work", time: "2026-10-12T10:00:00Z", repoId: "rep_1", actor: "usr_ana", data }) as G1tEvent;
81+
82+test("harnesses are named as people know them", () => {
83+ assert.equal(harnessName("claude_code"), "Claude Code");
84+ assert.equal(harnessName("codex"), "Codex");
85+ assert.equal(harnessName(null), null);
86+});
87+
88+test("a claim says when it lapses; one that ended says nothing", () => {
89+ const now = Date.parse("2026-10-12T10:07:00Z");
90+ assert.equal(expiresIn(claim(1), now), "expires in 23m");
91+ assert.equal(expiresIn(claim(1, { expiresAt: "2026-10-12T12:00:00Z" }), now), "expires in 1h 53m");
92+ assert.equal(expiresIn(claim(1, { expiresAt: "2026-10-12T10:00:00Z" }), now), "expiring now");
93+ assert.equal(expiresIn(claim(1, { state: "released" }), now), null);
94+ assert.equal(span(30_000), "less than a minute");
95+ assert.equal(span(2 * 3_600_000), "2h");
96+});
97+
98+test("live work is grouped by issue, most recent first, with sessions on no issue apart", () => {
99+ const { issues, loose } = liveByIssue(
100+ [claim(1), claim(2, { createdAt: "2026-10-12T10:20:00Z" }), claim(2, { actor: bo })],
101+ [session("ses_a", { issue: 1, lastSeenAt: "2026-10-12T10:30:00Z" }), session("ses_b")],
102+ );
103+ assert.deepEqual(
104+ issues.map((group) => [group.issue, group.claims.length, group.sessions.length]),
105+ [
106+ [1, 1, 1],
107+ [2, 2, 0],
108+ ],
109+ );
110+ assert.deepEqual(loose.map((one) => one.id), ["ses_b"]);
111+});
112+
113+test("evidence is read as far as it goes", () => {
114+ const failed = decision("integration_failure", "2026-10-12T10:00:00Z", {
115+ evidence: { commit: "abc1234", check: { name: "CI / merge_group", conclusion: "failure", url: "https://g1t.sh/acme/web/actions/runs/run_1" }, combination: [4, 5], suspects: [4], bisected: false },
116+ });
117+ assert.deepEqual(evidenceOf(failed), {
118+ commit: "abc1234",
119+ check: { name: "CI / merge_group", conclusion: "failure", url: "https://g1t.sh/acme/web/actions/runs/run_1" },
120+ combination: [4, 5],
121+ suspects: [4],
122+ bisected: false,
123+ });
124+ assert.deepEqual(evidenceOf(decision("note", "2026-10-12T10:00:00Z", { evidence: { combination: "nope" } })).combination, []);
125+ assert.equal(checkHref("https://g1t.sh/acme/web/actions/runs/run_1"), "/acme/web/actions/runs/run_1");
126+ assert.equal(checkHref("https://ci.example.com/build/9"), "https://ci.example.com/build/9");
127+ assert.equal(checkHref(null), null);
128+});
129+
130+test("a failure stands until the pull request passes in the queue or merges, and a fix after it answers it", () => {
131+ const failed = decision("integration_failure", "2026-10-12T10:00:00Z");
132+ const fix = decision("fix", "2026-10-12T10:10:00Z", { confidence: "claimed" });
133+ const passed = decision("integration_passed", "2026-10-12T10:20:00Z");
134+ assert.equal(standingFailure([failed], 5), failed);
135+ assert.equal(standingFailure([failed, fix], 5), failed);
136+ assert.equal(standingFailure([passed, failed], 5), null);
137+ assert.equal(standingFailure([failed], 6), null);
138+ assert.deepEqual(resolutionOf([failed, fix, passed], failed), { fix, passed });
139+ assert.deepEqual(resolutionOf([fix, failed], { ...failed, createdAt: "2026-10-12T10:15:00Z" }), { fix: null, passed: null });
140+});
141+
142+test("coordination events read as lines of the feed", () => {
143+ const claimed = feedLine(event("work.claimed", { repoId: "rep_1", number: 2, issue: 2, claim: claim(2, { actor: bo, harness: "codex" }), others: [claim(2)] }))!;
144+ assert.equal(claimed.who, "bo");
145+ assert.equal(claimed.harness, "codex");
146+ assert.equal(claimed.text, "claimed #2 (ana is on it too)");
147+ assert.equal(claimed.tone, "warn");
148+
149+ const failed = feedLine(
150+ event("integration.failed", {
151+ repoId: "rep_1", pullId: "pul_5", number: 5, pull: 5, issue: 2, combination: [4, 5],
152+ check: { name: "CI / merge_group", conclusion: "failure", url: null }, bisected: false, suspects: [4], otherPulls: [4], commit: null,
153+ }),
154+ )!;
155+ assert.equal(failed.who, null);
156+ assert.equal(failed.text, "#5 failed in the merge queue combined as #4, #5: CI / merge_group; fails with #4");
157+ assert.equal(failed.tone, "danger");
158+
159+ const unblocked = feedLine(
160+ event("dependency.changed", { repoId: "rep_1", number: 2, issue: 2, blockedBy: [1], added: [], removed: [], cleared: [1], remaining: [], mergedPull: 4 }),
161+ )!;
162+ assert.equal(unblocked.text, "#2 #1 landed (#4 merged); unblocked");
163+
164+ const overlap = feedLine(event("overlap.detected", { repoId: "rep_1", number: 5, pull: 5, otherPull: 4, files: ["src/auth.rs"] }))!;
165+ assert.equal(overlap.text, "#5 and #4 change the same file: src/auth.rs");
166+
167+ const started = feedLine(event("session.started", { repoId: "rep_1", session: session("ses_a", { issue: 2 }) }))!;
168+ assert.equal(started.text, "started a session on #2");
169+ assert.equal(feedLine(event("session.appended", { repoId: "rep_1", number: 5, pullId: "p", count: 3 })), null);
170+});
171+
172+test("numbers in a sentence are found for linking", () => {
173+ assert.deepEqual(numberedParts("#5 fails with #4."), [5, " fails with ", 4, "."]);
174+ assert.deepEqual(numberedParts("no numbers"), ["no numbers"]);
175+});
176+
177+test("blocked_by is typed as numbers", () => {
178+ assert.deepEqual(parseBlockedBy("#12, 14 #12"), [12, 14]);
179+ assert.deepEqual(parseBlockedBy(""), []);
180+ assert.equal(parseBlockedBy("#12, soon"), null);
181+ assert.equal(parseBlockedBy("0"), null);
182+});
+371−0
1+/**
2+ * Coordinating work, as the site shows it: who is on which issue (claims),
3+ * which harness sessions are running, what the merge queue found when it
4+ * combined changes, and the decision records behind the result. Pure: the
5+ * pages and components/coordination.tsx draw what these say.
6+ */
7+import type {
8+ Claim,
9+ ClaimState,
10+ Decision,
11+ DecisionConfidence,
12+ EventType,
13+ G1tEvent,
14+ Harness,
15+ SessionState,
16+ WorkSession,
17+} from "@g1t/contracts";
18+
19+/** How each harness is named. */
20+export const HARNESS_NAMES: Record<Harness, string> = {
21+ claude_code: "Claude Code",
22+ codex: "Codex",
23+ cursor: "Cursor",
24+ copilot: "Copilot",
25+ other: "Another harness",
26+};
27+
28+export function harnessName(harness: Harness | null | undefined): string | null {
29+ return harness ? (HARNESS_NAMES[harness] ?? harness) : null;
30+}
31+
32+/** The coordination events the Work page lists, newest first. */
33+export const COORDINATION_TYPES = [
34+ "work.claimed",
35+ "work.released",
36+ "work.claim_expired",
37+ "work.handoff_requested",
38+ "session.started",
39+ "session.ended",
40+ "dependency.changed",
41+ "overlap.detected",
42+ "integration.failed",
43+ "integration.passed",
44+ "decision.recorded",
45+] as const satisfies readonly EventType[];
46+
47+export type CoordinationType = (typeof COORDINATION_TYPES)[number];
48+
49+// ── Time ────────────────────────────────────────────────────────────────
50+
51+const MINUTE = 60_000;
52+
53+/** "4m", "1h 20m", "2h": a span, for "expires in …". */
54+export function span(ms: number): string {
55+ const minutes = Math.max(0, Math.floor(ms / MINUTE));
56+ if (minutes < 1) return "less than a minute";
57+ if (minutes < 60) return `${minutes}m`;
58+ const hours = Math.floor(minutes / 60);
59+ const rest = minutes % 60;
60+ return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`;
61+}
62+
63+/** When an active claim lapses, in words; null for one that is not active. */
64+export function expiresIn(claim: Pick<Claim, "state" | "expiresAt">, now: number): string | null {
65+ if (claim.state !== "active") return null;
66+ const left = new Date(claim.expiresAt).getTime() - now;
67+ return left <= 0 ? "expiring now" : `expires in ${span(left)}`;
68+}
69+
70+/** What became of a claim that is no longer active. */
71+export const CLAIM_ENDED: Record<Exclude<ClaimState, "active">, string> = {
72+ released: "Released",
73+ expired: "Expired",
74+ handed_off: "Handed off",
75+};
76+
77+export const SESSION_STATES: Record<SessionState, { label: string; hint: string }> = {
78+ active: { label: "Active", hint: "Heard from in the last 15 minutes." },
79+ idle: { label: "Idle", hint: "Not heard from for 15 minutes. It may still come back." },
80+ ended: { label: "Ended", hint: "Ended, or not heard from for 2 hours." },
81+};
82+
83+// ── Live now ────────────────────────────────────────────────────────────
84+
85+/** One issue being worked on: its claims and the sessions on it. */
86+export type LiveIssue = { issue: number; claims: Claim[]; sessions: WorkSession[] };
87+
88+/**
89+ * Active claims and sessions grouped by the issue they are on, the issue
90+ * with the most recent activity first; sessions on no issue last, together.
91+ */
92+export function liveByIssue(claims: Claim[], sessions: WorkSession[]): { issues: LiveIssue[]; loose: WorkSession[] } {
93+ const byIssue = new Map<number, LiveIssue>();
94+ const at = (issue: number) => {
95+ let group = byIssue.get(issue);
96+ if (!group) byIssue.set(issue, (group = { issue, claims: [], sessions: [] }));
97+ return group;
98+ };
99+ for (const claim of claims) at(claim.issue).claims.push(claim);
100+ const loose: WorkSession[] = [];
101+ for (const session of sessions) {
102+ if (session.issue != null) at(session.issue).sessions.push(session);
103+ else loose.push(session);
104+ }
105+ const latest = (group: LiveIssue) =>
106+ Math.max(
107+ 0,
108+ ...group.claims.map((claim) => Date.parse(claim.createdAt)),
109+ ...group.sessions.map((session) => Date.parse(session.lastSeenAt)),
110+ );
111+ return { issues: [...byIssue.values()].sort((a, b) => latest(b) - latest(a) || a.issue - b.issue), loose };
112+}
113+
114+// ── Decisions ───────────────────────────────────────────────────────────
115+
116+export const CONFIDENCE: Record<DecisionConfidence, { label: string; hint: string; tone: "neutral" | "info" | "success" }> = {
117+ claimed: { label: "Claimed", hint: "Someone said so: an agent or a person recorded it.", tone: "neutral" },
118+ tested: { label: "Tested", hint: "A check or the merge queue proved it.", tone: "info" },
119+ approved: { label: "Approved", hint: "A person approved it.", tone: "success" },
120+};
121+
122+export const DECISION_KINDS: Record<Decision["kind"], string> = {
123+ proposal: "Proposal",
124+ claim: "Claim",
125+ integration_failure: "Failed together",
126+ integration_passed: "Passed together",
127+ fix: "Fix",
128+ approval: "Approval",
129+ merge: "Merge",
130+ note: "Note",
131+};
132+
133+/** The check a decision's evidence names. */
134+export type EvidenceCheck = { name: string; conclusion: string; url: string | null };
135+
136+/** What a decision rests on, read from its evidence as far as it says. */
137+export type Evidence = {
138+ commit: string | null;
139+ check: EvidenceCheck | null;
140+ /** The pull requests tested together, in queue order. */
141+ combination: number[];
142+ /** Those ahead it fails with. */
143+ suspects: number[];
144+ bisected: boolean;
145+};
146+
147+function numbers(value: unknown): number[] {
148+ return Array.isArray(value) ? value.filter((n): n is number => typeof n === "number" && Number.isInteger(n)) : [];
149+}
150+
151+export function evidenceOf(decision: Pick<Decision, "evidence">): Evidence {
152+ const raw = (decision.evidence ?? {}) as Record<string, unknown>;
153+ const check = raw.check && typeof raw.check === "object" ? (raw.check as Record<string, unknown>) : null;
154+ return {
155+ commit: typeof raw.commit === "string" ? raw.commit : null,
156+ check:
157+ check && typeof check.name === "string"
158+ ? {
159+ name: check.name,
160+ conclusion: typeof check.conclusion === "string" ? check.conclusion : "failure",
161+ url: typeof check.url === "string" && check.url ? check.url : null,
162+ }
163+ : null,
164+ combination: numbers(raw.combination),
165+ suspects: numbers(raw.suspects),
166+ bisected: raw.bisected === true,
167+ };
168+}
169+
170+/** Where a check's address leads on the site: its path when it is g1t's own, else the address as it is. */
171+export function checkHref(url: string | null): string | null {
172+ if (!url) return null;
173+ try {
174+ const parsed = new URL(url);
175+ if (parsed.hostname === "g1t.sh" || parsed.hostname.endsWith(".g1t.sh")) return parsed.pathname + parsed.search;
176+ return url;
177+ } catch {
178+ return url.startsWith("/") ? url : null;
179+ }
180+}
181+
182+/**
183+ * The integration failure a pull request still answers for: its latest
184+ * `integration_failure`, unless it passed in the queue or merged since.
185+ * `decisions` in any order.
186+ */
187+export function standingFailure(decisions: Decision[], pull?: number): Decision | null {
188+ const mine = decisions
189+ .filter((decision) => pull == null || decision.pull === pull)
190+ .filter((decision) => ["integration_failure", "integration_passed", "merge"].includes(decision.kind))
191+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
192+ return mine[0]?.kind === "integration_failure" ? mine[0] : null;
193+}
194+
195+/** The decisions after a failure that answer it: a fix recorded since, and a combination that then passed. */
196+export function resolutionOf(decisions: Decision[], failure: Decision): { fix: Decision | null; passed: Decision | null } {
197+ const after = decisions
198+ .filter((decision) => Date.parse(decision.createdAt) > Date.parse(failure.createdAt))
199+ .sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
200+ return {
201+ fix: after.find((decision) => decision.kind === "fix" && (decision.pull === failure.pull || decision.issue === failure.issue)) ?? null,
202+ passed: after.find((decision) => decision.kind === "integration_passed" && decision.pull === failure.pull) ?? null,
203+ };
204+}
205+
206+// ── The feed ────────────────────────────────────────────────────────────
207+
208+/** One line of the coordination feed: what happened, in words, and what it links to. */
209+export type FeedLine = {
210+ id: string;
211+ time: string;
212+ type: CoordinationType;
213+ /** Who did it, by username; null for g1t itself. */
214+ who: string | null;
215+ /** The harness it came from, when it says. */
216+ harness: Harness | null;
217+ /** The sentence, after the name. Numbers in it are written `#N`. */
218+ text: string;
219+ /** The issue or pull request it is about. */
220+ number: number | null;
221+ tone: "neutral" | "accent" | "success" | "warn" | "danger" | "info";
222+};
223+
224+const list = (all: number[]) => all.map((n) => `#${n}`).join(", ");
225+const plural = (n: number, one: string, many: string) => (n === 1 ? one : many);
226+
227+/** A coordination event as a line of the feed, or null for one it does not show. */
228+export function feedLine(event: G1tEvent): FeedLine | null {
229+ const base = { id: event.id, time: event.time, harness: null as Harness | null, number: null as number | null };
230+ const data = event.data as Record<string, unknown> & { number?: number };
231+ const number = typeof data.number === "number" ? data.number : null;
232+ switch (event.type) {
233+ case "work.claimed": {
234+ const { claim, others = [], tookOver } = event.data;
235+ const also = others.length > 0 ? ` (${others.map((other) => other.actor.username).join(", ")} ${plural(others.length, "is", "are")} on it too)` : "";
236+ return {
237+ ...base,
238+ type: event.type,
239+ who: claim.actor.username,
240+ harness: claim.harness,
241+ text: `${tookOver ? "took over" : "claimed"} #${claim.issue}${also}`,
242+ number: claim.issue,
243+ tone: others.length > 0 ? "warn" : "accent",
244+ };
245+ }
246+ case "work.released": {
247+ const { claim, reason } = event.data;
248+ return {
249+ ...base,
250+ type: event.type,
251+ who: claim.actor.username,
252+ harness: claim.harness,
253+ text: reason === "issue_closed" ? `no longer holds #${claim.issue}: the issue closed` : `released #${claim.issue}${reason ? `: ${reason}` : ""}`,
254+ number: claim.issue,
255+ tone: "neutral",
256+ };
257+ }
258+ case "work.claim_expired": {
259+ const { claim } = event.data;
260+ return { ...base, type: event.type, who: claim.actor.username, harness: claim.harness, text: `let the claim on #${claim.issue} expire`, number: claim.issue, tone: "neutral" };
261+ }
262+ case "work.handoff_requested": {
263+ const { claim, to, note } = event.data;
264+ return {
265+ ...base,
266+ type: event.type,
267+ who: claim.actor.username,
268+ harness: claim.harness,
269+ text: `handed #${claim.issue} off to ${to ?? "anyone"}${note ? `: ${note}` : ""}`,
270+ number: claim.issue,
271+ tone: "info",
272+ };
273+ }
274+ case "session.started":
275+ case "session.ended": {
276+ const { session } = event.data;
277+ const on = session.issue != null ? ` on #${session.issue}` : session.pull != null ? ` on #${session.pull}` : "";
278+ const why = event.type === "session.ended" && session.endReason === "timed_out" ? " (timed out)" : "";
279+ return {
280+ ...base,
281+ type: event.type,
282+ who: session.actor.username,
283+ harness: session.harness,
284+ text: `${event.type === "session.started" ? "started a session" : "ended a session"}${on}${why}`,
285+ number: session.issue ?? session.pull,
286+ tone: event.type === "session.started" ? "accent" : "neutral",
287+ };
288+ }
289+ case "dependency.changed": {
290+ const { issue, added, removed, cleared, remaining, mergedPull } = event.data;
291+ const parts = [
292+ added.length > 0 && `now waits on ${list(added)}`,
293+ removed.length > 0 && `no longer waits on ${list(removed)}`,
294+ cleared.length > 0 && `${list(cleared)} ${mergedPull ? `landed (#${mergedPull} merged)` : "closed"}`,
295+ ].filter(Boolean);
296+ // What it still waits on, unless that is only what was just added.
297+ const said = remaining.every((n) => added.includes(n)) && parts.length > 0;
298+ const left = remaining.length === 0 ? "unblocked" : said ? null : `still waits on ${list(remaining)}`;
299+ return {
300+ ...base,
301+ type: event.type,
302+ who: null,
303+ text: `#${issue} ${[...parts, left].filter(Boolean).join("; ")}`,
304+ number: issue,
305+ tone: remaining.length === 0 ? "success" : "info",
306+ };
307+ }
308+ case "overlap.detected": {
309+ const { pull, otherPull, files } = event.data;
310+ return {
311+ ...base,
312+ type: event.type,
313+ who: null,
314+ text: `#${pull} and #${otherPull} change the same ${plural(files.length, "file", "files")}: ${files.slice(0, 3).join(", ")}${files.length > 3 ? ` and ${files.length - 3} more` : ""}`,
315+ number: pull,
316+ tone: "warn",
317+ };
318+ }
319+ case "integration.failed": {
320+ const { pull, combination, check, suspects } = event.data;
321+ const together = combination.length > 1 ? ` combined as ${list(combination)}` : " on its own";
322+ const with_ = suspects.length > 0 ? `; fails with ${list(suspects)}` : "";
323+ return { ...base, type: event.type, who: null, text: `#${pull} failed in the merge queue${together}: ${check.name}${with_}`, number: pull, tone: "danger" };
324+ }
325+ case "integration.passed": {
326+ const { pull, combination } = event.data;
327+ return {
328+ ...base,
329+ type: event.type,
330+ who: null,
331+ text: combination.length > 1 ? `#${pull} passed in the merge queue combined as ${list(combination)}` : `#${pull} passed in the merge queue`,
332+ number: pull,
333+ tone: "success",
334+ };
335+ }
336+ case "decision.recorded": {
337+ const { decision } = event.data;
338+ const kind = DECISION_KINDS[decision.kind as Decision["kind"]]?.toLowerCase() ?? decision.kind;
339+ return {
340+ ...base,
341+ type: event.type,
342+ who: decision.actor,
343+ text: `recorded ${/^[aeiou]/.test(kind) ? "an" : "a"} ${kind}, ${decision.confidence}: ${decision.summary}`,
344+ number: decision.pull ?? decision.issue ?? number,
345+ tone: decision.kind === "integration_failure" ? "danger" : decision.confidence === "approved" ? "success" : "neutral",
346+ };
347+ }
348+ default:
349+ return null;
350+ }
351+}
352+
353+/** Splits text at `#N`, for linking each number. */
354+export function numberedParts(text: string): (string | number)[] {
355+ const parts: (string | number)[] = [];
356+ let last = 0;
357+ for (const match of text.matchAll(/#(\d+)\b/g)) {
358+ if (match.index > last) parts.push(text.slice(last, match.index));
359+ parts.push(Number(match[1]));
360+ last = match.index + match[0].length;
361+ }
362+ if (last < text.length) parts.push(text.slice(last));
363+ return parts;
364+}
365+
366+/** `blocked_by` as typed: numbers, with or without `#`, by commas or spaces; null when any is not one. */
367+export function parseBlockedBy(text: string): number[] | null {
368+ const words = text.split(/[\s,]+/).map((word) => word.trim().replace(/^#/, "")).filter(Boolean);
369+ if (words.some((word) => !/^\d+$/.test(word) || Number(word) <= 0)) return null;
370+ return [...new Set(words.map(Number))];
371+}
+2−1
9696 },
9797 ];
9898
99−/** Pull requests and the merge queue share a page's tabs too. */
99+/** Pull requests, the work on them, the merge queue and sessions share a page's tabs too. */
100100 export const PULLS: Section = {
101101 key: "Code",
102102 tabs: [
103103 { label: "Pull requests", path: "pulls", also: ["pull"] },
104+ { label: "Work", path: "work" },
104105 { label: "Merge queue", path: "queue" },
105106 { label: "Sessions", path: "sessions" },
106107 ],
+12−0
9393 assert.equal(issue(push("main")), false);
9494 });
9595
96+test("the Work page hears claims, sessions starting and ending, what the queue finds, and merges", () => {
97+ const page = repoPages.work();
98+ for (const kind of ["work.claimed", "work.claim_expired", "session.started", "session.ended", "dependency.changed", "overlap.detected", "integration.failed", "decision.recorded", "queue.changed", "pull.merged"]) {
99+ assert.equal(page(change(kind, { number: 3 })), true, kind);
100+ }
101+ assert.equal(page(change("session.appended", { number: 3 })), false);
102+ assert.equal(page(change("comment.created", { number: 3 })), false);
103+ assert.equal(page(push("main")), false);
104+ // An issue hears coordination about itself and its pull requests by number.
105+ assert.equal(repoPages.issue(3, [9])(change("integration.failed", { number: 9 })), true);
106+});
107+
96108 test("runs, one run, and security", () => {
97109 assert.equal(repoPages.runs()(change("run.updated", { run: "run_1" })), true);
98110 assert.equal(repoPages.runs()(change("status.created", { sha: "abc" })), true, "a new run reports pending on its commit");
+12−0
116116 issue(number: number, linked: number[] = []): RepoMatch {
117117 return (change) => change.number != null && (change.number === number || linked.includes(change.number));
118118 },
119+ /**
120+ * The Work page: claims, sessions starting and ending, dependencies,
121+ * overlaps, what the merge queue finds and decisions, the queue moving,
122+ * and merges.
123+ */
124+ work(): RepoMatch {
125+ return (change) =>
126+ ["work", "dependency", "overlap", "integration", "decision", "queue"].includes(family(change)) ||
127+ change.kind === "session.started" ||
128+ change.kind === "session.ended" ||
129+ change.kind === "pull.merged";
130+ },
119131 /** The runs list: runs starting, moving and finishing, and what starts them. */
120132 runs(): RepoMatch {
121133 return (change) => ["workflow", "run", "status", "check_suite"].includes(family(change));
+3−0
268268 route("pulls/new", "routes/repo/pull-new.tsx"),
269269 route("pull/:number", "routes/repo/pull.tsx"),
270270 route("queue", "routes/repo/queue.tsx"),
271+ // Who is working on what, live: claims, harness sessions, the queue, coordination events.
272+ route("work", "routes/repo/work.tsx"),
273+ route("work/sessions/:id", "routes/repo/work-session.tsx"),
271274 route("usage", "routes/repo/usage.tsx"),
272275 route("sessions", "routes/repo/sessions.tsx"),
273276 route("sessions/:number", "routes/repo/session.tsx"),
+62−4
3939 import { DetailsDisclosure, detailsSummary } from "../../components/details-disclosure";
4040 import { repoPages } from "../../lib/repo-live";
4141 import { useRepoLive } from "../../lib/use-repo-live";
42+import { DecisionList, IntegrationFailure, IssueWorkPanel } from "../../components/coordination";
43+import { parseBlockedBy, resolutionOf } from "../../lib/coordination";
4244
4345
4446 export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
6264 const path = { namespace: params.owner, name: params.repo };
6365 const number = Number(params.number);
6466 const access = accessTo(context, params);
65− const [{ can }, found, labels, milestones, members, links, subscription, teams] = await Promise.all([
67+ const [{ can }, found, labels, milestones, members, links, subscription, teams, coordination, ended] = await Promise.all([
6668 access,
6769 work.getIssue(path, number, viewer),
6870 work.listLabels(path, viewer),
8082 : null,
8183 // The teams a member can mention, for the comment box.
8284 roleIn(viewer, params.owner) ? identity.listTeams(viewer, params.owner).catch(() => null) : null,
85+ // Who is on it, its sessions, what it waits on and its decisions
86+ // (services/work/src/coordination): the Work panel.
87+ work.getWork(path, number, viewer).catch(() => null),
88+ // Its claims that ended, shown muted under the active ones.
89+ Promise.all(
90+ (["released", "expired", "handed_off"] as const).map((state) =>
91+ work.listClaims(path, viewer, { state, issue: number }).catch(() => null),
92+ ),
93+ ),
8394 ]);
8495 if (!found.ok) {
8596 // Issues and pull requests share numbers; this one may be a pull request.
95106 milestones: milestones.ok ? milestones.value : [],
96107 links,
97108 subscription,
109+ coordination: coordination?.ok ? coordination.value : null,
110+ endedClaims: ended
111+ .flatMap((claims) => (claims?.ok ? claims.value : []))
112+ .sort((a, b) => Date.parse(b.endedAt ?? b.createdAt) - Date.parse(a.endedAt ?? a.createdAt)),
98113 members: members?.ok ? members.value.map((member) => member.username) : [],
99114 handles: teams?.ok ? teams.value.map((team) => `@${team.workspace}/${team.slug}`) : [],
100115 // The author can close and reopen their own issue, and whoever g1t's
115130 // What each form needs; closing your own issue is checked by work.
116131 // Labels: its author may use the repository's on their own issue, and
117132 // work says so; anyone else, and every milestone, needs Triage.
133+ // What it is blocked by: its author or Triage, which work checks too.
118134 const needed = form.get("action") === "assign" || form.get("action") === "milestone" ? "triage" : null;
119135 const refused = needed ? await refusal(context, params, needed) : null;
120136 if (refused) return { error: refused, action: String(form.get("action")) };
148164 const result = await work.setLabels(user, path, number, form.getAll("label").map(String));
149165 return result.ok ? null : { error: result.error.message, action: "labels" };
150166 }
167+ case "blocked-by": {
168+ const blockedBy = parseBlockedBy(String(form.get("blocked_by") ?? ""));
169+ if (!blockedBy) return { error: "List issue numbers, such as #12, #14.", action: "blocked-by" };
170+ const result = await work.updateIssue(user, path, number, { blocked_by: blockedBy });
171+ return result.ok ? null : { error: result.error.message, action: "blocked-by" };
172+ }
151173 case "milestone": {
152174 const result = await work.updateIssue(user, path, number, { milestone: Number(form.get("milestone")) || 0 });
153175 return result.ok ? null : { error: result.error.message, action: "milestone" };
245267 }
246268
247269 export default function IssuePage({ loaderData, actionData, params }: Route.ComponentProps) {
248− const { issue, pulls, comments, viewer, labels, members, canManage, can } = loaderData;
270+ const { issue, pulls, comments, viewer, labels, members, canManage, can, coordination } = loaderData;
249271 // Follow pull requests at work without a manual reload.
250272 const running = pulls.some(
251273 (pull) =>
254276 pull.checkStatus === "running",
255277 );
256278 useRefreshWhile(running);
279+ // Claims lapse and sessions go idle with no event to say so: looked at again now and then.
280+ useRefreshWhile((coordination?.claims.length ?? 0) > 0 || (coordination?.sessions.some((session) => session.state !== "ended") ?? false), 30_000);
281+ const now = Date.now();
257282 // Comments, changes and its pull requests, as they happen; one reading at its end follows them.
258283 useRepoLive({ match: repoPages.issue(issue.number, pulls.map((pull) => pull.number)), followBottom: true });
259284
266291 const ordered = [...pulls].sort(
267292 (a, b) => rank[a.status] - rank[b.status] || a.number - b.number,
268293 );
294+ // Its decisions as a story, oldest first; the failure that still stands, with what answered it.
295+ const decisions = coordination ? [...coordination.decisions].reverse() : [];
296+ const failure = coordination?.lastIntegrationFailure ?? null;
297+ const resolution = failure ? resolutionOf(coordination!.decisions, failure) : null;
269298
270299 return (
271300 // On a phone: the title, then the sidebar as a Details disclosure, then
351380 )}
352381 </div>
353382
383+ {failure && (
384+ <IntegrationFailure className="mt-4" failure={failure} base={base} fixed={resolution?.passed ?? resolution?.fix} />
385+ )}
386+
387+ {decisions.length > 0 && (
388+ <>
389+ <div className="mt-10 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
390+ <h3 className="font-semibold tracking-tight">Decisions</h3>
391+ <p className="text-sm text-muted">Why the work is the way it is, and how sure that is</p>
392+ </div>
393+ <div className="mt-3">
394+ <DecisionList decisions={decisions} base={base} />
395+ </div>
396+ </>
397+ )}
398+
354399 <h3 className="mt-10 font-semibold tracking-tight">Discussion</h3>
355400 <div className="mt-3">
356401 <div className="space-y-4">
384429 </div>
385430 <div className="mt-2">
386431 {/* A refused comment edit says so under the comment. */}
387− {!(actionData && "comment" in actionData) && <ErrorText>{actionData?.error}</ErrorText>}
432+ {!(actionData && ("comment" in actionData || ("action" in actionData && actionData.action === "blocked-by"))) && (
433+ <ErrorText>{actionData?.error}</ErrorText>
434+ )}
388435 </div>
389436 </div>
390437
391438 <DetailsDisclosure
392439 className="lg:col-start-2 lg:row-span-2 lg:row-start-1"
393440 summary={detailsSummary([
441+ [coordination?.claims.length ?? 0, "claim", "claims"],
394442 [issue.assignees.length, "assignee", "assignees"],
395443 [issue.labels.length, "label", "labels"],
396444 [issue.milestone ? 1 : 0, "milestone", "milestones"],
397445 ])}
398446 >
447+ {coordination && (
448+ <IssueWorkPanel
449+ work={coordination}
450+ ended={loaderData.endedClaims}
451+ base={base}
452+ now={now}
453+ canEdit={canManage}
454+ error={actionData && "action" in actionData && actionData.action === "blocked-by" ? actionData.error : null}
455+ />
456+ )}
399457 {loaderData.links.length > 0 && (
400458 <section>
401459 <h3 className="text-sm font-medium">From outside g1t</h3>
436494 </li>
437495 ))}
438496 </ul>
439− {issue.blockedBy.length > 0 && (
497+ {!coordination && issue.blockedBy.length > 0 && (
440498 <p className="mt-3 text-xs text-muted">
441499 Depends on{" "}
442500 {issue.blockedBy.map((number, index) => (
+64−13
6464 import { CheckboxOption } from "../../components/ui/checkbox";
6565 import { Hint } from "../../components/ui/hint";
6666 import { TabStrip } from "../../components/ui/tab-strip";
67+import { DecisionList, IntegrationFailure, SessionLine } from "../../components/coordination";
68+import { resolutionOf, standingFailure } from "../../lib/coordination";
6769 import { WorkflowStatuses } from "../../components/actions";
6870 import { AddCiPrompt } from "../../components/add-ci";
6971 import { AttemptsBox } from "../../components/attempts";
138140 const milestonesFound = work.listMilestones(path, viewer);
139141 labelsFound.catch(() => null);
140142 milestonesFound.catch(() => null);
141− const [{ can }, found, repo, settings, members, session, deployed, subscription, teamList] = await Promise.all([
143+ const [{ can }, found, repo, settings, members, session, deployed, subscription, teamList, decisions, harnessSessions] = await Promise.all([
142144 access,
143145 pullFound,
144146 repos.get(path, viewer),
156158 : null,
157159 // A member asks the workspace's teams to review, and mentions them.
158160 member ? identity.listTeams(viewer, params.owner).catch(() => null) : null,
161+ // Its decision records: what the merge queue found when it was combined
162+ // with others, and the reasons kept on it (decisions.rs).
163+ tab !== "changes" ? work.listDecisions(path, number, viewer).catch(() => null) : null,
164+ // The harness sessions working on it, for the Session tab.
165+ tab === "session" ? work.listWorkSessions(path, viewer, { pull: number }).catch(() => null) : null,
159166 ]);
160167 if (!found.ok) {
161168 // Issues and pull requests share numbers; this one may be an issue.
226233 attempts,
227234 tab,
228235 session: session?.ok ? session.value : [],
236+ decisions: decisions?.ok ? decisions.value : [],
237+ harnessSessions: harnessSessions?.ok ? harnessSessions.value : [],
229238 // An empty comparison if it could not be made.
230239 comparison: comparison && (comparison.ok ? comparison.value : EMPTY_COMPARISON),
231240 viewer,
617626 rules,
618627 tab,
619628 session,
629+ decisions,
630+ harnessSessions,
620631 comparison,
621632 viewer,
622633 canMerge,
680691 // ones for the same issue are alternatives, and expected to: those are
681692 // compared under their own heading.
682693 const { collisions } = splitOverlaps(overlaps, pull.issue);
694+ // A combination with it that failed in the merge queue and has not passed since.
695+ const failure = active ? standingFailure(decisions, pull.number) : null;
696+ const answered = failure ? resolutionOf(decisions, failure).fix : null;
683697 const review = {
684698 changesUrl: here + "?tab=changes",
685699 // Nobody reviews their own pull request, nor one g1t made for them.
928942 }}
929943 />
930944 ) : tab === "session" ? (
931− session.length === 0 ? (
932− <EmptyState title="Nothing recorded yet">
933− A coding agent's prompts, reasoning and tool calls appear here once
934− it records them with record_session.
935− </EmptyState>
936− ) : (
937− <ol className="relative space-y-5 before:absolute before:top-2 before:bottom-2 before:left-3.25 before:w-px before:bg-line">
938− {session.map((entry) => (
939− <Entry key={entry.seq} entry={entry} agent={pull.agent} />
940− ))}
941− </ol>
942− )
945+ <div className="space-y-8">
946+ {harnessSessions.length > 0 && (
947+ <section aria-label="Harness sessions">
948+ <h3 className="text-sm font-medium">Sessions on it</h3>
949+ <Card asChild divided className="mt-3">
950+ <ul>
951+ {harnessSessions.map((one) => (
952+ <li key={one.id} className="px-4 py-3">
953+ <SessionLine session={one} base={base} showWhere={false} />
954+ </li>
955+ ))}
956+ </ul>
957+ </Card>
958+ </section>
959+ )}
960+ {decisions.length > 0 && (
961+ <section aria-label="Decisions">
962+ <h3 className="text-sm font-medium">Decisions</h3>
963+ <p className="mt-1 text-xs text-muted">
964+ Why it is the way it is, each with how sure it is: claimed by whoever recorded it, tested by a check or the merge queue, approved by a person.
965+ </p>
966+ <div className="mt-3">
967+ <DecisionList decisions={decisions} base={base} />
968+ </div>
969+ </section>
970+ )}
971+ <section aria-label="What happened">
972+ {(harnessSessions.length > 0 || decisions.length > 0) && <h3 className="mb-3 text-sm font-medium">What happened</h3>}
973+ {session.length === 0 ? (
974+ <EmptyState title="Nothing recorded yet">
975+ A coding agent's prompts, reasoning and tool calls appear here once
976+ it records them, through the g1t plugin, a harness session or record_session.
977+ </EmptyState>
978+ ) : (
979+ <ol className="relative space-y-5 before:absolute before:top-2 before:bottom-2 before:left-3.25 before:w-px before:bg-line">
980+ {session.map((entry) => (
981+ <Entry key={entry.seq} entry={entry} agent={pull.agent} />
982+ ))}
983+ </ol>
984+ )}
985+ </section>
986+ </div>
943987 ) : (
944988 <div className="space-y-4">
945989 <TimelineItem
10441088 </StatusBox>
10451089 )}
10461090
1091+ {failure && (
1092+ <div className="flex gap-3">
1093+ <span className="hidden w-8 shrink-0 sm:block" />
1094+ <IntegrationFailure className="min-w-0 grow" failure={failure} base={base} branch={defaultBranch} fixed={answered} />
1095+ </div>
1096+ )}
1097+
10471098 {pull.status === "open" && (
10481099 <StatusBox>
10491100 {/* Checks by status at once; job by job when the runs are read. */}
+9−187
Loading the diff…
+5−72
Loading the diff…
+8−3
Loading the diff…
+106−0
Loading the diff…
+269−0
Loading the diff…
+19−1
Loading the diff…
+17−0
Loading the diff…
+118−1
Loading the diff…