@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.
Files changed (74) 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/sessions/route.ts +7 -1
  4. package/app/globals.css +14 -0
  5. package/components/Chat/ChatPanel.tsx +41 -29
  6. package/components/Chat/Forks.tsx +16 -1
  7. package/components/Chat/Messages.tsx +14 -0
  8. package/components/Chat/WorktreeRemovedNotice.tsx +17 -0
  9. package/components/Chat/composer/ComposerBody.tsx +10 -1
  10. package/components/Chat/composer/ForkHeader.tsx +26 -15
  11. package/components/Chat/fork-draft.test.ts +57 -0
  12. package/components/Chat/fork-draft.ts +34 -0
  13. package/components/Devices/DeviceRow.tsx +83 -69
  14. package/components/Devices/PasskeysSection.test.ts +89 -0
  15. package/components/Devices/PasskeysSection.tsx +36 -26
  16. package/components/Health/HealthPanel.tsx +41 -16
  17. package/components/Merge/GlobalMergeSection.tsx +2 -5
  18. package/components/Merge/MergeSettingsFields.tsx +39 -77
  19. package/components/Merge/ProjectMergeSection.tsx +2 -6
  20. package/components/Merge/WorktreeSweepSection.tsx +40 -14
  21. package/components/Models/AgentRoleCard.tsx +61 -66
  22. package/components/Models/AgentRolesSection.tsx +5 -8
  23. package/components/Models/ModelsSection.tsx +7 -18
  24. package/components/Models/PriceInput.tsx +59 -0
  25. package/components/PhoneNotify/PhoneNotifyForm.tsx +37 -58
  26. package/components/Settings/AppWideLimits.tsx +61 -0
  27. package/components/Settings/ContextSection.tsx +96 -0
  28. package/components/Settings/ExperimentsSection.test.ts +9 -7
  29. package/components/Settings/ExperimentsSection.tsx +22 -23
  30. package/components/Settings/InlineConfirm.tsx +51 -0
  31. package/components/Settings/OwnerNameRow.tsx +10 -40
  32. package/components/Settings/PickerSetting.tsx +59 -0
  33. package/components/Settings/SettingRow.tsx +131 -0
  34. package/components/Settings/SettingsDialog.test.ts +1 -0
  35. package/components/Settings/SettingsDialog.tsx +47 -2
  36. package/components/Settings/settings-fields.test.ts +103 -0
  37. package/components/Settings/settings-fields.tsx +427 -0
  38. package/components/Shares/SharedLinksPanel.tsx +62 -53
  39. package/components/Workspaces/HqFolderForm.tsx +21 -54
  40. package/components/Workspaces/McpServersForm.tsx +20 -19
  41. package/components/Workspaces/ProjectTiersForm.tsx +10 -8
  42. package/components/Workspaces/RulesForm.test.ts +127 -0
  43. package/components/Workspaces/RulesForm.tsx +54 -126
  44. package/components/Workspaces/WorkspaceModelsForm.tsx +27 -26
  45. package/components/Workspaces/WorkspaceSettings.tsx +61 -24
  46. package/content/whats-new.json +32 -0
  47. package/data/fresh-start/index.ts +1 -1
  48. package/data/workspaces/index.ts +9 -0
  49. package/hooks/useAutoSave.test.ts +116 -0
  50. package/hooks/useAutoSave.ts +98 -0
  51. package/lib/away/window-limits.ts +3 -1
  52. package/lib/chat/refresh.ts +10 -6
  53. package/lib/chat/socket.ts +7 -3
  54. package/lib/chat/stall-limits.ts +3 -1
  55. package/lib/chat/start-error.ts +13 -0
  56. package/lib/chat/worker/cwd.ts +77 -0
  57. package/lib/chat/worker/host.ts +11 -2
  58. package/lib/chat/worker/main.ts +11 -2
  59. package/lib/db/types.ts +2 -0
  60. package/lib/fork/note.ts +6 -0
  61. package/lib/fork/start.ts +6 -2
  62. package/lib/fork/types.ts +2 -0
  63. package/lib/fork/view.ts +14 -1
  64. package/lib/settings/defaults.ts +27 -0
  65. package/lib/settings/parse.ts +17 -0
  66. package/lib/settings-url.ts +1 -0
  67. package/lib/worktree-sweep/limits.ts +3 -0
  68. package/lib/worktree-sweep/settings.ts +4 -2
  69. package/package.json +1 -1
  70. package/components/Away/AwayWindowForm.tsx +0 -91
  71. package/components/Workspaces/FreshStartForm.tsx +0 -177
  72. package/components/Workspaces/StallForm.tsx +0 -101
  73. package/components/Workspaces/TaskLimitForm.tsx +0 -84
  74. package/components/Workspaces/fresh-start.ts +0 -19
@@ -1,20 +1,16 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
4
- import { toast } from "sonner";
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
- <form
26
- onSubmit={(e) => {
27
- e.preventDefault();
28
- save.mutate(folder, {
29
- onSuccess: () => {
30
- setDraft(null);
31
- toast.success(
32
- folder.trim() ? "HQ folder saved" : "HQ folder cleared"
33
- );
34
- },
35
- onError: (err) => toast.error(err.message),
36
- });
37
- }}
38
- className="space-y-1.5"
39
- >
40
- <label htmlFor={id} className="text-sm">
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/&lt;project&gt;/ 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 { Switch } from "@/components/ui/switch";
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 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 className="space-y-2">
24
25
  <div>
25
- <p className="text-sm font-medium">
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 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.
30
+ All of a server&apos;s tools are allowed once it&apos;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
- names.map((name) => (
40
- <label
41
- 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
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
- onCheckedChange={(on) => toggle(name, on)}
47
+ defaultValue={false}
48
+ onSave={(on) => toggle(name, on)}
48
49
  />
49
- </label>
50
- ))
50
+ ))}
51
+ </SettingSection>
51
52
  )}
52
53
  </div>
53
54
  );
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { SettingToggle } from "@/components/Settings/SettingToggle";
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
- <div className="space-y-2">
17
+ <SettingSection>
17
18
  {mine.map((p) => (
18
- <SettingToggle
19
+ <SwitchSetting
19
20
  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."
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
- onChange={(on) =>
24
- update.mutate({
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
- </div>
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 { useState } from "react";
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 { Input } from "@/components/ui/input";
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
- <div className="text-sm">Orchestrator rules</div>
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
- ) : rules.length ? (
33
- <ul className="space-y-1">
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
- </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>
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
- <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>
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
- <ul className="space-y-2">
42
+ <SettingSection>
41
43
  {WORKSPACE_ROLES.map((role) => (
42
- <li key={role} className="space-y-1">
43
- <label
44
- htmlFor={`ws-${workspaceId}-${role}`}
45
- className="text-muted-foreground block text-xs"
46
- >
47
- {ROLE_LABELS[role].label}
48
- </label>
49
- <ModelSelect
50
- id={`ws-${workspaceId}-${role}`}
51
- value={roles[role] ?? null}
52
- effective={models.data?.effective}
53
- inheritLabel={`As Settings (${models.data?.effective.roles[role] ?? "…"})`}
54
- disabled={update.isPending || !models.data}
55
- onChange={(ref) => {
56
- const next = { ...roles };
57
- if (ref) next[role] = ref;
58
- else delete next[role];
59
- update.mutate({ roles: next });
60
- }}
61
- />
62
- </li>
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
- </ul>
65
- )}
66
- {update.isError && (
67
- <p className="text-destructive text-xs">{update.error.message}</p>
68
+ </SettingSection>
68
69
  )}
69
70
  </div>
70
71
  );