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