| 1 | import { CornerDownRight, Lock } from "lucide-react"; |
| 2 | import type { ReactNode } from "react"; |
| 3 | import { Link } from "react-router"; |
| 4 | |
| 5 | import type { AgentSession, SpendSlice } from "@g1t/contracts"; |
| 6 | |
| 7 | import { AgentAvatar } from "../agent-avatar"; |
| 8 | import { Badge } from "../ui/badge"; |
| 9 | import { Hint } from "../ui/hint"; |
| 10 | import { TimeAgo } from "../ui"; |
| 11 | import { cn } from "../../lib/cn"; |
| 12 | import { money } from "../../lib/usage"; |
| 13 | import { kindLabel, meterTone, monthDays, sessionStatus, shareOf, shortDay, topSlices, whereLabel } from "./format"; |
| 14 | |
| 15 | /** Where a session's page is. */ |
| 16 | export 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. */ |
| 21 | export 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. */ |
| 37 | export 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 | |
| 46 | const 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. */ |
| 49 | export 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. */ |
| 67 | export 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. */ |
| 77 | export 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". */ |
| 86 | export 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 | */ |
| 95 | export 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. */ |
| 151 | export 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. */ |
| 184 | export 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 | */ |
| 200 | export 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 | */ |
| 250 | export 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. */ |
| 287 | export 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 | } |