feat: add desktop notifications and skills updates

This commit is contained in:
Simos Mikelatos
2026-06-26 10:25:47 +00:00
parent e6c6f89dda
commit 63f3c3941d
32 changed files with 1693 additions and 328 deletions

View File

@@ -110,6 +110,7 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
channels: {
inApp: true,
webPush: false,
desktop: false,
sound: true,
},
events: {
@@ -128,6 +129,7 @@ const normalizeNotificationPreferences = (
channels: {
inApp: preferences?.channels?.inApp ?? defaults.channels.inApp,
webPush: preferences?.channels?.webPush ?? defaults.channels.webPush,
desktop: preferences?.channels?.desktop ?? defaults.channels.desktop,
sound: preferences?.channels?.sound ?? defaults.channels.sound,
},
events: {

View File

@@ -30,6 +30,7 @@ export type NotificationPreferencesState = {
channels: {
inApp: boolean;
webPush: boolean;
desktop: boolean;
sound: boolean;
};
events: {

View File

@@ -1,3 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -18,8 +19,22 @@ import { useSettingsController } from '../hooks/useSettingsController';
import { useWebPush } from '../../../hooks/useWebPush';
import type { SettingsProps } from '../types/types';
type DesktopNotificationsState = {
enabled: boolean;
supported: boolean;
connectedCount?: number;
targetCount?: number;
lastError?: string | null;
};
function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: SettingsProps) {
const { t } = useTranslation('settings');
const desktopNotificationsBridge = useMemo(() => (
typeof window === 'undefined'
? null
: ((window as any).cloudcliDesktopNotifications || null)
), []);
const [desktopNotificationsState, setDesktopNotificationsState] = useState<DesktopNotificationsState | null>(null);
const {
activeTab,
setActiveTab,
@@ -75,6 +90,45 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
});
};
useEffect(() => {
if (!desktopNotificationsBridge) return undefined;
let mounted = true;
desktopNotificationsBridge.getState().then((state: any) => {
if (mounted) {
setDesktopNotificationsState(state?.desktopNotifications || null);
}
}).catch(() => {});
const unsubscribe = desktopNotificationsBridge.onStateUpdated?.((state: any) => {
if (mounted) {
setDesktopNotificationsState(state?.desktopNotifications || null);
}
});
return () => {
mounted = false;
unsubscribe?.();
};
}, [desktopNotificationsBridge]);
const handleEnableDesktopNotifications = async () => {
if (!desktopNotificationsBridge) return;
const state = await desktopNotificationsBridge.update({ enabled: true });
setDesktopNotificationsState(state?.desktopNotifications || null);
setNotificationPreferences({
...notificationPreferences,
channels: { ...notificationPreferences.channels, desktop: true },
});
};
const handleDisableDesktopNotifications = async () => {
if (!desktopNotificationsBridge) return;
const state = await desktopNotificationsBridge.update({ enabled: false });
setDesktopNotificationsState(state?.desktopNotifications || null);
setNotificationPreferences({
...notificationPreferences,
channels: { ...notificationPreferences.channels, desktop: false },
});
};
if (!isOpen) {
return null;
}
@@ -155,6 +209,10 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
isPushLoading={isPushLoading}
onEnablePush={handleEnablePush}
onDisablePush={handleDisablePush}
isDesktop={Boolean(desktopNotificationsBridge)}
desktopNotifications={desktopNotificationsState}
onEnableDesktopNotifications={handleEnableDesktopNotifications}
onDisableDesktopNotifications={handleDisableDesktopNotifications}
/>
)}

View File

@@ -13,6 +13,16 @@ type NotificationsSettingsTabProps = {
isPushLoading: boolean;
onEnablePush: () => void;
onDisablePush: () => void;
isDesktop?: boolean;
desktopNotifications?: {
enabled: boolean;
supported: boolean;
connectedCount?: number;
targetCount?: number;
lastError?: string | null;
} | null;
onEnableDesktopNotifications?: () => void;
onDisableDesktopNotifications?: () => void;
};
export default function NotificationsSettingsTab({
@@ -23,6 +33,10 @@ export default function NotificationsSettingsTab({
isPushLoading,
onEnablePush,
onDisablePush,
isDesktop = false,
desktopNotifications = null,
onEnableDesktopNotifications,
onDisableDesktopNotifications,
}: NotificationsSettingsTabProps) {
const { t } = useTranslation('settings');
@@ -33,57 +47,107 @@ export default function NotificationsSettingsTab({
<div className="space-y-6 md:space-y-8">
<div className="space-y-4">
<div className="flex items-center gap-3">
<Bell className="w-5 h-5 text-blue-600" />
<Bell className="h-5 w-5 text-blue-600" />
<h3 className="text-lg font-medium text-foreground">{t('notifications.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{t('notifications.description')}</p>
</div>
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
<h4 className="font-medium text-foreground">{t('notifications.webPush.title')}</h4>
{!pushSupported ? (
<p className="text-sm text-muted-foreground">{t('notifications.webPush.unsupported')}</p>
) : pushDenied ? (
<p className="text-sm text-muted-foreground">{t('notifications.webPush.denied')}</p>
) : (
<div className="flex items-center gap-3">
<button
type="button"
disabled={isPushLoading}
onClick={() => {
if (isPushSubscribed) {
onDisablePush();
} else {
onEnablePush();
}
}}
className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
isPushSubscribed
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
}`}
>
{isPushLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : isPushSubscribed ? (
<BellOff className="w-4 h-4" />
) : (
<BellRing className="w-4 h-4" />
{isDesktop ? (
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
<h4 className="font-medium text-foreground">
{t('notifications.desktop.title', { defaultValue: 'Notify this desktop app' })}
</h4>
{desktopNotifications?.supported === false ? (
<p className="text-sm text-muted-foreground">
{t('notifications.desktop.unsupported', { defaultValue: 'Desktop notifications are not supported on this system.' })}
</p>
) : (
<div className="space-y-2">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => {
if (desktopNotifications?.enabled) {
onDisableDesktopNotifications?.();
} else {
onEnableDesktopNotifications?.();
}
}}
className={`inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors ${
desktopNotifications?.enabled
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
}`}
>
{desktopNotifications?.enabled ? (
<BellOff className="h-4 w-4" />
) : (
<BellRing className="h-4 w-4" />
)}
{desktopNotifications?.enabled
? t('notifications.desktop.disable', { defaultValue: 'Disable desktop notifications' })
: t('notifications.desktop.enable', { defaultValue: 'Enable desktop notifications' })}
</button>
{desktopNotifications?.enabled && (
<span className="text-sm text-green-600 dark:text-green-400">
{t('notifications.desktop.enabled', { defaultValue: 'Desktop notifications are enabled' })}
</span>
)}
</div>
{desktopNotifications?.lastError && (
<p className="text-sm text-red-600 dark:text-red-400">{desktopNotifications.lastError}</p>
)}
{isPushLoading
? t('notifications.webPush.loading')
: isPushSubscribed
? t('notifications.webPush.disable')
: t('notifications.webPush.enable')}
</button>
{isPushSubscribed && (
<span className="text-sm text-green-600 dark:text-green-400">
{t('notifications.webPush.enabled')}
</span>
)}
</div>
)}
</div>
</div>
)}
</div>
) : (
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
<h4 className="font-medium text-foreground">{t('notifications.webPush.title')}</h4>
{!pushSupported ? (
<p className="text-sm text-muted-foreground">{t('notifications.webPush.unsupported')}</p>
) : pushDenied ? (
<p className="text-sm text-muted-foreground">{t('notifications.webPush.denied')}</p>
) : (
<div className="flex items-center gap-3">
<button
type="button"
disabled={isPushLoading}
onClick={() => {
if (isPushSubscribed) {
onDisablePush();
} else {
onEnablePush();
}
}}
className={`inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
isPushSubscribed
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
}`}
>
{isPushLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isPushSubscribed ? (
<BellOff className="h-4 w-4" />
) : (
<BellRing className="h-4 w-4" />
)}
{isPushLoading
? t('notifications.webPush.loading')
: isPushSubscribed
? t('notifications.webPush.disable')
: t('notifications.webPush.enable')}
</button>
{isPushSubscribed && (
<span className="text-sm text-green-600 dark:text-green-400">
{t('notifications.webPush.enabled')}
</span>
)}
</div>
)}
</div>
)}
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
@@ -133,7 +197,7 @@ export default function NotificationsSettingsTab({
</Button>
</div>
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm text-foreground">
@@ -149,7 +213,7 @@ export default function NotificationsSettingsTab({
},
})
}
className="w-4 h-4"
className="h-4 w-4"
/>
{t('notifications.events.actionRequired')}
</label>
@@ -167,7 +231,7 @@ export default function NotificationsSettingsTab({
},
})
}
className="w-4 h-4"
className="h-4 w-4"
/>
{t('notifications.events.stop')}
</label>
@@ -185,7 +249,7 @@ export default function NotificationsSettingsTab({
},
})
}
className="w-4 h-4"
className="h-4 w-4"
/>
{t('notifications.events.error')}
</label>

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { authenticatedFetch } from '../utils/api';
type WebPushState = {
@@ -22,7 +23,12 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
export function useWebPush(): WebPushState {
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(() => {
if (typeof window === 'undefined' || !('Notification' in window) || !('serviceWorker' in navigator)) {
if (
typeof window === 'undefined'
|| Boolean((window as any).cloudcliDesktopNotifications)
|| !('Notification' in window)
|| !('serviceWorker' in navigator)
) {
return 'unsupported';
}
return Notification.permission;

View File

@@ -105,14 +105,21 @@
"title": "Notifications",
"description": "Control which notification events you receive.",
"webPush": {
"title": "Web Push Notifications",
"enable": "Enable Push Notifications",
"disable": "Disable Push Notifications",
"enabled": "Push notifications are enabled",
"title": "Notify this browser",
"enable": "Enable notifications",
"disable": "Disable notifications",
"enabled": "Notifications are enabled for this browser",
"loading": "Updating...",
"unsupported": "Push notifications are not supported in this browser.",
"denied": "Push notifications are blocked. Please allow them in your browser settings."
},
"desktop": {
"title": "Notify this desktop app",
"enable": "Enable notifications",
"disable": "Disable notifications",
"enabled": "Notifications are enabled for this desktop app",
"unsupported": "Desktop notifications are not supported on this system."
},
"sound": {
"title": "Sound",
"description": "Play a short tone when a chat run finishes or needs tool approval.",