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