Skip to content
281 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 { BUTTONS } from "./agents/dialogs";
15import { Hint } from "./ui/hint";
16import { TimeAgo } from "./ui";
17import { cn } from "../lib/cn";
18import { EFFORT_OPTIONS, acceptedLabel, costAt, costLine, effortLabel, savingLabel, totalSaving } from "../lib/effort";
19import { 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 */
26export 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. */
79export 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&apos;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 */
122export 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&apos;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&apos;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 */
172export 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&apos;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
246function 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}