Skip to content
295 linesCodeBlameRaw
1import { CornerDownRight, Lock } from "lucide-react";
2import type { ReactNode } from "react";
3import { Link } from "react-router";
4
5import type { AgentSession, SpendSlice } from "@g1t/contracts";
6
7import { AgentAvatar } from "../agent-avatar";
8import { Badge } from "../ui/badge";
9import { Hint } from "../ui/hint";
10import { TimeAgo } from "../ui";
11import { cn } from "../../lib/cn";
12import { money } from "../../lib/usage";
13import { kindLabel, meterTone, monthDays, sessionStatus, shareOf, shortDay, topSlices, whereLabel } from "./format";
14
15/** Where a session's page is. */
16export function sessionHref(slug: string, session: Pick<AgentSession, "id" | "agent_handle">): string {
17 return `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`;
18}
19
20/** A session's status as a chip; a soft pulse while it is moving. */
21export function StatusChip({ status, className }: { status: string; className?: string }) {
22 const chip = sessionStatus(status);
23 return (
24 <Badge tone={chip.tone} className={className}>
25 {chip.moving && (
26 <span className="relative flex size-1.5">
27 <span className="absolute inline-flex size-full animate-ping rounded-full bg-current opacity-60 motion-reduce:animate-none" />
28 <span className="relative inline-flex size-1.5 rounded-full bg-current" />
29 </span>
30 )}
31 {chip.label}
32 </Badge>
33 );
34}
35
36/** Where a session came from: Chat, Routine, Helper or Subagent. */
37export function KindBadge({ kind, subagent }: { kind: string; subagent?: string | null }) {
38 return (
39 <span className="inline-flex h-[1.125rem] shrink-0 items-center rounded-[5px] bg-raised px-1.5 text-[0.6875rem] font-medium text-muted ring-1 ring-line ring-inset">
40 {kindLabel(kind)}
41 {subagent ? <span className="ml-1 font-mono text-faint">{subagent}</span> : null}
42 </span>
43 );
44}
45
46const BAR: Record<"accent" | "warn" | "danger", string> = { accent: "bg-accent", warn: "bg-warn", danger: "bg-danger" };
47
48/** Spend against a cap as a thin bar; a plain track with no cap. */
49export function Meter({ spent, cap, label, className, size = "md" }: { spent: number; cap: number | null | undefined; label: string; className?: string; size?: "sm" | "md" }) {
50 const share = shareOf(spent, cap);
51 const width = share == null ? 0 : Math.min(100, Math.max(share * 100, spent > 0 ? 1.5 : 0));
52 return (
53 <div
54 className={cn("overflow-hidden rounded-full bg-line", size === "sm" ? "h-1" : "h-2", className)}
55 role="meter"
56 aria-label={label}
57 aria-valuemin={0}
58 aria-valuemax={cap ?? undefined}
59 aria-valuenow={spent}
60 >
61 {share != null && <div className={cn("h-full rounded-full transition-[width]", BAR[meterTone(share)])} style={{ width: `${width}%` }} />}
62 </div>
63 );
64}
65
66/** "$0.42 of $2.00", or just what it spent. */
67export function SpendOfCap({ spent, cap }: { spent: number; cap: number | null | undefined }) {
68 return (
69 <span className="tabular-nums">
70 {money(spent)}
71 {cap != null && <span className="text-faint"> of {money(cap)}</span>}
72 </span>
73 );
74}
75
76/** A session someone else's conversation holds: it ran and what it cost, nothing more. */
77export function PrivateTitle({ className }: { className?: string }) {
78 return (
79 <span className={cn("inline-flex items-center gap-1.5 text-muted italic", className)}>
80 <Lock size={12} className="shrink-0 not-italic" aria-hidden="true" />A private session
81 </span>
82 );
83}
84
85/** "12 steps · 30 tools". */
86export function stepsLine(session: Pick<AgentSession, "steps" | "tool_calls">): string {
87 return `${session.steps} ${session.steps === 1 ? "step" : "steps"} · ${session.tool_calls} ${session.tool_calls === 1 ? "tool" : "tools"}`;
88}
89
90/**
91 * One session as a list shows it: its title (or that it is private), kind,
92 * status, who asked and where, steps and spend against its cap, and when.
93 * `depth` indents a child under its parent.
94 */
95export function SessionRow({ slug, session, depth = 0, showAgent = false }: { slug: string; session: AgentSession; depth?: number; showAgent?: boolean }) {
96 const title = session.visible ? <span className="truncate font-medium text-fg">{session.title || "Untitled session"}</span> : <PrivateTitle />;
97 return (
98 <li className="group relative">
99 <div className="flex items-start gap-3 px-4 py-3 transition-colors group-hover:bg-raised/40" style={{ paddingLeft: `${1 + depth * 1.5}rem` }}>
100 {depth > 0 && <CornerDownRight size={14} className="mt-1 shrink-0 text-faint" aria-hidden="true" />}
101 {showAgent && (
102 <span className="mt-0.5 shrink-0">
103 <AgentAvatar agent={{ handle: session.agent_handle, avatar_seed: session.agent_avatar_seed }} size={22} />
104 </span>
105 )}
106 <div className="min-w-0 grow">
107 <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm">
108 {session.visible ? (
109 <Link to={sessionHref(slug, session)} className="min-w-0 truncate after:absolute after:inset-0 hover:underline">
110 {title}
111 </Link>
112 ) : (
113 title
114 )}
115 <KindBadge kind={session.kind} subagent={session.subagent} />
116 <StatusChip status={session.status} />
117 </div>
118 <p className="mt-1 flex flex-wrap items-center gap-x-1.5 text-xs text-faint">
119 {showAgent && <span className="text-muted">{session.agent_name}</span>}
120 {showAgent && <span aria-hidden="true">·</span>}
121 {session.visible && session.asked_by_username && (
122 <>
123 <span>asked by @{session.asked_by_username}</span>
124 <span aria-hidden="true">·</span>
125 </>
126 )}
127 {session.visible && (
128 <>
129 <span>{whereLabel(session)}</span>
130 <span aria-hidden="true">·</span>
131 </>
132 )}
133 <span>{stepsLine(session)}</span>
134 <span aria-hidden="true">·</span>
135 <TimeAgo at={session.created_at} />
136 </p>
137 {session.visible && session.status_note && session.status !== "done" && <p className="mt-1 text-xs text-muted">{session.status_note}</p>}
138 </div>
139 <div className="hidden w-32 shrink-0 text-right sm:block">
140 <p className="text-xs">
141 <SpendOfCap spent={session.charged_micros} cap={session.cap_micros} />
142 </p>
143 {session.cap_micros != null && <Meter spent={session.charged_micros} cap={session.cap_micros} label="Spent of its cap" size="sm" className="mt-1.5" />}
144 </div>
145 </div>
146 </li>
147 );
148}
149
150/** A live session as a card: the agent, what it is doing, how far and what it has cost. */
151export function SessionCard({ slug, session, action }: { slug: string; session: AgentSession; action?: ReactNode }) {
152 return (
153 <li className="relative flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
154 <div className="flex items-center gap-2.5">
155 <AgentAvatar agent={{ handle: session.agent_handle, avatar_seed: session.agent_avatar_seed }} size={28} />
156 <div className="min-w-0 grow leading-tight">
157 <p className="truncate text-sm font-medium">{session.agent_name}</p>
158 <p className="truncate text-xs text-faint">
159 {whereLabel(session)}
160 {session.asked_by_username ? ` · @${session.asked_by_username}` : ""}
161 </p>
162 </div>
163 <StatusChip status={session.status} />
164 </div>
165 <Link to={sessionHref(slug, session)} className="mt-3 line-clamp-2 text-[0.9375rem] font-medium text-fg after:absolute after:inset-0 hover:underline">
166 {session.visible ? session.title || "Untitled session" : "A private session"}
167 </Link>
168 {session.status_note && <p className="mt-1 line-clamp-2 text-xs text-muted">{session.status_note}</p>}
169 <div className="mt-auto pt-4">
170 <div className="flex items-center justify-between gap-3 text-xs text-muted">
171 <span>
172 {stepsLine(session)} · <TimeAgo at={session.created_at} />
173 </span>
174 <SpendOfCap spent={session.charged_micros} cap={session.cap_micros} />
175 </div>
176 {session.cap_micros != null && <Meter spent={session.charged_micros} cap={session.cap_micros} label="Spent of its cap" size="sm" className="mt-2" />}
177 {action && <div className="relative z-10 mt-3">{action}</div>}
178 </div>
179 </li>
180 );
181}
182
183/** A titled panel, as the overview and Spend lay them out. */
184export function Panel({ title, aside, children, className }: { title: ReactNode; aside?: ReactNode; children: ReactNode; className?: string }) {
185 return (
186 <section className={cn("rounded-xl border border-line bg-surface", className)}>
187 <header className="flex items-baseline justify-between gap-3 border-b border-line px-4 py-2.5">
188 <h3 className="text-sm font-medium">{title}</h3>
189 {aside && <span className="text-xs text-faint">{aside}</span>}
190 </header>
191 {children}
192 </section>
193 );
194}
195
196/**
197 * Where money went, ranked: each slice's name, a bar against the largest,
198 * its count and its spend. Past five, the rest fold into one line.
199 */
200export function SliceList({
201 slices,
202 unit = "",
203 empty = "Nothing spent yet this month.",
204 href,
205 icon,
206}: {
207 slices: SpendSlice[];
208 /** What `count` counts: "sessions"; empty to leave it out. */
209 unit?: string;
210 empty?: string;
211 href?: (slice: SpendSlice) => string | null;
212 icon?: (slice: SpendSlice) => ReactNode;
213}) {
214 const shown = topSlices(slices.filter((s) => s.micros > 0 || s.count > 0));
215 const max = Math.max(1, ...shown.map((s) => s.micros));
216 if (shown.length === 0) return <p className="px-4 py-6 text-center text-sm text-faint">{empty}</p>;
217 return (
218 <ul className="divide-y divide-line/60">
219 {shown.map((slice) => {
220 const to = slice.key !== "__rest" ? href?.(slice) : null;
221 const name = <span className="truncate">{slice.label}</span>;
222 return (
223 <li key={slice.key || "none"} className="px-4 py-2.5 text-sm">
224 <div className="flex items-center gap-2">
225 {slice.key !== "__rest" && icon?.(slice)}
226 {to ? (
227 <Link to={to} className="min-w-0 truncate hover:underline">
228 {name}
229 </Link>
230 ) : (
231 <span className={cn("min-w-0 truncate", slice.key === "__rest" && "text-muted")}>{name}</span>
232 )}
233 <span className="ml-auto shrink-0 text-xs text-faint tabular-nums">{unit && `${slice.count} ${unit}`}</span>
234 <span className="w-20 shrink-0 text-right tabular-nums">{money(slice.micros)}</span>
235 </div>
236 <div className="mt-1.5 h-1 overflow-hidden rounded-full bg-line/60" aria-hidden="true">
237 <div className="h-full rounded-full bg-accent/80" style={{ width: `${Math.max((slice.micros / max) * 100, slice.micros > 0 ? 1 : 0)}%` }} />
238 </div>
239 </li>
240 );
241 })}
242 </ul>
243 );
244}
245
246/**
247 * Spend by day this month as bars, one per day, each with its amount on
248 * hover or focus; a table for screen readers.
249 */
250export function DailyBars({ period, days, className }: { period: string; days: { day: string; micros: number }[]; className?: string }) {
251 const all = monthDays(period, days);
252 const max = Math.max(0, ...all.map((d) => d.micros));
253 return (
254 <div className={className}>
255 <div className="flex h-24 items-end gap-0.5" aria-hidden="true">
256 {all.map((d) => (
257 <Hint key={d.day} label={`${shortDay(d.day)} · ${money(d.micros)}`}>
258 <span className="flex h-full min-w-0 flex-1 items-end rounded-t-[3px] hover:bg-raised/60">
259 <span
260 className={cn("block w-full rounded-t-[3px]", d.micros > 0 ? "bg-accent/80" : "bg-line/70")}
261 style={{ height: max > 0 && d.micros > 0 ? `${Math.max(4, (d.micros / max) * 100)}%` : "2px" }}
262 />
263 </span>
264 </Hint>
265 ))}
266 </div>
267 <div className="mt-1.5 flex justify-between text-[0.6875rem] text-faint" aria-hidden="true">
268 <span>{all[0] ? shortDay(all[0].day) : ""}</span>
269 <span>{all.length > 1 ? shortDay(all[all.length - 1]!.day) : ""}</span>
270 </div>
271 <table className="sr-only">
272 <caption>Spend by day</caption>
273 <tbody>
274 {all.map((d) => (
275 <tr key={d.day}>
276 <th scope="row">{shortDay(d.day)}</th>
277 <td>{money(d.micros)}</td>
278 </tr>
279 ))}
280 </tbody>
281 </table>
282 </div>
283 );
284}
285
286/** A quiet empty state inside a panel or a tab. */
287export function Quiet({ title, children, icon }: { title: string; children?: ReactNode; icon?: ReactNode }) {
288 return (
289 <div className="rounded-xl border border-dashed border-line px-6 py-10 text-center">
290 {icon && <span className="mx-auto mb-3 flex size-10 items-center justify-center rounded-xl bg-raised text-muted">{icon}</span>}
291 <p className="text-sm font-medium">{title}</p>
292 {children && <div className="mx-auto mt-1 max-w-md text-sm text-muted">{children}</div>}
293 </div>
294 );
295}