feat: introduce notification system and claude notifications (#450)

* feat: introduce notification system and claude notifications

* fix(sw): prevent caching of API requests and WebSocket upgrades

* default to false for webpush notifications and translations for the button

* fix: notifications orchestrator and add a notification when  first enabled

* fix: remove unused state update and dependency in settings controller hook

* fix: show notifications settings tab

* fix: add notifications for response completion for all providers

* feat: show session name in notification and don't reload tab on clicking
--- the notification

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Haileyesus <something@gmail.com>
This commit is contained in:
Simos Mikelatos
2026-03-13 16:59:09 +01:00
committed by GitHub
parent 6f6dacad5e
commit 45e71a0e73
36 changed files with 1629 additions and 69 deletions

View File

@@ -72,6 +72,40 @@ export default function AppContent() {
};
}, [openSettings]);
useEffect(() => {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
return undefined;
}
const handleServiceWorkerMessage = (event: MessageEvent) => {
const message = event.data;
if (!message || message.type !== 'notification:navigate') {
return;
}
if (typeof message.provider === 'string' && message.provider.trim()) {
localStorage.setItem('selected-provider', message.provider);
}
setActiveTab('chat');
setSidebarOpen(false);
void refreshProjectsSilently();
if (typeof message.sessionId === 'string' && message.sessionId) {
navigate(`/session/${message.sessionId}`);
return;
}
navigate('/');
};
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage);
return () => {
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage);
};
}, [navigate, refreshProjectsSilently, setActiveTab, setSidebarOpen]);
// Permission recovery: query pending permissions on WebSocket reconnect or session change
useEffect(() => {
const isReconnect = isConnected && !wasConnectedRef.current;

View File

@@ -82,6 +82,24 @@ const createFakeSubmitEvent = () => {
const isTemporarySessionId = (sessionId: string | null | undefined) =>
Boolean(sessionId && sessionId.startsWith('new-session-'));
const getNotificationSessionSummary = (
selectedSession: ProjectSession | null,
fallbackInput: string,
): string | null => {
const sessionSummary = selectedSession?.summary || selectedSession?.name || selectedSession?.title;
if (typeof sessionSummary === 'string' && sessionSummary.trim()) {
const normalized = sessionSummary.replace(/\s+/g, ' ').trim();
return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
}
const normalizedFallback = fallbackInput.replace(/\s+/g, ' ').trim();
if (!normalizedFallback) {
return null;
}
return normalizedFallback.length > 80 ? `${normalizedFallback.slice(0, 77)}...` : normalizedFallback;
};
export function useChatComposerState({
selectedProject,
selectedSession,
@@ -603,6 +621,7 @@ export function useChatComposerState({
const toolsSettings = getToolsSettings();
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput);
if (provider === 'cursor') {
sendMessage({
@@ -616,6 +635,7 @@ export function useChatComposerState({
resume: Boolean(effectiveSessionId),
model: cursorModel,
skipPermissions: toolsSettings?.skipPermissions || false,
sessionSummary,
toolsSettings,
},
});
@@ -630,6 +650,7 @@ export function useChatComposerState({
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
model: codexModel,
sessionSummary,
permissionMode: permissionMode === 'plan' ? 'default' : permissionMode,
},
});
@@ -644,6 +665,7 @@ export function useChatComposerState({
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
model: geminiModel,
sessionSummary,
permissionMode,
toolsSettings,
},
@@ -660,6 +682,7 @@ export function useChatComposerState({
toolsSettings,
permissionMode,
model: claudeModel,
sessionSummary,
images: uploadedImages,
},
});
@@ -681,6 +704,7 @@ export function useChatComposerState({
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
},
[
selectedSession,
attachedImages,
claudeModel,
codexModel,
@@ -697,7 +721,6 @@ export function useChatComposerState({
resetCommandMenuState,
scrollToBottom,
selectedProject,
selectedSession?.id,
sendMessage,
setCanAbortSession,
setChatMessages,

View File

@@ -80,7 +80,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
}
}, [displayConfig, parsedData, onFileOpen]);
// Route subagent containers to dedicated component (after hooks to keep call order stable)
// Route subagent containers to dedicated component (after hooks to satisfy Rules of Hooks)
if (isSubagentContainer && subagentState) {
if (mode === 'result') {
return null;

View File

@@ -18,6 +18,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTab[] = [
'git',
'api',
'tasks',
'notifications',
];
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex'];

View File

@@ -20,6 +20,7 @@ import type {
McpServer,
McpToolsResult,
McpTestResult,
NotificationPreferencesState,
ProjectSortOrder,
SettingsMainTab,
SettingsProject,
@@ -96,9 +97,14 @@ type CodexSettingsStorage = {
permissionMode?: CodexPermissionMode;
};
type NotificationPreferencesResponse = {
success?: boolean;
preferences?: NotificationPreferencesState;
};
type ActiveLoginProvider = AgentProvider | '';
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'plugins'];
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'notifications', 'plugins'];
const normalizeMainTab = (tab: string): SettingsMainTab => {
// Keep backwards compatibility with older callers that still pass "tools".
@@ -186,6 +192,18 @@ const createEmptyCursorPermissions = (): CursorPermissionsState => ({
...DEFAULT_CURSOR_PERMISSIONS,
});
const createDefaultNotificationPreferences = (): NotificationPreferencesState => ({
channels: {
inApp: true,
webPush: false,
},
events: {
actionRequired: true,
stop: true,
error: true,
},
});
export function useSettingsController({ isOpen, initialTab, projects, onClose }: UseSettingsControllerArgs) {
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
const closeTimerRef = useRef<number | null>(null);
@@ -204,6 +222,9 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
const [cursorPermissions, setCursorPermissions] = useState<CursorPermissionsState>(() => (
createEmptyCursorPermissions()
));
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferencesState>(() => (
createDefaultNotificationPreferences()
));
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
const [geminiPermissionMode, setGeminiPermissionMode] = useState<GeminiPermissionMode>('default');
@@ -670,6 +691,22 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
);
setGeminiPermissionMode(savedGeminiSettings.permissionMode || 'default');
try {
const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences');
if (notificationResponse.ok) {
const notificationData = await toResponseJson<NotificationPreferencesResponse>(notificationResponse);
if (notificationData.success && notificationData.preferences) {
setNotificationPreferences(notificationData.preferences);
} else {
setNotificationPreferences(createDefaultNotificationPreferences());
}
} else {
setNotificationPreferences(createDefaultNotificationPreferences());
}
} catch {
setNotificationPreferences(createDefaultNotificationPreferences());
}
await Promise.all([
fetchMcpServers(),
fetchCursorMcpServers(),
@@ -679,6 +716,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
console.error('Error loading settings:', error);
setClaudePermissions(createEmptyClaudePermissions());
setCursorPermissions(createEmptyCursorPermissions());
setNotificationPreferences(createDefaultNotificationPreferences());
setCodexPermissionMode('default');
setProjectSortOrder('name');
}
@@ -699,7 +737,9 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
void checkAuthStatus(loginProvider);
}, [checkAuthStatus, loginProvider]);
const saveSettings = useCallback(() => {
const saveSettings = useCallback(async () => {
setSaveStatus(null);
try {
const now = new Date().toISOString();
localStorage.setItem('claude-settings', JSON.stringify({
@@ -727,6 +767,14 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
lastUpdated: now,
}));
const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences', {
method: 'PUT',
body: JSON.stringify(notificationPreferences),
});
if (!notificationResponse.ok) {
throw new Error('Failed to save notification preferences');
}
setSaveStatus('success');
} catch (error) {
console.error('Error saving settings:', error);
@@ -740,6 +788,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
cursorPermissions.allowedCommands,
cursorPermissions.disallowedCommands,
cursorPermissions.skipPermissions,
notificationPreferences,
geminiPermissionMode,
projectSortOrder,
]);
@@ -862,6 +911,8 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
setClaudePermissions,
cursorPermissions,
setCursorPermissions,
notificationPreferences,
setNotificationPreferences,
codexPermissionMode,
setCodexPermissionMode,
mcpServers,

View File

@@ -1,6 +1,6 @@
import type { Dispatch, SetStateAction } from 'react';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'plugins';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications' | 'plugins';
export type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
export type AgentCategory = 'account' | 'permissions' | 'mcp';
export type ProjectSortOrder = 'name' | 'date';
@@ -106,6 +106,18 @@ export type ClaudePermissionsState = {
skipPermissions: boolean;
};
export type NotificationPreferencesState = {
channels: {
inApp: boolean;
webPush: boolean;
};
events: {
actionRequired: boolean;
stop: boolean;
error: boolean;
};
};
export type CursorPermissionsState = {
allowedCommands: string[];
disallowedCommands: string[];

View File

@@ -9,9 +9,11 @@ import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
import PluginSettingsTab from '../../plugins/view/PluginSettingsTab';
import { useSettingsController } from '../hooks/useSettingsController';
import { useWebPush } from '../../../hooks/useWebPush';
import type { SettingsProps } from '../types/types';
function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: SettingsProps) {
@@ -27,6 +29,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
updateCodeEditorSetting,
claudePermissions,
setClaudePermissions,
notificationPreferences,
setNotificationPreferences,
cursorPermissions,
setCursorPermissions,
codexPermissionMode,
@@ -70,6 +74,32 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
onClose,
});
const {
permission: pushPermission,
isSubscribed: isPushSubscribed,
isLoading: isPushLoading,
subscribe: pushSubscribe,
unsubscribe: pushUnsubscribe,
} = useWebPush();
const handleEnablePush = async () => {
await pushSubscribe();
// Server sets webPush: true in preferences on subscribe; sync local state
setNotificationPreferences({
...notificationPreferences,
channels: { ...notificationPreferences.channels, webPush: true },
});
};
const handleDisablePush = async () => {
await pushUnsubscribe();
// Server sets webPush: false in preferences on unsubscribe; sync local state
setNotificationPreferences({
...notificationPreferences,
channels: { ...notificationPreferences.channels, webPush: false },
});
};
if (!isOpen) {
return null;
}
@@ -161,6 +191,18 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
{activeTab === 'tasks' && <TasksSettingsTab />}
{activeTab === 'notifications' && (
<NotificationsSettingsTab
notificationPreferences={notificationPreferences}
onNotificationPreferencesChange={setNotificationPreferences}
pushPermission={pushPermission}
isPushSubscribed={isPushSubscribed}
isPushLoading={isPushLoading}
onEnablePush={handleEnablePush}
onDisablePush={handleDisablePush}
/>
)}
{activeTab === 'api' && <CredentialsSettingsTab />}
{activeTab === 'plugins' && <PluginSettingsTab />}

View File

@@ -20,6 +20,7 @@ const TAB_CONFIG: MainTabConfig[] = [
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
{ id: 'tasks', labelKey: 'mainTabs.tasks' },
{ id: 'notifications', labelKey: 'mainTabs.notifications' },
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
];
@@ -28,7 +29,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa
return (
<div className="border-b border-border">
<div className="flex px-4 md:px-6" role="tablist" aria-label={t('mainTabs.label', { defaultValue: 'Settings' })}>
<div className="flex px-4 md:px-6 overflow-x-auto scrollbar-hide" role="tablist" aria-label={t('mainTabs.label', { defaultValue: 'Settings' })}>
{TAB_CONFIG.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
@@ -39,7 +40,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa
role="tab"
aria-selected={isActive}
onClick={() => onChange(tab.id)}
className={`border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
isActive
? 'border-blue-600 text-blue-600 dark:text-blue-400'
: 'border-transparent text-muted-foreground hover:text-foreground'

View File

@@ -1,4 +1,4 @@
import { Bot, GitBranch, Key, ListChecks, Palette, Puzzle } from 'lucide-react';
import { Bell, Bot, GitBranch, Key, ListChecks, Palette, Puzzle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../../lib/utils';
import { PillBar, Pill } from '../../../shared/view/ui';
@@ -22,6 +22,7 @@ const NAV_ITEMS: NavItem[] = [
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
{ id: 'tasks', labelKey: 'mainTabs.tasks', icon: ListChecks },
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
{ id: 'notifications', labelKey: 'mainTabs.notifications', icon: Bell },
];
export default function SettingsSidebar({ activeTab, onChange }: SettingsSidebarProps) {

View File

@@ -0,0 +1,145 @@
import { Bell, BellOff, BellRing, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { NotificationPreferencesState } from '../../types/types';
type NotificationsSettingsTabProps = {
notificationPreferences: NotificationPreferencesState;
onNotificationPreferencesChange: (value: NotificationPreferencesState) => void;
pushPermission: NotificationPermission | 'unsupported';
isPushSubscribed: boolean;
isPushLoading: boolean;
onEnablePush: () => void;
onDisablePush: () => void;
};
export default function NotificationsSettingsTab({
notificationPreferences,
onNotificationPreferencesChange,
pushPermission,
isPushSubscribed,
isPushLoading,
onEnablePush,
onDisablePush,
}: NotificationsSettingsTabProps) {
const { t } = useTranslation('settings');
const pushSupported = pushPermission !== 'unsupported';
const pushDenied = pushPermission === 'denied';
return (
<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" />
<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" />
)}
{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 bg-card border border-border rounded-lg 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">
<input
type="checkbox"
checked={notificationPreferences.events.actionRequired}
onChange={(event) =>
onNotificationPreferencesChange({
...notificationPreferences,
events: {
...notificationPreferences.events,
actionRequired: event.target.checked,
},
})
}
className="w-4 h-4"
/>
{t('notifications.events.actionRequired')}
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={notificationPreferences.events.stop}
onChange={(event) =>
onNotificationPreferencesChange({
...notificationPreferences,
events: {
...notificationPreferences.events,
stop: event.target.checked,
},
})
}
className="w-4 h-4"
/>
{t('notifications.events.stop')}
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={notificationPreferences.events.error}
onChange={(event) =>
onNotificationPreferencesChange({
...notificationPreferences,
events: {
...notificationPreferences.events,
error: event.target.checked,
},
})
}
className="w-4 h-4"
/>
{t('notifications.events.error')}
</label>
</div>
</div>
</div>
);
}

View File

@@ -255,6 +255,7 @@ function ClaudePermissions({
<li><code className="rounded bg-blue-100 px-1 dark:bg-blue-800">"Bash(rm:*)"</code> {t('permissions.toolExamples.bashRm')}</li>
</ul>
</div>
</div>
);
}