@saadnvd1/agent-os 0.3.836 → 0.3.839

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/app/api/fresh-start/route.test.ts +33 -0
  2. package/app/api/fresh-start/route.ts +3 -0
  3. package/components/Devices/DeviceRow.tsx +83 -69
  4. package/components/Devices/PasskeysSection.test.ts +89 -0
  5. package/components/Devices/PasskeysSection.tsx +36 -26
  6. package/components/Health/HealthPanel.tsx +41 -16
  7. package/components/Merge/GlobalMergeSection.tsx +2 -5
  8. package/components/Merge/MergeSettingsFields.tsx +39 -77
  9. package/components/Merge/ProjectMergeSection.tsx +2 -6
  10. package/components/Merge/WorktreeSweepSection.tsx +40 -14
  11. package/components/Models/AgentRoleCard.tsx +61 -66
  12. package/components/Models/AgentRolesSection.tsx +5 -8
  13. package/components/Models/ModelsSection.tsx +7 -18
  14. package/components/Models/PriceInput.tsx +59 -0
  15. package/components/PhoneNotify/PhoneNotifyForm.tsx +37 -58
  16. package/components/Settings/AppWideLimits.tsx +61 -0
  17. package/components/Settings/ContextSection.tsx +96 -0
  18. package/components/Settings/ExperimentsSection.test.ts +9 -7
  19. package/components/Settings/ExperimentsSection.tsx +22 -23
  20. package/components/Settings/InlineConfirm.tsx +51 -0
  21. package/components/Settings/OwnerNameRow.tsx +10 -40
  22. package/components/Settings/PickerSetting.tsx +59 -0
  23. package/components/Settings/SettingRow.tsx +131 -0
  24. package/components/Settings/SettingsDialog.test.ts +1 -0
  25. package/components/Settings/SettingsDialog.tsx +47 -2
  26. package/components/Settings/settings-fields.test.ts +103 -0
  27. package/components/Settings/settings-fields.tsx +427 -0
  28. package/components/Shares/SharedLinksPanel.tsx +62 -53
  29. package/components/Workspaces/HqFolderForm.tsx +21 -54
  30. package/components/Workspaces/McpServersForm.tsx +20 -19
  31. package/components/Workspaces/ProjectTiersForm.tsx +10 -8
  32. package/components/Workspaces/RulesForm.test.ts +127 -0
  33. package/components/Workspaces/RulesForm.tsx +54 -126
  34. package/components/Workspaces/WorkspaceModelsForm.tsx +27 -26
  35. package/components/Workspaces/WorkspaceSettings.tsx +61 -24
  36. package/content/whats-new.json +16 -0
  37. package/data/fresh-start/index.ts +1 -1
  38. package/data/workspaces/index.ts +9 -0
  39. package/hooks/useAutoSave.test.ts +116 -0
  40. package/hooks/useAutoSave.ts +98 -0
  41. package/lib/away/window-limits.ts +3 -1
  42. package/lib/chat/refresh.ts +10 -6
  43. package/lib/chat/stall-limits.ts +3 -1
  44. package/lib/settings/defaults.ts +27 -0
  45. package/lib/settings/parse.ts +17 -0
  46. package/lib/settings-url.ts +1 -0
  47. package/lib/worktree-sweep/limits.ts +3 -0
  48. package/lib/worktree-sweep/settings.ts +4 -2
  49. package/package.json +1 -1
  50. package/components/Away/AwayWindowForm.tsx +0 -91
  51. package/components/Workspaces/FreshStartForm.tsx +0 -177
  52. package/components/Workspaces/StallForm.tsx +0 -101
  53. package/components/Workspaces/TaskLimitForm.tsx +0 -84
  54. package/components/Workspaces/fresh-start.ts +0 -19
