@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.
- package/app/api/fresh-start/route.test.ts +33 -0
- package/app/api/fresh-start/route.ts +3 -0
- package/components/Devices/DeviceRow.tsx +83 -69
- package/components/Devices/PasskeysSection.test.ts +89 -0
- package/components/Devices/PasskeysSection.tsx +36 -26
- package/components/Health/HealthPanel.tsx +41 -16
- package/components/Merge/GlobalMergeSection.tsx +2 -5
- package/components/Merge/MergeSettingsFields.tsx +39 -77
- package/components/Merge/ProjectMergeSection.tsx +2 -6
- package/components/Merge/WorktreeSweepSection.tsx +40 -14
- package/components/Models/AgentRoleCard.tsx +61 -66
- package/components/Models/AgentRolesSection.tsx +5 -8
- package/components/Models/ModelsSection.tsx +7 -18
- package/components/Models/PriceInput.tsx +59 -0
- package/components/PhoneNotify/PhoneNotifyForm.tsx +37 -58
- package/components/Settings/AppWideLimits.tsx +61 -0
- package/components/Settings/ContextSection.tsx +96 -0
- package/components/Settings/ExperimentsSection.test.ts +9 -7
- package/components/Settings/ExperimentsSection.tsx +22 -23
- package/components/Settings/InlineConfirm.tsx +51 -0
- package/components/Settings/OwnerNameRow.tsx +10 -40
- package/components/Settings/PickerSetting.tsx +59 -0
- package/components/Settings/SettingRow.tsx +131 -0
- package/components/Settings/SettingsDialog.test.ts +1 -0
- package/components/Settings/SettingsDialog.tsx +47 -2
- package/components/Settings/settings-fields.test.ts +103 -0
- package/components/Settings/settings-fields.tsx +427 -0
- package/components/Shares/SharedLinksPanel.tsx +62 -53
- package/components/Workspaces/HqFolderForm.tsx +21 -54
- package/components/Workspaces/McpServersForm.tsx +20 -19
- package/components/Workspaces/ProjectTiersForm.tsx +10 -8
- package/components/Workspaces/RulesForm.test.ts +127 -0
- package/components/Workspaces/RulesForm.tsx +54 -126
- package/components/Workspaces/WorkspaceModelsForm.tsx +27 -26
- package/components/Workspaces/WorkspaceSettings.tsx +61 -24
- package/content/whats-new.json +16 -0
- package/data/fresh-start/index.ts +1 -1
- package/data/workspaces/index.ts +9 -0
- package/hooks/useAutoSave.test.ts +116 -0
- package/hooks/useAutoSave.ts +98 -0
- package/lib/away/window-limits.ts +3 -1
- package/lib/chat/refresh.ts +10 -6
- package/lib/chat/stall-limits.ts +3 -1
- package/lib/settings/defaults.ts +27 -0
- package/lib/settings/parse.ts +17 -0
- package/lib/settings-url.ts +1 -0
- package/lib/worktree-sweep/limits.ts +3 -0
- package/lib/worktree-sweep/settings.ts +4 -2
- package/package.json +1 -1
- package/components/Away/AwayWindowForm.tsx +0 -91
- package/components/Workspaces/FreshStartForm.tsx +0 -177
- package/components/Workspaces/StallForm.tsx +0 -101
- package/components/Workspaces/TaskLimitForm.tsx +0 -84
- 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
|
-
<
|
|
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
|
+
});
|