@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
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import { Button } from "@/components/ui/button";
|
|
4
4
|
import { PhoneNotifyForm } from "@/components/PhoneNotify/PhoneNotifyForm";
|
|
5
5
|
import type { NotificationSettings } from "@/lib/notifications";
|
|
6
|
-
import {
|
|
6
|
+
import { SettingRow, SettingSection } from "./SettingRow";
|
|
7
|
+
import { SwitchSetting } from "./settings-fields";
|
|
7
8
|
|
|
8
9
|
// This browser's alerts live in the page (useNotifications), so they come in
|
|
9
10
|
// as props.
|
|
@@ -21,44 +22,45 @@ export function NotificationsSection({
|
|
|
21
22
|
}) {
|
|
22
23
|
return (
|
|
23
24
|
<div className="space-y-5">
|
|
24
|
-
<
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
<SettingSection title="This browser">
|
|
26
|
+
<SwitchSetting
|
|
27
|
+
id="notify-sound"
|
|
28
|
+
label="Sound"
|
|
29
|
+
help="Play a sound when a session needs you or hits an error."
|
|
29
30
|
checked={browser.settings.sound}
|
|
30
|
-
|
|
31
|
+
defaultValue={true}
|
|
32
|
+
onSave={async (sound) => browser.updateSettings({ sound })}
|
|
31
33
|
/>
|
|
32
34
|
{browser.permissionGranted ? (
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
<SwitchSetting
|
|
36
|
+
id="notify-browser"
|
|
37
|
+
label="Browser alerts"
|
|
38
|
+
help="Show a system notification when a session needs you and AgentOS isn't in front."
|
|
36
39
|
checked={browser.settings.browserNotifications}
|
|
37
|
-
|
|
40
|
+
onSave={async (browserNotifications) =>
|
|
38
41
|
browser.updateSettings({ browserNotifications })
|
|
39
42
|
}
|
|
40
43
|
/>
|
|
41
44
|
) : (
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
</div>
|
|
45
|
+
<SettingRow
|
|
46
|
+
id="notify-browser"
|
|
47
|
+
label="Browser alerts"
|
|
48
|
+
help="This browser hasn't allowed AgentOS to show notifications."
|
|
49
|
+
control={
|
|
50
|
+
<Button
|
|
51
|
+
size="sm"
|
|
52
|
+
variant="outline"
|
|
53
|
+
className="h-11 md:h-9"
|
|
54
|
+
onClick={() => void browser.requestPermission()}
|
|
55
|
+
>
|
|
56
|
+
Allow
|
|
57
|
+
</Button>
|
|
58
|
+
}
|
|
59
|
+
/>
|
|
58
60
|
)}
|
|
59
|
-
</
|
|
61
|
+
</SettingSection>
|
|
60
62
|
<div className="space-y-2">
|
|
61
|
-
<h3 className="text-sm font-
|
|
63
|
+
<h3 className="text-sm font-semibold">Your phone</h3>
|
|
62
64
|
<PhoneNotifyForm />
|
|
63
65
|
</div>
|
|
64
66
|
</div>
|
|
@@ -18,6 +18,7 @@ export function PickerSetting<T>({
|
|
|
18
18
|
defaultValue,
|
|
19
19
|
hint,
|
|
20
20
|
error,
|
|
21
|
+
resetLabel,
|
|
21
22
|
render,
|
|
22
23
|
below,
|
|
23
24
|
}: {
|
|
@@ -30,6 +31,8 @@ export function PickerSetting<T>({
|
|
|
30
31
|
defaultValue?: T;
|
|
31
32
|
hint?: string;
|
|
32
33
|
error?: string | null;
|
|
34
|
+
// What Reset goes back to, in words.
|
|
35
|
+
resetLabel?: string;
|
|
33
36
|
render: (picker: { value: T; commit: (next: T) => void }) => ReactNode;
|
|
34
37
|
below?: ReactNode;
|
|
35
38
|
}) {
|
|
@@ -40,7 +43,8 @@ export function PickerSetting<T>({
|
|
|
40
43
|
label={label}
|
|
41
44
|
help={help}
|
|
42
45
|
hint={hint}
|
|
43
|
-
error={error}
|
|
46
|
+
error={error ?? auto.error}
|
|
47
|
+
resetLabel={resetLabel}
|
|
44
48
|
status={auto.status}
|
|
45
49
|
onUndo={auto.undo}
|
|
46
50
|
onReset={
|
|
@@ -16,6 +16,10 @@ export function matchesQuery(query: string, ...text: (string | undefined)[]) {
|
|
|
16
16
|
return q.split(/\s+/).every((w) => haystack.includes(w));
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// One padding and one grid for every row: label left, control right.
|
|
20
|
+
const ROW =
|
|
21
|
+
"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.5 first:border-t-0";
|
|
22
|
+
|
|
19
23
|
/**
|
|
20
24
|
* One setting on a Settings page, 44px at least: label and one-line help on
|
|
21
25
|
* the left, the control on the right (below the label on a phone), and a meta
|
|
@@ -33,11 +37,12 @@ export function SettingRow({
|
|
|
33
37
|
status = "idle",
|
|
34
38
|
onUndo,
|
|
35
39
|
onReset,
|
|
40
|
+
resetLabel,
|
|
36
41
|
below,
|
|
37
42
|
}: {
|
|
38
43
|
id: string;
|
|
39
44
|
label: string;
|
|
40
|
-
help?:
|
|
45
|
+
help?: ReactNode;
|
|
41
46
|
control: ReactNode;
|
|
42
47
|
controlClassName?: string;
|
|
43
48
|
hint?: string;
|
|
@@ -46,15 +51,15 @@ export function SettingRow({
|
|
|
46
51
|
onUndo?: () => void;
|
|
47
52
|
// Given only while the value differs from the default.
|
|
48
53
|
onReset?: () => void;
|
|
54
|
+
// What Reset goes back to, in words ("squash", "on", "200k").
|
|
55
|
+
resetLabel?: string;
|
|
49
56
|
below?: ReactNode;
|
|
50
57
|
}) {
|
|
51
58
|
const query = useContext(SettingsQueryContext);
|
|
52
|
-
if (!matchesQuery(query, label, help))
|
|
59
|
+
if (!matchesQuery(query, label, typeof help === "string" ? help : undefined))
|
|
60
|
+
return null;
|
|
53
61
|
return (
|
|
54
|
-
<div
|
|
55
|
-
data-setting={id}
|
|
56
|
-
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"
|
|
57
|
-
>
|
|
62
|
+
<div data-setting={id} className={ROW}>
|
|
58
63
|
<div className="min-w-0">
|
|
59
64
|
<label htmlFor={id} className="block text-sm font-medium">
|
|
60
65
|
{label}
|
|
@@ -108,7 +113,7 @@ export function SettingRow({
|
|
|
108
113
|
onClick={onReset}
|
|
109
114
|
className="text-primary -my-3 -mr-1 min-h-11 px-1 hover:underline"
|
|
110
115
|
>
|
|
111
|
-
Reset
|
|
116
|
+
{resetLabel ? `Reset to ${resetLabel}` : "Reset"}
|
|
112
117
|
</button>
|
|
113
118
|
)}
|
|
114
119
|
</span>
|
|
@@ -158,7 +163,7 @@ export function SettingBlock({
|
|
|
158
163
|
return (
|
|
159
164
|
<div
|
|
160
165
|
data-setting={id}
|
|
161
|
-
className="border-foreground/5 space-y-1.5 border-t px-3 py-2 first:border-t-0"
|
|
166
|
+
className="border-foreground/5 space-y-1.5 border-t px-3 py-2.5 first:border-t-0"
|
|
162
167
|
>
|
|
163
168
|
{label && <p className="text-sm font-medium">{label}</p>}
|
|
164
169
|
{children}
|
|
@@ -28,9 +28,14 @@ const stub = (name: string) =>
|
|
|
28
28
|
vi.mock("@/components/Merge/GlobalMergeSection", () => ({
|
|
29
29
|
GlobalMergeSection: stub("merging"),
|
|
30
30
|
}));
|
|
31
|
-
vi.mock("./
|
|
32
|
-
|
|
31
|
+
vi.mock("./ApprovalsSection", () => ({
|
|
32
|
+
ApprovalsSection: stub("approvals"),
|
|
33
33
|
}));
|
|
34
|
+
vi.mock("./GeneralSection", () => ({ GeneralSection: stub("general") }));
|
|
35
|
+
vi.mock("@/components/Health/HealthPanel", () => ({
|
|
36
|
+
HealthPanel: stub("health"),
|
|
37
|
+
}));
|
|
38
|
+
vi.mock("@/components/Recipes", () => ({ RecipesPanel: stub("recipes") }));
|
|
34
39
|
vi.mock("@/components/Workspaces", () => ({
|
|
35
40
|
WorkspaceSettings: stub("workspaces"),
|
|
36
41
|
}));
|
|
@@ -128,8 +133,8 @@ describe("SettingsDialog", () => {
|
|
|
128
133
|
it("shows the first section beside the list when none is chosen", async () => {
|
|
129
134
|
await mount();
|
|
130
135
|
await act(async () => settingsUiActions.open());
|
|
131
|
-
expect(body()).toContain("body:
|
|
132
|
-
expect(navButton("
|
|
136
|
+
expect(body()).toContain("body:general");
|
|
137
|
+
expect(navButton("General").getAttribute("aria-current")).toBe("page");
|
|
133
138
|
});
|
|
134
139
|
|
|
135
140
|
it("goes back to the list on a phone", async () => {
|
|
@@ -198,6 +203,28 @@ describe("SettingsDialog", () => {
|
|
|
198
203
|
expect(body()).toContain("body:workspaces");
|
|
199
204
|
});
|
|
200
205
|
|
|
206
|
+
it("sends an old ?settings=setup link to Health and rewrites the address", async () => {
|
|
207
|
+
await mount("/?session=s1&settings=setup");
|
|
208
|
+
await flush();
|
|
209
|
+
expect(settingsUi).toMatchObject({ open: true, section: "health" });
|
|
210
|
+
expect(body()).toContain("body:health");
|
|
211
|
+
expect(search()).toBe("?session=s1&settings=health");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("names each section once in the list", async () => {
|
|
215
|
+
await mount();
|
|
216
|
+
await act(async () => settingsUiActions.open());
|
|
217
|
+
const labels = [
|
|
218
|
+
...document.querySelectorAll(
|
|
219
|
+
'nav[aria-label="Settings sections"] button'
|
|
220
|
+
),
|
|
221
|
+
].map((b) => b.querySelector("span span")?.textContent);
|
|
222
|
+
expect(new Set(labels).size).toBe(labels.length);
|
|
223
|
+
expect(labels).toContain("Approvals");
|
|
224
|
+
expect(labels).not.toContain("No approvals");
|
|
225
|
+
expect(labels).not.toContain("Setup & health");
|
|
226
|
+
});
|
|
227
|
+
|
|
201
228
|
it("drops the section from the address when closed", async () => {
|
|
202
229
|
await mount("/?session=s1&settings=merging");
|
|
203
230
|
await act(async () => settingsUiActions.close());
|
|
@@ -16,8 +16,8 @@ import {
|
|
|
16
16
|
LayoutGrid,
|
|
17
17
|
Search,
|
|
18
18
|
Link2,
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
ShieldCheck,
|
|
20
|
+
SlidersHorizontal,
|
|
21
21
|
FlaskConical,
|
|
22
22
|
RefreshCw,
|
|
23
23
|
Smartphone,
|
|
@@ -52,8 +52,8 @@ import { RecipesPanel } from "@/components/Recipes";
|
|
|
52
52
|
import { settingsUi, settingsUiActions } from "@/stores/settingsUi";
|
|
53
53
|
import { ContextSection } from "./ContextSection";
|
|
54
54
|
import { SettingsQueryContext } from "./SettingRow";
|
|
55
|
-
import {
|
|
56
|
-
import {
|
|
55
|
+
import { ApprovalsSection } from "./ApprovalsSection";
|
|
56
|
+
import { GeneralSection } from "./GeneralSection";
|
|
57
57
|
import {
|
|
58
58
|
NotificationsSection,
|
|
59
59
|
type BrowserNotifications,
|
|
@@ -63,15 +63,20 @@ export const SECTION_INFO: Record<
|
|
|
63
63
|
SettingsSection,
|
|
64
64
|
{ label: string; hint: string; icon: LucideIcon }
|
|
65
65
|
> = {
|
|
66
|
+
general: {
|
|
67
|
+
label: "General",
|
|
68
|
+
hint: "Your name, alerts, first-run setup",
|
|
69
|
+
icon: SlidersHorizontal,
|
|
70
|
+
},
|
|
66
71
|
merging: {
|
|
67
72
|
label: "Merging",
|
|
68
|
-
hint: "
|
|
73
|
+
hint: "Merge method, branch and worktree clean-up",
|
|
69
74
|
icon: GitMerge,
|
|
70
75
|
},
|
|
71
76
|
approvals: {
|
|
72
|
-
label: "
|
|
73
|
-
hint: "
|
|
74
|
-
icon:
|
|
77
|
+
label: "Approvals",
|
|
78
|
+
hint: "No approvals, merge approval, what was approved for you",
|
|
79
|
+
icon: ShieldCheck,
|
|
75
80
|
},
|
|
76
81
|
workspaces: {
|
|
77
82
|
label: "Workspaces",
|
|
@@ -125,14 +130,9 @@ export const SECTION_INFO: Record<
|
|
|
125
130
|
},
|
|
126
131
|
health: {
|
|
127
132
|
label: "Health",
|
|
128
|
-
hint: "What the watchers caught,
|
|
133
|
+
hint: "What the watchers caught, thresholds, clean-up, this machine",
|
|
129
134
|
icon: HeartPulse,
|
|
130
135
|
},
|
|
131
|
-
setup: {
|
|
132
|
-
label: "Setup & health",
|
|
133
|
-
hint: "What this machine has, agents, first-run setup",
|
|
134
|
-
icon: ListChecks,
|
|
135
|
-
},
|
|
136
136
|
updates: {
|
|
137
137
|
label: "Updates",
|
|
138
138
|
hint: "Version, check for updates, the update banner, tips",
|
|
@@ -153,10 +153,12 @@ function SectionBody({
|
|
|
153
153
|
browser: BrowserNotifications;
|
|
154
154
|
}) {
|
|
155
155
|
switch (section) {
|
|
156
|
+
case "general":
|
|
157
|
+
return <GeneralSection />;
|
|
156
158
|
case "merging":
|
|
157
159
|
return <GlobalMergeSection />;
|
|
158
160
|
case "approvals":
|
|
159
|
-
return <
|
|
161
|
+
return <ApprovalsSection />;
|
|
160
162
|
case "workspaces":
|
|
161
163
|
return <WorkspaceSettings />;
|
|
162
164
|
case "context":
|
|
@@ -179,8 +181,6 @@ function SectionBody({
|
|
|
179
181
|
return <AuditsPanel />;
|
|
180
182
|
case "health":
|
|
181
183
|
return <HealthPanel />;
|
|
182
|
-
case "setup":
|
|
183
|
-
return <SetupSection />;
|
|
184
184
|
case "updates":
|
|
185
185
|
return <UpdatesSection />;
|
|
186
186
|
case "experiments":
|
|
@@ -234,6 +234,11 @@ export const SettingsDialog = memo(function SettingsDialog({
|
|
|
234
234
|
}) {
|
|
235
235
|
const { open, section } = useSnapshot(settingsUi);
|
|
236
236
|
useSettingsUrl();
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
if (!open) return;
|
|
239
|
+
document.documentElement.setAttribute("data-settings-open", "");
|
|
240
|
+
return () => document.documentElement.removeAttribute("data-settings-open");
|
|
241
|
+
}, [open]);
|
|
237
242
|
// A wide screen always shows a section; a phone shows the list first.
|
|
238
243
|
const shown = section ?? SETTINGS_SECTIONS[0];
|
|
239
244
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { cn } from "@/lib/utils";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a select trigger or item shows: a short label, and the resolved
|
|
5
|
+
* value (a model id, say) under it in secondary text. Each line ends in an
|
|
6
|
+
* ellipsis at the edge, never cut through a letter.
|
|
7
|
+
*/
|
|
8
|
+
export function ValueLabel({
|
|
9
|
+
label,
|
|
10
|
+
detail,
|
|
11
|
+
className,
|
|
12
|
+
}: {
|
|
13
|
+
label: string;
|
|
14
|
+
detail?: string;
|
|
15
|
+
className?: string;
|
|
16
|
+
}) {
|
|
17
|
+
return (
|
|
18
|
+
<span
|
|
19
|
+
className={cn("flex min-w-0 flex-col text-left leading-tight", className)}
|
|
20
|
+
>
|
|
21
|
+
<span className="truncate">{label}</span>
|
|
22
|
+
{detail && (
|
|
23
|
+
<span className="text-muted-foreground truncate text-xs">{detail}</span>
|
|
24
|
+
)}
|
|
25
|
+
</span>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The trigger classes that let a ValueLabel use two lines.
|
|
30
|
+
export const TWO_LINE_TRIGGER =
|
|
31
|
+
"h-auto min-h-11 py-1.5 md:min-h-9 [&>span]:line-clamp-none";
|
|
@@ -4,7 +4,13 @@ import { createRoot, type Root } from "react-dom/client";
|
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
|
|
6
6
|
// Radix dispatches its own events; they must be jsdom's, not Node's.
|
|
7
|
-
for (const key of [
|
|
7
|
+
for (const key of [
|
|
8
|
+
"Event",
|
|
9
|
+
"CustomEvent",
|
|
10
|
+
"FocusEvent",
|
|
11
|
+
"HTMLButtonElement",
|
|
12
|
+
"HTMLFormElement",
|
|
13
|
+
])
|
|
8
14
|
Object.defineProperty(globalThis, key, {
|
|
9
15
|
value: (window as unknown as Record<string, unknown>)[key],
|
|
10
16
|
configurable: true,
|
|
@@ -15,7 +21,7 @@ for (const key of ["Event", "CustomEvent", "FocusEvent", "HTMLButtonElement"])
|
|
|
15
21
|
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
16
22
|
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
|
|
17
23
|
|
|
18
|
-
const { NumberSetting } = await import("./settings-fields");
|
|
24
|
+
const { NumberSetting, SwitchSetting } = await import("./settings-fields");
|
|
19
25
|
|
|
20
26
|
let root: Root;
|
|
21
27
|
let host: HTMLElement;
|
|
@@ -101,3 +107,76 @@ describe("NumberSetting", () => {
|
|
|
101
107
|
expect(input.value).toBe("7");
|
|
102
108
|
});
|
|
103
109
|
});
|
|
110
|
+
|
|
111
|
+
async function render(el: ReturnType<typeof createElement>) {
|
|
112
|
+
host = document.createElement("div");
|
|
113
|
+
document.body.appendChild(host);
|
|
114
|
+
root = createRoot(host);
|
|
115
|
+
await act(async () => root.render(el));
|
|
116
|
+
}
|
|
117
|
+
const noop = () => Promise.resolve();
|
|
118
|
+
|
|
119
|
+
describe("Reset and default text", () => {
|
|
120
|
+
const num = (value: number, extra = {}) =>
|
|
121
|
+
createElement(NumberSetting, {
|
|
122
|
+
id: "n",
|
|
123
|
+
label: "Days",
|
|
124
|
+
value,
|
|
125
|
+
defaultValue: 7,
|
|
126
|
+
min: 1,
|
|
127
|
+
max: 30,
|
|
128
|
+
unit: "days",
|
|
129
|
+
onSave: noop,
|
|
130
|
+
...extra,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("shows no default text or Reset while the value is the default", async () => {
|
|
134
|
+
await render(num(7));
|
|
135
|
+
expect(host.textContent).not.toMatch(/default|Reset/i);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("offers 'Reset to X' once it differs", async () => {
|
|
139
|
+
await render(num(9));
|
|
140
|
+
expect(host.textContent).toContain("Reset to 7");
|
|
141
|
+
expect(host.textContent).not.toContain("Default:");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("puts the range in the help text and the unit right after the number", async () => {
|
|
145
|
+
await render(num(7));
|
|
146
|
+
expect(host.textContent).toContain("Allowed: 1–30 days.");
|
|
147
|
+
const input = host.querySelector("input")!;
|
|
148
|
+
expect(input.nextElementSibling?.textContent).toBe("days");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("disables and empties the field while its switch is off", async () => {
|
|
152
|
+
await render(num(0, { optional: { off: 0, on: 7 } }));
|
|
153
|
+
const input = host.querySelector("input")!;
|
|
154
|
+
expect(input.disabled).toBe(true);
|
|
155
|
+
expect(input.value).toBe("");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("shows Reset to on/off for a switch only when it differs", async () => {
|
|
159
|
+
await render(
|
|
160
|
+
createElement(SwitchSetting, {
|
|
161
|
+
id: "s",
|
|
162
|
+
label: "Sound",
|
|
163
|
+
checked: true,
|
|
164
|
+
defaultValue: true,
|
|
165
|
+
onSave: noop,
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
expect(host.textContent).not.toMatch(/Reset/);
|
|
169
|
+
await act(async () => root.unmount());
|
|
170
|
+
host.remove();
|
|
171
|
+
await render(
|
|
172
|
+
createElement(SwitchSetting, {
|
|
173
|
+
id: "s",
|
|
174
|
+
label: "Sound",
|
|
175
|
+
checked: false,
|
|
176
|
+
defaultValue: true,
|
|
177
|
+
onSave: noop,
|
|
178
|
+
})
|
|
179
|
+
);
|
|
180
|
+
expect(host.textContent).toContain("Reset to on");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -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
|
);
|