From 061f0fd29796144e2c2aa798d3ba2a3de79dc660 Mon Sep 17 00:00:00 2001 From: simosmik Date: Fri, 27 Feb 2026 14:44:44 +0000 Subject: [PATCH] feat: introduce notification system and claude notifications --- package-lock.json | 76 ++++++++- package.json | 1 + public/sw.js | 53 ++++++- server/claude-sdk.js | 84 +++++++++- server/database/db.js | 144 ++++++++++++++++- server/database/init.sql | 29 +++- server/index.js | 13 +- server/middleware/auth.js | 9 +- server/routes/settings.js | 69 +++++++- server/services/notification-orchestrator.js | 149 ++++++++++++++++++ server/services/vapid-keys.js | 35 ++++ .../settings/constants/constants.ts | 1 + .../settings/hooks/useSettingsController.ts | 53 ++++++- src/components/settings/types/types.ts | 14 +- src/components/settings/view/Settings.tsx | 40 +++++ .../settings/view/SettingsMainTabs.tsx | 5 +- .../view/tabs/NotificationsSettingsTab.tsx | 129 +++++++++++++++ .../sections/content/PermissionsContent.tsx | 1 + src/hooks/useWebPush.ts | 103 ++++++++++++ src/i18n/locales/en/common.json | 30 ++++ src/i18n/locales/en/settings.json | 21 ++- src/i18n/locales/ja/common.json | 30 ++++ src/i18n/locales/ja/settings.json | 21 ++- src/i18n/locales/ko/common.json | 30 ++++ src/i18n/locales/ko/settings.json | 21 ++- src/i18n/locales/zh-CN/common.json | 30 ++++ src/i18n/locales/zh-CN/settings.json | 21 ++- src/main.jsx | 10 +- 28 files changed, 1187 insertions(+), 35 deletions(-) create mode 100644 server/services/notification-orchestrator.js create mode 100644 server/services/vapid-keys.js create mode 100644 src/components/settings/view/tabs/NotificationsSettingsTab.tsx create mode 100644 src/hooks/useWebPush.ts diff --git a/package-lock.json b/package-lock.json index 5283adb..f736ab5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "sqlite": "^5.1.1", "sqlite3": "^5.1.7", "tailwind-merge": "^3.3.1", + "web-push": "^3.6.7", "ws": "^8.14.2" }, "bin": { @@ -3374,7 +3375,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -3509,6 +3509,18 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -3745,6 +3757,12 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -6323,6 +6341,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -6364,7 +6391,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -8276,6 +8302,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -12753,6 +12785,46 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/web-push/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/web-push/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index 96828f9..6b07650 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "sqlite": "^5.1.1", "sqlite3": "^5.1.7", "tailwind-merge": "^3.3.1", + "web-push": "^3.6.7", "ws": "^8.14.2" }, "devDependencies": { diff --git a/public/sw.js b/public/sw.js index 181c60d..0858977 100755 --- a/public/sw.js +++ b/public/sw.js @@ -22,11 +22,9 @@ self.addEventListener('fetch', event => { event.respondWith( caches.match(event.request) .then(response => { - // Return cached response if found if (response) { return response; } - // Otherwise fetch from network return fetch(event.request); } ) @@ -46,4 +44,53 @@ self.addEventListener('activate', event => { ); }) ); -}); \ No newline at end of file + self.clients.claim(); +}); + +// Push notification event +self.addEventListener('push', event => { + if (!event.data) return; + + let payload; + try { + payload = event.data.json(); + } catch { + payload = { title: 'Claude Code UI', body: event.data.text() }; + } + + const options = { + body: payload.body || '', + icon: '/logo.png', + badge: '/logo.png', + data: payload.data || {}, + tag: payload.data?.code || 'default', + renotify: true + }; + + event.waitUntil( + self.registration.showNotification(payload.title || 'Claude Code UI', options) + ); +}); + +// Notification click event +self.addEventListener('notificationclick', event => { + event.notification.close(); + + const sessionId = event.notification.data?.sessionId; + const urlPath = sessionId ? `/session/${sessionId}` : '/'; + + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => { + for (const client of clientList) { + if (client.url.includes(self.location.origin)) { + client.focus(); + if (sessionId) { + client.navigate(self.location.origin + urlPath); + } + return; + } + } + return self.clients.openWindow(urlPath); + }) + ); +}); diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 485aa55..7cbb55f 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -18,6 +18,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; import { CLAUDE_MODELS } from '../shared/modelConstants.js'; +import { createNotificationEvent, notifyUserIfEnabled } from './services/notification-orchestrator.js'; const activeSessions = new Map(); const pendingToolApprovals = new Map(); @@ -461,6 +462,14 @@ async function queryClaudeSDK(command, options = {}, ws) { let tempImagePaths = []; let tempDir = null; + const emitNotification = (event) => { + notifyUserIfEnabled({ + userId: ws?.userId || null, + writer: ws, + event + }); + }; + try { // Map CLI options to SDK format const sdkOptions = mapCliOptionsToSDK(options); @@ -477,6 +486,42 @@ async function queryClaudeSDK(command, options = {}, ws) { tempImagePaths = imageResult.tempImagePaths; tempDir = imageResult.tempDir; + sdkOptions.hooks = { + Notification: [{ + matcher: '', + hooks: [async (input) => { + const message = typeof input?.message === 'string' ? input.message : 'Claude requires your attention.'; + emitNotification(createNotificationEvent({ + provider: 'claude', + sessionId: capturedSessionId || sessionId || null, + kind: 'action_required', + code: 'agent.notification', + meta: { message }, + severity: 'warning', + requiresUserAction: true, + dedupeKey: `claude:hook:notification:${capturedSessionId || sessionId || 'none'}:${message}` + })); + return {}; + }] + }], + Stop: [{ + matcher: '', + hooks: [async (input) => { + const stopReason = typeof input?.stop_reason === 'string' ? input.stop_reason : 'completed'; + emitNotification(createNotificationEvent({ + provider: 'claude', + sessionId: capturedSessionId || sessionId || null, + kind: 'stop', + code: 'run.stopped', + meta: { stopReason }, + severity: 'info', + dedupeKey: `claude:hook:stop:${capturedSessionId || sessionId || 'none'}:${stopReason}` + })); + return {}; + }] + }] + }; + sdkOptions.canUseTool = async (toolName, input, context) => { const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName); @@ -508,6 +553,16 @@ async function queryClaudeSDK(command, options = {}, ws) { input, sessionId: capturedSessionId || sessionId || null }); + emitNotification(createNotificationEvent({ + provider: 'claude', + sessionId: capturedSessionId || sessionId || null, + kind: 'action_required', + code: 'permission.required', + meta: { toolName }, + severity: 'warning', + requiresUserAction: true, + dedupeKey: `claude:permission:${capturedSessionId || sessionId || 'none'}:${requestId}` + })); const decision = await waitForToolApproval(requestId, { timeoutMs: requiresInteraction ? 0 : undefined, @@ -548,10 +603,22 @@ async function queryClaudeSDK(command, options = {}, ws) { const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT; process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000'; - const queryInstance = query({ - prompt: finalCommand, - options: sdkOptions - }); + let queryInstance; + try { + queryInstance = query({ + prompt: finalCommand, + options: sdkOptions + }); + } catch (hookError) { + // Older/newer SDK versions may not accept hook shapes yet. + // Keep notification behavior operational via runtime events even if hook registration fails. + console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError); + delete sdkOptions.hooks; + queryInstance = query({ + prompt: finalCommand, + options: sdkOptions + }); + } // Restore immediately — Query constructor already captured the value if (prevStreamTimeout !== undefined) { @@ -653,6 +720,15 @@ async function queryClaudeSDK(command, options = {}, ws) { error: error.message, sessionId: capturedSessionId || sessionId || null }); + emitNotification(createNotificationEvent({ + provider: 'claude', + sessionId: capturedSessionId || sessionId || null, + kind: 'error', + code: 'run.failed', + meta: { error: error.message }, + severity: 'error', + dedupeKey: `claude:error:${capturedSessionId || sessionId || 'none'}:${error.message}` + })); throw error; } diff --git a/server/database/db.js b/server/database/db.js index e805545..30b63c6 100644 --- a/server/database/db.js +++ b/server/database/db.js @@ -91,6 +91,36 @@ const runMigrations = () => { db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0'); } + db.exec(` + CREATE TABLE IF NOT EXISTS user_notification_preferences ( + user_id INTEGER PRIMARY KEY, + preferences_json TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + db.exec(` + CREATE TABLE IF NOT EXISTS vapid_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + public_key TEXT NOT NULL, + private_key TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + db.exec(` + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + endpoint TEXT NOT NULL UNIQUE, + keys_p256dh TEXT NOT NULL, + keys_auth TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + console.log('Database migrations completed successfully'); } catch (error) { console.error('Error running migrations:', error.message); @@ -348,6 +378,116 @@ const credentialsDb = { } }; +const DEFAULT_NOTIFICATION_PREFERENCES = { + channels: { + inApp: false, + webPush: true + }, + events: { + actionRequired: true, + stop: true, + error: true + } +}; + +const normalizeNotificationPreferences = (value) => { + const source = value && typeof value === 'object' ? value : {}; + + return { + channels: { + inApp: source.channels?.inApp === true, + webPush: source.channels?.webPush !== false + }, + events: { + actionRequired: source.events?.actionRequired !== false, + stop: source.events?.stop !== false, + error: source.events?.error !== false + } + }; +}; + +const notificationPreferencesDb = { + getPreferences: (userId) => { + try { + const row = db.prepare('SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?').get(userId); + if (!row) { + const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES); + db.prepare( + 'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)' + ).run(userId, JSON.stringify(defaults)); + return defaults; + } + + let parsed; + try { + parsed = JSON.parse(row.preferences_json); + } catch { + parsed = DEFAULT_NOTIFICATION_PREFERENCES; + } + return normalizeNotificationPreferences(parsed); + } catch (err) { + throw err; + } + }, + + updatePreferences: (userId, preferences) => { + try { + const normalized = normalizeNotificationPreferences(preferences); + db.prepare( + `INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(user_id) DO UPDATE SET + preferences_json = excluded.preferences_json, + updated_at = CURRENT_TIMESTAMP` + ).run(userId, JSON.stringify(normalized)); + return normalized; + } catch (err) { + throw err; + } + } +}; + +const pushSubscriptionsDb = { + saveSubscription: (userId, endpoint, keysP256dh, keysAuth) => { + try { + db.prepare( + `INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth) + VALUES (?, ?, ?, ?) + ON CONFLICT(endpoint) DO UPDATE SET + user_id = excluded.user_id, + keys_p256dh = excluded.keys_p256dh, + keys_auth = excluded.keys_auth` + ).run(userId, endpoint, keysP256dh, keysAuth); + } catch (err) { + throw err; + } + }, + + getSubscriptions: (userId) => { + try { + return db.prepare('SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?').all(userId); + } catch (err) { + throw err; + } + }, + + removeSubscription: (endpoint) => { + try { + db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint); + } catch (err) { + throw err; + } + }, + + removeAllForUser: (userId) => { + try { + db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId); + } catch (err) { + throw err; + } + } +}; + // Backward compatibility - keep old names pointing to new system const githubTokensDb = { createGithubToken: (userId, tokenName, githubToken, description = null) => { @@ -373,5 +513,7 @@ export { userDb, apiKeysDb, credentialsDb, + notificationPreferencesDb, + pushSubscriptionsDb, githubTokensDb // Backward compatibility -}; \ No newline at end of file +}; diff --git a/server/database/init.sql b/server/database/init.sql index e52daef..8f145f5 100644 --- a/server/database/init.sql +++ b/server/database/init.sql @@ -49,4 +49,31 @@ CREATE TABLE IF NOT EXISTS user_credentials ( CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id); CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type); -CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active); \ No newline at end of file +CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active); + +-- User notification preferences (backend-owned, provider-agnostic) +CREATE TABLE IF NOT EXISTS user_notification_preferences ( + user_id INTEGER PRIMARY KEY, + preferences_json TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- VAPID key pair for Web Push notifications +CREATE TABLE IF NOT EXISTS vapid_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + public_key TEXT NOT NULL, + private_key TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Browser push subscriptions +CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + endpoint TEXT NOT NULL UNIQUE, + keys_p256dh TEXT NOT NULL, + keys_auth TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); diff --git a/server/index.js b/server/index.js index b671dcb..d4902ec 100755 --- a/server/index.js +++ b/server/index.js @@ -62,6 +62,7 @@ import cliAuthRoutes from './routes/cli-auth.js'; import userRoutes from './routes/user.js'; import codexRoutes from './routes/codex.js'; import { initializeDatabase } from './database/db.js'; +import { configureWebPush } from './services/vapid-keys.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; import { IS_PLATFORM } from './constants/config.js'; @@ -888,7 +889,7 @@ wss.on('connection', (ws, request) => { if (pathname === '/shell') { handleShellConnection(ws); } else if (pathname === '/ws') { - handleChatConnection(ws); + handleChatConnection(ws, request); } else { console.log('[WARN] Unknown WebSocket path:', pathname); ws.close(); @@ -899,9 +900,10 @@ wss.on('connection', (ws, request) => { * WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface */ class WebSocketWriter { - constructor(ws) { + constructor(ws, userId = null) { this.ws = ws; this.sessionId = null; + this.userId = userId; this.isWebSocketWriter = true; // Marker for transport detection } @@ -922,14 +924,14 @@ class WebSocketWriter { } // Handle chat WebSocket connections -function handleChatConnection(ws) { +function handleChatConnection(ws, request) { console.log('[INFO] Chat WebSocket connected'); // Add to connected clients for project updates connectedClients.add(ws); // Wrap WebSocket with writer for consistent interface with SSEStreamWriter - const writer = new WebSocketWriter(ws); + const writer = new WebSocketWriter(ws, request?.user?.id ?? request?.user?.userId ?? null); ws.on('message', async (message) => { try { @@ -1918,6 +1920,9 @@ async function startServer() { // Initialize authentication database await initializeDatabase(); + // Configure Web Push (VAPID keys) + configureWebPush(); + // Check if running in production mode (dist folder exists) const distIndexPath = path.join(__dirname, '../dist/index.html'); const isProduction = fs.existsSync(distIndexPath); diff --git a/server/middleware/auth.js b/server/middleware/auth.js index ab12e0c..3426fc2 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -85,7 +85,7 @@ const authenticateWebSocket = (token) => { try { const user = userDb.getFirstUser(); if (user) { - return { userId: user.id, username: user.username }; + return { id: user.id, userId: user.id, username: user.username }; } return null; } catch (error) { @@ -101,7 +101,10 @@ const authenticateWebSocket = (token) => { try { const decoded = jwt.verify(token, JWT_SECRET); - return decoded; + return { + ...decoded, + id: decoded.userId + }; } catch (error) { console.error('WebSocket token verification error:', error); return null; @@ -114,4 +117,4 @@ export { generateToken, authenticateWebSocket, JWT_SECRET -}; \ No newline at end of file +}; diff --git a/server/routes/settings.js b/server/routes/settings.js index d1c141b..8afcb4b 100644 --- a/server/routes/settings.js +++ b/server/routes/settings.js @@ -1,5 +1,6 @@ import express from 'express'; -import { apiKeysDb, credentialsDb } from '../database/db.js'; +import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js'; +import { getPublicKey } from '../services/vapid-keys.js'; const router = express.Router(); @@ -175,4 +176,70 @@ router.patch('/credentials/:credentialId/toggle', async (req, res) => { } }); +// =============================== +// Notification Preferences +// =============================== + +router.get('/notification-preferences', async (req, res) => { + try { + const preferences = notificationPreferencesDb.getPreferences(req.user.id); + res.json({ success: true, preferences }); + } catch (error) { + console.error('Error fetching notification preferences:', error); + res.status(500).json({ error: 'Failed to fetch notification preferences' }); + } +}); + +router.put('/notification-preferences', async (req, res) => { + try { + const preferences = notificationPreferencesDb.updatePreferences(req.user.id, req.body || {}); + res.json({ success: true, preferences }); + } catch (error) { + console.error('Error saving notification preferences:', error); + res.status(500).json({ error: 'Failed to save notification preferences' }); + } +}); + +// =============================== +// Push Subscription Management +// =============================== + +router.get('/push/vapid-public-key', async (req, res) => { + try { + const publicKey = getPublicKey(); + res.json({ publicKey }); + } catch (error) { + console.error('Error fetching VAPID public key:', error); + res.status(500).json({ error: 'Failed to fetch VAPID public key' }); + } +}); + +router.post('/push/subscribe', async (req, res) => { + try { + const { endpoint, keys } = req.body; + if (!endpoint || !keys?.p256dh || !keys?.auth) { + return res.status(400).json({ error: 'Missing subscription fields' }); + } + pushSubscriptionsDb.saveSubscription(req.user.id, endpoint, keys.p256dh, keys.auth); + res.json({ success: true }); + } catch (error) { + console.error('Error saving push subscription:', error); + res.status(500).json({ error: 'Failed to save push subscription' }); + } +}); + +router.post('/push/unsubscribe', async (req, res) => { + try { + const { endpoint } = req.body; + if (!endpoint) { + return res.status(400).json({ error: 'Missing endpoint' }); + } + pushSubscriptionsDb.removeSubscription(endpoint); + res.json({ success: true }); + } catch (error) { + console.error('Error removing push subscription:', error); + res.status(500).json({ error: 'Failed to remove push subscription' }); + } +}); + export default router; diff --git a/server/services/notification-orchestrator.js b/server/services/notification-orchestrator.js new file mode 100644 index 0000000..7e3f9cf --- /dev/null +++ b/server/services/notification-orchestrator.js @@ -0,0 +1,149 @@ +import webPush from 'web-push'; +import { notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js'; + +const KIND_TO_PREF_KEY = { + action_required: 'actionRequired', + stop: 'stop', + error: 'error' +}; + +const recentEventKeys = new Map(); +const DEDUPE_WINDOW_MS = 20000; + +const cleanupOldEventKeys = () => { + const now = Date.now(); + for (const [key, timestamp] of recentEventKeys.entries()) { + if (now - timestamp > DEDUPE_WINDOW_MS) { + recentEventKeys.delete(key); + } + } +}; + +function shouldSendPush(preferences, event) { + const webPushEnabled = Boolean(preferences?.channels?.webPush); + const prefEventKey = KIND_TO_PREF_KEY[event.kind]; + const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true; + + return webPushEnabled && eventEnabled; +} + +function isDuplicate(event) { + cleanupOldEventKeys(); + const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`; + if (recentEventKeys.has(key)) { + return true; + } + recentEventKeys.set(key, Date.now()); + return false; +} + +function createNotificationEvent({ + provider, + sessionId = null, + kind = 'info', + code = 'generic.info', + meta = {}, + severity = 'info', + dedupeKey = null, + requiresUserAction = false +}) { + return { + provider, + sessionId, + kind, + code, + meta, + severity, + requiresUserAction, + dedupeKey, + createdAt: new Date().toISOString() + }; +} + +function buildPushBody(event) { + const CODE_MAP = { + 'permission.required': { + title: 'Action Required', + body: event.meta?.toolName + ? `Tool "${event.meta.toolName}" needs approval` + : 'A tool needs your approval' + }, + 'run.stopped': { + title: 'Run Stopped', + body: event.meta?.stopReason || 'The run has stopped' + }, + 'run.failed': { + title: 'Run Failed', + body: event.meta?.error ? String(event.meta.error) : 'The run encountered an error' + }, + 'agent.notification': { + title: 'Agent Notification', + body: event.meta?.message ? String(event.meta.message) : 'You have a new notification' + } + }; + + const mapped = CODE_MAP[event.code]; + return { + title: mapped?.title || 'Claude Code UI', + body: mapped?.body || 'You have a new notification', + data: { + sessionId: event.sessionId || null, + code: event.code + } + }; +} + +async function sendWebPush(userId, event) { + const subscriptions = pushSubscriptionsDb.getSubscriptions(userId); + if (!subscriptions.length) return; + + const payload = JSON.stringify(buildPushBody(event)); + + const results = await Promise.allSettled( + subscriptions.map((sub) => + webPush.sendNotification( + { + endpoint: sub.endpoint, + keys: { + p256dh: sub.keys_p256dh, + auth: sub.keys_auth + } + }, + payload + ) + ) + ); + + // Clean up gone subscriptions (410 Gone or 404) + results.forEach((result, index) => { + if (result.status === 'rejected') { + const statusCode = result.reason?.statusCode; + if (statusCode === 410 || statusCode === 404) { + pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint); + } + } + }); +} + +function notifyUserIfEnabled({ userId, event }) { + if (!userId || !event) { + return; + } + + const preferences = notificationPreferencesDb.getPreferences(userId); + if (!shouldSendPush(preferences, event)) { + return; + } + if (isDuplicate(event)) { + return; + } + + sendWebPush(userId, event).catch((err) => { + console.error('Web push send error:', err); + }); +} + +export { + createNotificationEvent, + notifyUserIfEnabled +}; diff --git a/server/services/vapid-keys.js b/server/services/vapid-keys.js new file mode 100644 index 0000000..1abaeba --- /dev/null +++ b/server/services/vapid-keys.js @@ -0,0 +1,35 @@ +import webPush from 'web-push'; +import { db } from '../database/db.js'; + +let cachedKeys = null; + +function ensureVapidKeys() { + if (cachedKeys) return cachedKeys; + + const row = db.prepare('SELECT public_key, private_key FROM vapid_keys ORDER BY id DESC LIMIT 1').get(); + if (row) { + cachedKeys = { publicKey: row.public_key, privateKey: row.private_key }; + return cachedKeys; + } + + const keys = webPush.generateVAPIDKeys(); + db.prepare('INSERT INTO vapid_keys (public_key, private_key) VALUES (?, ?)').run(keys.publicKey, keys.privateKey); + cachedKeys = keys; + return cachedKeys; +} + +function getPublicKey() { + return ensureVapidKeys().publicKey; +} + +function configureWebPush() { + const keys = ensureVapidKeys(); + webPush.setVapidDetails( + 'mailto:noreply@claudecodeui.local', + keys.publicKey, + keys.privateKey + ); + console.log('Web Push notifications configured'); +} + +export { ensureVapidKeys, getPublicKey, configureWebPush }; diff --git a/src/components/settings/constants/constants.ts b/src/components/settings/constants/constants.ts index dd11171..628d8d8 100644 --- a/src/components/settings/constants/constants.ts +++ b/src/components/settings/constants/constants.ts @@ -18,6 +18,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTab[] = [ 'git', 'api', 'tasks', + 'notifications', ]; export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex']; diff --git a/src/components/settings/hooks/useSettingsController.ts b/src/components/settings/hooks/useSettingsController.ts index 9e23319..ddb84ea 100644 --- a/src/components/settings/hooks/useSettingsController.ts +++ b/src/components/settings/hooks/useSettingsController.ts @@ -19,6 +19,7 @@ import type { McpServer, McpToolsResult, McpTestResult, + NotificationPreferencesState, ProjectSortOrder, SettingsMainTab, SettingsProject, @@ -94,9 +95,14 @@ type CodexSettingsStorage = { permissionMode?: CodexPermissionMode; }; +type NotificationPreferencesResponse = { + success?: boolean; + preferences?: NotificationPreferencesState; +}; + type ActiveLoginProvider = AgentProvider | ''; -const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks']; +const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'notifications']; const normalizeMainTab = (tab: string): SettingsMainTab => { // Keep backwards compatibility with older callers that still pass "tools". @@ -184,6 +190,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(null); @@ -203,6 +221,9 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: const [cursorPermissions, setCursorPermissions] = useState(() => ( createEmptyCursorPermissions() )); + const [notificationPreferences, setNotificationPreferences] = useState(() => ( + createDefaultNotificationPreferences() + )); const [codexPermissionMode, setCodexPermissionMode] = useState('default'); const [mcpServers, setMcpServers] = useState([]); @@ -655,6 +676,22 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: ); setCodexPermissionMode(toCodexPermissionMode(savedCodexSettings.permissionMode)); + try { + const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences'); + if (notificationResponse.ok) { + const notificationData = await toResponseJson(notificationResponse); + if (notificationData.success && notificationData.preferences) { + setNotificationPreferences(notificationData.preferences); + } else { + setNotificationPreferences(createDefaultNotificationPreferences()); + } + } else { + setNotificationPreferences(createDefaultNotificationPreferences()); + } + } catch { + setNotificationPreferences(createDefaultNotificationPreferences()); + } + await Promise.all([ fetchMcpServers(), fetchCursorMcpServers(), @@ -664,6 +701,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: console.error('Error loading settings:', error); setClaudePermissions(createEmptyClaudePermissions()); setCursorPermissions(createEmptyCursorPermissions()); + setNotificationPreferences(createDefaultNotificationPreferences()); setCodexPermissionMode('default'); setProjectSortOrder('name'); } @@ -684,7 +722,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: void checkAuthStatus(loginProvider); }, [checkAuthStatus, loginProvider]); - const saveSettings = useCallback(() => { + const saveSettings = useCallback(async () => { setIsSaving(true); setSaveStatus(null); @@ -710,6 +748,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'); if (closeTimerRef.current !== null) { window.clearTimeout(closeTimerRef.current); @@ -730,6 +776,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: cursorPermissions.allowedCommands, cursorPermissions.disallowedCommands, cursorPermissions.skipPermissions, + notificationPreferences, onClose, projectSortOrder, ]); @@ -805,6 +852,8 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: setClaudePermissions, cursorPermissions, setCursorPermissions, + notificationPreferences, + setNotificationPreferences, codexPermissionMode, setCodexPermissionMode, mcpServers, diff --git a/src/components/settings/types/types.ts b/src/components/settings/types/types.ts index a0071c8..950e914 100644 --- a/src/components/settings/types/types.ts +++ b/src/components/settings/types/types.ts @@ -1,6 +1,6 @@ import type { Dispatch, SetStateAction } from 'react'; -export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks'; +export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications'; export type AgentProvider = 'claude' | 'cursor' | 'codex'; export type AgentCategory = 'account' | 'permissions' | 'mcp'; export type ProjectSortOrder = 'name' | 'date'; @@ -104,6 +104,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[]; diff --git a/src/components/settings/view/Settings.tsx b/src/components/settings/view/Settings.tsx index 397bb77..b8164ea 100644 --- a/src/components/settings/view/Settings.tsx +++ b/src/components/settings/view/Settings.tsx @@ -9,8 +9,10 @@ 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 { useSettingsController } from '../hooks/useSettingsController'; +import { useWebPush } from '../../../hooks/useWebPush'; import type { AgentProvider, SettingsProject, SettingsProps } from '../types/types'; type LoginModalProps = { @@ -38,6 +40,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set updateCodeEditorSetting, claudePermissions, setClaudePermissions, + notificationPreferences, + setNotificationPreferences, cursorPermissions, setCursorPermissions, codexPermissionMode, @@ -79,6 +83,30 @@ 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(); + setNotificationPreferences({ + ...notificationPreferences, + channels: { ...notificationPreferences.channels, webPush: true }, + }); + }; + + const handleDisablePush = async () => { + await pushUnsubscribe(); + setNotificationPreferences({ + ...notificationPreferences, + channels: { ...notificationPreferences.channels, webPush: false }, + }); + }; + if (!isOpen) { return null; } @@ -164,6 +192,18 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set )} + {activeTab === 'notifications' && ( + + )} + {activeTab === 'api' && (
diff --git a/src/components/settings/view/SettingsMainTabs.tsx b/src/components/settings/view/SettingsMainTabs.tsx index f1886f1..c4ddc78 100644 --- a/src/components/settings/view/SettingsMainTabs.tsx +++ b/src/components/settings/view/SettingsMainTabs.tsx @@ -19,6 +19,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' }, ]; export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) { @@ -26,7 +27,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa return (
-
+
{TAB_CONFIG.map((tab) => { const Icon = tab.icon; const isActive = activeTab === tab.id; @@ -37,7 +38,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa role="tab" aria-selected={isActive} onClick={() => onChange(tab.id)} - className={`px-4 py-3 text-sm font-medium border-b-2 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' diff --git a/src/components/settings/view/tabs/NotificationsSettingsTab.tsx b/src/components/settings/view/tabs/NotificationsSettingsTab.tsx new file mode 100644 index 0000000..4187d52 --- /dev/null +++ b/src/components/settings/view/tabs/NotificationsSettingsTab.tsx @@ -0,0 +1,129 @@ +import { Bell } 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 ( +
+
+
+ +

{t('notifications.title')}

+
+

{t('notifications.description')}

+
+ +
+

{t('notifications.webPush.title')}

+ {!pushSupported ? ( +

{t('notifications.webPush.unsupported')}

+ ) : pushDenied ? ( +

{t('notifications.webPush.denied')}

+ ) : ( + + )} +
+ +
+

{t('notifications.events.title')}

+
+ + + + + +
+
+
+ ); +} diff --git a/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx b/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx index 10f77be..6aab399 100644 --- a/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx +++ b/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx @@ -256,6 +256,7 @@ function ClaudePermissions({
  • "Bash(rm:*)" {t('permissions.toolExamples.bashRm')}
  • +
    ); } diff --git a/src/hooks/useWebPush.ts b/src/hooks/useWebPush.ts new file mode 100644 index 0000000..b5e365a --- /dev/null +++ b/src/hooks/useWebPush.ts @@ -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; + unsubscribe: () => Promise; +}; + +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(() => { + 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 }; +} diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index ac64eda..932f40c 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -191,6 +191,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", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 12f9737..d4ecbf2 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -88,7 +88,26 @@ "appearance": "Appearance", "git": "Git", "apiTokens": "API & Tokens", - "tasks": "Tasks" + "tasks": "Tasks", + "notifications": "Notifications" + }, + "notifications": { + "title": "Notifications", + "description": "Control which notification events you receive.", + "webPush": { + "title": "Web Push Notifications", + "enabled": "Push notifications are enabled", + "disabled": "Enable push notifications", + "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": { diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 10d991d..fb3521a 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -191,6 +191,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": "新しいバージョンが利用可能です", diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index e3304b3..b16e5d9 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -88,7 +88,26 @@ "appearance": "外観", "git": "Git", "apiTokens": "API & トークン", - "tasks": "タスク" + "tasks": "タスク", + "notifications": "通知" + }, + "notifications": { + "title": "通知", + "description": "受信する通知イベントを設定します。", + "webPush": { + "title": "Webプッシュ通知", + "enabled": "プッシュ通知は有効です", + "disabled": "プッシュ通知を有効にする", + "loading": "更新中...", + "unsupported": "このブラウザではプッシュ通知がサポートされていません。", + "denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。" + }, + "events": { + "title": "イベント種別", + "actionRequired": "対応が必要", + "stop": "実行停止", + "error": "実行失敗" + } }, "appearanceSettings": { "darkMode": { diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index d3e95ac..c5233c9 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -191,6 +191,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": "새 버전이 준비되었습니다", diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index 60b1018..53b9203 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -88,7 +88,26 @@ "appearance": "외관", "git": "Git", "apiTokens": "API & 토큰", - "tasks": "작업" + "tasks": "작업", + "notifications": "알림" + }, + "notifications": { + "title": "알림", + "description": "수신할 알림 이벤트를 설정합니다.", + "webPush": { + "title": "웹 푸시 알림", + "enabled": "푸시 알림이 활성화되었습니다", + "disabled": "푸시 알림 활성화", + "loading": "업데이트 중...", + "unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.", + "denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요." + }, + "events": { + "title": "이벤트 유형", + "actionRequired": "작업 필요", + "stop": "실행 중지", + "error": "실행 실패" + } }, "appearanceSettings": { "darkMode": { diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index f02127e..e4fc639 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -191,6 +191,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": "新版本已准备就绪", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 24dd6c1..a367bba 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -88,7 +88,26 @@ "appearance": "外观", "git": "Git", "apiTokens": "API 和令牌", - "tasks": "任务" + "tasks": "任务", + "notifications": "通知" + }, + "notifications": { + "title": "通知", + "description": "控制你希望接收的通知事件。", + "webPush": { + "title": "Web 推送通知", + "enabled": "推送通知已启用", + "disabled": "启用推送通知", + "loading": "更新中...", + "unsupported": "此浏览器不支持推送通知。", + "denied": "推送通知已被阻止,请在浏览器设置中允许。" + }, + "events": { + "title": "事件类型", + "actionRequired": "需要处理", + "stop": "运行已停止", + "error": "运行失败" + } }, "appearanceSettings": { "darkMode": { diff --git a/src/main.jsx b/src/main.jsx index cacb2db..0c88aea 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -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); }); }