@saadnvd1/agent-os 0.3.839 → 0.3.848

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 (100) hide show
  1. package/app/api/approvals/route.ts +5 -1
  2. package/app/api/health/config/route.ts +2 -0
  3. package/app/api/health/route.ts +4 -2
  4. package/app/globals.css +6 -0
  5. package/components/Audits/AuditsPanel.tsx +42 -35
  6. package/components/Chat/quote-selection.test.ts +6 -0
  7. package/components/Chat/quote-selection.ts +13 -10
  8. package/components/Devices/ConnectCard.tsx +52 -56
  9. package/components/Devices/DeviceGroupRow.tsx +65 -0
  10. package/components/Devices/DeviceRow.tsx +2 -2
  11. package/components/Devices/DevicesPanel.tsx +63 -49
  12. package/components/Devices/NetworkSection.tsx +29 -26
  13. package/components/Devices/TailscaleCard.tsx +9 -10
  14. package/components/Devices/group.test.ts +66 -0
  15. package/components/Devices/group.ts +67 -0
  16. package/components/Health/HealthPanel.tsx +5 -81
  17. package/components/Health/MachineChecks.tsx +20 -0
  18. package/components/Health/SweepRuns.tsx +46 -51
  19. package/components/Health/Thresholds.tsx +50 -0
  20. package/components/Merge/GlobalMergeSection.tsx +5 -6
  21. package/components/Merge/MergeApprovalToggle.tsx +20 -14
  22. package/components/Merge/MergeSettingsFields.tsx +0 -2
  23. package/components/Merge/WorktreeSweepSection.tsx +15 -34
  24. package/components/Models/AgentRoleCard.tsx +7 -7
  25. package/components/Models/ModelSelect.tsx +14 -3
  26. package/components/Models/ModelsSection.tsx +42 -37
  27. package/components/Models/ProviderCard.tsx +1 -1
  28. package/components/Models/RoleModelSelect.tsx +15 -3
  29. package/components/Models/SubagentModels.tsx +60 -45
  30. package/components/PhoneNotify/PhoneNotifyForm.tsx +6 -13
  31. package/components/Recipes/RecipesPanel.tsx +20 -9
  32. package/components/Recipes/RunHistory.tsx +1 -1
  33. package/components/Schedules/SchedulesPanel.tsx +12 -10
  34. package/components/SessionList/GroupShelves.tsx +8 -3
  35. package/components/SessionList/OrchestratorPinRow.tsx +2 -2
  36. package/components/SessionList/RowParts.tsx +39 -35
  37. package/components/SessionList/RowRename.test.ts +86 -17
  38. package/components/SessionList/RowShell.tsx +2 -5
  39. package/components/SessionList/SessionRow.tsx +0 -1
  40. package/components/Settings/AppWideLimits.tsx +2 -2
  41. package/components/Settings/{NoApprovalsSection.test.ts → ApprovalsSection.test.ts} +61 -22
  42. package/components/Settings/ApprovalsSection.tsx +93 -0
  43. package/components/Settings/ExperimentsSection.test.ts +20 -4
  44. package/components/Settings/ExperimentsSection.tsx +54 -34
  45. package/components/Settings/GeneralSection.tsx +39 -0
  46. package/components/Settings/ListSection.test.ts +85 -0
  47. package/components/Settings/ListSection.tsx +132 -0
  48. package/components/Settings/NotificationsSection.tsx +31 -29
  49. package/components/Settings/PickerSetting.tsx +5 -1
  50. package/components/Settings/SettingRow.tsx +91 -49
  51. package/components/Settings/SettingsDialog.test.ts +31 -4
  52. package/components/Settings/SettingsDialog.tsx +22 -17
  53. package/components/Settings/ValueLabel.tsx +31 -0
  54. package/components/Settings/settings-fields.test.ts +81 -2
  55. package/components/Settings/settings-fields.tsx +28 -27
  56. package/components/Shares/SharedLinksPanel.tsx +11 -14
  57. package/components/Updates/AutoUpdates.tsx +12 -8
  58. package/components/Updates/UpdatesSection.tsx +18 -17
  59. package/components/Workspaces/HqFolderForm.tsx +21 -19
  60. package/components/Workspaces/McpServersForm.tsx +20 -28
  61. package/components/Workspaces/ProjectTiersForm.tsx +25 -12
  62. package/components/Workspaces/RuleEditor.tsx +86 -0
  63. package/components/Workspaces/RulesForm.test.ts +58 -31
  64. package/components/Workspaces/RulesForm.tsx +129 -58
  65. package/components/Workspaces/WorkspaceModeForm.tsx +3 -3
  66. package/components/Workspaces/WorkspaceModelsForm.tsx +40 -41
  67. package/components/Workspaces/WorkspacePanel.tsx +73 -0
  68. package/components/Workspaces/WorkspacePicker.tsx +44 -0
  69. package/components/Workspaces/WorkspaceSettings.test.ts +153 -0
  70. package/components/Workspaces/WorkspaceSettings.tsx +51 -79
  71. package/components/Workspaces/useSettingsWorkspace.ts +47 -0
  72. package/components/ui/switch.tsx +1 -1
  73. package/content/whats-new.json +40 -0
  74. package/data/approvals/index.ts +2 -2
  75. package/data/health/index.ts +3 -0
  76. package/hooks/useAutoSave.ts +18 -5
  77. package/hooks/useSettingsUrl.ts +6 -3
  78. package/lib/approvals/describe.ts +64 -0
  79. package/lib/away/deploy.ts +34 -7
  80. package/lib/health/config.ts +70 -17
  81. package/lib/models/plan-fresh.ts +61 -0
  82. package/lib/models/plan-step.ts +40 -24
  83. package/lib/models/providers.ts +1 -1
  84. package/lib/orchestrator/scope.ts +10 -0
  85. package/lib/recipes/settle.ts +22 -0
  86. package/lib/settings-url.ts +14 -2
  87. package/lib/updates/banner.ts +2 -0
  88. package/lib/updates/run.ts +9 -0
  89. package/lib/updates/view.ts +28 -6
  90. package/lib/worktree-deps/paths.ts +9 -4
  91. package/lib/worktrees.ts +3 -0
  92. package/package.json +1 -1
  93. package/scripts/autodeploy +11 -0
  94. package/scripts/autodeploy.test.ts +30 -0
  95. package/scripts/lib/update-verify.mjs +11 -0
  96. package/scripts/update-run.mjs +29 -0
  97. package/scripts/update-run.test.ts +62 -4
  98. package/components/Settings/NoApprovalsSection.tsx +0 -103
  99. package/components/Settings/SettingToggle.tsx +0 -36
  100. package/components/Settings/SetupSection.tsx +0 -46
