@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.
Files changed (81) hide show
  1. package/app/api/fresh-start/route.test.ts +33 -0
  2. package/app/api/fresh-start/route.ts +3 -0
  3. package/app/api/health/route.ts +4 -2
  4. package/components/Devices/DeviceRow.tsx +83 -69
  5. package/components/Devices/PasskeysSection.test.ts +89 -0
  6. package/components/Devices/PasskeysSection.tsx +36 -26
  7. package/components/Health/HealthPanel.tsx +41 -16
  8. package/components/Merge/GlobalMergeSection.tsx +2 -5
  9. package/components/Merge/MergeSettingsFields.tsx +39 -77
  10. package/components/Merge/ProjectMergeSection.tsx +2 -6
  11. package/components/Merge/WorktreeSweepSection.tsx +40 -14
  12. package/components/Models/AgentRoleCard.tsx +61 -66
  13. package/components/Models/AgentRolesSection.tsx +5 -8
  14. package/components/Models/ModelsSection.tsx +7 -18
  15. package/components/Models/PriceInput.tsx +59 -0
  16. package/components/PhoneNotify/PhoneNotifyForm.tsx +37 -58
  17. package/components/SessionList/GroupShelves.tsx +8 -3
  18. package/components/SessionList/OrchestratorPinRow.tsx +2 -2
  19. package/components/SessionList/RowParts.tsx +39 -35
  20. package/components/SessionList/RowRename.test.ts +86 -17
  21. package/components/SessionList/RowShell.tsx +2 -5
  22. package/components/SessionList/SessionRow.tsx +0 -1
  23. package/components/Settings/AppWideLimits.tsx +61 -0
  24. package/components/Settings/ContextSection.tsx +96 -0
  25. package/components/Settings/ExperimentsSection.test.ts +9 -7
  26. package/components/Settings/ExperimentsSection.tsx +22 -23
  27. package/components/Settings/InlineConfirm.tsx +51 -0
  28. package/components/Settings/OwnerNameRow.tsx +10 -40
  29. package/components/Settings/PickerSetting.tsx +59 -0
  30. package/components/Settings/SettingRow.tsx +168 -0
  31. package/components/Settings/SettingsDialog.test.ts +1 -0
  32. package/components/Settings/SettingsDialog.tsx +47 -2
  33. package/components/Settings/settings-fields.test.ts +103 -0
  34. package/components/Settings/settings-fields.tsx +428 -0
  35. package/components/Shares/SharedLinksPanel.tsx +62 -53
  36. package/components/Workspaces/HqFolderForm.tsx +22 -53
  37. package/components/Workspaces/McpServersForm.tsx +21 -28
  38. package/components/Workspaces/ProjectTiersForm.tsx +28 -13
  39. package/components/Workspaces/RuleEditor.tsx +86 -0
  40. package/components/Workspaces/RulesForm.test.ts +154 -0
  41. package/components/Workspaces/RulesForm.tsx +116 -117
  42. package/components/Workspaces/WorkspaceModeForm.tsx +3 -3
  43. package/components/Workspaces/WorkspaceModelsForm.tsx +36 -36
  44. package/components/Workspaces/WorkspacePanel.tsx +73 -0
  45. package/components/Workspaces/WorkspacePicker.tsx +44 -0
  46. package/components/Workspaces/WorkspaceSettings.test.ts +153 -0
  47. package/components/Workspaces/WorkspaceSettings.tsx +63 -52
  48. package/components/Workspaces/useSettingsWorkspace.ts +47 -0
  49. package/content/whats-new.json +40 -0
  50. package/data/fresh-start/index.ts +1 -1
  51. package/data/workspaces/index.ts +9 -0
  52. package/hooks/useAutoSave.test.ts +116 -0
  53. package/hooks/useAutoSave.ts +98 -0
  54. package/lib/away/deploy.ts +34 -7
  55. package/lib/away/window-limits.ts +3 -1
  56. package/lib/chat/refresh.ts +10 -6
  57. package/lib/chat/stall-limits.ts +3 -1
  58. package/lib/models/plan-fresh.ts +61 -0
  59. package/lib/models/plan-step.ts +40 -24
  60. package/lib/orchestrator/scope.ts +10 -0
  61. package/lib/settings/defaults.ts +27 -0
  62. package/lib/settings/parse.ts +17 -0
  63. package/lib/settings-url.ts +1 -0
  64. package/lib/updates/banner.ts +2 -0
  65. package/lib/updates/run.ts +9 -0
  66. package/lib/updates/view.ts +28 -6
  67. package/lib/worktree-deps/paths.ts +9 -4
  68. package/lib/worktree-sweep/limits.ts +3 -0
  69. package/lib/worktree-sweep/settings.ts +4 -2
  70. package/lib/worktrees.ts +3 -0
  71. package/package.json +1 -1
  72. package/scripts/autodeploy +11 -0
  73. package/scripts/autodeploy.test.ts +30 -0
  74. package/scripts/lib/update-verify.mjs +11 -0
  75. package/scripts/update-run.mjs +29 -0
  76. package/scripts/update-run.test.ts +62 -4
  77. package/components/Away/AwayWindowForm.tsx +0 -91
  78. package/components/Workspaces/FreshStartForm.tsx +0 -177
  79. package/components/Workspaces/StallForm.tsx +0 -101
  80. package/components/Workspaces/TaskLimitForm.tsx +0 -84
  81. package/components/Workspaces/fresh-start.ts +0 -19
