@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
@@ -12,6 +12,7 @@ import {
12
12
  Trash2,
13
13
  } from "lucide-react";
14
14
  import { Button } from "@/components/ui/button";
15
+ import { ListSection } from "@/components/Settings/ListSection";
15
16
  import { Switch } from "@/components/ui/switch";
16
17
  import { useWorkspacesQuery } from "@/data/workspaces";
17
18
  import { useNotifySettings } from "@/data/notify";
@@ -93,7 +94,7 @@ function ScheduleRow({ s, onOpen }: { s: ScheduleView; onOpen: () => void }) {
93
94
  (e.key === "Enter" || e.key === " ") &&
94
95
  onOpen()
95
96
  }
96
- className="border-border/60 hover:bg-foreground/[0.03] focus-visible:ring-ring flex min-h-14 cursor-pointer items-center gap-3 rounded-xl border px-3 py-2.5 outline-none focus-visible:ring-2"
97
+ className="hover:bg-foreground/[0.03] focus-visible:ring-ring flex min-h-14 cursor-pointer items-center gap-3 px-3 py-2.5 outline-none focus-visible:ring-2 focus-visible:ring-inset"
97
98
  >
98
99
  <div className="min-w-0 flex-1 space-y-0.5">
99
100
  <div className="flex min-w-0 items-center gap-2">
@@ -114,7 +115,9 @@ function ScheduleRow({ s, onOpen }: { s: ScheduleView; onOpen: () => void }) {
114
115
  {s.oneOff && ` · by ${s.createdBy.name}`}
115
116
  </p>
116
117
  </div>
117
- {s.lastRun && <OutcomeChip run={s.lastRun} />}
118
+ {s.lastRun && !s.done_at && s.lastRun.outcome !== "started" && (
119
+ <OutcomeChip run={s.lastRun} />
120
+ )}
118
121
  {!s.done_at && <EnableSwitch s={s} />}
119
122
  </div>
120
123
  );
@@ -131,14 +134,13 @@ function Group({
131
134
  }) {
132
135
  if (rows.length === 0) return null;
133
136
  return (
134
- <section className="space-y-2">
135
- <h4 className="text-muted-foreground px-1 text-xs font-medium">
136
- {title} · {rows.length}
137
- </h4>
138
- {rows.map((s) => (
139
- <ScheduleRow key={s.id} s={s} onOpen={() => onOpen(s.id)} />
140
- ))}
141
- </section>
137
+ <ListSection
138
+ title={title}
139
+ items={rows}
140
+ keyOf={(s) => s.id}
141
+ render={(s) => <ScheduleRow s={s} onOpen={() => onOpen(s.id)} />}
142
+ empty=""
143
+ />
142
144
  );
143
145
  }
144
146
 
@@ -17,6 +17,7 @@ import {
17
17
  DropdownMenuTrigger,
18
18
  } from "@/components/ui/dropdown-menu";
19
19
  import { cn } from "@/lib/utils";
20
+ import { keepRenameFocus, useRenameInput } from "./RowParts";
20
21
  import { SessionRow } from "./SessionRow";
21
22
  import { useDropZone } from "./useGroupDrag";
22
23
 
