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