@saadnvd1/agent-os 0.3.845 → 0.3.851

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 (80) hide show
  1. package/README.md +6 -0
  2. package/app/api/approvals/route.ts +5 -1
  3. package/app/api/health/config/route.ts +2 -0
  4. package/app/api/updates/run/route.ts +7 -3
  5. package/app/globals.css +6 -0
  6. package/components/Audits/AuditsPanel.tsx +42 -35
  7. package/components/Chat/quote-selection.test.ts +6 -0
  8. package/components/Chat/quote-selection.ts +13 -10
  9. package/components/Devices/ConnectCard.tsx +52 -56
  10. package/components/Devices/DeviceGroupRow.tsx +65 -0
  11. package/components/Devices/DeviceRow.tsx +2 -2
  12. package/components/Devices/DevicesPanel.tsx +63 -49
  13. package/components/Devices/NetworkSection.tsx +29 -26
  14. package/components/Devices/TailscaleCard.tsx +9 -10
  15. package/components/Devices/group.test.ts +66 -0
  16. package/components/Devices/group.ts +67 -0
  17. package/components/Health/HealthPanel.tsx +5 -81
  18. package/components/Health/MachineChecks.tsx +20 -0
  19. package/components/Health/SweepRuns.tsx +46 -51
  20. package/components/Health/Thresholds.tsx +50 -0
  21. package/components/Merge/GlobalMergeSection.tsx +5 -6
  22. package/components/Merge/MergeApprovalToggle.tsx +20 -14
  23. package/components/Merge/MergeSettingsFields.tsx +0 -2
  24. package/components/Merge/WorktreeSweepSection.tsx +15 -34
  25. package/components/Models/AgentRoleCard.tsx +7 -7
  26. package/components/Models/ModelSelect.tsx +14 -3
  27. package/components/Models/ModelsSection.tsx +42 -37
  28. package/components/Models/ProviderCard.tsx +1 -1
  29. package/components/Models/RoleModelSelect.tsx +15 -3
  30. package/components/Models/SubagentModels.tsx +60 -45
  31. package/components/PhoneNotify/PhoneNotifyForm.tsx +6 -13
  32. package/components/Recipes/RecipesPanel.tsx +20 -9
  33. package/components/Recipes/RunHistory.tsx +1 -1
  34. package/components/Schedules/SchedulesPanel.tsx +12 -10
  35. package/components/Settings/AppWideLimits.tsx +2 -2
  36. package/components/Settings/{NoApprovalsSection.test.ts → ApprovalsSection.test.ts} +61 -22
  37. package/components/Settings/ApprovalsSection.tsx +93 -0
  38. package/components/Settings/ExperimentsSection.test.ts +20 -4
  39. package/components/Settings/ExperimentsSection.tsx +54 -34
  40. package/components/Settings/GeneralSection.tsx +39 -0
  41. package/components/Settings/ListSection.test.ts +85 -0
  42. package/components/Settings/ListSection.tsx +132 -0
  43. package/components/Settings/NotificationsSection.tsx +31 -29
  44. package/components/Settings/PickerSetting.tsx +5 -1
  45. package/components/Settings/SettingRow.tsx +13 -8
  46. package/components/Settings/SettingsDialog.test.ts +31 -4
  47. package/components/Settings/SettingsDialog.tsx +22 -17
  48. package/components/Settings/ValueLabel.tsx +31 -0
  49. package/components/Settings/settings-fields.test.ts +81 -2
  50. package/components/Settings/settings-fields.tsx +27 -27
  51. package/components/Shares/SharedLinksPanel.tsx +11 -14
  52. package/components/Updates/AutoUpdates.tsx +12 -8
  53. package/components/Updates/UpdateActions.tsx +18 -3
  54. package/components/Updates/UpdateToast.test.ts +17 -0
  55. package/components/Updates/UpdateToast.tsx +15 -5
  56. package/components/Updates/UpdatesSection.tsx +27 -18
  57. package/components/Workspaces/WorkspaceSettings.test.ts +5 -5
  58. package/components/Workspaces/WorkspaceSettings.tsx +0 -2
  59. package/components/ui/switch.tsx +1 -1
  60. package/content/whats-new.json +24 -0
  61. package/data/approvals/index.ts +2 -2
  62. package/data/health/index.ts +3 -0
  63. package/hooks/useAutoSave.ts +18 -5
  64. package/hooks/useReloadWhenUpdated.ts +9 -0
  65. package/hooks/useSettingsUrl.ts +6 -3
  66. package/lib/approvals/describe.ts +64 -0
  67. package/lib/health/config.ts +70 -17
  68. package/lib/models/providers.ts +1 -1
  69. package/lib/recipes/settle.ts +22 -0
  70. package/lib/settings-url.ts +14 -2
  71. package/lib/updates/banner.ts +12 -2
  72. package/lib/updates/request.ts +29 -0
  73. package/lib/updates/run.ts +24 -5
  74. package/lib/updates/view.ts +3 -0
  75. package/package.json +1 -1
  76. package/scripts/autodeploy +25 -3
  77. package/scripts/autodeploy.test.ts +47 -0
  78. package/components/Settings/NoApprovalsSection.tsx +0 -103
  79. package/components/Settings/SettingToggle.tsx +0 -36
  80. package/components/Settings/SetupSection.tsx +0 -46
