@saadnvd1/agent-os 0.3.833 → 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/app/api/sessions/route.ts +7 -1
- package/app/globals.css +14 -0
- package/components/Chat/ChatPanel.tsx +41 -29
- package/components/Chat/Forks.tsx +16 -1
- package/components/Chat/Messages.tsx +14 -0
- package/components/Chat/WorktreeRemovedNotice.tsx +17 -0
- package/components/Chat/composer/ComposerBody.tsx +10 -1
- package/components/Chat/composer/ForkHeader.tsx +26 -15
- package/components/Chat/fork-draft.test.ts +57 -0
- package/components/Chat/fork-draft.ts +34 -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 +32 -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/socket.ts +7 -3
- package/lib/chat/stall-limits.ts +3 -1
- package/lib/chat/start-error.ts +13 -0
- package/lib/chat/worker/cwd.ts +77 -0
- package/lib/chat/worker/host.ts +11 -2
- package/lib/chat/worker/main.ts +11 -2
- package/lib/db/types.ts +2 -0
- package/lib/fork/note.ts +6 -0
- package/lib/fork/start.ts +6 -2
- package/lib/fork/types.ts +2 -0
- package/lib/fork/view.ts +14 -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
|
@@ -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 />
|
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
Select,
|
|
6
|
-
SelectContent,
|
|
7
|
-
SelectItem,
|
|
8
|
-
SelectTrigger,
|
|
9
|
-
SelectValue,
|
|
10
|
-
} from "@/components/ui/select";
|
|
3
|
+
import { SelectSetting } from "@/components/Settings/settings-fields";
|
|
4
|
+
import { SettingSection } from "@/components/Settings/SettingRow";
|
|
11
5
|
import {
|
|
12
6
|
MERGE_METHODS,
|
|
13
7
|
METHOD_LABEL,
|
|
@@ -29,47 +23,6 @@ const SOURCE: Record<MergeSource, string> = {
|
|
|
29
23
|
|
|
30
24
|
const onOff = (on: boolean) => (on ? "On" : "Off");
|
|
31
25
|
|
|
32
|
-
function Field({
|
|
33
|
-
label,
|
|
34
|
-
detail,
|
|
35
|
-
value,
|
|
36
|
-
inheritLabel,
|
|
37
|
-
options,
|
|
38
|
-
onChange,
|
|
39
|
-
disabled,
|
|
40
|
-
}: {
|
|
41
|
-
label: string;
|
|
42
|
-
detail: string;
|
|
43
|
-
value: string;
|
|
44
|
-
inheritLabel: string;
|
|
45
|
-
options: Array<{ value: string; label: string }>;
|
|
46
|
-
onChange: (value: string) => void;
|
|
47
|
-
disabled?: boolean;
|
|
48
|
-
}) {
|
|
49
|
-
const id = useId();
|
|
50
|
-
return (
|
|
51
|
-
<div className="space-y-1.5">
|
|
52
|
-
<label htmlFor={id} className="text-sm font-medium">
|
|
53
|
-
{label}
|
|
54
|
-
</label>
|
|
55
|
-
<Select value={value} onValueChange={onChange} disabled={disabled}>
|
|
56
|
-
<SelectTrigger id={id} className="h-11 md:h-9">
|
|
57
|
-
<SelectValue />
|
|
58
|
-
</SelectTrigger>
|
|
59
|
-
<SelectContent>
|
|
60
|
-
<SelectItem value={INHERIT}>{inheritLabel}</SelectItem>
|
|
61
|
-
{options.map((o) => (
|
|
62
|
-
<SelectItem key={o.value} value={o.value}>
|
|
63
|
-
{o.label}
|
|
64
|
-
</SelectItem>
|
|
65
|
-
))}
|
|
66
|
-
</SelectContent>
|
|
67
|
-
</Select>
|
|
68
|
-
<p className="text-muted-foreground text-xs">{detail}</p>
|
|
69
|
-
</div>
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
26
|
const TOGGLE = [
|
|
74
27
|
{ value: "on", label: "On" },
|
|
75
28
|
{ value: "off", label: "Off" },
|
|
@@ -78,38 +31,43 @@ const TOGGLE = [
|
|
|
78
31
|
/**
|
|
79
32
|
* The merge method and the two deletions after a merge, each either set
|
|
80
33
|
* here or left to what this level inherits (`inherited`, with where that
|
|
81
|
-
* comes from).
|
|
34
|
+
* comes from). Each saves the moment it changes.
|
|
82
35
|
*/
|
|
83
36
|
export function MergeSettingsFields({
|
|
37
|
+
idPrefix,
|
|
84
38
|
settings,
|
|
85
39
|
inherited,
|
|
86
40
|
from,
|
|
87
41
|
allowed,
|
|
88
|
-
|
|
89
|
-
onChange,
|
|
42
|
+
onSave,
|
|
90
43
|
}: {
|
|
44
|
+
idPrefix: string;
|
|
91
45
|
settings: MergeSettings;
|
|
92
46
|
inherited: MergePolicy;
|
|
93
47
|
from?: Record<keyof MergePolicy, MergeSource>;
|
|
94
48
|
// The repository's allowed methods, when known.
|
|
95
49
|
allowed?: MergeMethod[] | null;
|
|
96
|
-
|
|
97
|
-
onChange: (settings: MergeSettings) => void;
|
|
50
|
+
onSave: (settings: MergeSettings) => Promise<unknown>;
|
|
98
51
|
}) {
|
|
99
|
-
const
|
|
100
|
-
|
|
52
|
+
const inheritOption = (key: keyof MergePolicy, shown: string) => ({
|
|
53
|
+
value: INHERIT,
|
|
54
|
+
label: from
|
|
55
|
+
? `Inherit: ${shown} (${SOURCE[from[key]]})`
|
|
56
|
+
: `Default: ${shown}`,
|
|
57
|
+
});
|
|
101
58
|
const set = <K extends keyof MergeSettings>(
|
|
102
59
|
key: K,
|
|
103
60
|
value: MergeSettings[K] | undefined
|
|
104
|
-
) =>
|
|
61
|
+
) => onSave({ ...settings, [key]: value });
|
|
105
62
|
const toggle = (key: "delete_remote_branch" | "delete_worktree") => (
|
|
106
|
-
<
|
|
63
|
+
<SelectSetting
|
|
64
|
+
id={`${idPrefix}-${key}`}
|
|
107
65
|
label={
|
|
108
66
|
key === "delete_remote_branch"
|
|
109
67
|
? "Delete the branch on origin after a merge"
|
|
110
68
|
: "Delete the local worktree and branch after a merge"
|
|
111
69
|
}
|
|
112
|
-
|
|
70
|
+
help={
|
|
113
71
|
key === "delete_remote_branch"
|
|
114
72
|
? "Never main, master, or a branch a stacked task still targets."
|
|
115
73
|
: "Never one with uncommitted work or commits the merge didn't include."
|
|
@@ -117,10 +75,10 @@ export function MergeSettingsFields({
|
|
|
117
75
|
value={
|
|
118
76
|
settings[key] === undefined ? INHERIT : settings[key] ? "on" : "off"
|
|
119
77
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
78
|
+
options={[inheritOption(key, onOff(inherited[key])), ...TOGGLE]}
|
|
79
|
+
defaultValue={INHERIT}
|
|
80
|
+
hint=""
|
|
81
|
+
onSave={(v) => set(key, v === INHERIT ? undefined : v === "on")}
|
|
124
82
|
/>
|
|
125
83
|
);
|
|
126
84
|
|
|
@@ -128,25 +86,29 @@ export function MergeSettingsFields({
|
|
|
128
86
|
const refused = allowed && !allowed.includes(method);
|
|
129
87
|
|
|
130
88
|
return (
|
|
131
|
-
<
|
|
132
|
-
<
|
|
89
|
+
<SettingSection>
|
|
90
|
+
<SelectSetting
|
|
91
|
+
id={`${idPrefix}-method`}
|
|
133
92
|
label="Merge method"
|
|
134
|
-
|
|
93
|
+
help="How sign-off, Done, Land and external PRs merge. If GitHub refuses it, the merge fails and says so; it never falls back to another method."
|
|
135
94
|
value={settings.method ?? INHERIT}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
95
|
+
options={[
|
|
96
|
+
inheritOption("method", METHOD_LABEL[inherited.method]),
|
|
97
|
+
...MERGE_METHODS.map((m) => ({
|
|
98
|
+
value: m,
|
|
99
|
+
label:
|
|
100
|
+
METHOD_LABEL[m] +
|
|
101
|
+
(allowed && !allowed.includes(m) ? " (off on GitHub)" : ""),
|
|
102
|
+
})),
|
|
103
|
+
]}
|
|
104
|
+
defaultValue={INHERIT}
|
|
105
|
+
hint=""
|
|
106
|
+
onSave={(v) =>
|
|
145
107
|
set("method", v === INHERIT ? undefined : (v as MergeMethod))
|
|
146
108
|
}
|
|
147
109
|
/>
|
|
148
110
|
{refused && (
|
|
149
|
-
<p className="text-destructive text-xs">
|
|
111
|
+
<p className="text-destructive px-3 py-2 text-xs">
|
|
150
112
|
This repository has “{REPO_SETTING[method]}” turned off on
|
|
151
113
|
GitHub, so merges will be refused until it’s on or the method
|
|
152
114
|
changes.
|
|
@@ -154,6 +116,6 @@ export function MergeSettingsFields({
|
|
|
154
116
|
)}
|
|
155
117
|
{toggle("delete_remote_branch")}
|
|
156
118
|
{toggle("delete_worktree")}
|
|
157
|
-
</
|
|
119
|
+
</SettingSection>
|
|
158
120
|
);
|
|
159
121
|
}
|
|
@@ -35,14 +35,10 @@ export function ProjectMergeSection({
|
|
|
35
35
|
inherited={data.inherited}
|
|
36
36
|
from={data.inherited.from}
|
|
37
37
|
allowed={data.allowed}
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
idPrefix={`merge-${projectId}`}
|
|
39
|
+
onSave={(s) => update.mutateAsync(s)}
|
|
40
40
|
/>
|
|
41
41
|
)}
|
|
42
|
-
{update.isError && (
|
|
43
|
-
<p className="text-destructive text-sm">{update.error.message}</p>
|
|
44
|
-
)}
|
|
45
|
-
<p className="text-muted-foreground text-xs">Saved as you change them.</p>
|
|
46
42
|
<ProjectSweepSection projectId={projectId} />
|
|
47
43
|
</div>
|
|
48
44
|
);
|