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