feat(KB-026): add ntfy.sh push notification support
- Add ntfy settings (ntfyEnabled, ntfyTopic) to core types and config storage - Create NtfyNotifier service with event-based notifications for task completion/failure - Add Notifications section to SettingsModal for configuring ntfy topic - Wire up NtfyNotifier in engine to send notifications on task state changes - Include ntfy topic in task templates for per-task notification override - Add comprehensive tests for NtfyNotifier service
This commit is contained in:
@@ -44,7 +44,8 @@ The Git Manager provides comprehensive repository visualization and management d
|
||||
- View operation results and error states
|
||||
|
||||
### Configuration
|
||||
- **Settings Modal**: Configure scheduling, worktrees, build commands, merge preferences
|
||||
- **Settings Modal**: Configure scheduling, worktrees, build commands, merge preferences, and notifications
|
||||
- **Notifications**: ntfy.sh integration for push notifications when tasks complete or fail
|
||||
- **Authentication**: OAuth provider management for AI model access
|
||||
- **Pause Controls**: Soft pause (stop new work) and hard stop (kill all agents)
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ const SETTINGS_SECTIONS = [
|
||||
{ id: "worktrees", label: "Worktrees" },
|
||||
{ id: "commands", label: "Commands" },
|
||||
{ id: "merge", label: "Merge" },
|
||||
{ id: "notifications", label: "Notifications" },
|
||||
{ id: "authentication", label: "Authentication" },
|
||||
] as const;
|
||||
|
||||
@@ -488,6 +489,52 @@ export function SettingsModal({ onClose, addToast, initialSection }: SettingsMod
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case "notifications":
|
||||
return (
|
||||
<>
|
||||
<h4 className="settings-section-heading">Notifications</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="ntfyEnabled" className="checkbox-label">
|
||||
<input
|
||||
id="ntfyEnabled"
|
||||
type="checkbox"
|
||||
checked={form.ntfyEnabled || false}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, ntfyEnabled: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Enable ntfy.sh notifications
|
||||
</label>
|
||||
<small>Receive push notifications when tasks complete or fail via ntfy.sh</small>
|
||||
</div>
|
||||
{form.ntfyEnabled && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="ntfyTopic">ntfy Topic</label>
|
||||
<input
|
||||
id="ntfyTopic"
|
||||
type="text"
|
||||
placeholder="my-topic-name"
|
||||
value={form.ntfyTopic || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, ntfyTopic: val || undefined }));
|
||||
}}
|
||||
/>
|
||||
<small>
|
||||
Your ntfy.sh topic name (1–64 alphanumeric/hyphen/underscore characters).{" "}
|
||||
<a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer">
|
||||
Learn more about ntfy.sh
|
||||
</a>
|
||||
</small>
|
||||
{form.ntfyTopic && !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic) && (
|
||||
<small className="field-error">
|
||||
Topic must be 1–64 alphanumeric, hyphen, or underscore characters
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case "authentication":
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -15,6 +15,8 @@ const defaultSettings: Settings = {
|
||||
buildCommand: "",
|
||||
autoResolveConflicts: true,
|
||||
smartConflictResolution: true,
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
};
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
@@ -681,17 +683,17 @@ describe("SettingsModal", () => {
|
||||
expect(layout!.querySelector(".settings-content")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has .settings-sidebar with 7 .settings-nav-item buttons for all sections", async () => {
|
||||
it("has .settings-sidebar with 8 .settings-nav-item buttons for all sections", async () => {
|
||||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
const sidebar = container.querySelector(".settings-sidebar");
|
||||
expect(sidebar).toBeTruthy();
|
||||
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
|
||||
expect(navItems.length).toBe(7);
|
||||
expect(navItems.length).toBe(8);
|
||||
|
||||
const labels = Array.from(navItems).map((el) => el.textContent);
|
||||
expect(labels).toEqual(["General", "Model", "Scheduling", "Worktrees", "Commands", "Merge", "Authentication"]);
|
||||
expect(labels).toEqual(["General", "Model", "Scheduling", "Worktrees", "Commands", "Merge", "Notifications", "Authentication"]);
|
||||
});
|
||||
|
||||
it("has .settings-content as sibling of .settings-sidebar", async () => {
|
||||
@@ -744,4 +746,136 @@ describe("SettingsModal", () => {
|
||||
expect(row.querySelector("button")).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Notifications section tests ---
|
||||
|
||||
it("shows Notifications in sidebar", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
expect(screen.getAllByText("Notifications").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("shows ntfy enable checkbox in Notifications section", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||||
expect(checkbox).toBeTruthy();
|
||||
expect(checkbox.getAttribute("type")).toBe("checkbox");
|
||||
});
|
||||
|
||||
it("ntfy topic input is hidden when ntfy is disabled", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
expect(screen.queryByLabelText("ntfy Topic")).toBeNull();
|
||||
});
|
||||
|
||||
it("ntfy topic input is visible when ntfy is enabled", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(screen.getByLabelText("ntfy Topic")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggling ntfyEnabled checkbox sends true in save payload", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.ntfyEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("ntfy topic field saves correctly when set", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "my-topic" } });
|
||||
expect(input.value).toBe("my-topic");
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.ntfyTopic).toBe("my-topic");
|
||||
});
|
||||
|
||||
it("ntfy topic field submits undefined when empty", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "existing-topic",
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.ntfyTopic).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ntfy topic shows validation error for invalid input", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "invalid topic with spaces!" } });
|
||||
|
||||
expect(screen.getByText("Topic must be 1–64 alphanumeric, hyphen, or underscore characters")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ntfyEnabled defaults to false when setting is undefined", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("ntfyEnabled shows correct state when enabled in settings", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "my-topic",
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
expect(screen.getByLabelText("ntfy Topic")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user