@@ -1,13 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { useId } from "react";
4
- import {
5
- Select,
6
- SelectContent,
7
- SelectItem,
8
- SelectTrigger,
9
- SelectValue,
10
- } from "@/components/ui/select";
3
+ import { SelectSetting } from "@/components/Settings/settings-fields";
4
+ import { SettingSection } from "@/components/Settings/SettingRow";
11
5
  import {
12
6
  MERGE_METHODS,
13
7
  METHOD_LABEL,
@@ -29,47 +23,6 @@ const SOURCE: Record<MergeSource, string> = {
29
23
 
30
24
  const onOff = (on: boolean) => (on ? "On" : "Off");
31
25
 
32
- function Field({
33
- label,
34
- detail,
35
- value,
36
- inheritLabel,
37
- options,
38
- onChange,
39
- disabled,
40
- }: {
41
- label: string;
42
- detail: string;
43
- value: string;
44
- inheritLabel: string;
45
- options: Array<{ value: string; label: string }>;
46
- onChange: (value: string) => void;
47
- disabled?: boolean;
48
- }) {
49
- const id = useId();
50
- return (
51
- <div className="space-y-1.5">
52
- <label htmlFor={id} className="text-sm font-medium">
53
- {label}
54
- </label>
55
- <Select value={value} onValueChange={onChange} disabled={disabled}>
56
- <SelectTrigger id={id} className="h-11 md:h-9">
57
- <SelectValue />
58
- </SelectTrigger>
59
- <SelectContent>
60
- <SelectItem value={INHERIT}>{inheritLabel}</SelectItem>
61
- {options.map((o) => (
62
- <SelectItem key={o.value} value={o.value}>
63
- {o.label}
64
- </SelectItem>
65
- ))}
66
- </SelectContent>
67
- </Select>
68
- <p className="text-muted-foreground text-xs">{detail}</p>
69
- </div>
70
- );
71
- }
72
-
73
26
  const TOGGLE = [
74
27
  { value: "on", label: "On" },
75
28
  { value: "off", label: "Off" },
@@ -78,38 +31,43 @@ const TOGGLE = [
78
31
  /**
79
32
  * The merge method and the two deletions after a merge, each either set
80
33
  * here or left to what this level inherits (`inherited`, with where that
81
- * comes from).
34
+ * comes from). Each saves the moment it changes.
82
35
  */
83
36
  export function MergeSettingsFields({
37
+ idPrefix,
84
38
  settings,
85
39
  inherited,
86
40
  from,
87
41
  allowed,
88
- disabled,
89
- onChange,
42
+ onSave,
90
43
  }: {
44
+ idPrefix: string;
91
45
  settings: MergeSettings;
92
46
  inherited: MergePolicy;
93
47
  from?: Record<keyof MergePolicy, MergeSource>;
94
48
  // The repository's allowed methods, when known.
95
49
  allowed?: MergeMethod[] | null;
96
- disabled?: boolean;
97
- onChange: (settings: MergeSettings) => void;
50
+ onSave: (settings: MergeSettings) => Promise<unknown>;
98
51
  }) {
99
- const inheritLabel = (key: keyof MergePolicy, shown: string) =>
100
- from ? `Inherit: ${shown} (${SOURCE[from[key]]})` : `Default: ${shown}`;
52
+ const inheritOption = (key: keyof MergePolicy, shown: string) => ({
53
+ value: INHERIT,
54
+ label: from
55
+ ? `Inherit: ${shown} (${SOURCE[from[key]]})`
56
+ : `Default: ${shown}`,
57
+ });
101
58
  const set = <K extends keyof MergeSettings>(
102
59
  key: K,
103
60
  value: MergeSettings[K] | undefined
104
- ) => onChange({ ...settings, [key]: value });
61
+ ) => onSave({ ...settings, [key]: value });
105
62
  const toggle = (key: "delete_remote_branch" | "delete_worktree") => (
106
- <Field
63
+ <SelectSetting
64
+ id={`${idPrefix}-${key}`}
107
65
  label={
108
66
  key === "delete_remote_branch"
109
67
  ? "Delete the branch on origin after a merge"
110
68
  : "Delete the local worktree and branch after a merge"
111
69
  }
112
- detail={
70
+ help={
113
71
  key === "delete_remote_branch"
114
72
  ? "Never main, master, or a branch a stacked task still targets."
115
73
  : "Never one with uncommitted work or commits the merge didn't include."
@@ -117,10 +75,10 @@ export function MergeSettingsFields({
117
75
  value={
118
76
  settings[key] === undefined ? INHERIT : settings[key] ? "on" : "off"
119
77
  }
120
- inheritLabel={inheritLabel(key, onOff(inherited[key]))}
121
- options={TOGGLE}
122
- disabled={disabled}
123
- onChange={(v) => set(key, v === INHERIT ? undefined : v === "on")}
78
+ options={[inheritOption(key, onOff(inherited[key])), ...TOGGLE]}
79
+ defaultValue={INHERIT}
80
+ hint=""
81
+ onSave={(v) => set(key, v === INHERIT ? undefined : v === "on")}
124
82
  />
125
83
  );
126
84
 
@@ -128,25 +86,29 @@ export function MergeSettingsFields({
128
86
  const refused = allowed && !allowed.includes(method);
129
87
 
130
88
  return (
131
- <div className="space-y-3">
132
- <Field
89
+ <SettingSection>
90
+ <SelectSetting
91
+ id={`${idPrefix}-method`}
133
92
  label="Merge method"
134
- detail="How sign-off, Done, Land and external PRs merge. If GitHub refuses it, the merge fails and says so; it never falls back to another method."
93
+ help="How sign-off, Done, Land and external PRs merge. If GitHub refuses it, the merge fails and says so; it never falls back to another method."
135
94
  value={settings.method ?? INHERIT}
136
- inheritLabel={inheritLabel("method", METHOD_LABEL[inherited.method])}
137
- options={MERGE_METHODS.map((m) => ({
138
- value: m,
139
- label:
140
- METHOD_LABEL[m] +
141
- (allowed && !allowed.includes(m) ? " (off on GitHub)" : ""),
142
- }))}
143
- disabled={disabled}
144
- onChange={(v) =>
95
+ options={[
96
+ inheritOption("method", METHOD_LABEL[inherited.method]),
97
+ ...MERGE_METHODS.map((m) => ({
98
+ value: m,
99
+ label:
100
+ METHOD_LABEL[m] +
101
+ (allowed && !allowed.includes(m) ? " (off on GitHub)" : ""),
102
+ })),
103
+ ]}
104
+ defaultValue={INHERIT}
105
+ hint=""
106
+ onSave={(v) =>
145
107
  set("method", v === INHERIT ? undefined : (v as MergeMethod))
146
108
  }
147
109
  />
148
110
  {refused && (
149
- <p className="text-destructive text-xs">
111
+ <p className="text-destructive px-3 py-2 text-xs">
150
112
  This repository has &ldquo;{REPO_SETTING[method]}&rdquo; turned off on
151
113
  GitHub, so merges will be refused until it&rsquo;s on or the method
152
114
  changes.
@@ -154,6 +116,6 @@ export function MergeSettingsFields({
154
116
  )}
155
117
  {toggle("delete_remote_branch")}
156
118
  {toggle("delete_worktree")}
157
- </div>
119
+ </SettingSection>
158
120
  );
159
121
  }
@@ -35,14 +35,10 @@ export function ProjectMergeSection({
35
35
  inherited={data.inherited}
36
36
  from={data.inherited.from}
37
37
  allowed={data.allowed}
38
- disabled={update.isPending}
39
- onChange={(s) => update.mutate(s)}
38
+ idPrefix={`merge-${projectId}`}
39
+ onSave={(s) => update.mutateAsync(s)}
40
40
  />
41
41
  )}
42
- {update.isError && (
43
- <p className="text-destructive text-sm">{update.error.message}</p>
44
- )}
45
- <p className="text-muted-foreground text-xs">Saved as you change them.</p>
46
42
  <ProjectSweepSection projectId={projectId} />
47
43
  </div>
48
44
  );
@@ -5,6 +5,8 @@ import { Input } from "@/components/ui/input";
5
5
  import { useSetSweepDays, useWorktreeSweep } from "@/data/worktree-sweep";
6
6
  import { compactTimeAgo, fromSqliteTime } from "@/lib/session-meta";
7
7
  import { formatBytes } from "@/lib/share/view";
8
+ import { parseSettingNumber } from "@/lib/settings/parse";
9
+ import { MAX_SWEEP_DAYS } from "@/lib/worktree-sweep/limits";
8
10
 
9
11
  // Days as the box shows them; empty means "inherit".
10
12
  function DaysInput({
@@ -19,24 +21,48 @@ function DaysInput({
19
21
  onSave: (days: number | null) => void;
20
22
  }) {
21
23
  const [text, setText] = useState(value === null ? "" : String(value));
24
+ const [error, setError] = useState<string | null>(null);
22
25
  const commit = () => {
23
26
  const raw = text.trim();
24
- const next = raw === "" ? null : Number(raw);
25
- if (next === value || (next !== null && !Number.isInteger(next))) return;
26
- onSave(next);
27
+ let next: number | null = null;
28
+ if (raw !== "") {
29
+ const parsed = parseSettingNumber(raw, { min: 0, max: MAX_SWEEP_DAYS });
30
+ if ("error" in parsed) return setError(parsed.error);
31
+ next = parsed.value;
32
+ }
33
+ setError(null);
34
+ if (next !== value) onSave(next);
27
35
  };
28
36
  return (
29
- <Input
30
- aria-label="Days before a settled worktree is removed"
31
- inputMode="numeric"
32
- value={text}
33
- placeholder={placeholder}
34
- disabled={disabled}
35
- onChange={(e) => setText(e.target.value)}
36
- onBlur={commit}
37
- onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()}
38
- className="h-11 w-24 text-right tabular-nums md:h-9"
39
- />
37
+ <span className="flex flex-col gap-1">
38
+ <Input
39
+ aria-label="Days before a settled worktree is removed"
40
+ aria-invalid={!!error}
41
+ inputMode="numeric"
42
+ value={text}
43
+ placeholder={placeholder}
44
+ disabled={disabled}
45
+ onChange={(e) => {
46
+ setText(e.target.value);
47
+ setError(null);
48
+ }}
49
+ onBlur={commit}
50
+ onKeyDown={(e) => {
51
+ if (e.key === "Enter") e.currentTarget.blur();
52
+ if (e.key === "Escape") {
53
+ setText(value === null ? "" : String(value));
54
+ setError(null);
55
+ e.currentTarget.blur();
56
+ }
57
+ }}
58
+ className="h-11 w-24 text-right tabular-nums md:h-9"
59
+ />
60
+ {error && (
61
+ <span role="alert" className="text-destructive text-xs">
62
+ {error}
63
+ </span>
64
+ )}
65
+ </span>
40
66
  );
41
67
  }
42
68
 
@@ -4,6 +4,7 @@ import { AlertTriangle } from "lucide-react";
4
4
  import type { AgentRoleView } from "@/lib/models/agent-roles-view";
5
5
  import { analystBlock, defaultAnalystLabel } from "@/lib/models/usable";
6
6
  import { cn } from "@/lib/utils";
7
+ import { PickerSetting } from "@/components/Settings/PickerSetting";
7
8
  import { RoleModelSelect } from "./RoleModelSelect";
8
9
 
9
10
  const SOURCE: Record<string, string> = {
@@ -16,22 +17,18 @@ const SOURCE: Record<string, string> = {
16
17
  default: "Default",
17
18
  };
18
19
 
19
- const initial = (label: string) => label.slice(0, 1);
20
-
21
- // One role: what it does, the model it runs on, where that came from, and
20
+ // One role as settings rows: the model it runs on, where that came from, and
22
21
  // whether the model's server answers. A role whose local model is down says so.
23
22
  export function AgentRoleCard({
24
23
  role,
25
24
  scope,
26
- pending,
27
25
  onChange,
28
26
  onFallback,
29
27
  }: {
30
28
  role: AgentRoleView;
31
29
  scope: "settings" | "workspace" | "project";
32
- pending: boolean;
33
- onChange: (ref: string | null) => void;
34
- onFallback: (ref: string | null) => void;
30
+ onChange: (ref: string | null) => Promise<unknown>;
31
+ onFallback: (ref: string | null) => Promise<unknown>;
35
32
  }) {
36
33
  const down = role.choice?.availability.state === "down";
37
34
  const analyst = role.id === "analyst";
@@ -45,77 +42,75 @@ export function AgentRoleCard({
45
42
  ? "Same as Gate review"
46
43
  : label
47
44
  : "Inherit";
45
+ const source = role.binding
46
+ ? `${SOURCE[role.binding.source] ?? role.binding.source}${role.choice?.endpoint ? ` · ${role.choice.endpoint}` : ""}`
47
+ : undefined;
48
48
  return (
49
- <li
50
- className={cn(
51
- "bg-muted/40 space-y-2 rounded-xl px-3 py-3",
52
- down && "ring-destructive/50 ring-1"
53
- )}
54
- >
55
- <label htmlFor={`agent-role-${role.id}`} className="flex gap-2.5">
56
- <span className="bg-accent text-primary grid h-8 w-8 shrink-0 place-items-center rounded-lg text-sm font-semibold">
57
- {initial(role.label)}
58
- </span>
59
- <span className="min-w-0">
60
- <span className="block text-sm font-medium">{role.label}</span>
61
- <span className="text-muted-foreground block text-xs">
62
- {role.purpose}
63
- </span>
64
- </span>
65
- </label>
66
- <RoleModelSelect
49
+ <>
50
+ <PickerSetting<string | null>
67
51
  id={`agent-role-${role.id}`}
52
+ label={role.label}
53
+ help={role.purpose}
68
54
  value={role.value}
69
- inheritLabel={
70
- role.value === null && role.binding
71
- ? `${inheritLabel} · ${role.binding.ref.split("/").slice(1).join("/")}`
72
- : inheritLabel
55
+ defaultValue={null}
56
+ hint={source}
57
+ error={role.error}
58
+ onSave={onChange}
59
+ render={({ value, commit }) => (
60
+ <RoleModelSelect
61
+ id={`agent-role-${role.id}`}
62
+ value={value}
63
+ inheritLabel={
64
+ value === null && role.binding
65
+ ? `${inheritLabel} · ${role.binding.ref.split("/").slice(1).join("/")}`
66
+ : inheritLabel
67
+ }
68
+ blockedBy={blockedBy}
69
+ onChange={commit}
70
+ />
71
+ )}
72
+ below={
73
+ down && (
74
+ <div
75
+ role="alert"
76
+ className={cn(
77
+ "bg-destructive/10 text-foreground ring-destructive/35 my-1 flex gap-2 rounded-lg p-2.5 text-xs ring-1"
78
+ )}
79
+ >
80
+ <AlertTriangle className="text-destructive mt-0.5 h-3.5 w-3.5 shrink-0" />
81
+ <span>
82
+ <b className="font-medium">
83
+ This model&apos;s server isn&apos;t answering.
84
+ </b>{" "}
85
+ {role.choice?.availability.error
86
+ ? `${role.choice.availability.error}. `
87
+ : ""}
88
+ New {role.label} sessions can&apos;t start until it is back
89
+ {role.fallback ? ", or you start on the fallback" : ""}.
90
+ </span>
91
+ </div>
92
+ )
73
93
  }
74
- blockedBy={blockedBy}
75
- disabled={pending}
76
- onChange={onChange}
77
94
  />
78
- <p className="text-muted-foreground text-xs">
79
- {role.error
80
- ? role.error
81
- : role.binding
82
- ? `${SOURCE[role.binding.source] ?? role.binding.source}${role.choice?.endpoint ? ` · ${role.choice.endpoint}` : ""}`
83
- : ""}
84
- </p>
85
- {down && (
86
- <div
87
- role="alert"
88
- className="bg-destructive/10 text-foreground ring-destructive/35 flex gap-2 rounded-lg p-2.5 text-xs ring-1"
89
- >
90
- <AlertTriangle className="text-destructive mt-0.5 h-3.5 w-3.5 shrink-0" />
91
- <span>
92
- <b className="font-medium">
93
- This model&apos;s server isn&apos;t answering.
94
- </b>{" "}
95
- {role.choice?.availability.error
96
- ? `${role.choice.availability.error}. `
97
- : ""}
98
- New {role.label} sessions can&apos;t start until it is back
99
- {role.fallback ? ", or you start on the fallback" : ""}.
100
- </span>
101
- </div>
102
- )}
103
95
  {scope === "settings" && (
104
- <div className="flex flex-wrap items-center gap-2">
105
- <span className="text-muted-foreground text-xs">Fallback</span>
106
- <div className="min-w-44 flex-1">
96
+ <PickerSetting<string | null>
97
+ id={`agent-role-fallback-${role.id}`}
98
+ label={`${role.label} fallback`}
99
+ value={role.fallback}
100
+ defaultValue={null}
101
+ onSave={onFallback}
102
+ render={({ value, commit }) => (
107
103
  <RoleModelSelect
108
104
  id={`agent-role-fallback-${role.id}`}
109
- value={role.fallback}
105
+ value={value}
110
106
  inheritLabel="None (don't fall back)"
111
107
  claudeOnly={!analyst}
112
108
  blockedBy={blockedBy}
113
- disabled={pending}
114
- onChange={onFallback}
109
+ onChange={commit}
115
110
  />
116
- </div>
117
- </div>
111
+ )}
112
+ />
118
113
  )}
119
- </li>
114
+ </>
120
115
  );
121
116
  }
@@ -7,6 +7,7 @@ import { useWorkspacesQuery } from "@/data/workspaces";
7
7
  import type { RoleMap } from "@/lib/models/config";
8
8
  import type { RoleScope } from "@/lib/models/agent-roles-view";
9
9
  import { cn } from "@/lib/utils";
10
+ import { SettingSection } from "@/components/Settings/SettingRow";
10
11
  import { AgentRoleCard } from "./AgentRoleCard";
11
12
 
12
13
  const SCOPES: { id: RoleScope; label: string }[] = [
@@ -44,13 +45,13 @@ export function AgentRolesSection() {
44
45
  const roles: RoleMap = { ...saved("value") };
45
46
  if (ref) roles[modelRole as keyof RoleMap] = ref;
46
47
  else delete roles[modelRole as keyof RoleMap];
47
- save.mutate({ roles });
48
+ return save.mutateAsync({ roles });
48
49
  };
49
50
  const setFallback = (modelRole: string, ref: string | null) => {
50
51
  const fallbacks: RoleMap = { ...saved("fallback") };
51
52
  if (ref) fallbacks[modelRole as keyof RoleMap] = ref;
52
53
  else delete fallbacks[modelRole as keyof RoleMap];
53
- save.mutate({ roles: saved("value"), fallbacks });
54
+ return save.mutateAsync({ roles: saved("value"), fallbacks });
54
55
  };
55
56
 
56
57
  return (
@@ -111,22 +112,18 @@ export function AgentRolesSection() {
111
112
  ) : isError ? (
112
113
  <p className="text-destructive text-sm">{error.message}</p>
113
114
  ) : data ? (
114
- <ul className="space-y-2.5">
115
+ <SettingSection>
115
116
  {data.roles.map((r) => (
116
117
  <AgentRoleCard
117
118
  key={r.id}
118
119
  role={r}
119
120
  scope={scope}
120
- pending={save.isPending}
121
121
  onChange={(ref) => setRole(r.modelRole, ref)}
122
122
  onFallback={(ref) => setFallback(r.modelRole, ref)}
123
123
  />
124
124
  ))}
125
- </ul>
125
+ </SettingSection>
126
126
  ) : null}
127
- {save.isError && (
128
- <p className="text-destructive text-xs">{save.error.message}</p>
129
- )}
130
127
  </div>
131
128
  );
132
129
  }
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { Button } from "@/components/ui/button";
4
- import { Input } from "@/components/ui/input";
4
+ import { PriceInput } from "./PriceInput";
5
5
  import { SettingToggle } from "@/components/Settings/SettingToggle";
6
6
  import { useModelsQuery, useUpdateModels } from "@/data/models";
7
7
  import type { ModelSettings } from "@/lib/models/config";
@@ -169,28 +169,17 @@ export function ModelsSection() {
169
169
  <td className="py-1 pr-2 font-mono">{ref}</td>
170
170
  {PRICE_FIELDS.map((f) => (
171
171
  <td key={f.key} className="py-1 pr-2">
172
- <Input
173
- type="number"
174
- min={0}
175
- step="0.001"
176
- defaultValue={price[f.key]}
177
- aria-label={`${ref} ${f.label}`}
178
- className="h-11 w-20 tabular-nums md:h-8"
179
- onBlur={(e) => {
180
- const v = Number(e.target.value);
181
- if (
182
- !Number.isFinite(v) ||
183
- v < 0 ||
184
- v === price[f.key]
185
- )
186
- return;
172
+ <PriceInput
173
+ label={`${ref} ${f.label}`}
174
+ value={price[f.key]}
175
+ onSave={(v) =>
187
176
  save({
188
177
  prices: {
189
178
  ...saved.prices,
190
179
  [ref]: { ...price, [f.key]: v },
191
180
  },
192
- });
193
- }}
181
+ })
182
+ }
194
183
  />
195
184
  </td>
196
185
  ))}
@@ -0,0 +1,59 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Input } from "@/components/ui/input";
5
+ import { parseSettingNumber } from "@/lib/settings/parse";
6
+
7
+ // A model price in $ per million tokens, saved on blur or Enter. Blank or
8
+ // invalid stays in the box with a message and is never sent (a blank used to
9
+ // save as $0); Esc puts the saved price back.
10
+ export function PriceInput({
11
+ label,
12
+ value,
13
+ onSave,
14
+ }: {
15
+ label: string;
16
+ value: number;
17
+ onSave: (price: number) => void;
18
+ }) {
19
+ const [text, setText] = useState<string | null>(null);
20
+ const [error, setError] = useState<string | null>(null);
21
+ const revert = () => {
22
+ setText(null);
23
+ setError(null);
24
+ };
25
+ return (
26
+ <span className="flex flex-col gap-1">
27
+ <Input
28
+ inputMode="decimal"
29
+ value={text ?? String(value)}
30
+ aria-label={label}
31
+ aria-invalid={!!error}
32
+ className="h-11 w-20 tabular-nums md:h-8"
33
+ onChange={(e) => {
34
+ setText(e.target.value);
35
+ setError(null);
36
+ }}
37
+ onBlur={() => {
38
+ if (text === null) return;
39
+ const parsed = parseSettingNumber(text, { min: 0, integer: false });
40
+ if ("error" in parsed) return setError(parsed.error);
41
+ revert();
42
+ if (parsed.value !== value) onSave(parsed.value);
43
+ }}
44
+ onKeyDown={(e) => {
45
+ if (e.key === "Enter") e.currentTarget.blur();
46
+ if (e.key === "Escape") {
47
+ revert();
48
+ e.currentTarget.blur();
49
+ }
50
+ }}
51
+ />
52
+ {error && (
53
+ <span role="alert" className="text-destructive text-xs">
54
+ {error}
55
+ </span>
56
+ )}
57
+ </span>
58
+ );
59
+ }