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.

290 linesCodeBlameRaw
1import { Activity, GitMerge, History, Layers, Radio } from "lucide-react";
2import type { ReactNode } from "react";
3import { Link } from "react-router";
4
5import { type EventType, MAX_LIST_REPOS, type RepoPull } from "@g1t/contracts";
6
7import type { Route } from "./+types/list";
8import { Unavailable } from "../../../components/boundary";
9import { CoordinationFeed } from "../../../components/coordination";
10import { QueueTrain } from "../../../components/merge-queue";
11import { SessionCard, SessionRow, SessionsFiltersBar, SessionsViews } from "../../../components/sessions";
12import { EmptyState, TimeAgo } from "../../../components/ui";
13import { Avatar } from "../../../components/ui/avatar";
14import { Card } from "../../../components/ui/card";
15import { PullIcon } from "../../../components/work-icons";
16import { COORDINATION_TYPES, feedLine } from "../../../lib/coordination";
17import { page } from "../../../lib/meta";
18import { isFailed, part } from "../../../lib/partial";
19import { useRefreshWhile } from "../../../lib/refresh";
20import { repoPages, workPages } from "../../../lib/repo-live";
21import { events, work } from "../../../lib/services.server";
22import { getViewer, unwrap } from "../../../lib/session.server";
23import { narrowed, peopleIn, repoName, sessionsFilters, splitLive } from "../../../lib/sessions";
24import { useLive } from "../../../lib/use-repo-live";
25import { itemPath, sessionsPath } from "../../../lib/work-items";
26
27/** How many coordination events What happened shows. */
28const FEED = 30;
29/** How many ended sessions Recent shows under Live now. */
30const RECENT = 20;
31
32export function meta({ params, location, ...args }: Route.MetaArgs) {
33 const repo = new URLSearchParams(location.search).get("repo");
34 return page({ location, ...args }, { title: `Sessions${repo ? ` · ${repo}` : ""} · ${params.owner} · g1t` });
35}
36
37export async function loader({ params, context, request }: Route.LoaderArgs) {
38 const viewer = getViewer(context);
39 const workspace = params.owner.toLowerCase();
40 const filters = sessionsFilters(new URL(request.url).searchParams);
41 const narrow = { repo: filters.repo ?? undefined, person: filters.person ?? undefined, harness: filters.harness ?? undefined, kind: filters.kind ?? undefined };
42 const every = filters.state === "recent";
43 // What is going now is what the page is about; what ended, a part of its own.
44 const [now, ended] = await Promise.all([
45 work.listWorkspaceSessions(workspace, viewer, { ...narrow, shown: every ? "recent" : "live", limit: every ? 120 : 60 }),
46 every ? null : part(work.listWorkspaceSessions(workspace, viewer, { ...narrow, shown: "ended", sessionsOnly: true, limit: RECENT }), "Recent sessions"),
47 ]);
48 const found = unwrap(now);
49 const paths = new Map(found.repos.map((repo) => [repo.id, repo.path]));
50 // What happened, across the repositories looked in (the one asked for, or every one the viewer may read).
51 const recent = await part(
52 events.list({ repoIds: found.repos.slice(0, MAX_LIST_REPOS).map((repo) => repo.id), types: [...COORDINATION_TYPES] as EventType[], limit: FEED }),
53 "What happened",
54 );
55 const feed = isFailed(recent)
56 ? recent
57 : recent.flatMap((event) => {
58 const line = feedLine(event);
59 if (!line) return [];
60 const repo = event.repoId ? paths.get(event.repoId) : undefined;
61 return [repo ? { ...line, repo } : line];
62 });
63 return {
64 workspace,
65 filters,
66 signedIn: viewer != null,
67 me: viewer?.username ?? null,
68 sessions: found.sessions,
69 queue: found.queue,
70 landed: found.landed,
71 repos: found.repos,
72 ended: ended == null ? null : isFailed(ended) ? ended : ended.ok ? ended.value.sessions : [],
73 feed,
74 };
75}
76
77function Section({ icon, title, aside, children, id }: { icon: ReactNode; title: string; aside?: ReactNode; children: ReactNode; id?: string }) {
78 return (
79 <section id={id} className="mt-9 first:mt-6">
80 <div className="flex flex-wrap items-baseline justify-between gap-2">
81 <h2 className="flex items-center gap-2 text-sm font-medium">
82 {icon}
83 {title}
84 </h2>
85 {aside && <span className="text-xs text-muted">{aside}</span>}
86 </div>
87 <div className="mt-3">{children}</div>
88 </section>
89 );
90}
91
92function LandedRow({ pull, workspace }: { pull: RepoPull; workspace: string }) {
93 const base = `/${pull.repo}`;
94 return (
95 <li className="flex items-start gap-3 px-4 py-3 text-sm">
96 <span className="mt-0.5">
97 <PullIcon status={pull.status} />
98 </span>
99 <span className="flex min-w-0 grow flex-wrap items-center gap-x-3 gap-y-1">
100 <Link to={`${base}/pull/${pull.number}`} prefetch="intent" className="min-w-0 grow font-medium wrap-break-word hover:text-accent">
101 {pull.title} <span className="font-normal text-faint">#{pull.number}</span>
102 </Link>
103 <span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
104 <Link to={base} className="font-mono hover:text-fg">
105 {repoName(pull.repo, workspace)}
106 </Link>
107 {pull.item != null && (
108 <Link to={itemPath(workspace, pull.item)} className="hover:text-fg">
109 for #{pull.item}
110 </Link>
111 )}
112 <Link to={`${base}/pull/${pull.number}/session`} className="hover:text-fg">
113 Why
114 </Link>
115 {pull.mergedBy && (
116 <span className="flex items-center gap-1.5">
117 <Avatar name={pull.mergedBy} size={14} />
118 {pull.mergedBy}
119 </span>
120 )}
121 {pull.mergedAt && <TimeAgo at={pull.mergedAt} />}
122 </span>
123 </span>
124 </li>
125 );
126}
127
128/**
129 * Sessions: what is cooking across the workspace, in every repository of
130 * it the viewer may read. Live now first (people's harnesses and their
131 * subagents, sessions started on g1t, g1t's agent, what is being recorded
132 * on pull requests), then the merge queues, what just landed, what
133 * happened and what ended.
134 */
135export default function SessionsPage({ loaderData }: Route.ComponentProps) {
136 const { workspace, filters, sessions, queue, landed, repos, ended, feed, signedIn, me } = loaderData;
137 const every = filters.state === "recent";
138 const { active, idle } = splitLive(sessions);
139 const going = active.length + idle.length;
140 const waiting = queue.reduce((sum, one) => sum + one.active.length, 0);
141 const repoNames = repos.map((repo) => repoName(repo.path, workspace)).sort();
142 const people = peopleIn([...sessions, ...(ended && !isFailed(ended) ? ended : [])]);
143 // Sessions, claims, overlaps, integration results and merges in any of its repositories, and its work items.
144 useLive({
145 match: repoPages.work(),
146 repos: repos.map((repo) => ({ id: repo.id, path: repo.path })),
147 signedIn,
148 workspace: { slug: workspace, match: workPages.list() },
149 });
150 // Sessions go idle and time out with no event to say so.
151 useRefreshWhile(going > 0 || waiting > 0, 15_000);
152
153 return (
154 <div className="max-w-5xl">
155 <h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
156 <Activity size={19} className="text-accent" />
157 Sessions
158 </h1>
159 <p className="mt-1.5 max-w-2xl text-sm text-muted">
160 What is cooking across {workspace}: everyone's harness sessions and their subagents, sessions started on g1t, g1t's own agent,
161 the merge queues combining their changes, and what landed.
162 </p>
163 <div className="mt-4 flex flex-wrap items-center gap-2">
164 <SessionsViews workspace={workspace} filters={filters} />
165 <SessionsFiltersBar workspace={workspace} filters={filters} repos={repoNames} people={people} me={me} />
166 </div>
167
168 {every ? (
169 <Section icon={<History size={15} className="text-accent" />} title="Every session" aside={`${sessions.length} most recently heard from`}>
170 {sessions.length === 0 ? (
171 <EmptyState title={narrowed(filters) ? "No sessions match these filters" : "No sessions yet"}>
172 Sessions start when someone works with g1t's plugin in their harness, starts one on a work item, or an agent records its work on a pull request.
173 </EmptyState>
174 ) : (
175 <Card asChild divided>
176 <ol>
177 {sessions.map((session) => (
178 <SessionRow key={session.id} session={session} workspace={workspace} />
179 ))}
180 </ol>
181 </Card>
182 )}
183 </Section>
184 ) : (
185 <Section
186 icon={<Radio size={15} className={going > 0 ? "text-success" : "text-faint"} />}
187 title="Live now"
188 aside={going > 0 ? `${active.length} going${idle.length > 0 ? `, ${idle.length} idle` : ""}` : undefined}
189 >
190 {going === 0 ? (
191 <EmptyState title={narrowed(filters) ? "Nothing matching is running right now" : "Nothing is running right now"}>
192 Sessions show here as they go: Claude Code, Codex or Cursor with g1t's plugin, sessions started on a work item, and g1t's own agent.
193 {narrowed(filters) && (
194 <>
195 {" "}
196 <Link to={sessionsPath(workspace)} className="text-fg underline underline-offset-4">
197 Clear the filters
198 </Link>
199 .
200 </>
201 )}
202 </EmptyState>
203 ) : (
204 <ol className="grid gap-3 md:grid-cols-2">
205 {[...active, ...idle].map((session) => (
206 <SessionCard key={session.id} session={session} workspace={workspace} />
207 ))}
208 </ol>
209 )}
210 </Section>
211 )}
212
213 {queue.length > 0 && (
214 <Section id="merge-queue" icon={<Layers size={15} className="text-accent" />} title="Merge queue" aside={`${waiting} waiting to land`}>
215 <div className="space-y-4">
216 {queue.map((one) => (
217 <div key={one.repo}>
218 <p className="mb-2 flex items-center justify-between gap-2 text-xs text-muted">
219 <Link to={`/${one.repo}`} className="font-mono hover:text-fg">
220 {repoName(one.repo, workspace)}
221 </Link>
222 <Link to={`/${one.repo}/queue`} className="hover:text-fg">
223 The whole queue
224 </Link>
225 </p>
226 <QueueTrain active={one.active} base={`/${one.repo}`} branch={one.defaultBranch} empty="Nothing is waiting to land." />
227 </div>
228 ))}
229 </div>
230 </Section>
231 )}
232
233 {!every && (
234 <Section icon={<GitMerge size={15} className="text-merged" />} title="Just landed">
235 {landed.length === 0 ? (
236 <EmptyState title="Nothing merged yet">Merged pull requests show here, each with the session behind it.</EmptyState>
237 ) : (
238 <Card asChild divided>
239 <ol>
240 {landed.map((pull) => (
241 <LandedRow key={pull.id} pull={pull} workspace={workspace} />
242 ))}
243 </ol>
244 </Card>
245 )}
246 </Section>
247 )}
248
249 {!every && (
250 <Section icon={<History size={15} className="text-accent" />} title="What happened">
251 {isFailed(feed) ? (
252 <Unavailable what={feed.what} />
253 ) : feed.length === 0 ? (
254 <EmptyState title="Nothing yet">
255 Claims, sessions, overlapping changes, dependencies and what the merge queue finds when it combines changes are listed here as they happen.
256 </EmptyState>
257 ) : (
258 <CoordinationFeed lines={feed} base={`/${workspace}/-/work`} />
259 )}
260 </Section>
261 )}
262
263 {ended != null && (
264 <Section
265 icon={<History size={15} className="text-faint" />}
266 title="Recent"
267 aside={
268 <Link to={sessionsPath(workspace, { state: "recent", repo: filters.repo, person: filters.person, harness: filters.harness, kind: filters.kind })} className="hover:text-fg">
269 Every session
270 </Link>
271 }
272 >
273 {isFailed(ended) ? (
274 <Unavailable what={ended.what} />
275 ) : ended.length === 0 ? (
276 <EmptyState title="Nothing ended yet">Sessions that ended show here, newest first.</EmptyState>
277 ) : (
278 <Card asChild divided>
279 <ol>
280 {ended.map((session) => (
281 <SessionRow key={session.id} session={session} workspace={workspace} />
282 ))}
283 </ol>
284 </Card>
285 )}
286 </Section>
287 )}
288 </div>
289 );
290}