@saadnvd1/agent-os 0.3.836 → 0.3.845

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 (81) 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/app/api/health/route.ts +4 -2
  4. package/components/Devices/DeviceRow.tsx +83 -69
  5. package/components/Devices/PasskeysSection.test.ts +89 -0
  6. package/components/Devices/PasskeysSection.tsx +36 -26
  7. package/components/Health/HealthPanel.tsx +41 -16
  8. package/components/Merge/GlobalMergeSection.tsx +2 -5
  9. package/components/Merge/MergeSettingsFields.tsx +39 -77
  10. package/components/Merge/ProjectMergeSection.tsx +2 -6
  11. package/components/Merge/WorktreeSweepSection.tsx +40 -14
  12. package/components/Models/AgentRoleCard.tsx +61 -66
  13. package/components/Models/AgentRolesSection.tsx +5 -8
  14. package/components/Models/ModelsSection.tsx +7 -18
  15. package/components/Models/PriceInput.tsx +59 -0
  16. package/components/PhoneNotify/PhoneNotifyForm.tsx +37 -58
  17. package/components/SessionList/GroupShelves.tsx +8 -3
  18. package/components/SessionList/OrchestratorPinRow.tsx +2 -2
  19. package/components/SessionList/RowParts.tsx +39 -35
  20. package/components/SessionList/RowRename.test.ts +86 -17
  21. package/components/SessionList/RowShell.tsx +2 -5
  22. package/components/SessionList/SessionRow.tsx +0 -1
  23. package/components/Settings/AppWideLimits.tsx +61 -0
  24. package/components/Settings/ContextSection.tsx +96 -0
  25. package/components/Settings/ExperimentsSection.test.ts +9 -7
  26. package/components/Settings/ExperimentsSection.tsx +22 -23
  27. package/components/Settings/InlineConfirm.tsx +51 -0
  28. package/components/Settings/OwnerNameRow.tsx +10 -40
  29. package/components/Settings/PickerSetting.tsx +59 -0
  30. package/components/Settings/SettingRow.tsx +168 -0
  31. package/components/Settings/SettingsDialog.test.ts +1 -0
  32. package/components/Settings/SettingsDialog.tsx +47 -2
  33. package/components/Settings/settings-fields.test.ts +103 -0
  34. package/components/Settings/settings-fields.tsx +428 -0
  35. package/components/Shares/SharedLinksPanel.tsx +62 -53
  36. package/components/Workspaces/HqFolderForm.tsx +22 -53
  37. package/components/Workspaces/McpServersForm.tsx +21 -28
  38. package/components/Workspaces/ProjectTiersForm.tsx +28 -13
  39. package/components/Workspaces/RuleEditor.tsx +86 -0
  40. package/components/Workspaces/RulesForm.test.ts +154 -0
  41. package/components/Workspaces/RulesForm.tsx +116 -117
  42. package/components/Workspaces/WorkspaceModeForm.tsx +3 -3
  43. package/components/Workspaces/WorkspaceModelsForm.tsx +36 -36
  44. package/components/Workspaces/WorkspacePanel.tsx +73 -0
  45. package/components/Workspaces/WorkspacePicker.tsx +44 -0
  46. package/components/Workspaces/WorkspaceSettings.test.ts +153 -0
  47. package/components/Workspaces/WorkspaceSettings.tsx +63 -52
  48. package/components/Workspaces/useSettingsWorkspace.ts +47 -0
  49. package/content/whats-new.json +40 -0
  50. package/data/fresh-start/index.ts +1 -1
  51. package/data/workspaces/index.ts +9 -0
  52. package/hooks/useAutoSave.test.ts +116 -0
  53. package/hooks/useAutoSave.ts +98 -0
  54. package/lib/away/deploy.ts +34 -7
  55. package/lib/away/window-limits.ts +3 -1
  56. package/lib/chat/refresh.ts +10 -6
  57. package/lib/chat/stall-limits.ts +3 -1
  58. package/lib/models/plan-fresh.ts +61 -0
  59. package/lib/models/plan-step.ts +40 -24
  60. package/lib/orchestrator/scope.ts +10 -0
  61. package/lib/settings/defaults.ts +27 -0
  62. package/lib/settings/parse.ts +17 -0
  63. package/lib/settings-url.ts +1 -0
  64. package/lib/updates/banner.ts +2 -0
  65. package/lib/updates/run.ts +9 -0
  66. package/lib/updates/view.ts +28 -6
  67. package/lib/worktree-deps/paths.ts +9 -4
  68. package/lib/worktree-sweep/limits.ts +3 -0
  69. package/lib/worktree-sweep/settings.ts +4 -2
  70. package/lib/worktrees.ts +3 -0
  71. package/package.json +1 -1
  72. package/scripts/autodeploy +11 -0
  73. package/scripts/autodeploy.test.ts +30 -0
  74. package/scripts/lib/update-verify.mjs +11 -0
  75. package/scripts/update-run.mjs +29 -0
  76. package/scripts/update-run.test.ts +62 -4
  77. package/components/Away/AwayWindowForm.tsx +0 -91
  78. package/components/Workspaces/FreshStartForm.tsx +0 -177
  79. package/components/Workspaces/StallForm.tsx +0 -101
  80. package/components/Workspaces/TaskLimitForm.tsx +0 -84
  81. package/components/Workspaces/fresh-start.ts +0 -19
