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