Skip to content
589 linesCodeBlameRaw
1import { ArrowLeft,
2 Ban,
3 BookmarkPlus,
4 Check,
5 CircleAlert,
6 CircleDot,
7 CornerDownRight,
8 EyeOff,
9 Flag,
10 GitBranch,
11 Lock,
12 Megaphone,
13 MessageSquare,
14 Send,
15 Square,
16 Wrench,
17} from "lucide-react";
18import { type ReactNode, useEffect, useRef } from "react";
19import { Link, data, redirect, useFetcher } from "react-router";
20
21import type { AgentSession, AgentSessionDetail, SessionEvent } from "@g1t/contracts";
22
23import type { Route } from "./+types/session";
24import { AgentAvatar } from "../../../components/agent-avatar";
25import { sessionFaceOf } from "../../../components/agent-face";
26import { agentsAction, answer } from "../../../components/agents/actions.server";
27import { type ActionResult, ApproveDialog, Confirm } from "../../../components/agents/dialogs";
28import { isLive, kindLabel, sessionRows, whereLabel } from "../../../components/agents/format";
29import { KindBadge, Meter, PrivateTitle, SpendOfCap, StatusChip, sessionHref, stepsLine } from "../../../components/agents/parts";
30import { skillsPath } from "../../../components/agents/skills";
31import { Markdown } from "../../../components/markdown";
32import { ButtonLink, TimeAgo } from "../../../components/ui";
33import { Button } from "../../../components/ui/button";
34import { Card } from "../../../components/ui/card";
35import { Hint } from "../../../components/ui/hint";
36import { Textarea } from "../../../components/ui/textarea";
37import { microsFromDollars } from "../../../lib/agent-form";
38import { channelPath } from "../../../lib/chat";
39import { cn } from "../../../lib/cn";
40import { page } from "../../../lib/meta";
41import { useRefreshWhile } from "../../../lib/refresh";
42import { skillLibrary, workspaceAgents } from "../../../lib/services.server";
43import { requireUser, roleIn } from "../../../lib/session.server";
44
45export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
46 const session = loaderData?.detail?.session;
47 const title = session?.visible ? session.title : "A session";
48 return page(args, { title: `${title} · @${params.handle} · ${params.owner} · g1t` });
49}
50
51/** One session: its transcript, its tree, what it produced, and what the viewer may do to it. */
52export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ detail: AgentSessionDetail | null }> {
53 const viewer = requireUser(context, request);
54 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
55 const found = await workspaceAgents.session(params.owner.toLowerCase(), params.id, viewer).catch(() => null);
56 if (found && !found.ok && found.error.code === "not_found") throw data(null, { status: 404 });
57 const detail = found?.ok ? found.value : null;
58 // A session is under the agent that runs it.
59 if (detail && detail.session.agent_handle !== params.handle.toLowerCase()) {
60 throw redirect(`/${params.owner}/-/agents/${detail.session.agent_handle}/sessions/${detail.session.id}`);
61 }
62 return { detail };
63}
64
65/** Stop it (and everything under it), approve more spend, or say something to it. */
66export async function action({ params, context, request }: Route.ActionArgs): Promise<ActionResult> {
67 const { viewer, slug, form } = await agentsAction(request, context, params.owner);
68 const intent = String(form.get("intent") ?? "");
69 if (intent === "stop") return answer(intent, workspaceAgents.stopSession(slug, params.id, viewer));
70 if (intent === "approve") {
71 const cap = microsFromDollars(form.get("cap"));
72 if (cap == null || Number.isNaN(cap)) return { ok: false, intent, error: "Write the new cap in dollars, such as 5 or 2.50." };
73 return answer(intent, workspaceAgents.approveSession(slug, params.id, viewer, cap));
74 }
75 if (intent === "steer") {
76 const body = String(form.get("body") ?? "").trim();
77 if (!body) return { ok: false, intent, error: "Write something to send." };
78 return answer(intent, workspaceAgents.steerSession(slug, params.id, viewer, body));
79 }
80 // Save as skill: the agent drafts one from this session, for a person to review.
81 if (intent === "save_skill") {
82 const drafted = await skillLibrary.draftFromSession(slug, viewer, params.id).catch(() => null);
83 if (!drafted) return { ok: false, intent, error: "The agents service didn't answer. Try again in a moment." };
84 if (!drafted.ok) return { ok: false, intent, error: drafted.error.message };
85 throw redirect(skillsPath(slug, drafted.value.skill.name, drafted.value.skill.can_edit ? "/edit" : ""));
86 }
87 return { ok: false, intent, error: "Unknown request." };
88}
89
90export default function SessionPage({ loaderData, params }: Route.ComponentProps) {
91 const { detail } = loaderData;
92 const slug = params.owner;
93 const live = Boolean(detail && isLive(detail.session.status));
94 useRefreshWhile(live);
95 const back = (
96 <Link to={`/${slug}/-/agents/${params.handle}`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
97 <ArrowLeft size={14} />
98 Sessions
99 </Link>
100 );
101 if (!detail) {
102 return (
103 <div className="space-y-4">
104 {back}
105 <Card tone="plain" className="border-dashed px-6 py-14 text-center">
106 <p className="font-medium">This session can&apos;t be shown right now</p>
107 <p className="mt-1.5 text-sm text-muted">The agents service didn&apos;t answer. Reload in a moment.</p>
108 </Card>
109 </div>
110 );
111 }
112 const { session, events, tree } = detail;
113 return (
114 <div className="space-y-8">
115 {back}
116 <Header slug={slug} detail={detail} />
117 <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_17rem]">
118 <div className="min-w-0 space-y-6">
119 {!session.visible ? (
120 <Card className="flex items-start gap-3 px-4 py-4 text-sm text-muted">
121 <Lock size={16} className="mt-0.5 shrink-0 text-faint" />
122 <p>
123 This session came from a conversation you&apos;re not in. You can see that it ran, how far it got and what it cost, but not what it was about.
124 </p>
125 </Card>
126 ) : (
127 <>
128 {session.summary && !events.some((e) => e.kind === "result") && <Report body={session.summary} />}
129 <Transcript events={events} agentHandle={session.agent_handle} agentSeed={session.agent_avatar_seed} live={live} />
130 {detail.can_steer && <Steer session={session} live={live} />}
131 </>
132 )}
133 </div>
134 <aside className="space-y-6">
135 <TreePanel slug={slug} tree={tree} current={session.id} />
136 {session.visible && <Outputs slug={slug} session={session} />}
137 </aside>
138 </div>
139 </div>
140 );
141}
142
143function Header({ slug, detail }: { slug: string; detail: AgentSessionDetail }) {
144 const { session } = detail;
145 const chat = session.visible
146 ? `${channelPath(slug, { id: session.channel_id, kind: session.channel_kind, name: session.channel_name })}${session.card_message_id ? `?thread=${encodeURIComponent(session.card_message_id)}` : ""}`
147 : null;
148 return (
149 <header className="rounded-2xl border border-line bg-surface p-5 sm:p-6">
150 <div className="flex flex-wrap items-center gap-2">
151 <KindBadge kind={session.kind} subagent={session.subagent} />
152 <StatusChip status={session.status} />
153 </div>
154 <h1 className="mt-3 text-xl font-semibold tracking-tight text-balance sm:text-2xl">
155 {session.visible ? session.title || "Untitled session" : <PrivateTitle className="not-italic" />}
156 </h1>
157 {session.visible && session.status_note && (
158 <p className={cn("mt-2 text-sm", session.status === "failed" ? "text-danger" : session.status === "needs_approval" ? "text-warn" : "text-muted")}>{session.status_note}</p>
159 )}
160 <dl className="mt-5 grid gap-x-6 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
161 <Meta label="Agent">
162 <Link to={`/${slug}/-/agents/${session.agent_handle}`} className="inline-flex items-center gap-1.5 hover:underline">
163 <AgentAvatar agent={{ handle: session.agent_handle, avatar_seed: session.agent_avatar_seed, look: session.agent_look }} size={18} state={sessionFaceOf(session.status)} />
164 {session.agent_name}
165 </Link>
166 </Meta>
167 <Meta label="Asked by">{session.visible && session.asked_by_username ? `@${session.asked_by_username}` : session.kind === "routine" ? "A routine" : "—"}</Meta>
168 <Meta label="Where">{session.visible ? whereLabel(session) : "A conversation you're not in"}</Meta>
169 <Meta label="Started">
170 <TimeAgo at={session.created_at} />
171 {session.finished_at && (
172 <span className="text-faint">
173 {" "}
174 · ended <TimeAgo at={session.finished_at} />
175 </span>
176 )}
177 </Meta>
178 <Meta label="Model">{session.model ? <span className="font-mono text-[0.8125rem]">{session.model}</span> : "—"}</Meta>
179 <Meta label="Work">{stepsLine(session)}</Meta>
180 <Meta label="Tokens">
181 <span className="tabular-nums">
182 {session.input_tokens.toLocaleString("en-US")} in · {session.output_tokens.toLocaleString("en-US")} out
183 </span>
184 </Meta>
185 <Meta label={session.parent_id ? "Spent (paid by its root)" : "Spent"}>
186 <SpendOfCap spent={session.charged_micros} cap={session.cap_micros} />
187 {session.cap_micros != null && <Meter spent={session.charged_micros} cap={session.cap_micros} label="Spent of its cap" size="sm" className="mt-1.5" />}
188 </Meta>
189 </dl>
190 {(detail.can_stop || detail.can_approve || chat) && (
191 <div className="mt-5 flex flex-wrap gap-2 border-t border-line pt-4">
192 {detail.can_approve && (
193 <ApproveDialog
194 slug={slug}
195 session={session}
196 trigger={
197 <Button type="button" variant="accent">
198 <Check size={15} />
199 Approve more…
200 </Button>
201 }
202 />
203 )}
204 {chat && (
205 <ButtonLink to={chat} variant="outline">
206 <MessageSquare size={15} />
207 Open in chat
208 </ButtonLink>
209 )}
210 {session.visible && session.status === "done" && <SaveAsSkill agentName={session.agent_name} />}
211 {detail.can_stop && (
212 <Confirm
213 title="Stop this session?"
214 confirm="Stop session"
215 fields={{ intent: "stop" }}
216 fetcherKey={`stop-${session.id}`}
217 trigger={
218 <Button type="button" variant="outline" className="hover:border-danger/50 hover:text-danger">
219 <Square size={13} />
220 Stop
221 </Button>
222 }
223 >
224 {session.agent_name} stops where it is, and so does every session it started. What it spent so far stays spent; its card in the conversation says it was
225 stopped.
226 </Confirm>
227 )}
228 </div>
229 )}
230 </header>
231 );
232}
233
234/** Save as skill: the agent drafts a skill from this session; a person reviews it before any agent uses it. */
235function SaveAsSkill({ agentName }: { agentName: string }) {
236 const fetcher = useFetcher<ActionResult>({ key: "save-skill" });
237 const busy = fetcher.state !== "idle";
238 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
239 return (
240 <fetcher.Form method="post" className="contents">
241 <input type="hidden" name="intent" value="save_skill" />
242 <Hint label={`${agentName} drafts a skill from this session, so the work can be done the same way again. It is billed like a short step, and no agent uses it until it is reviewed and published.`}>
243 <Button type="submit" variant="outline" disabled={busy}>
244 <BookmarkPlus size={15} />
245 {busy ? "Drafting…" : "Save as skill"}
246 </Button>
247 </Hint>
248 {error && (
249 <p role="alert" className="w-full text-sm text-danger">
250 {error}
251 </p>
252 )}
253 </fetcher.Form>
254 );
255}
256
257function Meta({ label, children }: { label: string; children: ReactNode }) {
258 return (
259 <div className="min-w-0">
260 <dt className="text-xs text-faint">{label}</dt>
261 <dd className="mt-0.5 truncate">{children}</dd>
262 </div>
263 );
264}
265
266const OUTCOMES: Record<string, { icon: ReactNode; label: string }> = {
267 allowed: { icon: <Check size={13} className="text-success" />, label: "Read" },
268 withheld: { icon: <EyeOff size={13} className="text-warn" />, label: "Withheld: the audience can't see it" },
269 refused: { icon: <Ban size={13} className="text-danger" />, label: "Refused" },
270 error: { icon: <CircleAlert size={13} className="text-danger" />, label: "Failed" },
271};
272
273/** The session's transcript, oldest first: its goal, what it said and did, who steered it, and its report. */
274function Transcript({ events, agentHandle, agentSeed, live }: { events: SessionEvent[]; agentHandle: string; agentSeed: string; live: boolean }) {
275 if (events.length === 0) {
276 return <p className="rounded-xl border border-dashed border-line px-4 py-8 text-center text-sm text-faint">{live ? "Getting started…" : "Nothing was recorded for this session."}</p>;
277 }
278 return (
279 <section aria-label="Transcript">
280 <ol className="relative space-y-1 before:absolute before:top-3 before:bottom-3 before:left-[0.6875rem] before:w-px before:bg-line">
281 {events.map((event) => (
282 <EventItem key={event.seq} event={event} agentHandle={agentHandle} agentSeed={agentSeed} />
283 ))}
284 {live && (
285 <li className="relative flex items-center gap-3 py-2 pl-0 text-sm text-faint">
286 <span className="relative z-10 flex size-6 items-center justify-center rounded-full bg-bg">
287 <CircleDot size={13} className="animate-pulse text-accent motion-reduce:animate-none" />
288 </span>
289 Working…
290 </li>
291 )}
292 </ol>
293 </section>
294 );
295}
296
297function Rail({ children }: { children: ReactNode }) {
298 return <span className="relative z-10 flex size-6 shrink-0 items-center justify-center rounded-full bg-bg ring-1 ring-line">{children}</span>;
299}
300
301function EventItem({ event, agentHandle, agentSeed }: { event: SessionEvent; agentHandle: string; agentSeed: string }) {
302 const when = (
303 <span className="ml-auto shrink-0 text-[0.6875rem] text-faint">
304 <TimeAgo at={event.created_at} />
305 </span>
306 );
307 switch (event.kind) {
308 case "goal":
309 return (
310 <li className="relative flex gap-3 py-2">
311 <Rail>
312 <Flag size={12} className="text-accent" />
313 </Rail>
314 <div className="min-w-0 grow rounded-lg border border-accent/30 bg-accent/[0.06] px-3.5 py-2.5">
315 <p className="flex items-center gap-2 text-xs font-medium text-accent">Goal{when}</p>
316 <p className="mt-1 text-sm whitespace-pre-wrap wrap-break-word text-fg">{event.body}</p>
317 </div>
318 </li>
319 );
320 case "text":
321 return (
322 <li className="relative flex gap-3 py-2">
323 <span className="relative z-10 shrink-0">
324 <AgentAvatar agent={{ handle: event.by ?? agentHandle, avatar_seed: event.by && event.by !== agentHandle ? event.by : agentSeed }} size={24} />
325 </span>
326 <div className="min-w-0 grow">
327 <p className="flex items-center gap-2 text-xs text-muted">
328 <span className="font-medium text-fg">{event.by ? `@${event.by}` : "g1t"}</span>
329 {when}
330 </p>
331 <p className="mt-0.5 text-sm whitespace-pre-wrap wrap-break-word text-fg-soft">{event.body}</p>
332 </div>
333 </li>
334 );
335 case "tool": {
336 const outcome = OUTCOMES[event.outcome ?? ""] ?? { icon: <Wrench size={12} className="text-faint" />, label: event.outcome ?? "Called" };
337 const long = event.body.length > 140;
338 return (
339 <li className="relative flex gap-3 py-1">
340 <Rail>
341 <Hint label={outcome.label}>
342 <span className="flex" aria-label={outcome.label}>
343 {outcome.icon}
344 </span>
345 </Hint>
346 </Rail>
347 <div className="min-w-0 grow pt-0.5">
348 {long ? (
349 <details className="group">
350 <summary className="flex cursor-pointer list-none items-center gap-2 text-[0.8125rem] [&::-webkit-details-marker]:hidden">
351 <span className="font-mono text-fg">{event.tool ?? "tool"}</span>
352 <span className="min-w-0 truncate font-mono text-xs text-faint group-open:hidden">{event.body}</span>
353 {when}
354 </summary>
355 <pre className="mt-1.5 overflow-x-auto rounded-md border border-line bg-bg px-3 py-2 font-mono text-xs whitespace-pre-wrap text-muted">{event.body}</pre>
356 </details>
357 ) : (
358 <p className="flex items-center gap-2 text-[0.8125rem]">
359 <span className="font-mono text-fg">{event.tool ?? "tool"}</span>
360 <span className="min-w-0 truncate font-mono text-xs text-faint">{event.body}</span>
361 {when}
362 </p>
363 )}
364 </div>
365 </li>
366 );
367 }
368 case "steer":
369 return (
370 <li className="relative flex gap-3 py-2">
371 <Rail>
372 <MessageSquare size={12} className="text-info" />
373 </Rail>
374 <div className="min-w-0 grow rounded-lg border border-info/30 bg-info/[0.06] px-3.5 py-2.5">
375 <p className="flex items-center gap-2 text-xs text-info">
376 <span className="font-medium">{event.by ? `@${event.by}` : "Someone"}</span> steered it{when}
377 </p>
378 <p className="mt-1 text-sm whitespace-pre-wrap wrap-break-word text-fg">{event.body}</p>
379 </div>
380 </li>
381 );
382 case "update":
383 return (
384 <li className="relative flex gap-3 py-2">
385 <Rail>
386 <Megaphone size={12} className="text-muted" />
387 </Rail>
388 <div className="min-w-0 grow">
389 <p className="flex items-center gap-2 text-xs text-muted">Posted an update in the conversation{when}</p>
390 <p className="mt-0.5 text-sm whitespace-pre-wrap wrap-break-word text-fg-soft">{event.body}</p>
391 </div>
392 </li>
393 );
394 case "child":
395 return (
396 <li className="relative flex gap-3 py-2">
397 <Rail>
398 <GitBranch size={12} className="text-merged" />
399 </Rail>
400 <div className="min-w-0 grow">
401 <p className="flex items-center gap-2 text-xs text-muted">
402 {event.by ? <span className="font-medium text-fg">@{event.by}</span> : null} reported back{when}
403 </p>
404 <p className="mt-0.5 text-sm whitespace-pre-wrap wrap-break-word text-fg-soft">{event.body}</p>
405 </div>
406 </li>
407 );
408 case "result":
409 return (
410 <li className="relative flex gap-3 py-2">
411 <Rail>
412 <Check size={12} className="text-success" />
413 </Rail>
414 <div className="min-w-0 grow">
415 <Report body={event.body} when={event.created_at} />
416 </div>
417 </li>
418 );
419 default:
420 return (
421 <li className="relative flex gap-3 py-1.5">
422 <Rail>
423 <CircleDot size={11} className="text-faint" />
424 </Rail>
425 <p className="flex min-w-0 grow items-center gap-2 pt-0.5 text-xs text-faint italic">
426 <span className="min-w-0">{event.body}</span>
427 {when}
428 </p>
429 </li>
430 );
431 }
432}
433
434/** What it found or did: its report, rendered. */
435function Report({ body, when }: { body: string; when?: string }) {
436 return (
437 <section className="rounded-xl border border-success/30 bg-surface">
438 <header className="flex items-center gap-2 border-b border-line px-4 py-2 text-xs font-medium text-success">
439 Report
440 {when && (
441 <span className="ml-auto font-normal text-faint">
442 <TimeAgo at={when} />
443 </span>
444 )}
445 </header>
446 <div className="px-4 py-3 text-sm">
447 <Markdown source={body} />
448 </div>
449 </section>
450 );
451}
452
453/** Say something to the session: it reads it before its next step, or picks back up if it had finished. */
454function Steer({ session, live }: { session: AgentSession; live: boolean }) {
455 const fetcher = useFetcher<ActionResult>({ key: `steer-${session.id}` });
456 const form = useRef<HTMLFormElement>(null);
457 const busy = fetcher.state !== "idle";
458 useEffect(() => {
459 if (fetcher.state === "idle" && fetcher.data?.ok) form.current?.reset();
460 }, [fetcher.state, fetcher.data]);
461 return (
462 <Card asChild className="p-3 focus-within:border-accent-dim">
463 <fetcher.Form ref={form} method="post">
464 <input type="hidden" name="intent" value="steer" />
465 <label htmlFor="steer" className="sr-only">
466 Message this session
467 </label>
468 <Textarea
469 id="steer"
470 name="body"
471 rows={2}
472 maxLength={4000}
473 required
474 placeholder={live ? `Message this session: ${session.agent_name} reads it before its next step` : `Message this session: ${session.agent_name} picks it back up`}
475 className="min-h-14 border-0 bg-transparent px-1 hover:border-0 focus-visible:ring-0"
476 onKeyDown={(event) => {
477 if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
478 event.preventDefault();
479 event.currentTarget.form?.requestSubmit();
480 }
481 }}
482 />
483 <div className="mt-2 flex items-center justify-between gap-3">
484 <p className="text-xs text-faint">
485 {fetcher.data && !fetcher.data.ok ? <span className="text-danger">{fetcher.data.error}</span> : live ? "It reads this before its next step." : "It picks the session back up with everything it knew."}
486 </p>
487 <Button type="submit" disabled={busy} variant="accent" size="sm">
488 <Send size={14} />
489 {busy ? "Sending…" : "Send"}
490 </Button>
491 </div>
492 </fetcher.Form>
493 </Card>
494 );
495}
496
497/** Every session in this one's tree, root first, each under the one that started it. */
498function TreePanel({ slug, tree, current }: { slug: string; tree: AgentSession[]; current: string }) {
499 const rows = sessionRows(tree);
500 return (
501 <section>
502 <h2 className="text-xs font-medium tracking-wide text-faint uppercase">Session tree</h2>
503 {rows.length <= 1 ? (
504 <p className="mt-2 text-sm text-muted">It hasn&apos;t brought in a colleague or a subagent. If it does, they show here, paid from this session.</p>
505 ) : (
506 <ul className="mt-2 space-y-0.5">
507 {rows.map(({ session, depth }) => {
508 const here = session.id === current;
509 const body = (
510 <>
511 {depth > 0 && <CornerDownRight size={12} className="shrink-0 text-faint" />}
512 <AgentAvatar agent={{ handle: session.agent_handle, avatar_seed: session.agent_avatar_seed, look: session.agent_look }} size={18} state={sessionFaceOf(session.status)} />
513 <span className="min-w-0 grow">
514 <span className="block truncate text-[0.8125rem]">{session.visible ? session.title : "A private session"}</span>
515 <span className="block truncate text-[0.6875rem] text-faint">
516 {session.subagent ? `${session.agent_name} · ${session.subagent}` : session.agent_name} · {kindLabel(session.kind)}
517 </span>
518 </span>
519 <StatusChip status={session.status} className="px-1.5" />
520 </>
521 );
522 return (
523 <li key={session.id} style={{ paddingLeft: `${depth * 0.875}rem` }}>
524 {here ? (
525 <div className="flex items-center gap-2 rounded-md bg-raised px-2 py-1.5" aria-current="page">
526 {body}
527 </div>
528 ) : session.visible ? (
529 <Link to={sessionHref(slug, session)} className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-raised/60">
530 {body}
531 </Link>
532 ) : (
533 <div className="flex items-center gap-2 px-2 py-1.5 text-muted">{body}</div>
534 )}
535 </li>
536 );
537 })}
538 </ul>
539 )}
540 <p className="mt-3 text-xs text-faint">Everything in the tree is charged to the agent at its root.</p>
541 </section>
542 );
543}
544
545/** What it produced: issues filed, sessions started, memories kept. */
546function Outputs({ slug, session }: { slug: string; session: AgentSession }) {
547 return (
548 <section>
549 <h2 className="text-xs font-medium tracking-wide text-faint uppercase">What it produced</h2>
550 {session.outputs.length === 0 ? (
551 <p className="mt-2 text-sm text-muted">Nothing yet. Issues it files, sessions it starts and facts it keeps show here.</p>
552 ) : (
553 <ul className="mt-2 space-y-1">
554 {session.outputs.map((output, i) => (
555 <li key={i} className="text-sm">
556 {output.kind === "issue" ? (
557 <Link to={`/${output.repo}/issues/${output.number}`} className="flex items-start gap-2 rounded-md px-2 py-1.5 hover:bg-raised/60">
558 <CircleDot size={14} className="mt-0.5 shrink-0 text-success" />
559 <span className="min-w-0">
560 <span className="block truncate">{output.title}</span>
561 <span className="block font-mono text-[0.6875rem] text-faint">
562 {output.repo}#{output.number}
563 </span>
564 </span>
565 </Link>
566 ) : output.kind === "session" ? (
567 <Link to={`/${slug}/-/agents/${output.agent_handle}/sessions/${output.id}`} className="flex items-start gap-2 rounded-md px-2 py-1.5 hover:bg-raised/60">
568 <GitBranch size={14} className="mt-0.5 shrink-0 text-merged" />
569 <span className="min-w-0">
570 <span className="block truncate">{output.title}</span>
571 <span className="block text-[0.6875rem] text-faint">Session for @{output.agent_handle}</span>
572 </span>
573 </Link>
574 ) : (
575 <Link to={`/${slug}/-/agents/${session.agent_handle}/memory`} className="flex items-start gap-2 rounded-md px-2 py-1.5 hover:bg-raised/60">
576 <BookmarkPlus size={14} className="mt-0.5 shrink-0 text-accent" />
577 <span className="min-w-0">
578 <span className="line-clamp-2">{output.body}</span>
579 <span className="block text-[0.6875rem] text-faint">Kept in memory</span>
580 </span>
581 </Link>
582 )}
583 </li>
584 ))}
585 </ul>
586 )}
587 </section>
588 );
589}