@saadnvd1/agent-os 0.3.845 → 0.3.851
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/README.md +6 -0
- package/app/api/approvals/route.ts +5 -1
- package/app/api/health/config/route.ts +2 -0
- package/app/api/updates/run/route.ts +7 -3
- package/app/globals.css +6 -0
- package/components/Audits/AuditsPanel.tsx +42 -35
- package/components/Chat/quote-selection.test.ts +6 -0
- package/components/Chat/quote-selection.ts +13 -10
- package/components/Devices/ConnectCard.tsx +52 -56
- package/components/Devices/DeviceGroupRow.tsx +65 -0
- package/components/Devices/DeviceRow.tsx +2 -2
- package/components/Devices/DevicesPanel.tsx +63 -49
- package/components/Devices/NetworkSection.tsx +29 -26
- package/components/Devices/TailscaleCard.tsx +9 -10
- package/components/Devices/group.test.ts +66 -0
- package/components/Devices/group.ts +67 -0
- package/components/Health/HealthPanel.tsx +5 -81
- package/components/Health/MachineChecks.tsx +20 -0
- package/components/Health/SweepRuns.tsx +46 -51
- package/components/Health/Thresholds.tsx +50 -0
- package/components/Merge/GlobalMergeSection.tsx +5 -6
- package/components/Merge/MergeApprovalToggle.tsx +20 -14
- package/components/Merge/MergeSettingsFields.tsx +0 -2
- package/components/Merge/WorktreeSweepSection.tsx +15 -34
- package/components/Models/AgentRoleCard.tsx +7 -7
- package/components/Models/ModelSelect.tsx +14 -3
- package/components/Models/ModelsSection.tsx +42 -37
- package/components/Models/ProviderCard.tsx +1 -1
- package/components/Models/RoleModelSelect.tsx +15 -3
- package/components/Models/SubagentModels.tsx +60 -45
- package/components/PhoneNotify/PhoneNotifyForm.tsx +6 -13
- package/components/Recipes/RecipesPanel.tsx +20 -9
- package/components/Recipes/RunHistory.tsx +1 -1
- package/components/Schedules/SchedulesPanel.tsx +12 -10
- package/components/Settings/AppWideLimits.tsx +2 -2
- package/components/Settings/{NoApprovalsSection.test.ts → ApprovalsSection.test.ts} +61 -22
- package/components/Settings/ApprovalsSection.tsx +93 -0
- package/components/Settings/ExperimentsSection.test.ts +20 -4
- package/components/Settings/ExperimentsSection.tsx +54 -34
- package/components/Settings/GeneralSection.tsx +39 -0
- package/components/Settings/ListSection.test.ts +85 -0
- package/components/Settings/ListSection.tsx +132 -0
- package/components/Settings/NotificationsSection.tsx +31 -29
- package/components/Settings/PickerSetting.tsx +5 -1
- package/components/Settings/SettingRow.tsx +13 -8
- package/components/Settings/SettingsDialog.test.ts +31 -4
- package/components/Settings/SettingsDialog.tsx +22 -17
- package/components/Settings/ValueLabel.tsx +31 -0
- package/components/Settings/settings-fields.test.ts +81 -2
- package/components/Settings/settings-fields.tsx +27 -27
- package/components/Shares/SharedLinksPanel.tsx +11 -14
- package/components/Updates/AutoUpdates.tsx +12 -8
- package/components/Updates/UpdateActions.tsx +18 -3
- package/components/Updates/UpdateToast.test.ts +17 -0
- package/components/Updates/UpdateToast.tsx +15 -5
- package/components/Updates/UpdatesSection.tsx +27 -18
- package/components/Workspaces/WorkspaceSettings.test.ts +5 -5
- package/components/Workspaces/WorkspaceSettings.tsx +0 -2
- package/components/ui/switch.tsx +1 -1
- package/content/whats-new.json +24 -0
- package/data/approvals/index.ts +2 -2
- package/data/health/index.ts +3 -0
- package/hooks/useAutoSave.ts +18 -5
- package/hooks/useReloadWhenUpdated.ts +9 -0
- package/hooks/useSettingsUrl.ts +6 -3
- package/lib/approvals/describe.ts +64 -0
- package/lib/health/config.ts +70 -17
- package/lib/models/providers.ts +1 -1
- package/lib/recipes/settle.ts +22 -0
- package/lib/settings-url.ts +14 -2
- package/lib/updates/banner.ts +12 -2
- package/lib/updates/request.ts +29 -0
- package/lib/updates/run.ts +24 -5
- package/lib/updates/view.ts +3 -0
- package/package.json +1 -1
- package/scripts/autodeploy +25 -3
- package/scripts/autodeploy.test.ts +47 -0
- package/components/Settings/NoApprovalsSection.tsx +0 -103
- package/components/Settings/SettingToggle.tsx +0 -36
- package/components/Settings/SetupSection.tsx +0 -46
|
@@ -32,11 +32,12 @@ export function SwitchSetting({
|
|
|
32
32
|
confirmLabel = "Turn on",
|
|
33
33
|
undoable,
|
|
34
34
|
locked,
|
|
35
|
+
disabled,
|
|
35
36
|
extra,
|
|
36
37
|
}: {
|
|
37
38
|
id: string;
|
|
38
39
|
label: string;
|
|
39
|
-
help?:
|
|
40
|
+
help?: ReactNode;
|
|
40
41
|
checked: boolean;
|
|
41
42
|
onSave: Save<boolean>;
|
|
42
43
|
defaultValue?: boolean;
|
|
@@ -45,6 +46,8 @@ export function SwitchSetting({
|
|
|
45
46
|
undoable?: boolean;
|
|
46
47
|
// Can't be changed from here.
|
|
47
48
|
locked?: boolean;
|
|
49
|
+
// Greyed for a reason the help already gives.
|
|
50
|
+
disabled?: boolean;
|
|
48
51
|
// Shown under the row (usage, links).
|
|
49
52
|
extra?: ReactNode;
|
|
50
53
|
}) {
|
|
@@ -54,14 +57,11 @@ export function SwitchSetting({
|
|
|
54
57
|
<SettingRow
|
|
55
58
|
id={id}
|
|
56
59
|
label={label}
|
|
57
|
-
help={locked ?
|
|
60
|
+
help={locked ? <>{help} Can't be changed from here.</> : help}
|
|
58
61
|
status={auto.status}
|
|
59
62
|
onUndo={auto.undo}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
? undefined
|
|
63
|
-
: `Default: ${defaultValue ? "on" : "off"}`
|
|
64
|
-
}
|
|
63
|
+
error={auto.error}
|
|
64
|
+
resetLabel={defaultValue ? "on" : "off"}
|
|
65
65
|
onReset={
|
|
66
66
|
defaultValue !== undefined && auto.value !== defaultValue
|
|
67
67
|
? () => void auto.commit(defaultValue, { undoable: true })
|
|
@@ -72,9 +72,8 @@ export function SwitchSetting({
|
|
|
72
72
|
<Switch
|
|
73
73
|
id={id}
|
|
74
74
|
checked={auto.value}
|
|
75
|
-
disabled={locked}
|
|
75
|
+
disabled={locked || disabled}
|
|
76
76
|
aria-describedby={`${id}-hint`}
|
|
77
|
-
className="data-[state=checked]:bg-primary"
|
|
78
77
|
onCheckedChange={(on) => {
|
|
79
78
|
if (on && confirm) setAsking(true);
|
|
80
79
|
else void auto.commit(on, { undoable: undoable || !on });
|
|
@@ -149,10 +148,11 @@ export function NumberSetting({
|
|
|
149
148
|
const off = optional ? auto.value === optional.off : false;
|
|
150
149
|
const shown = (off ? optional!.on : auto.value) / scale;
|
|
151
150
|
const defOff = optional ? defaultValue === optional.off : false;
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
151
|
+
const resetLabel = defOff ? "off" : `${defaultValue / scale}${short}`;
|
|
152
|
+
const range =
|
|
153
|
+
max === undefined
|
|
154
|
+
? undefined
|
|
155
|
+
: `${min / scale}–${max / scale}${short || (unit ? ` ${unit}` : "")}`;
|
|
156
156
|
|
|
157
157
|
const finish = () => {
|
|
158
158
|
if (text === null) return;
|
|
@@ -180,10 +180,10 @@ export function NumberSetting({
|
|
|
180
180
|
<SettingRow
|
|
181
181
|
id={id}
|
|
182
182
|
label={label}
|
|
183
|
-
help={help}
|
|
183
|
+
help={[help, range && `Allowed: ${range}.`].filter(Boolean).join(" ")}
|
|
184
184
|
status={auto.status}
|
|
185
|
-
error={error}
|
|
186
|
-
|
|
185
|
+
error={error ?? auto.error}
|
|
186
|
+
resetLabel={resetLabel}
|
|
187
187
|
onUndo={auto.undo}
|
|
188
188
|
onReset={
|
|
189
189
|
auto.value !== defaultValue
|
|
@@ -200,7 +200,8 @@ export function NumberSetting({
|
|
|
200
200
|
id={id}
|
|
201
201
|
type="text"
|
|
202
202
|
inputMode={integer ? "numeric" : "decimal"}
|
|
203
|
-
value={text ?? String(shown)}
|
|
203
|
+
value={off ? "" : (text ?? String(shown))}
|
|
204
|
+
placeholder={off ? "Off" : undefined}
|
|
204
205
|
disabled={off}
|
|
205
206
|
aria-invalid={!!error}
|
|
206
207
|
aria-describedby={`${id}-hint`}
|
|
@@ -220,7 +221,9 @@ export function NumberSetting({
|
|
|
220
221
|
className={`${FIELD} w-24 text-right tabular-nums`}
|
|
221
222
|
/>
|
|
222
223
|
{unit && (
|
|
223
|
-
<span
|
|
224
|
+
<span
|
|
225
|
+
className={`text-sm ${off ? "text-muted-foreground/50" : "text-muted-foreground"}`}
|
|
226
|
+
>
|
|
224
227
|
{unit}
|
|
225
228
|
</span>
|
|
226
229
|
)}
|
|
@@ -261,7 +264,7 @@ export function SelectSetting({
|
|
|
261
264
|
options: { value: string; label: string }[];
|
|
262
265
|
onSave: Save<string>;
|
|
263
266
|
defaultValue?: string;
|
|
264
|
-
//
|
|
267
|
+
// A line under the row (what an Inherit choice resolves to).
|
|
265
268
|
hint?: string;
|
|
266
269
|
}) {
|
|
267
270
|
const auto = useAutoSave({ saved: value, save: onSave });
|
|
@@ -273,12 +276,9 @@ export function SelectSetting({
|
|
|
273
276
|
help={help}
|
|
274
277
|
status={auto.status}
|
|
275
278
|
onUndo={auto.undo}
|
|
276
|
-
hint={
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
? `Default: ${def.label}`
|
|
280
|
-
: undefined)
|
|
281
|
-
}
|
|
279
|
+
hint={hint}
|
|
280
|
+
error={auto.error}
|
|
281
|
+
resetLabel={def?.label}
|
|
282
282
|
onReset={
|
|
283
283
|
defaultValue !== undefined && auto.value !== defaultValue
|
|
284
284
|
? () => void auto.commit(defaultValue, { undoable: true })
|
|
@@ -367,7 +367,7 @@ export function TextSetting({
|
|
|
367
367
|
label={label}
|
|
368
368
|
help={help}
|
|
369
369
|
status={checking ? "saving" : auto.status}
|
|
370
|
-
error={error}
|
|
370
|
+
error={error ?? auto.error}
|
|
371
371
|
onUndo={auto.undo}
|
|
372
372
|
onReset={
|
|
373
373
|
resettable && !secret && auto.value !== defaultValue
|
|
@@ -407,7 +407,7 @@ export function TextSetting({
|
|
|
407
407
|
e.currentTarget.blur();
|
|
408
408
|
}
|
|
409
409
|
}}
|
|
410
|
-
className={`${FIELD} w-full sm:w-64`}
|
|
410
|
+
className={`${FIELD} w-full ${deliberate ? "sm:w-48" : "sm:w-64"}`}
|
|
411
411
|
/>
|
|
412
412
|
{deliberate && (
|
|
413
413
|
<Button
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { toast } from "sonner";
|
|
5
|
-
import { Copy
|
|
5
|
+
import { Copy } from "lucide-react";
|
|
6
|
+
import { ListSection } from "@/components/Settings/ListSection";
|
|
6
7
|
import { Button } from "@/components/ui/button";
|
|
7
8
|
import { writeClipboard } from "@/hooks/useCopyToClipboard";
|
|
8
9
|
import {
|
|
@@ -52,7 +53,7 @@ function Row({ share }: { share: SharedLinkRow }) {
|
|
|
52
53
|
);
|
|
53
54
|
|
|
54
55
|
return (
|
|
55
|
-
<
|
|
56
|
+
<div className="px-3 py-2.5">
|
|
56
57
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
57
58
|
<div className="min-w-0 flex-1">
|
|
58
59
|
<div className="truncate text-sm font-medium">{title}</div>
|
|
@@ -114,7 +115,7 @@ function Row({ share }: { share: SharedLinkRow }) {
|
|
|
114
115
|
onCancel={() => setRevoking(false)}
|
|
115
116
|
/>
|
|
116
117
|
)}
|
|
117
|
-
</
|
|
118
|
+
</div>
|
|
118
119
|
);
|
|
119
120
|
}
|
|
120
121
|
|
|
@@ -194,18 +195,14 @@ export function SharedLinksPanel() {
|
|
|
194
195
|
)}
|
|
195
196
|
{isLoading ? (
|
|
196
197
|
<p className="text-muted-foreground text-sm">Loading…</p>
|
|
197
|
-
) : data?.shares.length ? (
|
|
198
|
-
<ul className="space-y-2">
|
|
199
|
-
{data.shares.map((s) => (
|
|
200
|
-
<Row key={s.id} share={s} />
|
|
201
|
-
))}
|
|
202
|
-
</ul>
|
|
203
198
|
) : (
|
|
204
|
-
<
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
199
|
+
<ListSection
|
|
200
|
+
title="Snapshots"
|
|
201
|
+
items={data?.shares ?? []}
|
|
202
|
+
keyOf={(s) => s.id}
|
|
203
|
+
render={(s) => <Row share={s} />}
|
|
204
|
+
empty="Nothing shared yet. Use Share… in a chat's menu, or the share button on a page."
|
|
205
|
+
/>
|
|
209
206
|
)}
|
|
210
207
|
</div>
|
|
211
208
|
);
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { toast } from "sonner";
|
|
4
4
|
import { CheckCircle2, CircleAlert, RotateCcw } from "lucide-react";
|
|
5
|
-
import {
|
|
5
|
+
import { SwitchSetting } from "@/components/Settings/settings-fields";
|
|
6
|
+
import { SettingSection } from "@/components/Settings/SettingRow";
|
|
6
7
|
import {
|
|
7
8
|
Select,
|
|
8
9
|
SelectContent,
|
|
@@ -80,13 +81,16 @@ export function AutoUpdates({ auto }: { auto: AutoUpdateView }) {
|
|
|
80
81
|
const onError = (e: Error) => toast.error(e.message);
|
|
81
82
|
return (
|
|
82
83
|
<div className="space-y-3">
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
84
|
+
<SettingSection>
|
|
85
|
+
<SwitchSetting
|
|
86
|
+
id="auto-update"
|
|
87
|
+
label="Install updates automatically"
|
|
88
|
+
help="Once everything has been idle for a while. Terminals and chats keep running through the restart, and a version that doesn't start cleanly is rolled back."
|
|
89
|
+
checked={auto.enabled}
|
|
90
|
+
locked={auto.locked}
|
|
91
|
+
onSave={(on) => save.mutateAsync({ auto: on })}
|
|
92
|
+
/>
|
|
93
|
+
</SettingSection>
|
|
90
94
|
{auto.enabled && (
|
|
91
95
|
<div className="flex flex-wrap items-center justify-between gap-2 px-3">
|
|
92
96
|
<label htmlFor="auto-idle" className="text-sm">
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
|
+
import { Loader2 } from "lucide-react";
|
|
4
5
|
import { toast } from "sonner";
|
|
5
6
|
import { Button } from "@/components/ui/button";
|
|
6
7
|
import {
|
|
@@ -38,6 +39,12 @@ export function UpdateActions({ view }: { view: UpdateView }) {
|
|
|
38
39
|
const [confirm, setConfirm] = useState(false);
|
|
39
40
|
const busy = workingTotal(view.working) > 0;
|
|
40
41
|
|
|
42
|
+
if (view.queued)
|
|
43
|
+
return (
|
|
44
|
+
<Button size="sm" className="min-h-11 md:min-h-0" disabled>
|
|
45
|
+
<Loader2 className="h-4 w-4 animate-spin" /> Updating…
|
|
46
|
+
</Button>
|
|
47
|
+
);
|
|
41
48
|
if (view.pending)
|
|
42
49
|
return (
|
|
43
50
|
<Button
|
|
@@ -60,7 +67,10 @@ export function UpdateActions({ view }: { view: UpdateView }) {
|
|
|
60
67
|
|
|
61
68
|
const start = (when: "now" | "idle") => {
|
|
62
69
|
setConfirm(false);
|
|
63
|
-
run.mutate(when, {
|
|
70
|
+
run.mutate(when, {
|
|
71
|
+
onError: toastError,
|
|
72
|
+
onSuccess: (v) => v.message && toast.success(v.message),
|
|
73
|
+
});
|
|
64
74
|
};
|
|
65
75
|
|
|
66
76
|
return (
|
|
@@ -87,10 +97,15 @@ export function UpdateActions({ view }: { view: UpdateView }) {
|
|
|
87
97
|
<Button variant="ghost" onClick={() => setConfirm(false)}>
|
|
88
98
|
Cancel
|
|
89
99
|
</Button>
|
|
90
|
-
<Button
|
|
100
|
+
<Button
|
|
101
|
+
variant={view.managed ? "default" : "outline"}
|
|
102
|
+
onClick={() => start("now")}
|
|
103
|
+
>
|
|
91
104
|
Update now
|
|
92
105
|
</Button>
|
|
93
|
-
|
|
106
|
+
{!view.managed && (
|
|
107
|
+
<Button onClick={() => start("idle")}>Update when idle</Button>
|
|
108
|
+
)}
|
|
94
109
|
</DialogFooter>
|
|
95
110
|
</DialogContent>
|
|
96
111
|
</Dialog>
|
|
@@ -96,6 +96,8 @@ const make = (over: Partial<UpdateView> = {}): UpdateView => ({
|
|
|
96
96
|
canUpdate: { ok: true },
|
|
97
97
|
run: null,
|
|
98
98
|
pending: null,
|
|
99
|
+
managed: false,
|
|
100
|
+
queued: null,
|
|
99
101
|
working: { sessions: 0, reviews: 0, setups: 0 },
|
|
100
102
|
...over,
|
|
101
103
|
});
|
|
@@ -212,6 +214,21 @@ describe("UpdateToast", () => {
|
|
|
212
214
|
expect(runMutate).toHaveBeenCalledWith("idle", expect.anything());
|
|
213
215
|
});
|
|
214
216
|
|
|
217
|
+
it("on a machine autodeploy manages, explains and still offers Update now", async () => {
|
|
218
|
+
view = make({ managed: true });
|
|
219
|
+
await render();
|
|
220
|
+
expect(text()).toContain("deployed by autodeploy");
|
|
221
|
+
await act(async () => button("Update now")!.click());
|
|
222
|
+
expect(runMutate).toHaveBeenCalledWith("now", expect.anything());
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("shows a queued request as a disabled Updating… button", async () => {
|
|
226
|
+
view = make({ managed: true, queued: { requestedAt: 1 } });
|
|
227
|
+
await render();
|
|
228
|
+
expect(button("Updating…")?.disabled).toBe(true);
|
|
229
|
+
expect(button("Update now")).toBeUndefined();
|
|
230
|
+
});
|
|
231
|
+
|
|
215
232
|
it("shows a pending update with Cancel", async () => {
|
|
216
233
|
view = make({ pending: { requestedAt: 1 } });
|
|
217
234
|
await render();
|
|
@@ -10,6 +10,7 @@ import { useDismissedUpdate } from "@/hooks/useDismissedUpdate";
|
|
|
10
10
|
import { useReloadWhenUpdated } from "@/hooks/useReloadWhenUpdated";
|
|
11
11
|
import {
|
|
12
12
|
bannerState,
|
|
13
|
+
MANAGED_NOTE,
|
|
13
14
|
type BannerState,
|
|
14
15
|
type UpdateView,
|
|
15
16
|
} from "@/lib/updates/banner";
|
|
@@ -72,6 +73,10 @@ export function UpdateToastView({
|
|
|
72
73
|
if (state === "available") text = <>AgentOS {latest?.version} is available</>;
|
|
73
74
|
else if (state === "pending")
|
|
74
75
|
text = <>AgentOS will update when nothing is working</>;
|
|
76
|
+
else if (state === "queued")
|
|
77
|
+
text = (
|
|
78
|
+
<>Updating AgentOS to {latest?.version}. It deploys within a minute.</>
|
|
79
|
+
);
|
|
75
80
|
else if (state === "built")
|
|
76
81
|
text = <>Built {run?.toVersion}. Restart AgentOS to finish updating.</>;
|
|
77
82
|
else if (failed) text = <>The update didn't finish. {run?.error}</>;
|
|
@@ -110,7 +115,7 @@ export function UpdateToastView({
|
|
|
110
115
|
)}
|
|
111
116
|
>
|
|
112
117
|
<span className="flex h-6 shrink-0 items-center">
|
|
113
|
-
{state === "running" ? (
|
|
118
|
+
{state === "running" || state === "queued" ? (
|
|
114
119
|
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
|
115
120
|
) : (
|
|
116
121
|
<ArrowUpCircle
|
|
@@ -130,6 +135,9 @@ export function UpdateToastView({
|
|
|
130
135
|
{view.canUpdate.reason}
|
|
131
136
|
</p>
|
|
132
137
|
)}
|
|
138
|
+
{state === "available" && view.managed && view.canUpdate.ok && (
|
|
139
|
+
<p className="text-muted-foreground text-xs">{MANAGED_NOTE}</p>
|
|
140
|
+
)}
|
|
133
141
|
{notesOpen && latest && (
|
|
134
142
|
<ul className="text-muted-foreground list-disc space-y-0.5 pl-4 text-xs">
|
|
135
143
|
{latest.notes.map((n, i) => (
|
|
@@ -138,9 +146,9 @@ export function UpdateToastView({
|
|
|
138
146
|
</ul>
|
|
139
147
|
)}
|
|
140
148
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
141
|
-
{(state === "available" ||
|
|
142
|
-
|
|
143
|
-
|
|
149
|
+
{(state === "available" ||
|
|
150
|
+
state === "pending" ||
|
|
151
|
+
state === "queued") && <UpdateActions view={view} />}
|
|
144
152
|
{state === "running" && elapsed > SLOW_MS && (
|
|
145
153
|
<Button size="sm" onClick={() => window.location.reload()}>
|
|
146
154
|
Reload
|
|
@@ -243,7 +251,9 @@ export function UpdateToast() {
|
|
|
243
251
|
? `running:${view?.run?.id}`
|
|
244
252
|
: computed === "pending"
|
|
245
253
|
? `pending:${view?.pending?.requestedAt}`
|
|
246
|
-
:
|
|
254
|
+
: computed === "queued"
|
|
255
|
+
? `queued:${view?.queued?.requestedAt}`
|
|
256
|
+
: null;
|
|
247
257
|
const state = key && key === closed ? "hidden" : computed;
|
|
248
258
|
|
|
249
259
|
const { dismissVersion, dismissRun } = dismissed;
|
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
import { toast } from "sonner";
|
|
4
4
|
import { Loader2 } from "lucide-react";
|
|
5
5
|
import { Button } from "@/components/ui/button";
|
|
6
|
-
import {
|
|
6
|
+
import { SwitchSetting } from "@/components/Settings/settings-fields";
|
|
7
|
+
import { SettingSection } from "@/components/Settings/SettingRow";
|
|
7
8
|
import { useSetTipsOff, useTips } from "@/data/tips";
|
|
8
9
|
import {
|
|
9
10
|
useCheckForUpdates,
|
|
10
11
|
useUpdateBannerSetting,
|
|
11
12
|
useUpdates,
|
|
12
13
|
} from "@/data/updates";
|
|
13
|
-
import { isActivePhase } from "@/lib/updates/banner";
|
|
14
|
+
import { isActivePhase, MANAGED_NOTE } from "@/lib/updates/banner";
|
|
14
15
|
import { AutoUpdates } from "./AutoUpdates";
|
|
15
16
|
import { UpdateActions } from "./UpdateActions";
|
|
16
17
|
|
|
@@ -107,9 +108,17 @@ export function UpdatesSection() {
|
|
|
107
108
|
{view.run?.phase})…
|
|
108
109
|
</p>
|
|
109
110
|
)}
|
|
111
|
+
{view.available && view.managed && view.canUpdate.ok && (
|
|
112
|
+
<p className="text-muted-foreground text-xs">{MANAGED_NOTE}</p>
|
|
113
|
+
)}
|
|
110
114
|
{view.available && !view.canUpdate.ok && view.canUpdate.reason && (
|
|
111
115
|
<p className="text-muted-foreground text-xs">{view.canUpdate.reason}</p>
|
|
112
116
|
)}
|
|
117
|
+
{view.queued && (
|
|
118
|
+
<p className="text-muted-foreground text-sm">
|
|
119
|
+
Queued for autodeploy. It deploys within about a minute.
|
|
120
|
+
</p>
|
|
121
|
+
)}
|
|
113
122
|
{view.pending && (
|
|
114
123
|
<p className="text-muted-foreground text-sm">
|
|
115
124
|
Will update when nothing is working.
|
|
@@ -141,16 +150,17 @@ export function UpdatesSection() {
|
|
|
141
150
|
|
|
142
151
|
{view.auto && <AutoUpdates auto={view.auto} />}
|
|
143
152
|
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
<SettingSection>
|
|
154
|
+
<SwitchSetting
|
|
155
|
+
id="update-banner"
|
|
156
|
+
label="Show update banner"
|
|
157
|
+
help="Tell everyone using this AgentOS when a newer version is out, and when it updated itself."
|
|
158
|
+
checked={view.bannerEnabled}
|
|
159
|
+
locked={view.bannerLocked}
|
|
160
|
+
onSave={(on) => banner.mutateAsync(on)}
|
|
161
|
+
/>
|
|
162
|
+
<TipsToggle />
|
|
163
|
+
</SettingSection>
|
|
154
164
|
</div>
|
|
155
165
|
);
|
|
156
166
|
}
|
|
@@ -159,13 +169,12 @@ function TipsToggle() {
|
|
|
159
169
|
const { data } = useTips();
|
|
160
170
|
const setOff = useSetTipsOff();
|
|
161
171
|
return (
|
|
162
|
-
<
|
|
163
|
-
|
|
164
|
-
|
|
172
|
+
<SwitchSetting
|
|
173
|
+
id="tips"
|
|
174
|
+
label="Show Tip of the Day"
|
|
175
|
+
help="A short tip about something AgentOS can do, behind the lightbulb in the sidebar. It never opens on its own."
|
|
165
176
|
checked={!!data && !data.off}
|
|
166
|
-
|
|
167
|
-
setOff.mutate(!on, { onError: (e) => toast.error(e.message) })
|
|
168
|
-
}
|
|
177
|
+
onSave={(on) => setOff.mutateAsync(!on)}
|
|
169
178
|
/>
|
|
170
179
|
);
|
|
171
180
|
}
|
|
@@ -124,19 +124,19 @@ describe("WorkspaceSettings", () => {
|
|
|
124
124
|
await render();
|
|
125
125
|
expect(limit().id).toBe("task-limit-w1");
|
|
126
126
|
expect(limit().disabled).toBe(true);
|
|
127
|
-
expect(limit().value).toBe("
|
|
128
|
-
expect(
|
|
127
|
+
expect(limit().value).toBe("");
|
|
128
|
+
expect(limit().placeholder).toBe("Off");
|
|
129
|
+
expect(host.textContent).not.toContain("Reset");
|
|
129
130
|
});
|
|
130
131
|
|
|
131
|
-
it("enables the number when a limit is set, and
|
|
132
|
+
it("enables the number when a limit is set, and offers Reset to off", async () => {
|
|
132
133
|
localStorage.setItem("agentOS-settings-workspace", "w2");
|
|
133
134
|
await render();
|
|
134
135
|
expect(limit().disabled).toBe(false);
|
|
135
136
|
expect(limit().value).toBe("2");
|
|
136
|
-
expect(host.textContent).toContain("Default: off");
|
|
137
137
|
expect(
|
|
138
138
|
[...host.querySelectorAll("button")].some(
|
|
139
|
-
(b) => b.textContent === "Reset"
|
|
139
|
+
(b) => b.textContent === "Reset to off"
|
|
140
140
|
)
|
|
141
141
|
).toBe(true);
|
|
142
142
|
});
|
|
@@ -4,7 +4,6 @@ import { useContext, useState } from "react";
|
|
|
4
4
|
import { settingsUiActions } from "@/stores/settingsUi";
|
|
5
5
|
import { useWorkspacesQuery } from "@/data/workspaces";
|
|
6
6
|
import { useProjectsQuery } from "@/data/projects";
|
|
7
|
-
import { AppWideLimits } from "@/components/Settings/AppWideLimits";
|
|
8
7
|
import {
|
|
9
8
|
matchesQuery,
|
|
10
9
|
SettingsQueryContext,
|
|
@@ -69,7 +68,6 @@ export function WorkspaceSettings() {
|
|
|
69
68
|
}}
|
|
70
69
|
/>
|
|
71
70
|
<WorkspacePanel key={shown.id} workspace={shown} />
|
|
72
|
-
<AppWideLimits />
|
|
73
71
|
<p className="text-muted-foreground text-sm">
|
|
74
72
|
Fresh starts and compaction are app-wide:{" "}
|
|
75
73
|
<button
|
package/components/ui/switch.tsx
CHANGED
|
@@ -13,7 +13,7 @@ function Switch({
|
|
|
13
13
|
<SwitchPrimitive.Root
|
|
14
14
|
data-slot="switch"
|
|
15
15
|
className={cn(
|
|
16
|
-
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-
|
|
16
|
+
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted-foreground/35 data-[state=unchecked]:border-foreground/15 focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-muted-foreground/40 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
|
17
17
|
className
|
|
18
18
|
)}
|
|
19
19
|
{...props}
|
package/content/whats-new.json
CHANGED
|
@@ -2009,6 +2009,30 @@
|
|
|
2009
2009
|
"area": "Sidebar",
|
|
2010
2010
|
"text": "Renaming a session for the first time no longer closes the name box before you can type.",
|
|
2011
2011
|
"url": "https://github.com/saadnvd1/agent-os/pull/568"
|
|
2012
|
+
},
|
|
2013
|
+
{
|
|
2014
|
+
"id": 569,
|
|
2015
|
+
"date": "2026-11-02",
|
|
2016
|
+
"kind": "fixed",
|
|
2017
|
+
"area": "Chat",
|
|
2018
|
+
"text": "Pressing Enter with a selection that ends on a reply's button row no longer quotes it.",
|
|
2019
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/569"
|
|
2020
|
+
},
|
|
2021
|
+
{
|
|
2022
|
+
"id": 570,
|
|
2023
|
+
"date": "2026-11-02",
|
|
2024
|
+
"kind": "improved",
|
|
2025
|
+
"area": "Settings",
|
|
2026
|
+
"text": "Settings is tidier: clearer switches, shorter lists, one Approvals page, and a General page for your name and alerts.",
|
|
2027
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/570"
|
|
2028
|
+
},
|
|
2029
|
+
{
|
|
2030
|
+
"id": 571,
|
|
2031
|
+
"date": "2026-11-02",
|
|
2032
|
+
"kind": "fixed",
|
|
2033
|
+
"area": "Updates",
|
|
2034
|
+
"text": "You can update AgentOS by hand again on machines that deploy themselves, and Update now skips the hourly wait.",
|
|
2035
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/571"
|
|
2012
2036
|
}
|
|
2013
2037
|
]
|
|
2014
2038
|
}
|
package/data/approvals/index.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ApprovalLine } from "@/lib/approvals/describe";
|
|
3
3
|
import { provePresence } from "../presence";
|
|
4
4
|
import { usePollWhenOffline } from "../push/connection";
|
|
5
5
|
|
|
6
6
|
export interface ApprovalsMode {
|
|
7
7
|
on: boolean;
|
|
8
|
-
audit:
|
|
8
|
+
audit: ApprovalLine[];
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export const approvalsKeys = { all: ["approvals"] as const };
|
package/data/health/index.ts
CHANGED
|
@@ -91,10 +91,13 @@ export function useDeleteNote() {
|
|
|
91
91
|
});
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
import type { HealthMeta } from "@/lib/health/config";
|
|
95
|
+
|
|
94
96
|
export interface ConfigResponse {
|
|
95
97
|
config: Record<string, number>;
|
|
96
98
|
defaults: Record<string, number>;
|
|
97
99
|
labels: Record<string, string>;
|
|
100
|
+
meta: Record<string, HealthMeta>;
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
export function useHealthConfig() {
|
package/hooks/useAutoSave.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
4
|
import { toast } from "sonner";
|
|
5
|
+
import { settingsUi } from "@/stores/settingsUi";
|
|
5
6
|
|
|
6
7
|
export type SaveStatus = "idle" | "saving" | "saved" | "undo";
|
|
7
8
|
|
|
@@ -13,6 +14,9 @@ export interface AutoSave<T> {
|
|
|
13
14
|
// rejected save drops the in-flight value, so this is the saved one again.
|
|
14
15
|
value: T;
|
|
15
16
|
status: SaveStatus;
|
|
17
|
+
// Why the last save was rejected; cleared by the next commit. Shown on the
|
|
18
|
+
// row while Settings is open (a toast would sit over the dialog).
|
|
19
|
+
error: string | null;
|
|
16
20
|
// Commit a new value. `undoable` keeps the previous one for Undo (large or
|
|
17
21
|
// destructive changes). Resolves false when the server rejected it.
|
|
18
22
|
commit: (next: T, opts?: { undoable?: boolean }) => Promise<boolean>;
|
|
@@ -27,7 +31,7 @@ export function useAutoSave<T>({
|
|
|
27
31
|
saved,
|
|
28
32
|
save,
|
|
29
33
|
equals = Object.is,
|
|
30
|
-
notify
|
|
34
|
+
notify,
|
|
31
35
|
}: {
|
|
32
36
|
saved: T;
|
|
33
37
|
save: (next: T) => Promise<unknown>;
|
|
@@ -36,6 +40,7 @@ export function useAutoSave<T>({
|
|
|
36
40
|
}): AutoSave<T> {
|
|
37
41
|
const [pending, setPending] = useState<{ value: T } | null>(null);
|
|
38
42
|
const [status, setStatus] = useState<SaveStatus>("idle");
|
|
43
|
+
const [error, setError] = useState<string | null>(null);
|
|
39
44
|
const previous = useRef<{ value: T } | null>(null);
|
|
40
45
|
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
41
46
|
const seq = useRef(0);
|
|
@@ -56,6 +61,7 @@ export function useAutoSave<T>({
|
|
|
56
61
|
const mine = ++seq.current;
|
|
57
62
|
clearTimeout(timer.current);
|
|
58
63
|
setPending({ value: next });
|
|
64
|
+
setError(null);
|
|
59
65
|
setStatus("saving");
|
|
60
66
|
try {
|
|
61
67
|
await latest.current.save(next);
|
|
@@ -64,9 +70,10 @@ export function useAutoSave<T>({
|
|
|
64
70
|
setPending(null);
|
|
65
71
|
setStatus("idle");
|
|
66
72
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
);
|
|
73
|
+
const message = err instanceof Error ? err.message : "Couldn't save";
|
|
74
|
+
if (latest.current.notify) latest.current.notify(message);
|
|
75
|
+
else if (settingsUi.open) setError(message);
|
|
76
|
+
else toast.error(message);
|
|
70
77
|
return false;
|
|
71
78
|
}
|
|
72
79
|
if (mine !== seq.current) return true;
|
|
@@ -94,5 +101,11 @@ export function useAutoSave<T>({
|
|
|
94
101
|
return run(back.value, false, latest.current.saved);
|
|
95
102
|
}, [run]);
|
|
96
103
|
|
|
97
|
-
return {
|
|
104
|
+
return {
|
|
105
|
+
value: pending ? pending.value : saved,
|
|
106
|
+
status,
|
|
107
|
+
error,
|
|
108
|
+
commit,
|
|
109
|
+
undo,
|
|
110
|
+
};
|
|
98
111
|
}
|
|
@@ -9,7 +9,16 @@ import { isActivePhase, type UpdateView } from "@/lib/updates/banner";
|
|
|
9
9
|
*/
|
|
10
10
|
export function useReloadWhenUpdated(view: UpdateView | undefined) {
|
|
11
11
|
const watched = useRef<string | null>(null);
|
|
12
|
+
const queuedFrom = useRef<string | null>(null);
|
|
12
13
|
const run = view?.run;
|
|
14
|
+
// An Update now that autodeploy took: the page was loaded from the old build.
|
|
15
|
+
const sha = view?.current?.sha;
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!sha) return;
|
|
18
|
+
if (view?.queued) queuedFrom.current = sha;
|
|
19
|
+
else if (queuedFrom.current && queuedFrom.current !== sha)
|
|
20
|
+
window.location.reload();
|
|
21
|
+
}, [view?.queued, sha]);
|
|
13
22
|
useEffect(() => {
|
|
14
23
|
if (!run) return;
|
|
15
24
|
if (isActivePhase(run.phase)) watched.current = run.id;
|