@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
|
@@ -1,34 +1,35 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { SettingSection } from "@/components/Settings/SettingRow";
|
|
4
|
+
import { SwitchSetting } from "@/components/Settings/settings-fields";
|
|
4
5
|
import { useMcpServersQuery, useUpdateWorkspace } from "@/data/workspaces";
|
|
5
6
|
import type { Workspace } from "@/lib/db";
|
|
6
7
|
|
|
7
8
|
// Which MCP servers a workspace's orchestrator may use. Servers carry their
|
|
8
|
-
// own auth, so none is allowed unless
|
|
9
|
+
// own auth, so none is allowed unless switched on here.
|
|
9
10
|
export function McpServersForm({ workspace }: { workspace: Workspace }) {
|
|
10
11
|
const { data: configured = [] } = useMcpServersQuery();
|
|
11
12
|
const update = useUpdateWorkspace();
|
|
12
13
|
const allowed = workspace.orch_mcp_servers;
|
|
13
|
-
//
|
|
14
|
+
// An allowed server no longer configured stays listed, so it can be removed.
|
|
14
15
|
const names = [...new Set([...configured, ...allowed])];
|
|
15
16
|
|
|
16
17
|
const toggle = (name: string, on: boolean) =>
|
|
17
|
-
update.
|
|
18
|
+
update.mutateAsync({
|
|
18
19
|
id: workspace.id,
|
|
19
20
|
mcpServers: on ? [...allowed, name] : allowed.filter((n) => n !== name),
|
|
20
21
|
});
|
|
21
22
|
|
|
22
23
|
return (
|
|
23
|
-
<div className="
|
|
24
|
+
<div className="space-y-2">
|
|
24
25
|
<div>
|
|
25
|
-
<p className="text-sm font-
|
|
26
|
+
<p className="text-sm font-semibold">
|
|
26
27
|
MCP servers its orchestrator may use
|
|
27
28
|
</p>
|
|
28
29
|
<p className="text-muted-foreground text-xs">
|
|
29
|
-
All of a
|
|
30
|
-
email or post through one still goes to you as an ask. Takes
|
|
31
|
-
its next turn.
|
|
30
|
+
All of a server's tools are allowed once it's on. Sending a
|
|
31
|
+
message, email or post through one still goes to you as an ask. Takes
|
|
32
|
+
effect at its next turn.
|
|
32
33
|
</p>
|
|
33
34
|
</div>
|
|
34
35
|
{names.length === 0 ? (
|
|
@@ -36,18 +37,18 @@ export function McpServersForm({ workspace }: { workspace: Workspace }) {
|
|
|
36
37
|
No MCP servers are configured on this machine.
|
|
37
38
|
</p>
|
|
38
39
|
) : (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
<Switch
|
|
40
|
+
<SettingSection>
|
|
41
|
+
{names.map((name) => (
|
|
42
|
+
<SwitchSetting
|
|
43
|
+
key={name}
|
|
44
|
+
id={`mcp-${workspace.id}-${name}`}
|
|
45
|
+
label={name}
|
|
46
46
|
checked={allowed.includes(name)}
|
|
47
|
-
|
|
47
|
+
defaultValue={false}
|
|
48
|
+
onSave={(on) => toggle(name, on)}
|
|
48
49
|
/>
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
))}
|
|
51
|
+
</SettingSection>
|
|
51
52
|
)}
|
|
52
53
|
</div>
|
|
53
54
|
);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { SettingSection } from "@/components/Settings/SettingRow";
|
|
4
|
+
import { SwitchSetting } from "@/components/Settings/settings-fields";
|
|
4
5
|
import { useProjectsQuery, useUpdateProject } from "@/data/projects";
|
|
5
6
|
|
|
6
7
|
// A workspace's projects, each switched between production (every merge gate)
|
|
@@ -13,21 +14,22 @@ export function ProjectTiersForm({ workspaceId }: { workspaceId: string }) {
|
|
|
13
14
|
);
|
|
14
15
|
if (!mine.length) return null;
|
|
15
16
|
return (
|
|
16
|
-
<
|
|
17
|
+
<SettingSection>
|
|
17
18
|
{mine.map((p) => (
|
|
18
|
-
<
|
|
19
|
+
<SwitchSetting
|
|
19
20
|
key={p.id}
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
id={`tier-${p.id}`}
|
|
22
|
+
label={`${p.name}: production`}
|
|
23
|
+
help="On: CI, the independent review and scope gate its merges. Off (fast): a task's PR merges as soon as it opens."
|
|
22
24
|
checked={p.tier === "production"}
|
|
23
|
-
|
|
24
|
-
update.
|
|
25
|
+
onSave={(on) =>
|
|
26
|
+
update.mutateAsync({
|
|
25
27
|
projectId: p.id,
|
|
26
28
|
tier: on ? "production" : "fast",
|
|
27
29
|
})
|
|
28
30
|
}
|
|
29
31
|
/>
|
|
30
32
|
))}
|
|
31
|
-
</
|
|
33
|
+
</SettingSection>
|
|
32
34
|
);
|
|
33
35
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
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(() => ({
|
|
17
|
+
update: vi.fn(),
|
|
18
|
+
add: vi.fn(),
|
|
19
|
+
toast: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
vi.mock("sonner", () => ({ toast: { error: state.toast } }));
|
|
22
|
+
vi.mock("@/data/orchestrators/rules", () => ({
|
|
23
|
+
useRulesQuery: () => ({
|
|
24
|
+
isPending: false,
|
|
25
|
+
isError: false,
|
|
26
|
+
data: [
|
|
27
|
+
{ id: 4, workspace_id: "w1", text: "Never merge #12", added_by: "saad" },
|
|
28
|
+
],
|
|
29
|
+
}),
|
|
30
|
+
useAddRule: () => ({ mutateAsync: state.add }),
|
|
31
|
+
useUpdateRule: () => ({ mutateAsync: state.update }),
|
|
32
|
+
useRemoveRule: () => ({ mutate: vi.fn(), isPending: false }),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
const { RulesForm } = await import("./RulesForm");
|
|
36
|
+
|
|
37
|
+
let root: Root;
|
|
38
|
+
let host: HTMLDivElement;
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
state.update.mockReset().mockResolvedValue(undefined);
|
|
41
|
+
state.add.mockReset().mockResolvedValue(undefined);
|
|
42
|
+
state.toast.mockReset();
|
|
43
|
+
host = document.createElement("div");
|
|
44
|
+
document.body.appendChild(host);
|
|
45
|
+
root = createRoot(host);
|
|
46
|
+
});
|
|
47
|
+
afterEach(async () => {
|
|
48
|
+
await act(async () => root.unmount());
|
|
49
|
+
host.remove();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const type = async (input: HTMLInputElement, text: string) => {
|
|
53
|
+
const set = Object.getOwnPropertyDescriptor(
|
|
54
|
+
HTMLInputElement.prototype,
|
|
55
|
+
"value"
|
|
56
|
+
)!.set!;
|
|
57
|
+
await act(async () => {
|
|
58
|
+
set.call(input, text);
|
|
59
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
const rule = () => host.querySelector<HTMLInputElement>("#rule-4")!;
|
|
63
|
+
const click = (el: Element | null | undefined) =>
|
|
64
|
+
act(async () => {
|
|
65
|
+
el?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
|
66
|
+
});
|
|
67
|
+
const button = (name: string) =>
|
|
68
|
+
[...host.querySelectorAll("button")].find((b) => b.textContent === name);
|
|
69
|
+
|
|
70
|
+
describe("RulesForm", () => {
|
|
71
|
+
beforeEach(async () => {
|
|
72
|
+
await act(async () =>
|
|
73
|
+
root.render(createElement(RulesForm, { workspaceId: "w1" }))
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("never asks for a passkey on blur", async () => {
|
|
78
|
+
await type(rule(), "Never merge #13");
|
|
79
|
+
await act(async () => {
|
|
80
|
+
rule().dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
|
81
|
+
});
|
|
82
|
+
expect(state.update).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("saves through the passkey step on the Save button, and says Saved", async () => {
|
|
86
|
+
await type(rule(), "Never merge #13");
|
|
87
|
+
await click(button("Save"));
|
|
88
|
+
expect(state.update).toHaveBeenCalledWith({
|
|
89
|
+
id: 4,
|
|
90
|
+
text: "Never merge #13",
|
|
91
|
+
});
|
|
92
|
+
expect(host.textContent).toContain("Saved");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("saves on Enter", async () => {
|
|
96
|
+
await type(rule(), "Never merge #13");
|
|
97
|
+
await act(async () => {
|
|
98
|
+
rule().dispatchEvent(
|
|
99
|
+
new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
expect(state.update).toHaveBeenCalledOnce();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("rolls back and toasts when the passkey is cancelled", async () => {
|
|
106
|
+
state.update.mockRejectedValue(new Error("Passkey cancelled"));
|
|
107
|
+
await type(rule(), "Never merge #13");
|
|
108
|
+
await click(button("Save"));
|
|
109
|
+
expect(rule().value).toBe("Never merge #12");
|
|
110
|
+
expect(state.toast).toHaveBeenCalledWith("Passkey cancelled");
|
|
111
|
+
expect(host.textContent).not.toContain("Saved");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("won't save an empty rule", async () => {
|
|
115
|
+
await type(rule(), " ");
|
|
116
|
+
await click(button("Save"));
|
|
117
|
+
expect(state.update).not.toHaveBeenCalled();
|
|
118
|
+
expect(host.textContent).toContain("A rule can't be empty");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("adds a rule through the same step", async () => {
|
|
122
|
+
const input = host.querySelector<HTMLInputElement>("#rule-new-w1")!;
|
|
123
|
+
await type(input, "House rule");
|
|
124
|
+
await click(button("Add"));
|
|
125
|
+
expect(state.add).toHaveBeenCalledWith("House rule");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import { Check, Pencil, Trash2, X } from "lucide-react";
|
|
3
|
+
import { Trash2 } from "lucide-react";
|
|
5
4
|
import { toast } from "sonner";
|
|
6
5
|
import { Button } from "@/components/ui/button";
|
|
7
|
-
import {
|
|
6
|
+
import { SettingSection } from "@/components/Settings/SettingRow";
|
|
7
|
+
import { TextSetting } from "@/components/Settings/settings-fields";
|
|
8
8
|
import type { RuleRow } from "@/lib/orchestrator/rules";
|
|
9
9
|
import {
|
|
10
10
|
useAddRule,
|
|
@@ -17,58 +17,44 @@ const MAX_CHARS = 300;
|
|
|
17
17
|
|
|
18
18
|
// The orchestrator's standing rules for one workspace: what it keeps
|
|
19
19
|
// knowing across fresh starts. It adds them itself too; Saad edits here.
|
|
20
|
+
// Every change takes his passkey (data/orchestrators/rules), so a rule saves
|
|
21
|
+
// on Enter or its button, never on blur; a cancelled or refused passkey rolls
|
|
22
|
+
// the row back like any failed save.
|
|
20
23
|
export function RulesForm({ workspaceId }: { workspaceId: string }) {
|
|
21
24
|
const { data: rules, isPending, isError, error } = useRulesQuery(workspaceId);
|
|
22
25
|
const add = useAddRule(workspaceId);
|
|
23
|
-
const [text, setText] = useState("");
|
|
24
26
|
|
|
25
27
|
return (
|
|
26
28
|
<div className="space-y-2">
|
|
27
|
-
<
|
|
29
|
+
<p className="text-sm font-semibold">Orchestrator rules</p>
|
|
28
30
|
{isPending ? (
|
|
29
31
|
<div className="bg-muted/60 h-11 animate-pulse rounded-md" />
|
|
30
32
|
) : isError ? (
|
|
31
33
|
<p className="text-destructive text-xs">{error.message}</p>
|
|
32
|
-
) :
|
|
33
|
-
<
|
|
34
|
+
) : (
|
|
35
|
+
<SettingSection
|
|
36
|
+
help={
|
|
37
|
+
rules.length
|
|
38
|
+
? "Each change asks for your passkey."
|
|
39
|
+
: "None yet. The orchestrator keeps one when you tell it something standing, like a PR never to merge. Each change asks for your passkey."
|
|
40
|
+
}
|
|
41
|
+
>
|
|
34
42
|
{rules.map((r) => (
|
|
35
43
|
<RuleItem key={`${r.id}:${r.text}`} rule={r} />
|
|
36
44
|
))}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
<TextSetting
|
|
46
|
+
id={`rule-new-${workspaceId}`}
|
|
47
|
+
label="New rule"
|
|
48
|
+
value=""
|
|
49
|
+
explicit
|
|
50
|
+
resettable={false}
|
|
51
|
+
buttonLabel="Add"
|
|
52
|
+
maxLength={MAX_CHARS}
|
|
53
|
+
placeholder="A PR never to merge, a URL, a house rule"
|
|
54
|
+
onSave={(text) => add.mutateAsync(text)}
|
|
55
|
+
/>
|
|
56
|
+
</SettingSection>
|
|
43
57
|
)}
|
|
44
|
-
<form
|
|
45
|
-
onSubmit={(e) => {
|
|
46
|
-
e.preventDefault();
|
|
47
|
-
if (!text.trim()) return;
|
|
48
|
-
add.mutate(text, {
|
|
49
|
-
onSuccess: () => setText(""),
|
|
50
|
-
onError: (err) => toast.error(err.message),
|
|
51
|
-
});
|
|
52
|
-
}}
|
|
53
|
-
className="flex items-center gap-2"
|
|
54
|
-
>
|
|
55
|
-
<Input
|
|
56
|
-
value={text}
|
|
57
|
-
maxLength={MAX_CHARS}
|
|
58
|
-
onChange={(e) => setText(e.target.value)}
|
|
59
|
-
placeholder="A PR never to merge, a URL, a house rule"
|
|
60
|
-
aria-label="New orchestrator rule"
|
|
61
|
-
className="h-11 text-base sm:h-9 md:text-sm"
|
|
62
|
-
/>
|
|
63
|
-
<Button
|
|
64
|
-
type="submit"
|
|
65
|
-
size="sm"
|
|
66
|
-
className="h-11 sm:h-8"
|
|
67
|
-
disabled={!text.trim() || add.isPending}
|
|
68
|
-
>
|
|
69
|
-
Add
|
|
70
|
-
</Button>
|
|
71
|
-
</form>
|
|
72
58
|
</div>
|
|
73
59
|
);
|
|
74
60
|
}
|
|
@@ -76,91 +62,33 @@ export function RulesForm({ workspaceId }: { workspaceId: string }) {
|
|
|
76
62
|
function RuleItem({ rule }: { rule: RuleRow }) {
|
|
77
63
|
const update = useUpdateRule(rule.workspace_id);
|
|
78
64
|
const remove = useRemoveRule(rule.workspace_id);
|
|
79
|
-
const [editing, setEditing] = useState(false);
|
|
80
|
-
const [text, setText] = useState(rule.text);
|
|
81
|
-
|
|
82
|
-
if (editing)
|
|
83
|
-
return (
|
|
84
|
-
<li>
|
|
85
|
-
<form
|
|
86
|
-
onSubmit={(e) => {
|
|
87
|
-
e.preventDefault();
|
|
88
|
-
update.mutate(
|
|
89
|
-
{ id: rule.id, text },
|
|
90
|
-
{
|
|
91
|
-
onSuccess: () => setEditing(false),
|
|
92
|
-
onError: (err) => toast.error(err.message),
|
|
93
|
-
}
|
|
94
|
-
);
|
|
95
|
-
}}
|
|
96
|
-
className="flex items-center gap-1"
|
|
97
|
-
>
|
|
98
|
-
<Input
|
|
99
|
-
autoFocus
|
|
100
|
-
value={text}
|
|
101
|
-
maxLength={MAX_CHARS}
|
|
102
|
-
onChange={(e) => setText(e.target.value)}
|
|
103
|
-
aria-label={`Rule ${rule.id}`}
|
|
104
|
-
className="h-11 text-base sm:h-9 md:text-sm"
|
|
105
|
-
/>
|
|
106
|
-
<Button
|
|
107
|
-
type="submit"
|
|
108
|
-
variant="ghost"
|
|
109
|
-
size="icon"
|
|
110
|
-
className="size-11 shrink-0 sm:size-8"
|
|
111
|
-
disabled={!text.trim() || update.isPending}
|
|
112
|
-
aria-label="Save rule"
|
|
113
|
-
>
|
|
114
|
-
<Check className="size-4" />
|
|
115
|
-
</Button>
|
|
116
|
-
<Button
|
|
117
|
-
type="button"
|
|
118
|
-
variant="ghost"
|
|
119
|
-
size="icon"
|
|
120
|
-
className="size-11 shrink-0 sm:size-8"
|
|
121
|
-
onClick={() => {
|
|
122
|
-
setText(rule.text);
|
|
123
|
-
setEditing(false);
|
|
124
|
-
}}
|
|
125
|
-
aria-label="Cancel"
|
|
126
|
-
>
|
|
127
|
-
<X className="size-4" />
|
|
128
|
-
</Button>
|
|
129
|
-
</form>
|
|
130
|
-
</li>
|
|
131
|
-
);
|
|
132
|
-
|
|
133
65
|
return (
|
|
134
|
-
<
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
>
|
|
162
|
-
<Trash2 className="size-4" />
|
|
163
|
-
</Button>
|
|
164
|
-
</li>
|
|
66
|
+
<TextSetting
|
|
67
|
+
id={`rule-${rule.id}`}
|
|
68
|
+
label={rule.added_by === "saad" ? "Your rule" : "Orchestrator's rule"}
|
|
69
|
+
value={rule.text}
|
|
70
|
+
explicit
|
|
71
|
+
resettable={false}
|
|
72
|
+
buttonLabel="Save"
|
|
73
|
+
maxLength={MAX_CHARS}
|
|
74
|
+
validate={(text) => (text ? null : "A rule can't be empty")}
|
|
75
|
+
onSave={(text) => update.mutateAsync({ id: rule.id, text })}
|
|
76
|
+
after={
|
|
77
|
+
<Button
|
|
78
|
+
variant="ghost"
|
|
79
|
+
size="icon"
|
|
80
|
+
className="size-11 shrink-0 sm:size-8"
|
|
81
|
+
disabled={remove.isPending}
|
|
82
|
+
onClick={() =>
|
|
83
|
+
remove.mutate(rule.id, {
|
|
84
|
+
onError: (err) => toast.error(err.message),
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
aria-label={`Remove rule: ${rule.text}`}
|
|
88
|
+
>
|
|
89
|
+
<Trash2 className="size-4" />
|
|
90
|
+
</Button>
|
|
91
|
+
}
|
|
92
|
+
/>
|
|
165
93
|
);
|
|
166
94
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
|
+
import { PickerSetting } from "@/components/Settings/PickerSetting";
|
|
5
|
+
import { SettingSection } from "@/components/Settings/SettingRow";
|
|
4
6
|
import { ModelSelect } from "@/components/Models/ModelSelect";
|
|
5
7
|
import {
|
|
6
8
|
useModelsQuery,
|
|
@@ -37,34 +39,33 @@ export function WorkspaceModelsForm({ workspaceId }: { workspaceId: string }) {
|
|
|
37
39
|
{open ? "▴" : "▾"}
|
|
38
40
|
</button>
|
|
39
41
|
{open && (
|
|
40
|
-
<
|
|
42
|
+
<SettingSection>
|
|
41
43
|
{WORKSPACE_ROLES.map((role) => (
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
44
|
+
<PickerSetting<string | null>
|
|
45
|
+
key={role}
|
|
46
|
+
id={`ws-${workspaceId}-${role}`}
|
|
47
|
+
label={ROLE_LABELS[role].label}
|
|
48
|
+
value={roles[role] ?? null}
|
|
49
|
+
defaultValue={null}
|
|
50
|
+
onSave={(ref) => {
|
|
51
|
+
const next = { ...roles };
|
|
52
|
+
if (ref) next[role] = ref;
|
|
53
|
+
else delete next[role];
|
|
54
|
+
return update.mutateAsync({ roles: next });
|
|
55
|
+
}}
|
|
56
|
+
render={({ value, commit }) => (
|
|
57
|
+
<ModelSelect
|
|
58
|
+
id={`ws-${workspaceId}-${role}`}
|
|
59
|
+
value={value}
|
|
60
|
+
effective={models.data?.effective}
|
|
61
|
+
inheritLabel={`As Settings (${models.data?.effective.roles[role] ?? "…"})`}
|
|
62
|
+
disabled={!models.data}
|
|
63
|
+
onChange={commit}
|
|
64
|
+
/>
|
|
65
|
+
)}
|
|
66
|
+
/>
|
|
63
67
|
))}
|
|
64
|
-
</
|
|
65
|
-
)}
|
|
66
|
-
{update.isError && (
|
|
67
|
-
<p className="text-destructive text-xs">{update.error.message}</p>
|
|
68
|
+
</SettingSection>
|
|
68
69
|
)}
|
|
69
70
|
</div>
|
|
70
71
|
);
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { settingsUiActions } from "@/stores/settingsUi";
|
|
4
4
|
import { useUpdateWorkspace, useWorkspacesQuery } from "@/data/workspaces";
|
|
5
|
-
import { TaskLimitForm } from "./TaskLimitForm";
|
|
6
5
|
import { RulesForm } from "./RulesForm";
|
|
7
|
-
import { FreshStartForm } from "./FreshStartForm";
|
|
8
6
|
import { McpServersForm } from "./McpServersForm";
|
|
9
7
|
import { HqFolderForm } from "./HqFolderForm";
|
|
10
8
|
import { ProjectTiersForm } from "./ProjectTiersForm";
|
|
11
|
-
import { StallForm } from "./StallForm";
|
|
12
9
|
import { WorkspaceModeForm } from "./WorkspaceModeForm";
|
|
13
10
|
import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
|
|
14
|
-
import {
|
|
11
|
+
import { AppWideLimits } from "@/components/Settings/AppWideLimits";
|
|
12
|
+
import {
|
|
13
|
+
NumberSetting,
|
|
14
|
+
SwitchSetting,
|
|
15
|
+
} from "@/components/Settings/settings-fields";
|
|
16
|
+
import { SettingSection } from "@/components/Settings/SettingRow";
|
|
17
|
+
import { SETTING_DEFAULTS } from "@/lib/settings/defaults";
|
|
15
18
|
|
|
16
19
|
// Settings > Workspaces: each workspace's own settings.
|
|
17
20
|
export function WorkspaceSettings() {
|
|
@@ -43,33 +46,67 @@ export function WorkspaceSettings() {
|
|
|
43
46
|
className="bg-muted/40 space-y-3 rounded-lg px-3 py-3"
|
|
44
47
|
>
|
|
45
48
|
<h3 className="truncate text-sm font-medium">{w.name}</h3>
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
<SettingSection>
|
|
50
|
+
<NumberSetting
|
|
51
|
+
id={`task-limit-${w.id}`}
|
|
52
|
+
label="Tasks at a time"
|
|
53
|
+
help="Cap on tasks running at once. The rest wait in Queued and start in order."
|
|
54
|
+
value={w.max_running_tasks ?? 0}
|
|
55
|
+
defaultValue={0}
|
|
56
|
+
onSave={(n) =>
|
|
57
|
+
update.mutateAsync({
|
|
58
|
+
id: w.id,
|
|
59
|
+
maxRunningTasks: n === 0 ? null : n,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
min={1}
|
|
63
|
+
integer
|
|
64
|
+
unit="at a time"
|
|
65
|
+
optional={{ off: 0, on: SETTING_DEFAULTS.taskLimitOn }}
|
|
66
|
+
/>
|
|
67
|
+
</SettingSection>
|
|
50
68
|
<WorkspaceModeForm workspaceId={w.id} />
|
|
51
69
|
<RulesForm workspaceId={w.id} />
|
|
52
70
|
<WorkspaceModelsForm workspaceId={w.id} />
|
|
53
71
|
<McpServersForm workspace={w} />
|
|
54
72
|
<HqFolderForm workspaceId={w.id} />
|
|
55
73
|
<ProjectTiersForm workspaceId={w.id} />
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
<SettingSection>
|
|
75
|
+
<SwitchSetting
|
|
76
|
+
id={`wake-${w.id}`}
|
|
77
|
+
label="Wake tasks on CI failures and reviews"
|
|
78
|
+
help="A task's agent is messaged once when a check fails on its PR, and when someone reviews or comments."
|
|
79
|
+
checked={w.wake_tasks_on_pr}
|
|
80
|
+
onSave={(on) =>
|
|
81
|
+
update.mutateAsync({ id: w.id, wakeTasksOnPR: on })
|
|
82
|
+
}
|
|
83
|
+
/>
|
|
84
|
+
<SwitchSetting
|
|
85
|
+
id={`full-access-${w.id}`}
|
|
86
|
+
label="Orchestrator full access"
|
|
87
|
+
help="Its shell and file writes aren't checked. Applies when its conversation next restarts."
|
|
88
|
+
checked={w.orch_full_access}
|
|
89
|
+
confirm="Let this orchestrator act without approval? It can push, commit, edit any repository or file on this machine, merge a PR directly, and edit .claude files. Off: the deny list, write limits and merge-only-through-the-gates apply."
|
|
90
|
+
confirmLabel="Turn on full access"
|
|
91
|
+
onSave={(on) =>
|
|
92
|
+
update.mutateAsync({ id: w.id, orchFullAccess: on })
|
|
93
|
+
}
|
|
94
|
+
/>
|
|
95
|
+
</SettingSection>
|
|
68
96
|
</section>
|
|
69
97
|
))}
|
|
70
|
-
<
|
|
71
|
-
<
|
|
72
|
-
|
|
98
|
+
<AppWideLimits />
|
|
99
|
+
<p className="text-muted-foreground text-sm">
|
|
100
|
+
Fresh starts and compaction are app-wide:{" "}
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
className="text-primary underline-offset-2 hover:underline"
|
|
104
|
+
onClick={() => settingsUiActions.show("context")}
|
|
105
|
+
>
|
|
106
|
+
open Context
|
|
107
|
+
</button>
|
|
108
|
+
.
|
|
109
|
+
</p>
|
|
73
110
|
</div>
|
|
74
111
|
);
|
|
75
112
|
}
|
package/content/whats-new.json
CHANGED
|
@@ -1969,6 +1969,22 @@
|
|
|
1969
1969
|
"area": "AgentOS",
|
|
1970
1970
|
"text": "Forking from a selection is tidier: the fork pill can be dismissed while keeping your quote and text, and started forks show a one-line marker and what they're doing.",
|
|
1971
1971
|
"url": "https://github.com/saadnvd1/agent-os/pull/561"
|
|
1972
|
+
},
|
|
1973
|
+
{
|
|
1974
|
+
"id": 562,
|
|
1975
|
+
"date": "2026-11-02",
|
|
1976
|
+
"kind": "improved",
|
|
1977
|
+
"area": "Settings",
|
|
1978
|
+
"text": "Settings now save as you change them, with a clear \"Saved\" note, undo and reset, and Context settings have their own page.",
|
|
1979
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/562"
|
|
1980
|
+
},
|
|
1981
|
+
{
|
|
1982
|
+
"id": 563,
|
|
1983
|
+
"date": "2026-11-02",
|
|
1984
|
+
"kind": "improved",
|
|
1985
|
+
"area": "Settings",
|
|
1986
|
+
"text": "More settings now save as you change them, and revoking a passkey or link asks inline instead of with a browser pop-up.",
|
|
1987
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/563"
|
|
1972
1988
|
}
|
|
1973
1989
|
]
|
|
1974
1990
|
}
|