@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
@@ -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,14 +57,11 @@ 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 || auto.value === defaultValue
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 })
@@ -72,9 +72,8 @@ export function SwitchSetting({
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,10 +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 && auto.value !== defaultValue
154
- ? `Default: ${defOff ? "off" : `${defaultValue / scale}${short}`}`
155
- : 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}` : "")}`;
156
156
 
157
157
  const finish = () => {
158
158
  if (text === null) return;
@@ -180,10 +180,10 @@ export function NumberSetting({
180
180
  <SettingRow
181
181
  id={id}
182
182
  label={label}
183
- help={help}
183
+ help={[help, range && `Allowed: ${range}.`].filter(Boolean).join(" ")}
184
184
  status={auto.status}
185
- error={error}
186
- hint={hint}
185
+ error={error ?? auto.error}
186
+ resetLabel={resetLabel}
187
187
  onUndo={auto.undo}
188
188
  onReset={
189
189
  auto.value !== defaultValue
@@ -200,7 +200,8 @@ export function NumberSetting({
200
200
  id={id}
201
201
  type="text"
202
202
  inputMode={integer ? "numeric" : "decimal"}
203
- value={text ?? String(shown)}
203
+ value={off ? "" : (text ?? String(shown))}
204
+ placeholder={off ? "Off" : undefined}
204
205
  disabled={off}
205
206
  aria-invalid={!!error}
206
207
  aria-describedby={`${id}-hint`}
@@ -220,7 +221,9 @@ export function NumberSetting({
220
221
  className={`${FIELD} w-24 text-right tabular-nums`}
221
222
  />
222
223
  {unit && (
223
- <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
+ >
224
227
  {unit}
225
228
  </span>
226
229
  )}
@@ -261,7 +264,7 @@ export function SelectSetting({
261
264
  options: { value: string; label: string }[];
262
265
  onSave: Save<string>;
263
266
  defaultValue?: string;
264
- // Replaces "default <label>" (an empty string for none).
267
+ // A line under the row (what an Inherit choice resolves to).
265
268
  hint?: string;
266
269
  }) {
267
270
  const auto = useAutoSave({ saved: value, save: onSave });
@@ -273,12 +276,9 @@ export function SelectSetting({
273
276
  help={help}
274
277
  status={auto.status}
275
278
  onUndo={auto.undo}
276
- hint={
277
- hint ??
278
- (def && auto.value !== defaultValue
279
- ? `Default: ${def.label}`
280
- : undefined)
281
- }
279
+ hint={hint}
280
+ error={auto.error}
281
+ resetLabel={def?.label}
282
282
  onReset={
283
283
  defaultValue !== undefined && auto.value !== defaultValue
284
284
  ? () => void auto.commit(defaultValue, { undoable: true })
@@ -367,7 +367,7 @@ export function TextSetting({
367
367
  label={label}
368
368
  help={help}
369
369
  status={checking ? "saving" : auto.status}
370
- error={error}
370
+ error={error ?? auto.error}
371
371
  onUndo={auto.undo}
372
372
  onReset={
373
373
  resettable && !secret && auto.value !== defaultValue
@@ -407,7 +407,7 @@ export function TextSetting({
407
407
  e.currentTarget.blur();
408
408
  }
409
409
  }}
410
- className={`${FIELD} w-full sm:w-64`}
410
+ className={`${FIELD} w-full ${deliberate ? "sm:w-48" : "sm:w-64"}`}
411
411
  />
412
412
  {deliberate && (
413
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
  );
@@ -2,7 +2,8 @@
2
2
 
3
3
  import { toast } from "sonner";
4
4
  import { CheckCircle2, CircleAlert, RotateCcw } from "lucide-react";
5
- import { SettingToggle } from "@/components/Settings/SettingToggle";
5
+ import { SwitchSetting } from "@/components/Settings/settings-fields";
6
+ import { SettingSection } from "@/components/Settings/SettingRow";
6
7
  import {
7
8
  Select,
8
9
  SelectContent,
@@ -80,13 +81,16 @@ export function AutoUpdates({ auto }: { auto: AutoUpdateView }) {
80
81
  const onError = (e: Error) => toast.error(e.message);
81
82
  return (
82
83
  <div className="space-y-3">
83
- <SettingToggle
84
- title="Install updates automatically"
85
- detail="Once everything has been idle for a while. Terminals and chats keep running through the restart, and a version that doesn't start cleanly is rolled back."
86
- checked={auto.enabled}
87
- locked={auto.locked}
88
- onChange={(on) => save.mutate({ auto: on }, { onError })}
89
- />
84
+ <SettingSection>
85
+ <SwitchSetting
86
+ id="auto-update"
87
+ label="Install updates automatically"
88
+ help="Once everything has been idle for a while. Terminals and chats keep running through the restart, and a version that doesn't start cleanly is rolled back."
89
+ checked={auto.enabled}
90
+ locked={auto.locked}
91
+ onSave={(on) => save.mutateAsync({ auto: on })}
92
+ />
93
+ </SettingSection>
90
94
  {auto.enabled && (
91
95
  <div className="flex flex-wrap items-center justify-between gap-2 px-3">
92
96
  <label htmlFor="auto-idle" className="text-sm">
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useState } from "react";
4
+ import { Loader2 } from "lucide-react";
4
5
  import { toast } from "sonner";
5
6
  import { Button } from "@/components/ui/button";
6
7
  import {
@@ -38,6 +39,12 @@ export function UpdateActions({ view }: { view: UpdateView }) {
38
39
  const [confirm, setConfirm] = useState(false);
39
40
  const busy = workingTotal(view.working) > 0;
40
41
 
42
+ if (view.queued)
43
+ return (
44
+ <Button size="sm" className="min-h-11 md:min-h-0" disabled>
45
+ <Loader2 className="h-4 w-4 animate-spin" /> Updating…
46
+ </Button>
47
+ );
41
48
  if (view.pending)
42
49
  return (
43
50
  <Button
@@ -60,7 +67,10 @@ export function UpdateActions({ view }: { view: UpdateView }) {
60
67
 
61
68
  const start = (when: "now" | "idle") => {
62
69
  setConfirm(false);
63
- run.mutate(when, { onError: toastError });
70
+ run.mutate(when, {
71
+ onError: toastError,
72
+ onSuccess: (v) => v.message && toast.success(v.message),
73
+ });
64
74
  };
65
75
 
66
76
  return (
@@ -87,10 +97,15 @@ export function UpdateActions({ view }: { view: UpdateView }) {
87
97
  <Button variant="ghost" onClick={() => setConfirm(false)}>
88
98
  Cancel
89
99
  </Button>
90
- <Button variant="outline" onClick={() => start("now")}>
100
+ <Button
101
+ variant={view.managed ? "default" : "outline"}
102
+ onClick={() => start("now")}
103
+ >
91
104
  Update now
92
105
  </Button>
93
- <Button onClick={() => start("idle")}>Update when idle</Button>
106
+ {!view.managed && (
107
+ <Button onClick={() => start("idle")}>Update when idle</Button>
108
+ )}
94
109
  </DialogFooter>
95
110
  </DialogContent>
96
111
  </Dialog>
@@ -96,6 +96,8 @@ const make = (over: Partial<UpdateView> = {}): UpdateView => ({
96
96
  canUpdate: { ok: true },
97
97
  run: null,
98
98
  pending: null,
99
+ managed: false,
100
+ queued: null,
99
101
  working: { sessions: 0, reviews: 0, setups: 0 },
100
102
  ...over,
101
103
  });
@@ -212,6 +214,21 @@ describe("UpdateToast", () => {
212
214
  expect(runMutate).toHaveBeenCalledWith("idle", expect.anything());
213
215
  });
214
216
 
217
+ it("on a machine autodeploy manages, explains and still offers Update now", async () => {
218
+ view = make({ managed: true });
219
+ await render();
220
+ expect(text()).toContain("deployed by autodeploy");
221
+ await act(async () => button("Update now")!.click());
222
+ expect(runMutate).toHaveBeenCalledWith("now", expect.anything());
223
+ });
224
+
225
+ it("shows a queued request as a disabled Updating… button", async () => {
226
+ view = make({ managed: true, queued: { requestedAt: 1 } });
227
+ await render();
228
+ expect(button("Updating…")?.disabled).toBe(true);
229
+ expect(button("Update now")).toBeUndefined();
230
+ });
231
+
215
232
  it("shows a pending update with Cancel", async () => {
216
233
  view = make({ pending: { requestedAt: 1 } });
217
234
  await render();
@@ -10,6 +10,7 @@ import { useDismissedUpdate } from "@/hooks/useDismissedUpdate";
10
10
  import { useReloadWhenUpdated } from "@/hooks/useReloadWhenUpdated";
11
11
  import {
12
12
  bannerState,
13
+ MANAGED_NOTE,
13
14
  type BannerState,
14
15
  type UpdateView,
15
16
  } from "@/lib/updates/banner";
@@ -72,6 +73,10 @@ export function UpdateToastView({
72
73
  if (state === "available") text = <>AgentOS {latest?.version} is available</>;
73
74
  else if (state === "pending")
74
75
  text = <>AgentOS will update when nothing is working</>;
76
+ else if (state === "queued")
77
+ text = (
78
+ <>Updating AgentOS to {latest?.version}. It deploys within a minute.</>
79
+ );
75
80
  else if (state === "built")
76
81
  text = <>Built {run?.toVersion}. Restart AgentOS to finish updating.</>;
77
82
  else if (failed) text = <>The update didn&apos;t finish. {run?.error}</>;
@@ -110,7 +115,7 @@ export function UpdateToastView({
110
115
  )}
111
116
  >
112
117
  <span className="flex h-6 shrink-0 items-center">
113
- {state === "running" ? (
118
+ {state === "running" || state === "queued" ? (
114
119
  <Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
115
120
  ) : (
116
121
  <ArrowUpCircle
@@ -130,6 +135,9 @@ export function UpdateToastView({
130
135
  {view.canUpdate.reason}
131
136
  </p>
132
137
  )}
138
+ {state === "available" && view.managed && view.canUpdate.ok && (
139
+ <p className="text-muted-foreground text-xs">{MANAGED_NOTE}</p>
140
+ )}
133
141
  {notesOpen && latest && (
134
142
  <ul className="text-muted-foreground list-disc space-y-0.5 pl-4 text-xs">
135
143
  {latest.notes.map((n, i) => (
@@ -138,9 +146,9 @@ export function UpdateToastView({
138
146
  </ul>
139
147
  )}
140
148
  <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
141
- {(state === "available" || state === "pending") && (
142
- <UpdateActions view={view} />
143
- )}
149
+ {(state === "available" ||
150
+ state === "pending" ||
151
+ state === "queued") && <UpdateActions view={view} />}
144
152
  {state === "running" && elapsed > SLOW_MS && (
145
153
  <Button size="sm" onClick={() => window.location.reload()}>
146
154
  Reload
@@ -243,7 +251,9 @@ export function UpdateToast() {
243
251
  ? `running:${view?.run?.id}`
244
252
  : computed === "pending"
245
253
  ? `pending:${view?.pending?.requestedAt}`
246
- : null;
254
+ : computed === "queued"
255
+ ? `queued:${view?.queued?.requestedAt}`
256
+ : null;
247
257
  const state = key && key === closed ? "hidden" : computed;
248
258
 
249
259
  const { dismissVersion, dismissRun } = dismissed;
@@ -3,14 +3,15 @@
3
3
  import { toast } from "sonner";
4
4
  import { Loader2 } from "lucide-react";
5
5
  import { Button } from "@/components/ui/button";
6
- import { SettingToggle } from "@/components/Settings/SettingToggle";
6
+ import { SwitchSetting } from "@/components/Settings/settings-fields";
7
+ import { SettingSection } from "@/components/Settings/SettingRow";
7
8
  import { useSetTipsOff, useTips } from "@/data/tips";
8
9
  import {
9
10
  useCheckForUpdates,
10
11
  useUpdateBannerSetting,
11
12
  useUpdates,
12
13
  } from "@/data/updates";
13
- import { isActivePhase } from "@/lib/updates/banner";
14
+ import { isActivePhase, MANAGED_NOTE } from "@/lib/updates/banner";
14
15
  import { AutoUpdates } from "./AutoUpdates";
15
16
  import { UpdateActions } from "./UpdateActions";
16
17
 
@@ -107,9 +108,17 @@ export function UpdatesSection() {
107
108
  {view.run?.phase})…
108
109
  </p>
109
110
  )}
111
+ {view.available && view.managed && view.canUpdate.ok && (
112
+ <p className="text-muted-foreground text-xs">{MANAGED_NOTE}</p>
113
+ )}
110
114
  {view.available && !view.canUpdate.ok && view.canUpdate.reason && (
111
115
  <p className="text-muted-foreground text-xs">{view.canUpdate.reason}</p>
112
116
  )}
117
+ {view.queued && (
118
+ <p className="text-muted-foreground text-sm">
119
+ Queued for autodeploy. It deploys within about a minute.
120
+ </p>
121
+ )}
113
122
  {view.pending && (
114
123
  <p className="text-muted-foreground text-sm">
115
124
  Will update when nothing is working.
@@ -141,16 +150,17 @@ export function UpdatesSection() {
141
150
 
142
151
  {view.auto && <AutoUpdates auto={view.auto} />}
143
152
 
144
- <SettingToggle
145
- title="Show update banner"
146
- detail="Tell everyone using this AgentOS when a newer version is out, and when it updated itself."
147
- checked={view.bannerEnabled}
148
- locked={view.bannerLocked}
149
- onChange={(on) =>
150
- banner.mutate(on, { onError: (e) => toast.error(e.message) })
151
- }
152
- />
153
- <TipsToggle />
153
+ <SettingSection>
154
+ <SwitchSetting
155
+ id="update-banner"
156
+ label="Show update banner"
157
+ help="Tell everyone using this AgentOS when a newer version is out, and when it updated itself."
158
+ checked={view.bannerEnabled}
159
+ locked={view.bannerLocked}
160
+ onSave={(on) => banner.mutateAsync(on)}
161
+ />
162
+ <TipsToggle />
163
+ </SettingSection>
154
164
  </div>
155
165
  );
156
166
  }
@@ -159,13 +169,12 @@ function TipsToggle() {
159
169
  const { data } = useTips();
160
170
  const setOff = useSetTipsOff();
161
171
  return (
162
- <SettingToggle
163
- title="Show Tip of the Day"
164
- detail="A short tip about something AgentOS can do, behind the lightbulb in the sidebar. It never opens on its own."
172
+ <SwitchSetting
173
+ id="tips"
174
+ label="Show Tip of the Day"
175
+ help="A short tip about something AgentOS can do, behind the lightbulb in the sidebar. It never opens on its own."
165
176
  checked={!!data && !data.off}
166
- onChange={(on) =>
167
- setOff.mutate(!on, { onError: (e) => toast.error(e.message) })
168
- }
177
+ onSave={(on) => setOff.mutateAsync(!on)}
169
178
  />
170
179
  );
171
180
  }
@@ -124,19 +124,19 @@ describe("WorkspaceSettings", () => {
124
124
  await render();
125
125
  expect(limit().id).toBe("task-limit-w1");
126
126
  expect(limit().disabled).toBe(true);
127
- expect(limit().value).toBe("3");
128
- expect(host.textContent).not.toContain("Default");
127
+ expect(limit().value).toBe("");
128
+ expect(limit().placeholder).toBe("Off");
129
+ expect(host.textContent).not.toContain("Reset");
129
130
  });
130
131
 
131
- it("enables the number when a limit is set, and hints the default", async () => {
132
+ it("enables the number when a limit is set, and offers Reset to off", async () => {
132
133
  localStorage.setItem("agentOS-settings-workspace", "w2");
133
134
  await render();
134
135
  expect(limit().disabled).toBe(false);
135
136
  expect(limit().value).toBe("2");
136
- expect(host.textContent).toContain("Default: off");
137
137
  expect(
138
138
  [...host.querySelectorAll("button")].some(
139
- (b) => b.textContent === "Reset"
139
+ (b) => b.textContent === "Reset to off"
140
140
  )
141
141
  ).toBe(true);
142
142
  });
@@ -4,7 +4,6 @@ import { useContext, useState } from "react";
4
4
  import { settingsUiActions } from "@/stores/settingsUi";
5
5
  import { useWorkspacesQuery } from "@/data/workspaces";
6
6
  import { useProjectsQuery } from "@/data/projects";
7
- import { AppWideLimits } from "@/components/Settings/AppWideLimits";
8
7
  import {
9
8
  matchesQuery,
10
9
  SettingsQueryContext,
@@ -69,7 +68,6 @@ export function WorkspaceSettings() {
69
68
  }}
70
69
  />
71
70
  <WorkspacePanel key={shown.id} workspace={shown} />
72
- <AppWideLimits />
73
71
  <p className="text-muted-foreground text-sm">
74
72
  Fresh starts and compaction are app-wide:{" "}
75
73
  <button
@@ -13,7 +13,7 @@ function Switch({
13
13
  <SwitchPrimitive.Root
14
14
  data-slot="switch"
15
15
  className={cn(
16
- "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
16
+ "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted-foreground/35 data-[state=unchecked]:border-foreground/15 focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-muted-foreground/40 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
17
17
  className
18
18
  )}
19
19
  {...props}
@@ -2009,6 +2009,30 @@
2009
2009
  "area": "Sidebar",
2010
2010
  "text": "Renaming a session for the first time no longer closes the name box before you can type.",
2011
2011
  "url": "https://github.com/saadnvd1/agent-os/pull/568"
2012
+ },
2013
+ {
2014
+ "id": 569,
2015
+ "date": "2026-11-02",
2016
+ "kind": "fixed",
2017
+ "area": "Chat",
2018
+ "text": "Pressing Enter with a selection that ends on a reply's button row no longer quotes it.",
2019
+ "url": "https://github.com/saadnvd1/agent-os/pull/569"
2020
+ },
2021
+ {
2022
+ "id": 570,
2023
+ "date": "2026-11-02",
2024
+ "kind": "improved",
2025
+ "area": "Settings",
2026
+ "text": "Settings is tidier: clearer switches, shorter lists, one Approvals page, and a General page for your name and alerts.",
2027
+ "url": "https://github.com/saadnvd1/agent-os/pull/570"
2028
+ },
2029
+ {
2030
+ "id": 571,
2031
+ "date": "2026-11-02",
2032
+ "kind": "fixed",
2033
+ "area": "Updates",
2034
+ "text": "You can update AgentOS by hand again on machines that deploy themselves, and Update now skips the hourly wait.",
2035
+ "url": "https://github.com/saadnvd1/agent-os/pull/571"
2012
2036
  }
2013
2037
  ]
2014
2038
  }
@@ -1,11 +1,11 @@
1
1
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
- import type { AuditRow } from "@/lib/approvals/no-approvals";
2
+ import type { ApprovalLine } from "@/lib/approvals/describe";
3
3
  import { provePresence } from "../presence";
4
4
  import { usePollWhenOffline } from "../push/connection";
5
5
 
6
6
  export interface ApprovalsMode {
7
7
  on: boolean;
8
- audit: AuditRow[];
8
+ audit: ApprovalLine[];
9
9
  }
10
10
 
11
11
  export const approvalsKeys = { all: ["approvals"] as const };
@@ -91,10 +91,13 @@ export function useDeleteNote() {
91
91
  });
92
92
  }
93
93
 
94
+ import type { HealthMeta } from "@/lib/health/config";
95
+
94
96
  export interface ConfigResponse {
95
97
  config: Record<string, number>;
96
98
  defaults: Record<string, number>;
97
99
  labels: Record<string, string>;
100
+ meta: Record<string, HealthMeta>;
98
101
  }
99
102
 
100
103
  export function useHealthConfig() {
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
4
  import { toast } from "sonner";
5
+ import { settingsUi } from "@/stores/settingsUi";
5
6
 
6
7
  export type SaveStatus = "idle" | "saving" | "saved" | "undo";
7
8
 
@@ -13,6 +14,9 @@ export interface AutoSave<T> {
13
14
  // rejected save drops the in-flight value, so this is the saved one again.
14
15
  value: T;
15
16
  status: SaveStatus;
17
+ // Why the last save was rejected; cleared by the next commit. Shown on the
18
+ // row while Settings is open (a toast would sit over the dialog).
19
+ error: string | null;
16
20
  // Commit a new value. `undoable` keeps the previous one for Undo (large or
17
21
  // destructive changes). Resolves false when the server rejected it.
18
22
  commit: (next: T, opts?: { undoable?: boolean }) => Promise<boolean>;
@@ -27,7 +31,7 @@ export function useAutoSave<T>({
27
31
  saved,
28
32
  save,
29
33
  equals = Object.is,
30
- notify = (message) => toast.error(message),
34
+ notify,
31
35
  }: {
32
36
  saved: T;
33
37
  save: (next: T) => Promise<unknown>;
@@ -36,6 +40,7 @@ export function useAutoSave<T>({
36
40
  }): AutoSave<T> {
37
41
  const [pending, setPending] = useState<{ value: T } | null>(null);
38
42
  const [status, setStatus] = useState<SaveStatus>("idle");
43
+ const [error, setError] = useState<string | null>(null);
39
44
  const previous = useRef<{ value: T } | null>(null);
40
45
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
41
46
  const seq = useRef(0);
@@ -56,6 +61,7 @@ export function useAutoSave<T>({
56
61
  const mine = ++seq.current;
57
62
  clearTimeout(timer.current);
58
63
  setPending({ value: next });
64
+ setError(null);
59
65
  setStatus("saving");
60
66
  try {
61
67
  await latest.current.save(next);
@@ -64,9 +70,10 @@ export function useAutoSave<T>({
64
70
  setPending(null);
65
71
  setStatus("idle");
66
72
  }
67
- latest.current.notify(
68
- err instanceof Error ? err.message : "Couldn't save"
69
- );
73
+ const message = err instanceof Error ? err.message : "Couldn't save";
74
+ if (latest.current.notify) latest.current.notify(message);
75
+ else if (settingsUi.open) setError(message);
76
+ else toast.error(message);
70
77
  return false;
71
78
  }
72
79
  if (mine !== seq.current) return true;
@@ -94,5 +101,11 @@ export function useAutoSave<T>({
94
101
  return run(back.value, false, latest.current.saved);
95
102
  }, [run]);
96
103
 
97
- return { value: pending ? pending.value : saved, status, commit, undo };
104
+ return {
105
+ value: pending ? pending.value : saved,
106
+ status,
107
+ error,
108
+ commit,
109
+ undo,
110
+ };
98
111
  }
@@ -9,7 +9,16 @@ import { isActivePhase, type UpdateView } from "@/lib/updates/banner";
9
9
  */
10
10
  export function useReloadWhenUpdated(view: UpdateView | undefined) {
11
11
  const watched = useRef<string | null>(null);
12
+ const queuedFrom = useRef<string | null>(null);
12
13
  const run = view?.run;
14
+ // An Update now that autodeploy took: the page was loaded from the old build.
15
+ const sha = view?.current?.sha;
16
+ useEffect(() => {
17
+ if (!sha) return;
18
+ if (view?.queued) queuedFrom.current = sha;
19
+ else if (queuedFrom.current && queuedFrom.current !== sha)
20
+ window.location.reload();
21
+ }, [view?.queued, sha]);
13
22
  useEffect(() => {
14
23
  if (!run) return;
15
24
  if (isActivePhase(run.phase)) watched.current = run.id;