Skip to content
130 linesCodeBlameRaw
1import { Link, data, useOutletContext, useSearchParams } from "react-router";
2
3import type { AgentSession, WorkspaceAgent } from "@g1t/contracts";
4
5import type { Route } from "./+types/sessions";
6import { MessageAgent } from "./agent";
7import { AgentAvatar } from "../../../components/agent-avatar";
8import { readOrNull } from "../../../components/agents/actions.server";
9import { isLive, sessionRows } from "../../../components/agents/format";
10import { Quiet, SessionRow } from "../../../components/agents/parts";
11import { isOrchestrator } from "../../../components/orchestrator";
12import { cn } from "../../../lib/cn";
13import { useRefreshWhile } from "../../../lib/refresh";
14import { workspaceAgents } from "../../../lib/services.server";
15import { requireUser, roleIn } from "../../../lib/session.server";
16
17type Filter = "all" | "live" | "done";
18
19/** The agent's sessions, newest first; `?status=live` or `done` narrows them. */
20export 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. */
32const 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 */
43export 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&apos;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 &ldquo;{EMPTY[agent.personality_preset] ?? EMPTY.crisp}&rdquo;
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
117function 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}