@@ -1,54 +1,47 @@
1
1
  "use client";
2
2
 
3
- import { Switch } from "@/components/ui/switch";
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 ticked here.
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
- // A ticked server no longer configured stays listed, so it can be unticked.
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.mutate({
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="bg-muted/40 space-y-2 rounded-lg px-3 py-2.5">
24
- <div>
25
- <p className="text-sm font-medium">
26
- MCP servers its orchestrator may use
27
- </p>
28
- <p className="text-muted-foreground text-xs">
29
- All of a ticked server&apos;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
- <p className="text-muted-foreground text-xs">
36
- No MCP servers are configured on this machine.
37
- </p>
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
- <label
36
+ <SwitchSetting
41
37
  key={name}
42
- className="flex min-h-11 items-center justify-between gap-3 text-sm"
43
- >
44
- <span className="min-w-0 truncate">{name}</span>
45
- <Switch
46
- checked={allowed.includes(name)}
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
- </div>
45
+ </SettingSection>
53
46
  );
54
47
  }
@@ -1,8 +1,17 @@
1
1
  "use client";
2
2
 
3
- import { SettingToggle } from "@/components/Settings/SettingToggle";
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
- <div className="space-y-2">
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
- <SettingToggle
30
+ <PickerSetting<Tier>
19
31
  key={p.id}
20
- title={`${p.name}: production`}
21
- detail="On: CI, the independent review and scope gate its merges. Off (fast): a task's PR merges as soon as it opens."
22
- checked={p.tier === "production"}
23
- onChange={(on) =>
24
- update.mutate({
25
- projectId: p.id,
26
- tier: on ? "production" : "fast",
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
- </div>
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 { Check, Pencil, Trash2, X } from "lucide-react";
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 { Input } from "@/components/ui/input";
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 MAX_CHARS = 300;
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 [text, setText] = useState("");
33
+ const [adding, setAdding] = useState(false);
34
+ const query = useContext(SettingsQueryContext);
24
35
 
25
36
  return (
26
- <div className="space-y-2">
27
- <div className="text-sm">Orchestrator rules</div>
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-xs">{error.message}</p>
32
- ) : rules.length ? (
33
- <ul className="space-y-1">
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
- </ul>
38
- ) : (
39
- <p className="text-muted-foreground text-xs">
40
- None yet. The orchestrator keeps one when you tell it something
41
- standing, like a PR never to merge.
42
- </p>
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
- <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
- </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 [text, setText] = useState(rule.text);
100
+ const query = useContext(SettingsQueryContext);
101
+ if (!matchesQuery(query, rule.text)) return null;
81
102
 
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
- );
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
- 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
- />
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
- disabled={!text.trim() || update.isPending}
112
- aria-label="Save rule"
142
+ onClick={() => setEditing(true)}
143
+ aria-label={`Edit rule: ${rule.text}`}
113
144
  >
114
- <Check className="size-4" />
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
- onClick={() => {
122
- setText(rule.text);
123
- setEditing(false);
124
- }}
125
- aria-label="Cancel"
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
- <X className="size-4" />
159
+ <Trash2 className="size-4" />
128
160
  </Button>
129
- </form>
130
- </li>
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
  }