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.

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