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>
);
}

103
src/hooks/useWebPush.ts Normal file
View File

@@ -0,0 +1,103 @@
import { useCallback, useEffect, useState } from 'react';
import { authenticatedFetch } from '../utils/api';
type WebPushState = {
permission: NotificationPermission | 'unsupported';
isSubscribed: boolean;
isLoading: boolean;
subscribe: () => Promise<void>;
unsubscribe: () => Promise<void>;
};
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export function useWebPush(): WebPushState {
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(() => {
if (typeof window === 'undefined' || !('Notification' in window) || !('serviceWorker' in navigator)) {
return 'unsupported';
}
return Notification.permission;
});
const [isSubscribed, setIsSubscribed] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// Check existing subscription on mount
useEffect(() => {
if (permission === 'unsupported') return;
navigator.serviceWorker.ready.then((registration) => {
registration.pushManager.getSubscription().then((sub) => {
setIsSubscribed(sub !== null);
});
}).catch(() => {
// SW not ready yet
});
}, [permission]);
const subscribe = useCallback(async () => {
if (permission === 'unsupported') return;
setIsLoading(true);
try {
const perm = await Notification.requestPermission();
setPermission(perm);
if (perm !== 'granted') return;
const keyRes = await authenticatedFetch('/api/settings/push/vapid-public-key');
const { publicKey } = await keyRes.json();
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey).buffer as ArrayBuffer,
});
const subJson = subscription.toJSON();
await authenticatedFetch('/api/settings/push/subscribe', {
method: 'POST',
body: JSON.stringify({
endpoint: subJson.endpoint,
keys: subJson.keys,
}),
});
setIsSubscribed(true);
} catch (err) {
console.error('Push subscribe failed:', err);
} finally {
setIsLoading(false);
}
}, [permission]);
const unsubscribe = useCallback(async () => {
setIsLoading(true);
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
const endpoint = subscription.endpoint;
await subscription.unsubscribe();
await authenticatedFetch('/api/settings/push/unsubscribe', {
method: 'POST',
body: JSON.stringify({ endpoint }),
});
}
setIsSubscribed(false);
} catch (err) {
console.error('Push unsubscribe failed:', err);
} finally {
setIsLoading(false);
}
}, []);
return { permission, isSubscribed, isLoading, subscribe, unsubscribe };
}

View File

