Skip to content
553 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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