@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,131 @@
1
+ "use client";
2
+
3
+ import { Check, Loader2 } from "lucide-react";
4
+ import { createContext, useContext, type ReactNode } from "react";
5
+ import type { SaveStatus } from "@/hooks/useAutoSave";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ // What the Settings search box holds; a row that doesn't match hides.
9
+ export const SettingsQueryContext = createContext("");
10
+
11
+ /**
12
+ * One setting on a Settings page, 44px at least: label and one-line help on
13
+ * the left, the control on the right (below the label on a phone), and a meta
14
+ * line for the default hint or the error, "Saved", Undo and Reset. Every
15
+ * variant (settings-fields) renders through this.
16
+ */
17
+ export function SettingRow({
18
+ id,
19
+ label,
20
+ help,
21
+ control,
22
+ controlClassName,
23
+ hint,
24
+ error,
25
+ status = "idle",
26
+ onUndo,
27
+ onReset,
28
+ below,
29
+ }: {
30
+ id: string;
31
+ label: string;
32
+ help?: string;
33
+ control: ReactNode;
34
+ controlClassName?: string;
35
+ hint?: string;
36
+ error?: string | null;
37
+ status?: SaveStatus;
38
+ onUndo?: () => void;
39
+ // Given only while the value differs from the default.
40
+ onReset?: () => void;
41
+ below?: ReactNode;
42
+ }) {
43
+ const query = useContext(SettingsQueryContext).trim().toLowerCase();
44
+ const haystack = `${label} ${help ?? ""}`.toLowerCase();
45
+ if (query && !query.split(/\s+/).every((w) => haystack.includes(w)))
46
+ return null;
47
+ return (
48
+ <div
49
+ data-setting={id}
50
+ className="border-foreground/5 grid min-h-11 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-0.5 border-t px-3 py-2 first:border-t-0"
51
+ >
52
+ <div className="min-w-0">
53
+ <label htmlFor={id} className="block text-sm font-medium">
54
+ {label}
55
+ </label>
56
+ {help && (
57
+ <p className="text-muted-foreground text-xs leading-snug">{help}</p>
58
+ )}
59
+ </div>
60
+ <div
61
+ className={cn(
62
+ "flex min-w-0 items-center justify-end gap-2 max-sm:col-span-2 max-sm:row-start-2 max-sm:mt-1 max-sm:justify-start",
63
+ controlClassName
64
+ )}
65
+ >
66
+ {control}
67
+ </div>
68
+ <div className="text-muted-foreground col-span-2 flex min-h-5 items-center justify-between gap-2 text-xs">
69
+ <span
70
+ id={`${id}-hint`}
71
+ className={cn("min-w-0", error && "text-destructive")}
72
+ role={error ? "alert" : undefined}
73
+ >
74
+ {error ?? hint}
75
+ </span>
76
+ <span className="flex shrink-0 items-center gap-3" aria-live="polite">
77
+ {status === "saving" && (
78
+ <span className="inline-flex items-center gap-1">
79
+ <Loader2 className="size-3 animate-spin" />
80
+ Saving…
81
+ </span>
82
+ )}
83
+ {(status === "saved" || status === "undo") && (
84
+ <span className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
85
+ <Check className="size-3" />
86
+ Saved
87
+ </span>
88
+ )}
89
+ {status === "undo" && onUndo && (
90
+ <button
91
+ type="button"
92
+ onClick={onUndo}
93
+ className="text-primary -my-3 -mr-1 min-h-11 px-1 hover:underline"
94
+ >
95
+ Undo
96
+ </button>
97
+ )}
98
+ {onReset && status !== "undo" && (
99
+ <button
100
+ type="button"
101
+ onClick={onReset}
102
+ className="text-primary -my-3 -mr-1 min-h-11 px-1 hover:underline"
103
+ >
104
+ Reset
105
+ </button>
106
+ )}
107
+ </span>
108
+ </div>
109
+ {below && <div className="col-span-2">{below}</div>}
110
+ </div>
111
+ );
112
+ }
113
+
114
+ /** A titled group of rows with one line of help. */
115
+ export function SettingSection({
116
+ title,
117
+ help,
118
+ children,
119
+ }: {
120
+ title?: string;
121
+ help?: string;
122
+ children: ReactNode;
123
+ }) {
124
+ return (
125
+ <section className="mb-5 [&:not(:has([data-setting]))]:hidden">
126
+ {title && <h3 className="text-sm font-semibold">{title}</h3>}
127
+ {help && <p className="text-muted-foreground mb-2 text-xs">{help}</p>}
128
+ <div className="bg-muted/40 overflow-hidden rounded-xl">{children}</div>
129
+ </section>
130
+ );
131
+ }
@@ -34,6 +34,7 @@ vi.mock("./NoApprovalsSection", () => ({
34
34
  vi.mock("@/components/Workspaces", () => ({
35
35
  WorkspaceSettings: stub("workspaces"),
36
36
  }));
37
+ vi.mock("./ContextSection", () => ({ ContextSection: stub("context") }));
37
38
  vi.mock("@/components/Models/ModelsSection", () => ({
38
39
  ModelsSection: stub("models"),
39
40
  }));
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { memo, useEffect, useRef } from "react";
3
+ import { memo, useEffect, useRef, useState } from "react";
4
4
  import { useSnapshot } from "valtio";
5
5
  import {
6
6
  ArrowLeft,
@@ -12,7 +12,9 @@ import {
12
12
  Cpu,
13
13
  GitMerge,
14
14
  HeartPulse,
15
+ Layers,
15
16
  LayoutGrid,
17
+ Search,
16
18
  Link2,
17
19
  ListChecks,
18
20
  ShieldOff,
@@ -48,6 +50,8 @@ import { cn } from "@/lib/utils";
48
50
  import { schedulesUiActions } from "@/stores/schedulesUi";
49
51
  import { RecipesPanel } from "@/components/Recipes";
50
52
  import { settingsUi, settingsUiActions } from "@/stores/settingsUi";
53
+ import { ContextSection } from "./ContextSection";
54
+ import { SettingsQueryContext } from "./SettingRow";
51
55
  import { SetupSection } from "./SetupSection";
52
56
  import { NoApprovalsSection } from "./NoApprovalsSection";
53
57
  import {
@@ -84,6 +88,11 @@ export const SECTION_INFO: Record<
84
88
  hint: "Snapshot links you made, with views, and revoke",
85
89
  icon: Link2,
86
90
  },
91
+ context: {
92
+ label: "Context",
93
+ hint: "When agents start fresh or compact",
94
+ icon: Layers,
95
+ },
87
96
  models: {
88
97
  label: "Models",
89
98
  hint: "Which model each kind of work runs on, and providers",
@@ -150,6 +159,8 @@ function SectionBody({
150
159
  return <NoApprovalsSection />;
151
160
  case "workspaces":
152
161
  return <WorkspaceSettings />;
162
+ case "context":
163
+ return <ContextSection />;
153
164
  case "models":
154
165
  return <ModelsSection />;
155
166
  case "agent-roles":
@@ -177,6 +188,40 @@ function SectionBody({
177
188
  }
178
189
  }
179
190
 
191
+ // Sections built from SettingRow, which the search box filters.
192
+ const SEARCHABLE: ReadonlySet<SettingsSection> = new Set([
193
+ "context",
194
+ "workspaces",
195
+ ]);
196
+
197
+ function SearchableBody({
198
+ section,
199
+ browser,
200
+ }: {
201
+ section: SettingsSection;
202
+ browser: BrowserNotifications;
203
+ }) {
204
+ const [query, setQuery] = useState("");
205
+ if (!SEARCHABLE.has(section))
206
+ return <SectionBody section={section} browser={browser} />;
207
+ return (
208
+ <SettingsQueryContext.Provider value={query}>
209
+ <div className="relative mb-4">
210
+ <Search className="text-muted-foreground pointer-events-none absolute top-3.5 left-3 size-4 md:top-2.5" />
211
+ <input
212
+ type="search"
213
+ value={query}
214
+ onChange={(e) => setQuery(e.target.value)}
215
+ placeholder="Search settings"
216
+ aria-label="Search settings"
217
+ className="bg-input/60 hairline h-11 w-full rounded-xl border-0 pr-3 pl-9 text-base outline-none md:h-9 md:text-sm"
218
+ />
219
+ </div>
220
+ <SectionBody section={section} browser={browser} />
221
+ </SettingsQueryContext.Provider>
222
+ );
223
+ }
224
+
180
225
  /**
181
226
  * Every AgentOS setting, in one place. Wide screens show the sections beside
182
227
  * the open one; a phone shows the list, then the section with a way back.
@@ -323,7 +368,7 @@ export const SettingsDialog = memo(function SettingsDialog({
323
368
  {SECTION_INFO[shown].label}
324
369
  </h2>
325
370
  {open && (
326
- <SectionBody
371
+ <SearchableBody
327
372
  key={shown}
328
373
  section={shown}
329
374
  browser={notifications}
@@ -0,0 +1,103 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+
6
+ // Radix dispatches its own events; they must be jsdom's, not Node's.
7
+ for (const key of ["Event", "CustomEvent", "FocusEvent", "HTMLButtonElement"])
8
+ Object.defineProperty(globalThis, key, {
9
+ value: (window as unknown as Record<string, unknown>)[key],
10
+ configurable: true,
11
+ writable: true,
12
+ });
13
+ (
14
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
15
+ ).IS_REACT_ACT_ENVIRONMENT = true;
16
+ vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
17
+
18
+ const { NumberSetting } = await import("./settings-fields");
19
+
20
+ let root: Root;
21
+ let host: HTMLElement;
22
+ afterEach(async () => {
23
+ await act(async () => root.unmount());
24
+ host.remove();
25
+ });
26
+
27
+ async function mount(save: (n: number) => Promise<unknown>) {
28
+ host = document.createElement("div");
29
+ document.body.appendChild(host);
30
+ root = createRoot(host);
31
+ await act(async () =>
32
+ root.render(
33
+ createElement(NumberSetting, {
34
+ id: "n",
35
+ label: "Days",
36
+ value: 7,
37
+ defaultValue: 7,
38
+ min: 1,
39
+ max: 30,
40
+ onSave: save,
41
+ })
42
+ )
43
+ );
44
+ return host.querySelector("input")!;
45
+ }
46
+
47
+ // React tracks the value; set it the way the browser does, then blur.
48
+ const type = async (input: HTMLInputElement, text: string) => {
49
+ const set = Object.getOwnPropertyDescriptor(
50
+ HTMLInputElement.prototype,
51
+ "value"
52
+ )!.set!;
53
+ await act(async () => {
54
+ set.call(input, text);
55
+ input.dispatchEvent(new Event("input", { bubbles: true }));
56
+ });
57
+ };
58
+ const blur = (input: HTMLInputElement) =>
59
+ act(async () => {
60
+ input.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
61
+ });
62
+
63
+ describe("NumberSetting", () => {
64
+ it("keeps an invalid value in the field with a message and sends nothing", async () => {
65
+ const save = vi.fn().mockResolvedValue(undefined);
66
+ const input = await mount(save);
67
+ await type(input, "40");
68
+ await blur(input);
69
+ expect(save).not.toHaveBeenCalled();
70
+ expect(input.value).toBe("40");
71
+ expect(host.textContent).toContain("Enter a whole number from 1 to 30.");
72
+ });
73
+
74
+ it("saves a valid value on blur", async () => {
75
+ const save = vi.fn().mockResolvedValue(undefined);
76
+ const input = await mount(save);
77
+ await type(input, "14");
78
+ await blur(input);
79
+ expect(save).toHaveBeenCalledWith(14);
80
+ });
81
+
82
+ it("reverts on Escape without saving", async () => {
83
+ const save = vi.fn().mockResolvedValue(undefined);
84
+ const input = await mount(save);
85
+ await type(input, "40");
86
+ await act(async () => {
87
+ input.dispatchEvent(
88
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
89
+ );
90
+ });
91
+ expect(input.value).toBe("7");
92
+ expect(save).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it("goes back to the saved value when the server rejects", async () => {
96
+ const save = vi.fn().mockRejectedValue(new Error("no"));
97
+ const input = await mount(save);
98
+ await type(input, "14");
99
+ await blur(input);
100
+ expect(save).toHaveBeenCalledWith(14);
101
+ expect(input.value).toBe("7");
102
+ });
103
+ });