@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,33 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { NextRequest } from "next/server";
|
|
3
|
+
import { refreshSettings } from "@/lib/chat/refresh";
|
|
4
|
+
import { SETTING_DEFAULTS } from "@/lib/settings/defaults";
|
|
5
|
+
import { GET, PATCH } from "./route";
|
|
6
|
+
|
|
7
|
+
const patch = (body: unknown) =>
|
|
8
|
+
PATCH(
|
|
9
|
+
new NextRequest("http://localhost/api/fresh-start", {
|
|
10
|
+
method: "PATCH",
|
|
11
|
+
body: JSON.stringify(body),
|
|
12
|
+
})
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
describe("PATCH /api/fresh-start", () => {
|
|
16
|
+
it("changes only the field it's sent", async () => {
|
|
17
|
+
const res = await patch({ compactTokens: 120_000 });
|
|
18
|
+
expect(res.status).toBe(200);
|
|
19
|
+
expect(refreshSettings()).toEqual({
|
|
20
|
+
...SETTING_DEFAULTS.fresh,
|
|
21
|
+
compactTokens: 120_000,
|
|
22
|
+
});
|
|
23
|
+
expect(await (await GET()).json()).toMatchObject({
|
|
24
|
+
interactiveAutoCompactTokens: 300_000,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("rejects an out-of-range value and saves nothing", async () => {
|
|
29
|
+
const res = await patch({ taskTokens: 10_000 });
|
|
30
|
+
expect(res.status).toBe(400);
|
|
31
|
+
expect(refreshSettings().taskTokens).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -10,6 +10,9 @@ export async function GET() {
|
|
|
10
10
|
return NextResponse.json(refreshSettings());
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// One field at a time, or several: only those named change.
|
|
14
|
+
export const PATCH = PUT;
|
|
15
|
+
|
|
13
16
|
export async function PUT(request: NextRequest) {
|
|
14
17
|
try {
|
|
15
18
|
const body = await request.json();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { useState } from "react";
|
|
3
4
|
import { Laptop, Smartphone, Tablet, Trash2 } from "lucide-react";
|
|
4
5
|
import { Button } from "@/components/ui/button";
|
|
5
6
|
import type { ListedDevice } from "@/lib/security/devices";
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
useSendTestPush,
|
|
10
11
|
useSetCanApprove,
|
|
11
12
|
} from "@/data/devices";
|
|
13
|
+
import { InlineConfirm } from "@/components/Settings/InlineConfirm";
|
|
12
14
|
import { Switch } from "@/components/ui/switch";
|
|
13
15
|
|
|
14
16
|
// SQLite's datetime('now') is UTC without a zone.
|
|
@@ -53,87 +55,99 @@ export function DeviceRow({
|
|
|
53
55
|
: `Test failed: ${tested.error}`
|
|
54
56
|
: null;
|
|
55
57
|
|
|
58
|
+
const [signingOut, setSigningOut] = useState(false);
|
|
59
|
+
// The device in use asks first; any other goes at once.
|
|
56
60
|
const onRevoke = () => {
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
!window.confirm("This is the device you're using. Sign it out?")
|
|
60
|
-
)
|
|
61
|
-
return;
|
|
62
|
-
revoke.mutate(device.id);
|
|
61
|
+
if (isCurrent) setSigningOut(true);
|
|
62
|
+
else revoke.mutate(device.id);
|
|
63
63
|
};
|
|
64
64
|
|
|
65
65
|
return (
|
|
66
|
-
<div className="bg-muted/40
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
<p className="text-muted-foreground truncate text-xs">
|
|
78
|
-
{lastSeen(device.last_seen_at)}
|
|
79
|
-
{device.last_address ? ` · ${device.last_address}` : ""}
|
|
80
|
-
</p>
|
|
81
|
-
{device.push.enabled && (
|
|
82
|
-
<p className="text-muted-foreground truncate text-xs">
|
|
83
|
-
Push on ·{" "}
|
|
84
|
-
{device.push.categories
|
|
85
|
-
.map((c) => CATEGORIES[c].label)
|
|
86
|
-
.join(", ") || "nothing selected"}
|
|
66
|
+
<div className="bg-muted/40 rounded-lg px-3 py-2.5">
|
|
67
|
+
<div className="flex items-center gap-3">
|
|
68
|
+
<DeviceIcon ua={device.user_agent} />
|
|
69
|
+
<div className="min-w-0 flex-1">
|
|
70
|
+
<p className="flex items-center gap-2 truncate text-sm font-medium">
|
|
71
|
+
{device.name}
|
|
72
|
+
{isCurrent && (
|
|
73
|
+
<span className="bg-primary/15 text-primary rounded-full px-1.5 py-0.5 text-[10px]">
|
|
74
|
+
this device
|
|
75
|
+
</span>
|
|
76
|
+
)}
|
|
87
77
|
</p>
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
className="text-destructive truncate text-xs"
|
|
92
|
-
title={device.push.lastError}
|
|
93
|
-
>
|
|
94
|
-
Push error: {device.push.lastError}
|
|
78
|
+
<p className="text-muted-foreground truncate text-xs">
|
|
79
|
+
{lastSeen(device.last_seen_at)}
|
|
80
|
+
{device.last_address ? ` · ${device.last_address}` : ""}
|
|
95
81
|
</p>
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
82
|
+
{device.push.enabled && (
|
|
83
|
+
<p className="text-muted-foreground truncate text-xs">
|
|
84
|
+
Push on ·{" "}
|
|
85
|
+
{device.push.categories
|
|
86
|
+
.map((c) => CATEGORIES[c].label)
|
|
87
|
+
.join(", ") || "nothing selected"}
|
|
88
|
+
</p>
|
|
89
|
+
)}
|
|
90
|
+
{device.push.lastError && (
|
|
91
|
+
<p
|
|
92
|
+
className="text-destructive truncate text-xs"
|
|
93
|
+
title={device.push.lastError}
|
|
94
|
+
>
|
|
95
|
+
Push error: {device.push.lastError}
|
|
96
|
+
</p>
|
|
97
|
+
)}
|
|
98
|
+
{testNote && (
|
|
99
|
+
<p
|
|
100
|
+
className={`truncate text-xs ${tested?.status === "sent" ? "text-muted-foreground" : "text-destructive"}`}
|
|
101
|
+
role="status"
|
|
102
|
+
>
|
|
103
|
+
{testNote}
|
|
104
|
+
</p>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
{device.push.enabled && (
|
|
108
|
+
<Button
|
|
109
|
+
variant="outline"
|
|
110
|
+
size="sm"
|
|
111
|
+
className="h-11 shrink-0 sm:h-8"
|
|
112
|
+
onClick={() => sendTest.mutate(device.id)}
|
|
113
|
+
disabled={sendTest.isPending}
|
|
101
114
|
>
|
|
102
|
-
{
|
|
103
|
-
</
|
|
115
|
+
{sendTest.isPending ? "Sending…" : "Send test"}
|
|
116
|
+
</Button>
|
|
104
117
|
)}
|
|
105
|
-
|
|
106
|
-
|
|
118
|
+
<label className="text-muted-foreground flex min-h-11 shrink-0 items-center gap-2 text-xs sm:min-h-8">
|
|
119
|
+
Can approve
|
|
120
|
+
<Switch
|
|
121
|
+
checked={!!device.can_approve}
|
|
122
|
+
disabled={!canManage || setCanApprove.isPending}
|
|
123
|
+
onCheckedChange={(on) =>
|
|
124
|
+
setCanApprove.mutate({ id: device.id, canApprove: on })
|
|
125
|
+
}
|
|
126
|
+
/>
|
|
127
|
+
</label>
|
|
107
128
|
<Button
|
|
108
|
-
variant="
|
|
109
|
-
size="sm"
|
|
110
|
-
className="h-11
|
|
111
|
-
onClick={
|
|
112
|
-
disabled={
|
|
129
|
+
variant="ghost"
|
|
130
|
+
size="icon-sm"
|
|
131
|
+
className="h-11 w-11 sm:h-8 sm:w-8"
|
|
132
|
+
onClick={onRevoke}
|
|
133
|
+
disabled={revoke.isPending}
|
|
134
|
+
aria-label={`Remove ${device.name}`}
|
|
113
135
|
>
|
|
114
|
-
|
|
136
|
+
<Trash2 className="h-4 w-4" />
|
|
115
137
|
</Button>
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
138
|
+
</div>
|
|
139
|
+
{signingOut && (
|
|
140
|
+
<InlineConfirm
|
|
141
|
+
label={`Sign out ${device.name}`}
|
|
142
|
+
message="This is the device you're using. Sign it out?"
|
|
143
|
+
confirmLabel="Sign out"
|
|
144
|
+
onConfirm={() => {
|
|
145
|
+
setSigningOut(false);
|
|
146
|
+
revoke.mutate(device.id);
|
|
147
|
+
}}
|
|
148
|
+
onCancel={() => setSigningOut(false)}
|
|
125
149
|
/>
|
|
126
|
-
|
|
127
|
-
<Button
|
|
128
|
-
variant="ghost"
|
|
129
|
-
size="icon-sm"
|
|
130
|
-
className="h-11 w-11 sm:h-8 sm:w-8"
|
|
131
|
-
onClick={onRevoke}
|
|
132
|
-
disabled={revoke.isPending}
|
|
133
|
-
aria-label={`Remove ${device.name}`}
|
|
134
|
-
>
|
|
135
|
-
<Trash2 className="h-4 w-4" />
|
|
136
|
-
</Button>
|
|
150
|
+
)}
|
|
137
151
|
</div>
|
|
138
152
|
);
|
|
139
153
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
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, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
for (const key of ["Event", "CustomEvent", "FocusEvent", "HTMLButtonElement"])
|
|
7
|
+
Object.defineProperty(globalThis, key, {
|
|
8
|
+
value: (window as unknown as Record<string, unknown>)[key],
|
|
9
|
+
configurable: true,
|
|
10
|
+
writable: true,
|
|
11
|
+
});
|
|
12
|
+
(
|
|
13
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
14
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
15
|
+
|
|
16
|
+
const state = vi.hoisted(() => ({ revoke: vi.fn() }));
|
|
17
|
+
vi.stubGlobal("location", { hostname: "x" });
|
|
18
|
+
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
|
|
19
|
+
vi.mock("@/data/presence", () => ({
|
|
20
|
+
NO_PASSKEYS_HERE: "no",
|
|
21
|
+
passkeysHere: () => true,
|
|
22
|
+
}));
|
|
23
|
+
vi.mock("@/data/presence/passkeys", () => ({
|
|
24
|
+
usePasskeysQuery: () => ({
|
|
25
|
+
data: {
|
|
26
|
+
bootstrapped: true,
|
|
27
|
+
passkeys: [
|
|
28
|
+
{
|
|
29
|
+
id: "k1",
|
|
30
|
+
name: "Laptop",
|
|
31
|
+
rp_id: "x",
|
|
32
|
+
type: "browser",
|
|
33
|
+
created_at: "2026-10-01 10:00:00",
|
|
34
|
+
registered_from: null,
|
|
35
|
+
last_used_at: null,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
useAddPasskey: () => ({ mutate: vi.fn(), isPending: false }),
|
|
41
|
+
useEnrollCode: () => ({ mutate: vi.fn(), isPending: false }),
|
|
42
|
+
useRevokePasskey: () => ({ mutate: state.revoke, isPending: false }),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
const { PasskeysSection } = await import("./PasskeysSection");
|
|
46
|
+
|
|
47
|
+
let root: Root;
|
|
48
|
+
let host: HTMLDivElement;
|
|
49
|
+
beforeEach(async () => {
|
|
50
|
+
state.revoke.mockReset();
|
|
51
|
+
host = document.createElement("div");
|
|
52
|
+
document.body.appendChild(host);
|
|
53
|
+
root = createRoot(host);
|
|
54
|
+
await act(async () =>
|
|
55
|
+
root.render(createElement(PasskeysSection, { open: true }))
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
afterEach(async () => {
|
|
59
|
+
await act(async () => root.unmount());
|
|
60
|
+
host.remove();
|
|
61
|
+
vi.restoreAllMocks();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const click = (el: Element | null | undefined) =>
|
|
65
|
+
act(async () => {
|
|
66
|
+
el?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
|
67
|
+
});
|
|
68
|
+
const button = (name: string) =>
|
|
69
|
+
[...host.querySelectorAll("button")].find((b) => b.textContent === name);
|
|
70
|
+
|
|
71
|
+
describe("PasskeysSection revoke", () => {
|
|
72
|
+
it("asks inline, not with a browser dialog, and revokes only on confirm", async () => {
|
|
73
|
+
const dialog = vi.spyOn(window, "confirm");
|
|
74
|
+
await click(host.querySelector('[aria-label="Revoke Laptop"]'));
|
|
75
|
+
expect(host.querySelector('[role="alertdialog"]')).not.toBeNull();
|
|
76
|
+
expect(state.revoke).not.toHaveBeenCalled();
|
|
77
|
+
await click(button("Revoke"));
|
|
78
|
+
expect(state.revoke).toHaveBeenCalledWith("k1", expect.any(Object));
|
|
79
|
+
expect(dialog).not.toHaveBeenCalled();
|
|
80
|
+
expect(host.querySelector('[role="alertdialog"]')).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("Cancel leaves the passkey alone", async () => {
|
|
84
|
+
await click(host.querySelector('[aria-label="Revoke Laptop"]'));
|
|
85
|
+
await click(button("Cancel"));
|
|
86
|
+
expect(state.revoke).not.toHaveBeenCalled();
|
|
87
|
+
expect(host.querySelector('[role="alertdialog"]')).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
usePasskeysQuery,
|
|
13
13
|
useRevokePasskey,
|
|
14
14
|
} from "@/data/presence/passkeys";
|
|
15
|
+
import { InlineConfirm } from "@/components/Settings/InlineConfirm";
|
|
15
16
|
import { lastSeen } from "./DeviceRow";
|
|
16
17
|
|
|
17
18
|
const when = (s: string) =>
|
|
@@ -33,6 +34,7 @@ export function PasskeysSection({ open }: { open: boolean }) {
|
|
|
33
34
|
const enroll = useEnrollCode();
|
|
34
35
|
const revoke = useRevokePasskey();
|
|
35
36
|
const [code, setCode] = useState("");
|
|
37
|
+
const [revoking, setRevoking] = useState<string | null>(null);
|
|
36
38
|
const here = useSyncExternalStore(
|
|
37
39
|
() => () => {},
|
|
38
40
|
passkeysHere,
|
|
@@ -57,33 +59,41 @@ export function PasskeysSection({ open }: { open: boolean }) {
|
|
|
57
59
|
to confirm.
|
|
58
60
|
</p>
|
|
59
61
|
{keys.map((k) => (
|
|
60
|
-
<div
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
62
|
+
<div key={k.id} className="bg-muted/40 rounded-lg px-3 py-2.5">
|
|
63
|
+
<div className="flex items-center gap-3">
|
|
64
|
+
<KeyRound className="text-muted-foreground h-4 w-4 shrink-0" />
|
|
65
|
+
<div className="min-w-0 flex-1">
|
|
66
|
+
<p className="truncate text-sm font-medium">{k.name}</p>
|
|
67
|
+
<p className="text-muted-foreground truncate text-xs">
|
|
68
|
+
{k.type === "device" ? "Device key (Face ID)" : k.rp_id} · added{" "}
|
|
69
|
+
{when(k.created_at)}
|
|
70
|
+
{k.registered_from ? ` from ${k.registered_from}` : ""} · used{" "}
|
|
71
|
+
{lastSeen(k.last_used_at)}
|
|
72
|
+
</p>
|
|
73
|
+
</div>
|
|
74
|
+
<Button
|
|
75
|
+
variant="ghost"
|
|
76
|
+
size="icon-sm"
|
|
77
|
+
className="h-11 w-11 sm:h-8 sm:w-8"
|
|
78
|
+
disabled={revoke.isPending}
|
|
79
|
+
aria-label={`Revoke ${k.name}`}
|
|
80
|
+
onClick={() => setRevoking(k.id)}
|
|
81
|
+
>
|
|
82
|
+
<Trash2 className="h-4 w-4" />
|
|
83
|
+
</Button>
|
|
73
84
|
</div>
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
</Button>
|
|
85
|
+
{revoking === k.id && (
|
|
86
|
+
<InlineConfirm
|
|
87
|
+
label={`Revoke ${k.name}`}
|
|
88
|
+
message={`Revoke the passkey "${k.name}"? It can no longer approve anything.`}
|
|
89
|
+
confirmLabel="Revoke"
|
|
90
|
+
onCancel={() => setRevoking(null)}
|
|
91
|
+
onConfirm={() => {
|
|
92
|
+
setRevoking(null);
|
|
93
|
+
revoke.mutate(k.id, { onError: fail });
|
|
94
|
+
}}
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
87
97
|
</div>
|
|
88
98
|
))}
|
|
89
99
|
{data?.bootstrapped && keys.length === 0 ? (
|
|
@@ -306,7 +306,12 @@ function Thresholds() {
|
|
|
306
306
|
const { data } = useHealthConfig();
|
|
307
307
|
const save = useSaveConfig();
|
|
308
308
|
const [draft, setDraft] = useState<Record<string, string>>({});
|
|
309
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
309
310
|
if (!data) return null;
|
|
311
|
+
const drop = (k: string) => {
|
|
312
|
+
setDraft(({ [k]: _, ...rest }) => rest);
|
|
313
|
+
setErrors(({ [k]: _, ...rest }) => rest);
|
|
314
|
+
};
|
|
310
315
|
return (
|
|
311
316
|
<div className="space-y-3">
|
|
312
317
|
<p className="text-muted-foreground text-sm">
|
|
@@ -326,23 +331,43 @@ function Thresholds() {
|
|
|
326
331
|
default {data.defaults[k]}
|
|
327
332
|
</span>
|
|
328
333
|
</label>
|
|
329
|
-
<
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
334
|
+
<span className="flex flex-col items-end gap-1">
|
|
335
|
+
<Input
|
|
336
|
+
id={`health-${k}`}
|
|
337
|
+
inputMode="decimal"
|
|
338
|
+
value={value}
|
|
339
|
+
aria-invalid={!!errors[k]}
|
|
340
|
+
onChange={(e) => {
|
|
341
|
+
setDraft({ ...draft, [k]: e.target.value });
|
|
342
|
+
setErrors(({ [k]: _, ...rest }) => rest);
|
|
343
|
+
}}
|
|
344
|
+
onBlur={() => {
|
|
345
|
+
if (draft[k] === undefined) return;
|
|
346
|
+
const raw = draft[k].trim();
|
|
347
|
+
const n = raw === "" ? null : Number(raw);
|
|
348
|
+
if (n !== null && (!Number.isFinite(n) || n < 0))
|
|
349
|
+
return setErrors({
|
|
350
|
+
...errors,
|
|
351
|
+
[k]: "Enter a number, 0 or more.",
|
|
352
|
+
});
|
|
353
|
+
if (n === data.config[k]) return drop(k);
|
|
354
|
+
save.mutate({ [k]: n }, { onSettled: () => drop(k) });
|
|
355
|
+
}}
|
|
356
|
+
onKeyDown={(e) => {
|
|
357
|
+
if (e.key === "Enter") e.currentTarget.blur();
|
|
358
|
+
if (e.key === "Escape") {
|
|
359
|
+
drop(k);
|
|
360
|
+
e.currentTarget.blur();
|
|
341
361
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
362
|
+
}}
|
|
363
|
+
className="h-11 w-24 text-right tabular-nums md:h-9"
|
|
364
|
+
/>
|
|
365
|
+
{errors[k] && (
|
|
366
|
+
<span role="alert" className="text-destructive text-xs">
|
|
367
|
+
{errors[k]}
|
|
368
|
+
</span>
|
|
369
|
+
)}
|
|
370
|
+
</span>
|
|
346
371
|
</li>
|
|
347
372
|
);
|
|
348
373
|
})}
|
|
@@ -77,13 +77,10 @@ export function GlobalMergeSection() {
|
|
|
77
77
|
<MergeSettingsFields
|
|
78
78
|
settings={data.settings}
|
|
79
79
|
inherited={data.defaults}
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
idPrefix="merge-global"
|
|
81
|
+
onSave={(s) => update.mutateAsync(s)}
|
|
82
82
|
/>
|
|
83
83
|
)}
|
|
84
|
-
{update.isError && (
|
|
85
|
-
<p className="text-destructive text-sm">{update.error.message}</p>
|
|
86
|
-
)}
|
|
87
84
|
</div>
|
|
88
85
|
{data && <Overrides overrides={data.overrides} />}
|
|
89
86
|
<GlobalSweepSection />
|