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