@saadnvd1/agent-os 0.3.839 → 0.3.848

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 (100) hide show
  1. package/app/api/approvals/route.ts +5 -1
  2. package/app/api/health/config/route.ts +2 -0
  3. package/app/api/health/route.ts +4 -2
  4. package/app/globals.css +6 -0
  5. package/components/Audits/AuditsPanel.tsx +42 -35
  6. package/components/Chat/quote-selection.test.ts +6 -0
  7. package/components/Chat/quote-selection.ts +13 -10
  8. package/components/Devices/ConnectCard.tsx +52 -56
  9. package/components/Devices/DeviceGroupRow.tsx +65 -0
  10. package/components/Devices/DeviceRow.tsx +2 -2
  11. package/components/Devices/DevicesPanel.tsx +63 -49
  12. package/components/Devices/NetworkSection.tsx +29 -26
  13. package/components/Devices/TailscaleCard.tsx +9 -10
  14. package/components/Devices/group.test.ts +66 -0
  15. package/components/Devices/group.ts +67 -0
  16. package/components/Health/HealthPanel.tsx +5 -81
  17. package/components/Health/MachineChecks.tsx +20 -0
  18. package/components/Health/SweepRuns.tsx +46 -51
  19. package/components/Health/Thresholds.tsx +50 -0
  20. package/components/Merge/GlobalMergeSection.tsx +5 -6
  21. package/components/Merge/MergeApprovalToggle.tsx +20 -14
  22. package/components/Merge/MergeSettingsFields.tsx +0 -2
  23. package/components/Merge/WorktreeSweepSection.tsx +15 -34
  24. package/components/Models/AgentRoleCard.tsx +7 -7
  25. package/components/Models/ModelSelect.tsx +14 -3
  26. package/components/Models/ModelsSection.tsx +42 -37
  27. package/components/Models/ProviderCard.tsx +1 -1
  28. package/components/Models/RoleModelSelect.tsx +15 -3
  29. package/components/Models/SubagentModels.tsx +60 -45
  30. package/components/PhoneNotify/PhoneNotifyForm.tsx +6 -13
  31. package/components/Recipes/RecipesPanel.tsx +20 -9
  32. package/components/Recipes/RunHistory.tsx +1 -1
  33. package/components/Schedules/SchedulesPanel.tsx +12 -10
  34. package/components/SessionList/GroupShelves.tsx +8 -3
  35. package/components/SessionList/OrchestratorPinRow.tsx +2 -2
  36. package/components/SessionList/RowParts.tsx +39 -35
  37. package/components/SessionList/RowRename.test.ts +86 -17
  38. package/components/SessionList/RowShell.tsx +2 -5
  39. package/components/SessionList/SessionRow.tsx +0 -1
  40. package/components/Settings/AppWideLimits.tsx +2 -2
  41. package/components/Settings/{NoApprovalsSection.test.ts → ApprovalsSection.test.ts} +61 -22
  42. package/components/Settings/ApprovalsSection.tsx +93 -0
  43. package/components/Settings/ExperimentsSection.test.ts +20 -4
  44. package/components/Settings/ExperimentsSection.tsx +54 -34
  45. package/components/Settings/GeneralSection.tsx +39 -0
  46. package/components/Settings/ListSection.test.ts +85 -0
  47. package/components/Settings/ListSection.tsx +132 -0
  48. package/components/Settings/NotificationsSection.tsx +31 -29
  49. package/components/Settings/PickerSetting.tsx +5 -1
  50. package/components/Settings/SettingRow.tsx +91 -49
  51. package/components/Settings/SettingsDialog.test.ts +31 -4
  52. package/components/Settings/SettingsDialog.tsx +22 -17
  53. package/components/Settings/ValueLabel.tsx +31 -0
  54. package/components/Settings/settings-fields.test.ts +81 -2
  55. package/components/Settings/settings-fields.tsx +28 -27
  56. package/components/Shares/SharedLinksPanel.tsx +11 -14
  57. package/components/Updates/AutoUpdates.tsx +12 -8
  58. package/components/Updates/UpdatesSection.tsx +18 -17
  59. package/components/Workspaces/HqFolderForm.tsx +21 -19
  60. package/components/Workspaces/McpServersForm.tsx +20 -28
  61. package/components/Workspaces/ProjectTiersForm.tsx +25 -12
  62. package/components/Workspaces/RuleEditor.tsx +86 -0
  63. package/components/Workspaces/RulesForm.test.ts +58 -31
  64. package/components/Workspaces/RulesForm.tsx +129 -58
  65. package/components/Workspaces/WorkspaceModeForm.tsx +3 -3
  66. package/components/Workspaces/WorkspaceModelsForm.tsx +40 -41
  67. package/components/Workspaces/WorkspacePanel.tsx +73 -0
  68. package/components/Workspaces/WorkspacePicker.tsx +44 -0
  69. package/components/Workspaces/WorkspaceSettings.test.ts +153 -0
  70. package/components/Workspaces/WorkspaceSettings.tsx +51 -79
  71. package/components/Workspaces/useSettingsWorkspace.ts +47 -0
  72. package/components/ui/switch.tsx +1 -1
  73. package/content/whats-new.json +40 -0
  74. package/data/approvals/index.ts +2 -2
  75. package/data/health/index.ts +3 -0
  76. package/hooks/useAutoSave.ts +18 -5
  77. package/hooks/useSettingsUrl.ts +6 -3
  78. package/lib/approvals/describe.ts +64 -0
  79. package/lib/away/deploy.ts +34 -7
  80. package/lib/health/config.ts +70 -17
  81. package/lib/models/plan-fresh.ts +61 -0
  82. package/lib/models/plan-step.ts +40 -24
  83. package/lib/models/providers.ts +1 -1
  84. package/lib/orchestrator/scope.ts +10 -0
  85. package/lib/recipes/settle.ts +22 -0
  86. package/lib/settings-url.ts +14 -2
  87. package/lib/updates/banner.ts +2 -0
  88. package/lib/updates/run.ts +9 -0
  89. package/lib/updates/view.ts +28 -6
  90. package/lib/worktree-deps/paths.ts +9 -4
  91. package/lib/worktrees.ts +3 -0
  92. package/package.json +1 -1
  93. package/scripts/autodeploy +11 -0
  94. package/scripts/autodeploy.test.ts +30 -0
  95. package/scripts/lib/update-verify.mjs +11 -0
  96. package/scripts/update-run.mjs +29 -0
  97. package/scripts/update-run.test.ts +62 -4
  98. package/components/Settings/NoApprovalsSection.tsx +0 -103
  99. package/components/Settings/SettingToggle.tsx +0 -36
  100. package/components/Settings/SetupSection.tsx +0 -46
