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.

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