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.

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