| 1 | /** |
| 2 | * Effort and "spend less, keep quality": the control that sets how hard an |
| 3 | * agent works, with what each level has cost it, and the weekly |
| 4 | * suggestions with Apply and Dismiss. Every figure is the agents service's, |
| 5 | * measured from the agent's own sessions (lib/effort.ts). |
| 6 | */ |
| 7 | import { Check, Gauge, Lightbulb } from "lucide-react"; |
| 8 | import { useState } from "react"; |
| 9 | import { useFetcher } from "react-router"; |
| 10 | |
| 11 | import type { AgentEffort, AgentEffortCosts, AgentRecommendation, AgentRecommendations } from "@g1t/contracts"; |
| 12 | |
| 13 | import { AgentAvatar } from "./agent-avatar"; |
| 14 | import { BUTTONS } from "./agents/dialogs"; |
| 15 | import { Hint } from "./ui/hint"; |
| 16 | import { TimeAgo } from "./ui"; |
| 17 | import { cn } from "../lib/cn"; |
| 18 | import { EFFORT_OPTIONS, acceptedLabel, costAt, costLine, effortLabel, savingLabel, totalSaving } from "../lib/effort"; |
| 19 | import { money } from "../lib/usage"; |
| 20 | |
| 21 | /** |
| 22 | * The five settings as one segmented control, radio inputs underneath so |
| 23 | * it works inside any form. Each has a hint saying what it does; under it, |
| 24 | * what the chosen one has cost this agent. |
| 25 | */ |
| 26 | export function EffortPicker({ |
| 27 | name = "effort", |
| 28 | value, |
| 29 | costs, |
| 30 | disabled = false, |
| 31 | onChange, |
| 32 | }: { |
| 33 | name?: string; |
| 34 | value: AgentEffort; |
| 35 | costs: AgentEffortCosts | null | undefined; |
| 36 | disabled?: boolean; |
| 37 | onChange?: (effort: AgentEffort) => void; |
| 38 | }) { |
| 39 | const [chosen, setChosen] = useState<AgentEffort>(value); |
| 40 | return ( |
| 41 | <div className="min-w-0"> |
| 42 | <div role="radiogroup" aria-label="Effort" className="grid grid-cols-5 gap-0.5 rounded-lg border border-line bg-bg p-0.5 sm:inline-grid sm:w-auto sm:min-w-[22rem]"> |
| 43 | {EFFORT_OPTIONS.map((option) => { |
| 44 | const on = chosen === option.key; |
| 45 | return ( |
| 46 | <Hint key={option.key} label={option.about}> |
| 47 | <label |
| 48 | className={cn( |
| 49 | "relative flex h-8 cursor-pointer items-center justify-center rounded-md px-2 text-[0.8125rem] transition-colors select-none has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent", |
| 50 | on ? "bg-raised font-medium text-fg ring-1 ring-line-strong" : "text-muted hover:text-fg", |
| 51 | disabled && "cursor-not-allowed opacity-60", |
| 52 | )} |
| 53 | > |
| 54 | <input |
| 55 | type="radio" |
| 56 | name={name} |
| 57 | value={option.key} |
| 58 | checked={on} |
| 59 | disabled={disabled} |
| 60 | onChange={() => { |
| 61 | setChosen(option.key); |
| 62 | onChange?.(option.key); |
| 63 | }} |
| 64 | className="sr-only" |
| 65 | /> |
| 66 | {option.label} |
| 67 | </label> |
| 68 | </Hint> |
| 69 | ); |
| 70 | })} |
| 71 | </div> |
| 72 | <p className="mt-2 text-xs text-muted">{EFFORT_OPTIONS.find((o) => o.key === chosen)?.about}</p> |
| 73 | {costs !== undefined && <p className="mt-0.5 text-xs text-faint tabular-nums">{costLine(costs, chosen)}</p>} |
| 74 | </div> |
| 75 | ); |
| 76 | } |
| 77 | |
| 78 | /** What each level has cost this agent: a typical task, how many sessions, and how often nobody had to step in. */ |
| 79 | export function EffortCosts({ costs }: { costs: AgentEffortCosts | null }) { |
| 80 | if (!costs) return null; |
| 81 | return ( |
| 82 | <table className="w-full text-left text-xs"> |
| 83 | <caption className="sr-only">What each effort level has cost, from this agent's sessions</caption> |
| 84 | <thead className="text-faint"> |
| 85 | <tr> |
| 86 | <th className="py-1.5 pr-3 font-normal">Level</th> |
| 87 | <th className="py-1.5 pr-3 font-normal">Typical task</th> |
| 88 | <th className="py-1.5 pr-3 font-normal">Sessions</th> |
| 89 | <th className="py-1.5 font-normal"> |
| 90 | <Hint label="Finished with nobody having to step in: not steered, stopped or failed."> |
| 91 | <span className="cursor-help underline decoration-dotted underline-offset-2" tabIndex={0}> |
| 92 | Accepted |
| 93 | </span> |
| 94 | </Hint> |
| 95 | </th> |
| 96 | </tr> |
| 97 | </thead> |
| 98 | <tbody className="divide-y divide-line/60 tabular-nums"> |
| 99 | {costs.levels.map((level) => { |
| 100 | const at = costAt(costs, level.effort); |
| 101 | return ( |
| 102 | <tr key={level.effort} className={cn(costs.effort === level.effort && "text-fg")}> |
| 103 | <td className="py-1.5 pr-3"> |
| 104 | {effortLabel(level.effort)} |
| 105 | {costs.effort === level.effort && <span className="ml-1.5 text-faint">now</span>} |
| 106 | </td> |
| 107 | <td className="py-1.5 pr-3">{at ? money(at.typical_micros!) : <span className="text-faint">Not measured</span>}</td> |
| 108 | <td className="py-1.5 pr-3 text-muted">{level.sessions}</td> |
| 109 | <td className="py-1.5 text-muted">{acceptedLabel(at) || "—"}</td> |
| 110 | </tr> |
| 111 | ); |
| 112 | })} |
| 113 | </tbody> |
| 114 | </table> |
| 115 | ); |
| 116 | } |
| 117 | |
| 118 | /** |
| 119 | * One agent's effort, on its Spend tab: the control for owners (saved on |
| 120 | * its own), read-only for everyone else, and what each level has cost it. |
| 121 | */ |
| 122 | export function EffortCard({ value, costs, owner, action, name }: { value: AgentEffort; costs: AgentEffortCosts | null; owner: boolean; action: string; name: string }) { |
| 123 | const fetcher = useFetcher<{ ok: boolean; intent?: string; error?: string }>(); |
| 124 | const [picked, setPicked] = useState<AgentEffort>(value); |
| 125 | const busy = fetcher.state !== "idle"; |
| 126 | const result = fetcher.data?.intent === "effort" ? fetcher.data : null; |
| 127 | return ( |
| 128 | <section className="rounded-2xl border border-line bg-surface p-5 sm:p-6"> |
| 129 | <div className="flex items-start gap-3"> |
| 130 | <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent/15 text-accent"> |
| 131 | <Gauge size={16} aria-hidden="true" /> |
| 132 | </span> |
| 133 | <div className="min-w-0"> |
| 134 | <h2 className="text-sm font-semibold">Effort</h2> |
| 135 | <p className="mt-0.5 text-[0.8125rem] leading-relaxed text-muted"> |
| 136 | How hard {name} works: the tier its work starts on, how hard the model reasons, and how many steps a session may take. Its floor and |
| 137 | ceiling still hold. |
| 138 | </p> |
| 139 | </div> |
| 140 | </div> |
| 141 | <fetcher.Form method="post" action={action} className="mt-4 space-y-3"> |
| 142 | <input type="hidden" name="intent" value="effort" /> |
| 143 | <EffortPicker value={value} costs={costs} disabled={!owner} onChange={setPicked} /> |
| 144 | {owner && ( |
| 145 | <div className="flex flex-wrap items-center gap-3"> |
| 146 | <button type="submit" disabled={busy || picked === value} className={BUTTONS.PRIMARY}> |
| 147 | {busy ? "Saving…" : "Save effort"} |
| 148 | </button> |
| 149 | {result?.ok && picked === value && ( |
| 150 | <span role="status" className="inline-flex items-center gap-1 text-xs text-success"> |
| 151 | <Check size={13} aria-hidden="true" /> Saved as a new version |
| 152 | </span> |
| 153 | )} |
| 154 | {result && !result.ok && <span className="text-xs text-danger">{result.error}</span>} |
| 155 | </div> |
| 156 | )} |
| 157 | {!owner && <p className="text-xs text-faint">Only the workspace's owners change it.</p>} |
| 158 | </fetcher.Form> |
| 159 | <div className="mt-5 border-t border-line pt-4"> |
| 160 | <p className="mb-1 text-xs font-medium text-muted">What each level has cost {name}{costs ? `, last ${costs.window_days} days` : ""}</p> |
| 161 | {costs ? <EffortCosts costs={costs} /> : <p className="text-xs text-faint">Costs couldn't be read right now.</p>} |
| 162 | </div> |
| 163 | </section> |
| 164 | ); |
| 165 | } |
| 166 | |
| 167 | /** |
| 168 | * Spend less, keep quality: the weekly check's suggestions, each with the |
| 169 | * evidence it rests on and Apply / Dismiss for owners; agents with too |
| 170 | * little history to say, said plainly; and what was decided lately. |
| 171 | */ |
| 172 | export function Savings({ recs, owner, action, showAgent = true }: { recs: AgentRecommendations | null; owner: boolean; action: string; showAgent?: boolean }) { |
| 173 | if (!recs) return <p className="rounded-xl border border-line bg-surface px-4 py-6 text-center text-sm text-muted">Suggestions couldn't be read right now.</p>; |
| 174 | const total = totalSaving(recs); |
| 175 | return ( |
| 176 | <div className="overflow-hidden rounded-xl border border-accent/30 bg-surface"> |
| 177 | <div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 border-b border-line bg-accent/[0.05] px-4 py-3"> |
| 178 | <p className="flex items-center gap-2 text-sm font-medium"> |
| 179 | <Lightbulb size={14} className="text-accent" aria-hidden="true" /> |
| 180 | {recs.open.length ? `${recs.open.length} suggestion${recs.open.length === 1 ? "" : "s"}` : "No suggestions right now"} |
| 181 | {total > 0 && <span className="font-normal text-success tabular-nums">· about {money(total)} a month</span>} |
| 182 | </p> |
| 183 | <p className="text-xs text-faint"> |
| 184 | {recs.checked_at ? ( |
| 185 | <> |
| 186 | Checked against your own past work <TimeAgo at={recs.checked_at} />, weekly |
| 187 | </> |
| 188 | ) : ( |
| 189 | "Checked weekly against your agents' own finished sessions" |
| 190 | )} |
| 191 | </p> |
| 192 | </div> |
| 193 | {recs.open.length > 0 && ( |
| 194 | <ul className="divide-y divide-line"> |
| 195 | {recs.open.map((rec) => ( |
| 196 | <Suggestion key={rec.id} rec={rec} owner={owner} action={action} showAgent={showAgent} /> |
| 197 | ))} |
| 198 | </ul> |
| 199 | )} |
| 200 | {recs.open.length === 0 && ( |
| 201 | <p className="px-4 py-4 text-sm text-muted"> |
| 202 | {recs.checked_at |
| 203 | ? "Nothing measured would save money without the work getting worse." |
| 204 | : "The first check runs once agents have finished sessions. A suggestion appears only when a cheaper level held up on the same agent's past work."} |
| 205 | </p> |
| 206 | )} |
| 207 | {recs.thin.length > 0 && ( |
| 208 | <div className="border-t border-line px-4 py-3"> |
| 209 | <p className="text-xs font-medium text-muted">Not enough history yet</p> |
| 210 | <ul className="mt-1.5 space-y-1.5"> |
| 211 | {recs.thin.map((rec) => ( |
| 212 | <li key={rec.id} className="flex min-w-0 items-start gap-2 text-xs text-faint"> |
| 213 | {showAgent && <AgentAvatar agent={{ handle: rec.agent_handle, avatar_seed: rec.agent_avatar_seed }} size={16} />} |
| 214 | <span className="min-w-0"> |
| 215 | {showAgent && <span className="text-muted">@{rec.agent_handle}: </span>} |
| 216 | {rec.reason} |
| 217 | </span> |
| 218 | </li> |
| 219 | ))} |
| 220 | </ul> |
| 221 | </div> |
| 222 | )} |
| 223 | {recs.resolved.length > 0 && ( |
| 224 | <div className="border-t border-line px-4 py-3"> |
| 225 | <p className="text-xs font-medium text-muted">Decided lately</p> |
| 226 | <ul className="mt-1.5 space-y-1"> |
| 227 | {recs.resolved.slice(0, 5).map((rec) => ( |
| 228 | <li key={rec.id} className="truncate text-xs text-faint"> |
| 229 | {rec.status === "applied" ? "Applied" : "Dismissed"}: {rec.title} |
| 230 | {rec.resolved_by ? ` · @${rec.resolved_by}` : ""} |
| 231 | {rec.resolved_at ? ( |
| 232 | <> |
| 233 | {" "} |
| 234 | · <TimeAgo at={rec.resolved_at} /> |
| 235 | </> |
| 236 | ) : null} |
| 237 | </li> |
| 238 | ))} |
| 239 | </ul> |
| 240 | </div> |
| 241 | )} |
| 242 | </div> |
| 243 | ); |
| 244 | } |
| 245 | |
| 246 | function Suggestion({ rec, owner, action, showAgent }: { rec: AgentRecommendation; owner: boolean; action: string; showAgent: boolean }) { |
| 247 | const fetcher = useFetcher<{ ok: boolean; intent?: string; error?: string }>(); |
| 248 | const doing = fetcher.formData?.get("do"); |
| 249 | const error = fetcher.data && !fetcher.data.ok && fetcher.data.intent === "recommendation" ? fetcher.data.error : null; |
| 250 | const saving = savingLabel(rec); |
| 251 | return ( |
| 252 | <li className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-2 px-4 py-3.5 sm:grid-cols-[auto_minmax(0,1fr)_auto]"> |
| 253 | <span className="pt-0.5"> |
| 254 | {showAgent ? <AgentAvatar agent={{ handle: rec.agent_handle, avatar_seed: rec.agent_avatar_seed }} size={26} /> : <Gauge size={18} className="text-accent" aria-hidden="true" />} |
| 255 | </span> |
| 256 | <div className="min-w-0"> |
| 257 | <p className="text-sm font-medium">{rec.title}</p> |
| 258 | <p className="mt-0.5 text-[0.8125rem] leading-relaxed text-muted">{rec.reason}</p> |
| 259 | <p className="mt-1 text-xs text-faint"> |
| 260 | {effortLabel(rec.from_effort)} to {effortLabel(rec.to_effort)} |
| 261 | {saving && <span className="text-success"> · {saving}</span>} |
| 262 | </p> |
| 263 | {error && <p className="mt-1 text-xs text-danger">{error}</p>} |
| 264 | </div> |
| 265 | {owner ? ( |
| 266 | <fetcher.Form method="post" action={action} className="col-span-2 flex items-center gap-2 sm:col-span-1 sm:self-center"> |
| 267 | <input type="hidden" name="intent" value="recommendation" /> |
| 268 | <input type="hidden" name="id" value={rec.id} /> |
| 269 | <button type="submit" name="do" value="apply" disabled={fetcher.state !== "idle"} className={cn(BUTTONS.PRIMARY, "h-8 px-3 py-0 text-xs")}> |
| 270 | {doing === "apply" ? "Applying…" : "Apply"} |
| 271 | </button> |
| 272 | <button type="submit" name="do" value="dismiss" disabled={fetcher.state !== "idle"} className={cn(BUTTONS.QUIET, "h-8 px-3 py-0 text-xs")}> |
| 273 | {doing === "dismiss" ? "Dismissing…" : "Dismiss"} |
| 274 | </button> |
| 275 | </fetcher.Form> |
| 276 | ) : ( |
| 277 | <p className="col-span-2 text-xs text-faint sm:col-span-1 sm:self-center">An owner can apply it</p> |
| 278 | )} |
| 279 | </li> |
| 280 | ); |
| 281 | } |