@@ -1,10 +1,10 @@
1
1
  "use client";
2
2
 
3
3
  import { useState } from "react";
4
- import { toast } from "sonner";
5
4
  import { ArrowLeft } from "lucide-react";
6
5
  import { Button } from "@/components/ui/button";
7
- import { SettingToggle } from "@/components/Settings/SettingToggle";
6
+ import { SettingSection } from "@/components/Settings/SettingRow";
7
+ import { SwitchSetting } from "@/components/Settings/settings-fields";
8
8
  import { formatTokens } from "@/lib/chat/context";
9
9
  import {
10
10
  useExperimentDetailQuery,
@@ -194,31 +194,30 @@ export function ExperimentsSection() {
194
194
  work while off, and shows what it costs here.
195
195
  </p>
196
196
  {data.experiments.map((e) => (
197
- <div key={e.id} className="space-y-2">
198
- <SettingToggle
199
- title={`${e.name} · ${e.status === "beta" ? "Beta" : "Experimental"}`}
200
- detail={e.description}
197
+ <SettingSection key={e.id}>
198
+ <SwitchSetting
199
+ id={`experiment-${e.id}`}
200
+ label={`${e.name} · ${e.status === "beta" ? "Beta" : "Experimental"}`}
201
+ help={e.description}
201
202
  checked={e.on}
202
203
  locked={data.locked}
203
- onChange={(on) =>
204
- set.mutate(
205
- { id: e.id, on },
206
- { onError: (err) => toast.error(err.message) }
207
- )
204
+ defaultValue={false}
205
+ onSave={(on) => set.mutateAsync({ id: e.id, on })}
206
+ extra={
207
+ <div className="px-1 pb-1">
208
+ <Usage e={e} />
209
+ <Button
210
+ variant="link"
211
+ size="sm"
212
+ className="min-h-11 px-0 md:min-h-0"
213
+ onClick={() => setOpen(e.id)}
214
+ >
215
+ Details
216
+ </Button>
217
+ </div>
208
218
  }
209
219
  />
210
- <div className="px-3">
211
- <Usage e={e} />
212
- <Button
213
- variant="link"
214
- size="sm"
215
- className="min-h-11 px-0 md:min-h-0"
216
- onClick={() => setOpen(e.id)}
217
- >
218
- Details
219
- </Button>
220
- </div>
221
- </div>
220
+ </SettingSection>
222
221
  ))}
223
222
  </div>
224
223
  );
@@ -0,0 +1,51 @@
1
+ "use client";
2
+
3
+ import { Button } from "@/components/ui/button";
4
+
5
+ /**
6
+ * The in-app confirm under a row: a message and two buttons, never a browser
7
+ * dialog (which a webview drops). The full-access switch, and every revoke,
8
+ * ask this way.
9
+ */
10
+ export function InlineConfirm({
11
+ label,
12
+ message,
13
+ confirmLabel,
14
+ onConfirm,
15
+ onCancel,
16
+ }: {
17
+ // The dialog's accessible name.
18
+ label: string;
19
+ message: string;
20
+ confirmLabel: string;
21
+ onConfirm: () => void;
22
+ onCancel: () => void;
23
+ }) {
24
+ return (
25
+ <div
26
+ role="alertdialog"
27
+ aria-label={label}
28
+ className="bg-destructive/10 my-1 space-y-2 rounded-lg px-3 py-2 text-sm"
29
+ >
30
+ <p>{message}</p>
31
+ <div className="flex gap-2">
32
+ <Button
33
+ size="sm"
34
+ variant="destructive"
35
+ className="h-11 sm:h-9"
36
+ onClick={onConfirm}
37
+ >
38
+ {confirmLabel}
39
+ </Button>
40
+ <Button
41
+ size="sm"
42
+ variant="ghost"
43
+ className="h-11 sm:h-9"
44
+ onClick={onCancel}
45
+ >
46
+ Cancel
47
+ </Button>
48
+ </div>
49
+ </div>
50
+ );
51
+ }
@@ -1,55 +1,25 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
4
- import { Button } from "@/components/ui/button";
5
- import { Input } from "@/components/ui/input";
6
3
  import { useClientConfig } from "@/data/client-config/queries";
7
4
  import { useSetOwnerName } from "@/data/owner/mutations";
5
+ import { SettingSection } from "./SettingRow";
6
+ import { TextSetting } from "./settings-fields";
8
7
 
9
8
  // The name AgentOS and the orchestrators use when they refer to you.
10
9
  export function OwnerNameRow() {
11
10
  const saved = useClientConfig().data?.ownerName ?? "";
12
- // null: show what is saved.
13
- const [draft, setDraft] = useState<string | null>(null);
14
- const value = draft ?? saved;
15
11
  const save = useSetOwnerName();
16
12
  return (
17
- <form
18
- className="bg-muted/40 flex flex-col gap-2 rounded-lg px-3 py-2.5 sm:flex-row sm:items-center"
19
- onSubmit={(e) => {
20
- e.preventDefault();
21
- save.mutate(value.trim() || null, { onSuccess: () => setDraft(null) });
22
- }}
23
- >
24
- <div className="min-w-0 flex-1">
25
- <label htmlFor="owner-name" className="text-sm font-medium">
26
- Your name
27
- </label>
28
- <p className="text-muted-foreground text-xs">
29
- Used where AgentOS and your orchestrators refer to you. Orchestrators
30
- pick it up when they next start.
31
- </p>
32
- {save.error && (
33
- <p className="text-destructive text-xs">{save.error.message}</p>
34
- )}
35
- </div>
36
- <Input
13
+ <SettingSection>
14
+ <TextSetting
37
15
  id="owner-name"
38
- value={value}
39
- maxLength={40}
16
+ label="Your name"
17
+ help="Used where AgentOS and your orchestrators refer to you. Orchestrators pick it up when they next start."
40
18
  placeholder="Not set"
41
- onChange={(e) => setDraft(e.target.value)}
42
- className="min-h-11 sm:min-h-8 sm:w-44"
19
+ value={saved}
20
+ maxLength={40}
21
+ onSave={(name) => save.mutateAsync(name || null)}
43
22
  />
44
- <Button
45
- type="submit"
46
- size="sm"
47
- variant="outline"
48
- className="min-h-11 sm:min-h-8"
49
- disabled={save.isPending || value.trim() === saved}
50
- >
51
- Save
52
- </Button>
53
- </form>
23
+ </SettingSection>
54
24
  );
55
25
  }
@@ -0,0 +1,59 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { useAutoSave } from "@/hooks/useAutoSave";
5
+ import { SettingRow } from "./SettingRow";
6
+
7
+ /**
8
+ * A setting whose control is a picker of your own (a model list, say): the
9
+ * row, the instant save, rollback with a toast, Saved and Reset come from
10
+ * here. `render` gets the value to show and a `commit` that saves a new one.
11
+ */
12
+ export function PickerSetting<T>({
13
+ id,
14
+ label,
15
+ help,
16
+ value,
17
+ onSave,
18
+ defaultValue,
19
+ hint,
20
+ error,
21
+ render,
22
+ below,
23
+ }: {
24
+ id: string;
25
+ label: string;
26
+ help?: string;
27
+ value: T;
28
+ onSave: (next: T) => Promise<unknown>;
29
+ // Reset shows while the value differs from this.
30
+ defaultValue?: T;
31
+ hint?: string;
32
+ error?: string | null;
33
+ render: (picker: { value: T; commit: (next: T) => void }) => ReactNode;
34
+ below?: ReactNode;
35
+ }) {
36
+ const auto = useAutoSave({ saved: value, save: onSave });
37
+ return (
38
+ <SettingRow
39
+ id={id}
40
+ label={label}
41
+ help={help}
42
+ hint={hint}
43
+ error={error}
44
+ status={auto.status}
45
+ onUndo={auto.undo}
46
+ onReset={
47
+ defaultValue !== undefined && auto.value !== defaultValue
48
+ ? () => void auto.commit(defaultValue, { undoable: true })
49
+ : undefined
50
+ }
51
+ control={
52
+ <div className="w-60 max-w-full max-sm:w-full">
53
+ {render({ value: auto.value, commit: (v) => void auto.commit(v) })}
54
+ </div>
55
+ }
56
+ below={below}
57
+ />
58
+ );
59
+ }
@@ -0,0 +1,168 @@
1
+ "use client";
2
+
3
+ import { Check, Loader2 } from "lucide-react";
4
+ import { createContext, useContext, type ReactNode } from "react";
5
+ import type { SaveStatus } from "@/hooks/useAutoSave";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ // What the Settings search box holds; a row that doesn't match hides.
9
+ export const SettingsQueryContext = createContext("");
10
+
11
+ /** Whether a row with this text shows for the search box's words. */
12
+ export function matchesQuery(query: string, ...text: (string | undefined)[]) {
13
+ const q = query.trim().toLowerCase();
14
+ if (!q) return true;
15
+ const haystack = text.join(" ").toLowerCase();
16
+ return q.split(/\s+/).every((w) => haystack.includes(w));
17
+ }
18
+
19
+ /**
20
+ * One setting on a Settings page, 44px at least: label and one-line help on
21
+ * the left, the control on the right (below the label on a phone), and a meta
22
+ * line for the default hint or the error, "Saved", Undo and Reset. Every
23
+ * variant (settings-fields) renders through this.
24
+ */
25
+ export function SettingRow({
26
+ id,
27
+ label,
28
+ help,
29
+ control,
30
+ controlClassName,
31
+ hint,
32
+ error,
33
+ status = "idle",
34
+ onUndo,
35
+ onReset,
36
+ below,
37
+ }: {
38
+ id: string;
39
+ label: string;
40
+ help?: string;
41
+ control: ReactNode;
42
+ controlClassName?: string;
43
+ hint?: string;
44
+ error?: string | null;
45
+ status?: SaveStatus;
46
+ onUndo?: () => void;
47
+ // Given only while the value differs from the default.
48
+ onReset?: () => void;
49
+ below?: ReactNode;
50
+ }) {
51
+ const query = useContext(SettingsQueryContext);
52
+ if (!matchesQuery(query, label, help)) return null;
53
+ return (
54
+ <div
55
+ data-setting={id}
56
+ className="border-foreground/5 grid min-h-11 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-0.5 border-t px-3 py-2 first:border-t-0"
57
+ >
58
+ <div className="min-w-0">
59
+ <label htmlFor={id} className="block text-sm font-medium">
60
+ {label}
61
+ </label>
62
+ {help && (
63
+ <p className="text-muted-foreground text-xs leading-snug">{help}</p>
64
+ )}
65
+ </div>
66
+ <div
67
+ className={cn(
68
+ "flex min-w-0 items-center justify-end gap-2 max-sm:col-span-2 max-sm:row-start-2 max-sm:mt-1 max-sm:justify-start",
69
+ controlClassName
70
+ )}
71
+ >
72
+ {control}
73
+ </div>
74
+ {(error || hint || onReset || status !== "idle") && (
75
+ <div className="text-muted-foreground col-span-2 flex min-h-5 items-center justify-end gap-3 text-xs">
76
+ <span
77
+ id={`${id}-hint`}
78
+ className={cn("min-w-0 flex-1", error && "text-destructive")}
79
+ role={error ? "alert" : undefined}
80
+ >
81
+ {error ?? hint}
82
+ </span>
83
+ <span className="flex shrink-0 items-center gap-3" aria-live="polite">
84
+ {status === "saving" && (
85
+ <span className="inline-flex items-center gap-1">
86
+ <Loader2 className="size-3 animate-spin" />
87
+ Saving…
88
+ </span>
89
+ )}
90
+ {(status === "saved" || status === "undo") && (
91
+ <span className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
92
+ <Check className="size-3" />
93
+ Saved
94
+ </span>
95
+ )}
96
+ {status === "undo" && onUndo && (
97
+ <button
98
+ type="button"
99
+ onClick={onUndo}
100
+ className="text-primary -my-3 -mr-1 min-h-11 px-1 hover:underline"
101
+ >
102
+ Undo
103
+ </button>
104
+ )}
105
+ {onReset && status !== "undo" && (
106
+ <button
107
+ type="button"
108
+ onClick={onReset}
109
+ className="text-primary -my-3 -mr-1 min-h-11 px-1 hover:underline"
110
+ >
111
+ Reset
112
+ </button>
113
+ )}
114
+ </span>
115
+ </div>
116
+ )}
117
+ {below && <div className="col-span-2">{below}</div>}
118
+ </div>
119
+ );
120
+ }
121
+
122
+ /** A titled group of rows with one line of help. */
123
+ export function SettingSection({
124
+ title,
125
+ help,
126
+ children,
127
+ }: {
128
+ title?: string;
129
+ help?: string;
130
+ children: ReactNode;
131
+ }) {
132
+ return (
133
+ <section className="mb-5 [&:not(:has([data-setting]))]:hidden">
134
+ {title && <h3 className="text-sm font-semibold">{title}</h3>}
135
+ {help && <p className="text-muted-foreground mb-2 text-xs">{help}</p>}
136
+ <div className="bg-muted/40 overflow-hidden rounded-xl">{children}</div>
137
+ </section>
138
+ );
139
+ }
140
+
141
+ /**
142
+ * A row whose control is a block of its own (a list, a segmented control):
143
+ * the label sits above it, and the search box filters it like any row.
144
+ */
145
+ export function SettingBlock({
146
+ id,
147
+ label,
148
+ help,
149
+ children,
150
+ }: {
151
+ id: string;
152
+ label?: string;
153
+ help?: string;
154
+ children: ReactNode;
155
+ }) {
156
+ const query = useContext(SettingsQueryContext);
157
+ if (!matchesQuery(query, label, help)) return null;
158
+ return (
159
+ <div
160
+ data-setting={id}
161
+ className="border-foreground/5 space-y-1.5 border-t px-3 py-2 first:border-t-0"
162
+ >
163
+ {label && <p className="text-sm font-medium">{label}</p>}
164
+ {children}
165
+ {help && <p className="text-muted-foreground text-xs">{help}</p>}
166
+ </div>
167
+ );
168
+ }
@@ -34,6 +34,7 @@ vi.mock("./NoApprovalsSection", () => ({
34
34
  vi.mock("@/components/Workspaces", () => ({
35
35
  WorkspaceSettings: stub("workspaces"),
36
36
  }));
37
+ vi.mock("./ContextSection", () => ({ ContextSection: stub("context") }));
37
38
  vi.mock("@/components/Models/ModelsSection", () => ({
38
39
  ModelsSection: stub("models"),
39
40
  }));
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { memo, useEffect, useRef } from "react";
3
+ import { memo, useEffect, useRef, useState } from "react";
4
4
  import { useSnapshot } from "valtio";
5
5
  import {
6
6
  ArrowLeft,
@@ -12,7 +12,9 @@ import {
12
12
  Cpu,
13
13
  GitMerge,
14
14
  HeartPulse,
15
+ Layers,
15
16
  LayoutGrid,
17
+ Search,
16
18
  Link2,
17
19
  ListChecks,
18
20
  ShieldOff,
@@ -48,6 +50,8 @@ import { cn } from "@/lib/utils";
48
50
  import { schedulesUiActions } from "@/stores/schedulesUi";
49
51
  import { RecipesPanel } from "@/components/Recipes";
50
52
  import { settingsUi, settingsUiActions } from "@/stores/settingsUi";
53
+ import { ContextSection } from "./ContextSection";
54
+ import { SettingsQueryContext } from "./SettingRow";
51
55
  import { SetupSection } from "./SetupSection";
52
56
  import { NoApprovalsSection } from "./NoApprovalsSection";
53
57
  import {
@@ -84,6 +88,11 @@ export const SECTION_INFO: Record<
84
88
  hint: "Snapshot links you made, with views, and revoke",
85
89
  icon: Link2,
86
90
  },
91
+ context: {
92
+ label: "Context",
93
+ hint: "When agents start fresh or compact",
94
+ icon: Layers,
95
+ },
87
96
  models: {
88
97
  label: "Models",
89
98
  hint: "Which model each kind of work runs on, and providers",
@@ -150,6 +159,8 @@ function SectionBody({
150
159
  return <NoApprovalsSection />;
151
160
  case "workspaces":
152
161
  return <WorkspaceSettings />;
162
+ case "context":
163
+ return <ContextSection />;
153
164
  case "models":
154
165
  return <ModelsSection />;
155
166
  case "agent-roles":
@@ -177,6 +188,40 @@ function SectionBody({
177
188
  }
178
189
  }
179
190
 
191
+ // Sections built from SettingRow, which the search box filters.
192
+ const SEARCHABLE: ReadonlySet<SettingsSection> = new Set([
193
+ "context",
194
+ "workspaces",
195
+ ]);
196
+
197
+ function SearchableBody({
198
+ section,
199
+ browser,
200
+ }: {
201
+ section: SettingsSection;
202
+ browser: BrowserNotifications;
203
+ }) {
204
+ const [query, setQuery] = useState("");
205
+ if (!SEARCHABLE.has(section))
206
+ return <SectionBody section={section} browser={browser} />;
207
+ return (
208
+ <SettingsQueryContext.Provider value={query}>
209
+ <div className="relative mb-4">
210
+ <Search className="text-muted-foreground pointer-events-none absolute top-3.5 left-3 size-4 md:top-2.5" />
211
+ <input
212
+ type="search"
213
+ value={query}
214
+ onChange={(e) => setQuery(e.target.value)}
215
+ placeholder="Search settings"
216
+ aria-label="Search settings"
217
+ className="bg-input/60 hairline h-11 w-full rounded-xl border-0 pr-3 pl-9 text-base outline-none md:h-9 md:text-sm"
218
+ />
219
+ </div>
220
+ <SectionBody section={section} browser={browser} />
221
+ </SettingsQueryContext.Provider>
222
+ );
223
+ }
224
+
180
225
  /**
181
226
  * Every AgentOS setting, in one place. Wide screens show the sections beside
182
227
  * the open one; a phone shows the list, then the section with a way back.
@@ -323,7 +368,7 @@ export const SettingsDialog = memo(function SettingsDialog({
323
368
  {SECTION_INFO[shown].label}
324
369
  </h2>
325
370
  {open && (
326
- <SectionBody
371
+ <SearchableBody
327
372
  key={shown}
328
373
  section={shown}
329
374
  browser={notifications}
@@ -0,0 +1,103 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+
6
+ // Radix dispatches its own events; they must be jsdom's, not Node's.
7
+ for (const key of ["Event", "CustomEvent", "FocusEvent", "HTMLButtonElement"])
8
+ Object.defineProperty(globalThis, key, {
9
+ value: (window as unknown as Record<string, unknown>)[key],
10
+ configurable: true,
11
+ writable: true,
12
+ });
13
+ (
14
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
15
+ ).IS_REACT_ACT_ENVIRONMENT = true;
16
+ vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
17
+
18
+ const { NumberSetting } = await import("./settings-fields");
19
+
20
+ let root: Root;
21
+ let host: HTMLElement;
22
+ afterEach(async () => {
23
+ await act(async () => root.unmount());
24
+ host.remove();
25
+ });
26
+
27
+ async function mount(save: (n: number) => Promise<unknown>) {
28
+ host = document.createElement("div");
29
+ document.body.appendChild(host);
30
+ root = createRoot(host);
31
+ await act(async () =>
32
+ root.render(
33
+ createElement(NumberSetting, {
34
+ id: "n",
35
+ label: "Days",
36
+ value: 7,
37
+ defaultValue: 7,
38
+ min: 1,
39
+ max: 30,
40
+ onSave: save,
41
+ })
42
+ )
43
+ );
44
+ return host.querySelector("input")!;
45
+ }
46
+
47
+ // React tracks the value; set it the way the browser does, then blur.
48
+ const type = async (input: HTMLInputElement, text: string) => {
49
+ const set = Object.getOwnPropertyDescriptor(
50
+ HTMLInputElement.prototype,
51
+ "value"
52
+ )!.set!;
53
+ await act(async () => {
54
+ set.call(input, text);
55
+ input.dispatchEvent(new Event("input", { bubbles: true }));
56
+ });
57
+ };
58
+ const blur = (input: HTMLInputElement) =>
59
+ act(async () => {
60
+ input.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
61
+ });
62
+
63
+ describe("NumberSetting", () => {
64
+ it("keeps an invalid value in the field with a message and sends nothing", async () => {
65
+ const save = vi.fn().mockResolvedValue(undefined);
66
+ const input = await mount(save);
67
+ await type(input, "40");
68
+ await blur(input);
69
+ expect(save).not.toHaveBeenCalled();
70
+ expect(input.value).toBe("40");
71
+ expect(host.textContent).toContain("Enter a whole number from 1 to 30.");
72
+ });
73
+
74
+ it("saves a valid value on blur", async () => {
75
+ const save = vi.fn().mockResolvedValue(undefined);
76
+ const input = await mount(save);
77
+ await type(input, "14");
78
+ await blur(input);
79
+ expect(save).toHaveBeenCalledWith(14);
80
+ });
81
+
82
+ it("reverts on Escape without saving", async () => {
83
+ const save = vi.fn().mockResolvedValue(undefined);
84
+ const input = await mount(save);
85
+ await type(input, "40");
86
+ await act(async () => {
87
+ input.dispatchEvent(
88
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
89
+ );
90
+ });
91
+ expect(input.value).toBe("7");
92
+ expect(save).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it("goes back to the saved value when the server rejects", async () => {
96
+ const save = vi.fn().mockRejectedValue(new Error("no"));
97
+ const input = await mount(save);
98
+ await type(input, "14");
99
+ await blur(input);
100
+ expect(save).toHaveBeenCalledWith(14);
101
+ expect(input.value).toBe("7");
102
+ });
103
+ });