@@ -8,6 +8,18 @@ import { cn } from "@/lib/utils";
8
8
  // What the Settings search box holds; a row that doesn't match hides.
9
9
  export const SettingsQueryContext = createContext("");
10
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
+ // 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
+
11
23
  /**
12
24
  * One setting on a Settings page, 44px at least: label and one-line help on
13
25
  * the left, the control on the right (below the label on a phone), and a meta
@@ -25,11 +37,12 @@ export function SettingRow({
25
37
  status = "idle",
26
38
  onUndo,
27
39
  onReset,
40
+ resetLabel,
28
41
  below,
29
42
  }: {
30
43
  id: string;
31
44
  label: string;
32
- help?: string;
45
+ help?: ReactNode;
33
46
  control: ReactNode;
34
47
  controlClassName?: string;
35
48
  hint?: string;
@@ -38,17 +51,15 @@ export function SettingRow({
38
51
  onUndo?: () => void;
39
52
  // Given only while the value differs from the default.
40
53
  onReset?: () => void;
54
+ // What Reset goes back to, in words ("squash", "on", "200k").
55
+ resetLabel?: string;
41
56
  below?: ReactNode;
42
57
  }) {
43
- const query = useContext(SettingsQueryContext).trim().toLowerCase();
44
- const haystack = `${label} ${help ?? ""}`.toLowerCase();
45
- if (query && !query.split(/\s+/).every((w) => haystack.includes(w)))
58
+ const query = useContext(SettingsQueryContext);
59
+ if (!matchesQuery(query, label, typeof help === "string" ? help : undefined))
46
60
  return null;
47
61
  return (
48
- <div
49
- data-setting={id}
50
- 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"
51
- >
62
+ <div data-setting={id} className={ROW}>
52
63
  <div className="min-w-0">
53
64
  <label htmlFor={id} className="block text-sm font-medium">
54
65
  {label}
@@ -65,47 +76,49 @@ export function SettingRow({
65
76
  >
66
77
  {control}
67
78
  </div>
68
- <div className="text-muted-foreground col-span-2 flex min-h-5 items-center justify-between gap-2 text-xs">
69
- <span
70
- id={`${id}-hint`}
71
- className={cn("min-w-0", error && "text-destructive")}
72
- role={error ? "alert" : undefined}
73
- >
74
- {error ?? hint}
75
- </span>
76
- <span className="flex shrink-0 items-center gap-3" aria-live="polite">
77
- {status === "saving" && (
78
- <span className="inline-flex items-center gap-1">
79
- <Loader2 className="size-3 animate-spin" />
80
- Saving…
81
- </span>
82
- )}
83
- {(status === "saved" || status === "undo") && (
84
- <span className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
85
- <Check className="size-3" />
86
- Saved
87
- </span>
88
- )}
89
- {status === "undo" && onUndo && (
90
- <button
91
- type="button"
92
- onClick={onUndo}
93
- className="text-primary -my-3 -mr-1 min-h-11 px-1 hover:underline"
94
- >
95
- Undo
96
- </button>
97
- )}
98
- {onReset && status !== "undo" && (
99
- <button
100
- type="button"
101
- onClick={onReset}
102
- className="text-primary -my-3 -mr-1 min-h-11 px-1 hover:underline"
103
- >
104
- Reset
105
- </button>
106
- )}
107
- </span>
108
- </div>
79
+ {(error || hint || onReset || status !== "idle") && (
80
+ <div className="text-muted-foreground col-span-2 flex min-h-5 items-center justify-end gap-3 text-xs">
81
+ <span
82
+ id={`${id}-hint`}
83
+ className={cn("min-w-0 flex-1", error && "text-destructive")}
84
+ role={error ? "alert" : undefined}
85
+ >
86
+ {error ?? hint}
87
+ </span>
88
+ <span className="flex shrink-0 items-center gap-3" aria-live="polite">
89
+ {status === "saving" && (
90
+ <span className="inline-flex items-center gap-1">
91
+ <Loader2 className="size-3 animate-spin" />
92
+ Saving…
93
+ </span>
94
+ )}
95
+ {(status === "saved" || status === "undo") && (
96
+ <span className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
97
+ <Check className="size-3" />
98
+ Saved
99
+ </span>
100
+ )}
101
+ {status === "undo" && onUndo && (
102
+ <button
103
+ type="button"
104
+ onClick={onUndo}
105
+ className="text-primary -my-3 -mr-1 min-h-11 px-1 hover:underline"
106
+ >
107
+ Undo
108
+ </button>
109
+ )}
110
+ {onReset && status !== "undo" && (
111
+ <button
112
+ type="button"
113
+ onClick={onReset}
114
+ className="text-primary -my-3 -mr-1 min-h-11 px-1 hover:underline"
115
+ >
116
+ {resetLabel ? `Reset to ${resetLabel}` : "Reset"}
117
+ </button>
118
+ )}
119
+ </span>
120
+ </div>
121
+ )}
109
122
  {below && <div className="col-span-2">{below}</div>}
110
123
  </div>
111
124
  );
@@ -129,3 +142,32 @@ export function SettingSection({
129
142
  </section>
130
143
  );
131
144
  }
145
+
146
+ /**
147
+ * A row whose control is a block of its own (a list, a segmented control):
148
+ * the label sits above it, and the search box filters it like any row.
149
+ */
150
+ export function SettingBlock({
151
+ id,
152
+ label,
153
+ help,
154
+ children,
155
+ }: {
156
+ id: string;
157
+ label?: string;
158
+ help?: string;
159
+ children: ReactNode;
160
+ }) {
161
+ const query = useContext(SettingsQueryContext);
162
+ if (!matchesQuery(query, label, help)) return null;
163
+ return (
164
+ <div
165
+ data-setting={id}
166
+ className="border-foreground/5 space-y-1.5 border-t px-3 py-2.5 first:border-t-0"
167
+ >
168
+ {label && <p className="text-sm font-medium">{label}</p>}
169
+ {children}
170
+ {help && <p className="text-muted-foreground text-xs">{help}</p>}
171
+ </div>
172
+ );
173
+ }
@@ -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
+ });
@@ -32,11 +32,12 @@ export function SwitchSetting({
32
32
  confirmLabel = "Turn on",
33
33
  undoable,
34
34
  locked,
35
+ disabled,
35
36
  extra,
36
37
  }: {
37
38
  id: string;
38
39
  label: string;
39
- help?: string;
40
+ help?: ReactNode;
40
41
  checked: boolean;
41
42
  onSave: Save<boolean>;
42
43
  defaultValue?: boolean;
@@ -45,6 +46,8 @@ export function SwitchSetting({
45
46
  undoable?: boolean;
46
47
  // Can't be changed from here.
47
48
  locked?: boolean;
49
+ // Greyed for a reason the help already gives.
50
+ disabled?: boolean;
48
51
  // Shown under the row (usage, links).
49
52
  extra?: ReactNode;
50
53
  }) {
@@ -54,27 +57,23 @@ export function SwitchSetting({
54
57
  <SettingRow
55
58
  id={id}
56
59
  label={label}
57
- help={locked ? `${help ?? ""} Can't be changed from here.`.trim() : help}
60
+ help={locked ? <>{help} Can&apos;t be changed from here.</> : help}
58
61
  status={auto.status}
59
62
  onUndo={auto.undo}
60
- hint={
61
- defaultValue === undefined
62
- ? undefined
63
- : `default ${defaultValue ? "on" : "off"}`
64
- }
63
+ error={auto.error}
64
+ resetLabel={defaultValue ? "on" : "off"}
65
65
  onReset={
66
66
  defaultValue !== undefined && auto.value !== defaultValue
67
67
  ? () => void auto.commit(defaultValue, { undoable: true })
68
68
  : undefined
69
69
  }
70
- controlClassName="max-sm:col-span-1 max-sm:row-start-1 max-sm:mt-0"
70
+ controlClassName="max-sm:col-span-1 max-sm:col-start-2 max-sm:row-start-1 max-sm:mt-0 max-sm:justify-end"
71
71
  control={
72
72
  <Switch
73
73
  id={id}
74
74
  checked={auto.value}
75
- disabled={locked}
75
+ disabled={locked || disabled}
76
76
  aria-describedby={`${id}-hint`}
77
- className="data-[state=checked]:bg-primary"
78
77
  onCheckedChange={(on) => {
79
78
  if (on && confirm) setAsking(true);
80
79
  else void auto.commit(on, { undoable: undoable || !on });
@@ -149,14 +148,11 @@ export function NumberSetting({
149
148
  const off = optional ? auto.value === optional.off : false;
150
149
  const shown = (off ? optional!.on : auto.value) / scale;
151
150
  const defOff = optional ? defaultValue === optional.off : false;
152
- const hint =
153
- error === null
154
- ? `default ${defOff ? "off" : `${defaultValue / scale}${short}`} · ${
155
- max === undefined
156
- ? `${min / scale}${short}+`
157
- : `${min / scale}–${max / scale}${short}`
158
- }`
159
- : undefined;
151
+ const resetLabel = defOff ? "off" : `${defaultValue / scale}${short}`;
152
+ const range =
153
+ max === undefined
154
+ ? undefined
155
+ : `${min / scale}–${max / scale}${short || (unit ? ` ${unit}` : "")}`;
160
156
 
161
157
  const finish = () => {
162
158
  if (text === null) return;
@@ -184,10 +180,10 @@ export function NumberSetting({
184
180
  <SettingRow
185
181
  id={id}
186
182
  label={label}
187
- help={help}
183
+ help={[help, range && `Allowed: ${range}.`].filter(Boolean).join(" ")}
188
184
  status={auto.status}
189
- error={error}
190
- hint={hint}
185
+ error={error ?? auto.error}
186
+ resetLabel={resetLabel}
191
187
  onUndo={auto.undo}
192
188
  onReset={
193
189
  auto.value !== defaultValue
@@ -204,7 +200,8 @@ export function NumberSetting({
204
200
  id={id}
205
201
  type="text"
206
202
  inputMode={integer ? "numeric" : "decimal"}
207
- value={text ?? String(shown)}
203
+ value={off ? "" : (text ?? String(shown))}
204
+ placeholder={off ? "Off" : undefined}
208
205
  disabled={off}
209
206
  aria-invalid={!!error}
210
207
  aria-describedby={`${id}-hint`}
@@ -224,7 +221,9 @@ export function NumberSetting({
224
221
  className={`${FIELD} w-24 text-right tabular-nums`}
225
222
  />
226
223
  {unit && (
227
- <span className="text-muted-foreground min-w-16 text-sm">
224
+ <span
225
+ className={`text-sm ${off ? "text-muted-foreground/50" : "text-muted-foreground"}`}
226
+ >
228
227
  {unit}
229
228
  </span>
230
229
  )}
@@ -265,7 +264,7 @@ export function SelectSetting({
265
264
  options: { value: string; label: string }[];
266
265
  onSave: Save<string>;
267
266
  defaultValue?: string;
268
- // Replaces "default <label>" (an empty string for none).
267
+ // A line under the row (what an Inherit choice resolves to).
269
268
  hint?: string;
270
269
  }) {
271
270
  const auto = useAutoSave({ saved: value, save: onSave });
@@ -277,7 +276,9 @@ export function SelectSetting({
277
276
  help={help}
278
277
  status={auto.status}
279
278
  onUndo={auto.undo}
280
- hint={hint ?? (def ? `default ${def.label}` : undefined)}
279
+ hint={hint}
280
+ error={auto.error}
281
+ resetLabel={def?.label}
281
282
  onReset={
282
283
  defaultValue !== undefined && auto.value !== defaultValue
283
284
  ? () => void auto.commit(defaultValue, { undoable: true })
@@ -366,7 +367,7 @@ export function TextSetting({
366
367
  label={label}
367
368
  help={help}
368
369
  status={checking ? "saving" : auto.status}
369
- error={error}
370
+ error={error ?? auto.error}
370
371
  onUndo={auto.undo}
371
372
  onReset={
372
373
  resettable && !secret && auto.value !== defaultValue
@@ -406,7 +407,7 @@ export function TextSetting({
406
407
  e.currentTarget.blur();
407
408
  }
408
409
  }}
409
- className={`${FIELD} w-full sm:w-64`}
410
+ className={`${FIELD} w-full ${deliberate ? "sm:w-48" : "sm:w-64"}`}
410
411
  />
411
412
  {deliberate && (
412
413
  <Button
@@ -2,7 +2,8 @@
2
2
 
3
3
  import { useState } from "react";
4
4
  import { toast } from "sonner";
5
- import { Copy, Link2 } from "lucide-react";
5
+ import { Copy } from "lucide-react";
6
+ import { ListSection } from "@/components/Settings/ListSection";
6
7
  import { Button } from "@/components/ui/button";
7
8
  import { writeClipboard } from "@/hooks/useCopyToClipboard";
8
9
  import {
@@ -52,7 +53,7 @@ function Row({ share }: { share: SharedLinkRow }) {
52
53
  );
53
54
 
54
55
  return (
55
- <li className="bg-muted/40 rounded-lg px-3 py-2.5">
56
+ <div className="px-3 py-2.5">
56
57
  <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
57
58
  <div className="min-w-0 flex-1">
58
59
  <div className="truncate text-sm font-medium">{title}</div>
@@ -114,7 +115,7 @@ function Row({ share }: { share: SharedLinkRow }) {
114
115
  onCancel={() => setRevoking(false)}
115
116
  />
116
117
  )}
117
- </li>
118
+ </div>
118
119
  );
119
120
  }
120
121
 
@@ -194,18 +195,14 @@ export function SharedLinksPanel() {
194
195
  )}
195
196
  {isLoading ? (
196
197
  <p className="text-muted-foreground text-sm">Loading…</p>
197
- ) : data?.shares.length ? (
198
- <ul className="space-y-2">
199
- {data.shares.map((s) => (
200
- <Row key={s.id} share={s} />
201
- ))}
202
- </ul>
203
198
  ) : (
204
- <div className="text-muted-foreground flex items-center gap-2 rounded-lg border border-dashed px-3 py-6 text-sm">
205
- <Link2 className="h-4 w-4 shrink-0" />
206
- Nothing shared yet. Use Share… in a chat&apos;s menu, or the share
207
- button on a page.
208
- </div>
199
+ <ListSection
200
+ title="Snapshots"
201
+ items={data?.shares ?? []}
202
+ keyOf={(s) => s.id}
203
+ render={(s) => <Row share={s} />}
204
+ empty="Nothing shared yet. Use Share… in a chat's menu, or the share button on a page."
205
+ />
209
206
  )}
210
207
  </div>
211
208
  );