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 versions | 1 | import { |
| 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"; | |
| 19 | import { type ReactNode, useEffect, useRef } from "react"; | |
| 20 | import { Link, data, redirect, useFetcher } from "react-router"; | |
| 21 | ||
| 22 | import type { AgentSession, AgentSessionDetail, SessionEvent } from "@g1t/contracts"; | |
| 23 | ||
| 24 | import type { Route } from "./+types/session"; | |
| 25 | import { AgentAvatar } from "../../../components/agent-avatar"; | |
| 26 | import { agentsAction, answer } from "../../../components/agents/actions.server"; | |
| 27 | import { type ActionResult, ApproveDialog, BUTTONS, Confirm } from "../../../components/agents/dialogs"; | |
| 28 | import { isLive, kindLabel, sessionRows, whereLabel } from "../../../components/agents/format"; | |
| 29 | import { KindBadge, Meter, PrivateTitle, SpendOfCap, StatusChip, sessionHref, stepsLine } from "../../../components/agents/parts"; | |
| 30 | import { Markdown } from "../../../components/markdown"; | |
| 31 | import { TimeAgo } from "../../../components/ui"; | |
| 32 | import { Hint } from "../../../components/ui/hint"; | |
| 33 | import { Textarea } from "../../../components/ui/textarea"; | |
| 34 | import { microsFromDollars } from "../../../lib/agent-form"; | |
| 35 | import { channelPath } from "../../../lib/chat"; | |
| 36 | import { cn } from "../../../lib/cn"; | |
| 37 | import { page } from "../../../lib/meta"; | |
| 38 | import { useRefreshWhile } from "../../../lib/refresh"; | |
| 39 | import { workspaceAgents } from "../../../lib/services.server"; | |
| 40 | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 41 | ||
| 42 | export 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. */ | |
| 49 | export 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. */ | |
| 63 | export 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 | ||
| 80 | export 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't be shown right now</p> | |
| 97 | <p className="mt-1.5 text-sm text-muted">The agents service didn'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'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 | ||
| 133 | function 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 | ||
| 223 | function 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 | ||
| 232 | const 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. */ | |
| 240 | function 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 | ||
| 263 | function 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 | ||
| 267 | function 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> | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 282 | <p className="mt-1 text-sm whitespace-pre-wrap wrap-break-word text-fg">{event.body}</p> |
| Agents mode: an overview of every agent's work and spend, and each agent's sessions, memory, routines, spend, activity and versions | 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> | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 297 | <p className="mt-0.5 text-sm whitespace-pre-wrap wrap-break-word text-fg-soft">{event.body}</p> |
| Agents mode: an overview of every agent's work and spend, and each agent's sessions, memory, routines, spend, activity and versions | 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> | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 344 | <p className="mt-1 text-sm whitespace-pre-wrap wrap-break-word text-fg">{event.body}</p> |
| Agents mode: an overview of every agent's work and spend, and each agent's sessions, memory, routines, spend, activity and versions | 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> | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 356 | <p className="mt-0.5 text-sm whitespace-pre-wrap wrap-break-word text-fg-soft">{event.body}</p> |
| Agents mode: an overview of every agent's work and spend, and each agent's sessions, memory, routines, spend, activity and versions | 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> | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 370 | <p className="mt-0.5 text-sm whitespace-pre-wrap wrap-break-word text-fg-soft">{event.body}</p> |
| Agents mode: an overview of every agent's work and spend, and each agent's sessions, memory, routines, spend, activity and versions | 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. */ | |
| 401 | function 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. */ | |
| 420 | function 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. */ | |
| 462 | function 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'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. */ | |
| 510 | function 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 | } |