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