@saadnvd1/agent-os 0.3.845 → 0.3.848
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/approvals/route.ts +5 -1
- package/app/api/health/config/route.ts +2 -0
- 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/UpdatesSection.tsx +18 -17
- 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 +16 -0
- package/data/approvals/index.ts +2 -2
- package/data/health/index.ts +3 -0
- package/hooks/useAutoSave.ts +18 -5
- 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/package.json +1 -1
- package/components/Settings/NoApprovalsSection.tsx +0 -103
- package/components/Settings/SettingToggle.tsx +0 -36
- package/components/Settings/SetupSection.tsx +0 -46
|
@@ -25,6 +25,14 @@ const state = vi.hoisted(() => ({
|
|
|
25
25
|
audit: [] as object[],
|
|
26
26
|
mutate: vi.fn(),
|
|
27
27
|
}));
|
|
28
|
+
vi.mock("@/components/Merge/MergeApprovalToggle", () => ({
|
|
29
|
+
MergeApprovalToggle: ({ overriddenBy }: { overriddenBy?: boolean }) =>
|
|
30
|
+
createElement(
|
|
31
|
+
"p",
|
|
32
|
+
{ "data-merge": String(!!overriddenBy) },
|
|
33
|
+
"merge toggle"
|
|
34
|
+
),
|
|
35
|
+
}));
|
|
28
36
|
vi.mock("@/data/approvals", () => ({
|
|
29
37
|
useApprovalsModeQuery: () => ({
|
|
30
38
|
isPending: false,
|
|
@@ -32,14 +40,14 @@ vi.mock("@/data/approvals", () => ({
|
|
|
32
40
|
data: { on: state.on, audit: state.audit },
|
|
33
41
|
}),
|
|
34
42
|
useSetApprovalsMode: () => ({
|
|
35
|
-
|
|
43
|
+
mutateAsync: state.mutate,
|
|
36
44
|
isPending: false,
|
|
37
45
|
error: null,
|
|
38
46
|
}),
|
|
39
47
|
}));
|
|
40
48
|
|
|
41
49
|
const { TooltipProvider } = await import("@/components/ui/tooltip");
|
|
42
|
-
const {
|
|
50
|
+
const { ApprovalsSection } = await import("./ApprovalsSection");
|
|
43
51
|
const { NoApprovalsBadge } =
|
|
44
52
|
await import("@/components/Approvals/NoApprovalsBadge");
|
|
45
53
|
|
|
@@ -61,7 +69,7 @@ afterEach(() => {
|
|
|
61
69
|
const render = (el: ReturnType<typeof createElement>) =>
|
|
62
70
|
act(() => root.render(el));
|
|
63
71
|
const click = (el: Element | null) =>
|
|
64
|
-
act(() => {
|
|
72
|
+
act(async () => {
|
|
65
73
|
el?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
|
66
74
|
});
|
|
67
75
|
const byText = (text: string) =>
|
|
@@ -69,45 +77,76 @@ const byText = (text: string) =>
|
|
|
69
77
|
b.textContent?.includes(text)
|
|
70
78
|
) ?? null;
|
|
71
79
|
|
|
72
|
-
describe("the
|
|
73
|
-
|
|
74
|
-
|
|
80
|
+
describe("the Approvals section", () => {
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
state.mutate.mockResolvedValue(undefined);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("is off, and flipping it warns first without switching anything", async () => {
|
|
86
|
+
render(createElement(ApprovalsSection));
|
|
75
87
|
const toggle = host.querySelector('[role="switch"]');
|
|
76
88
|
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
|
77
89
|
expect(host.querySelector('[role="alertdialog"]')).toBeNull();
|
|
78
|
-
click(toggle!);
|
|
90
|
+
await click(toggle!);
|
|
79
91
|
const warning = host.querySelector('[role="alertdialog"]');
|
|
80
92
|
expect(warning?.textContent).toMatch(/hard lines included/);
|
|
81
93
|
expect(warning?.textContent).toMatch(/permission prompts off/);
|
|
82
94
|
expect(state.mutate).not.toHaveBeenCalled();
|
|
83
95
|
});
|
|
84
96
|
|
|
85
|
-
it("switches on only when the warning is confirmed, and cancel backs out", () => {
|
|
86
|
-
render(createElement(
|
|
87
|
-
click(host.querySelector('[role="switch"]'));
|
|
88
|
-
click(byText("Cancel"));
|
|
97
|
+
it("switches on only when the warning is confirmed, and cancel backs out", async () => {
|
|
98
|
+
render(createElement(ApprovalsSection));
|
|
99
|
+
await click(host.querySelector('[role="switch"]'));
|
|
100
|
+
await click(byText("Cancel"));
|
|
89
101
|
expect(host.querySelector('[role="alertdialog"]')).toBeNull();
|
|
90
102
|
expect(state.mutate).not.toHaveBeenCalled();
|
|
91
|
-
click(host.querySelector('[role="switch"]'));
|
|
92
|
-
click(byText("Turn on No approvals"));
|
|
93
|
-
expect(state.mutate).toHaveBeenCalledWith(true
|
|
103
|
+
await click(host.querySelector('[role="switch"]'));
|
|
104
|
+
await click(byText("Turn on No approvals"));
|
|
105
|
+
expect(state.mutate).toHaveBeenCalledWith(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("switches off at once", async () => {
|
|
109
|
+
state.on = true;
|
|
110
|
+
render(createElement(ApprovalsSection));
|
|
111
|
+
await click(host.querySelector('[role="switch"]'));
|
|
112
|
+
expect(state.mutate).toHaveBeenCalledWith(false);
|
|
94
113
|
});
|
|
95
114
|
|
|
96
|
-
it("
|
|
115
|
+
it("holds the merge approval switch in the same place, greyed while the mode is on", () => {
|
|
116
|
+
render(createElement(ApprovalsSection));
|
|
117
|
+
expect(host.querySelector("[data-merge]")?.getAttribute("data-merge")).toBe(
|
|
118
|
+
"false"
|
|
119
|
+
);
|
|
120
|
+
state.on = true;
|
|
121
|
+
act(() => root.unmount());
|
|
122
|
+
root = createRoot(host);
|
|
123
|
+
render(createElement(ApprovalsSection));
|
|
124
|
+
expect(host.querySelector("[data-merge]")?.getAttribute("data-merge")).toBe(
|
|
125
|
+
"true"
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("lists what was approved in words, with a command behind Details", async () => {
|
|
97
130
|
state.on = true;
|
|
98
131
|
state.audit = [
|
|
99
132
|
{
|
|
100
133
|
id: 1,
|
|
101
134
|
at: "2026-10-13 10:00:00",
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
135
|
+
where: "A chat",
|
|
136
|
+
summary: "Ran a command",
|
|
137
|
+
details: "ssh root@host 'ls /tmp'",
|
|
105
138
|
},
|
|
106
139
|
];
|
|
107
|
-
render(createElement(
|
|
108
|
-
expect(host.textContent).toContain(
|
|
109
|
-
|
|
110
|
-
|
|
140
|
+
render(createElement(ApprovalsSection));
|
|
141
|
+
expect(host.textContent).toContain("Ran a command");
|
|
142
|
+
expect(host.textContent).not.toContain("ssh root@host");
|
|
143
|
+
await click(byText("Details"));
|
|
144
|
+
expect(host.textContent).toContain("ssh root@host 'ls /tmp'");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("says so when nothing was approved", () => {
|
|
148
|
+
render(createElement(ApprovalsSection));
|
|
149
|
+
expect(host.textContent).toContain("Nothing yet.");
|
|
111
150
|
});
|
|
112
151
|
});
|
|
113
152
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { ListSection } from "@/components/Settings/ListSection";
|
|
5
|
+
import { SettingSection } from "@/components/Settings/SettingRow";
|
|
6
|
+
import { SwitchSetting } from "@/components/Settings/settings-fields";
|
|
7
|
+
import { MergeApprovalToggle } from "@/components/Merge/MergeApprovalToggle";
|
|
8
|
+
import { useApprovalsModeQuery, useSetApprovalsMode } from "@/data/approvals";
|
|
9
|
+
import type { ApprovalLine } from "@/lib/approvals/describe";
|
|
10
|
+
|
|
11
|
+
// What it switches off, said plainly before it can be switched on.
|
|
12
|
+
export const NO_APPROVALS_WARNING = [
|
|
13
|
+
"Every ask is approved the moment it is raised, hard lines included: public posts and messages, money, irreversible or destructive changes, credentials.",
|
|
14
|
+
"A PR stuck failing its gates, with no CI, or too big to review still merges.",
|
|
15
|
+
"Brakes stop holding new starts. Plan-only tasks still wait for approve_plan.",
|
|
16
|
+
"Every agent AgentOS starts runs with its permission prompts off, and prompts already waiting are allowed.",
|
|
17
|
+
"Terminals already running in an agent other than Claude keep asking until they are restarted. Each approval made for you is logged here.",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const when = (at: string) =>
|
|
21
|
+
new Date(`${at.replace(" ", "T")}Z`).toLocaleString(undefined, {
|
|
22
|
+
month: "short",
|
|
23
|
+
day: "numeric",
|
|
24
|
+
hour: "numeric",
|
|
25
|
+
minute: "2-digit",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function Line({ line }: { line: ApprovalLine }) {
|
|
29
|
+
const [open, setOpen] = useState(false);
|
|
30
|
+
return (
|
|
31
|
+
<div className="px-3 py-2.5 text-sm">
|
|
32
|
+
<p className="break-words">{line.summary}</p>
|
|
33
|
+
<p className="text-muted-foreground text-xs">
|
|
34
|
+
{line.where} · {when(line.at)}
|
|
35
|
+
{line.details && (
|
|
36
|
+
<>
|
|
37
|
+
{" · "}
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
aria-expanded={open}
|
|
41
|
+
onClick={() => setOpen(!open)}
|
|
42
|
+
className="text-primary -my-3 min-h-11 hover:underline"
|
|
43
|
+
>
|
|
44
|
+
{open ? "Hide details" : "Details"}
|
|
45
|
+
</button>
|
|
46
|
+
</>
|
|
47
|
+
)}
|
|
48
|
+
</p>
|
|
49
|
+
{open && line.details && (
|
|
50
|
+
<code className="bg-foreground/[0.05] mt-1 block rounded px-2 py-1 font-mono text-xs break-all whitespace-pre-wrap">
|
|
51
|
+
{line.details}
|
|
52
|
+
</code>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Every approval AgentOS would wait on, in one place: the master switch, the
|
|
59
|
+
// merge gate, and what the switch approved for you.
|
|
60
|
+
export function ApprovalsSection() {
|
|
61
|
+
const { data, isPending, isError, error } = useApprovalsModeQuery();
|
|
62
|
+
const set = useSetApprovalsMode();
|
|
63
|
+
|
|
64
|
+
if (isPending)
|
|
65
|
+
return <div className="bg-muted/40 h-16 animate-pulse rounded-lg" />;
|
|
66
|
+
if (isError)
|
|
67
|
+
return <p className="text-destructive text-sm">{error.message}</p>;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="space-y-5">
|
|
71
|
+
<SettingSection>
|
|
72
|
+
<SwitchSetting
|
|
73
|
+
id="no-approvals"
|
|
74
|
+
label="No approvals"
|
|
75
|
+
help="Shuts off every approval AgentOS would otherwise wait on, and every agent's permission prompts."
|
|
76
|
+
checked={data.on}
|
|
77
|
+
defaultValue={false}
|
|
78
|
+
confirm={NO_APPROVALS_WARNING.join(" ")}
|
|
79
|
+
confirmLabel="Turn on No approvals"
|
|
80
|
+
onSave={(on) => set.mutateAsync(on)}
|
|
81
|
+
/>
|
|
82
|
+
<MergeApprovalToggle overriddenBy={data.on} />
|
|
83
|
+
</SettingSection>
|
|
84
|
+
<ListSection
|
|
85
|
+
title="Approved for you"
|
|
86
|
+
items={data.audit}
|
|
87
|
+
keyOf={(l) => String(l.id)}
|
|
88
|
+
render={(l) => <Line line={l} />}
|
|
89
|
+
empty="Nothing yet."
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -25,11 +25,12 @@ const w = (calls: number) => ({
|
|
|
25
25
|
costUsd: calls * 0.01,
|
|
26
26
|
scripts: 0,
|
|
27
27
|
shown: 0,
|
|
28
|
-
accepted: 1,
|
|
29
|
-
dismissed: 1,
|
|
30
|
-
acceptRate: 0.5,
|
|
28
|
+
accepted: calls ? 1 : 0,
|
|
29
|
+
dismissed: calls ? 1 : 0,
|
|
30
|
+
acceptRate: calls ? 0.5 : null,
|
|
31
31
|
});
|
|
32
32
|
const state = vi.hoisted(() => ({
|
|
33
|
+
zero: false,
|
|
33
34
|
mutateAsync: vi.fn().mockResolvedValue({}),
|
|
34
35
|
}));
|
|
35
36
|
vi.mock("@/data/experiments", () => ({
|
|
@@ -47,7 +48,9 @@ vi.mock("@/data/experiments", () => ({
|
|
|
47
48
|
status: "experimental",
|
|
48
49
|
suggests: true,
|
|
49
50
|
on: false,
|
|
50
|
-
usage:
|
|
51
|
+
usage: state.zero
|
|
52
|
+
? { today: w(0), d7: w(0), d30: w(0) }
|
|
53
|
+
: { today: w(1), d7: w(2), d30: w(3) },
|
|
51
54
|
},
|
|
52
55
|
],
|
|
53
56
|
},
|
|
@@ -62,6 +65,7 @@ let root: Root;
|
|
|
62
65
|
let host: HTMLDivElement;
|
|
63
66
|
beforeEach(() => {
|
|
64
67
|
state.mutateAsync.mockClear();
|
|
68
|
+
state.zero = false;
|
|
65
69
|
host = document.createElement("div");
|
|
66
70
|
document.body.appendChild(host);
|
|
67
71
|
root = createRoot(host);
|
|
@@ -82,6 +86,18 @@ describe("ExperimentsSection", () => {
|
|
|
82
86
|
).toBe("false");
|
|
83
87
|
});
|
|
84
88
|
|
|
89
|
+
it("doesn't tag every card Experimental", () => {
|
|
90
|
+
act(() => root.render(createElement(ExperimentsSection)));
|
|
91
|
+
expect(host.textContent).not.toContain("Experimental");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("shows an empty state instead of a table of zeros", () => {
|
|
95
|
+
state.zero = true;
|
|
96
|
+
act(() => root.render(createElement(ExperimentsSection)));
|
|
97
|
+
expect(host.querySelector("table")).toBeNull();
|
|
98
|
+
expect(host.textContent).toContain("No usage yet");
|
|
99
|
+
});
|
|
100
|
+
|
|
85
101
|
it("turns one on from its toggle", () => {
|
|
86
102
|
act(() => root.render(createElement(ExperimentsSection)));
|
|
87
103
|
act(() => {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { ArrowLeft } from "lucide-react";
|
|
5
5
|
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { ScrollTable } from "@/components/Settings/ListSection";
|
|
6
7
|
import { SettingSection } from "@/components/Settings/SettingRow";
|
|
7
8
|
import { SwitchSetting } from "@/components/Settings/settings-fields";
|
|
8
9
|
import { formatTokens } from "@/lib/chat/context";
|
|
@@ -23,41 +24,60 @@ const WINDOWS = [
|
|
|
23
24
|
["d30", "30 days"],
|
|
24
25
|
] as const;
|
|
25
26
|
|
|
27
|
+
export const hasUsage = (e: ExperimentView) =>
|
|
28
|
+
WINDOWS.some(([key]) => {
|
|
29
|
+
const w = e.usage[key];
|
|
30
|
+
return (
|
|
31
|
+
w.calls > 0 ||
|
|
32
|
+
w.tokens > 0 ||
|
|
33
|
+
w.costUsd > 0 ||
|
|
34
|
+
w.accepted + w.dismissed > 0
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
26
38
|
function Usage({ e }: { e: ExperimentView }) {
|
|
39
|
+
if (!hasUsage(e))
|
|
40
|
+
return (
|
|
41
|
+
<p className="text-muted-foreground py-1 text-xs">
|
|
42
|
+
No usage yet. It shows here once it has run.
|
|
43
|
+
</p>
|
|
44
|
+
);
|
|
27
45
|
return (
|
|
28
|
-
<
|
|
29
|
-
<
|
|
30
|
-
<
|
|
31
|
-
<
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
46
|
+
<ScrollTable>
|
|
47
|
+
<table className="w-full text-xs tabular-nums">
|
|
48
|
+
<thead className="text-muted-foreground">
|
|
49
|
+
<tr>
|
|
50
|
+
<th className="py-1 text-left font-normal" />
|
|
51
|
+
<th className="py-1 text-right font-normal">Calls</th>
|
|
52
|
+
<th className="py-1 text-right font-normal">Tokens</th>
|
|
53
|
+
<th className="py-1 text-right font-normal">Cost</th>
|
|
54
|
+
{e.suggests && (
|
|
55
|
+
<th className="py-1 text-right font-normal">Accepted</th>
|
|
56
|
+
)}
|
|
57
|
+
</tr>
|
|
58
|
+
</thead>
|
|
59
|
+
<tbody>
|
|
60
|
+
{WINDOWS.map(([key, label]) => {
|
|
61
|
+
const w = e.usage[key];
|
|
62
|
+
return (
|
|
63
|
+
<tr key={key} className="border-border border-t">
|
|
64
|
+
<td className="py-1">{label}</td>
|
|
65
|
+
<td className="py-1 text-right">{w.calls}</td>
|
|
66
|
+
<td className="py-1 text-right">{formatTokens(w.tokens)}</td>
|
|
67
|
+
<td className="py-1 text-right">{usd(w.costUsd)}</td>
|
|
68
|
+
{e.suggests && (
|
|
69
|
+
<td className="py-1 text-right">
|
|
70
|
+
{pct(w.acceptRate)}
|
|
71
|
+
{w.accepted + w.dismissed > 0 &&
|
|
72
|
+
` (${w.accepted}/${w.accepted + w.dismissed})`}
|
|
73
|
+
</td>
|
|
74
|
+
)}
|
|
75
|
+
</tr>
|
|
76
|
+
);
|
|
77
|
+
})}
|
|
78
|
+
</tbody>
|
|
79
|
+
</table>
|
|
80
|
+
</ScrollTable>
|
|
61
81
|
);
|
|
62
82
|
}
|
|
63
83
|
|
|
@@ -197,7 +217,7 @@ export function ExperimentsSection() {
|
|
|
197
217
|
<SettingSection key={e.id}>
|
|
198
218
|
<SwitchSetting
|
|
199
219
|
id={`experiment-${e.id}`}
|
|
200
|
-
label={
|
|
220
|
+
label={e.status === "beta" ? `${e.name} · Beta` : e.name}
|
|
201
221
|
help={e.description}
|
|
202
222
|
checked={e.on}
|
|
203
223
|
locked={data.locked}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import { settingsUiActions } from "@/stores/settingsUi";
|
|
5
|
+
import { welcomeUiActions } from "@/stores/welcomeUi";
|
|
6
|
+
import { AppWideLimits } from "./AppWideLimits";
|
|
7
|
+
import { OwnerNameRow } from "./OwnerNameRow";
|
|
8
|
+
import { SettingRow, SettingSection } from "./SettingRow";
|
|
9
|
+
|
|
10
|
+
// The app-wide basics: who you are, alerts that apply everywhere, and the
|
|
11
|
+
// first-run setup again.
|
|
12
|
+
export function GeneralSection() {
|
|
13
|
+
return (
|
|
14
|
+
<div className="space-y-5">
|
|
15
|
+
<OwnerNameRow />
|
|
16
|
+
<AppWideLimits />
|
|
17
|
+
<SettingSection>
|
|
18
|
+
<SettingRow
|
|
19
|
+
id="first-run-setup"
|
|
20
|
+
label="First-run setup"
|
|
21
|
+
help="Agents, your first project, and a first session."
|
|
22
|
+
control={
|
|
23
|
+
<Button
|
|
24
|
+
size="sm"
|
|
25
|
+
variant="outline"
|
|
26
|
+
className="min-h-11 sm:min-h-8"
|
|
27
|
+
onClick={() => {
|
|
28
|
+
settingsUiActions.close();
|
|
29
|
+
welcomeUiActions.open("agents");
|
|
30
|
+
}}
|
|
31
|
+
>
|
|
32
|
+
Run setup again
|
|
33
|
+
</Button>
|
|
34
|
+
}
|
|
35
|
+
/>
|
|
36
|
+
</SettingSection>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
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 } from "vitest";
|
|
5
|
+
import { ListSection, ScrollTable } from "./ListSection";
|
|
6
|
+
|
|
7
|
+
(
|
|
8
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
9
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
let root: Root;
|
|
12
|
+
let host: HTMLElement;
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await act(async () => root.unmount());
|
|
15
|
+
host.remove();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const items = (n: number) => Array.from({ length: n }, (_, i) => `item ${i}`);
|
|
19
|
+
|
|
20
|
+
async function mount(
|
|
21
|
+
props: Partial<Parameters<typeof ListSection<string>>[0]>
|
|
22
|
+
) {
|
|
23
|
+
host = document.createElement("div");
|
|
24
|
+
document.body.appendChild(host);
|
|
25
|
+
root = createRoot(host);
|
|
26
|
+
await act(async () =>
|
|
27
|
+
root.render(
|
|
28
|
+
createElement(ListSection<string>, {
|
|
29
|
+
title: "Things",
|
|
30
|
+
items: [],
|
|
31
|
+
keyOf: (s) => s,
|
|
32
|
+
render: (s) => createElement("span", null, s),
|
|
33
|
+
empty: "Nothing here.",
|
|
34
|
+
...props,
|
|
35
|
+
})
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
const click = (text: string) =>
|
|
40
|
+
act(async () => {
|
|
41
|
+
[...host.querySelectorAll("button")]
|
|
42
|
+
.find((b) => b.textContent?.includes(text))!
|
|
43
|
+
.click();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("ListSection", () => {
|
|
47
|
+
it("shows the empty state and a zero count", async () => {
|
|
48
|
+
await mount({});
|
|
49
|
+
expect(host.textContent).toContain("Nothing here.");
|
|
50
|
+
expect(host.querySelector("h3")!.textContent).toBe("Things0");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("shows the count and only the first 10, then Show all", async () => {
|
|
54
|
+
await mount({ items: items(25) });
|
|
55
|
+
expect(host.querySelector("h3")!.textContent).toBe("Things25");
|
|
56
|
+
expect(host.querySelectorAll("li")).toHaveLength(10);
|
|
57
|
+
await click("Show all 25");
|
|
58
|
+
expect(host.querySelectorAll("li")).toHaveLength(25);
|
|
59
|
+
await click("Show fewer");
|
|
60
|
+
expect(host.querySelectorAll("li")).toHaveLength(10);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("has no Show all when everything fits", async () => {
|
|
64
|
+
await mount({ items: items(10) });
|
|
65
|
+
expect(host.querySelectorAll("button")).toHaveLength(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("keeps older items behind their own toggle and counts them", async () => {
|
|
69
|
+
await mount({ items: items(2), older: ["old a", "old b", "old c"] });
|
|
70
|
+
expect(host.querySelector("h3")!.textContent).toBe("Things5");
|
|
71
|
+
expect(host.textContent).not.toContain("old a");
|
|
72
|
+
await click("Older (3)");
|
|
73
|
+
expect(host.textContent).toContain("old c");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("ScrollTable", () => {
|
|
78
|
+
it("scrolls in its own box", async () => {
|
|
79
|
+
host = document.createElement("div");
|
|
80
|
+
document.body.appendChild(host);
|
|
81
|
+
root = createRoot(host);
|
|
82
|
+
await act(async () => root.render(createElement(ScrollTable, null, "x")));
|
|
83
|
+
expect(host.firstElementChild!.className).toContain("overflow-x-auto");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type ReactNode } from "react";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
|
|
6
|
+
export const LIST_SECTION_LIMIT = 10;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A long list on a Settings page: a title with the count, the first
|
|
10
|
+
* `limit` items, "Show all" for the rest, and an empty state. Older items
|
|
11
|
+
* can go in a group of their own (`older`), collapsed until asked for.
|
|
12
|
+
*/
|
|
13
|
+
export function ListSection<T>({
|
|
14
|
+
title,
|
|
15
|
+
help,
|
|
16
|
+
items,
|
|
17
|
+
keyOf,
|
|
18
|
+
render,
|
|
19
|
+
empty,
|
|
20
|
+
limit = LIST_SECTION_LIMIT,
|
|
21
|
+
older,
|
|
22
|
+
olderLabel = "Older",
|
|
23
|
+
action,
|
|
24
|
+
className,
|
|
25
|
+
}: {
|
|
26
|
+
title: string;
|
|
27
|
+
help?: string;
|
|
28
|
+
items: readonly T[];
|
|
29
|
+
keyOf: (item: T) => string;
|
|
30
|
+
render: (item: T) => ReactNode;
|
|
31
|
+
empty: string;
|
|
32
|
+
limit?: number;
|
|
33
|
+
// Items that are shown only under their own "Show <label>" toggle.
|
|
34
|
+
older?: readonly T[];
|
|
35
|
+
olderLabel?: string;
|
|
36
|
+
action?: ReactNode;
|
|
37
|
+
className?: string;
|
|
38
|
+
}) {
|
|
39
|
+
const [all, setAll] = useState(false);
|
|
40
|
+
const [showOlder, setShowOlder] = useState(false);
|
|
41
|
+
const shown = all ? items : items.slice(0, limit);
|
|
42
|
+
const hidden = items.length - shown.length;
|
|
43
|
+
const oldCount = older?.length ?? 0;
|
|
44
|
+
const count = items.length + oldCount;
|
|
45
|
+
return (
|
|
46
|
+
<section className={cn("mb-5", className)} data-list-section={title}>
|
|
47
|
+
<div className="flex min-h-8 items-center gap-2">
|
|
48
|
+
<h3 className="text-sm font-semibold">
|
|
49
|
+
{title}
|
|
50
|
+
<span className="text-muted-foreground ml-1.5 font-normal tabular-nums">
|
|
51
|
+
{count}
|
|
52
|
+
</span>
|
|
53
|
+
</h3>
|
|
54
|
+
{action && <div className="ml-auto">{action}</div>}
|
|
55
|
+
</div>
|
|
56
|
+
{help && <p className="text-muted-foreground mb-2 text-xs">{help}</p>}
|
|
57
|
+
{count === 0 ? (
|
|
58
|
+
<p className="bg-muted/40 text-muted-foreground rounded-xl px-3 py-4 text-sm">
|
|
59
|
+
{empty}
|
|
60
|
+
</p>
|
|
61
|
+
) : (
|
|
62
|
+
<div className="bg-muted/40 overflow-hidden rounded-xl">
|
|
63
|
+
<ul className="divide-foreground/5 divide-y">
|
|
64
|
+
{shown.map((item) => (
|
|
65
|
+
<li key={keyOf(item)}>{render(item)}</li>
|
|
66
|
+
))}
|
|
67
|
+
</ul>
|
|
68
|
+
{hidden > 0 && (
|
|
69
|
+
<ShowButton onClick={() => setAll(true)}>
|
|
70
|
+
Show all {items.length}
|
|
71
|
+
</ShowButton>
|
|
72
|
+
)}
|
|
73
|
+
{all && items.length > limit && (
|
|
74
|
+
<ShowButton onClick={() => setAll(false)}>Show fewer</ShowButton>
|
|
75
|
+
)}
|
|
76
|
+
{oldCount > 0 && (
|
|
77
|
+
<>
|
|
78
|
+
<ShowButton onClick={() => setShowOlder((v) => !v)}>
|
|
79
|
+
{showOlder
|
|
80
|
+
? `Hide ${olderLabel}`
|
|
81
|
+
: `${olderLabel} (${oldCount})`}
|
|
82
|
+
</ShowButton>
|
|
83
|
+
{showOlder && (
|
|
84
|
+
<ul className="divide-foreground/5 divide-y">
|
|
85
|
+
{older!.map((item) => (
|
|
86
|
+
<li key={keyOf(item)}>{render(item)}</li>
|
|
87
|
+
))}
|
|
88
|
+
</ul>
|
|
89
|
+
)}
|
|
90
|
+
</>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
</section>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function ShowButton({
|
|
99
|
+
onClick,
|
|
100
|
+
children,
|
|
101
|
+
}: {
|
|
102
|
+
onClick: () => void;
|
|
103
|
+
children: ReactNode;
|
|
104
|
+
}) {
|
|
105
|
+
return (
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
onClick={onClick}
|
|
109
|
+
className="text-primary border-foreground/5 hover:bg-muted/60 min-h-11 w-full border-t px-3 text-left text-sm"
|
|
110
|
+
>
|
|
111
|
+
{children}
|
|
112
|
+
</button>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A table that scrolls sideways in its own box, not the page. */
|
|
117
|
+
export function ScrollTable({
|
|
118
|
+
children,
|
|
119
|
+
className,
|
|
120
|
+
}: {
|
|
121
|
+
children: ReactNode;
|
|
122
|
+
className?: string;
|
|
123
|
+
}) {
|
|
124
|
+
return (
|
|
125
|
+
<div
|
|
126
|
+
className={cn("max-w-full overflow-x-auto", className)}
|
|
127
|
+
data-scroll-table
|
|
128
|
+
>
|
|
129
|
+
{children}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|