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.

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