@saadnvd1/agent-os 0.3.833 → 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 (74) 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/app/api/sessions/route.ts +7 -1
  4. package/app/globals.css +14 -0
  5. package/components/Chat/ChatPanel.tsx +41 -29
  6. package/components/Chat/Forks.tsx +16 -1
  7. package/components/Chat/Messages.tsx +14 -0
  8. package/components/Chat/WorktreeRemovedNotice.tsx +17 -0
  9. package/components/Chat/composer/ComposerBody.tsx +10 -1
  10. package/components/Chat/composer/ForkHeader.tsx +26 -15
  11. package/components/Chat/fork-draft.test.ts +57 -0
  12. package/components/Chat/fork-draft.ts +34 -0
  13. package/components/Devices/DeviceRow.tsx +83 -69
  14. package/components/Devices/PasskeysSection.test.ts +89 -0
  15. package/components/Devices/PasskeysSection.tsx +36 -26
  16. package/components/Health/HealthPanel.tsx +41 -16
  17. package/components/Merge/GlobalMergeSection.tsx +2 -5
  18. package/components/Merge/MergeSettingsFields.tsx +39 -77
  19. package/components/Merge/ProjectMergeSection.tsx +2 -6
  20. package/components/Merge/WorktreeSweepSection.tsx +40 -14
  21. package/components/Models/AgentRoleCard.tsx +61 -66
  22. package/components/Models/AgentRolesSection.tsx +5 -8
  23. package/components/Models/ModelsSection.tsx +7 -18
  24. package/components/Models/PriceInput.tsx +59 -0
  25. package/components/PhoneNotify/PhoneNotifyForm.tsx +37 -58
  26. package/components/Settings/AppWideLimits.tsx +61 -0
  27. package/components/Settings/ContextSection.tsx +96 -0
  28. package/components/Settings/ExperimentsSection.test.ts +9 -7
  29. package/components/Settings/ExperimentsSection.tsx +22 -23
  30. package/components/Settings/InlineConfirm.tsx +51 -0
  31. package/components/Settings/OwnerNameRow.tsx +10 -40
  32. package/components/Settings/PickerSetting.tsx +59 -0
  33. package/components/Settings/SettingRow.tsx +131 -0
  34. package/components/Settings/SettingsDialog.test.ts +1 -0
  35. package/components/Settings/SettingsDialog.tsx +47 -2
  36. package/components/Settings/settings-fields.test.ts +103 -0
  37. package/components/Settings/settings-fields.tsx +427 -0
  38. package/components/Shares/SharedLinksPanel.tsx +62 -53
  39. package/components/Workspaces/HqFolderForm.tsx +21 -54
  40. package/components/Workspaces/McpServersForm.tsx +20 -19
  41. package/components/Workspaces/ProjectTiersForm.tsx +10 -8
  42. package/components/Workspaces/RulesForm.test.ts +127 -0
  43. package/components/Workspaces/RulesForm.tsx +54 -126
  44. package/components/Workspaces/WorkspaceModelsForm.tsx +27 -26
  45. package/components/Workspaces/WorkspaceSettings.tsx +61 -24
  46. package/content/whats-new.json +32 -0
  47. package/data/fresh-start/index.ts +1 -1
  48. package/data/workspaces/index.ts +9 -0
  49. package/hooks/useAutoSave.test.ts +116 -0
  50. package/hooks/useAutoSave.ts +98 -0
  51. package/lib/away/window-limits.ts +3 -1
  52. package/lib/chat/refresh.ts +10 -6
  53. package/lib/chat/socket.ts +7 -3
  54. package/lib/chat/stall-limits.ts +3 -1
  55. package/lib/chat/start-error.ts +13 -0
  56. package/lib/chat/worker/cwd.ts +77 -0
  57. package/lib/chat/worker/host.ts +11 -2
  58. package/lib/chat/worker/main.ts +11 -2
  59. package/lib/db/types.ts +2 -0
  60. package/lib/fork/note.ts +6 -0
  61. package/lib/fork/start.ts +6 -2
  62. package/lib/fork/types.ts +2 -0
  63. package/lib/fork/view.ts +14 -1
  64. package/lib/settings/defaults.ts +27 -0
  65. package/lib/settings/parse.ts +17 -0
  66. package/lib/settings-url.ts +1 -0
  67. package/lib/worktree-sweep/limits.ts +3 -0
  68. package/lib/worktree-sweep/settings.ts +4 -2
  69. package/package.json +1 -1
  70. package/components/Away/AwayWindowForm.tsx +0 -91
  71. package/components/Workspaces/FreshStartForm.tsx +0 -177
  72. package/components/Workspaces/StallForm.tsx +0 -101
  73. package/components/Workspaces/TaskLimitForm.tsx +0 -84
  74. package/components/Workspaces/fresh-start.ts +0 -19
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { useState } from "react";
3
4
  import { Laptop, Smartphone, Tablet, Trash2 } from "lucide-react";
