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.

269 linesCodeBlameRaw
1import { Activity, GitMerge, History, Layers, Radio } from "lucide-react";
2import type { ReactNode } from "react";
3import { Link } from "react-router";
4
5import type { EventType, Pull, WorkSession } from "@g1t/contracts";
6
7import type { Route } from "./+types/work";
8import { CoordinationFeed, ClaimLine, SessionLine } from "../../components/coordination";
9import { QueueTrain } from "../../components/merge-queue";
10import { CopyLine, EmptyState, TimeAgo } from "../../components/ui";
11import { Avatar } from "../../components/ui/avatar";
12import { Badge } from "../../components/ui/badge";
13import { Card } from "../../components/ui/card";
14import { IssueIcon, PullIcon } from "../../components/work-icons";
15import { requireRepo } from "../../lib/access.server";
16import { COORDINATION_TYPES, type LiveIssue, feedLine, liveByIssue } from "../../lib/coordination";
17import { page } from "../../lib/meta";
18import { useRefreshWhile } from "../../lib/refresh";
19import { repoPages } from "../../lib/repo-live";
20import { events, work } from "../../lib/services.server";
21import { useRepoLive } from "../../lib/use-repo-live";
22
23/** How many coordination events the feed shows. */
24const FEED = 30;
25/** How many merged pull requests Just landed shows. */
26const LANDED = 6;
27
28export function meta({ params, ...args }: Route.MetaArgs) {
29 return page(args, { title: `Work · ${params.owner}/${params.repo} · g1t` });
30}
31
32export async function loader({ params, context }: Route.LoaderArgs) {
33 const { viewer, repo } = await requireRepo(context, params, "read");
34 const path = { namespace: params.owner, name: params.repo };
35 // One round: who is on what, the queue, what happened, what landed.
36 const [claims, active, idle, queue, recent, open, closed] = await Promise.all([
37 work.listClaims(path, viewer).catch(() => null),
38 work.listWorkSessions(path, viewer, { state: "active" }).catch(() => null),
39 work.listWorkSessions(path, viewer, { state: "idle" }).catch(() => null),
40 work.queue(path, viewer).catch(() => null),
41 events
42 .list({
43 repoId: repo.id,
44 types: [...COORDINATION_TYPES] as EventType[],
45 limit: FEED,
46 })
47 .catch(() => []),
48 // The titles of the issues being worked on.
49 work.listIssues(path, viewer, { state: "open" }).catch(() => null),
50 work.listPulls(path, viewer, "closed").catch(() => null),
51 ]);
52 const titles: Record<number, string> = {};
53 for (const issue of open?.ok ? open.value : []) titles[issue.number] = issue.title;
54 const landed = (closed?.ok ? closed.value : [])
55 .filter((pull) => pull.status === "merged" && pull.mergedAt)
56 .sort((a, b) => Date.parse(b.mergedAt!) - Date.parse(a.mergedAt!))
57 .slice(0, LANDED);
58 return {
59 claims: claims?.ok ? claims.value : [],
60 sessions: [...(active?.ok ? active.value : []), ...(idle?.ok ? idle.value : [])],
61 queue: queue?.ok ? queue.value : null,
62 feed: recent.flatMap((event) => feedLine(event) ?? []),
63 titles,
64 landed,
65 defaultBranch: repo.defaultBranch,
66 };
67}
68
69function LiveIssueCard({ group, title, base, now }: { group: LiveIssue; title: string | undefined; base: string; now: number }) {
70 return (
71 <Card asChild divided>
72 <li>
73 <div className="flex items-start gap-2.5 px-4 py-3">
74 <span className="mt-1">
75 <IssueIcon issue={{ state: "open", reason: null }} />
76 </span>
77 <Link to={`${base}/issues/${group.issue}`} prefetch="intent" className="min-w-0 grow font-medium hover:text-accent">
78 {title ?? `Issue #${group.issue}`} <span className="font-normal text-faint">#{group.issue}</span>
79 </Link>
80 {group.claims.length > 1 && <Badge tone="warn">{group.claims.length} claims</Badge>}
81 </div>
82 {group.claims.map((claim) => (
83 <div key={claim.id} className="px-4 py-3">
84 <ClaimLine claim={claim} now={now} base={base} />
85 </div>
86 ))}
87 {group.sessions.map((session) => (
88 <div key={session.id} className="px-4 py-3">
89 <SessionLine session={session} base={base} />
90 </div>
91 ))}
92 </li>
93 </Card>
94 );
95}
96
97/** What to do when nothing is being worked on: bring a harness. */
98function StartHere() {
99 return (
100 <EmptyState title="Nobody is working on this repository right now">
101 <div className="mx-auto mt-3 max-w-lg space-y-3 text-left">
102 <p>Install the g1t plugin in Claude Code, and each session shows here as it works:</p>
103 <CopyLine text="claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git" prompt />
104 <CopyLine text="claude plugin install g1t@g1t" prompt />
105 <p>From any other harness, or by hand, claim an issue with the g1t CLI:</p>
106 <CopyLine text="g1t work claim 12" prompt />
107 <p>
108 <a href="https://docs.g1t.sh/guides/sessions/" className="text-fg underline underline-offset-4">
109 Sessions, claims and dependencies
110 </a>{" "}
111 says how it works.
112 </p>
113 </div>
114 </EmptyState>
115 );
116}
117
118function Section({ icon, title, aside, children }: { icon: ReactNode; title: string; aside?: ReactNode; children: ReactNode }) {
119 return (
120 <section className="mt-10">
121 <div className="flex flex-wrap items-baseline justify-between gap-2">
122 <h3 className="flex items-center gap-2 text-sm font-medium">
123 {icon}
124 {title}
125 </h3>
126 {aside && <span className="text-xs text-muted">{aside}</span>}
127 </div>
128 <div className="mt-3">{children}</div>
129 </section>
130 );
131}
132
133function LandedRow({ pull, base }: { pull: Pull; base: string }) {
134 return (
135 <li className="flex items-start gap-3 px-4 py-3 text-sm">
136 <span className="mt-0.5">
137 <PullIcon status={pull.status} />
138 </span>
139 <span className="flex min-w-0 grow flex-wrap items-center gap-x-3 gap-y-1">
140 <Link to={`${base}/pull/${pull.number}`} prefetch="intent" className="min-w-0 grow font-medium hover:text-accent">
141 {pull.title} <span className="font-normal text-faint">#{pull.number}</span>
142 </Link>
143 <span className="flex items-center gap-3 text-xs text-muted">
144 {pull.issue != null && (
145 <Link to={`${base}/issues/${pull.issue}`} className="hover:text-fg">
146 for #{pull.issue}
147 </Link>
148 )}
149 <Link to={`${base}/pull/${pull.number}?tab=session`} className="hover:text-fg">
150 Why
151 </Link>
152 {pull.mergedBy && (
153 <span className="flex items-center gap-1.5">
154 <Avatar name={pull.mergedBy} size={14} />
155 {pull.mergedBy}
156 </span>
157 )}
158 {pull.mergedAt && <TimeAgo at={pull.mergedAt} />}
159 </span>
160 </span>
161 </li>
162 );
163}
164
165export default function WorkPage({ loaderData, params }: Route.ComponentProps) {
166 const { claims, sessions, queue, feed, titles, landed, defaultBranch } = loaderData;
167 const base = `/${params.owner}/${params.repo}`;
168 const now = Date.now();
169 const live = liveByIssue(claims, sessions);
170 const working = claims.length > 0 || sessions.length > 0;
171 // Claims, sessions, overlaps, integration results and merges as they happen.
172 useRepoLive({ match: repoPages.work() });
173 // Claims lapse and sessions go idle with no event to say so.
174 useRefreshWhile(working || (queue?.active.length ?? 0) > 0, 15_000);
175
176 return (
177 <div className="max-w-4xl">
178 <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
179 <Activity size={19} className="text-accent" />
180 Work
181 </h2>
182 <p className="mt-1.5 max-w-2xl text-sm text-muted">
183 Who is working on what right now, each in their own harness: the issues they claimed, their sessions, the merge queue combining
184 their changes, and what happened along the way.
185 </p>
186
187 <Section
188 icon={<Radio size={15} className={working ? "text-success" : "text-faint"} />}
189 title="Live now"
190 aside={
191 working
192 ? `${claims.length} ${claims.length === 1 ? "claim" : "claims"}, ${sessions.length} ${sessions.length === 1 ? "session" : "sessions"}`
193 : undefined
194 }
195 >
196 {!working ? (
197 <StartHere />
198 ) : (
199 <ol className="space-y-3">
200 {live.issues.map((group) => (
201 <LiveIssueCard key={group.issue} group={group} title={titles[group.issue]} base={base} now={now} />
202 ))}
203 {live.loose.length > 0 && (
204 <Card asChild divided>
205 <li>
206 <p className="px-4 py-3 text-sm text-muted">Not on an issue yet</p>
207 {live.loose.map((session: WorkSession) => (
208 <div key={session.id} className="px-4 py-3">
209 <SessionLine session={session} base={base} />
210 </div>
211 ))}
212 </li>
213 </Card>
214 )}
215 </ol>
216 )}
217 </Section>
218
219 {queue && (queue.enabled || queue.active.length > 0) && (
220 <Section
221 icon={<Layers size={15} className="text-accent" />}
222 title="Merge queue"
223 aside={
224 <Link to={`${base}/queue`} className="hover:text-fg">
225 {queue.active.length} waiting to land · the whole queue
226 </Link>
227 }
228 >
229 <QueueTrain active={queue.active} base={base} branch={defaultBranch} empty="Nothing is waiting to land." />
230 </Section>
231 )}
232
233 <Section icon={<History size={15} className="text-accent" />} title="What happened">
234 {feed.length === 0 ? (
235 <EmptyState title="Nothing yet">
236 Claims, sessions, overlapping changes, dependencies and what the merge queue finds when it combines changes are listed here as
237 they happen.
238 </EmptyState>
239 ) : (
240 <CoordinationFeed lines={feed} base={base} />
241 )}
242 </Section>
243
244 <Section
245 icon={<GitMerge size={15} className="text-merged" />}
246 title="Just landed"
247 aside={
248 landed.length > 0 ? (
249 <Link to={`${base}/pulls?state=closed`} className="hover:text-fg">
250 All merged pull requests
251 </Link>
252 ) : undefined
253 }
254 >
255 {landed.length === 0 ? (
256 <EmptyState title="Nothing merged yet">Merged pull requests show here, each with the session behind it.</EmptyState>
257 ) : (
258 <Card asChild divided>
259 <ol>
260 {landed.map((pull) => (
261 <LandedRow key={pull.id} pull={pull} base={base} />
262 ))}
263 </ol>
264 </Card>
265 )}
266 </Section>
267 </div>
268 );
269}