@@ -2,7 +2,8 @@
2
2
 
3
3
  import { toast } from "sonner";
4
4
  import { CheckCircle2, CircleAlert, RotateCcw } from "lucide-react";
5
- import { SettingToggle } from "@/components/Settings/SettingToggle";
5
+ import { SwitchSetting } from "@/components/Settings/settings-fields";
6
+ import { SettingSection } from "@/components/Settings/SettingRow";
6
7
  import {
7
8
  Select,
8
9
  SelectContent,
@@ -80,13 +81,16 @@ export function AutoUpdates({ auto }: { auto: AutoUpdateView }) {
80
81
  const onError = (e: Error) => toast.error(e.message);
81
82
  return (
82
83
  <div className="space-y-3">
83
- <SettingToggle
84
- title="Install updates automatically"
85
- detail="Once everything has been idle for a while. Terminals and chats keep running through the restart, and a version that doesn't start cleanly is rolled back."
86
- checked={auto.enabled}
87
- locked={auto.locked}
88
- onChange={(on) => save.mutate({ auto: on }, { onError })}
89
- />
84
+ <SettingSection>
85
+ <SwitchSetting
86
+ id="auto-update"
87
+ label="Install updates automatically"
88
+ help="Once everything has been idle for a while. Terminals and chats keep running through the restart, and a version that doesn't start cleanly is rolled back."
89
+ checked={auto.enabled}
90
+ locked={auto.locked}
91
+ onSave={(on) => save.mutateAsync({ auto: on })}
92
+ />
93
+ </SettingSection>
90
94
  {auto.enabled && (
91
95
  <div className="flex flex-wrap items-center justify-between gap-2 px-3">
92
96
  <label htmlFor="auto-idle" className="text-sm">
@@ -3,7 +3,8 @@
3
3
  import { toast } from "sonner";
4
4
  import { Loader2 } from "lucide-react";
5
5
  import { Button } from "@/components/ui/button";
6
- import { SettingToggle } from "@/components/Settings/SettingToggle";
6
+ import { SwitchSetting } from "@/components/Settings/settings-fields";
7
+ import { SettingSection } from "@/components/Settings/SettingRow";
7
8
  import { useSetTipsOff, useTips } from "@/data/tips";
8
9
  import {
9
10
  useCheckForUpdates,
@@ -141,16 +142,17 @@ export function UpdatesSection() {
141
142
 
142
143
  {view.auto && <AutoUpdates auto={view.auto} />}
143
144
 
144
- <SettingToggle
145
- title="Show update banner"
146
- detail="Tell everyone using this AgentOS when a newer version is out, and when it updated itself."
147
- checked={view.bannerEnabled}
148
- locked={view.bannerLocked}
149
- onChange={(on) =>
150
- banner.mutate(on, { onError: (e) => toast.error(e.message) })
151
- }
152
- />
153
- <TipsToggle />
145
+ <SettingSection>
146
+ <SwitchSetting
147
+ id="update-banner"
148
+ label="Show update banner"
149
+ help="Tell everyone using this AgentOS when a newer version is out, and when it updated itself."
150
+ checked={view.bannerEnabled}
151
+ locked={view.bannerLocked}
152
+ onSave={(on) => banner.mutateAsync(on)}
153
+ />
154
+ <TipsToggle />
155
+ </SettingSection>
154
156
  </div>
155
157
  );
156
158
  }
@@ -159,13 +161,12 @@ function TipsToggle() {
159
161
  const { data } = useTips();
160
162
  const setOff = useSetTipsOff();
161
163
  return (
162
- <SettingToggle
163
- title="Show Tip of the Day"
164
- detail="A short tip about something AgentOS can do, behind the lightbulb in the sidebar. It never opens on its own."
164
+ <SwitchSetting
165
+ id="tips"
166
+ label="Show Tip of the Day"
167
+ help="A short tip about something AgentOS can do, behind the lightbulb in the sidebar. It never opens on its own."
165
168
  checked={!!data && !data.off}
166
- onChange={(on) =>
167
- setOff.mutate(!on, { onError: (e) => toast.error(e.message) })
168
- }
169
+ onSave={(on) => setOff.mutateAsync(!on)}
169
170
  />
170
171
  );
171
172
  }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { SettingSection } from "@/components/Settings/SettingRow";
3
+ import { SettingBlock } from "@/components/Settings/SettingRow";
4
4
  import { TextSetting } from "@/components/Settings/settings-fields";
5
5
  import { useSetWorkspaceHq, useWorkspaceHqQuery } from "@/data/session-logs";
6
6
 
@@ -13,26 +13,28 @@ export function HqFolderForm({ workspaceId }: { workspaceId: string }) {
13
13
  const save = useSetWorkspaceHq(workspaceId);
14
14
 
15
15
  if (isPending)
16
- return <div className="bg-muted/60 h-11 animate-pulse rounded-md" />;
16
+ return <div className="bg-muted/60 m-3 h-11 animate-pulse rounded-md" />;
17
17
  if (isError)
18
- return <p className="text-destructive text-xs">{error.message}</p>;
18
+ return (
19
+ <SettingBlock id={`hq-folder-${workspaceId}`} label="HQ folder">
20
+ <p className="text-destructive text-xs">{error.message}</p>
21
+ </SettingBlock>
22
+ );
19
23
 
20
24
  return (
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>
25
+ <TextSetting
26
+ id={`hq-folder-${workspaceId}`}
27
+ label="HQ folder"
28
+ help={`Session logs go to docs/sessions/<project>/ in this repo, one commit per file. ${
29
+ data.hq?.visibility === "private"
30
+ ? "The repo is private, so each commit is pushed."
31
+ : data.hq
32
+ ? "Commits stay local: the repo isn't a private GitHub repository."
33
+ : "It must be a private git repo outside the workspace's projects."
34
+ }`}
35
+ placeholder="~/path/to/a/private/git/repo"
36
+ value={data.hq?.folder ?? ""}
37
+ onSave={(folder) => save.mutateAsync(folder)}
38
+ />
37
39
  );
38
40
  }
@@ -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
- <div className="space-y-2">
25
- <div>
26
- <p className="text-sm font-semibold">
27
- MCP servers its orchestrator may use
28
- </p>
29
- <p className="text-muted-foreground text-xs">
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.
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
- <p className="text-muted-foreground text-xs">
37
- No MCP servers are configured on this machine.
38
- </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>
39
34
  ) : (
40
- <SettingSection>
41
- {names.map((name) => (
42
- <SwitchSetting
43
- key={name}
44
- id={`mcp-${workspace.id}-${name}`}
45
- label={name}
46
- checked={allowed.includes(name)}
47
- defaultValue={false}
48
- onSave={(on) => toggle(name, on)}
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
- </div>
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 { SwitchSetting } from "@/components/Settings/settings-fields";
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
- <SwitchSetting
30
+ <PickerSetting<Tier>
20
31
  key={p.id}
21
32
  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."
24
- checked={p.tier === "production"}
25
- onSave={(on) =>
26
- update.mutateAsync({
27
- projectId: p.id,
28
- tier: on ? "production" : "fast",
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: HTMLInputElement, text: string) => {
52
+ const type = async (input: HTMLTextAreaElement, text: string) => {
53
53
  const set = Object.getOwnPropertyDescriptor(
54
- HTMLInputElement.prototype,
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 rule = () => host.querySelector<HTMLInputElement>("#rule-4")!;
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 type(rule(), "Never merge #13");
105
+ await edit();
106
+ await type(editor("#rule-4"), "Never merge #13");
79
107
  await act(async () => {
80
- rule().dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
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 the Save button, and says Saved", async () => {
86
- await type(rule(), "Never merge #13");
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.textContent).toContain("Saved");
123
+ expect(host.querySelector("textarea")).toBeNull();
93
124
  });
94
125
 
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();
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("rolls back and toasts when the passkey is cancelled", async () => {
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 type(rule(), "Never merge #13");
139
+ await edit();
140
+ await type(editor("#rule-4"), "Never merge #13");
108
141
  await click(button("Save"));
109
- expect(rule().value).toBe("Never merge #12");
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
- const input = host.querySelector<HTMLInputElement>("#rule-new-w1")!;
123
- await type(input, "House rule");
124
- await click(button("Add"));
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
  });