4
5
  import { Button } from "@/components/ui/button";
5
6
  import type { ListedDevice } from "@/lib/security/devices";
@@ -9,6 +10,7 @@ import {
9
10
  useSendTestPush,
10
11
  useSetCanApprove,
11
12
  } from "@/data/devices";
13
+ import { InlineConfirm } from "@/components/Settings/InlineConfirm";
12
14
  import { Switch } from "@/components/ui/switch";
13
15
 
14
16
  // SQLite's datetime('now') is UTC without a zone.
@@ -53,87 +55,99 @@ export function DeviceRow({
53
55
  : `Test failed: ${tested.error}`
54
56
  : null;
55
57
 
58
+ const [signingOut, setSigningOut] = useState(false);
59
+ // The device in use asks first; any other goes at once.
56
60
  const onRevoke = () => {
57
- if (
58
- isCurrent &&
59
- !window.confirm("This is the device you're using. Sign it out?")
60
- )
61
- return;
62
- revoke.mutate(device.id);
61
+ if (isCurrent) setSigningOut(true);
62
+ else revoke.mutate(device.id);
63
63
  };
64
64
 
65
65
  return (
66
- <div className="bg-muted/40 flex items-center gap-3 rounded-lg px-3 py-2.5">
67
- <DeviceIcon ua={device.user_agent} />
68
- <div className="min-w-0 flex-1">
69
- <p className="flex items-center gap-2 truncate text-sm font-medium">
70
- {device.name}
71
- {isCurrent && (
72
- <span className="bg-primary/15 text-primary rounded-full px-1.5 py-0.5 text-[10px]">
73
- this device
74
- </span>
75
- )}
76
- </p>
77
- <p className="text-muted-foreground truncate text-xs">
78
- {lastSeen(device.last_seen_at)}
79
- {device.last_address ? ` · ${device.last_address}` : ""}
80
- </p>
81
- {device.push.enabled && (
82
- <p className="text-muted-foreground truncate text-xs">
83
- Push on ·{" "}
84
- {device.push.categories
85
- .map((c) => CATEGORIES[c].label)
86
- .join(", ") || "nothing selected"}
66
+ <div className="bg-muted/40 rounded-lg px-3 py-2.5">
67
+ <div className="flex items-center gap-3">
68
+ <DeviceIcon ua={device.user_agent} />
69
+ <div className="min-w-0 flex-1">
70
+ <p className="flex items-center gap-2 truncate text-sm font-medium">
71
+ {device.name}
72
+ {isCurrent && (
73
+ <span className="bg-primary/15 text-primary rounded-full px-1.5 py-0.5 text-[10px]">
74
+ this device
75
+ </span>
76
+ )}
87
77
  </p>
88
- )}
89
- {device.push.lastError && (
90
- <p
91
- className="text-destructive truncate text-xs"
92
- title={device.push.lastError}
93
- >
94
- Push error: {device.push.lastError}
78
+ <p className="text-muted-foreground truncate text-xs">
79
+ {lastSeen(device.last_seen_at)}
80
+ {device.last_address ? ` · ${device.last_address}` : ""}
95
81
  </p>
96
- )}
97
- {testNote && (
98
- <p
99
- className={`truncate text-xs ${tested?.status === "sent" ? "text-muted-foreground" : "text-destructive"}`}
100
- role="status"
82
+ {device.push.enabled && (
83
+ <p className="text-muted-foreground truncate text-xs">
84
+ Push on ·{" "}
85
+ {device.push.categories
86
+ .map((c) => CATEGORIES[c].label)
87
+ .join(", ") || "nothing selected"}
88
+ </p>
89
+ )}
90
+ {device.push.lastError && (
91
+ <p
92
+ className="text-destructive truncate text-xs"
93
+ title={device.push.lastError}
94
+ >
95
+ Push error: {device.push.lastError}
96
+ </p>
97
+ )}
98
+ {testNote && (
99
+ <p
100
+ className={`truncate text-xs ${tested?.status === "sent" ? "text-muted-foreground" : "text-destructive"}`}
101
+ role="status"
102
+ >
103
+ {testNote}
104
+ </p>
105
+ )}
106
+ </div>
107
+ {device.push.enabled && (
108
+ <Button
109
+ variant="outline"
110
+ size="sm"
111
+ className="h-11 shrink-0 sm:h-8"
112
+ onClick={() => sendTest.mutate(device.id)}
113
+ disabled={sendTest.isPending}
101
114
  >
102
- {testNote}
103
- </p>
115
+ {sendTest.isPending ? "Sending…" : "Send test"}
116
+ </Button>
104
117
  )}
105
- </div>
106
- {device.push.enabled && (
118
+ <label className="text-muted-foreground flex min-h-11 shrink-0 items-center gap-2 text-xs sm:min-h-8">
119
+ Can approve
120
+ <Switch
121
+ checked={!!device.can_approve}
122
+ disabled={!canManage || setCanApprove.isPending}
123
+ onCheckedChange={(on) =>
124
+ setCanApprove.mutate({ id: device.id, canApprove: on })
125
+ }
126
+ />
127
+ </label>
107
128
  <Button
108
- variant="outline"
109
- size="sm"
110
- className="h-11 shrink-0 sm:h-8"
111
- onClick={() => sendTest.mutate(device.id)}
112
- disabled={sendTest.isPending}
129
+ variant="ghost"
130
+ size="icon-sm"
131
+ className="h-11 w-11 sm:h-8 sm:w-8"
132
+ onClick={onRevoke}
133
+ disabled={revoke.isPending}
134
+ aria-label={`Remove ${device.name}`}
113
135
  >
114
- {sendTest.isPending ? "Sending…" : "Send test"}
136
+ <Trash2 className="h-4 w-4" />
115
137
  </Button>
116
- )}
117
- <label className="text-muted-foreground flex min-h-11 shrink-0 items-center gap-2 text-xs sm:min-h-8">
118
- Can approve
119
- <Switch
120
- checked={!!device.can_approve}
121
- disabled={!canManage || setCanApprove.isPending}
122
- onCheckedChange={(on) =>
123
- setCanApprove.mutate({ id: device.id, canApprove: on })
124
- }
138
+ </div>
139
+ {signingOut && (
140
+ <InlineConfirm
141
+ label={`Sign out ${device.name}`}
142
+ message="This is the device you're using. Sign it out?"
143
+ confirmLabel="Sign out"
144
+ onConfirm={() => {
145
+ setSigningOut(false);
146
+ revoke.mutate(device.id);
147
+ }}
148
+ onCancel={() => setSigningOut(false)}
125
149
  />
126
- </label>
127
- <Button
128
- variant="ghost"
129
- size="icon-sm"
130
- className="h-11 w-11 sm:h-8 sm:w-8"
131
- onClick={onRevoke}
132
- disabled={revoke.isPending}
133
- aria-label={`Remove ${device.name}`}
134
- >
135
- <Trash2 className="h-4 w-4" />
136
- </Button>
150
+ )}
137
151
  </div>
138
152
  );
139
153
  }
@@ -0,0 +1,89 @@
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(() => ({ revoke: vi.fn() }));
17
+ vi.stubGlobal("location", { hostname: "x" });
18
+ vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
19
+ vi.mock("@/data/presence", () => ({
20
+ NO_PASSKEYS_HERE: "no",
21
+ passkeysHere: () => true,
22
+ }));
23
+ vi.mock("@/data/presence/passkeys", () => ({
24
+ usePasskeysQuery: () => ({
25
+ data: {
26
+ bootstrapped: true,
27
+ passkeys: [
28
+ {
29
+ id: "k1",
30
+ name: "Laptop",
31
+ rp_id: "x",
32
+ type: "browser",
33
+ created_at: "2026-10-01 10:00:00",
34
+ registered_from: null,
35
+ last_used_at: null,
36
+ },
37
+ ],
38
+ },
39
+ }),
40
+ useAddPasskey: () => ({ mutate: vi.fn(), isPending: false }),
41
+ useEnrollCode: () => ({ mutate: vi.fn(), isPending: false }),
42
+ useRevokePasskey: () => ({ mutate: state.revoke, isPending: false }),
43
+ }));
44
+
45
+ const { PasskeysSection } = await import("./PasskeysSection");
46
+
47
+ let root: Root;
48
+ let host: HTMLDivElement;
49
+ beforeEach(async () => {
50
+ state.revoke.mockReset();
51
+ host = document.createElement("div");
52
+ document.body.appendChild(host);
53
+ root = createRoot(host);
54
+ await act(async () =>
55
+ root.render(createElement(PasskeysSection, { open: true }))
56
+ );
57
+ });
58
+ afterEach(async () => {
59
+ await act(async () => root.unmount());
60
+ host.remove();
61
+ vi.restoreAllMocks();
62
+ });
63
+
64
+ const click = (el: Element | null | undefined) =>
65
+ act(async () => {
66
+ el?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
67
+ });
68
+ const button = (name: string) =>
69
+ [...host.querySelectorAll("button")].find((b) => b.textContent === name);
70
+
71
+ describe("PasskeysSection revoke", () => {
72
+ it("asks inline, not with a browser dialog, and revokes only on confirm", async () => {
73
+ const dialog = vi.spyOn(window, "confirm");
74
+ await click(host.querySelector('[aria-label="Revoke Laptop"]'));
75
+ expect(host.querySelector('[role="alertdialog"]')).not.toBeNull();
76
+ expect(state.revoke).not.toHaveBeenCalled();
77
+ await click(button("Revoke"));
78
+ expect(state.revoke).toHaveBeenCalledWith("k1", expect.any(Object));
79
+ expect(dialog).not.toHaveBeenCalled();
80
+ expect(host.querySelector('[role="alertdialog"]')).toBeNull();
81
+ });
82
+
83
+ it("Cancel leaves the passkey alone", async () => {
84
+ await click(host.querySelector('[aria-label="Revoke Laptop"]'));
85
+ await click(button("Cancel"));
86
+ expect(state.revoke).not.toHaveBeenCalled();
87
+ expect(host.querySelector('[role="alertdialog"]')).toBeNull();
88
+ });
89
+ });
@@ -12,6 +12,7 @@ import {
12
12
  usePasskeysQuery,
13
13
  useRevokePasskey,
14
14
  } from "@/data/presence/passkeys";
15
+ import { InlineConfirm } from "@/components/Settings/InlineConfirm";
15
16
  import { lastSeen } from "./DeviceRow";
16
17
 
17
18
  const when = (s: string) =>
@@ -33,6 +34,7 @@ export function PasskeysSection({ open }: { open: boolean }) {
33
34
  const enroll = useEnrollCode();
34
35
  const revoke = useRevokePasskey();
35
36
  const [code, setCode] = useState("");
37
+ const [revoking, setRevoking] = useState<string | null>(null);
36
38
  const here = useSyncExternalStore(
37
39
  () => () => {},
38
40
  passkeysHere,
@@ -57,33 +59,41 @@ export function PasskeysSection({ open }: { open: boolean }) {
57
59
  to confirm.
58
60
  </p>
59
61
  {keys.map((k) => (
60
- <div
61
- key={k.id}
62
- className="bg-muted/40 flex items-center gap-3 rounded-lg px-3 py-2.5"
63
- >
64
- <KeyRound className="text-muted-foreground h-4 w-4 shrink-0" />
65
- <div className="min-w-0 flex-1">
66
- <p className="truncate text-sm font-medium">{k.name}</p>
67
- <p className="text-muted-foreground truncate text-xs">
68
- {k.type === "device" ? "Device key (Face ID)" : k.rp_id} · added{" "}
69
- {when(k.created_at)}
70
- {k.registered_from ? ` from ${k.registered_from}` : ""} · used{" "}
71
- {lastSeen(k.last_used_at)}
72
- </p>
62
+ <div key={k.id} className="bg-muted/40 rounded-lg px-3 py-2.5">
63
+ <div className="flex items-center gap-3">
64
+ <KeyRound className="text-muted-foreground h-4 w-4 shrink-0" />
65
+ <div className="min-w-0 flex-1">
66
+ <p className="truncate text-sm font-medium">{k.name}</p>
67
+ <p className="text-muted-foreground truncate text-xs">
68
+ {k.type === "device" ? "Device key (Face ID)" : k.rp_id} · added{" "}
69
+ {when(k.created_at)}
70
+ {k.registered_from ? ` from ${k.registered_from}` : ""} · used{" "}
71
+ {lastSeen(k.last_used_at)}
72
+ </p>
73
+ </div>
74
+ <Button
75
+ variant="ghost"
76
+ size="icon-sm"
77
+ className="h-11 w-11 sm:h-8 sm:w-8"
78
+ disabled={revoke.isPending}
79
+ aria-label={`Revoke ${k.name}`}
80
+ onClick={() => setRevoking(k.id)}
81
+ >
82
+ <Trash2 className="h-4 w-4" />
83
+ </Button>
73
84
  </div>
74
- <Button
75
- variant="ghost"
76
- size="icon-sm"
77
- className="h-11 w-11 sm:h-8 sm:w-8"
78
- disabled={revoke.isPending}
79
- aria-label={`Revoke ${k.name}`}
80
- onClick={() =>
81
- window.confirm(`Revoke the passkey "${k.name}"?`) &&
82
- revoke.mutate(k.id, { onError: fail })
83
- }
84
- >
85
- <Trash2 className="h-4 w-4" />
86
- </Button>
85
+ {revoking === k.id && (
86
+ <InlineConfirm
87
+ label={`Revoke ${k.name}`}
88
+ message={`Revoke the passkey "${k.name}"? It can no longer approve anything.`}
89
+ confirmLabel="Revoke"
90
+ onCancel={() => setRevoking(null)}
91
+ onConfirm={() => {
92
+ setRevoking(null);
93
+ revoke.mutate(k.id, { onError: fail });
94
+ }}
95
+ />
96
+ )}
87
97
  </div>
88
98
  ))}
89
99
  {data?.bootstrapped && keys.length === 0 ? (
@@ -306,7 +306,12 @@ function Thresholds() {
306
306
  const { data } = useHealthConfig();
307
307
  const save = useSaveConfig();
308
308
  const [draft, setDraft] = useState<Record<string, string>>({});
309
+ const [errors, setErrors] = useState<Record<string, string>>({});
309
310
  if (!data) return null;
311
+ const drop = (k: string) => {
312
+ setDraft(({ [k]: _, ...rest }) => rest);
313
+ setErrors(({ [k]: _, ...rest }) => rest);
314
+ };
310
315
  return (
311
316
  <div className="space-y-3">
312
317
  <p className="text-muted-foreground text-sm">
@@ -326,23 +331,43 @@ function Thresholds() {
326
331
  default {data.defaults[k]}
327
332
  </span>
328
333
  </label>
329
- <Input
330
- id={`health-${k}`}
331
- inputMode="decimal"
332
- value={value}
333
- onChange={(e) => setDraft({ ...draft, [k]: e.target.value })}
334
- onBlur={() => {
335
- if (draft[k] === undefined) return;
336
- const raw = draft[k].trim();
337
- save.mutate(
338
- { [k]: raw === "" ? null : Number(raw) },
339
- {
340
- onSettled: () => setDraft(({ [k]: _, ...rest }) => rest),
334
+ <span className="flex flex-col items-end gap-1">
335
+ <Input
336
+ id={`health-${k}`}
337
+ inputMode="decimal"
338
+ value={value}
339
+ aria-invalid={!!errors[k]}
340
+ onChange={(e) => {
341
+ setDraft({ ...draft, [k]: e.target.value });
342
+ setErrors(({ [k]: _, ...rest }) => rest);
343
+ }}
344
+ onBlur={() => {
345
+ if (draft[k] === undefined) return;
346
+ const raw = draft[k].trim();
347
+ const n = raw === "" ? null : Number(raw);
348
+ if (n !== null && (!Number.isFinite(n) || n < 0))
349
+ return setErrors({
350
+ ...errors,
351
+ [k]: "Enter a number, 0 or more.",
352
+ });
353
+ if (n === data.config[k]) return drop(k);
354
+ save.mutate({ [k]: n }, { onSettled: () => drop(k) });
355
+ }}
356
+ onKeyDown={(e) => {
357
+ if (e.key === "Enter") e.currentTarget.blur();
358
+ if (e.key === "Escape") {
359
+ drop(k);
360
+ e.currentTarget.blur();
341
361
  }
342
- );
343
- }}
344
- className="h-11 w-24 text-right tabular-nums md:h-9"
345
- />
362
+ }}
363
+ className="h-11 w-24 text-right tabular-nums md:h-9"
364
+ />
365
+ {errors[k] && (
366
+ <span role="alert" className="text-destructive text-xs">
367
+ {errors[k]}
368
+ </span>
369
+ )}
370
+ </span>
346
371
  </li>
347
372
  );
348
373
  })}
@@ -77,13 +77,10 @@ export function GlobalMergeSection() {
77
77
  <MergeSettingsFields
78
78
  settings={data.settings}
79
79
  inherited={data.defaults}
80
- disabled={update.isPending}
81
- onChange={(s) => update.mutate(s)}
80
+ idPrefix="merge-global"
81
+ onSave={(s) => update.mutateAsync(s)}
82
82
  />
83
83
  )}
84
- {update.isError && (
85
- <p className="text-destructive text-sm">{update.error.message}</p>
86
- )}
87
84
  </div>
88
85
  {data && <Overrides overrides={data.overrides} />}
89
86
  <GlobalSweepSection />
@@ -1,13 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { useId } from "react";
4
- import {
5
- Select,
6
- SelectContent,
7
- SelectItem,
8
- SelectTrigger,
9
- SelectValue,
10
- } from "@/components/ui/select";
3
+ import { SelectSetting } from "@/components/Settings/settings-fields";
4
+ import { SettingSection } from "@/components/Settings/SettingRow";
11
5
  import {
12
6
  MERGE_METHODS,
13
7
  METHOD_LABEL,
@@ -29,47 +23,6 @@ const SOURCE: Record<MergeSource, string> = {
29
23
 
30
24
  const onOff = (on: boolean) => (on ? "On" : "Off");
31
25
 
32
- function Field({
33
- label,
34
- detail,
35
- value,
36
- inheritLabel,
37
- options,
38
- onChange,
39
- disabled,
40
- }: {
41
- label: string;
42
- detail: string;
43
- value: string;
44
- inheritLabel: string;
45
- options: Array<{ value: string; label: string }>;
46
- onChange: (value: string) => void;
47
- disabled?: boolean;
48
- }) {
49
- const id = useId();
50
- return (
51
- <div className="space-y-1.5">
52
- <label htmlFor={id} className="text-sm font-medium">
53
- {label}
54
- </label>
55
- <Select value={value} onValueChange={onChange} disabled={disabled}>
56
- <SelectTrigger id={id} className="h-11 md:h-9">
57
- <SelectValue />
58
- </SelectTrigger>
59
- <SelectContent>
60
- <SelectItem value={INHERIT}>{inheritLabel}</SelectItem>
61
- {options.map((o) => (
62
- <SelectItem key={o.value} value={o.value}>
63
- {o.label}
64
- </SelectItem>
65
- ))}
66
- </SelectContent>
67
- </Select>
68
- <p className="text-muted-foreground text-xs">{detail}</p>
69
- </div>
70
- );
71
- }
72
-
73
26
  const TOGGLE = [
74
27
  { value: "on", label: "On" },
75
28
  { value: "off", label: "Off" },
@@ -78,38 +31,43 @@ const TOGGLE = [
78
31
  /**
79
32
  * The merge method and the two deletions after a merge, each either set
80
33
  * here or left to what this level inherits (`inherited`, with where that
81
- * comes from).
34
+ * comes from). Each saves the moment it changes.
82
35
  */
83
36
  export function MergeSettingsFields({
37
+ idPrefix,
84
38
  settings,
85
39
  inherited,
86
40
  from,
87
41
  allowed,
88
- disabled,
89
- onChange,
42
+ onSave,
90
43
  }: {
44
+ idPrefix: string;
91
45
  settings: MergeSettings;
92
46
  inherited: MergePolicy;
93
47
  from?: Record<keyof MergePolicy, MergeSource>;
94
48
  // The repository's allowed methods, when known.
95
49
  allowed?: MergeMethod[] | null;
96
- disabled?: boolean;
97
- onChange: (settings: MergeSettings) => void;
50
+ onSave: (settings: MergeSettings) => Promise<unknown>;
98
51
  }) {
99
- const inheritLabel = (key: keyof MergePolicy, shown: string) =>
100
- from ? `Inherit: ${shown} (${SOURCE[from[key]]})` : `Default: ${shown}`;
52
+ const inheritOption = (key: keyof MergePolicy, shown: string) => ({
53
+ value: INHERIT,
54
+ label: from
55
+ ? `Inherit: ${shown} (${SOURCE[from[key]]})`
56
+ : `Default: ${shown}`,
57
+ });
101
58
  const set = <K extends keyof MergeSettings>(
102
59
  key: K,
103
60
  value: MergeSettings[K] | undefined
104
- ) => onChange({ ...settings, [key]: value });
61
+ ) => onSave({ ...settings, [key]: value });
105
62
  const toggle = (key: "delete_remote_branch" | "delete_worktree") => (
106
- <Field
63
+ <SelectSetting
64
+ id={`${idPrefix}-${key}`}
107
65
  label={
108
66
  key === "delete_remote_branch"
109
67
  ? "Delete the branch on origin after a merge"
110
68
  : "Delete the local worktree and branch after a merge"
111
69
  }
112
- detail={
70
+ help={
113
71
  key === "delete_remote_branch"
114
72
  ? "Never main, master, or a branch a stacked task still targets."
115
73
  : "Never one with uncommitted work or commits the merge didn't include."
@@ -117,10 +75,10 @@ export function MergeSettingsFields({
117
75
  value={
118
76
  settings[key] === undefined ? INHERIT : settings[key] ? "on" : "off"
119
77
  }
120
- inheritLabel={inheritLabel(key, onOff(inherited[key]))}
121
- options={TOGGLE}
122
- disabled={disabled}
123
- onChange={(v) => set(key, v === INHERIT ? undefined : v === "on")}
78
+ options={[inheritOption(key, onOff(inherited[key])), ...TOGGLE]}
79
+ defaultValue={INHERIT}
80
+ hint=""
81
+ onSave={(v) => set(key, v === INHERIT ? undefined : v === "on")}
124
82
  />
125
83
  );
126
84
 
@@ -128,25 +86,29 @@ export function MergeSettingsFields({
128
86
  const refused = allowed && !allowed.includes(method);
129
87
 
130
88
  return (
131
- <div className="space-y-3">
132
- <Field
89
+ <SettingSection>
90
+ <SelectSetting
91
+ id={`${idPrefix}-method`}
133
92
  label="Merge method"
134
- detail="How sign-off, Done, Land and external PRs merge. If GitHub refuses it, the merge fails and says so; it never falls back to another method."
93
+ help="How sign-off, Done, Land and external PRs merge. If GitHub refuses it, the merge fails and says so; it never falls back to another method."
135
94
  value={settings.method ?? INHERIT}
136
- inheritLabel={inheritLabel("method", METHOD_LABEL[inherited.method])}
137
- options={MERGE_METHODS.map((m) => ({
138
- value: m,
139
- label:
140
- METHOD_LABEL[m] +
141
- (allowed && !allowed.includes(m) ? " (off on GitHub)" : ""),
142
- }))}
143
- disabled={disabled}
144
- onChange={(v) =>
95
+ options={[
96
+ inheritOption("method", METHOD_LABEL[inherited.method]),
97
+ ...MERGE_METHODS.map((m) => ({
98
+ value: m,
99
+ label:
100
+ METHOD_LABEL[m] +
101
+ (allowed && !allowed.includes(m) ? " (off on GitHub)" : ""),
102
+ })),
103
+ ]}
104
+ defaultValue={INHERIT}
105
+ hint=""
106
+ onSave={(v) =>
145
107
  set("method", v === INHERIT ? undefined : (v as MergeMethod))
146
108
  }
147
109
  />
148
110
  {refused && (
149
- <p className="text-destructive text-xs">
111
+ <p className="text-destructive px-3 py-2 text-xs">
150
112
  This repository has &ldquo;{REPO_SETTING[method]}&rdquo; turned off on
151
113
  GitHub, so merges will be refused until it&rsquo;s on or the method
152
114
  changes.
@@ -154,6 +116,6 @@ export function MergeSettingsFields({
154
116
  )}
155
117
  {toggle("delete_remote_branch")}
156
118
  {toggle("delete_worktree")}
157
- </div>
119
+ </SettingSection>
158
120
  );
159
121
  }
@@ -35,14 +35,10 @@ export function ProjectMergeSection({
35
35
  inherited={data.inherited}
36
36
  from={data.inherited.from}
37
37
  allowed={data.allowed}
38
- disabled={update.isPending}
39
- onChange={(s) => update.mutate(s)}
38
+ idPrefix={`merge-${projectId}`}
39
+ onSave={(s) => update.mutateAsync(s)}
40
40
  />
41
41
  )}
42
- {update.isError && (
43
- <p className="text-destructive text-sm">{update.error.message}</p>
44
- )}
45
- <p className="text-muted-foreground text-xs">Saved as you change them.</p>
46
42
  <ProjectSweepSection projectId={projectId} />
47
43
  </div>
48
44
  );