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.

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