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.

464 linesCodeBlameRaw
1import { ArrowLeft, Bot, ChevronRight, GitBranch, GitCommitHorizontal, Sparkles, Square } from "lucide-react";
2import { type ReactNode, useState } from "react";
3import { Form, Link } from "react-router";
4
5import type { Pull, SessionLink, SessionTouch, State, TouchKind, WorkSession } from "@g1t/contracts";
6
7import type { Route } from "./+types/work-session";
8import { Actor, HarnessBadge, SessionStateBadge } from "../../components/coordination";
9import { SessionEntries } from "../../components/session-entries";
10import { EmptyState, SubmitButton, TimeAgo } from "../../components/ui";
11import { Alert } from "../../components/ui/alert";
12import { Badge } from "../../components/ui/badge";
13import { Button } from "../../components/ui/button";
14import { Card } from "../../components/ui/card";
15import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../components/ui/collapsible";
16import { Hint } from "../../components/ui/hint";
17import { PersonLink } from "../../components/work";
18import { IssueIcon, PullIcon } from "../../components/work-icons";
19import { requireRepo } from "../../lib/access.server";
20import { type SessionNode, TOUCH_KINDS, duration, harnessName, sessionTree, touchedByKind, viaLabel } from "../../lib/coordination";
21import { page } from "../../lib/meta";
22import { notFound } from "../../lib/not-found.server";
23import { repoPages } from "../../lib/repo-live";
24import { agentSessions, work } from "../../lib/services.server";
25import { assertSameOrigin, requireUser } from "../../lib/session.server";
26import { useRepoLive } from "../../lib/use-repo-live";
27
28const MANAGED = "claude_managed_agents";
29
30/** What a session is called: its title, a subagent's kind and task, or whose it is. */
31function sessionName(session: WorkSession): string {
32 if (session.title) return session.title;
33 if (session.parentId) return [session.agentType ?? "Subagent", session.task].filter(Boolean).join(": ");
34 return `${session.actor.username}'s ${harnessName(session.harness) ?? ""} session`.replace(" ", " ");
35}
36
37export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
38 const session = loaderData?.session;
39 const name = session ? `${sessionName(session)} · ` : "";
40 return page(args, { title: `${name}Session · ${params.owner}/${params.repo} · g1t` });
41}
42
43export async function loader({ params, context }: Route.LoaderArgs) {
44 const { viewer } = await requireRepo(context, params, "read");
45 const path = { namespace: params.owner, name: params.repo };
46 const read = () => work.getWorkSession(path, params.id, viewer).catch(() => null);
47 let found = await read();
48 if (!found?.ok) throw notFound("page");
49 // A session on the workspace's Anthropic account is followed every
50 // minute; while it goes, it is read now, through the top of its tree.
51 const top = found.value.root ?? found.value.session;
52 if (top.harness === MANAGED && top.state !== "ended") {
53 const synced = await agentSessions.sync(path, top.id, viewer).catch(() => null);
54 if (synced?.ok) {
55 const again = await read();
56 if (again?.ok) found = again;
57 }
58 }
59 const { session } = found.value;
60 return {
61 ...found.value,
62 // Only whoever started it stops it, and only the top of the tree.
63 canStop: viewer?.id === session.actor.id && session.harness === MANAGED && session.state !== "ended" && !session.parentId,
64 };
65}
66
67export async function action({ request, params, context }: Route.ActionArgs) {
68 assertSameOrigin(request);
69 const user = requireUser(context, request);
70 await requireRepo(context, params, "read");
71 const form = await request.formData();
72 if (form.get("action") !== "stop") return { error: "There is nothing to do here but stop the session." };
73 const result = await agentSessions.stop(user, { namespace: params.owner, name: params.repo }, params.id).catch(() => null);
74 if (!result) return { error: "The session could not be stopped. Try again in a moment." };
75 return result.ok ? null : { error: result.error.message };
76}
77
78/** A part of the page under a heading. */
79function Section({ title, aside, children }: { title: ReactNode; aside?: ReactNode; children: ReactNode }) {
80 return (
81 <section className="mt-10">
82 <div className="flex flex-wrap items-baseline justify-between gap-2">
83 <h3 className="text-sm font-medium">{title}</h3>
84 {aside && <span className="text-xs text-muted">{aside}</span>}
85 </div>
86 <div className="mt-3">{children}</div>
87 </section>
88 );
89}
90
91/** Text a session wrote, as it wrote it: cut short, with a way to see it all. */
92function LongText({ text, limit = 600 }: { text: string; limit?: number }) {
93 const [all, setAll] = useState(false);
94 const long = text.length > limit;
95 return (
96 <div>
97 <p className="text-sm whitespace-pre-wrap wrap-break-word text-fg-soft">{long && !all ? `${text.slice(0, limit).trimEnd()}…` : text}</p>
98 {long && (
99 <Button variant="link" size="inline" className="mt-1 text-xs" onClick={() => setAll(!all)}>
100 {all ? "Show less" : "Show all"}
101 </Button>
102 )}
103 </div>
104 );
105}
106
107/** An issue or pull request it worked on, with how it was tied there. */
108function LinkChip({ link, base }: { link: SessionLink; base: string }) {
109 const icon =
110 link.kind === "pull" ? (
111 <PullIcon status={(["open", "draft", "merged", "closed"].includes(link.state) ? link.state : "open") as Pull["status"]} size={12} />
112 ) : (
113 <IssueIcon issue={{ state: (link.state === "closed" ? "closed" : "open") as State, reason: null }} size={12} />
114 );
115 return (
116 <Hint label={`Tied by ${link.via.map(viaLabel).join(", ")}`}>
117 <Link to={`${base}/${link.kind === "pull" ? "pull" : "issues"}/${link.number}`} prefetch="intent">
118 <Badge size="md" className="max-w-72 hover:border-line-strong hover:text-fg">
119 {icon}
120 <span className="truncate">
121 {link.title} <span className="text-faint">#{link.number}</span>
122 </span>
123 </Badge>
124 </Link>
125 </Hint>
126 );
127}
128
129/** One thing touched, linked where the site shows it. */
130function TouchedValue({ kind, value, base }: { kind: TouchKind; value: string; base: string }) {
131 if (kind === "file") return <span className="font-mono text-xs break-all text-fg-soft">{value}</span>;
132 const [to, text] =
133 kind === "commit"
134 ? [`${base}/commit/${value}`, value.slice(0, 7)]
135 : kind === "branch"
136 ? [`${base}/tree/${value}`, value]
137 : [`${base}/${kind === "pull" ? "pull" : "issues"}/${value}`, `#${value}`];
138 return (
139 <Link to={to} className="font-mono text-xs text-fg-soft hover:text-accent">
140 {text}
141 </Link>
142 );
143}
144
145const SHOWN = 8;
146
147/** Values of one kind: the first few, and the rest folded. */
148function TouchedValues({ kind, values, base }: { kind: TouchKind; values: string[]; base: string }) {
149 const [open, setOpen] = useState(false);
150 const list = (items: string[]) => (
151 <ul className={kind === "file" ? "space-y-0.5" : "flex flex-wrap gap-x-3 gap-y-0.5"}>
152 {items.map((value) => (
153 <li key={value}>
154 <TouchedValue kind={kind} value={value} base={base} />
155 </li>
156 ))}
157 </ul>
158 );
159 if (values.length <= SHOWN) return list(values);
160 return (
161 <Collapsible open={open} onOpenChange={setOpen}>
162 {list(values.slice(0, SHOWN))}
163 <CollapsibleContent>{list(values.slice(SHOWN))}</CollapsibleContent>
164 <CollapsibleTrigger className="mt-1 text-xs">
165 <ChevronRight size={12} />
166 {open ? "Show fewer" : `${values.length - SHOWN} more`}
167 </CollapsibleTrigger>
168 </Collapsible>
169 );
170}
171
172/** What was touched, by kind. */
173function Touched({ touched, base, kinds }: { touched: SessionTouch[]; base: string; kinds?: TouchKind[] }) {
174 const groups = touchedByKind(touched).filter((group) => !kinds || kinds.includes(group.kind));
175 if (groups.length === 0) return null;
176 return (
177 <dl className="space-y-3">
178 {groups.map((group) => (
179 <div key={group.kind}>
180 <dt className="text-xs font-medium text-muted">
181 {TOUCH_KINDS[group.kind]} <span className="font-normal text-faint">· {group.values.length}</span>
182 </dt>
183 <dd className="mt-1">
184 <TouchedValues kind={group.kind} values={group.values} base={base} />
185 </dd>
186 </div>
187 ))}
188 </dl>
189 );
190}
191
192/** A subagent in the tree: a line, and folded under it what it touched, what it reported and its own subagents. */
193function SubagentNode({ node, base, touched, now }: { node: SessionNode; base: string; touched: SessionTouch[]; now: number }) {
194 const [open, setOpen] = useState(false);
195 const { session } = node;
196 const mine = touched.filter((one) => one.sessionId === session.id);
197 return (
198 <li className="px-4 py-3">
199 <Collapsible open={open} onOpenChange={setOpen}>
200 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
201 <CollapsibleTrigger aria-label={open ? "Fold" : "Unfold"}>
202 <ChevronRight size={14} />
203 </CollapsibleTrigger>
204 <Bot size={14} className="shrink-0 text-faint" />
205 <Link to={`${base}/work/sessions/${session.id}`} prefetch="intent" className="shrink-0 text-sm font-medium hover:text-accent">
206 {session.agentType ?? "Subagent"}
207 </Link>
208 {session.task && (
209 <Hint label={session.task}>
210 <span className="min-w-0 flex-1 truncate text-sm text-muted">{session.task}</span>
211 </Hint>
212 )}
213 <span className="ml-auto flex items-center gap-2 text-xs text-faint">
214 {node.children.length > 0 && <Badge>{node.children.length} under it</Badge>}
215 <SessionStateBadge state={session.state} />
216 <TimeAgo at={session.startedAt} />
217 <span suppressHydrationWarning>· {duration(session, now)}</span>
218 </span>
219 </div>
220 <CollapsibleContent className="mt-3 space-y-4 pl-6">
221 {mine.length > 0 && <Touched touched={mine} base={base} />}
222 {session.result ? (
223 <div>
224 <p className="text-xs font-medium text-muted">What it reported</p>
225 <div className="mt-1">
226 <LongText text={session.result} limit={400} />
227 </div>
228 </div>
229 ) : (
230 mine.length === 0 && <p className="text-xs text-faint">{session.state === "ended" ? "It reported nothing." : "Nothing yet."}</p>
231 )}
232 {node.children.length > 0 && <SubagentTree nodes={node.children} base={base} touched={touched} now={now} />}
233 </CollapsibleContent>
234 </Collapsible>
235 </li>
236 );
237}
238
239function SubagentTree({ nodes, base, touched, now }: { nodes: SessionNode[]; base: string; touched: SessionTouch[]; now: number }) {
240 return (
241 <Card asChild divided tone="plain">
242 <ol>
243 {nodes.map((node) => (
244 <SubagentNode key={node.session.id} node={node} base={base} touched={touched} now={now} />
245 ))}
246 </ol>
247 </Card>
248 );
249}
250
251/** One harness session: who ran it, where, what it worked on, its subagents, what it touched and what it recorded. */
252export default function WorkSessionPage({ loaderData, actionData, params }: Route.ComponentProps) {
253 const { session, entries, children = [], touched = [], links = [], skills = [], root, canStop } = loaderData;
254 const base = `/${params.owner}/${params.repo}`;
255 const now = Date.now();
256 // Any session, claim or overlap in the repository: a change names no session.
257 useRepoLive({ match: repoPages.session() });
258
259 const tools = entries.filter((entry) => entry.kind === "tool_call").length;
260 const isSubagent = Boolean(session.parentId);
261 const parent = isSubagent && root?.id === session.parentId ? root : null;
262 // Its own subagents: those whose parent is in this tree (children lists every descendant).
263 const tree = sessionTree([session, ...children]).find((node) => node.session.id === session.id)?.children ?? [];
264 const managed = session.harness === MANAGED || root?.harness === MANAGED;
265
266 // The skills, summed by name across the tree.
267 const skillUses = new Map<string, number>();
268 for (const skill of skills) skillUses.set(skill.name, (skillUses.get(skill.name) ?? 0) + skill.uses);
269
270 const pulls = links.filter((link) => link.kind === "pull");
271 const groups = touchedByKind(touched);
272 const commits = groups.find((group) => group.kind === "commit")?.values ?? [];
273 const where: TouchKind[] = ["file", "branch", "issue"];
274
275 return (
276 <div className="max-w-4xl">
277 {isSubagent ? (
278 <p className="flex flex-wrap items-center gap-x-1.5 text-sm text-muted">
279 <Link to={`${base}/work/sessions/${session.parentId}`} className="inline-flex items-center gap-1.5 hover:text-fg">
280 <ArrowLeft size={14} />
281 Subagent of {parent ? sessionName(parent) : "another subagent"}
282 </Link>
283 {root && root.id !== session.parentId && (
284 <>
285 <span className="text-faint">in</span>
286 <Link to={`${base}/work/sessions/${root.id}`} className="hover:text-fg">
287 {sessionName(root)}
288 </Link>
289 </>
290 )}
291 </p>
292 ) : (
293 <Link to={`${base}/work`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
294 <ArrowLeft size={14} />
295 Work
296 </Link>
297 )}
298
299 <div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2">
300 <Actor username={session.actor.username} harness={session.harness} size={24} />
301 <h2 className="min-w-0 grow text-lg font-semibold tracking-tight wrap-break-word">{sessionName(session)}</h2>
302 {isSubagent && session.agentType && (
303 <Badge tone="accent">
304 <Bot size={11} />
305 {session.agentType}
306 </Badge>
307 )}
308 <HarnessBadge harness={session.harness} />
309 {session.model && <Badge shape="tag" className="font-mono">{session.model}</Badge>}
310 <SessionStateBadge state={session.state} />
311 {canStop && (
312 <Form method="post">
313 <input type="hidden" name="action" value="stop" />
314 <SubmitButton variant="destructive" size="xs" match={{ action: "stop" }} pending="Stopping…">
315 <Square size={12} />
316 Stop
317 </SubmitButton>
318 </Form>
319 )}
320 </div>
321
322 <p className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted">
323 <PersonLink name={session.actor.username} className="font-medium text-fg hover:underline" />
324 <span>
325 started <TimeAgo at={session.startedAt} />
326 </span>
327 <span suppressHydrationWarning>
328 · {session.state === "ended" ? "ran" : "running for"} {duration(session, now)}
329 </span>
330 <span>
331 · {session.state === "ended" ? "ended" : "last heard from"} <TimeAgo at={session.endedAt ?? session.lastSeenAt} />
332 {session.endReason && ` (${session.endReason === "timed_out" ? "timed out" : session.endReason})`}
333 </span>
334 {session.branch && (
335 <Link to={`${base}/tree/${session.branch}`} className="inline-flex items-center gap-1 font-mono text-xs hover:text-fg">
336 · <GitBranch size={12} />
337 {session.branch}
338 </Link>
339 )}
340 {session.headCommit && (
341 <Link to={`${base}/commit/${session.headCommit}`} className="inline-flex items-center gap-1 font-mono text-xs hover:text-fg">
342 · <GitCommitHorizontal size={12} />
343 {session.headCommit.slice(0, 7)}
344 </Link>
345 )}
346 </p>
347
348 {links.length > 0 && (
349 <div className="mt-3 flex flex-wrap gap-2">
350 {links.map((link) => (
351 <LinkChip key={`${link.kind}-${link.number}`} link={link} base={base} />
352 ))}
353 </div>
354 )}
355
356 {actionData?.error && (
357 <Alert tone="danger" className="mt-4">
358 {actionData.error}
359 </Alert>
360 )}
361 {managed && session.state !== "ended" && (
362 <Alert tone="info" className="mt-4">
363 It runs on the workspace's Anthropic account, with Claude Managed Agents. What it does shows here as it goes.
364 </Alert>
365 )}
366 <p className="mt-3 text-xs text-faint">
367 {managed
368 ? "Started by g1t on the workspace's Anthropic account, for whoever started it."
369 : `Run by ${session.actor.username}'s token in ${harnessName(session.harness)}, as the harness declared.`}{" "}
370 What it records is its own account; the commits, checks and merges are the record.
371 </p>
372
373 {isSubagent && (session.prompt || session.result) && (
374 <Card className="mt-6 space-y-4 p-4">
375 {session.prompt && (
376 <div>
377 <p className="text-xs font-medium text-muted">What it was asked</p>
378 <div className="mt-1">
379 <LongText text={session.prompt} />
380 </div>
381 </div>
382 )}
383 {session.result && (
384 <div>
385 <p className="text-xs font-medium text-muted">What it reported</p>
386 <div className="mt-1">
387 <LongText text={session.result} />
388 </div>
389 </div>
390 )}
391 </Card>
392 )}
393 {!isSubagent && session.result && (
394 <Section title="What it reported">
395 <Card className="p-4">
396 <LongText text={session.result} />
397 </Card>
398 </Section>
399 )}
400
401 {tree.length > 0 && (
402 <Section title="Subagents" aside={children.length > tree.length ? `${children.length} in all` : undefined}>
403 <SubagentTree nodes={tree} base={base} touched={touched} now={now} />
404 </Section>
405 )}
406
407 {skillUses.size > 0 && (
408 <Section title="Skills used">
409 <div className="flex flex-wrap gap-2">
410 {[...skillUses].map(([name, uses]) => (
411 <Badge key={name} size="md" shape="tag">
412 <Sparkles size={11} className="text-accent" />
413 <span className="font-mono">{name}</span>
414 {uses > 1 && <span className="text-faint">×{uses}</span>}
415 </Badge>
416 ))}
417 </div>
418 </Section>
419 )}
420
421 {groups.some((group) => where.includes(group.kind)) && (
422 <Section title="What it touched" aside={tree.length > 0 ? "With its subagents" : undefined}>
423 <Card className="p-4">
424 <Touched touched={touched} base={base} kinds={where} />
425 </Card>
426 </Section>
427 )}
428
429 {(pulls.length > 0 || commits.length > 0) && (
430 <Section title="Commits and pull requests">
431 <Card asChild divided>
432 <ul>
433 {pulls.map((link) => (
434 <li key={link.number} className="flex items-center gap-2.5 px-4 py-2.5 text-sm">
435 <PullIcon status={(["open", "draft", "merged", "closed"].includes(link.state) ? link.state : "open") as Pull["status"]} size={14} />
436 <Link to={`${base}/pull/${link.number}`} prefetch="intent" className="min-w-0 grow truncate hover:text-accent">
437 {link.title} <span className="text-faint">#{link.number}</span>
438 </Link>
439 <span className="shrink-0 text-xs text-faint">{link.via.map(viaLabel).join(", ")}</span>
440 </li>
441 ))}
442 {commits.map((sha) => (
443 <li key={sha} className="flex items-center gap-2.5 px-4 py-2.5 text-sm">
444 <GitCommitHorizontal size={14} className="shrink-0 text-faint" />
445 <Link to={`${base}/commit/${sha}`} className="font-mono text-xs hover:text-accent">
446 {sha.slice(0, 7)}
447 </Link>
448 </li>
449 ))}
450 </ul>
451 </Card>
452 </Section>
453 )}
454
455 <Section title="What it recorded" aside={`${entries.length} entries, ${tools} tool calls`}>
456 {entries.length === 0 ? (
457 <EmptyState title="Nothing recorded yet">Prompts, messages, tool calls, skills and notes show here as the session records them.</EmptyState>
458 ) : (
459 <SessionEntries entries={entries} />
460 )}
461 </Section>
462 </div>
463 );
464}