Skip to content
462 linesCodeBlameRaw
1import { AlertTriangle, Archive, CalendarClock, Coins, Hourglass, Lock, Wallet } from "lucide-react";
2import { Link, data, useFetcher } from "react-router";
3
4import type { AgentPolicy, AgentsOverview, ArchivedAgent, WorkspaceAgent } from "@g1t/contracts";
5
6import type { Route } from "./+types/agents";
7import { AgentFace, useAgentsData } from "../../components/agents-mode";
8import { SwitchCard } from "../../components/ui/switch";
9import { agentsAction, answer, readOrNull } from "../../components/agents/actions.server";
10import { ArchivedSection } from "../../components/agents/archived";
11import { ApproveDialog, BudgetDialog } from "../../components/agents/dialogs";
12import { Alert } from "../../components/ui/alert";
13import { meterTone, monthName, scheduleInWords, shareOf, untilLabel } from "../../components/agents/format";
14import { DailyBars, Meter, Panel, Quiet, SessionCard, SessionRow, SliceList, SpendOfCap } from "../../components/agents/parts";
15import { StatusDot, statusLabel } from "../../components/chat/marks";
16import { isOrchestrator } from "../../components/orchestrator";
17import { Badge } from "../../components/ui/badge";
18import { Button } from "../../components/ui/button";
19import { Card } from "../../components/ui/card";
20import { microsFromDollars } from "../../lib/agent-form";
21import { page } from "../../lib/meta";
22import { useRefreshWhile } from "../../lib/refresh";
23import { workspaceAgents } from "../../lib/services.server";
24import { requireUser, roleIn } from "../../lib/session.server";
25import { money } from "../../lib/money";
26
27export function meta({ params, ...args }: Route.MetaArgs) {
28 return page(args, { title: `Agents · ${params.owner} · g1t` });
29}
30
31/**
32 * Agents mode's front page: the budget, what is waiting and working, the
33 * roster, where the month went, and the archived agents, in the same round
34 * of calls. `?archived=<handle>` is the way back from archiving one: the
35 * page says where it went.
36 */
37export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ overview: AgentsOverview | null; archived: ArchivedAgent[]; justArchived: string | null }> {
38 // Signed out, sign in first (as Chat and Docs do), rather than a 404.
39 const viewer = requireUser(context, request);
40 if (!viewer || !roleIn(viewer, params.owner)) throw data(null, { status: 404 });
41 const slug = params.owner.toLowerCase();
42 const [overview, archived] = await Promise.all([readOrNull(workspaceAgents.overview(slug, viewer)), readOrNull(workspaceAgents.listArchived(slug, viewer))]);
43 const justArchived = new URL(request.url).searchParams.get("archived");
44 return { overview, archived: archived ?? [], justArchived: justArchived && /^[a-z0-9-]{1,40}$/.test(justArchived) ? justArchived : null };
45}
46
47/** Owners set the workspace's agent budget; archived agents are restored or deleted for good from here. */
48export async function action({ params, context, request }: Route.ActionArgs) {
49 const { viewer, slug, form } = await agentsAction(request, context, params.owner);
50 const intent = String(form.get("intent") ?? "");
51 // Whether members create personal agents: on unless an owner turns it off.
52 if (intent === "personal_agents") {
53 return answer("personal_agents", workspaceAgents.setPolicy(slug, viewer, { members_create_agents: form.get("members_create_agents") === "on" }));
54 }
55 // The Archived section (components/agents/archived.tsx): back to life, under its handle or a new one; or gone for good, with its handle typed back.
56 if (intent === "restore") {
57 const newHandle = String(form.get("new_handle") ?? "").trim();
58 return answer("restore", workspaceAgents.restore(slug, String(form.get("handle") ?? ""), viewer, { id: String(form.get("id") ?? "") || null, handle: newHandle || null }));
59 }
60 if (intent === "destroy") {
61 return answer("destroy", workspaceAgents.destroy(slug, String(form.get("handle") ?? ""), viewer, String(form.get("confirm") ?? ""), { id: String(form.get("id") ?? "") || null }));
62 }
63 if (form.get("intent") !== "policy") return { ok: false as const, error: "Unknown request." };
64 const monthly = microsFromDollars(form.get("monthly"));
65 const agent = microsFromDollars(form.get("default_agent"));
66 const session = microsFromDollars(form.get("default_session"));
67 if ([monthly, agent, session].some((v) => v != null && Number.isNaN(v))) return { ok: false as const, error: "Write each amount in dollars, such as 250 or 2.50." };
68 const changes: Partial<AgentPolicy> = { monthly_micros: monthly, default_agent_monthly_micros: agent };
69 if (session != null) changes.default_session_micros = session;
70 return answer("policy", workspaceAgents.setPolicy(slug, viewer, changes));
71}
72
73export default function AgentsOverviewPage({ loaderData, params }: Route.ComponentProps) {
74 const { overview, archived, justArchived } = loaderData;
75 const slug = params.owner;
76 useRefreshWhile(Boolean(overview && (overview.live.length > 0 || overview.waiting_on_you.length > 0)));
77 if (!overview) {
78 return (
79 <Quiet title="Agents can't be shown right now">
80 The agents service didn&apos;t answer. It&apos;s usually back within a minute; reload to try again.
81 </Quiet>
82 );
83 }
84 const liveCount = Object.values(overview.live_by_agent).reduce((n, c) => n + c, 0);
85 const handles = new Map(overview.agents.map((agent) => [agent.id, agent]));
86 return (
87 <div className="space-y-10">
88 {/* The way back from archiving: the agent is under Archived, below, not gone. */}
89 {justArchived && archived.some((agent) => agent.handle === justArchived) && (
90 <Alert tone="info" role="status" className="flex items-center gap-2.5 px-3.5 py-2.5">
91 <Archive size={15} className="shrink-0 text-muted" />
92 <span>
93 Archived <span className="font-medium">@{justArchived}</span>. It no longer answers; its conversations stay readable.{" "}
94 <Link to={`/${slug}/-/agents#archived`} className="font-medium underline-offset-4 hover:underline">
95 Restore or delete it under Archived
96 </Link>
97 .
98 </span>
99 </Alert>
100 )}
101 <BudgetHeader overview={overview} slug={slug} liveCount={liveCount} />
102
103 {overview.waiting_on_you.length > 0 && (
104 <section aria-labelledby="waiting">
105 <h2 id="waiting" className="flex items-center gap-2 text-sm font-medium">
106 <Hourglass size={15} className="text-warn" />
107 Waiting on you
108 <span className="text-faint">{overview.waiting_on_you.length}</span>
109 </h2>
110 <p className="mt-1 text-sm text-muted">Sessions that reached their spend cap. They stay paused until someone who may raise the cap says so.</p>
111 <ul className="mt-3 grid gap-3 sm:grid-cols-2">
112 {overview.waiting_on_you.map((session) => (
113 <SessionCard
114 key={session.id}
115 slug={slug}
116 session={session}
117 action={
118 overview.can_manage ? (
119 <ApproveDialog
120 slug={slug}
121 session={session}
122 trigger={
123 <Button type="button" variant="accent" size="sm" className="w-full">
124 Approve more…
125 </Button>
126 }
127 />
128 ) : (
129 <p className="text-xs text-faint">An owner approves more spend.</p>
130 )
131 }
132 />
133 ))}
134 </ul>
135 </section>
136 )}
137
138 <section aria-labelledby="working">
139 <h2 id="working" className="text-sm font-medium">
140 Working now {liveCount > 0 && <span className="text-faint">{liveCount}</span>}
141 </h2>
142 {overview.live.length === 0 ? (
143 <div className="mt-3">
144 <Quiet title={liveCount > 0 ? "Working in conversations you're not in" : "No sessions right now"}>
145 {liveCount > 0
146 ? "Agents are working on sessions started in private channels or direct messages. What they cost still shows below."
147 : "When a request needs real work, an agent spins off a session for it, with its own budget and a live card in the conversation."}
148 </Quiet>
149 </div>
150 ) : (
151 <ul className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
152 {overview.live.map((session) => (
153 <SessionCard key={session.id} slug={slug} session={session} />
154 ))}
155 </ul>
156 )}
157 </section>
158
159 {/* The workspace's agents; the viewer's own personal ones apart, under Yours. */}
160 <Roster slug={slug} agents={overview.agents.filter((agent) => agent.scope !== "personal")} live={overview.live_by_agent} />
161 {overview.agents.some((agent) => agent.scope === "personal") && (
162 <Roster slug={slug} title="Yours" agents={overview.agents.filter((agent) => agent.scope === "personal")} live={overview.live_by_agent} />
163 )}
164
165 {overview.can_manage && <PersonalAgents slug={slug} on={overview.policy.members_create_agents} />}
166
167 <SpendSection overview={overview} slug={slug} handles={handles} />
168
169 <div className="grid gap-6 lg:grid-cols-2">
170 <Panel title="Upcoming routines" aside={overview.upcoming.length > 0 ? "UTC" : undefined}>
171 {overview.upcoming.length === 0 ? (
172 <p className="px-4 py-6 text-center text-sm text-faint">No routines scheduled. Set one up on an agent&apos;s Routines tab.</p>
173 ) : (
174 <ul className="divide-y divide-line/60">
175 {overview.upcoming.map((routine) => (
176 <li key={routine.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
177 <CalendarClock size={15} className="shrink-0 text-faint" />
178 <div className="min-w-0 grow">
179 <Link to={`/${slug}/-/agents/${routine.agent_handle}/routines`} className="block truncate font-medium hover:underline">
180 {routine.name}
181 </Link>
182 <p className="truncate text-xs text-faint">
183 {routine.agent_name}
184 {routine.schedule ? ` · ${scheduleInWords(routine.schedule)}` : ""}
185 {routine.channel_name ? ` · #${routine.channel_name}` : ""}
186 </p>
187 </div>
188 {routine.next_run_at && (
189 <span className="shrink-0 text-xs text-muted tabular-nums" suppressHydrationWarning>
190 {untilLabel(routine.next_run_at)}
191 </span>
192 )}
193 </li>
194 ))}
195 </ul>
196 )}
197 </Panel>
198 <Panel title="Recently finished">
199 {overview.recent.length === 0 ? (
200 <p className="px-4 py-6 text-center text-sm text-faint">Finished sessions you can see show here, with what they found.</p>
201 ) : (
202 <ul className="divide-y divide-line/60">
203 {overview.recent.map((session) => (
204 <SessionRow key={session.id} slug={slug} session={session} showAgent />
205 ))}
206 </ul>
207 )}
208 </Panel>
209 </div>
210
211 {/* Archived agents, folded: owners see the workspace's, a member their own archived personal agents. */}
212 <ArchivedSection slug={slug} agents={archived} owner={overview.can_manage} />
213 </div>
214 );
215}
216
217/** The month's spend by every agent against the workspace's agent budget, with the alert and, for owners, the way to change it. */
218function BudgetHeader({ overview, slug, liveCount }: { overview: AgentsOverview; slug: string; liveCount: number }) {
219 const { policy, spent_month_micros: spent, alert } = overview;
220 const share = shareOf(spent, policy.monthly_micros);
221 const tone = meterTone(share);
222 return (
223 <section className="rounded-2xl border border-line bg-surface p-5 sm:p-6">
224 {alert != null && (
225 <div
226 role="status"
227 className={`mb-5 flex items-start gap-2.5 rounded-lg border px-3.5 py-2.5 text-sm ${
228 alert >= 100 ? "border-danger/40 bg-danger/10 text-danger" : "border-warn/40 bg-warn/10 text-warn"
229 }`}
230 >
231 <AlertTriangle size={16} className="mt-0.5 shrink-0" />
232 <span>
233 {alert >= 100
234 ? "Agents have used this month's budget. They take no new work until the 1st, or until an owner raises it."
235 : `Agents have used ${alert}% of this month's budget.`}{" "}
236 {overview.can_manage ? "" : "An owner can raise it."}
237 </span>
238 </div>
239 )}
240 <div className="flex flex-wrap items-start justify-between gap-4">
241 <div>
242 <p className="flex items-center gap-1.5 text-sm text-muted">
243 <Wallet size={14} />
244 Every agent, {monthName(overview.spend.period)}
245 </p>
246 <p className="mt-1 flex flex-wrap items-baseline gap-x-2">
247 <span className="text-3xl font-semibold tracking-tight tabular-nums">{money(spent)}</span>
248 <span className="text-sm text-muted">{policy.monthly_micros != null ? `of ${money(policy.monthly_micros)}` : "No agent budget: the workspace's spend limit applies"}</span>
249 </p>
250 </div>
251 {overview.can_manage && (
252 <BudgetDialog
253 policy={policy}
254 action={`/${slug}/-/agents?index`}
255 trigger={
256 <Button type="button" variant="outline">
257 <Coins size={15} />
258 Budget
259 </Button>
260 }
261 />
262 )}
263 </div>
264 {policy.monthly_micros != null && (
265 <>
266 <Meter spent={spent} cap={policy.monthly_micros} label="Spent of the agents' monthly budget" className="mt-4" />
267 <p className="mt-2 text-xs text-faint">
268 {Math.round((share ?? 0) * 100)}% used{tone === "accent" ? "" : tone === "warn" ? ", past the first alert" : ", at the limit"}. Every session, routine and helper rolls up here, then into the workspace&apos;s bill.
269 </p>
270 </>
271 )}
272 <dl className="mt-5 grid grid-cols-2 gap-4 border-t border-line pt-4 sm:grid-cols-4">
273 <Stat label="Working now" value={String(liveCount)} />
274 <Stat label="Waiting on approval" value={String(overview.waiting_on_you.length)} />
275 <Stat label="Session cap" value={money(policy.default_session_micros)} />
276 <Stat label="New agent budget" value={policy.default_agent_monthly_micros != null ? money(policy.default_agent_monthly_micros) : "None"} />
277 </dl>
278 </section>
279 );
280}
281
282function Stat({ label, value }: { label: string; value: string }) {
283 return (
284 <div>
285 <dt className="text-xs text-faint">{label}</dt>
286 <dd className="mt-0.5 text-sm font-medium tabular-nums">{value}</dd>
287 </div>
288 );
289}
290
291/** Every agent, g1t first: its status, what it is working on and its month against its own budget. */
292function Roster({ slug, agents, live, title = "Agents" }: { slug: string; agents: WorkspaceAgent[]; live: Record<string, number>; title?: string }) {
293 const sorted = [...agents].sort((a, b) => Number(isOrchestrator(b)) - Number(isOrchestrator(a)));
294 const id = `roster-${title.toLowerCase()}`;
295 return (
296 <section aria-labelledby={id}>
297 <div className="flex items-baseline justify-between gap-3">
298 <h2 id={id} className="text-sm font-medium">
299 {title} <span className="text-faint">{agents.length}</span>
300 </h2>
301 {title === "Agents" ? (
302 <Link to={`/${slug}/-/agents/templates`} className="text-xs text-muted hover:text-fg">
303 Start from a template
304 </Link>
305 ) : (
306 <span className="text-xs text-faint">Personal: only you talk to them</span>
307 )}
308 </div>
309 <Card asChild divided className="mt-3 overflow-hidden">
310 <ul>
311 {sorted.map((agent) => {
312 const count = live[agent.id] ?? 0;
313 const cap = agent.budget.monthly_micros;
314 return (
315 <li key={agent.id} className="relative flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised/40">
316 <span className="relative shrink-0">
317 <AgentFace agent={{ ...agent, builtin: isOrchestrator(agent) }} size={32} />
318 <StatusDot status={agent.status} className="absolute -right-0.5 -bottom-0.5 ring-2 ring-surface" />
319 </span>
320 <div className="min-w-0 grow">
321 <p className="flex items-center gap-2 text-sm">
322 <Link to={`/${slug}/-/agents/${agent.handle}`} className="truncate font-medium after:absolute after:inset-0 hover:underline">
323 {agent.display_name}
324 </Link>
325 {count > 0 && (
326 <Badge tone="accent">
327 {count} {count === 1 ? "session" : "sessions"}
328 </Badge>
329 )}
330 </p>
331 <p className="truncate text-xs text-faint">
332 {isOrchestrator(agent) ? "Orchestrator" : agent.title || agent.role}
333 {agent.status !== "idle" && ` · ${statusLabel(agent.status)}`}
334 </p>
335 </div>
336 <div className="w-28 shrink-0 text-right sm:w-40">
337 <p className="text-xs">
338 <SpendOfCap spent={agent.spent_month_micros} cap={cap} />
339 </p>
340 {cap != null && <Meter spent={agent.spent_month_micros} cap={cap} label={`${agent.display_name}'s spend of its monthly budget`} size="sm" className="mt-1.5" />}
341 </div>
342 </li>
343 );
344 })}
345 </ul>
346 </Card>
347 </section>
348 );
349}
350
351/** Where the month went: by day, by agent, by team, by kind of work and by who asked. */
352function SpendSection({ overview, slug, handles }: { overview: AgentsOverview; slug: string; handles: Map<string, WorkspaceAgent> }) {
353 const { spend } = overview;
354 return (
355 <section aria-labelledby="spend" className="space-y-4">
356 <div className="flex items-baseline justify-between gap-3">
357 <h2 id="spend" className="text-sm font-medium">
358 Spend this month
359 </h2>
360 <Link to={`/${slug}/-/usage`} className="text-xs text-muted hover:text-fg">
361 On the workspace&apos;s usage
362 </Link>
363 </div>
364 <Panel title={<span className="tabular-nums">{money(spend.total_micros)}</span>} aside="By day, UTC">
365 <DailyBars period={spend.period} days={spend.days} className="px-4 pt-4 pb-3" />
366 </Panel>
367 <div className="grid gap-4 lg:grid-cols-2">
368 <Panel title="By agent">
369 <SliceList
370 slices={spend.by_agent}
371 unit=""
372 icon={(slice) => {
373 const agent = handles.get(slice.key);
374 return agent ? <AgentFace agent={{ ...agent, builtin: isOrchestrator(agent) }} size={18} /> : null;
375 }}
376 href={(slice) => {
377 const agent = handles.get(slice.key);
378 return agent ? `/${slug}/-/agents/${agent.handle}/spend` : null;
379 }}
380 />
381 </Panel>
382 <Panel title="By team">
383 <SliceList slices={spend.by_team} />
384 </Panel>
385 <Panel title="By kind of work">
386 <SliceList slices={spend.by_kind} unit="runs" />
387 </Panel>
388 <Panel title="By who asked" aside="Routines and agent-to-agent work have no asker">
389 <SliceList slices={spend.by_person} unit="runs" />
390 </Panel>
391 </div>
392 {spend.top_sessions.length > 0 && (
393 <Panel title="Costliest sessions">
394 <ul className="divide-y divide-line/60">
395 {spend.top_sessions.map((session) => (
396 <SessionRow key={session.id} slug={slug} session={session} showAgent />
397 ))}
398 </ul>
399 </Panel>
400 )}
401 </section>
402 );
403}
404
405/**
406 * For owners: whether members create personal agents (on unless turned
407 * off), and the personal agents members have, to promote or archive.
408 */
409function PersonalAgents({ slug, on }: { slug: string; on: boolean }) {
410 const fetcher = useFetcher<{ ok: boolean; error?: string }>({ key: "personal-agents" });
411 const layout = useAgentsData();
412 const pending = fetcher.formData ? fetcher.formData.get("members_create_agents") === "on" : null;
413 const checked = pending ?? on;
414 const theirs = (layout?.agents ?? []).filter((agent) => agent.scope === "personal" && agent.personal_owner_id !== layout?.viewer_id);
415 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
416 return (
417 <section aria-labelledby="personal-agents">
418 <h2 id="personal-agents" className="flex items-center gap-2 text-sm font-medium">
419 <Lock size={14} className="text-faint" />
420 Personal agents
421 </h2>
422 <p className="mt-1 max-w-2xl text-sm text-muted">
423 A member&apos;s own agent: only they talk to it, in their direct message with it, and it spends from their budget. You keep the workspace&apos;s agents, and can promote a personal one to a workspace agent from its profile.
424 </p>
425 <fetcher.Form method="post" action={`/${slug}/-/agents?index`} className="mt-3 max-w-2xl">
426 <input type="hidden" name="intent" value="personal_agents" />
427 <SwitchCard
428 title="Members can create personal agents"
429 name="members_create_agents"
430 checked={checked}
431 onCheckedChange={(value) => fetcher.submit({ intent: "personal_agents", ...(value ? { members_create_agents: "on" } : {}) }, { method: "post", action: `/${slug}/-/agents?index` })}
432 >
433 {checked
434 ? "On: anyone in the workspace can describe an agent and make it theirs. Owners always can."
435 : "Off: only owners create agents. Personal agents members already have keep working."}
436 </SwitchCard>
437 {error && <p className="mt-2 text-sm text-danger">{error}</p>}
438 </fetcher.Form>
439 {theirs.length > 0 && (
440 <ul className="mt-4 grid max-w-2xl gap-2 sm:grid-cols-2">
441 {theirs.map((agent) => (
442 <li key={agent.id}>
443 <Link
444 to={`/${slug}/-/agents/${agent.handle}/profile`}
445 className="flex items-center gap-3 rounded-xl border border-line bg-surface px-3 py-2.5 transition-colors hover:border-line-strong"
446 >
447 <AgentFace agent={agent} size={28} />
448 <span className="min-w-0 grow leading-tight">
449 <span className="block truncate text-sm font-medium">{agent.display_name}</span>
450 <span className="block truncate text-xs text-faint">
451 @{agent.personal_owner}&apos;s · {agent.title || agent.role}
452 </span>
453 </span>
454 <span className="shrink-0 text-xs text-muted">Promote…</span>
455 </Link>
456 </li>
457 ))}
458 </ul>
459 )}
460 </section>
461 );
462}