@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
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { NextRequest } from "next/server";
3
+ import { refreshSettings } from "@/lib/chat/refresh";
4
+ import { SETTING_DEFAULTS } from "@/lib/settings/defaults";
5
+ import { GET, PATCH } from "./route";
6
+
7
+ const patch = (body: unknown) =>
8
+ PATCH(
9
+ new NextRequest("http://localhost/api/fresh-start", {
10
+ method: "PATCH",
11
+ body: JSON.stringify(body),
12
+ })
13
+ );
14
+
15
+ describe("PATCH /api/fresh-start", () => {
16
+ it("changes only the field it's sent", async () => {
17
+ const res = await patch({ compactTokens: 120_000 });
18
+ expect(res.status).toBe(200);
19
+ expect(refreshSettings()).toEqual({
20
+ ...SETTING_DEFAULTS.fresh,
21
+ compactTokens: 120_000,
22
+ });
23
+ expect(await (await GET()).json()).toMatchObject({
24
+ interactiveAutoCompactTokens: 300_000,
25
+ });
26
+ });
27
+
28
+ it("rejects an out-of-range value and saves nothing", async () => {
29
+ const res = await patch({ taskTokens: 10_000 });
30
+ expect(res.status).toBe(400);
31
+ expect(refreshSettings().taskTokens).toBe(0);
32
+ });
33
+ });
@@ -10,6 +10,9 @@ export async function GET() {
10
10
  return NextResponse.json(refreshSettings());
11
11
  }
12
12
 
13
+ // One field at a time, or several: only those named change.
14
+ export const PATCH = PUT;
15
+
13
16
  export async function PUT(request: NextRequest) {
14
17
  try {
15
18
  const body = await request.json();
@@ -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 />