@@ -0,0 +1,132 @@
1
+ "use client";
2
+
3
+ import { useState, type ReactNode } from "react";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ export const LIST_SECTION_LIMIT = 10;
7
+
8
+ /**
9
+ * A long list on a Settings page: a title with the count, the first
10
+ * `limit` items, "Show all" for the rest, and an empty state. Older items
11
+ * can go in a group of their own (`older`), collapsed until asked for.
12
+ */
13
+ export function ListSection<T>({
14
+ title,
15
+ help,
16
+ items,
17
+ keyOf,
18
+ render,
19
+ empty,
20
+ limit = LIST_SECTION_LIMIT,
21
+ older,
22
+ olderLabel = "Older",
23
+ action,
24
+ className,
25
+ }: {
26
+ title: string;
27
+ help?: string;
28
+ items: readonly T[];
29
+ keyOf: (item: T) => string;
30
+ render: (item: T) => ReactNode;
31
+ empty: string;
32
+ limit?: number;
33
+ // Items that are shown only under their own "Show <label>" toggle.
34
+ older?: readonly T[];
35
+ olderLabel?: string;
36
+ action?: ReactNode;
37
+ className?: string;
38
+ }) {
39
+ const [all, setAll] = useState(false);
40
+ const [showOlder, setShowOlder] = useState(false);
41
+ const shown = all ? items : items.slice(0, limit);
42
+ const hidden = items.length - shown.length;
43
+ const oldCount = older?.length ?? 0;
44
+ const count = items.length + oldCount;
45
+ return (
46
+ <section className={cn("mb-5", className)} data-list-section={title}>
47
+ <div className="flex min-h-8 items-center gap-2">
48
+ <h3 className="text-sm font-semibold">
49
+ {title}
50
+ <span className="text-muted-foreground ml-1.5 font-normal tabular-nums">
51
+ {count}
52
+ </span>
53
+ </h3>
54
+ {action && <div className="ml-auto">{action}</div>}
55
+ </div>
56
+ {help && <p className="text-muted-foreground mb-2 text-xs">{help}</p>}
57
+ {count === 0 ? (
58
+ <p className="bg-muted/40 text-muted-foreground rounded-xl px-3 py-4 text-sm">
59
+ {empty}
60
+ </p>
61
+ ) : (
62
+ <div className="bg-muted/40 overflow-hidden rounded-xl">
63
+ <ul className="divide-foreground/5 divide-y">
64
+ {shown.map((item) => (
65
+ <li key={keyOf(item)}>{render(item)}</li>
66
+ ))}
67
+ </ul>
68
+ {hidden > 0 && (
69
+ <ShowButton onClick={() => setAll(true)}>
70
+ Show all {items.length}
71
+ </ShowButton>
72
+ )}
73
+ {all && items.length > limit && (
74
+ <ShowButton onClick={() => setAll(false)}>Show fewer</ShowButton>
75
+ )}
76
+ {oldCount > 0 && (
77
+ <>
78
+ <ShowButton onClick={() => setShowOlder((v) => !v)}>
79
+ {showOlder
80
+ ? `Hide ${olderLabel}`
81
+ : `${olderLabel} (${oldCount})`}
82
+ </ShowButton>
83
+ {showOlder && (
84
+ <ul className="divide-foreground/5 divide-y">
85
+ {older!.map((item) => (
86
+ <li key={keyOf(item)}>{render(item)}</li>
87
+ ))}
88
+ </ul>
89
+ )}
90
+ </>
91
+ )}
92
+ </div>
93
+ )}
94
+ </section>
95
+ );
96
+ }
97
+
98
+ function ShowButton({
99
+ onClick,
100
+ children,
101
+ }: {
102
+ onClick: () => void;
103
+ children: ReactNode;
104
+ }) {
105
+ return (
106
+ <button
107
+ type="button"
108
+ onClick={onClick}
109
+ className="text-primary border-foreground/5 hover:bg-muted/60 min-h-11 w-full border-t px-3 text-left text-sm"
110
+ >
111
+ {children}
112
+ </button>
113
+ );
114
+ }
115
+
116
+ /** A table that scrolls sideways in its own box, not the page. */
117
+ export function ScrollTable({
118
+ children,
119
+ className,
120
+ }: {
121
+ children: ReactNode;
122
+ className?: string;
123
+ }) {
124
+ return (
125
+ <div
126
+ className={cn("max-w-full overflow-x-auto", className)}
127
+ data-scroll-table
128
+ >
129
+ {children}
130
+ </div>
131
+ );
132
+ }
@@ -3,7 +3,8 @@
3
3
  import { Button } from "@/components/ui/button";
