@saadnvd1/agent-os 0.3.836 → 0.3.845
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/health/route.ts +4 -2
- 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/SessionList/GroupShelves.tsx +8 -3
- package/components/SessionList/OrchestratorPinRow.tsx +2 -2
- package/components/SessionList/RowParts.tsx +39 -35
- package/components/SessionList/RowRename.test.ts +86 -17
- package/components/SessionList/RowShell.tsx +2 -5
- package/components/SessionList/SessionRow.tsx +0 -1
- 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 +168 -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 +428 -0
- package/components/Shares/SharedLinksPanel.tsx +62 -53
- package/components/Workspaces/HqFolderForm.tsx +22 -53
- package/components/Workspaces/McpServersForm.tsx +21 -28
- package/components/Workspaces/ProjectTiersForm.tsx +28 -13
- package/components/Workspaces/RuleEditor.tsx +86 -0
- package/components/Workspaces/RulesForm.test.ts +154 -0
- package/components/Workspaces/RulesForm.tsx +116 -117
- package/components/Workspaces/WorkspaceModeForm.tsx +3 -3
- package/components/Workspaces/WorkspaceModelsForm.tsx +36 -36
- package/components/Workspaces/WorkspacePanel.tsx +73 -0
- package/components/Workspaces/WorkspacePicker.tsx +44 -0
- package/components/Workspaces/WorkspaceSettings.test.ts +153 -0
- package/components/Workspaces/WorkspaceSettings.tsx +63 -52
- package/components/Workspaces/useSettingsWorkspace.ts +47 -0
- package/content/whats-new.json +40 -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/deploy.ts +34 -7
- 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/models/plan-fresh.ts +61 -0
- package/lib/models/plan-step.ts +40 -24
- package/lib/orchestrator/scope.ts +10 -0
- package/lib/settings/defaults.ts +27 -0
- package/lib/settings/parse.ts +17 -0
- package/lib/settings-url.ts +1 -0
- package/lib/updates/banner.ts +2 -0
- package/lib/updates/run.ts +9 -0
- package/lib/updates/view.ts +28 -6
- package/lib/worktree-deps/paths.ts +9 -4
- package/lib/worktree-sweep/limits.ts +3 -0
- package/lib/worktree-sweep/settings.ts +4 -2
- package/lib/worktrees.ts +3 -0
- package/package.json +1 -1
- package/scripts/autodeploy +11 -0
- package/scripts/autodeploy.test.ts +30 -0
- package/scripts/lib/update-verify.mjs +11 -0
- package/scripts/update-run.mjs +29 -0
- package/scripts/update-run.test.ts +62 -4
- 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,54 +1,47 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { SettingBlock, 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
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
</p>
|
|
28
|
-
<p className="text-muted-foreground text-xs">
|
|
29
|
-
All of a ticked server's tools are allowed. Sending a message,
|
|
30
|
-
email or post through one still goes to you as an ask. Takes effect at
|
|
31
|
-
its next turn.
|
|
32
|
-
</p>
|
|
33
|
-
</div>
|
|
24
|
+
<SettingSection
|
|
25
|
+
title="MCP servers"
|
|
26
|
+
help="One switch per server; all its tools are allowed once it's on. Sending a message, email or post through one still asks you. Takes effect at the orchestrator's next turn."
|
|
27
|
+
>
|
|
34
28
|
{names.length === 0 ? (
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
29
|
+
<SettingBlock id={`mcp-none-${workspace.id}`} label="MCP servers">
|
|
30
|
+
<p className="text-muted-foreground text-xs">
|
|
31
|
+
No MCP servers are configured on this machine.
|
|
32
|
+
</p>
|
|
33
|
+
</SettingBlock>
|
|
38
34
|
) : (
|
|
39
35
|
names.map((name) => (
|
|
40
|
-
<
|
|
36
|
+
<SwitchSetting
|
|
41
37
|
key={name}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
onCheckedChange={(on) => toggle(name, on)}
|
|
48
|
-
/>
|
|
49
|
-
</label>
|
|
38
|
+
id={`mcp-${workspace.id}-${name}`}
|
|
39
|
+
label={name}
|
|
40
|
+
checked={allowed.includes(name)}
|
|
41
|
+
onSave={(on) => toggle(name, on)}
|
|
42
|
+
/>
|
|
50
43
|
))
|
|
51
44
|
)}
|
|
52
|
-
</
|
|
45
|
+
</SettingSection>
|
|
53
46
|
);
|
|
54
47
|
}
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { PickerSetting } from "@/components/Settings/PickerSetting";
|
|
4
|
+
import { SettingSection } from "@/components/Settings/SettingRow";
|
|
5
|
+
import { Segmented } from "@/components/ui/segmented";
|
|
4
6
|
import { useProjectsQuery, useUpdateProject } from "@/data/projects";
|
|
5
7
|
|
|
8
|
+
type Tier = "production" | "fast";
|
|
9
|
+
|
|
10
|
+
const TIERS: { value: Tier; label: string }[] = [
|
|
11
|
+
{ value: "production", label: "Production" },
|
|
12
|
+
{ value: "fast", label: "Fast" },
|
|
13
|
+
];
|
|
14
|
+
|
|
6
15
|
// A workspace's projects, each switched between production (every merge gate)
|
|
7
16
|
// and fast (a task's PR merges as soon as it opens).
|
|
8
17
|
export function ProjectTiersForm({ workspaceId }: { workspaceId: string }) {
|
|
@@ -13,21 +22,27 @@ export function ProjectTiersForm({ workspaceId }: { workspaceId: string }) {
|
|
|
13
22
|
);
|
|
14
23
|
if (!mine.length) return null;
|
|
15
24
|
return (
|
|
16
|
-
<
|
|
25
|
+
<SettingSection
|
|
26
|
+
title="Projects"
|
|
27
|
+
help="Production: CI, the independent review and the scope gate every merge. Fast: a task's PR merges as soon as it opens."
|
|
28
|
+
>
|
|
17
29
|
{mine.map((p) => (
|
|
18
|
-
<
|
|
30
|
+
<PickerSetting<Tier>
|
|
19
31
|
key={p.id}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
id={`tier-${p.id}`}
|
|
33
|
+
label={p.name}
|
|
34
|
+
value={p.tier === "production" ? "production" : "fast"}
|
|
35
|
+
onSave={(tier) => update.mutateAsync({ projectId: p.id, tier })}
|
|
36
|
+
render={({ value, commit }) => (
|
|
37
|
+
<Segmented
|
|
38
|
+
label={`${p.name} merge tier`}
|
|
39
|
+
value={value}
|
|
40
|
+
options={TIERS}
|
|
41
|
+
onChange={commit}
|
|
42
|
+
/>
|
|
43
|
+
)}
|
|
29
44
|
/>
|
|
30
45
|
))}
|
|
31
|
-
</
|
|
46
|
+
</SettingSection>
|
|
32
47
|
);
|
|
33
48
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
6
|
+
|
|
7
|
+
export const MAX_RULE_CHARS = 300;
|
|
8
|
+
|
|
9
|
+
// The one editor for a rule, new or existing. Save and Cancel are here only
|
|
10
|
+
// while it's open, and Save waits for a real change.
|
|
11
|
+
export function RuleEditor({
|
|
12
|
+
id,
|
|
13
|
+
label,
|
|
14
|
+
initial,
|
|
15
|
+
saveLabel = "Save",
|
|
16
|
+
placeholder,
|
|
17
|
+
onSave,
|
|
18
|
+
onCancel,
|
|
19
|
+
}: {
|
|
20
|
+
id: string;
|
|
21
|
+
label: string;
|
|
22
|
+
initial: string;
|
|
23
|
+
saveLabel?: string;
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
// Throws to refuse; the editor stays open with what was typed.
|
|
26
|
+
onSave: (text: string) => Promise<unknown>;
|
|
27
|
+
onCancel: () => void;
|
|
28
|
+
}) {
|
|
29
|
+
const [text, setText] = useState(initial);
|
|
30
|
+
const [saving, setSaving] = useState(false);
|
|
31
|
+
const next = text.trim();
|
|
32
|
+
const canSave = !!next && next !== initial.trim() && !saving;
|
|
33
|
+
|
|
34
|
+
const save = async () => {
|
|
35
|
+
if (!canSave) return;
|
|
36
|
+
setSaving(true);
|
|
37
|
+
try {
|
|
38
|
+
await onSave(next);
|
|
39
|
+
} catch {
|
|
40
|
+
setSaving(false);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="space-y-2">
|
|
46
|
+
<Textarea
|
|
47
|
+
id={id}
|
|
48
|
+
autoFocus
|
|
49
|
+
rows={3}
|
|
50
|
+
value={text}
|
|
51
|
+
maxLength={MAX_RULE_CHARS}
|
|
52
|
+
placeholder={placeholder}
|
|
53
|
+
aria-label={label}
|
|
54
|
+
onChange={(e) => setText(e.target.value)}
|
|
55
|
+
onKeyDown={(e) => {
|
|
56
|
+
if (e.key === "Escape") onCancel();
|
|
57
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void save();
|
|
58
|
+
}}
|
|
59
|
+
className="text-base md:text-sm"
|
|
60
|
+
/>
|
|
61
|
+
<div className="flex items-center justify-end gap-2">
|
|
62
|
+
<span className="text-muted-foreground mr-auto text-xs tabular-nums">
|
|
63
|
+
{text.length}/{MAX_RULE_CHARS}
|
|
64
|
+
</span>
|
|
65
|
+
<Button
|
|
66
|
+
type="button"
|
|
67
|
+
variant="ghost"
|
|
68
|
+
size="sm"
|
|
69
|
+
className="h-11 sm:h-9"
|
|
70
|
+
onClick={onCancel}
|
|
71
|
+
>
|
|
72
|
+
Cancel
|
|
73
|
+
</Button>
|
|
74
|
+
<Button
|
|
75
|
+
type="button"
|
|
76
|
+
size="sm"
|
|
77
|
+
className="h-11 sm:h-9"
|
|
78
|
+
disabled={!canSave}
|
|
79
|
+
onClick={() => void save()}
|
|
80
|
+
>
|
|
81
|
+
{saveLabel}
|
|
82
|
+
</Button>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
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: HTMLTextAreaElement, text: string) => {
|
|
53
|
+
const set = Object.getOwnPropertyDescriptor(
|
|
54
|
+
window.HTMLTextAreaElement.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 editor = (id: string) => host.querySelector<HTMLTextAreaElement>(id)!;
|
|
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
|
+
const edit = () => click(host.querySelector('button[aria-label^="Edit rule"]'));
|
|
70
|
+
|
|
71
|
+
describe("RulesForm", () => {
|
|
72
|
+
beforeEach(async () => {
|
|
73
|
+
await act(async () =>
|
|
74
|
+
root.render(createElement(RulesForm, { workspaceId: "w1" }))
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("lists the rule as text with a You badge, and no editor or Save", () => {
|
|
79
|
+
expect(host.textContent).toContain("Never merge #12");
|
|
80
|
+
expect(host.textContent).toContain("You");
|
|
81
|
+
expect(host.querySelector("textarea")).toBeNull();
|
|
82
|
+
expect(button("Save")).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("opens a multi-line editor on a click of the rule or its edit icon", async () => {
|
|
86
|
+
await edit();
|
|
87
|
+
expect(editor("#rule-4").value).toBe("Never merge #12");
|
|
88
|
+
await click(button("Cancel"));
|
|
89
|
+
await click(host.querySelector("li, [data-setting='rule-4'] button"));
|
|
90
|
+
expect(editor("#rule-4")).not.toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("only enables Save once the text has changed", async () => {
|
|
94
|
+
await edit();
|
|
95
|
+
expect(button("Save")!.disabled).toBe(true);
|
|
96
|
+
await type(editor("#rule-4"), "Never merge #13");
|
|
97
|
+
expect(button("Save")!.disabled).toBe(false);
|
|
98
|
+
await type(editor("#rule-4"), "Never merge #12");
|
|
99
|
+
expect(button("Save")!.disabled).toBe(true);
|
|
100
|
+
await type(editor("#rule-4"), " ");
|
|
101
|
+
expect(button("Save")!.disabled).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("never asks for a passkey on blur", async () => {
|
|
105
|
+
await edit();
|
|
106
|
+
await type(editor("#rule-4"), "Never merge #13");
|
|
107
|
+
await act(async () => {
|
|
108
|
+
editor("#rule-4").dispatchEvent(
|
|
109
|
+
new FocusEvent("focusout", { bubbles: true })
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
expect(state.update).not.toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("saves through the passkey step on Save and closes the editor", async () => {
|
|
116
|
+
await edit();
|
|
117
|
+
await type(editor("#rule-4"), "Never merge #13");
|
|
118
|
+
await click(button("Save"));
|
|
119
|
+
expect(state.update).toHaveBeenCalledWith({
|
|
120
|
+
id: 4,
|
|
121
|
+
text: "Never merge #13",
|
|
122
|
+
});
|
|
123
|
+
expect(host.querySelector("textarea")).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("cancel closes the editor and drops what was typed", async () => {
|
|
127
|
+
await edit();
|
|
128
|
+
await type(editor("#rule-4"), "Never merge #13");
|
|
129
|
+
await click(button("Cancel"));
|
|
130
|
+
expect(state.update).not.toHaveBeenCalled();
|
|
131
|
+
expect(host.querySelector("textarea")).toBeNull();
|
|
132
|
+
expect(host.textContent).toContain("Never merge #12");
|
|
133
|
+
await edit();
|
|
134
|
+
expect(editor("#rule-4").value).toBe("Never merge #12");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("keeps the editor open and toasts when the passkey is cancelled", async () => {
|
|
138
|
+
state.update.mockRejectedValue(new Error("Passkey cancelled"));
|
|
139
|
+
await edit();
|
|
140
|
+
await type(editor("#rule-4"), "Never merge #13");
|
|
141
|
+
await click(button("Save"));
|
|
142
|
+
expect(editor("#rule-4").value).toBe("Never merge #13");
|
|
143
|
+
expect(state.toast).toHaveBeenCalledWith("Passkey cancelled");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("adds a rule from the same editor, through the same step", async () => {
|
|
147
|
+
await click(button("Add rule"));
|
|
148
|
+
expect(button("Add rule")!.disabled).toBe(true);
|
|
149
|
+
await type(editor("#rule-new-w1"), "House rule");
|
|
150
|
+
await click(button("Add rule"));
|
|
151
|
+
expect(state.add).toHaveBeenCalledWith("House rule");
|
|
152
|
+
expect(host.querySelector("textarea")).toBeNull();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState } from "react";
|
|
4
|
-
import {
|
|
3
|
+
import { useContext, useState } from "react";
|
|
4
|
+
import { Pencil, Plus, Trash2 } from "lucide-react";
|
|
5
5
|
import { toast } from "sonner";
|
|
6
6
|
import { Button } from "@/components/ui/button";
|
|
7
|
-
import {
|
|
7
|
+
import { StatusPill } from "@/components/ui/status-pill";
|
|
8
|
+
import {
|
|
9
|
+
matchesQuery,
|
|
10
|
+
SettingBlock,
|
|
11
|
+
SettingSection,
|
|
12
|
+
SettingsQueryContext,
|
|
13
|
+
} from "@/components/Settings/SettingRow";
|
|
8
14
|
import type { RuleRow } from "@/lib/orchestrator/rules";
|
|
9
15
|
import {
|
|
10
16
|
useAddRule,
|
|
@@ -12,64 +18,78 @@ import {
|
|
|
12
18
|
useRulesQuery,
|
|
13
19
|
useUpdateRule,
|
|
14
20
|
} from "@/data/orchestrators/rules";
|
|
21
|
+
import { RuleEditor } from "./RuleEditor";
|
|
15
22
|
|
|
16
|
-
const
|
|
23
|
+
const ROW = "border-foreground/5 border-t px-3 py-2 first:border-t-0";
|
|
17
24
|
|
|
18
25
|
// The orchestrator's standing rules for one workspace: what it keeps
|
|
19
26
|
// knowing across fresh starts. It adds them itself too; Saad edits here.
|
|
27
|
+
// Every add, edit and remove takes his passkey (data/orchestrators/rules), so
|
|
28
|
+
// nothing saves until Save, never on blur; a cancelled or refused passkey
|
|
29
|
+
// leaves the editor open with what was typed.
|
|
20
30
|
export function RulesForm({ workspaceId }: { workspaceId: string }) {
|
|
21
31
|
const { data: rules, isPending, isError, error } = useRulesQuery(workspaceId);
|
|
22
32
|
const add = useAddRule(workspaceId);
|
|
23
|
-
const [
|
|
33
|
+
const [adding, setAdding] = useState(false);
|
|
34
|
+
const query = useContext(SettingsQueryContext);
|
|
24
35
|
|
|
25
36
|
return (
|
|
26
|
-
<
|
|
27
|
-
|
|
37
|
+
<SettingSection
|
|
38
|
+
title="Rules"
|
|
39
|
+
help="What the orchestrator keeps across fresh starts, like a PR never to merge. Each change asks for your passkey."
|
|
40
|
+
>
|
|
28
41
|
{isPending ? (
|
|
29
|
-
<div className="bg-muted/60 h-11 animate-pulse rounded-md" />
|
|
42
|
+
<div className="bg-muted/60 m-3 h-11 animate-pulse rounded-md" />
|
|
30
43
|
) : isError ? (
|
|
31
|
-
<p className="text-destructive text-
|
|
32
|
-
) :
|
|
33
|
-
|
|
44
|
+
<p className="text-destructive px-3 py-2 text-sm">{error.message}</p>
|
|
45
|
+
) : (
|
|
46
|
+
<>
|
|
47
|
+
{rules.length === 0 && !adding && (
|
|
48
|
+
<SettingBlock id={`rules-none-${workspaceId}`} label="Rules">
|
|
49
|
+
<p className="text-muted-foreground text-xs">None yet.</p>
|
|
50
|
+
</SettingBlock>
|
|
51
|
+
)}
|
|
34
52
|
{rules.map((r) => (
|
|
35
53
|
<RuleItem key={`${r.id}:${r.text}`} rule={r} />
|
|
36
54
|
))}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
55
|
+
{matchesQuery(query, "add rule") && (
|
|
56
|
+
<div data-setting={`rule-new-${workspaceId}`} className={ROW}>
|
|
57
|
+
{adding ? (
|
|
58
|
+
<RuleEditor
|
|
59
|
+
id={`rule-new-${workspaceId}`}
|
|
60
|
+
label="New rule"
|
|
61
|
+
initial=""
|
|
62
|
+
saveLabel="Add rule"
|
|
63
|
+
placeholder="A PR never to merge, a URL, a house rule"
|
|
64
|
+
onSave={async (text) => {
|
|
65
|
+
try {
|
|
66
|
+
await add.mutateAsync(text);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
toast.error(
|
|
69
|
+
err instanceof Error ? err.message : "Couldn't add"
|
|
70
|
+
);
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
setAdding(false);
|
|
74
|
+
}}
|
|
75
|
+
onCancel={() => setAdding(false)}
|
|
76
|
+
/>
|
|
77
|
+
) : (
|
|
78
|
+
<Button
|
|
79
|
+
variant="ghost"
|
|
80
|
+
size="sm"
|
|
81
|
+
className="text-primary -ml-2 h-11 sm:h-9"
|
|
82
|
+
onClick={() => setAdding(true)}
|
|
83
|
+
>
|
|
84
|
+
<Plus className="size-4" />
|
|
85
|
+
Add rule
|
|
86
|
+
</Button>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
</>
|
|
43
91
|
)}
|
|
44
|
-
|
|
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
|
-
</div>
|
|
92
|
+
</SettingSection>
|
|
73
93
|
);
|
|
74
94
|
}
|
|
75
95
|
|
|
@@ -77,90 +97,69 @@ function RuleItem({ rule }: { rule: RuleRow }) {
|
|
|
77
97
|
const update = useUpdateRule(rule.workspace_id);
|
|
78
98
|
const remove = useRemoveRule(rule.workspace_id);
|
|
79
99
|
const [editing, setEditing] = useState(false);
|
|
80
|
-
const
|
|
100
|
+
const query = useContext(SettingsQueryContext);
|
|
101
|
+
if (!matchesQuery(query, rule.text)) return null;
|
|
81
102
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
103
|
+
return (
|
|
104
|
+
<div data-setting={`rule-${rule.id}`} className={ROW}>
|
|
105
|
+
{editing ? (
|
|
106
|
+
<RuleEditor
|
|
107
|
+
id={`rule-${rule.id}`}
|
|
108
|
+
label="Edit rule"
|
|
109
|
+
initial={rule.text}
|
|
110
|
+
onSave={async (text) => {
|
|
111
|
+
try {
|
|
112
|
+
await update.mutateAsync({ id: rule.id, text });
|
|
113
|
+
} catch (err) {
|
|
114
|
+
toast.error(err instanceof Error ? err.message : "Couldn't save");
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
setEditing(false);
|
|
95
118
|
}}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
119
|
+
onCancel={() => setEditing(false)}
|
|
120
|
+
/>
|
|
121
|
+
) : (
|
|
122
|
+
<div className="flex items-start gap-1">
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
onClick={() => setEditing(true)}
|
|
126
|
+
className="hover:bg-muted/60 -my-1 min-h-11 min-w-0 flex-1 cursor-pointer rounded-md px-2 py-1 text-left"
|
|
127
|
+
>
|
|
128
|
+
<StatusPill
|
|
129
|
+
tone={rule.added_by === "saad" ? "primary" : "muted"}
|
|
130
|
+
className="mb-1"
|
|
131
|
+
>
|
|
132
|
+
{rule.added_by === "saad" ? "You" : "Orchestrator"}
|
|
133
|
+
</StatusPill>
|
|
134
|
+
<span className="block text-sm break-words whitespace-pre-wrap">
|
|
135
|
+
{rule.text}
|
|
136
|
+
</span>
|
|
137
|
+
</button>
|
|
106
138
|
<Button
|
|
107
|
-
type="submit"
|
|
108
139
|
variant="ghost"
|
|
109
140
|
size="icon"
|
|
110
141
|
className="size-11 shrink-0 sm:size-8"
|
|
111
|
-
|
|
112
|
-
aria-label=
|
|
142
|
+
onClick={() => setEditing(true)}
|
|
143
|
+
aria-label={`Edit rule: ${rule.text}`}
|
|
113
144
|
>
|
|
114
|
-
<
|
|
145
|
+
<Pencil className="size-4" />
|
|
115
146
|
</Button>
|
|
116
147
|
<Button
|
|
117
|
-
type="button"
|
|
118
148
|
variant="ghost"
|
|
119
149
|
size="icon"
|
|
120
150
|
className="size-11 shrink-0 sm:size-8"
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
151
|
+
disabled={remove.isPending}
|
|
152
|
+
onClick={() =>
|
|
153
|
+
remove.mutate(rule.id, {
|
|
154
|
+
onError: (err) => toast.error(err.message),
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
aria-label={`Remove rule: ${rule.text}`}
|
|
126
158
|
>
|
|
127
|
-
<
|
|
159
|
+
<Trash2 className="size-4" />
|
|
128
160
|
</Button>
|
|
129
|
-
</
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
return (
|
|
134
|
-
<li className="bg-background/60 flex items-start gap-1 rounded-md py-1 pr-1 pl-2.5">
|
|
135
|
-
<div className="min-w-0 flex-1 py-1.5">
|
|
136
|
-
<p className="text-sm break-words">{rule.text}</p>
|
|
137
|
-
<p className="text-muted-foreground text-xs">
|
|
138
|
-
{rule.added_by === "saad" ? "You" : "Orchestrator"}
|
|
139
|
-
</p>
|
|
140
|
-
</div>
|
|
141
|
-
<Button
|
|
142
|
-
variant="ghost"
|
|
143
|
-
size="icon"
|
|
144
|
-
className="size-11 shrink-0 sm:size-8"
|
|
145
|
-
onClick={() => setEditing(true)}
|
|
146
|
-
aria-label={`Edit rule: ${rule.text}`}
|
|
147
|
-
>
|
|
148
|
-
<Pencil className="size-4" />
|
|
149
|
-
</Button>
|
|
150
|
-
<Button
|
|
151
|
-
variant="ghost"
|
|
152
|
-
size="icon"
|
|
153
|
-
className="size-11 shrink-0 sm:size-8"
|
|
154
|
-
disabled={remove.isPending}
|
|
155
|
-
onClick={() =>
|
|
156
|
-
remove.mutate(rule.id, {
|
|
157
|
-
onError: (err) => toast.error(err.message),
|
|
158
|
-
})
|
|
159
|
-
}
|
|
160
|
-
aria-label={`Remove rule: ${rule.text}`}
|
|
161
|
-
>
|
|
162
|
-
<Trash2 className="size-4" />
|
|
163
|
-
</Button>
|
|
164
|
-
</li>
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
165
164
|
);
|
|
166
165
|
}
|