Skip to content

Commit 4fb4d44

Browse files
yarin-magclaude
andcommitted
feat: add notification channel selector (browser vs Discord)
Users can now choose between browser pop-ups or Discord webhook notifications in Preferences — only the selected channel fires. - Server: gate Discord webhook on new `notificationChannel` preference (default: "browser") - Client: skip browser notifications when channel is set to "discord" - Settings UI: unified Notifications card with a radio group selector; conditionally renders browser toggles or Discord webhook config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 27f5411 commit 4fb4d44

7 files changed

Lines changed: 222 additions & 41 deletions

File tree

apps/server/src/app.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ import { config } from "./config/index.js";
1111
export function createApp() {
1212
const app = express();
1313

14-
// Middleware — restrict CORS to local origins only (localhost / 127.0.0.1)
14+
// Middleware — restrict CORS to local origins and the GitHub Pages frontend
1515
app.use(cors({
1616
origin: (origin, callback) => {
1717
// Allow requests with no origin (curl, Electron shell, same-origin) and
1818
// any localhost / 127.0.0.1 origin on any port.
19-
if (!origin || origin === "null" || /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
19+
if (!origin || origin === "null" || /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin) || origin === "https://yarin-mag.github.io") {
2020
callback(null, true);
2121
} else {
2222
logger.warn(`CORS blocked origin: "${origin}"`);

apps/server/src/repositories/preferences.repository.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const DEFAULTS: Record<string, unknown> = {
44
mcpSetTaskEnabled: true,
55
mcpJiraEnabled: true,
66
discordWebhookUrl: null,
7+
notificationChannel: "browser",
78
};
89

910
export class PreferencesRepository extends BaseRepository {

apps/server/src/services/notification.service.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,11 @@ export class NotificationService {
4141
return;
4242
}
4343

44-
const webhookUrl = await this.getWebhookUrl();
45-
if (!webhookUrl) return;
44+
const [webhookUrl, channel] = await Promise.all([
45+
this.getWebhookUrl(),
46+
this.getNotificationChannel(),
47+
]);
48+
if (!webhookUrl || channel !== "discord") return;
4649

4750
const embed = isDone
4851
? this.buildFinishedEmbed(agent)
@@ -117,6 +120,16 @@ export class NotificationService {
117120
}
118121
}
119122

123+
private async getNotificationChannel(): Promise<string> {
124+
try {
125+
const all = await this.prefsRepo.getAll();
126+
const channel = all["notificationChannel"];
127+
return typeof channel === "string" ? channel : "browser";
128+
} catch {
129+
return "browser";
130+
}
131+
}
132+
120133
private async post(webhookUrl: string, embed: object): Promise<void> {
121134
const res = await fetch(webhookUrl, {
122135
method: "POST",

apps/web/src/features/settings/components/NotificationsSection.tsx

Lines changed: 158 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,37 @@
1-
import { Bell } from "lucide-react";
1+
import { useState } from "react";
2+
import { Bell, MessageSquare, Eye, EyeOff } from "lucide-react";
23
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../../components/ui/card";
34
import { Label } from "../../../components/ui/label";
45
import { Switch } from "../../../components/ui/switch";
6+
import { Button } from "../../../components/ui/button";
7+
import { RadioGroup, RadioGroupItem } from "../../../components/ui/radio-group";
58
import type { NotificationPreferences } from "../../../lib/user-preferences";
69

710
interface NotificationsSectionProps {
811
notifications: NotificationPreferences;
912
onUpdate: (updated: NotificationPreferences) => void;
13+
notificationChannel: "browser" | "discord";
14+
onChannelChange: (channel: "browser" | "discord") => Promise<void>;
15+
webhookUrl: string | null;
16+
webhookSaveError: string | null;
17+
webhookTestStatus: "idle" | "success" | "error";
18+
onWebhookChange: (value: string) => Promise<void>;
19+
onWebhookTest: () => Promise<void>;
1020
}
1121

12-
export function NotificationsSection({ notifications, onUpdate }: NotificationsSectionProps) {
22+
export function NotificationsSection({
23+
notifications,
24+
onUpdate,
25+
notificationChannel,
26+
onChannelChange,
27+
webhookUrl,
28+
webhookSaveError,
29+
webhookTestStatus,
30+
onWebhookChange,
31+
onWebhookTest,
32+
}: NotificationsSectionProps) {
33+
const [showUrl, setShowUrl] = useState(false);
34+
1335
function toggle<K extends keyof NotificationPreferences>(key: K, value: boolean) {
1436
onUpdate({ ...notifications, [key]: value });
1537
}
@@ -23,40 +45,150 @@ export function NotificationsSection({ notifications, onUpdate }: NotificationsS
2345
</div>
2446
<div>
2547
<CardTitle>Notifications</CardTitle>
26-
<CardDescription>Choose which agent events trigger browser notifications</CardDescription>
48+
<CardDescription>Choose how you want to be notified about agent events</CardDescription>
2749
</div>
2850
</div>
2951
</CardHeader>
3052
<CardContent className="space-y-6">
31-
<div className="flex items-center justify-between gap-4">
32-
<div className="space-y-1">
33-
<Label className="text-base font-semibold">Agent awaits your response</Label>
34-
<p className="text-xs text-muted-foreground">
35-
Fires when an agent is waiting for your input. Debounced by 2s to avoid noise from
36-
brief status flickers.
37-
</p>
38-
</div>
39-
<Switch
40-
checked={notifications.awaitingInput}
41-
onCheckedChange={(value) => toggle("awaitingInput", value)}
42-
/>
53+
<div className="space-y-3">
54+
<Label className="text-sm font-semibold">Notification channel</Label>
55+
<RadioGroup
56+
value={notificationChannel}
57+
onValueChange={(v) => void onChannelChange(v as "browser" | "discord")}
58+
className="grid grid-cols-2 gap-3"
59+
>
60+
<label
61+
htmlFor="channel-browser"
62+
className={`flex items-center gap-3 rounded-lg border p-3 cursor-pointer transition-colors ${
63+
notificationChannel === "browser"
64+
? "border-primary bg-primary/5"
65+
: "border-muted hover:border-muted-foreground/40"
66+
}`}
67+
>
68+
<RadioGroupItem value="browser" id="channel-browser" />
69+
<div className="space-y-0.5">
70+
<p className="text-sm font-medium leading-none">Browser</p>
71+
<p className="text-xs text-muted-foreground">Desktop pop-ups</p>
72+
</div>
73+
</label>
74+
<label
75+
htmlFor="channel-discord"
76+
className={`flex items-center gap-3 rounded-lg border p-3 cursor-pointer transition-colors ${
77+
notificationChannel === "discord"
78+
? "border-primary bg-primary/5"
79+
: "border-muted hover:border-muted-foreground/40"
80+
}`}
81+
>
82+
<RadioGroupItem value="discord" id="channel-discord" />
83+
<div className="space-y-0.5">
84+
<p className="text-sm font-medium leading-none">Discord</p>
85+
<p className="text-xs text-muted-foreground">Webhook messages</p>
86+
</div>
87+
</label>
88+
</RadioGroup>
4389
</div>
4490

4591
<div className="h-px bg-gradient-to-r from-transparent via-border to-transparent" />
4692

47-
<div className="flex items-center justify-between gap-4">
48-
<div className="space-y-1">
49-
<Label className="text-base font-semibold">Agent session finished</Label>
50-
<p className="text-xs text-muted-foreground">
51-
Fires when an agent finishes or disconnects.
52-
</p>
93+
{notificationChannel === "browser" && (
94+
<div className="space-y-6">
95+
<div className="flex items-center justify-between gap-4">
96+
<div className="space-y-1">
97+
<Label className="text-base font-semibold">Agent awaits your response</Label>
98+
<p className="text-xs text-muted-foreground">
99+
Fires when an agent is waiting for your input. Debounced by 2s to avoid noise from
100+
brief status flickers.
101+
</p>
102+
</div>
103+
<Switch
104+
checked={notifications.awaitingInput}
105+
onCheckedChange={(value) => toggle("awaitingInput", value)}
106+
/>
107+
</div>
108+
109+
<div className="h-px bg-gradient-to-r from-transparent via-border to-transparent" />
110+
111+
<div className="flex items-center justify-between gap-4">
112+
<div className="space-y-1">
113+
<Label className="text-base font-semibold">Agent session finished</Label>
114+
<p className="text-xs text-muted-foreground">
115+
Fires when an agent finishes or disconnects.
116+
</p>
117+
</div>
118+
<Switch
119+
checked={notifications.agentFinished}
120+
onCheckedChange={(value) => toggle("agentFinished", value)}
121+
/>
122+
</div>
53123
</div>
54-
<Switch
55-
checked={notifications.agentFinished}
56-
onCheckedChange={(value) => toggle("agentFinished", value)}
57-
/>
58-
</div>
124+
)}
125+
126+
{notificationChannel === "discord" && (
127+
<div className="space-y-4">
128+
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
129+
<MessageSquare className="h-4 w-4" />
130+
Discord webhook
131+
</div>
132+
<div className="space-y-2">
133+
<Label htmlFor="discord-webhook">Webhook URL</Label>
134+
<div className="flex gap-2">
135+
<div className="relative flex-1">
136+
<input
137+
id="discord-webhook"
138+
type={showUrl ? "text" : "password"}
139+
placeholder="https://discord.com/api/webhooks/..."
140+
value={webhookUrl ?? ""}
141+
onChange={(e) => void onWebhookChange(e.target.value)}
142+
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm pr-9 focus:outline-none focus:ring-1 focus:ring-ring"
143+
/>
144+
<button
145+
type="button"
146+
onClick={() => setShowUrl((v) => !v)}
147+
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
148+
tabIndex={-1}
149+
>
150+
{showUrl ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
151+
</button>
152+
</div>
153+
<Button
154+
variant="outline"
155+
size="sm"
156+
disabled={!webhookUrl}
157+
onClick={onWebhookTest}
158+
>
159+
Test
160+
</Button>
161+
</div>
162+
{webhookTestStatus === "success" && (
163+
<p className="text-xs text-green-600 dark:text-green-400">Test message sent! Check your Discord channel.</p>
164+
)}
165+
{webhookTestStatus === "error" && (
166+
<p className="text-xs text-destructive">Test failed. Double-check the webhook URL.</p>
167+
)}
168+
{webhookSaveError && (
169+
<p className="text-xs text-destructive">{webhookSaveError}</p>
170+
)}
171+
</div>
59172

173+
<div className="rounded-md border border-muted bg-muted/30 p-3 space-y-1">
174+
<p className="text-xs font-semibold text-muted-foreground">How to get a webhook URL</p>
175+
<ol className="text-xs text-muted-foreground space-y-0.5 list-decimal list-inside">
176+
<li>Open your Discord server → go to the channel you want</li>
177+
<li>Channel Settings → Integrations → Webhooks → New Webhook</li>
178+
<li>Copy the webhook URL and paste it above</li>
179+
</ol>
180+
</div>
181+
182+
<div className="text-xs text-muted-foreground space-y-1">
183+
<p className="font-semibold">You'll be notified when:</p>
184+
<ul className="list-disc list-inside space-y-0.5">
185+
<li>A session finishes — with task name, duration, and token count</li>
186+
<li>An agent crashes or hits an error</li>
187+
<li>An agent is waiting for your input</li>
188+
</ul>
189+
</div>
190+
</div>
191+
)}
60192
</CardContent>
61193
</Card>
62194
);

apps/web/src/features/settings/hooks/use-discord-preferences.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,23 @@ import { fetchServerPreference, saveServerPreference } from "../../../lib/user-p
33

44
interface DiscordPreferences {
55
webhookUrl: string | null;
6+
notificationChannel: "browser" | "discord";
67
saveError: string | null;
78
testStatus: "idle" | "success" | "error";
89
handleChange: (value: string) => Promise<void>;
10+
handleChannelChange: (channel: "browser" | "discord") => Promise<void>;
911
testWebhook: () => Promise<void>;
1012
}
1113

1214
export function useDiscordPreferences(): DiscordPreferences {
1315
const [webhookUrl, setWebhookUrl] = useState<string | null>(null);
16+
const [notificationChannel, setNotificationChannel] = useState<"browser" | "discord">("browser");
1417
const [saveError, setSaveError] = useState<string | null>(null);
1518
const [testStatus, setTestStatus] = useState<"idle" | "success" | "error">("idle");
1619

1720
useEffect(() => {
1821
fetchServerPreference("discordWebhookUrl", "").then(setWebhookUrl);
22+
fetchServerPreference<"browser" | "discord">("notificationChannel", "browser").then(setNotificationChannel);
1923
}, []);
2024

2125
async function handleChange(value: string) {
@@ -29,6 +33,16 @@ export function useDiscordPreferences(): DiscordPreferences {
2933
}
3034
}
3135

36+
async function handleChannelChange(channel: "browser" | "discord") {
37+
setNotificationChannel(channel);
38+
try {
39+
await saveServerPreference("notificationChannel", channel);
40+
setSaveError(null);
41+
} catch {
42+
setSaveError("Failed to save. Check your connection.");
43+
}
44+
}
45+
3246
async function testWebhook() {
3347
if (!webhookUrl) return;
3448
setTestStatus("idle");
@@ -51,5 +65,5 @@ export function useDiscordPreferences(): DiscordPreferences {
5165
}
5266
}
5367

54-
return { webhookUrl, saveError, testStatus, handleChange, testWebhook };
68+
return { webhookUrl, notificationChannel, saveError, testStatus, handleChange, handleChannelChange, testWebhook };
5569
}

apps/web/src/features/settings/pages/PreferencesPage.tsx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,20 @@ import { AppearanceSection } from "../components/AppearanceSection";
88
import { DashboardSection } from "../components/DashboardSection";
99
import { NotificationsSection } from "../components/NotificationsSection";
1010
import { McpIntegrationSection } from "../components/McpIntegrationSection";
11-
import { DiscordSection } from "../components/DiscordSection";
1211

1312
export function PreferencesPage() {
1413
const navigate = useNavigate();
1514
const { preferences, updatePreference } = useUserPreferences();
1615
const { mcpSetTaskEnabled, mcpJiraEnabled, saveError, handleMcpToggle, handleJiraToggle } = useMcpPreferences();
17-
const { webhookUrl, saveError: discordSaveError, testStatus, handleChange, testWebhook } = useDiscordPreferences();
16+
const {
17+
webhookUrl,
18+
notificationChannel,
19+
saveError: discordSaveError,
20+
testStatus,
21+
handleChange,
22+
handleChannelChange,
23+
testWebhook,
24+
} = useDiscordPreferences();
1825

1926
return (
2027
<div className="container mx-auto max-w-4xl py-8 space-y-6">
@@ -35,6 +42,13 @@ export function PreferencesPage() {
3542
<NotificationsSection
3643
notifications={preferences.notifications}
3744
onUpdate={(updated) => updatePreference("notifications", updated)}
45+
notificationChannel={notificationChannel}
46+
onChannelChange={handleChannelChange}
47+
webhookUrl={webhookUrl}
48+
webhookSaveError={discordSaveError}
49+
webhookTestStatus={testStatus}
50+
onWebhookChange={handleChange}
51+
onWebhookTest={testWebhook}
3852
/>
3953
<McpIntegrationSection
4054
mcpSetTaskEnabled={mcpSetTaskEnabled}
@@ -43,13 +57,6 @@ export function PreferencesPage() {
4357
onJiraToggle={handleJiraToggle}
4458
saveError={saveError}
4559
/>
46-
<DiscordSection
47-
webhookUrl={webhookUrl}
48-
saveError={discordSaveError}
49-
testStatus={testStatus}
50-
onUpdate={handleChange}
51-
onTest={testWebhook}
52-
/>
5360
</div>
5461
);
5562
}

0 commit comments

Comments
 (0)