@saadnvd1/agent-os 0.3.836 → 0.3.839

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 (54) hide show
  1. package/app/api/fresh-start/route.test.ts +33 -0
  2. package/app/api/fresh-start/route.ts +3 -0
  3. package/components/Devices/DeviceRow.tsx +83 -69
  4. package/components/Devices/PasskeysSection.test.ts +89 -0
  5. package/components/Devices/PasskeysSection.tsx +36 -26
  6. package/components/Health/HealthPanel.tsx +41 -16
  7. package/components/Merge/GlobalMergeSection.tsx +2 -5
  8. package/components/Merge/MergeSettingsFields.tsx +39 -77
  9. package/components/Merge/ProjectMergeSection.tsx +2 -6
  10. package/components/Merge/WorktreeSweepSection.tsx +40 -14
  11. package/components/Models/AgentRoleCard.tsx +61 -66
  12. package/components/Models/AgentRolesSection.tsx +5 -8
  13. package/components/Models/ModelsSection.tsx +7 -18
  14. package/components/Models/PriceInput.tsx +59 -0
  15. package/components/PhoneNotify/PhoneNotifyForm.tsx +37 -58
  16. package/components/Settings/AppWideLimits.tsx +61 -0
  17. package/components/Settings/ContextSection.tsx +96 -0
  18. package/components/Settings/ExperimentsSection.test.ts +9 -7
  19. package/components/Settings/ExperimentsSection.tsx +22 -23
  20. package/components/Settings/InlineConfirm.tsx +51 -0
  21. package/components/Settings/OwnerNameRow.tsx +10 -40
  22. package/components/Settings/PickerSetting.tsx +59 -0
  23. package/components/Settings/SettingRow.tsx +131 -0
  24. package/components/Settings/SettingsDialog.test.ts +1 -0
  25. package/components/Settings/SettingsDialog.tsx +47 -2
  26. package/components/Settings/settings-fields.test.ts +103 -0
  27. package/components/Settings/settings-fields.tsx +427 -0
  28. package/components/Shares/SharedLinksPanel.tsx +62 -53
  29. package/components/Workspaces/HqFolderForm.tsx +21 -54
  30. package/components/Workspaces/McpServersForm.tsx +20 -19
  31. package/components/Workspaces/ProjectTiersForm.tsx +10 -8
  32. package/components/Workspaces/RulesForm.test.ts +127 -0
  33. package/components/Workspaces/RulesForm.tsx +54 -126
  34. package/components/Workspaces/WorkspaceModelsForm.tsx +27 -26
  35. package/components/Workspaces/WorkspaceSettings.tsx +61 -24
  36. package/content/whats-new.json +16 -0
  37. package/data/fresh-start/index.ts +1 -1
  38. package/data/workspaces/index.ts +9 -0
  39. package/hooks/useAutoSave.test.ts +116 -0
  40. package/hooks/useAutoSave.ts +98 -0
  41. package/lib/away/window-limits.ts +3 -1
  42. package/lib/chat/refresh.ts +10 -6
  43. package/lib/chat/stall-limits.ts +3 -1
  44. package/lib/settings/defaults.ts +27 -0
  45. package/lib/settings/parse.ts +17 -0
  46. package/lib/settings-url.ts +1 -0
  47. package/lib/worktree-sweep/limits.ts +3 -0
  48. package/lib/worktree-sweep/settings.ts +4 -2
  49. package/package.json +1 -1
  50. package/components/Away/AwayWindowForm.tsx +0 -91
  51. package/components/Workspaces/FreshStartForm.tsx +0 -177
  52. package/components/Workspaces/StallForm.tsx +0 -101
  53. package/components/Workspaces/TaskLimitForm.tsx +0 -84
  54. package/components/Workspaces/fresh-start.ts +0 -19
@@ -1,34 +1,35 @@
1
1
  "use client";
2
2
 
3
- import { Switch } from "@/components/ui/switch";
3
+ import { SettingSection } from "@/components/Settings/SettingRow";
4
+ import { SwitchSetting } from "@/components/Settings/settings-fields";
4
5
  import { useMcpServersQuery, useUpdateWorkspace } from "@/data/workspaces";
5
6
  import type { Workspace } from "@/lib/db";
6
7
 
7
8
  // Which MCP servers a workspace's orchestrator may use. Servers carry their
