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.

630 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 { session, entries, updates = [], children = [], touched = [], links = [], skills = [], root, canStop, canEnd, consoleUrl, limited, workspace, repo } = loaderData;
382 const base = `/${repo}`;
383 const [ending, setEnding] = useState(false);
384 const now = Date.now();
385 // Any session, claim or overlap in its repository: a change names no session.
386 useLive({ match: repoPages.session(), repos: [{ id: loaderData.repoId, path: repo }], signedIn: loaderData.signedIn });
387
388 const tools = entries.filter((entry) => entry.kind === "tool_call").length;
389 const firstPrompt = entries.find((entry) => entry.kind === "prompt")?.text;
390 const isSubagent = Boolean(session.parentId);
391 const parent = isSubagent && root?.id === session.parentId ? root : null;
392 // Its own subagents: those whose parent is in this tree (children lists every descendant).
393 const tree = sessionTree([session, ...children]).find((node) => node.session.id === session.id)?.children ?? [];
394 const managed = session.harness === MANAGED || root?.harness === MANAGED;
395 const cost = sessionCost(session);
396
397 // The skills, summed by name across the tree.
398 const skillUses = new Map<string, number>();
399 for (const skill of skills) skillUses.set(skill.name, (skillUses.get(skill.name) ?? 0) + skill.uses);
400
401 const pulls = links.filter((link) => link.kind === "pull");
402 const groups = touchedByKind(touched);
403 const commits = groups.find((group) => group.kind === "commit")?.values ?? [];
404 const where: TouchKind[] = ["file", "branch", "item"];
405
406 return (
407 <div className="max-w-4xl">
408 {isSubagent ? (
409 <p className="flex flex-wrap items-center gap-x-1.5 text-sm text-muted">
410 <Link to={sessionPath(workspace, session.parentId!)} className="inline-flex items-center gap-1.5 hover:text-fg">
411 <ArrowLeft size={14} />
412 Subagent of {parent ? sessionName(parent) : "another subagent"}
413 </Link>
414 {root && root.id !== session.parentId && (
415 <>
416 <span className="text-faint">in</span>
417 <Link to={sessionPath(workspace, root.id)} className="hover:text-fg">
418 {sessionName(root)}
419 </Link>
420 </>
421 )}
422 </p>
423 ) : (
424 <Link to={sessionsPath(workspace)} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
425 <ArrowLeft size={14} />
426 Sessions
427 </Link>
428 )}
429
430 <div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2">
431 <Actor username={session.actor.username} harness={session.harness} size={24} />
432 <SessionHeading session={session} name={sessionName(session, firstPrompt)} canTitle={canEnd && !isSubagent} />
433 {isSubagent && session.agentType && (
434 <Badge tone="accent">
435 <Bot size={11} />
436 {session.agentType}
437 </Badge>
438 )}
439 <HarnessBadge harness={session.harness} />
440 {session.model && <Badge shape="tag" className="font-mono">{session.model}</Badge>}
441 <SessionStateBadge state={session.state} />
442 {canStop && (
443 <Form method="post">
444 <input type="hidden" name="action" value="stop" />
445 <SubmitButton variant="destructive" size="xs" match={{ action: "stop" }} pending="Stopping…">
446 <Square size={12} />
447 Stop
448 </SubmitButton>
449 </Form>
450 )}
451 {canEnd && (
452 <>
453 <Button variant="outline" size="xs" onClick={() => setEnding(true)}>
454 <Square size={12} />
455 End session
456 </Button>
457 <EndSessionDialog session={session} base={base} open={ending} onOpenChange={setEnding} />
458 </>
459 )}
460 {consoleUrl && (
461 <Button asChild variant="outline" size="xs">
462 <a href={consoleUrl} target="_blank" rel="noreferrer">
463 <ExternalLink size={12} />
464 Open in Claude Console
465 </a>
466 </Button>
467 )}
468 </div>
469
470 <p className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted">
471 <PersonLink name={session.actor.username} className="font-medium text-fg hover:underline" />
472 <Link to={base} className="font-mono text-xs hover:text-fg">
473 in {repo}
474 </Link>
475 <span>
476 started <TimeAgo at={session.startedAt} />
477 </span>
478 <span suppressHydrationWarning>
479 · {session.state === "ended" ? "ran" : "running for"} {duration(session, now)}
480 </span>
481 <span>
482 · {session.state === "ended" ? "ended" : "last heard from"} <TimeAgo at={session.endedAt ?? session.lastSeenAt} />
483 {session.endReason && ` (${endReasonText(session.endReason)})`}
484 </span>
485 {cost && (
486 <Hint label="Estimated from the tokens its transcript recorded, at Anthropic's list price. Its subagents count on their own sessions.">
487 <span className="tabular-nums">· {cost}</span>
488 </Hint>
489 )}
490 {session.branch && (
491 <Link to={`${base}/tree/${session.branch}`} className="inline-flex items-center gap-1 font-mono text-xs hover:text-fg">
492 · <GitBranch size={12} />
493 {session.branch}
494 </Link>
495 )}
496 {session.headCommit && (
497 <Link to={`${base}/commit/${session.headCommit}`} className="inline-flex items-center gap-1 font-mono text-xs hover:text-fg">
498 · <GitCommitHorizontal size={12} />
499 {session.headCommit.slice(0, 7)}
500 </Link>
501 )}
502 </p>
503
504 {links.length > 0 && (
505 <div className="mt-3 flex flex-wrap gap-2">
506 {links.map((link) => (
507 <LinkChip key={`${link.kind}-${link.number}`} link={link} base={base} />
508 ))}
509 </div>
510 )}
511
512 {actionData?.error && (
513 <Alert tone="danger" className="mt-4">
514 {actionData.error}
515 </Alert>
516 )}
517 {managed && session.state !== "ended" && (
518 <Alert tone="info" className="mt-4">
519 It runs on the workspace's Anthropic account, with Claude Managed Agents. What it does shows here as it goes.
520 </Alert>
521 )}
522 <p className="mt-3 text-xs text-faint">
523 {managed
524 ? "Started by g1t on the workspace's Anthropic account, for whoever started it."
525 : `Run by ${session.actor.username}'s token in ${harnessName(session.harness)}, as the harness declared.`}{" "}
526 What it records is its own account; the commits, checks and merges are the record.
527 </p>
528
529 {isSubagent && (session.prompt || session.result) && (
530 <Card className="mt-6 space-y-4 p-4">
531 {session.prompt && (
532 <div>
533 <p className="text-xs font-medium text-muted">What it was asked</p>
534 <div className="mt-1">
535 <LongText text={session.prompt} />
536 </div>
537 </div>
538 )}
539 {session.result && (
540 <div>
541 <p className="text-xs font-medium text-muted">What it reported</p>
542 <div className="mt-1">
543 <LongText text={session.result} />
544 </div>
545 </div>
546 )}
547 </Card>
548 )}
549 {!isSubagent && session.result && (
550 <Section title="What it reported">
551 <Card className="p-4">
552 <LongText text={session.result} />
553 </Card>
554 </Section>
555 )}
556
557 {tree.length > 0 && (
558 <Section title="Subagents" aside={children.length > tree.length ? `${children.length} in all` : undefined}>
559 <SubagentTree nodes={tree} base={base} touched={touched} now={now} />
560 </Section>
561 )}
562
563 {skillUses.size > 0 && (
564 <Section title="Skills used">
565 <div className="flex flex-wrap gap-2">
566 {[...skillUses].map(([name, uses]) => (
567 <Badge key={name} size="md" shape="tag">
568 <Sparkles size={11} className="text-accent" />
569 <span className="font-mono">{name}</span>
570 {uses > 1 && <span className="text-faint">×{uses}</span>}
571 </Badge>
572 ))}
573 </div>
574 </Section>
575 )}
576
577 {groups.some((group) => where.includes(group.kind)) && (
578 <Section title="What it touched" aside={tree.length > 0 ? "With its subagents" : undefined}>
579 <Card className="p-4">
580 <Touched touched={touched} base={base} kinds={where} />
581 </Card>
582 </Section>
583 )}
584
585 {(pulls.length > 0 || commits.length > 0) && (
586 <Section title="Commits and pull requests">
587 <Card asChild divided>
588 <ul>
589 {pulls.map((link) => (
590 <li key={link.number} className="flex items-center gap-2.5 px-4 py-2.5 text-sm">
591 <PullIcon status={(["open", "draft", "merged", "closed"].includes(link.state) ? link.state : "open") as Pull["status"]} size={14} />
592 <Link to={`${base}/pull/${link.number}`} prefetch="intent" className="min-w-0 grow truncate hover:text-accent">
593 {link.title} <span className="text-faint">#{link.number}</span>
594 </Link>
595 <span className="shrink-0 text-xs text-faint">{link.via.map(viaLabel).join(", ")}</span>
596 </li>
597 ))}
598 {commits.map((sha) => (
599 <li key={sha} className="flex items-center gap-2.5 px-4 py-2.5 text-sm">
600 <GitCommitHorizontal size={14} className="shrink-0 text-faint" />
601 <Link to={`${base}/commit/${sha}`} className="font-mono text-xs hover:text-accent">
602 {sha.slice(0, 7)}
603 </Link>
604 </li>
605 ))}
606 </ul>
607 </Card>
608 </Section>
609 )}
610
611 {updates.length > 0 && (
612 <Section title="Awaiting agent acknowledgement" aside={`${updates.length}${updates.length === 100 ? "+" : ""} pending`}>
613 <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>
614 <CoordinationFeed lines={pendingLines(updates)} base={base} />
615 </Section>
616 )}
617 <Section title="What it recorded" aside={limited ? undefined : `${entries.length} entries, ${tools} tool calls`}>
618 {limited ? (
619 <EmptyState title="Shown to the workspace's members">
620 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 what it is tied to.
621 </EmptyState>
622 ) : entries.length === 0 ? (
623 <EmptyState title="Nothing recorded yet">Prompts, messages, tool calls, skills and notes show here as the session records them.</EmptyState>
624 ) : (
625 <SessionEntries entries={entries} base={base} />
626 )}
627 </Section>
628 </div>
629 );
630}