@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
@@ -25,11 +25,12 @@ const w = (calls: number) => ({
25
25
  costUsd: calls * 0.01,
26
26
  scripts: 0,
27
27
  shown: 0,
28
- accepted: 1,
29
- dismissed: 1,
30
- acceptRate: 0.5,
28
+ accepted: calls ? 1 : 0,
29
+ dismissed: calls ? 1 : 0,
30
+ acceptRate: calls ? 0.5 : null,
31
31
  });
32
32
  const state = vi.hoisted(() => ({
33
+ zero: false,
33
34
  mutateAsync: vi.fn().mockResolvedValue({}),
34
35
  }));
35
36
  vi.mock("@/data/experiments", () => ({
@@ -47,7 +48,9 @@ vi.mock("@/data/experiments", () => ({
47
48
  status: "experimental",
48
49
  suggests: true,
49
50
  on: false,
50
- usage: { today: w(1), d7: w(2), d30: w(3) },
51
+ usage: state.zero
52
+ ? { today: w(0), d7: w(0), d30: w(0) }
53
+ : { today: w(1), d7: w(2), d30: w(3) },
51
54
  },
52
55
  ],
53
56
  },
@@ -62,6 +65,7 @@ let root: Root;
62
65
  let host: HTMLDivElement;
63
66
  beforeEach(() => {
64
67
  state.mutateAsync.mockClear();
68
+ state.zero = false;
65
69
  host = document.createElement("div");
66
70
  document.body.appendChild(host);
67
71
  root = createRoot(host);
@@ -82,6 +86,18 @@ describe("ExperimentsSection", () => {
82
86
  ).toBe("false");
83
87
  });
84
88
 
89
+ it("doesn't tag every card Experimental", () => {
90
+ act(() => root.render(createElement(ExperimentsSection)));
91
+ expect(host.textContent).not.toContain("Experimental");
92
+ });
93
+
94
+ it("shows an empty state instead of a table of zeros", () => {
95
+ state.zero = true;
96
+ act(() => root.render(createElement(ExperimentsSection)));
97
+ expect(host.querySelector("table")).toBeNull();
98
+ expect(host.textContent).toContain("No usage yet");
99
+ });
100
+
85
101
  it("turns one on from its toggle", () => {
86
102
  act(() => root.render(createElement(ExperimentsSection)));
87
103
  act(() => {
@@ -3,6 +3,7 @@
3
3
  import { useState } from "react";
4
4
  import { ArrowLeft } from "lucide-react";
5
5
  import { Button } from "@/components/ui/button";
6
+ import { ScrollTable } from "@/components/Settings/ListSection";
6
7
  import { SettingSection } from "@/components/Settings/SettingRow";
7
8
  import { SwitchSetting } from "@/components/Settings/settings-fields";
8
9
  import { formatTokens } from "@/lib/chat/context";
@@ -23,41 +24,60 @@ const WINDOWS = [
23
24
  ["d30", "30 days"],
24
25
  ] as const;
25
26
 
27
+ export const hasUsage = (e: ExperimentView) =>
28
+ WINDOWS.some(([key]) => {
29
+ const w = e.usage[key];
30
+ return (
31
+ w.calls > 0 ||
32
+ w.tokens > 0 ||
33
+ w.costUsd > 0 ||
34
+ w.accepted + w.dismissed > 0
35
+ );
36
+ });
37
+
26
38
  function Usage({ e }: { e: ExperimentView }) {
39
+ if (!hasUsage(e))
40
+ return (
41
+ <p className="text-muted-foreground py-1 text-xs">
42
+ No usage yet. It shows here once it has run.
43
+ </p>
44
+ );
27
45
  return (
28
- <table className="w-full text-xs tabular-nums">
29
- <thead className="text-muted-foreground">
30
- <tr>
31
- <th className="py-1 text-left font-normal" />
32
- <th className="py-1 text-right font-normal">Calls</th>
33
- <th className="py-1 text-right font-normal">Tokens</th>
34
- <th className="py-1 text-right font-normal">Cost</th>
35
- {e.suggests && (
36
- <th className="py-1 text-right font-normal">Accepted</th>
37
- )}
38
- </tr>
39
- </thead>
40
- <tbody>
41
- {WINDOWS.map(([key, label]) => {
42
- const w = e.usage[key];
43
- return (
44
- <tr key={key} className="border-border border-t">
45
- <td className="py-1">{label}</td>
46
- <td className="py-1 text-right">{w.calls}</td>
47
- <td className="py-1 text-right">{formatTokens(w.tokens)}</td>
48
- <td className="py-1 text-right">{usd(w.costUsd)}</td>
49
- {e.suggests && (
50
- <td className="py-1 text-right">
51
- {pct(w.acceptRate)}
52
- {w.accepted + w.dismissed > 0 &&
53
- ` (${w.accepted}/${w.accepted + w.dismissed})`}
54
- </td>
55
- )}
56
- </tr>
57
- );
58
- })}
59
- </tbody>
60
- </table>
46
+ <ScrollTable>
47
+ <table className="w-full text-xs tabular-nums">
48
+ <thead className="text-muted-foreground">
49
+ <tr>
50
+ <th className="py-1 text-left font-normal" />
51
+ <th className="py-1 text-right font-normal">Calls</th>
52
+ <th className="py-1 text-right font-normal">Tokens</th>
53
+ <th className="py-1 text-right font-normal">Cost</th>
54
+ {e.suggests && (
55
+ <th className="py-1 text-right font-normal">Accepted</th>
56
+ )}
57
+ </tr>
58
+ </thead>
59
+ <tbody>
60
+ {WINDOWS.map(([key, label]) => {
61
+ const w = e.usage[key];
62
+ return (
63
+ <tr key={key} className="border-border border-t">
64
+ <td className="py-1">{label}</td>
65
+ <td className="py-1 text-right">{w.calls}</td>
66
+ <td className="py-1 text-right">{formatTokens(w.tokens)}</td>
67
+ <td className="py-1 text-right">{usd(w.costUsd)}</td>
68
+ {e.suggests && (
69
+ <td className="py-1 text-right">
70
+ {pct(w.acceptRate)}
71
+ {w.accepted + w.dismissed > 0 &&
72
+ ` (${w.accepted}/${w.accepted + w.dismissed})`}
73
+ </td>
74
+ )}
75
+ </tr>
76
+ );
77
+ })}
78
+ </tbody>
79
+ </table>
80
+ </ScrollTable>
61
81
  );
62
82
  }
63
83
 
@@ -197,7 +217,7 @@ export function ExperimentsSection() {
197
217
  <SettingSection key={e.id}>
198
218
  <SwitchSetting
199
219
  id={`experiment-${e.id}`}
200
- label={`${e.name} · ${e.status === "beta" ? "Beta" : "Experimental"}`}
220
+ label={e.status === "beta" ? `${e.name} · Beta` : e.name}
201
221
  help={e.description}
202
222
  checked={e.on}
203
223
  locked={data.locked}
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import { Button } from "@/components/ui/button";
4
+ import { settingsUiActions } from "@/stores/settingsUi";
5
+ import { welcomeUiActions } from "@/stores/welcomeUi";
6
+ import { AppWideLimits } from "./AppWideLimits";
7
+ import { OwnerNameRow } from "./OwnerNameRow";
8
+ import { SettingRow, SettingSection } from "./SettingRow";
9
+
10
+ // The app-wide basics: who you are, alerts that apply everywhere, and the
11
+ // first-run setup again.
12
+ export function GeneralSection() {
13
+ return (
14
+ <div className="space-y-5">
15
+ <OwnerNameRow />
16
+ <AppWideLimits />
17
+ <SettingSection>
18
+ <SettingRow
19
+ id="first-run-setup"
20
+ label="First-run setup"
21
+ help="Agents, your first project, and a first session."
22
+ control={
23
+ <Button
24
+ size="sm"
25
+ variant="outline"
26
+ className="min-h-11 sm:min-h-8"
27
+ onClick={() => {
28
+ settingsUiActions.close();
29
+ welcomeUiActions.open("agents");
30
+ }}
31
+ >
32
+ Run setup again
33
+ </Button>
34
+ }
35
+ />
36
+ </SettingSection>
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,85 @@
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 } from "vitest";
5
+ import { ListSection, ScrollTable } from "./ListSection";
6
+
7
+ (
8
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
9
+ ).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ let root: Root;
12
+ let host: HTMLElement;
13
+ afterEach(async () => {
14
+ await act(async () => root.unmount());
15
+ host.remove();
16
+ });
17
+
18
+ const items = (n: number) => Array.from({ length: n }, (_, i) => `item ${i}`);
19
+
20
+ async function mount(
21
+ props: Partial<Parameters<typeof ListSection<string>>[0]>
22
+ ) {
23
+ host = document.createElement("div");
24
+ document.body.appendChild(host);
25
+ root = createRoot(host);
26
+ await act(async () =>
27
+ root.render(
28
+ createElement(ListSection<string>, {
29
+ title: "Things",
30
+ items: [],
31
+ keyOf: (s) => s,
32
+ render: (s) => createElement("span", null, s),
33
+ empty: "Nothing here.",
34
+ ...props,
35
+ })
36
+ )
37
+ );
38
+ }
39
+ const click = (text: string) =>
40
+ act(async () => {
41
+ [...host.querySelectorAll("button")]
42
+ .find((b) => b.textContent?.includes(text))!
43
+ .click();
44
+ });
45
+
46
+ describe("ListSection", () => {
47
+ it("shows the empty state and a zero count", async () => {
48
+ await mount({});
49
+ expect(host.textContent).toContain("Nothing here.");
50
+ expect(host.querySelector("h3")!.textContent).toBe("Things0");
51
+ });
52
+
53
+ it("shows the count and only the first 10, then Show all", async () => {
54
+ await mount({ items: items(25) });
55
+ expect(host.querySelector("h3")!.textContent).toBe("Things25");
56
+ expect(host.querySelectorAll("li")).toHaveLength(10);
57
+ await click("Show all 25");
58
+ expect(host.querySelectorAll("li")).toHaveLength(25);
59
+ await click("Show fewer");
60
+ expect(host.querySelectorAll("li")).toHaveLength(10);
61
+ });
62
+
63
+ it("has no Show all when everything fits", async () => {
64
+ await mount({ items: items(10) });
65
+ expect(host.querySelectorAll("button")).toHaveLength(0);
66
+ });
67
+
68
+ it("keeps older items behind their own toggle and counts them", async () => {
69
+ await mount({ items: items(2), older: ["old a", "old b", "old c"] });
70
+ expect(host.querySelector("h3")!.textContent).toBe("Things5");
71
+ expect(host.textContent).not.toContain("old a");
72
+ await click("Older (3)");
73
+ expect(host.textContent).toContain("old c");
74
+ });
75
+ });
76
+
77
+ describe("ScrollTable", () => {
78
+ it("scrolls in its own box", async () => {
79
+ host = document.createElement("div");
80
+ document.body.appendChild(host);
81
+ root = createRoot(host);
82
+ await act(async () => root.render(createElement(ScrollTable, null, "x")));
83
+ expect(host.firstElementChild!.className).toContain("overflow-x-auto");
84
+ });
85
+ });
@@ -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={