4
4
  import { PhoneNotifyForm } from "@/components/PhoneNotify/PhoneNotifyForm";
5
5
  import type { NotificationSettings } from "@/lib/notifications";
6
- import { SettingToggle } from "./SettingToggle";
6
+ import { SettingRow, SettingSection } from "./SettingRow";
7
+ import { SwitchSetting } from "./settings-fields";
7
8
 
8
9
  // This browser's alerts live in the page (useNotifications), so they come in
9
10
  // as props.
@@ -21,44 +22,45 @@ export function NotificationsSection({
21
22
  }) {
22
23
  return (
23
24
  <div className="space-y-5">
24
- <div className="space-y-2">
25
- <h3 className="text-sm font-medium">This browser</h3>
26
- <SettingToggle
27
- title="Sound"
28
- detail="Play a sound when a session needs you or hits an error."
25
+ <SettingSection title="This browser">
26
+ <SwitchSetting
27
+ id="notify-sound"
28
+ label="Sound"
29
+ help="Play a sound when a session needs you or hits an error."
29
30
  checked={browser.settings.sound}
30
- onChange={(sound) => browser.updateSettings({ sound })}
31
+ defaultValue={true}
32
+ onSave={async (sound) => browser.updateSettings({ sound })}
31
33
  />
32
34
  {browser.permissionGranted ? (
33
- <SettingToggle
34
- title="Browser alerts"
35
- detail="Show a system notification when a session needs you and AgentOS isn't in front."
35
+ <SwitchSetting
36
+ id="notify-browser"
37
+ label="Browser alerts"
38
+ help="Show a system notification when a session needs you and AgentOS isn't in front."
36
39
  checked={browser.settings.browserNotifications}
37
- onChange={(browserNotifications) =>
40
+ onSave={async (browserNotifications) =>
38
41
  browser.updateSettings({ browserNotifications })
39
42
  }
40
43
  />
41
44
  ) : (
42
- <div className="bg-muted/40 flex items-center gap-3 rounded-lg px-3 py-2.5">
43
- <div className="min-w-0 flex-1">
44
- <p className="text-sm font-medium">Browser alerts</p>
45
- <p className="text-muted-foreground text-xs">
46
- This browser hasn&rsquo;t allowed AgentOS to show notifications.
47
- </p>
48
- </div>
49
- <Button
50
- size="sm"
51
- variant="outline"
52
- className="h-11 md:h-8"
53
- onClick={() => void browser.requestPermission()}
54
- >
55
- Allow
56
- </Button>
57
- </div>
45
+ <SettingRow
46
+ id="notify-browser"
47
+ label="Browser alerts"
48
+ help="This browser hasn't allowed AgentOS to show notifications."
49
+ control={
50
+ <Button
51
+ size="sm"
52
+ variant="outline"
53
+ className="h-11 md:h-9"
54
+ onClick={() => void browser.requestPermission()}
55
+ >
56
+ Allow
57
+ </Button>
58
+ }
59
+ />
58
60
  )}
59
- </div>
61
+ </SettingSection>
60
62
  <div className="space-y-2">
61
- <h3 className="text-sm font-medium">Your phone</h3>
63
+ <h3 className="text-sm font-semibold">Your phone</h3>
62
64
  <PhoneNotifyForm />
63
65
  </div>
64
66
  </div>
@@ -18,6 +18,7 @@ export function PickerSetting<T>({
18
18
  defaultValue,
19
19
  hint,
20
20
  error,
21
+ resetLabel,
21
22
  render,
22
23
  below,
23
24
  }: {
@@ -30,6 +31,8 @@ export function PickerSetting<T>({
30
31
  defaultValue?: T;
31
32
  hint?: string;
32
33
  error?: string | null;
34
+ // What Reset goes back to, in words.
35
+ resetLabel?: string;
33
36
  render: (picker: { value: T; commit: (next: T) => void }) => ReactNode;
34
37
  below?: ReactNode;
35
38
  }) {
@@ -40,7 +43,8 @@ export function PickerSetting<T>({
40
43
  label={label}
41
44
  help={help}
42
45
  hint={hint}
43
- error={error}
46
+ error={error ?? auto.error}
47
+ resetLabel={resetLabel}
44
48
  status={auto.status}
45
49
  onUndo={auto.undo}
46
50
  onReset={
@@ -16,6 +16,10 @@ export function matchesQuery(query: string, ...text: (string | undefined)[]) {
16
16
  return q.split(/\s+/).every((w) => haystack.includes(w));
17
17
  }
18
18
 
19
+ // One padding and one grid for every row: label left, control right.
20
+ const ROW =
21
+ "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.5 first:border-t-0";
22
+
19
23
  /**
20
24
  * One setting on a Settings page, 44px at least: label and one-line help on
21
25
  * the left, the control on the right (below the label on a phone), and a meta
@@ -33,11 +37,12 @@ export function SettingRow({
33
37
  status = "idle",
34
38
  onUndo,
35
39
  onReset,
40
+ resetLabel,
36
41
  below,
37
42
  }: {
38
43
  id: string;
39
44
  label: string;
40
- help?: string;
45
+ help?: ReactNode;
41
46
  control: ReactNode;
42
47
  controlClassName?: string;
43
48
  hint?: string;
@@ -46,15 +51,15 @@ export function SettingRow({
46
51
  onUndo?: () => void;
47
52
  // Given only while the value differs from the default.
48
53
  onReset?: () => void;
54
+ // What Reset goes back to, in words ("squash", "on", "200k").
55
+ resetLabel?: string;
49
56
  below?: ReactNode;
50
57
  }) {
51
58
  const query = useContext(SettingsQueryContext);
52
- if (!matchesQuery(query, label, help)) return null;
59
+ if (!matchesQuery(query, label, typeof help === "string" ? help : undefined))
60
+ return null;
53
61
  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
- >
62
+ <div data-setting={id} className={ROW}>
58
63
  <div className="min-w-0">
59
64
  <label htmlFor={id} className="block text-sm font-medium">
60
65
  {label}
@@ -108,7 +113,7 @@ export function SettingRow({
108
113
  onClick={onReset}
109
114
  className="text-primary -my-3 -mr-1 min-h-11 px-1 hover:underline"
110
115
  >
111
- Reset
116
+ {resetLabel ? `Reset to ${resetLabel}` : "Reset"}
112
117
  </button>
113
118
  )}
114
119
  </span>
@@ -158,7 +163,7 @@ export function SettingBlock({
158
163
  return (
159
164
  <div
160
165
  data-setting={id}
161
- className="border-foreground/5 space-y-1.5 border-t px-3 py-2 first:border-t-0"
166
+ className="border-foreground/5 space-y-1.5 border-t px-3 py-2.5 first:border-t-0"
162
167
  >
163
168
  {label && <p className="text-sm font-medium">{label}</p>}
164
169
  {children}
@@ -28,9 +28,14 @@ const stub = (name: string) =>
28
28
  vi.mock("@/components/Merge/GlobalMergeSection", () => ({
29
29
  GlobalMergeSection: stub("merging"),
30
30
  }));
31
- vi.mock("./NoApprovalsSection", () => ({
32
- NoApprovalsSection: stub("approvals"),
31
+ vi.mock("./ApprovalsSection", () => ({
32
+ ApprovalsSection: stub("approvals"),
33
33
  }));
34
+ vi.mock("./GeneralSection", () => ({ GeneralSection: stub("general") }));
35
+ vi.mock("@/components/Health/HealthPanel", () => ({
36
+ HealthPanel: stub("health"),
37
+ }));
38
+ vi.mock("@/components/Recipes", () => ({ RecipesPanel: stub("recipes") }));
34
39
  vi.mock("@/components/Workspaces", () => ({
35
40
  WorkspaceSettings: stub("workspaces"),
36
41
  }));
@@ -128,8 +133,8 @@ describe("SettingsDialog", () => {
128
133
  it("shows the first section beside the list when none is chosen", async () => {
129
134
  await mount();
130
135
  await act(async () => settingsUiActions.open());
131
- expect(body()).toContain("body:merging");
132
- expect(navButton("Merging").getAttribute("aria-current")).toBe("page");
136
+ expect(body()).toContain("body:general");
137
+ expect(navButton("General").getAttribute("aria-current")).toBe("page");
133
138
  });
134
139
 
135
140
  it("goes back to the list on a phone", async () => {
@@ -198,6 +203,28 @@ describe("SettingsDialog", () => {
198
203
  expect(body()).toContain("body:workspaces");
199
204
  });
200
205
 
206
+ it("sends an old ?settings=setup link to Health and rewrites the address", async () => {
207
+ await mount("/?session=s1&settings=setup");
208
+ await flush();
209
+ expect(settingsUi).toMatchObject({ open: true, section: "health" });
210
+ expect(body()).toContain("body:health");
211
+ expect(search()).toBe("?session=s1&settings=health");
212
+ });
213
+
214
+ it("names each section once in the list", async () => {
215
+ await mount();
216
+ await act(async () => settingsUiActions.open());
217
+ const labels = [
218
+ ...document.querySelectorAll(
219
+ 'nav[aria-label="Settings sections"] button'
220
+ ),
221
+ ].map((b) => b.querySelector("span span")?.textContent);
222
+ expect(new Set(labels).size).toBe(labels.length);
223
+ expect(labels).toContain("Approvals");
224
+ expect(labels).not.toContain("No approvals");
225
+ expect(labels).not.toContain("Setup & health");
226
+ });
227
+
201
228
  it("drops the section from the address when closed", async () => {
202
229
  await mount("/?session=s1&settings=merging");
203
230
  await act(async () => settingsUiActions.close());
@@ -16,8 +16,8 @@ import {
16
16
  LayoutGrid,
17
17
  Search,
18
18
  Link2,
19
- ListChecks,
20
- ShieldOff,
19
+ ShieldCheck,
20
+ SlidersHorizontal,
21
21
  FlaskConical,
22
22
  RefreshCw,
23
23
  Smartphone,
@@ -52,8 +52,8 @@ import { RecipesPanel } from "@/components/Recipes";
52
52
  import { settingsUi, settingsUiActions } from "@/stores/settingsUi";
53
53
  import { ContextSection } from "./ContextSection";
54
54
  import { SettingsQueryContext } from "./SettingRow";
55
- import { SetupSection } from "./SetupSection";
56
- import { NoApprovalsSection } from "./NoApprovalsSection";
55
+ import { ApprovalsSection } from "./ApprovalsSection";
56
+ import { GeneralSection } from "./GeneralSection";
57
57
  import {
58
58
  NotificationsSection,
59
59
  type BrowserNotifications,
@@ -63,15 +63,20 @@ export const SECTION_INFO: Record<
63
63
  SettingsSection,
64
64
  { label: string; hint: string; icon: LucideIcon }
65
65
  > = {
66
+ general: {
67
+ label: "General",
68
+ hint: "Your name, alerts, first-run setup",
69
+ icon: SlidersHorizontal,
70
+ },
66
71
  merging: {
67
72
  label: "Merging",
68
- hint: "Approval, merge method, branch and worktree clean-up",
73
+ hint: "Merge method, branch and worktree clean-up",
69
74
  icon: GitMerge,
70
75
  },
71
76
  approvals: {
72
- label: "No approvals",
73
- hint: "Switch off every approval and permission prompt",
74
- icon: ShieldOff,
77
+ label: "Approvals",
78
+ hint: "No approvals, merge approval, what was approved for you",
79
+ icon: ShieldCheck,
75
80
  },
76
81
  workspaces: {
77
82
  label: "Workspaces",
@@ -125,14 +130,9 @@ export const SECTION_INFO: Record<
125
130
  },
126
131
  health: {
127
132
  label: "Health",
128
- hint: "What the watchers caught, what normal looks like, thresholds",
133
+ hint: "What the watchers caught, thresholds, clean-up, this machine",
129
134
  icon: HeartPulse,
130
135
  },
131
- setup: {
132
- label: "Setup & health",
133
- hint: "What this machine has, agents, first-run setup",
134
- icon: ListChecks,
135
- },
136
136
  updates: {
137
137
  label: "Updates",
138
138
  hint: "Version, check for updates, the update banner, tips",
@@ -153,10 +153,12 @@ function SectionBody({
153
153
  browser: BrowserNotifications;
154
154
  }) {
155
155
  switch (section) {
156
+ case "general":
157
+ return <GeneralSection />;
156
158
  case "merging":
157
159
  return <GlobalMergeSection />;
158
160
  case "approvals":
159
- return <NoApprovalsSection />;
161
+ return <ApprovalsSection />;
160
162
  case "workspaces":
161
163
  return <WorkspaceSettings />;
162
164
  case "context":
@@ -179,8 +181,6 @@ function SectionBody({
179
181
  return <AuditsPanel />;
180
182
  case "health":
181
183
  return <HealthPanel />;
182
- case "setup":
183
- return <SetupSection />;
184
184
  case "updates":
185
185
  return <UpdatesSection />;
186
186
  case "experiments":
@@ -234,6 +234,11 @@ export const SettingsDialog = memo(function SettingsDialog({
234
234
  }) {
235
235
  const { open, section } = useSnapshot(settingsUi);
236
236
  useSettingsUrl();
237
+ useEffect(() => {
238
+ if (!open) return;
239
+ document.documentElement.setAttribute("data-settings-open", "");
240
+ return () => document.documentElement.removeAttribute("data-settings-open");
241
+ }, [open]);
237
242
  // A wide screen always shows a section; a phone shows the list first.
238
243
  const shown = section ?? SETTINGS_SECTIONS[0];
239
244
 
@@ -0,0 +1,31 @@
1
+ import { cn } from "@/lib/utils";
2
+
3
+ /**
4
+ * What a select trigger or item shows: a short label, and the resolved
5
+ * value (a model id, say) under it in secondary text. Each line ends in an
6
+ * ellipsis at the edge, never cut through a letter.
7
+ */
8
+ export function ValueLabel({
9
+ label,
10
+ detail,
11
+ className,
12
+ }: {
13
+ label: string;
14
+ detail?: string;
15
+ className?: string;
16
+ }) {
17
+ return (
18
+ <span
19
+ className={cn("flex min-w-0 flex-col text-left leading-tight", className)}
20
+ >
21
+ <span className="truncate">{label}</span>
22
+ {detail && (
23
+ <span className="text-muted-foreground truncate text-xs">{detail}</span>
24
+ )}
25
+ </span>
26
+ );
27
+ }
28
+
29
+ // The trigger classes that let a ValueLabel use two lines.
30
+ export const TWO_LINE_TRIGGER =
31
+ "h-auto min-h-11 py-1.5 md:min-h-9 [&>span]:line-clamp-none";
@@ -4,7 +4,13 @@ import { createRoot, type Root } from "react-dom/client";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
 
6
6
  // Radix dispatches its own events; they must be jsdom's, not Node's.
7
- for (const key of ["Event", "CustomEvent", "FocusEvent", "HTMLButtonElement"])
7
+ for (const key of [
8
+ "Event",
9
+ "CustomEvent",
10
+ "FocusEvent",
11
+ "HTMLButtonElement",
12
+ "HTMLFormElement",
13
+ ])
8
14
  Object.defineProperty(globalThis, key, {
9
15
  value: (window as unknown as Record<string, unknown>)[key],
10
16
  configurable: true,
@@ -15,7 +21,7 @@ for (const key of ["Event", "CustomEvent", "FocusEvent", "HTMLButtonElement"])
15
21
  ).IS_REACT_ACT_ENVIRONMENT = true;
16
22
  vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
17
23
 
18
- const { NumberSetting } = await import("./settings-fields");
24
+ const { NumberSetting, SwitchSetting } = await import("./settings-fields");
19
25
 
20
26
  let root: Root;
21
27
  let host: HTMLElement;
@@ -101,3 +107,76 @@ describe("NumberSetting", () => {
101
107
  expect(input.value).toBe("7");
102
108
  });
103
109
  });
110
+
111
+ async function render(el: ReturnType<typeof createElement>) {
112
+ host = document.createElement("div");
113
+ document.body.appendChild(host);
114
+ root = createRoot(host);
115
+ await act(async () => root.render(el));
116
+ }
117
+ const noop = () => Promise.resolve();
118
+
119
+ describe("Reset and default text", () => {
120
+ const num = (value: number, extra = {}) =>
121
+ createElement(NumberSetting, {
122
+ id: "n",
123
+ label: "Days",
124
+ value,
125
+ defaultValue: 7,
126
+ min: 1,
127
+ max: 30,
128
+ unit: "days",
129
+ onSave: noop,
130
+ ...extra,
131
+ });
132
+
133
+ it("shows no default text or Reset while the value is the default", async () => {
134
+ await render(num(7));
135
+ expect(host.textContent).not.toMatch(/default|Reset/i);
136
+ });
137
+
138
+ it("offers 'Reset to X' once it differs", async () => {
139
+ await render(num(9));
140
+ expect(host.textContent).toContain("Reset to 7");
141
+ expect(host.textContent).not.toContain("Default:");
142
+ });
143
+
144
+ it("puts the range in the help text and the unit right after the number", async () => {
145
+ await render(num(7));
146
+ expect(host.textContent).toContain("Allowed: 1–30 days.");
147
+ const input = host.querySelector("input")!;
148
+ expect(input.nextElementSibling?.textContent).toBe("days");
149
+ });
150
+
151
+ it("disables and empties the field while its switch is off", async () => {
152
+ await render(num(0, { optional: { off: 0, on: 7 } }));
153
+ const input = host.querySelector("input")!;
154
+ expect(input.disabled).toBe(true);
155
+ expect(input.value).toBe("");
156
+ });
157
+
158
+ it("shows Reset to on/off for a switch only when it differs", async () => {
159
+ await render(
160
+ createElement(SwitchSetting, {
161
+ id: "s",
162
+ label: "Sound",
163
+ checked: true,
164
+ defaultValue: true,
165
+ onSave: noop,
166
+ })
167
+ );
168
+ expect(host.textContent).not.toMatch(/Reset/);
169
+ await act(async () => root.unmount());
170
+ host.remove();
171
+ await render(
172
+ createElement(SwitchSetting, {
173
+ id: "s",
174
+ label: "Sound",
175
+ checked: false,
176
+ defaultValue: true,
177
+ onSave: noop,
178
+ })
179
+ );
180
+ expect(host.textContent).toContain("Reset to on");
181
+ });
182
+ });