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