8
- // own auth, so none is allowed unless ticked here.
9
+ // own auth, so none is allowed unless switched on here.
9
10
  export function McpServersForm({ workspace }: { workspace: Workspace }) {
10
11
  const { data: configured = [] } = useMcpServersQuery();
11
12
  const update = useUpdateWorkspace();
12
13
  const allowed = workspace.orch_mcp_servers;
13
- // A ticked server no longer configured stays listed, so it can be unticked.
14
+ // An allowed server no longer configured stays listed, so it can be removed.
14
15
  const names = [...new Set([...configured, ...allowed])];
15
16
 
16
17
  const toggle = (name: string, on: boolean) =>
17
- update.mutate({
18
+ update.mutateAsync({
18
19
  id: workspace.id,
19
20
  mcpServers: on ? [...allowed, name] : allowed.filter((n) => n !== name),
20
21
  });
21
22
 
22
23
  return (
23
- <div className="bg-muted/40 space-y-2 rounded-lg px-3 py-2.5">
24
+ <div className="space-y-2">
24
25
  <div>
25
- <p className="text-sm font-medium">
26
+ <p className="text-sm font-semibold">
26
27
  MCP servers its orchestrator may use
27
28
  </p>
28
29
  <p className="text-muted-foreground text-xs">
29
- All of a ticked server&apos;s tools are allowed. Sending a message,
30
- email or post through one still goes to you as an ask. Takes effect at
31
- its next turn.
30
+ All of a server&apos;s tools are allowed once it&apos;s on. Sending a
31
+ message, email or post through one still goes to you as an ask. Takes
32
+ effect at its next turn.
32
33
  </p>
33
34
  </div>
34
35
  {names.length === 0 ? (
@@ -36,18 +37,18 @@ export function McpServersForm({ workspace }: { workspace: Workspace }) {
36
37
  No MCP servers are configured on this machine.
37
38
  </p>
38
39
  ) : (
39
- names.map((name) => (
40
- <label
41
- key={name}
42
- className="flex min-h-11 items-center justify-between gap-3 text-sm"
43
- >
44
- <span className="min-w-0 truncate">{name}</span>
45
- <Switch
40
+ <SettingSection>
41
+ {names.map((name) => (
42
+ <SwitchSetting
43
+ key={name}
44
+ id={`mcp-${workspace.id}-${name}`}
45
+ label={name}
46
46
  checked={allowed.includes(name)}
47
- onCheckedChange={(on) => toggle(name, on)}
47
+ defaultValue={false}
48
+ onSave={(on) => toggle(name, on)}
48
49
  />
49
- </label>
50
- ))
50
+ ))}
51
+ </SettingSection>
51
52
  )}
52
53
  </div>
53
54
  );
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { SettingToggle } from "@/components/Settings/SettingToggle";
3
+ import { SettingSection } from "@/components/Settings/SettingRow";
4
+ import { SwitchSetting } from "@/components/Settings/settings-fields";
4
5
  import { useProjectsQuery, useUpdateProject } from "@/data/projects";
5
6
 
6
7
  // A workspace's projects, each switched between production (every merge gate)
@@ -13,21 +14,22 @@ export function ProjectTiersForm({ workspaceId }: { workspaceId: string }) {
13
14
  );
14
15
  if (!mine.length) return null;
15
16
  return (
16
- <div className="space-y-2">
17
+ <SettingSection>
17
18
  {mine.map((p) => (
18
- <SettingToggle
19
+ <SwitchSetting
19
20
  key={p.id}
20
- title={`${p.name}: production`}
21
- detail="On: CI, the independent review and scope gate its merges. Off (fast): a task's PR merges as soon as it opens."
21
+ id={`tier-${p.id}`}
22
+ label={`${p.name}: production`}
23
+ help="On: CI, the independent review and scope gate its merges. Off (fast): a task's PR merges as soon as it opens."
22
24
  checked={p.tier === "production"}
23
- onChange={(on) =>
24
- update.mutate({
25
+ onSave={(on) =>
26
+ update.mutateAsync({
25
27
  projectId: p.id,
26
28
  tier: on ? "production" : "fast",
27
29
  })
28
30
  }
29
31
  />
30
32
  ))}
31
- </div>
33
+ </SettingSection>
32
34
  );
33
35
  }
@@ -0,0 +1,127 @@
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, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ for (const key of ["Event", "CustomEvent", "FocusEvent", "HTMLButtonElement"])
7
+ Object.defineProperty(globalThis, key, {
8
+ value: (window as unknown as Record<string, unknown>)[key],
9
+ configurable: true,
10
+ writable: true,
11
+ });
12
+ (
13
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
14
+ ).IS_REACT_ACT_ENVIRONMENT = true;
15
+
16
+ const state = vi.hoisted(() => ({
17
+ update: vi.fn(),
18
+ add: vi.fn(),
19
+ toast: vi.fn(),
20
+ }));
21
+ vi.mock("sonner", () => ({ toast: { error: state.toast } }));
22
+ vi.mock("@/data/orchestrators/rules", () => ({
23
+ useRulesQuery: () => ({
24
+ isPending: false,
25
+ isError: false,
26
+ data: [
27
+ { id: 4, workspace_id: "w1", text: "Never merge #12", added_by: "saad" },
28
+ ],
29
+ }),
30
+ useAddRule: () => ({ mutateAsync: state.add }),
31
+ useUpdateRule: () => ({ mutateAsync: state.update }),
32
+ useRemoveRule: () => ({ mutate: vi.fn(), isPending: false }),
33
+ }));
34
+
35
+ const { RulesForm } = await import("./RulesForm");
36
+
37
+ let root: Root;
38
+ let host: HTMLDivElement;
39
+ beforeEach(() => {
40
+ state.update.mockReset().mockResolvedValue(undefined);
41
+ state.add.mockReset().mockResolvedValue(undefined);
42
+ state.toast.mockReset();
43
+ host = document.createElement("div");
44
+ document.body.appendChild(host);
45
+ root = createRoot(host);
46
+ });
47
+ afterEach(async () => {
48
+ await act(async () => root.unmount());
49
+ host.remove();
50
+ });
51
+
52
+ const type = async (input: HTMLInputElement, text: string) => {
53
+ const set = Object.getOwnPropertyDescriptor(
54
+ HTMLInputElement.prototype,
55
+ "value"
56
+ )!.set!;
57
+ await act(async () => {
58
+ set.call(input, text);
59
+ input.dispatchEvent(new Event("input", { bubbles: true }));
60
+ });
61
+ };
62
+ const rule = () => host.querySelector<HTMLInputElement>("#rule-4")!;
63
+ const click = (el: Element | null | undefined) =>
64
+ act(async () => {
65
+ el?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
66
+ });
67
+ const button = (name: string) =>
68
+ [...host.querySelectorAll("button")].find((b) => b.textContent === name);
69
+
70
+ describe("RulesForm", () => {
71
+ beforeEach(async () => {
72
+ await act(async () =>
73
+ root.render(createElement(RulesForm, { workspaceId: "w1" }))
74
+ );
75
+ });
76
+
77
+ it("never asks for a passkey on blur", async () => {
78
+ await type(rule(), "Never merge #13");
79
+ await act(async () => {
80
+ rule().dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
81
+ });
82
+ expect(state.update).not.toHaveBeenCalled();
83
+ });
84
+
85
+ it("saves through the passkey step on the Save button, and says Saved", async () => {
86
+ await type(rule(), "Never merge #13");
87
+ await click(button("Save"));
88
+ expect(state.update).toHaveBeenCalledWith({
89
+ id: 4,
90
+ text: "Never merge #13",
91
+ });
92
+ expect(host.textContent).toContain("Saved");
93
+ });
94
+
95
+ it("saves on Enter", async () => {
96
+ await type(rule(), "Never merge #13");
97
+ await act(async () => {
98
+ rule().dispatchEvent(
99
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
100
+ );
101
+ });
102
+ expect(state.update).toHaveBeenCalledOnce();
103
+ });
104
+
105
+ it("rolls back and toasts when the passkey is cancelled", async () => {
106
+ state.update.mockRejectedValue(new Error("Passkey cancelled"));
107
+ await type(rule(), "Never merge #13");
108
+ await click(button("Save"));
109
+ expect(rule().value).toBe("Never merge #12");
110
+ expect(state.toast).toHaveBeenCalledWith("Passkey cancelled");
111
+ expect(host.textContent).not.toContain("Saved");
112
+ });
113
+
114
+ it("won't save an empty rule", async () => {
115
+ await type(rule(), " ");
116
+ await click(button("Save"));
117
+ expect(state.update).not.toHaveBeenCalled();
118
+ expect(host.textContent).toContain("A rule can't be empty");
119
+ });
120
+
121
+ it("adds a rule through the same step", async () => {
122
+ const input = host.querySelector<HTMLInputElement>("#rule-new-w1")!;
123
+ await type(input, "House rule");
124
+ await click(button("Add"));
125
+ expect(state.add).toHaveBeenCalledWith("House rule");
126
+ });
127
+ });
@@ -1,10 +1,10 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
4
- import { Check, Pencil, Trash2, X } from "lucide-react";
3
+ import { Trash2 } from "lucide-react";
5
4
  import { toast } from "sonner";
6
5
  import { Button } from "@/components/ui/button";
7
- import { Input } from "@/components/ui/input";
6
+ import { SettingSection } from "@/components/Settings/SettingRow";
7
+ import { TextSetting } from "@/components/Settings/settings-fields";
8
8
  import type { RuleRow } from "@/lib/orchestrator/rules";
9
9
  import {
10
10
  useAddRule,
@@ -17,58 +17,44 @@ const MAX_CHARS = 300;
17
17
 
18
18
  // The orchestrator's standing rules for one workspace: what it keeps
19
19
  // knowing across fresh starts. It adds them itself too; Saad edits here.
20
+ // Every change takes his passkey (data/orchestrators/rules), so a rule saves
21
+ // on Enter or its button, never on blur; a cancelled or refused passkey rolls
22
+ // the row back like any failed save.
20
23
  export function RulesForm({ workspaceId }: { workspaceId: string }) {
21
24
  const { data: rules, isPending, isError, error } = useRulesQuery(workspaceId);
22
25
  const add = useAddRule(workspaceId);
23
- const [text, setText] = useState("");
24
26
 
25
27
  return (
26
28
  <div className="space-y-2">
27
- <div className="text-sm">Orchestrator rules</div>
29
+ <p className="text-sm font-semibold">Orchestrator rules</p>
28
30
  {isPending ? (
29
31
  <div className="bg-muted/60 h-11 animate-pulse rounded-md" />
30
32
  ) : isError ? (
31
33
  <p className="text-destructive text-xs">{error.message}</p>
32
- ) : rules.length ? (
33
- <ul className="space-y-1">
34
+ ) : (
35
+ <SettingSection
36
+ help={
37
+ rules.length
38
+ ? "Each change asks for your passkey."
39
+ : "None yet. The orchestrator keeps one when you tell it something standing, like a PR never to merge. Each change asks for your passkey."
40
+ }
41
+ >
34
42
  {rules.map((r) => (
35
43
  <RuleItem key={`${r.id}:${r.text}`} rule={r} />
36
44
  ))}
37
- </ul>
38
- ) : (
39
- <p className="text-muted-foreground text-xs">
40
- None yet. The orchestrator keeps one when you tell it something
41
- standing, like a PR never to merge.
42
- </p>
45
+ <TextSetting
46
+ id={`rule-new-${workspaceId}`}
47
+ label="New rule"
48
+ value=""
49
+ explicit
50
+ resettable={false}
51
+ buttonLabel="Add"
52
+ maxLength={MAX_CHARS}
53
+ placeholder="A PR never to merge, a URL, a house rule"
54
+ onSave={(text) => add.mutateAsync(text)}
55
+ />
56
+ </SettingSection>
43
57
  )}
44
- <form
45
- onSubmit={(e) => {
46
- e.preventDefault();
47
- if (!text.trim()) return;
48
- add.mutate(text, {
49
- onSuccess: () => setText(""),
50
- onError: (err) => toast.error(err.message),
51
- });
52
- }}
53
- className="flex items-center gap-2"
54
- >
55
- <Input
56
- value={text}
57
- maxLength={MAX_CHARS}
58
- onChange={(e) => setText(e.target.value)}
59
- placeholder="A PR never to merge, a URL, a house rule"
60
- aria-label="New orchestrator rule"
61
- className="h-11 text-base sm:h-9 md:text-sm"
62
- />
63
- <Button
64
- type="submit"
65
- size="sm"
66
- className="h-11 sm:h-8"
67
- disabled={!text.trim() || add.isPending}
68
- >
69
- Add
70
- </Button>
71
- </form>
72
58
  </div>
73
59
  );
74
60
  }
@@ -76,91 +62,33 @@ export function RulesForm({ workspaceId }: { workspaceId: string }) {
76
62
  function RuleItem({ rule }: { rule: RuleRow }) {
77
63
  const update = useUpdateRule(rule.workspace_id);
78
64
  const remove = useRemoveRule(rule.workspace_id);
79
- const [editing, setEditing] = useState(false);
80
- const [text, setText] = useState(rule.text);
81
-
82
- if (editing)
83
- return (
84
- <li>
85
- <form
86
- onSubmit={(e) => {
87
- e.preventDefault();
88
- update.mutate(
89
- { id: rule.id, text },
90
- {
91
- onSuccess: () => setEditing(false),
92
- onError: (err) => toast.error(err.message),
93
- }
94
- );
95
- }}
96
- className="flex items-center gap-1"
97
- >
98
- <Input
99
- autoFocus
100
- value={text}
101
- maxLength={MAX_CHARS}
102
- onChange={(e) => setText(e.target.value)}
103
- aria-label={`Rule ${rule.id}`}
104
- className="h-11 text-base sm:h-9 md:text-sm"
105
- />
106
- <Button
107
- type="submit"
108
- variant="ghost"
109
- size="icon"
110
- className="size-11 shrink-0 sm:size-8"
111
- disabled={!text.trim() || update.isPending}
112
- aria-label="Save rule"
113
- >
114
- <Check className="size-4" />
115
- </Button>
116
- <Button
117
- type="button"
118
- variant="ghost"
119
- size="icon"
120
- className="size-11 shrink-0 sm:size-8"
121
- onClick={() => {
122
- setText(rule.text);
123
- setEditing(false);
124
- }}
125
- aria-label="Cancel"
126
- >
127
- <X className="size-4" />
128
- </Button>
129
- </form>
130
- </li>
131
- );
132
-
133
65
  return (
134
- <li className="bg-background/60 flex items-start gap-1 rounded-md py-1 pr-1 pl-2.5">
135
- <div className="min-w-0 flex-1 py-1.5">
136
- <p className="text-sm break-words">{rule.text}</p>
137
- <p className="text-muted-foreground text-xs">
138
- {rule.added_by === "saad" ? "You" : "Orchestrator"}
139
- </p>
140
- </div>
141
- <Button
142
- variant="ghost"
143
- size="icon"
144
- className="size-11 shrink-0 sm:size-8"
145
- onClick={() => setEditing(true)}
146
- aria-label={`Edit rule: ${rule.text}`}
147
- >
148
- <Pencil className="size-4" />
149
- </Button>
150
- <Button
151
- variant="ghost"
152
- size="icon"
153
- className="size-11 shrink-0 sm:size-8"
154
- disabled={remove.isPending}
155
- onClick={() =>
156
- remove.mutate(rule.id, {
157
- onError: (err) => toast.error(err.message),
158
- })
159
- }
160
- aria-label={`Remove rule: ${rule.text}`}
161
- >
162
- <Trash2 className="size-4" />
163
- </Button>
164
- </li>
66
+ <TextSetting
67
+ id={`rule-${rule.id}`}
68
+ label={rule.added_by === "saad" ? "Your rule" : "Orchestrator's rule"}
69
+ value={rule.text}
70
+ explicit
71
+ resettable={false}
72
+ buttonLabel="Save"
73
+ maxLength={MAX_CHARS}
74
+ validate={(text) => (text ? null : "A rule can't be empty")}
75
+ onSave={(text) => update.mutateAsync({ id: rule.id, text })}
76
+ after={
77
+ <Button
78
+ variant="ghost"
79
+ size="icon"
80
+ className="size-11 shrink-0 sm:size-8"
81
+ disabled={remove.isPending}
82
+ onClick={() =>
83
+ remove.mutate(rule.id, {
84
+ onError: (err) => toast.error(err.message),
85
+ })
86
+ }
87
+ aria-label={`Remove rule: ${rule.text}`}
88
+ >
89
+ <Trash2 className="size-4" />
90
+ </Button>
91
+ }
92
+ />
165
93
  );
166
94
  }
@@ -1,6 +1,8 @@
1
1
  "use client";
2
2
 
3
3
  import { useState } from "react";
4
+ import { PickerSetting } from "@/components/Settings/PickerSetting";
5
+ import { SettingSection } from "@/components/Settings/SettingRow";
4
6
  import { ModelSelect } from "@/components/Models/ModelSelect";
5
7
  import {
6
8
  useModelsQuery,
@@ -37,34 +39,33 @@ export function WorkspaceModelsForm({ workspaceId }: { workspaceId: string }) {
37
39
  {open ? "▴" : "▾"}
38
40
  </button>
39
41
  {open && (
40
- <ul className="space-y-2">
42
+ <SettingSection>
41
43
  {WORKSPACE_ROLES.map((role) => (
42
- <li key={role} className="space-y-1">
43
- <label
44
- htmlFor={`ws-${workspaceId}-${role}`}
45
- className="text-muted-foreground block text-xs"
46
- >
47
- {ROLE_LABELS[role].label}
48
- </label>
49
- <ModelSelect
50
- id={`ws-${workspaceId}-${role}`}
51
- value={roles[role] ?? null}
52
- effective={models.data?.effective}
53
- inheritLabel={`As Settings (${models.data?.effective.roles[role] ?? "…"})`}
54
- disabled={update.isPending || !models.data}
55
- onChange={(ref) => {
56
- const next = { ...roles };
57
- if (ref) next[role] = ref;
58
- else delete next[role];
59
- update.mutate({ roles: next });
60
- }}
61
- />
62
- </li>
44
+ <PickerSetting<string | null>
45
+ key={role}
46
+ id={`ws-${workspaceId}-${role}`}
47
+ label={ROLE_LABELS[role].label}
48
+ value={roles[role] ?? null}
49
+ defaultValue={null}
50
+ onSave={(ref) => {
51
+ const next = { ...roles };
52
+ if (ref) next[role] = ref;
53
+ else delete next[role];
54
+ return update.mutateAsync({ roles: next });
55
+ }}
56
+ render={({ value, commit }) => (
57
+ <ModelSelect
58
+ id={`ws-${workspaceId}-${role}`}
59
+ value={value}
60
+ effective={models.data?.effective}
61
+ inheritLabel={`As Settings (${models.data?.effective.roles[role] ?? "…"})`}
62
+ disabled={!models.data}
63
+ onChange={commit}
64
+ />
65
+ )}
66
+ />
63
67
  ))}
64
- </ul>
65
- )}
66
- {update.isError && (
67
- <p className="text-destructive text-xs">{update.error.message}</p>
68
+ </SettingSection>
68
69
  )}
69
70
  </div>
70
71
  );
@@ -1,17 +1,20 @@
1
1
  "use client";
2
2
 
3
- import { SettingToggle } from "@/components/Settings/SettingToggle";
3
+ import { settingsUiActions } from "@/stores/settingsUi";
4
4
  import { useUpdateWorkspace, useWorkspacesQuery } from "@/data/workspaces";
5
- import { TaskLimitForm } from "./TaskLimitForm";
6
5
  import { RulesForm } from "./RulesForm";
7
- import { FreshStartForm } from "./FreshStartForm";
8
6
  import { McpServersForm } from "./McpServersForm";
9
7
  import { HqFolderForm } from "./HqFolderForm";
10
8
  import { ProjectTiersForm } from "./ProjectTiersForm";
11
- import { StallForm } from "./StallForm";
12
9
  import { WorkspaceModeForm } from "./WorkspaceModeForm";
13
10
  import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
14
- import { AwayWindowForm } from "@/components/Away/AwayWindowForm";
11
+ import { AppWideLimits } from "@/components/Settings/AppWideLimits";
12
+ import {
13
+ NumberSetting,
14
+ SwitchSetting,
15
+ } from "@/components/Settings/settings-fields";
16
+ import { SettingSection } from "@/components/Settings/SettingRow";
17
+ import { SETTING_DEFAULTS } from "@/lib/settings/defaults";
15
18
 
16
19
  // Settings > Workspaces: each workspace's own settings.
17
20
  export function WorkspaceSettings() {
@@ -43,33 +46,67 @@ export function WorkspaceSettings() {
43
46
  className="bg-muted/40 space-y-3 rounded-lg px-3 py-3"
44
47
  >
45
48
  <h3 className="truncate text-sm font-medium">{w.name}</h3>
46
- <TaskLimitForm
47
- key={`${w.id}:${w.max_running_tasks ?? "off"}`}
48
- workspace={w}
49
- />
49
+ <SettingSection>
50
+ <NumberSetting
51
+ id={`task-limit-${w.id}`}
52
+ label="Tasks at a time"
53
+ help="Cap on tasks running at once. The rest wait in Queued and start in order."
54
+ value={w.max_running_tasks ?? 0}
55
+ defaultValue={0}
56
+ onSave={(n) =>
57
+ update.mutateAsync({
58
+ id: w.id,
59
+ maxRunningTasks: n === 0 ? null : n,
60
+ })
61
+ }
62
+ min={1}
63
+ integer
64
+ unit="at a time"
65
+ optional={{ off: 0, on: SETTING_DEFAULTS.taskLimitOn }}
66
+ />
67
+ </SettingSection>
50
68
  <WorkspaceModeForm workspaceId={w.id} />
51
69
  <RulesForm workspaceId={w.id} />
52
70
  <WorkspaceModelsForm workspaceId={w.id} />
53
71
  <McpServersForm workspace={w} />
54
72
  <HqFolderForm workspaceId={w.id} />
55
73
  <ProjectTiersForm workspaceId={w.id} />
56
- <SettingToggle
57
- title="Wake tasks on CI failures and reviews"
58
- detail="A task's agent is messaged once when a check fails on its PR's head, with the log's tail, and when someone reviews or comments on it."
59
- checked={w.wake_tasks_on_pr}
60
- onChange={(on) => update.mutate({ id: w.id, wakeTasksOnPR: on })}
61
- />
62
- <SettingToggle
63
- title="Orchestrator full access"
64
- detail="Its shell and file writes aren't checked: it can push, commit, edit any repository or file on this machine, and merge a PR directly, and edit .claude files (skills, settings, hooks). Applies when its conversation next restarts. Off: the deny list, write limits, quick-change cap and merge-only-through-the-gates apply."
65
- checked={w.orch_full_access}
66
- onChange={(on) => update.mutate({ id: w.id, orchFullAccess: on })}
67
- />
74
+ <SettingSection>
75
+ <SwitchSetting
76
+ id={`wake-${w.id}`}
77
+ label="Wake tasks on CI failures and reviews"
78
+ help="A task's agent is messaged once when a check fails on its PR, and when someone reviews or comments."
79
+ checked={w.wake_tasks_on_pr}
80
+ onSave={(on) =>
81
+ update.mutateAsync({ id: w.id, wakeTasksOnPR: on })
82
+ }
83
+ />
84
+ <SwitchSetting
85
+ id={`full-access-${w.id}`}
86
+ label="Orchestrator full access"
87
+ help="Its shell and file writes aren't checked. Applies when its conversation next restarts."
88
+ checked={w.orch_full_access}
89
+ confirm="Let this orchestrator act without approval? It can push, commit, edit any repository or file on this machine, merge a PR directly, and edit .claude files. Off: the deny list, write limits and merge-only-through-the-gates apply."
90
+ confirmLabel="Turn on full access"
91
+ onSave={(on) =>
92
+ update.mutateAsync({ id: w.id, orchFullAccess: on })
93
+ }
94
+ />
95
+ </SettingSection>
68
96
  </section>
69
97
  ))}
70
- <FreshStartForm />
71
- <StallForm />
72
- <AwayWindowForm />
98
+ <AppWideLimits />
99
+ <p className="text-muted-foreground text-sm">
100
+ Fresh starts and compaction are app-wide:{" "}
101
+ <button
102
+ type="button"
103
+ className="text-primary underline-offset-2 hover:underline"
104
+ onClick={() => settingsUiActions.show("context")}
105
+ >
106
+ open Context
107
+ </button>
108
+ .
109
+ </p>
73
110
  </div>
74
111
  );
75
112
  }
@@ -1969,6 +1969,22 @@
1969
1969
  "area": "AgentOS",
1970
1970
  "text": "Forking from a selection is tidier: the fork pill can be dismissed while keeping your quote and text, and started forks show a one-line marker and what they're doing.",
1971
1971
  "url": "https://github.com/saadnvd1/agent-os/pull/561"
1972
+ },
1973
+ {
1974
+ "id": 562,
1975
+ "date": "2026-11-02",
1976
+ "kind": "improved",
1977
+ "area": "Settings",
1978
+ "text": "Settings now save as you change them, with a clear \"Saved\" note, undo and reset, and Context settings have their own page.",
1979
+ "url": "https://github.com/saadnvd1/agent-os/pull/562"
1980
+ },
1981
+ {
1982
+ "id": 563,
1983
+ "date": "2026-11-02",
1984
+ "kind": "improved",
1985
+ "area": "Settings",
1986
+ "text": "More settings now save as you change them, and revoking a passkey or link asks inline instead of with a browser pop-up.",
1987
+ "url": "https://github.com/saadnvd1/agent-os/pull/563"
1972
1988
  }
1973
1989
  ]
1974
1990
  }