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