@@ -48,18 +49,18 @@ function GroupHeader({
48
49
  update.mutate({ id: group.id, name: next });
49
50
  else setName(group.name);
50
51
  };
52
+ const renameInput = useRenameInput(() => commit());
51
53
 
52
54
  if (renaming)
53
55
  return (
54
56
  <div className="px-2.5 pt-3.5 pb-1.5">
55
57
  <Input
56
- autoFocus
58
+ {...renameInput}
57
59
  value={name}
58
60
  maxLength={60}
59
61
  aria-label="Group name"
60
62
  className="h-11 md:h-8"
61
63
  onChange={(e) => setName(e.target.value)}
62
- onBlur={commit}
63
64
  onKeyDown={(e) => {
64
65
  if (e.key === "Enter") commit();
65
66
  if (e.key === "Escape") {
@@ -112,7 +113,11 @@ function GroupHeader({
112
113
  <MoreHorizontal className="h-4 w-4" />
113
114
  </button>
114
115
  </DropdownMenuTrigger>
115
- <DropdownMenuContent align="end" className="w-44">
116
+ <DropdownMenuContent
117
+ align="end"
118
+ className="w-44"
119
+ onCloseAutoFocus={keepRenameFocus}
120
+ >
116
121
  <DropdownMenuItem
117
122
  className="min-h-11 md:min-h-8"
118
123
  onSelect={() => setRenaming(true)}
@@ -160,7 +160,7 @@ export const OrchestratorPinRow = memo(function OrchestratorPinRow({
160
160
  <DM.DropdownMenuContent
161
161
  align="end"
162
162
  className={ROW_MENU_CONTENT}
163
- onCloseAutoFocus={keepRenameFocus(renaming)}
163
+ onCloseAutoFocus={keepRenameFocus}
164
164
  onClick={(e) => e.stopPropagation()}
165
165
  >
166
166
  {menu("dropdown")}
@@ -175,7 +175,7 @@ export const OrchestratorPinRow = memo(function OrchestratorPinRow({
175
175
  <CM.ContextMenuTrigger asChild>{content}</CM.ContextMenuTrigger>
176
176
  <CM.ContextMenuContent
177
177
  className={ROW_MENU_CONTENT}
178
- onCloseAutoFocus={keepRenameFocus(renaming)}
178
+ onCloseAutoFocus={keepRenameFocus}
179
179
  >
180
180
  {menu("context")}
181
181
  </CM.ContextMenuContent>
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { Lightbulb } from "lucide-react";
4
- import { useEffect, useRef, useState, type ReactNode } from "react";
4
+ import { useCallback, useRef, useState, type ReactNode } from "react";
5
5
  import type { SidebarRow } from "@/lib/sidebar/shelves";
6
6
  import { cn } from "@/lib/utils";
7
7
 
@@ -61,12 +61,44 @@ export function CardBadge({ count }: { count: number }) {
61
61
  );
62
62
  }
63
63
 
64
- // For a menu's onCloseAutoFocus: while a rename is open, the input keeps the
65
- // focus instead of the menu's trigger getting it back.
66
- export const keepRenameFocus = (renaming: boolean) => (e: Event) => {
67
- if (renaming) e.preventDefault();
64
+ const RENAME_INPUT = "input[data-rename-input]";
65
+
66
+ function focusRename(el: HTMLInputElement) {
67
+ el.focus();
68
+ el.select();
69
+ }
70
+
71
+ // For a menu's onCloseAutoFocus: while a rename input is open it takes the
72
+ // focus the menu would hand back to its trigger. Reads the DOM, not state, so
73
+ // it can't see a render that hasn't landed yet.
74
+ export const keepRenameFocus = (e: Event) => {
75
+ const el = document.querySelector<HTMLInputElement>(RENAME_INPUT);
76
+ if (!el) return;
77
+ e.preventDefault();
78
+ focusRename(el);
68
79
  };
69
80
 
81
+ // Props for a rename input that was opened from a menu. While the menu is
82
+ // still on screen it traps focus, so the input waits for keepRenameFocus; a
83
+ // blur before the input has held focus once is never a commit.
84
+ export function useRenameInput(onDone: () => void) {
85
+ const hadFocus = useRef(false);
86
+ const ref = useCallback((el: HTMLInputElement | null) => {
87
+ hadFocus.current = false;
88
+ if (el && !document.querySelector('[role="menu"]')) focusRename(el);
89
+ }, []);
90
+ return {
91
+ ref,
92
+ "data-rename-input": "",
93
+ onFocus: () => {
94
+ hadFocus.current = true;
95
+ },
96
+ onBlur: () => {
97
+ if (hadFocus.current) onDone();
98
+ },
99
+ };
100
+ }
101
+
70
102
  export function RowRename({
71
103
  initial,
72
104
  onDone,
@@ -75,41 +107,13 @@ export function RowRename({
75
107
  onDone: (name: string | null) => void;
76
108
  }) {
77
109
  const [value, setValue] = useState(initial);
78
- const ref = useRef<HTMLInputElement>(null);
79
- const hadFocus = useRef(false);
80
- // The menu closing hands focus back to its trigger (RowShell stops that
81
- // while renaming); keep taking it until we hold it, then leave it alone.
82
- useEffect(() => {
83
- const take = () => {
84
- const el = ref.current;
85
- if (!el || hadFocus.current) return;
86
- el.focus();
87
- if (document.activeElement === el) {
88
- hadFocus.current = true;
89
- el.select();
90
- }
91
- };
92
- take();
93
- const t = setInterval(take, 50);
94
- const stop = setTimeout(() => clearInterval(t), 1000);
95
- return () => {
96
- clearInterval(t);
97
- clearTimeout(stop);
98
- };
99
- }, []);
110
+ const input = useRenameInput(() => onDone(value.trim() || null));
100
111
  return (
101
112
  <input
102
- ref={ref}
113
+ {...input}
103
114
  value={value}
104
115
  aria-label="Session name"
105
116
  onChange={(e) => setValue(e.target.value)}
106
- onFocus={() => {
107
- hadFocus.current = true;
108
- }}
109
- onBlur={() => {
110
- // Only once we've held focus: the menu closing hands it to its trigger.
111
- if (hadFocus.current) onDone(value.trim() || null);
112
- }}
113
117
  onKeyDown={(e) => {
114
118
  e.stopPropagation();
115
119
  if (e.key === "Enter") onDone(value.trim() || null);
@@ -2,12 +2,16 @@ import "@/components/Chat/composer/test-dom";
2
2
  import { act, createElement, useState } from "react";
3
3
  import { createRoot, type Root } from "react-dom/client";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
- import { keepRenameFocus, RowRename } from "./RowParts";
5
+ import * as DM from "@/components/ui/dropdown-menu";
6
+ import { keepRenameFocus, RowRename, useRenameInput } from "./RowParts";
6
7
 
7
8
  (
8
9
  globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
9
10
  ).IS_REACT_ACT_ENVIRONMENT = true;
10
11
 
12
+ // Radix dispatches CustomEvents; node's own class is rejected by jsdom.
13
+ (globalThis as { CustomEvent?: unknown }).CustomEvent = window.CustomEvent;
14
+
11
15
  let root: Root;
12
16
  let host: HTMLElement;
13
17
  const input = () => host.querySelector("input")!;
@@ -28,23 +32,87 @@ function mount(onDone: (n: string | null) => void, initial = "old") {
28
32
  act(() => root.render(createElement(RowRename, { initial, onDone })));
29
33
  }
30
34
 
35
+ // A row whose menu has a Rename item, wired like RowShell / GroupShelves.
36
+ function Row({ group = false }: { group?: boolean }) {
37
+ const [renaming, setRenaming] = useState(false);
38
+ const [name, setName] = useState("old");
39
+ const rename = useRenameInput(() => setRenaming(false));
40
+ const menu = createElement(
41
+ DM.DropdownMenu,
42
+ { defaultOpen: true },
43
+ createElement(
44
+ DM.DropdownMenuTrigger,
45
+ { asChild: true },
46
+ createElement("button", { "aria-label": "menu" })
47
+ ),
48
+ createElement(
49
+ DM.DropdownMenuContent,
50
+ { onCloseAutoFocus: keepRenameFocus },
51
+ createElement(
52
+ DM.DropdownMenuItem,
53
+ { onSelect: () => setRenaming(true) },
54
+ "Rename"
55
+ )
56
+ )
57
+ );
58
+ return createElement(
59
+ "div",
60
+ null,
61
+ menu,
62
+ renaming
63
+ ? group
64
+ ? createElement("input", {
65
+ ...rename,
66
+ value: name,
67
+ onChange: (e: { target: { value: string } }) =>
68
+ setName(e.target.value),
69
+ })
70
+ : createElement(RowRename, {
71
+ initial: name,
72
+ onDone: () => setRenaming(false),
73
+ })
74
+ : null
75
+ );
76
+ }
77
+
78
+ describe.each([
79
+ ["session row", false],
80
+ ["group header", true],
81
+ ])("rename from the menu on a fresh mount (%s)", (_n, group) => {
82
+ it("keeps the input open, focused, and accepting typing", async () => {
83
+ vi.useRealTimers();
84
+ act(() => root.render(createElement(Row, { group })));
85
+ const item = [...document.querySelectorAll('[role="menuitem"]')].find(
86
+ (el) => el.textContent === "Rename"
87
+ ) as HTMLElement;
88
+ expect(item).toBeTruthy();
89
+ await act(async () => {
90
+ item.click();
91
+ await new Promise((r) => setTimeout(r, 100));
92
+ });
93
+ const el = input();
94
+ expect(el).toBeTruthy();
95
+ expect(document.activeElement).toBe(el);
96
+ const set = Object.getOwnPropertyDescriptor(
97
+ window.HTMLInputElement.prototype,
98
+ "value"
99
+ )!.set!;
100
+ act(() => {
101
+ set.call(el, "typed");
102
+ el.dispatchEvent(new window.Event("input", { bubbles: true }));
103
+ });
104
+ expect(input()).toBe(el);
105
+ expect(input().value).toBe("typed");
106
+ });
107
+ });
108
+
31
109
  describe("RowRename", () => {
32
- it("retries focus the menu stole, and ignores blur until it has it", () => {
33
- const real = window.HTMLElement.prototype.focus;
34
- let calls = 0;
35
- // The first focus attempts land while the menu still traps focus.
36
- vi.spyOn(window.HTMLElement.prototype, "focus").mockImplementation(
37
- function (this: HTMLElement) {
38
- if (++calls > 2) real.call(this);
39
- }
40
- );
110
+ it("ignores a blur that comes before it was ever focused", () => {
41
111
  const done = vi.fn();
42
112
  mount(done);
113
+ // an open menu keeps the input from taking focus on mount
43
114
  act(() => input().dispatchEvent(new window.FocusEvent("blur")));
44
115
  expect(done).not.toHaveBeenCalled();
45
- act(() => vi.advanceTimersByTime(150));
46
- expect(document.activeElement).toBe(input());
47
- vi.restoreAllMocks();
48
116
  });
49
117
 
50
118
  it("keeps typed text across a parent re-render (list refresh)", () => {
@@ -75,7 +143,6 @@ describe("RowRename", () => {
75
143
  it("saves on Enter, cancels on Escape, saves on blur once focused", () => {
76
144
  const done = vi.fn();
77
145
  mount(done);
78
- act(() => vi.advanceTimersByTime(60));
79
146
  const key = (k: string) =>
80
147
  act(() => {
81
148
  input().dispatchEvent(
@@ -92,11 +159,13 @@ describe("RowRename", () => {
92
159
  });
93
160
 
94
161
  describe("keepRenameFocus", () => {
95
- it("stops the menu returning focus only while renaming", () => {
162
+ it("takes the focus the menu would return, only while a rename input is open", () => {
96
163
  const e = { preventDefault: vi.fn() } as unknown as Event;
97
- keepRenameFocus(false)(e);
164
+ keepRenameFocus(e);
98
165
  expect(e.preventDefault).not.toHaveBeenCalled();
99
- keepRenameFocus(true)(e);
166
+ mount(() => {});
167
+ keepRenameFocus(e);
100
168
  expect(e.preventDefault).toHaveBeenCalled();
169
+ expect(document.activeElement).toBe(input());
101
170
  });
102
171
  });
@@ -42,7 +42,6 @@ export function RowShell({
42
42
  menu,
43
43
  menuLabel,
44
44
  menuButton = true,
45
- renaming = false,
46
45
  }: {
47
46
  title: string;
48
47
  tipKey?: string;
@@ -62,8 +61,6 @@ export function RowShell({
62
61
  menuLabel: string;
63
62
  // False keeps the right-click menu but hides the ⋯ (while selecting).
64
63
  menuButton?: boolean;
65
- // A rename input is open: closing the menu mustn't move focus off it.
66
- renaming?: boolean;
67
64
  }) {
68
65
  const content = (
69
66
  <div
@@ -118,7 +115,7 @@ export function RowShell({
118
115
  <DM.DropdownMenuContent
119
116
  align="end"
120
117
  className={ROW_MENU_CONTENT}
121
- onCloseAutoFocus={keepRenameFocus(renaming)}
118
+ onCloseAutoFocus={keepRenameFocus}
122
119
  onClick={(e) => e.stopPropagation()}
123
120
  >
124
121
  {menu("dropdown")}
@@ -133,7 +130,7 @@ export function RowShell({
133
130
  <CM.ContextMenuTrigger asChild>{content}</CM.ContextMenuTrigger>
134
131
  <CM.ContextMenuContent
135
132
  className={ROW_MENU_CONTENT}
136
- onCloseAutoFocus={keepRenameFocus(renaming)}
133
+ onCloseAutoFocus={keepRenameFocus}
137
134
  >
138
135
  {menu("context")}
139
136
  </CM.ContextMenuContent>
@@ -127,7 +127,6 @@ export const SessionRow = memo(function SessionRow({
127
127
  menu={menu}
128
128
  menuLabel="Session actions"
129
129
  menuButton={!selecting}
130
- renaming={renaming}
131
130
  >
132
131
  {renaming ? (
133
132
  <RowRename
@@ -19,8 +19,8 @@ export function AppWideLimits() {
19
19
  const updateAway = useUpdateAwaySettings();
20
20
  return (
21
21
  <SettingSection
22
- title="All workspaces"
23
- help="Stall alerts and Away mode apply across every workspace."
22
+ title="Alerts and Away"
23
+ help="Apply across every workspace."
24
24
  >
25
25
  {stall.data && (
26
26
  <NumberSetting
@@ -25,6 +25,14 @@ const state = vi.hoisted(() => ({
25
25
  audit: [] as object[],
26
26
  mutate: vi.fn(),
27
27
  }));
28
+ vi.mock("@/components/Merge/MergeApprovalToggle", () => ({
29
+ MergeApprovalToggle: ({ overriddenBy }: { overriddenBy?: boolean }) =>
30
+ createElement(
31
+ "p",
32
+ { "data-merge": String(!!overriddenBy) },
33
+ "merge toggle"
34
+ ),
35
+ }));
28
36
  vi.mock("@/data/approvals", () => ({
29
37
  useApprovalsModeQuery: () => ({
30
38
  isPending: false,
@@ -32,14 +40,14 @@ vi.mock("@/data/approvals", () => ({
32
40
  data: { on: state.on, audit: state.audit },
33
41
  }),
34
42
  useSetApprovalsMode: () => ({
35
- mutate: state.mutate,
43
+ mutateAsync: state.mutate,
36
44
  isPending: false,
37
45
  error: null,
38
46
  }),
39
47
  }));
40
48
 
41
49
  const { TooltipProvider } = await import("@/components/ui/tooltip");
42
- const { NoApprovalsSection } = await import("./NoApprovalsSection");
50
+ const { ApprovalsSection } = await import("./ApprovalsSection");
43
51
  const { NoApprovalsBadge } =
44
52
  await import("@/components/Approvals/NoApprovalsBadge");
45
53
 
@@ -61,7 +69,7 @@ afterEach(() => {
61
69
  const render = (el: ReturnType<typeof createElement>) =>
62
70
  act(() => root.render(el));
63
71
  const click = (el: Element | null) =>
64
- act(() => {
72
+ act(async () => {
65
73
  el?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
66
74
  });
67
75
  const byText = (text: string) =>
@@ -69,45 +77,76 @@ const byText = (text: string) =>
69
77
  b.textContent?.includes(text)
70
78
  ) ?? null;
71
79
 
72
- describe("the No approvals toggle", () => {
73
- it("is off, and flipping it warns first without switching anything", () => {
74
- render(createElement(NoApprovalsSection));
80
+ describe("the Approvals section", () => {
81
+ beforeEach(() => {
82
+ state.mutate.mockResolvedValue(undefined);
83
+ });
84
+
85
+ it("is off, and flipping it warns first without switching anything", async () => {
86
+ render(createElement(ApprovalsSection));
75
87
  const toggle = host.querySelector('[role="switch"]');
76
88
  expect(toggle?.getAttribute("aria-checked")).toBe("false");
77
89
  expect(host.querySelector('[role="alertdialog"]')).toBeNull();
78
- click(toggle!);
90
+ await click(toggle!);
79
91
  const warning = host.querySelector('[role="alertdialog"]');
80
92
  expect(warning?.textContent).toMatch(/hard lines included/);
81
93
  expect(warning?.textContent).toMatch(/permission prompts off/);
82
94
  expect(state.mutate).not.toHaveBeenCalled();
83
95
  });
84
96
 
85
- it("switches on only when the warning is confirmed, and cancel backs out", () => {
86
- render(createElement(NoApprovalsSection));
87
- click(host.querySelector('[role="switch"]'));
88
- click(byText("Cancel"));
97
+ it("switches on only when the warning is confirmed, and cancel backs out", async () => {
98
+ render(createElement(ApprovalsSection));
99
+ await click(host.querySelector('[role="switch"]'));
100
+ await click(byText("Cancel"));
89
101
  expect(host.querySelector('[role="alertdialog"]')).toBeNull();
90
102
  expect(state.mutate).not.toHaveBeenCalled();
91
- click(host.querySelector('[role="switch"]'));
92
- click(byText("Turn on No approvals"));
93
- expect(state.mutate).toHaveBeenCalledWith(true, expect.any(Object));
103
+ await click(host.querySelector('[role="switch"]'));
104
+ await click(byText("Turn on No approvals"));
105
+ expect(state.mutate).toHaveBeenCalledWith(true);
106
+ });
107
+
108
+ it("switches off at once", async () => {
109
+ state.on = true;
110
+ render(createElement(ApprovalsSection));
111
+ await click(host.querySelector('[role="switch"]'));
112
+ expect(state.mutate).toHaveBeenCalledWith(false);
94
113
  });
95
114
 
96
- it("switches off at once, and lists what was approved for you", () => {
115
+ it("holds the merge approval switch in the same place, greyed while the mode is on", () => {
116
+ render(createElement(ApprovalsSection));
117
+ expect(host.querySelector("[data-merge]")?.getAttribute("data-merge")).toBe(
118
+ "false"
119
+ );
120
+ state.on = true;
121
+ act(() => root.unmount());
122
+ root = createRoot(host);
123
+ render(createElement(ApprovalsSection));
124
+ expect(host.querySelector("[data-merge]")?.getAttribute("data-merge")).toBe(
125
+ "true"
126
+ );
127
+ });
128
+
129
+ it("lists what was approved in words, with a command behind Details", async () => {
97
130
  state.on = true;
98
131
  state.audit = [
99
132
  {
100
133
  id: 1,
101
134
  at: "2026-10-13 10:00:00",
102
- source: "ask",
103
- subject: 'money ask "Pay?"',
104
- detail: "",
135
+ where: "A chat",
136
+ summary: "Ran a command",
137
+ details: "ssh root@host 'ls /tmp'",
105
138
  },
106
139
  ];
107
- render(createElement(NoApprovalsSection));
108
- expect(host.textContent).toContain('money ask "Pay?"');
109
- click(host.querySelector('[role="switch"]'));
110
- expect(state.mutate).toHaveBeenCalledWith(false);
140
+ render(createElement(ApprovalsSection));
141
+ expect(host.textContent).toContain("Ran a command");
142
+ expect(host.textContent).not.toContain("ssh root@host");
143
+ await click(byText("Details"));
144
+ expect(host.textContent).toContain("ssh root@host 'ls /tmp'");
145
+ });
146
+
147
+ it("says so when nothing was approved", () => {
148
+ render(createElement(ApprovalsSection));
149
+ expect(host.textContent).toContain("Nothing yet.");
111
150
  });
112
151
  });
113
152
 
@@ -0,0 +1,93 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { ListSection } from "@/components/Settings/ListSection";
5
+ import { SettingSection } from "@/components/Settings/SettingRow";
6
+ import { SwitchSetting } from "@/components/Settings/settings-fields";
7
+ import { MergeApprovalToggle } from "@/components/Merge/MergeApprovalToggle";
8
+ import { useApprovalsModeQuery, useSetApprovalsMode } from "@/data/approvals";
9
+ import type { ApprovalLine } from "@/lib/approvals/describe";
10
+
11
+ // What it switches off, said plainly before it can be switched on.
12
+ export const NO_APPROVALS_WARNING = [
13
+ "Every ask is approved the moment it is raised, hard lines included: public posts and messages, money, irreversible or destructive changes, credentials.",
14
+ "A PR stuck failing its gates, with no CI, or too big to review still merges.",
15
+ "Brakes stop holding new starts. Plan-only tasks still wait for approve_plan.",
16
+ "Every agent AgentOS starts runs with its permission prompts off, and prompts already waiting are allowed.",
17
+ "Terminals already running in an agent other than Claude keep asking until they are restarted. Each approval made for you is logged here.",
18
+ ];
19
+
20
+ const when = (at: string) =>
21
+ new Date(`${at.replace(" ", "T")}Z`).toLocaleString(undefined, {
22
+ month: "short",
23
+ day: "numeric",
24
+ hour: "numeric",
25
+ minute: "2-digit",
26
+ });
27
+
28
+ function Line({ line }: { line: ApprovalLine }) {
29
+ const [open, setOpen] = useState(false);
30
+ return (
31
+ <div className="px-3 py-2.5 text-sm">
32
+ <p className="break-words">{line.summary}</p>
33
+ <p className="text-muted-foreground text-xs">
34
+ {line.where} · {when(line.at)}
35
+ {line.details && (
36
+ <>
37
+ {" · "}
38
+ <button
39
+ type="button"
40
+ aria-expanded={open}
41
+ onClick={() => setOpen(!open)}
42
+ className="text-primary -my-3 min-h-11 hover:underline"
43
+ >
44
+ {open ? "Hide details" : "Details"}
45
+ </button>
46
+ </>
47
+ )}
48
+ </p>
49
+ {open && line.details && (
50
+ <code className="bg-foreground/[0.05] mt-1 block rounded px-2 py-1 font-mono text-xs break-all whitespace-pre-wrap">
51
+ {line.details}
52
+ </code>
53
+ )}
54
+ </div>
55
+ );
56
+ }
57
+
58
+ // Every approval AgentOS would wait on, in one place: the master switch, the
59
+ // merge gate, and what the switch approved for you.
60
+ export function ApprovalsSection() {
61
+ const { data, isPending, isError, error } = useApprovalsModeQuery();
62
+ const set = useSetApprovalsMode();
63
+
64
+ if (isPending)
65
+ return <div className="bg-muted/40 h-16 animate-pulse rounded-lg" />;
66
+ if (isError)
67
+ return <p className="text-destructive text-sm">{error.message}</p>;
68
+
69
+ return (
70
+ <div className="space-y-5">
71
+ <SettingSection>
72
+ <SwitchSetting
73
+ id="no-approvals"
74
+ label="No approvals"
75
+ help="Shuts off every approval AgentOS would otherwise wait on, and every agent's permission prompts."
76
+ checked={data.on}
77
+ defaultValue={false}
78
+ confirm={NO_APPROVALS_WARNING.join(" ")}
79
+ confirmLabel="Turn on No approvals"
80
+ onSave={(on) => set.mutateAsync(on)}
81
+ />
82
+ <MergeApprovalToggle overriddenBy={data.on} />
83
+ </SettingSection>
84
+ <ListSection
85
+ title="Approved for you"
86
+ items={data.audit}
87
+ keyOf={(l) => String(l.id)}
88
+ render={(l) => <Line line={l} />}
89
+ empty="Nothing yet."
90
+ />
91
+ </div>
92
+ );
93
+ }