@saadnvd1/agent-os 0.3.839 → 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/health/route.ts +4 -2
- 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/SettingRow.tsx +82 -45
- package/components/Settings/settings-fields.tsx +11 -10
- package/components/Workspaces/HqFolderForm.tsx +21 -19
- package/components/Workspaces/McpServersForm.tsx +20 -28
- package/components/Workspaces/ProjectTiersForm.tsx +25 -12
- package/components/Workspaces/RuleEditor.tsx +86 -0
- package/components/Workspaces/RulesForm.test.ts +58 -31
- package/components/Workspaces/RulesForm.tsx +129 -58
- package/components/Workspaces/WorkspaceModeForm.tsx +3 -3
- package/components/Workspaces/WorkspaceModelsForm.tsx +40 -41
- 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 +51 -77
- package/components/Workspaces/useSettingsWorkspace.ts +47 -0
- package/content/whats-new.json +24 -0
- package/lib/away/deploy.ts +34 -7
- 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/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/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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { SettingSection } from "@/components/Settings/SettingRow";
|
|
3
|
+
import { SettingBlock, SettingSection } from "@/components/Settings/SettingRow";
|
|
4
4
|
import { SwitchSetting } from "@/components/Settings/settings-fields";
|
|
5
5
|
import { useMcpServersQuery, useUpdateWorkspace } from "@/data/workspaces";
|
|
6
6
|
import type { Workspace } from "@/lib/db";
|
|
@@ -21,35 +21,27 @@ export function McpServersForm({ workspace }: { workspace: Workspace }) {
|
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
return (
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
</p>
|
|
29
|
-
<p className="text-muted-foreground text-xs">
|
|
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.
|
|
33
|
-
</p>
|
|
34
|
-
</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
|
+
>
|
|
35
28
|
{names.length === 0 ? (
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
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>
|
|
39
34
|
) : (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
/>
|
|
50
|
-
))}
|
|
51
|
-
</SettingSection>
|
|
35
|
+
names.map((name) => (
|
|
36
|
+
<SwitchSetting
|
|
37
|
+
key={name}
|
|
38
|
+
id={`mcp-${workspace.id}-${name}`}
|
|
39
|
+
label={name}
|
|
40
|
+
checked={allowed.includes(name)}
|
|
41
|
+
onSave={(on) => toggle(name, on)}
|
|
42
|
+
/>
|
|
43
|
+
))
|
|
52
44
|
)}
|
|
53
|
-
</
|
|
45
|
+
</SettingSection>
|
|
54
46
|
);
|
|
55
47
|
}
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { PickerSetting } from "@/components/Settings/PickerSetting";
|
|
3
4
|
import { SettingSection } from "@/components/Settings/SettingRow";
|
|
4
|
-
import {
|
|
5
|
+
import { Segmented } from "@/components/ui/segmented";
|
|
5
6
|
import { useProjectsQuery, useUpdateProject } from "@/data/projects";
|
|
6
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
|
+
|
|
7
15
|
// A workspace's projects, each switched between production (every merge gate)
|
|
8
16
|
// and fast (a task's PR merges as soon as it opens).
|
|
9
17
|
export function ProjectTiersForm({ workspaceId }: { workspaceId: string }) {
|
|
@@ -14,20 +22,25 @@ export function ProjectTiersForm({ workspaceId }: { workspaceId: string }) {
|
|
|
14
22
|
);
|
|
15
23
|
if (!mine.length) return null;
|
|
16
24
|
return (
|
|
17
|
-
<SettingSection
|
|
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
|
+
>
|
|
18
29
|
{mine.map((p) => (
|
|
19
|
-
<
|
|
30
|
+
<PickerSetting<Tier>
|
|
20
31
|
key={p.id}
|
|
21
32
|
id={`tier-${p.id}`}
|
|
22
|
-
label={
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
+
)}
|
|
31
44
|
/>
|
|
32
45
|
))}
|
|
33
46
|
</SettingSection>
|
|
@@ -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
|
+
}
|
|
@@ -49,9 +49,9 @@ afterEach(async () => {
|
|
|
49
49
|
host.remove();
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
const type = async (input:
|
|
52
|
+
const type = async (input: HTMLTextAreaElement, text: string) => {
|
|
53
53
|
const set = Object.getOwnPropertyDescriptor(
|
|
54
|
-
|
|
54
|
+
window.HTMLTextAreaElement.prototype,
|
|
55
55
|
"value"
|
|
56
56
|
)!.set!;
|
|
57
57
|
await act(async () => {
|
|
@@ -59,13 +59,14 @@ const type = async (input: HTMLInputElement, text: string) => {
|
|
|
59
59
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
60
60
|
});
|
|
61
61
|
};
|
|
62
|
-
const
|
|
62
|
+
const editor = (id: string) => host.querySelector<HTMLTextAreaElement>(id)!;
|
|
63
63
|
const click = (el: Element | null | undefined) =>
|
|
64
64
|
act(async () => {
|
|
65
65
|
el?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
|
66
66
|
});
|
|
67
67
|
const button = (name: string) =>
|
|
68
68
|
[...host.querySelectorAll("button")].find((b) => b.textContent === name);
|
|
69
|
+
const edit = () => click(host.querySelector('button[aria-label^="Edit rule"]'));
|
|
69
70
|
|
|
70
71
|
describe("RulesForm", () => {
|
|
71
72
|
beforeEach(async () => {
|
|
@@ -74,54 +75,80 @@ describe("RulesForm", () => {
|
|
|
74
75
|
);
|
|
75
76
|
});
|
|
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
|
+
|
|
77
104
|
it("never asks for a passkey on blur", async () => {
|
|
78
|
-
await
|
|
105
|
+
await edit();
|
|
106
|
+
await type(editor("#rule-4"), "Never merge #13");
|
|
79
107
|
await act(async () => {
|
|
80
|
-
rule
|
|
108
|
+
editor("#rule-4").dispatchEvent(
|
|
109
|
+
new FocusEvent("focusout", { bubbles: true })
|
|
110
|
+
);
|
|
81
111
|
});
|
|
82
112
|
expect(state.update).not.toHaveBeenCalled();
|
|
83
113
|
});
|
|
84
114
|
|
|
85
|
-
it("saves through the passkey step on
|
|
86
|
-
await
|
|
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");
|
|
87
118
|
await click(button("Save"));
|
|
88
119
|
expect(state.update).toHaveBeenCalledWith({
|
|
89
120
|
id: 4,
|
|
90
121
|
text: "Never merge #13",
|
|
91
122
|
});
|
|
92
|
-
expect(host.
|
|
123
|
+
expect(host.querySelector("textarea")).toBeNull();
|
|
93
124
|
});
|
|
94
125
|
|
|
95
|
-
it("
|
|
96
|
-
await
|
|
97
|
-
await
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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");
|
|
103
135
|
});
|
|
104
136
|
|
|
105
|
-
it("
|
|
137
|
+
it("keeps the editor open and toasts when the passkey is cancelled", async () => {
|
|
106
138
|
state.update.mockRejectedValue(new Error("Passkey cancelled"));
|
|
107
|
-
await
|
|
139
|
+
await edit();
|
|
140
|
+
await type(editor("#rule-4"), "Never merge #13");
|
|
108
141
|
await click(button("Save"));
|
|
109
|
-
expect(rule
|
|
142
|
+
expect(editor("#rule-4").value).toBe("Never merge #13");
|
|
110
143
|
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
144
|
});
|
|
120
145
|
|
|
121
|
-
it("adds a rule through the same step", async () => {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
await
|
|
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"));
|
|
125
151
|
expect(state.add).toHaveBeenCalledWith("House rule");
|
|
152
|
+
expect(host.querySelector("textarea")).toBeNull();
|
|
126
153
|
});
|
|
127
154
|
});
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useContext, useState } from "react";
|
|
4
|
+
import { Pencil, Plus, Trash2 } from "lucide-react";
|
|
4
5
|
import { toast } from "sonner";
|
|
5
6
|
import { Button } from "@/components/ui/button";
|
|
6
|
-
import {
|
|
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,83 +18,148 @@ 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.
|
|
20
|
-
// Every
|
|
21
|
-
//
|
|
22
|
-
// the
|
|
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.
|
|
23
30
|
export function RulesForm({ workspaceId }: { workspaceId: string }) {
|
|
24
31
|
const { data: rules, isPending, isError, error } = useRulesQuery(workspaceId);
|
|
25
32
|
const add = useAddRule(workspaceId);
|
|
33
|
+
const [adding, setAdding] = useState(false);
|
|
34
|
+
const query = useContext(SettingsQueryContext);
|
|
26
35
|
|
|
27
36
|
return (
|
|
28
|
-
<
|
|
29
|
-
|
|
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
|
+
>
|
|
30
41
|
{isPending ? (
|
|
31
|
-
<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" />
|
|
32
43
|
) : isError ? (
|
|
33
|
-
<p className="text-destructive text-
|
|
44
|
+
<p className="text-destructive px-3 py-2 text-sm">{error.message}</p>
|
|
34
45
|
) : (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
rules
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
>
|
|
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
|
+
)}
|
|
42
52
|
{rules.map((r) => (
|
|
43
53
|
<RuleItem key={`${r.id}:${r.text}`} rule={r} />
|
|
44
54
|
))}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
</>
|
|
57
91
|
)}
|
|
58
|
-
</
|
|
92
|
+
</SettingSection>
|
|
59
93
|
);
|
|
60
94
|
}
|
|
61
95
|
|
|
62
96
|
function RuleItem({ rule }: { rule: RuleRow }) {
|
|
63
97
|
const update = useUpdateRule(rule.workspace_id);
|
|
64
98
|
const remove = useRemoveRule(rule.workspace_id);
|
|
99
|
+
const [editing, setEditing] = useState(false);
|
|
100
|
+
const query = useContext(SettingsQueryContext);
|
|
101
|
+
if (!matchesQuery(query, rule.text)) return null;
|
|
102
|
+
|
|
65
103
|
return (
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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);
|
|
118
|
+
}}
|
|
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>
|
|
138
|
+
<Button
|
|
139
|
+
variant="ghost"
|
|
140
|
+
size="icon"
|
|
141
|
+
className="size-11 shrink-0 sm:size-8"
|
|
142
|
+
onClick={() => setEditing(true)}
|
|
143
|
+
aria-label={`Edit rule: ${rule.text}`}
|
|
144
|
+
>
|
|
145
|
+
<Pencil className="size-4" />
|
|
146
|
+
</Button>
|
|
147
|
+
<Button
|
|
148
|
+
variant="ghost"
|
|
149
|
+
size="icon"
|
|
150
|
+
className="size-11 shrink-0 sm:size-8"
|
|
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}`}
|
|
158
|
+
>
|
|
159
|
+
<Trash2 className="size-4" />
|
|
160
|
+
</Button>
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
93
164
|
);
|
|
94
165
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { SettingBlock } from "@/components/Settings/SettingRow";
|
|
3
4
|
import { ModeChooser } from "@/components/Report/ModeChooser";
|
|
4
5
|
import { useSetWorkspaceMode, useWorkspaceMode } from "@/data/reports";
|
|
5
6
|
|
|
@@ -9,14 +10,13 @@ export function WorkspaceModeForm({ workspaceId }: { workspaceId: string }) {
|
|
|
9
10
|
const set = useSetWorkspaceMode(workspaceId);
|
|
10
11
|
if (!mode) return null;
|
|
11
12
|
return (
|
|
12
|
-
<
|
|
13
|
-
<h4 className="text-sm font-medium">Operating mode</h4>
|
|
13
|
+
<SettingBlock id={`mode-${workspaceId}`} label="Operating mode">
|
|
14
14
|
<ModeChooser
|
|
15
15
|
mode={mode}
|
|
16
16
|
onChange={(m) => set.mutate(m)}
|
|
17
17
|
disabled={set.isPending}
|
|
18
18
|
note="New tasks start in this mode; any task can change its own."
|
|
19
19
|
/>
|
|
20
|
-
</
|
|
20
|
+
</SettingBlock>
|
|
21
21
|
);
|
|
22
22
|
}
|