| 1 | import { Link, data, useOutletContext, useSearchParams } from "react-router"; |
| 2 | |
| 3 | import type { AgentSession, WorkspaceAgent } from "@g1t/contracts"; |
| 4 | |
| 5 | import type { Route } from "./+types/sessions"; |
| 6 | import { MessageAgent } from "./agent"; |
| 7 | import { AgentAvatar } from "../../../components/agent-avatar"; |
| 8 | import { readOrNull } from "../../../components/agents/actions.server"; |
| 9 | import { isLive, sessionRows } from "../../../components/agents/format"; |
| 10 | import { Quiet, SessionRow } from "../../../components/agents/parts"; |
| 11 | import { isOrchestrator } from "../../../components/orchestrator"; |
| 12 | import { cn } from "../../../lib/cn"; |
| 13 | import { useRefreshWhile } from "../../../lib/refresh"; |
| 14 | import { workspaceAgents } from "../../../lib/services.server"; |
| 15 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 16 | |
| 17 | type Filter = "all" | "live" | "done"; |
| 18 | |
| 19 | /** The agent's sessions, newest first; `?status=live` or `done` narrows them. */ |
| 20 | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ sessions: AgentSession[] | null; filter: Filter }> { |
| 21 | const viewer = requireUser(context, request); |
| 22 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); |
| 23 | const asked = new URL(request.url).searchParams.get("status"); |
| 24 | const filter: Filter = asked === "live" || asked === "done" ? asked : "all"; |
| 25 | const sessions = await readOrNull( |
| 26 | workspaceAgents.sessions(params.owner.toLowerCase(), viewer, { handle: params.handle.toLowerCase(), status: filter === "all" ? null : filter, limit: 100 }), |
| 27 | ); |
| 28 | return { sessions, filter }; |
| 29 | } |
| 30 | |
| 31 | /** An empty list, in the agent's own voice. */ |
| 32 | const EMPTY: Record<string, string> = { |
| 33 | crisp: "No sessions yet. Ask me for something that takes real work and I'll start one.", |
| 34 | friendly: "Nothing on my plate yet! Ask me for something bigger than a quick answer and I'll get going.", |
| 35 | socratic: "Nothing here yet. What would you like me to dig into first?", |
| 36 | terse: "No sessions. Send work.", |
| 37 | }; |
| 38 | |
| 39 | /** |
| 40 | * Every session the agent is working on or has worked on: live first, then |
| 41 | * finished, each child under the session that started it. |
| 42 | */ |
| 43 | export default function Sessions({ loaderData, params }: Route.ComponentProps) { |
| 44 | const agent = useOutletContext<WorkspaceAgent>(); |
| 45 | const { sessions, filter } = loaderData; |
| 46 | const [search] = useSearchParams(); |
| 47 | const live = (sessions ?? []).filter((s) => isLive(s.status)); |
| 48 | const done = (sessions ?? []).filter((s) => !isLive(s.status)); |
| 49 | useRefreshWhile(live.length > 0); |
| 50 | const tab = (value: Filter, label: string) => { |
| 51 | const next = new URLSearchParams(search); |
| 52 | if (value === "all") next.delete("status"); |
| 53 | else next.set("status", value); |
| 54 | const query = next.toString(); |
| 55 | return ( |
| 56 | <Link |
| 57 | key={value} |
| 58 | to={query ? `?${query}` : "."} |
| 59 | preventScrollReset |
| 60 | aria-current={filter === value ? "true" : undefined} |
| 61 | className={cn("rounded px-2.5 py-1 text-[0.8125rem]", filter === value ? "bg-raised text-fg" : "text-muted hover:text-fg")} |
| 62 | > |
| 63 | {label} |
| 64 | </Link> |
| 65 | ); |
| 66 | }; |
| 67 | if (!sessions) { |
| 68 | return <Quiet title="Sessions can't be shown right now">The agents service didn't answer. Reload in a moment.</Quiet>; |
| 69 | } |
| 70 | return ( |
| 71 | <div className="space-y-6"> |
| 72 | <div className="flex flex-wrap items-center justify-between gap-3"> |
| 73 | <p className="max-w-xl text-sm text-muted"> |
| 74 | A conversation with {agent.display_name} stays quick. When a request needs real work, it spins off a session with its own budget and a live card in the |
| 75 | conversation. Sessions can bring in colleagues or subagents; all of it is paid from the session that started it. |
| 76 | </p> |
| 77 | <nav aria-label="Which sessions" className="flex rounded-md border border-line p-0.5"> |
| 78 | {tab("all", "All")} |
| 79 | {tab("live", "Live")} |
| 80 | {tab("done", "Done")} |
| 81 | </nav> |
| 82 | </div> |
| 83 | |
| 84 | {sessions.length === 0 ? ( |
| 85 | filter === "all" ? ( |
| 86 | <div className="rounded-2xl border border-dashed border-line bg-surface/40 px-6 py-14 text-center"> |
| 87 | <span className="mx-auto flex justify-center"> |
| 88 | <AgentAvatar agent={{ ...agent, builtin: isOrchestrator(agent) }} size={52} /> |
| 89 | </span> |
| 90 | <h2 className="mt-4 text-base font-semibold">No sessions yet</h2> |
| 91 | <blockquote className="mx-auto mt-3 max-w-md text-[0.9375rem] text-fg-soft"> |
| 92 | “{EMPTY[agent.personality_preset] ?? EMPTY.crisp}” |
| 93 | <footer className="mt-1 text-xs text-faint">{agent.display_name}</footer> |
| 94 | </blockquote> |
| 95 | <div className="mt-6 flex justify-center"> |
| 96 | <MessageAgent slug={params.owner} agent={agent} variant="accent" /> |
| 97 | </div> |
| 98 | <p className="mt-4 text-xs text-faint"> |
| 99 | Works up to {agent.capacity} {agent.capacity === 1 ? "session" : "sessions"} at once; more wait in line. |
| 100 | </p> |
| 101 | </div> |
| 102 | ) : ( |
| 103 | <Quiet title={filter === "live" ? "Nothing live" : "Nothing finished yet"}> |
| 104 | {filter === "live" ? `${agent.display_name} isn't working on a session right now.` : "Finished sessions stay here with their transcripts and reports."} |
| 105 | </Quiet> |
| 106 | ) |
| 107 | ) : ( |
| 108 | <> |
| 109 | {live.length > 0 && <SessionGroup title="Live" slug={params.owner} sessions={live} />} |
| 110 | {done.length > 0 && <SessionGroup title={filter === "done" ? "Done" : "Past"} slug={params.owner} sessions={done} />} |
| 111 | </> |
| 112 | )} |
| 113 | </div> |
| 114 | ); |
| 115 | } |
| 116 | |
| 117 | function SessionGroup({ title, slug, sessions }: { title: string; slug: string; sessions: AgentSession[] }) { |
| 118 | return ( |
| 119 | <section> |
| 120 | <h2 className="text-sm font-medium"> |
| 121 | {title} <span className="text-faint">{sessions.length}</span> |
| 122 | </h2> |
| 123 | <ul className="mt-3 divide-y divide-line/60 overflow-hidden rounded-xl border border-line bg-surface"> |
| 124 | {sessionRows(sessions).map(({ session, depth }) => ( |
| 125 | <SessionRow key={session.id} slug={slug} session={session} depth={depth} /> |
| 126 | ))} |
| 127 | </ul> |
| 128 | </section> |
| 129 | ); |
| 130 | } |