diff --git a/apps/web/src/components/app/browser-notifications-section.tsx b/apps/web/src/components/app/browser-notifications-section.tsx
new file mode 100644
index 00000000..2deaaa55
--- /dev/null
+++ b/apps/web/src/components/app/browser-notifications-section.tsx
@@ -0,0 +1,160 @@
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ EVENT_OPTIONS,
+ type NotifyEventType,
+} from "@/components/app/notification-settings-constants";
+
+type BrowserNotificationsSectionProps = {
+ webNotifyEnabled: boolean;
+ webNotifyEvents: NotifyEventType[];
+ browserPermission: NotificationPermission;
+ saving: boolean;
+ webError: string;
+ webMessage: string;
+ onRequestPermission: () => void;
+ onToggleWebNotifyEnabled: (checked: boolean) => void;
+ onToggleWebEvent: (eventType: NotifyEventType) => void;
+ onTestWebNotification: () => void;
+};
+
+export function BrowserNotificationsSection({
+ webNotifyEnabled,
+ webNotifyEvents,
+ browserPermission,
+ saving,
+ webError,
+ webMessage,
+ onRequestPermission,
+ onToggleWebNotifyEnabled,
+ onToggleWebEvent,
+ onTestWebNotification,
+}: BrowserNotificationsSectionProps): JSX.Element {
+ const notificationsSupported = typeof Notification !== "undefined";
+ const isStandalone =
+ window.matchMedia("(display-mode: standalone)").matches ||
+ ("standalone" in navigator &&
+ (navigator as { standalone?: boolean }).standalone === true);
+ const isIOS =
+ /iPad|iPhone|iPod/.test(navigator.userAgent) ||
+ (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
+
+ return (
+
+
+ Browser Notifications
+
+
+ Get native desktop or mobile notifications when agents need attention.
+ When enabled and the app is open, browser notifications are used instead
+ of Slack.
+
+
+ {!notificationsSupported ? (
+
+ Browser notifications are not supported in this browser.
+ {isIOS && !isStandalone
+ ? " On iOS/iPadOS, install Dispatch as a PWA (Add to Home Screen) to enable notifications."
+ : ""}
+
+ ) : (
+
+ {/* Permission status + grant button */}
+ {browserPermission !== "granted" ? (
+
+
+
+ {browserPermission === "denied"
+ ? "Notifications blocked"
+ : "Permission required"}
+
+
+ {browserPermission === "denied"
+ ? isIOS
+ ? "Open device Settings > Notifications > Dispatch to enable, or tap Allow to re-request"
+ : "Update the notification permission in your browser settings, or tap Allow to re-request"
+ : isIOS
+ ? "Tap Allow, then confirm in the system prompt"
+ : "Your browser needs permission to show notifications"}
+
+
+
void onRequestPermission()}
+ >
+ Allow
+
+
+ ) : null}
+
+ {/* Enable toggle */}
+
+
+ void onToggleWebNotifyEnabled(checked === true)
+ }
+ data-testid="web-notify-enabled"
+ />
+
+
+ Enable browser notifications
+
+
+ {browserPermission === "granted"
+ ? "Show notifications when agents change status"
+ : "Grant permission above to enable"}
+
+
+
+
+ {/* Web event toggles */}
+ {webNotifyEnabled && browserPermission === "granted" && (
+
+
Notify on:
+ {EVENT_OPTIONS.map(({ id, label, description }) => (
+
+ void onToggleWebEvent(id)}
+ data-testid={`web-notify-event-${id}`}
+ />
+
+
+ {label}
+
+
+ {description}
+
+
+
+ ))}
+
+
+ Send test
+
+
+
+ )}
+ {webError ? (
+
{webError}
+ ) : null}
+ {webMessage ? (
+
{webMessage}
+ ) : null}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/app/notification-device-sections.tsx b/apps/web/src/components/app/notification-device-sections.tsx
new file mode 100644
index 00000000..22c78eda
--- /dev/null
+++ b/apps/web/src/components/app/notification-device-sections.tsx
@@ -0,0 +1,108 @@
+import { useAtom, useSetAtom } from "jotai";
+import { Play, RotateCcw } from "lucide-react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { soundCuesEnabledAtom } from "@/lib/store";
+import {
+ dismissedTipsAtom,
+ lastSeenVersionAtom,
+ tipsEnabledAtom,
+} from "@/lib/tips/tips-state";
+import { CUE_INTENTS, playCueForIntent, playTapCue } from "@/lib/sound-cues";
+
+export function SoundCuesSection(): JSX.Element {
+ const [enabled, setEnabled] = useAtom(soundCuesEnabledAtom);
+ return (
+
+
+ Sound Cues
+
+
+ Soft tones for agent status changes and mobile toolbar taps. This device
+ only.
+
+
+
+ setEnabled(checked === true)}
+ data-testid="sound-cues-enabled"
+ />
+ Enable
+
+ {enabled && (
+
+ {CUE_INTENTS.map(({ intent, label }) => (
+
playCueForIntent(intent)}
+ data-testid={`sound-preview-${intent}`}
+ className="gap-1.5"
+ >
+
+ {label}
+
+ ))}
+
playTapCue()}
+ data-testid="sound-preview-tap"
+ className="gap-1.5"
+ >
+
+ Mobile tap
+
+
+ )}
+
+
+ );
+}
+
+export function TipsSection(): JSX.Element {
+ const [enabled, setEnabled] = useAtom(tipsEnabledAtom);
+ const setDismissed = useSetAtom(dismissedTipsAtom);
+ const setLastSeenVersion = useSetAtom(lastSeenVersionAtom);
+
+ return (
+
+
+ Tips & Guidance
+
+
+ Contextual tips that highlight features and link to docs. This device
+ only.
+
+
+
+ setEnabled(checked === true)}
+ data-testid="tips-enabled"
+ />
+ Show tips
+
+
{
+ setDismissed([]);
+ setLastSeenVersion("0.0.0");
+ toast.success(
+ "Tips reset — you'll see them again as you use the app."
+ );
+ }}
+ data-testid="reset-dismissed-tips"
+ className="gap-1.5"
+ >
+
+ Reset dismissed tips
+
+
+
+ );
+}
diff --git a/apps/web/src/components/app/notification-settings-constants.ts b/apps/web/src/components/app/notification-settings-constants.ts
new file mode 100644
index 00000000..6ab9c74f
--- /dev/null
+++ b/apps/web/src/components/app/notification-settings-constants.ts
@@ -0,0 +1,26 @@
+export type NotifyEventType = "done" | "waiting_user" | "blocked";
+
+export type NotificationSettingsResponse = {
+ webhookUrl: string;
+ notifyEvents: NotifyEventType[];
+ webNotifyEnabled: boolean;
+ webNotifyEvents: NotifyEventType[];
+};
+
+export const EVENT_OPTIONS: Array<{
+ id: NotifyEventType;
+ label: string;
+ description: string;
+}> = [
+ { id: "done", label: "Done", description: "Agent finished its task" },
+ {
+ id: "waiting_user",
+ label: "Waiting for input",
+ description: "Agent needs your response",
+ },
+ {
+ id: "blocked",
+ label: "Blocked",
+ description: "Agent hit an error or obstacle",
+ },
+];
diff --git a/apps/web/src/components/app/notification-settings.tsx b/apps/web/src/components/app/notification-settings.tsx
index 6687425e..70674b04 100644
--- a/apps/web/src/components/app/notification-settings.tsx
+++ b/apps/web/src/components/app/notification-settings.tsx
@@ -1,417 +1,40 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-import { useAtom, useSetAtom } from "jotai";
-import { Play, RotateCcw } from "lucide-react";
-import { toast } from "sonner";
-import { Button } from "@/components/ui/button";
-import { Checkbox } from "@/components/ui/checkbox";
-import { Input } from "@/components/ui/input";
-import { api } from "@/lib/api";
-import { soundCuesEnabledAtom } from "@/lib/store";
+import { BrowserNotificationsSection } from "@/components/app/browser-notifications-section";
import {
- dismissedTipsAtom,
- lastSeenVersionAtom,
- tipsEnabledAtom,
-} from "@/lib/tips/tips-state";
-import { CUE_INTENTS, playCueForIntent, playTapCue } from "@/lib/sound-cues";
-import {
- getNotificationPermission,
- requestNotificationPermission,
-} from "@/lib/web-notifications";
-
-type NotifyEventType = "done" | "waiting_user" | "blocked";
-
-type NotificationSettingsResponse = {
- webhookUrl: string;
- notifyEvents: NotifyEventType[];
- webNotifyEnabled: boolean;
- webNotifyEvents: NotifyEventType[];
-};
-
-const EVENT_OPTIONS: Array<{
- id: NotifyEventType;
- label: string;
- description: string;
-}> = [
- { id: "done", label: "Done", description: "Agent finished its task" },
- {
- id: "waiting_user",
- label: "Waiting for input",
- description: "Agent needs your response",
- },
- {
- id: "blocked",
- label: "Blocked",
- description: "Agent hit an error or obstacle",
- },
-];
-
-function SoundCuesSection(): JSX.Element {
- const [enabled, setEnabled] = useAtom(soundCuesEnabledAtom);
- return (
-
-
- Sound Cues
-
-
- Soft tones for agent status changes and mobile toolbar taps. This device
- only.
-
-
-
- setEnabled(checked === true)}
- data-testid="sound-cues-enabled"
- />
- Enable
-
- {enabled && (
-
- {CUE_INTENTS.map(({ intent, label }) => (
-
playCueForIntent(intent)}
- data-testid={`sound-preview-${intent}`}
- className="gap-1.5"
- >
-
- {label}
-
- ))}
-
playTapCue()}
- data-testid="sound-preview-tap"
- className="gap-1.5"
- >
-
- Mobile tap
-
-
- )}
-
-
- );
-}
-
-function TipsSection(): JSX.Element {
- const [enabled, setEnabled] = useAtom(tipsEnabledAtom);
- const setDismissed = useSetAtom(dismissedTipsAtom);
- const setLastSeenVersion = useSetAtom(lastSeenVersionAtom);
-
- return (
-
-
- Tips & Guidance
-
-
- Contextual tips that highlight features and link to docs. This device
- only.
-
-
-
- setEnabled(checked === true)}
- data-testid="tips-enabled"
- />
- Show tips
-
-
{
- setDismissed([]);
- setLastSeenVersion("0.0.0");
- toast.success(
- "Tips reset — you'll see them again as you use the app."
- );
- }}
- data-testid="reset-dismissed-tips"
- className="gap-1.5"
- >
-
- Reset dismissed tips
-
-
-
- );
-}
+ SoundCuesSection,
+ TipsSection,
+} from "@/components/app/notification-device-sections";
+import { SlackNotificationsSection } from "@/components/app/slack-notifications-section";
+import { useNotificationSettings } from "@/components/app/use-notification-settings";
export function NotificationSettings(): JSX.Element {
- // Slack settings
- const [webhookUrl, setWebhookUrl] = useState("");
- const [savedUrl, setSavedUrl] = useState("");
- const [notifyEvents, setNotifyEvents] = useState([
- "done",
- "waiting_user",
- "blocked",
- ]);
- const [savedEvents, setSavedEvents] = useState([]);
-
- // Web notification settings
- const [webNotifyEnabled, setWebNotifyEnabled] = useState(false);
- const [savedWebEnabled, setSavedWebEnabled] = useState(false);
- const [webNotifyEvents, setWebNotifyEvents] = useState([
- "done",
- "waiting_user",
- "blocked",
- ]);
- const [savedWebEvents, setSavedWebEvents] = useState([]);
- const [browserPermission, setBrowserPermission] =
- useState(getNotificationPermission());
-
- // Re-check permission when the user returns to this page (e.g. after
- // changing settings in iOS Settings or browser site settings).
- useEffect(() => {
- const onVisibilityChange = () => {
- if (!document.hidden) {
- setBrowserPermission(getNotificationPermission());
- }
- };
- document.addEventListener("visibilitychange", onVisibilityChange);
- return () =>
- document.removeEventListener("visibilitychange", onVisibilityChange);
- }, []);
-
- const [loading, setLoading] = useState(true);
- const [saving, setSaving] = useState(false);
- const [testing, setTesting] = useState(false);
- const [message, setMessage] = useState("");
- const [error, setError] = useState("");
- const [webMessage, setWebMessage] = useState("");
- const [webError, setWebError] = useState("");
- const webNotifyEnabledRef = useRef(webNotifyEnabled);
- const webNotifyEventsRef = useRef(webNotifyEvents);
- const savedWebEnabledRef = useRef(savedWebEnabled);
- const savedWebEventsRef = useRef(savedWebEvents);
- const webSaveRequestIdRef = useRef(0);
-
- useEffect(() => {
- let cancelled = false;
- void (async () => {
- try {
- const data = await api(
- "/api/v1/notifications/settings"
- );
- if (cancelled) return;
- setWebhookUrl(data.webhookUrl);
- setSavedUrl(data.webhookUrl);
- setNotifyEvents(data.notifyEvents);
- setSavedEvents(data.notifyEvents);
- setWebNotifyEnabled(data.webNotifyEnabled);
- setSavedWebEnabled(data.webNotifyEnabled);
- setWebNotifyEvents(data.webNotifyEvents);
- setSavedWebEvents(data.webNotifyEvents);
- } catch {
- // ignore — first load may fail if server is starting
- } finally {
- if (!cancelled) setLoading(false);
- }
- })();
- return () => {
- cancelled = true;
- };
- }, []);
-
- useEffect(() => {
- webNotifyEnabledRef.current = webNotifyEnabled;
- webNotifyEventsRef.current = webNotifyEvents;
- savedWebEnabledRef.current = savedWebEnabled;
- savedWebEventsRef.current = savedWebEvents;
- }, [savedWebEnabled, savedWebEvents, webNotifyEnabled, webNotifyEvents]);
-
- const hasChanges =
- webhookUrl !== savedUrl ||
- JSON.stringify([...notifyEvents].sort()) !==
- JSON.stringify([...savedEvents].sort()) ||
- webNotifyEnabled !== savedWebEnabled ||
- JSON.stringify([...webNotifyEvents].sort()) !==
- JSON.stringify([...savedWebEvents].sort());
-
- const handleSave = useCallback(async () => {
- setError("");
- setMessage("");
- setSaving(true);
- try {
- const data = await api(
- "/api/v1/notifications/settings",
- {
- method: "POST",
- body: JSON.stringify({
- webhookUrl,
- notifyEvents,
- webNotifyEnabled,
- webNotifyEvents,
- }),
- }
- );
- setSavedUrl(data.webhookUrl);
- setSavedEvents(data.notifyEvents);
- setWebhookUrl(data.webhookUrl);
- setNotifyEvents(data.notifyEvents);
- setSavedWebEnabled(data.webNotifyEnabled);
- setSavedWebEvents(data.webNotifyEvents);
- setWebNotifyEnabled(data.webNotifyEnabled);
- setWebNotifyEvents(data.webNotifyEvents);
- setMessage("Settings saved.");
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to save.");
- } finally {
- setSaving(false);
- }
- }, [webhookUrl, notifyEvents, webNotifyEnabled, webNotifyEvents]);
-
- const persistWebNotificationSettings = useCallback(
- async (
- nextEnabled: boolean,
- nextEvents: NotifyEventType[]
- ): Promise => {
- const requestId = webSaveRequestIdRef.current + 1;
- webSaveRequestIdRef.current = requestId;
- setWebError("");
- setWebMessage("");
- setSaving(true);
- try {
- const data = await api(
- "/api/v1/notifications/settings",
- {
- method: "POST",
- body: JSON.stringify({
- webNotifyEnabled: nextEnabled,
- webNotifyEvents: nextEvents,
- }),
- }
- );
- if (webSaveRequestIdRef.current !== requestId) {
- return true;
- }
- setSavedWebEnabled(data.webNotifyEnabled);
- setSavedWebEvents(data.webNotifyEvents);
- setWebNotifyEnabled(data.webNotifyEnabled);
- setWebNotifyEvents(data.webNotifyEvents);
- setWebMessage("Browser notification settings saved.");
- return true;
- } catch (err) {
- if (webSaveRequestIdRef.current !== requestId) {
- return true;
- }
- setWebError(
- err instanceof Error
- ? err.message
- : "Failed to save browser notifications."
- );
- return false;
- } finally {
- if (webSaveRequestIdRef.current === requestId) {
- setSaving(false);
- }
- }
- },
- []
- );
-
- const handleTest = useCallback(async () => {
- setError("");
- setMessage("");
- setTesting(true);
- try {
- const result = await api<{ ok: boolean; error?: string }>(
- "/api/v1/notifications/test",
- {
- method: "POST",
- body: JSON.stringify({ webhookUrl: webhookUrl || undefined }),
- }
- );
- if (result.ok) {
- setMessage("Test message sent — check your Slack channel!");
- } else {
- setError(result.error ?? "Test failed.");
- }
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to send test.");
- } finally {
- setTesting(false);
- }
- }, [webhookUrl]);
-
- const toggleEvent = useCallback((eventType: NotifyEventType) => {
- setNotifyEvents((prev) =>
- prev.includes(eventType)
- ? prev.filter((e) => e !== eventType)
- : [...prev, eventType]
- );
- }, []);
-
- const toggleWebEvent = useCallback(
- async (eventType: NotifyEventType) => {
- const previousEnabled = webNotifyEnabledRef.current;
- const previousEvents = webNotifyEventsRef.current;
- const nextEvents = previousEvents.includes(eventType)
- ? previousEvents.filter((e) => e !== eventType)
- : [...previousEvents, eventType];
-
- setWebNotifyEvents(nextEvents);
- const saved = await persistWebNotificationSettings(
- previousEnabled,
- nextEvents
- );
- if (!saved) {
- setWebNotifyEnabled(savedWebEnabledRef.current);
- setWebNotifyEvents(savedWebEventsRef.current);
- }
- },
- [persistWebNotificationSettings]
- );
-
- const toggleWebNotifyEnabled = useCallback(
- async (checked: boolean) => {
- const previousEvents = webNotifyEventsRef.current;
- const nextEnabled = checked;
-
- setWebNotifyEnabled(nextEnabled);
- const saved = await persistWebNotificationSettings(
- nextEnabled,
- previousEvents
- );
- if (!saved) {
- setWebNotifyEnabled(savedWebEnabledRef.current);
- setWebNotifyEvents(savedWebEventsRef.current);
- }
- },
- [persistWebNotificationSettings]
- );
-
- const handleRequestPermission = useCallback(async () => {
- const result = await requestNotificationPermission();
- setBrowserPermission(result);
- }, []);
-
- const handleTestWebNotification = useCallback(() => {
- if (Notification.permission !== "granted") return;
- new Notification("Dispatch test notification", {
- body: "Browser notifications are working!",
- tag: "dispatch-test",
- });
- setMessage("Test notification sent — check your browser!");
- }, []);
+ const {
+ loading,
+ webhookUrl,
+ handleWebhookUrlChange,
+ notifyEvents,
+ toggleEvent,
+ webNotifyEnabled,
+ webNotifyEvents,
+ browserPermission,
+ toggleWebEvent,
+ toggleWebNotifyEnabled,
+ handleRequestPermission,
+ handleTestWebNotification,
+ saving,
+ testing,
+ message,
+ error,
+ webMessage,
+ webError,
+ hasChanges,
+ handleSave,
+ handleTest,
+ } = useNotificationSettings();
if (loading) {
return Loading...
;
}
- const notificationsSupported = typeof Notification !== "undefined";
- const isStandalone =
- window.matchMedia("(display-mode: standalone)").matches ||
- ("standalone" in navigator &&
- (navigator as { standalone?: boolean }).standalone === true);
- const isIOS =
- /iPad|iPhone|iPod/.test(navigator.userAgent) ||
- (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
-
return (
@@ -420,227 +43,33 @@ export function NotificationSettings(): JSX.Element {
- {/* Browser Notifications */}
-
-
- Browser Notifications
-
-
- Get native desktop or mobile notifications when agents need attention.
- When enabled and the app is open, browser notifications are used
- instead of Slack.
-
-
- {!notificationsSupported ? (
-
- Browser notifications are not supported in this browser.
- {isIOS && !isStandalone
- ? " On iOS/iPadOS, install Dispatch as a PWA (Add to Home Screen) to enable notifications."
- : ""}
-
- ) : (
-
- {/* Permission status + grant button */}
- {browserPermission !== "granted" ? (
-
-
-
- {browserPermission === "denied"
- ? "Notifications blocked"
- : "Permission required"}
-
-
- {browserPermission === "denied"
- ? isIOS
- ? "Open device Settings > Notifications > Dispatch to enable, or tap Allow to re-request"
- : "Update the notification permission in your browser settings, or tap Allow to re-request"
- : isIOS
- ? "Tap Allow, then confirm in the system prompt"
- : "Your browser needs permission to show notifications"}
-
-
-
void handleRequestPermission()}
- >
- Allow
-
-
- ) : null}
-
- {/* Enable toggle */}
-
-
- void toggleWebNotifyEnabled(checked === true)
- }
- data-testid="web-notify-enabled"
- />
-
-
- Enable browser notifications
-
-
- {browserPermission === "granted"
- ? "Show notifications when agents change status"
- : "Grant permission above to enable"}
-
-
-
-
- {/* Web event toggles */}
- {webNotifyEnabled && browserPermission === "granted" && (
-
-
Notify on:
- {EVENT_OPTIONS.map(({ id, label, description }) => (
-
- void toggleWebEvent(id)}
- data-testid={`web-notify-event-${id}`}
- />
-
-
- {label}
-
-
- {description}
-
-
-
- ))}
-
-
- Send test
-
-
-
- )}
- {webError ? (
-
{webError}
- ) : null}
- {webMessage ? (
-
{webMessage}
- ) : null}
-
- )}
-
-
- {/* Slack Webhook */}
-
-
- Slack Webhook
-
-
- Receive notifications in Slack when agents finish, need input, or get
- blocked.
- {webNotifyEnabled && (
- <>
- {" "}
- When browser notifications are active, Slack is used as a fallback
- for when the app is closed.
- >
- )}
- {!webNotifyEnabled && (
- <>
- {" "}
- Create an{" "}
-
- Incoming Webhook
- {" "}
- in your Slack workspace and paste the URL below.
- >
- )}
-
-
- {
- setWebhookUrl(e.target.value);
- setMessage("");
- setError("");
- }}
- data-testid="slack-webhook-url"
- />
-
-
-
- {/* Slack Event toggles */}
-
-
- Slack notify on
-
-
- Choose which agent status changes trigger a Slack notification.
-
-
- {EVENT_OPTIONS.map(({ id, label, description }) => (
-
- toggleEvent(id)}
- data-testid={`notify-event-${id}`}
- />
-
-
- {label}
-
-
- {description}
-
-
-
- ))}
-
-
-
- {/* Actions */}
-
- {error &&
{error}
}
- {message && (
-
{message}
- )}
-
- void handleSave()}
- data-testid="save-notification-settings"
- >
- {saving ? "Saving..." : "Save"}
-
- void handleTest()}
- data-testid="test-slack-webhook"
- >
- {testing ? "Sending..." : "Send Slack test"}
-
-
-
+
+
+
);
}
diff --git a/apps/web/src/components/app/slack-notifications-section.tsx b/apps/web/src/components/app/slack-notifications-section.tsx
new file mode 100644
index 00000000..7b591706
--- /dev/null
+++ b/apps/web/src/components/app/slack-notifications-section.tsx
@@ -0,0 +1,141 @@
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import {
+ EVENT_OPTIONS,
+ type NotifyEventType,
+} from "@/components/app/notification-settings-constants";
+
+type SlackNotificationsSectionProps = {
+ webhookUrl: string;
+ webNotifyEnabled: boolean;
+ notifyEvents: NotifyEventType[];
+ saving: boolean;
+ testing: boolean;
+ hasChanges: boolean;
+ message: string;
+ error: string;
+ onWebhookUrlChange: (value: string) => void;
+ onToggleEvent: (eventType: NotifyEventType) => void;
+ onSave: () => void;
+ onTest: () => void;
+};
+
+export function SlackNotificationsSection({
+ webhookUrl,
+ webNotifyEnabled,
+ notifyEvents,
+ saving,
+ testing,
+ hasChanges,
+ message,
+ error,
+ onWebhookUrlChange,
+ onToggleEvent,
+ onSave,
+ onTest,
+}: SlackNotificationsSectionProps): JSX.Element {
+ return (
+ <>
+ {/* Slack Webhook */}
+
+
+ Slack Webhook
+
+
+ Receive notifications in Slack when agents finish, need input, or get
+ blocked.
+ {webNotifyEnabled && (
+ <>
+ {" "}
+ When browser notifications are active, Slack is used as a fallback
+ for when the app is closed.
+ >
+ )}
+ {!webNotifyEnabled && (
+ <>
+ {" "}
+ Create an{" "}
+
+ Incoming Webhook
+ {" "}
+ in your Slack workspace and paste the URL below.
+ >
+ )}
+
+
+ onWebhookUrlChange(e.target.value)}
+ data-testid="slack-webhook-url"
+ />
+
+
+
+ {/* Slack Event toggles */}
+
+
+ Slack notify on
+
+
+ Choose which agent status changes trigger a Slack notification.
+
+
+ {EVENT_OPTIONS.map(({ id, label, description }) => (
+
+ onToggleEvent(id)}
+ data-testid={`notify-event-${id}`}
+ />
+
+
+ {label}
+
+
+ {description}
+
+
+
+ ))}
+
+
+
+ {/* Actions */}
+
+ {error &&
{error}
}
+ {message && (
+
{message}
+ )}
+
+ void onSave()}
+ data-testid="save-notification-settings"
+ >
+ {saving ? "Saving..." : "Save"}
+
+ void onTest()}
+ data-testid="test-slack-webhook"
+ >
+ {testing ? "Sending..." : "Send Slack test"}
+
+
+
+ >
+ );
+}
diff --git a/apps/web/src/components/app/use-notification-settings.ts b/apps/web/src/components/app/use-notification-settings.ts
new file mode 100644
index 00000000..0aeb0d86
--- /dev/null
+++ b/apps/web/src/components/app/use-notification-settings.ts
@@ -0,0 +1,302 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { api } from "@/lib/api";
+import {
+ getNotificationPermission,
+ requestNotificationPermission,
+} from "@/lib/web-notifications";
+import type {
+ NotificationSettingsResponse,
+ NotifyEventType,
+} from "@/components/app/notification-settings-constants";
+
+export function useNotificationSettings() {
+ // Slack settings
+ const [webhookUrl, setWebhookUrl] = useState("");
+ const [savedUrl, setSavedUrl] = useState("");
+ const [notifyEvents, setNotifyEvents] = useState([
+ "done",
+ "waiting_user",
+ "blocked",
+ ]);
+ const [savedEvents, setSavedEvents] = useState([]);
+
+ // Web notification settings
+ const [webNotifyEnabled, setWebNotifyEnabled] = useState(false);
+ const [savedWebEnabled, setSavedWebEnabled] = useState(false);
+ const [webNotifyEvents, setWebNotifyEvents] = useState([
+ "done",
+ "waiting_user",
+ "blocked",
+ ]);
+ const [savedWebEvents, setSavedWebEvents] = useState([]);
+ const [browserPermission, setBrowserPermission] =
+ useState(getNotificationPermission());
+
+ // Re-check permission when the user returns to this page (e.g. after
+ // changing settings in iOS Settings or browser site settings).
+ useEffect(() => {
+ const onVisibilityChange = () => {
+ if (!document.hidden) {
+ setBrowserPermission(getNotificationPermission());
+ }
+ };
+ document.addEventListener("visibilitychange", onVisibilityChange);
+ return () =>
+ document.removeEventListener("visibilitychange", onVisibilityChange);
+ }, []);
+
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [testing, setTesting] = useState(false);
+ const [message, setMessage] = useState("");
+ const [error, setError] = useState("");
+ const [webMessage, setWebMessage] = useState("");
+ const [webError, setWebError] = useState("");
+ const webNotifyEnabledRef = useRef(webNotifyEnabled);
+ const webNotifyEventsRef = useRef(webNotifyEvents);
+ const savedWebEnabledRef = useRef(savedWebEnabled);
+ const savedWebEventsRef = useRef(savedWebEvents);
+ const webSaveRequestIdRef = useRef(0);
+
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ try {
+ const data = await api(
+ "/api/v1/notifications/settings"
+ );
+ if (cancelled) return;
+ setWebhookUrl(data.webhookUrl);
+ setSavedUrl(data.webhookUrl);
+ setNotifyEvents(data.notifyEvents);
+ setSavedEvents(data.notifyEvents);
+ setWebNotifyEnabled(data.webNotifyEnabled);
+ setSavedWebEnabled(data.webNotifyEnabled);
+ setWebNotifyEvents(data.webNotifyEvents);
+ setSavedWebEvents(data.webNotifyEvents);
+ } catch {
+ // ignore — first load may fail if server is starting
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ webNotifyEnabledRef.current = webNotifyEnabled;
+ webNotifyEventsRef.current = webNotifyEvents;
+ savedWebEnabledRef.current = savedWebEnabled;
+ savedWebEventsRef.current = savedWebEvents;
+ }, [savedWebEnabled, savedWebEvents, webNotifyEnabled, webNotifyEvents]);
+
+ const hasChanges =
+ webhookUrl !== savedUrl ||
+ JSON.stringify([...notifyEvents].sort()) !==
+ JSON.stringify([...savedEvents].sort()) ||
+ webNotifyEnabled !== savedWebEnabled ||
+ JSON.stringify([...webNotifyEvents].sort()) !==
+ JSON.stringify([...savedWebEvents].sort());
+
+ const handleSave = useCallback(async () => {
+ setError("");
+ setMessage("");
+ setSaving(true);
+ try {
+ const data = await api(
+ "/api/v1/notifications/settings",
+ {
+ method: "POST",
+ body: JSON.stringify({
+ webhookUrl,
+ notifyEvents,
+ webNotifyEnabled,
+ webNotifyEvents,
+ }),
+ }
+ );
+ setSavedUrl(data.webhookUrl);
+ setSavedEvents(data.notifyEvents);
+ setWebhookUrl(data.webhookUrl);
+ setNotifyEvents(data.notifyEvents);
+ setSavedWebEnabled(data.webNotifyEnabled);
+ setSavedWebEvents(data.webNotifyEvents);
+ setWebNotifyEnabled(data.webNotifyEnabled);
+ setWebNotifyEvents(data.webNotifyEvents);
+ setMessage("Settings saved.");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to save.");
+ } finally {
+ setSaving(false);
+ }
+ }, [webhookUrl, notifyEvents, webNotifyEnabled, webNotifyEvents]);
+
+ const persistWebNotificationSettings = useCallback(
+ async (
+ nextEnabled: boolean,
+ nextEvents: NotifyEventType[]
+ ): Promise => {
+ const requestId = webSaveRequestIdRef.current + 1;
+ webSaveRequestIdRef.current = requestId;
+ setWebError("");
+ setWebMessage("");
+ setSaving(true);
+ try {
+ const data = await api(
+ "/api/v1/notifications/settings",
+ {
+ method: "POST",
+ body: JSON.stringify({
+ webNotifyEnabled: nextEnabled,
+ webNotifyEvents: nextEvents,
+ }),
+ }
+ );
+ if (webSaveRequestIdRef.current !== requestId) {
+ return true;
+ }
+ setSavedWebEnabled(data.webNotifyEnabled);
+ setSavedWebEvents(data.webNotifyEvents);
+ setWebNotifyEnabled(data.webNotifyEnabled);
+ setWebNotifyEvents(data.webNotifyEvents);
+ setWebMessage("Browser notification settings saved.");
+ return true;
+ } catch (err) {
+ if (webSaveRequestIdRef.current !== requestId) {
+ return true;
+ }
+ setWebError(
+ err instanceof Error
+ ? err.message
+ : "Failed to save browser notifications."
+ );
+ return false;
+ } finally {
+ if (webSaveRequestIdRef.current === requestId) {
+ setSaving(false);
+ }
+ }
+ },
+ []
+ );
+
+ const handleTest = useCallback(async () => {
+ setError("");
+ setMessage("");
+ setTesting(true);
+ try {
+ const result = await api<{ ok: boolean; error?: string }>(
+ "/api/v1/notifications/test",
+ {
+ method: "POST",
+ body: JSON.stringify({ webhookUrl: webhookUrl || undefined }),
+ }
+ );
+ if (result.ok) {
+ setMessage("Test message sent — check your Slack channel!");
+ } else {
+ setError(result.error ?? "Test failed.");
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to send test.");
+ } finally {
+ setTesting(false);
+ }
+ }, [webhookUrl]);
+
+ const handleWebhookUrlChange = useCallback((value: string) => {
+ setWebhookUrl(value);
+ setMessage("");
+ setError("");
+ }, []);
+
+ const toggleEvent = useCallback((eventType: NotifyEventType) => {
+ setNotifyEvents((prev) =>
+ prev.includes(eventType)
+ ? prev.filter((e) => e !== eventType)
+ : [...prev, eventType]
+ );
+ }, []);
+
+ const toggleWebEvent = useCallback(
+ async (eventType: NotifyEventType) => {
+ const previousEnabled = webNotifyEnabledRef.current;
+ const previousEvents = webNotifyEventsRef.current;
+ const nextEvents = previousEvents.includes(eventType)
+ ? previousEvents.filter((e) => e !== eventType)
+ : [...previousEvents, eventType];
+
+ setWebNotifyEvents(nextEvents);
+ const saved = await persistWebNotificationSettings(
+ previousEnabled,
+ nextEvents
+ );
+ if (!saved) {
+ setWebNotifyEnabled(savedWebEnabledRef.current);
+ setWebNotifyEvents(savedWebEventsRef.current);
+ }
+ },
+ [persistWebNotificationSettings]
+ );
+
+ const toggleWebNotifyEnabled = useCallback(
+ async (checked: boolean) => {
+ const previousEvents = webNotifyEventsRef.current;
+ const nextEnabled = checked;
+
+ setWebNotifyEnabled(nextEnabled);
+ const saved = await persistWebNotificationSettings(
+ nextEnabled,
+ previousEvents
+ );
+ if (!saved) {
+ setWebNotifyEnabled(savedWebEnabledRef.current);
+ setWebNotifyEvents(savedWebEventsRef.current);
+ }
+ },
+ [persistWebNotificationSettings]
+ );
+
+ const handleRequestPermission = useCallback(async () => {
+ const result = await requestNotificationPermission();
+ setBrowserPermission(result);
+ }, []);
+
+ const handleTestWebNotification = useCallback(() => {
+ if (Notification.permission !== "granted") return;
+ new Notification("Dispatch test notification", {
+ body: "Browser notifications are working!",
+ tag: "dispatch-test",
+ });
+ setMessage("Test notification sent — check your browser!");
+ }, []);
+
+ return {
+ loading,
+ // Slack
+ webhookUrl,
+ handleWebhookUrlChange,
+ notifyEvents,
+ toggleEvent,
+ // Web/browser notifications
+ webNotifyEnabled,
+ webNotifyEvents,
+ browserPermission,
+ toggleWebEvent,
+ toggleWebNotifyEnabled,
+ handleRequestPermission,
+ handleTestWebNotification,
+ // Shared status
+ saving,
+ testing,
+ message,
+ error,
+ webMessage,
+ webError,
+ hasChanges,
+ handleSave,
+ handleTest,
+ };
+}