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