@@ -206,6 +206,36 @@
"failedToCreateFolder": "Failed to create folder"
}
},
"notifications": {
"genericTool": "a tool",
"codes": {
"generic": {
"info": {
"title": "Notification"
}
},
"permission": {
"required": {
"title": "Action Required",
"body": "{{toolName}} is waiting for your decision."
}
},
"run": {
"stopped": {
"title": "Run Stopped",
"body": "Reason: {{reason}}"
},
"failed": {
"title": "Run Failed"
}
},
"agent": {
"notification": {
"title": "Agent Notification"
}
}
}
},
"versionUpdate": {
"title": "Update Available",
"newVersionReady": "A new version is ready",

View File

@@ -105,7 +105,28 @@
"git": "Git",
"apiTokens": "API & Tokens",
"tasks": "Tasks",
"notifications": "Notifications",
"plugins": "Plugins"
},
"notifications": {
"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",
"loading": "Updating...",
"unsupported": "Push notifications are not supported in this browser.",
"denied": "Push notifications are blocked. Please allow them in your browser settings."
},
"events": {
"title": "Event Types",
"actionRequired": "Action required",
"stop": "Run stopped",
"error": "Run failed"
}
},
"appearanceSettings": {
"darkMode": {

View File

@@ -206,6 +206,36 @@
"failedToCreateFolder": "フォルダの作成に失敗しました"
}
},
"notifications": {
"genericTool": "ツール",
"codes": {
"generic": {
"info": {
"title": "通知"
}
},
"permission": {
"required": {
"title": "対応が必要です",
"body": "{{toolName}} があなたの判断を待っています。"
}
},
"run": {
"stopped": {
"title": "実行が停止しました",
"body": "理由: {{reason}}"
},
"failed": {
"title": "実行に失敗しました"
}
},
"agent": {
"notification": {
"title": "エージェント通知"
}
}
}
},
"versionUpdate": {
"title": "アップデートのお知らせ",
"newVersionReady": "新しいバージョンが利用可能です",

View File

@@ -105,7 +105,28 @@
"git": "Git",
"apiTokens": "API & トークン",
"tasks": "タスク",
"notifications": "通知",
"plugins": "プラグイン"
},
"notifications": {
"title": "通知",
"description": "受信する通知イベントを設定します。",
"webPush": {
"title": "Webプッシュ通知",
"enable": "プッシュ通知を有効にする",
"disable": "プッシュ通知を無効にする",
"enabled": "プッシュ通知は有効です",
"loading": "更新中...",
"unsupported": "このブラウザではプッシュ通知がサポートされていません。",
"denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。"
},
"events": {
"title": "イベント種別",
"actionRequired": "対応が必要",
"stop": "実行停止",
"error": "実行失敗"
}
},
"appearanceSettings": {
"darkMode": {

View File

@@ -206,6 +206,36 @@
"failedToCreateFolder": "폴더 생성 실패"
}
},
"notifications": {
"genericTool": "도구",
"codes": {
"generic": {
"info": {
"title": "알림"
}
},
"permission": {
"required": {
"title": "작업 필요",
"body": "{{toolName}} 에 대한 결정을 기다리고 있습니다."
}
},
"run": {
"stopped": {
"title": "실행이 중지되었습니다",
"body": "사유: {{reason}}"
},
"failed": {
"title": "실행 실패"
}
},
"agent": {
"notification": {
"title": "에이전트 알림"
}
}
}
},
"versionUpdate": {
"title": "업데이트 가능",
"newVersionReady": "새 버전이 준비되었습니다",

View File

@@ -105,7 +105,28 @@
"git": "Git",
"apiTokens": "API & 토큰",
"tasks": "작업",
"notifications": "알림",
"plugins": "플러그인"
},
"notifications": {
"title": "알림",
"description": "수신할 알림 이벤트를 설정합니다.",
"webPush": {
"title": "웹 푸시 알림",
"enable": "푸시 알림 활성화",
"disable": "푸시 알림 비활성화",
"enabled": "푸시 알림이 활성화되었습니다",
"loading": "업데이트 중...",
"unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.",
"denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요."
},
"events": {
"title": "이벤트 유형",
"actionRequired": "작업 필요",
"stop": "실행 중지",
"error": "실행 실패"
}
},
"appearanceSettings": {
"darkMode": {

View File

@@ -206,6 +206,36 @@
"failedToCreateFolder": "创建文件夹失败"
}
},
"notifications": {
"genericTool": "工具",
"codes": {
"generic": {
"info": {
"title": "通知"
}
},
"permission": {
"required": {
"title": "需要处理",
"body": "{{toolName}} 正在等待你的决策。"
}
},
"run": {
"stopped": {
"title": "运行已停止",
"body": "原因:{{reason}}"
},
"failed": {
"title": "运行失败"
}
},
"agent": {
"notification": {
"title": "Agent 通知"
}
}
}
},
"versionUpdate": {
"title": "有可用更新",
"newVersionReady": "新版本已准备就绪",

View File

@@ -105,7 +105,28 @@
"git": "Git",
"apiTokens": "API 和令牌",
"tasks": "任务",
"notifications": "通知",
"plugins": "插件"
},
"notifications": {
"title": "通知",
"description": "控制你希望接收的通知事件。",
"webPush": {
"title": "Web 推送通知",
"enable": "启用推送通知",
"disable": "关闭推送通知",
"enabled": "推送通知已启用",
"loading": "更新中...",
"unsupported": "此浏览器不支持推送通知。",
"denied": "推送通知已被阻止,请在浏览器设置中允许。"
},
"events": {
"title": "事件类型",
"actionRequired": "需要处理",
"stop": "运行已停止",
"error": "运行失败"
}
},
"appearanceSettings": {
"darkMode": {

View File

@@ -7,14 +7,10 @@ import 'katex/dist/katex.min.css'
// Initialize i18n
import './i18n/config.js'
// Clean up stale service workers on app load to prevent caching issues after builds
// Register service worker for PWA + Web Push support
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(registrations => {
registrations.forEach(registration => {
registration.unregister();
});
}).catch(err => {
console.warn('Failed to unregister service workers:', err);
navigator.serviceWorker.register('/sw.js').catch(err => {
console.warn('Service worker registration failed:', err);
});
}