@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,20 +1,16 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { Button } from "@/components/ui/button";
|
|
6
|
-
import { Input } from "@/components/ui/input";
|
|
3
|
+
import { SettingSection } from "@/components/Settings/SettingRow";
|
|
4
|
+
import { TextSetting } from "@/components/Settings/settings-fields";
|
|
7
5
|
import { useSetWorkspaceHq, useWorkspaceHqQuery } from "@/data/session-logs";
|
|
8
6
|
|
|
9
7
|
// The git repo this workspace's session logs are written into and
|
|
10
|
-
// committed to. Blank keeps them in AgentOS's own folder.
|
|
8
|
+
// committed to. Blank keeps them in AgentOS's own folder. The server checks
|
|
9
|
+
// the folder exists and is a fit repo before anything is saved; a refusal
|
|
10
|
+
// shows as a toast and the field goes back to the saved folder.
|
|
11
11
|
export function HqFolderForm({ workspaceId }: { workspaceId: string }) {
|
|
12
12
|
const { data, isPending, isError, error } = useWorkspaceHqQuery(workspaceId);
|
|
13
13
|
const save = useSetWorkspaceHq(workspaceId);
|
|
14
|
-
const [draft, setDraft] = useState<string | null>(null);
|
|
15
|
-
const saved = data?.hq?.folder ?? "";
|
|
16
|
-
const folder = draft ?? saved;
|
|
17
|
-
const id = `hq-folder-${workspaceId}`;
|
|
18
14
|
|
|
19
15
|
if (isPending)
|
|
20
16
|
return <div className="bg-muted/60 h-11 animate-pulse rounded-md" />;
|
|
@@ -22,50 +18,21 @@ export function HqFolderForm({ workspaceId }: { workspaceId: string }) {
|
|
|
22
18
|
return <p className="text-destructive text-xs">{error.message}</p>;
|
|
23
19
|
|
|
24
20
|
return (
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
HQ folder
|
|
42
|
-
</label>
|
|
43
|
-
<div className="flex gap-2">
|
|
44
|
-
<Input
|
|
45
|
-
id={id}
|
|
46
|
-
value={folder}
|
|
47
|
-
onChange={(e) => setDraft(e.target.value)}
|
|
48
|
-
placeholder="~/path/to/a/private/git/repo"
|
|
49
|
-
className="h-11 flex-1 text-base md:h-9 md:text-sm"
|
|
50
|
-
/>
|
|
51
|
-
<Button
|
|
52
|
-
type="submit"
|
|
53
|
-
size="sm"
|
|
54
|
-
className="h-11 sm:h-9"
|
|
55
|
-
disabled={folder === saved || save.isPending}
|
|
56
|
-
>
|
|
57
|
-
Save
|
|
58
|
-
</Button>
|
|
59
|
-
</div>
|
|
60
|
-
<p className="text-muted-foreground text-xs">
|
|
61
|
-
Session logs go to docs/sessions/<project>/ in this repo and are
|
|
62
|
-
committed there, one file per commit.{" "}
|
|
63
|
-
{data.hq?.visibility === "private"
|
|
64
|
-
? "The repo is private, so each commit is pushed."
|
|
65
|
-
: data.hq
|
|
66
|
-
? "Commits stay local: the repo isn't a private GitHub repository."
|
|
67
|
-
: "It must be a private git repo outside the workspace's projects."}
|
|
68
|
-
</p>
|
|
69
|
-
</form>
|
|
21
|
+
<SettingSection>
|
|
22
|
+
<TextSetting
|
|
23
|
+
id={`hq-folder-${workspaceId}`}
|
|
24
|
+
label="HQ folder"
|
|
25
|
+
help={`Session logs go to docs/sessions/<project>/ in this repo, one commit per file. ${
|
|
26
|
+
data.hq?.visibility === "private"
|
|
27
|
+
? "The repo is private, so each commit is pushed."
|
|
28
|
+
: data.hq
|
|
29
|
+
? "Commits stay local: the repo isn't a private GitHub repository."
|
|
30
|
+
: "It must be a private git repo outside the workspace's projects."
|
|
31
|
+
}`}
|
|
32
|
+
placeholder="~/path/to/a/private/git/repo"
|
|
33
|
+
value={data.hq?.folder ?? ""}
|
|
34
|
+
onSave={(folder) => save.mutateAsync(folder)}
|
|
35
|
+
/>
|
|
36
|
+
</SettingSection>
|
|
70
37
|
);
|
|
71
38
|
}
|
|
@@ -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
|
);
|