feat: introduce notification system and claude notifications

This commit is contained in:
simosmik
2026-02-27 14:44:44 +00:00
parent 917c353115
commit 061f0fd297
28 changed files with 1187 additions and 35 deletions

76
package-lock.json generated
View File

@@ -64,6 +64,7 @@
"sqlite": "^5.1.1", "sqlite": "^5.1.1",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"web-push": "^3.6.7",
"ws": "^8.14.2" "ws": "^8.14.2"
}, },
"bin": { "bin": {
@@ -3374,7 +3375,6 @@
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 14" "node": ">= 14"
@@ -3509,6 +3509,18 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/ast-types": {
"version": "0.13.4", "version": "0.13.4",
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
@@ -3745,6 +3757,12 @@
"readable-stream": "^3.4.0" "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": { "node_modules/body-parser": {
"version": "1.20.3", "version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@@ -6323,6 +6341,15 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/http-cache-semantics": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -6364,7 +6391,6 @@
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"agent-base": "^7.1.2", "agent-base": "^7.1.2",
@@ -8276,6 +8302,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/minimatch": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@@ -12753,6 +12785,46 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/webidl-conversions": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",

View File

@@ -99,6 +99,7 @@
"sqlite": "^5.1.1", "sqlite": "^5.1.1",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"web-push": "^3.6.7",
"ws": "^8.14.2" "ws": "^8.14.2"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -22,11 +22,9 @@ self.addEventListener('fetch', event => {
event.respondWith( event.respondWith(
caches.match(event.request) caches.match(event.request)
.then(response => { .then(response => {
// Return cached response if found
if (response) { if (response) {
return response; return response;
} }
// Otherwise fetch from network
return fetch(event.request); return fetch(event.request);
} }
) )
@@ -46,4 +44,53 @@ self.addEventListener('activate', event => {
); );
}) })
); );
}); 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);
})
);
});

View File

@@ -18,6 +18,7 @@ import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
import { CLAUDE_MODELS } from '../shared/modelConstants.js'; import { CLAUDE_MODELS } from '../shared/modelConstants.js';
import { createNotificationEvent, notifyUserIfEnabled } from './services/notification-orchestrator.js';
const activeSessions = new Map(); const activeSessions = new Map();
const pendingToolApprovals = new Map(); const pendingToolApprovals = new Map();
@@ -461,6 +462,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
let tempImagePaths = []; let tempImagePaths = [];
let tempDir = null; let tempDir = null;
const emitNotification = (event) => {
notifyUserIfEnabled({
userId: ws?.userId || null,
writer: ws,
event
});
};
try { try {
// Map CLI options to SDK format // Map CLI options to SDK format
const sdkOptions = mapCliOptionsToSDK(options); const sdkOptions = mapCliOptionsToSDK(options);
@@ -477,6 +486,42 @@ async function queryClaudeSDK(command, options = {}, ws) {
tempImagePaths = imageResult.tempImagePaths; tempImagePaths = imageResult.tempImagePaths;
tempDir = imageResult.tempDir; 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) => { sdkOptions.canUseTool = async (toolName, input, context) => {
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName); const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
@@ -508,6 +553,16 @@ async function queryClaudeSDK(command, options = {}, ws) {
input, input,
sessionId: capturedSessionId || sessionId || null 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, { const decision = await waitForToolApproval(requestId, {
timeoutMs: requiresInteraction ? 0 : undefined, timeoutMs: requiresInteraction ? 0 : undefined,
@@ -548,10 +603,22 @@ async function queryClaudeSDK(command, options = {}, ws) {
const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT; const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000'; process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
const queryInstance = query({ let queryInstance;
prompt: finalCommand, try {
options: sdkOptions 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 // Restore immediately — Query constructor already captured the value
if (prevStreamTimeout !== undefined) { if (prevStreamTimeout !== undefined) {
@@ -653,6 +720,15 @@ async function queryClaudeSDK(command, options = {}, ws) {
error: error.message, error: error.message,
sessionId: capturedSessionId || sessionId || null 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; throw error;
} }

View File

@@ -91,6 +91,36 @@ const runMigrations = () => {
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0'); 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'); console.log('Database migrations completed successfully');
} catch (error) { } catch (error) {
console.error('Error running migrations:', error.message); 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 // Backward compatibility - keep old names pointing to new system
const githubTokensDb = { const githubTokensDb = {
createGithubToken: (userId, tokenName, githubToken, description = null) => { createGithubToken: (userId, tokenName, githubToken, description = null) => {
@@ -373,5 +513,7 @@ export {
userDb, userDb,
apiKeysDb, apiKeysDb,
credentialsDb, credentialsDb,
notificationPreferencesDb,
pushSubscriptionsDb,
githubTokensDb // Backward compatibility githubTokensDb // Backward compatibility
}; };

View File

@@ -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_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_type ON user_credentials(credential_type);
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active); 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
);

View File

@@ -62,6 +62,7 @@ import cliAuthRoutes from './routes/cli-auth.js';
import userRoutes from './routes/user.js'; import userRoutes from './routes/user.js';
import codexRoutes from './routes/codex.js'; import codexRoutes from './routes/codex.js';
import { initializeDatabase } from './database/db.js'; import { initializeDatabase } from './database/db.js';
import { configureWebPush } from './services/vapid-keys.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
import { IS_PLATFORM } from './constants/config.js'; import { IS_PLATFORM } from './constants/config.js';
@@ -888,7 +889,7 @@ wss.on('connection', (ws, request) => {
if (pathname === '/shell') { if (pathname === '/shell') {
handleShellConnection(ws); handleShellConnection(ws);
} else if (pathname === '/ws') { } else if (pathname === '/ws') {
handleChatConnection(ws); handleChatConnection(ws, request);
} else { } else {
console.log('[WARN] Unknown WebSocket path:', pathname); console.log('[WARN] Unknown WebSocket path:', pathname);
ws.close(); ws.close();
@@ -899,9 +900,10 @@ wss.on('connection', (ws, request) => {
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface * WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
*/ */
class WebSocketWriter { class WebSocketWriter {
constructor(ws) { constructor(ws, userId = null) {
this.ws = ws; this.ws = ws;
this.sessionId = null; this.sessionId = null;
this.userId = userId;
this.isWebSocketWriter = true; // Marker for transport detection this.isWebSocketWriter = true; // Marker for transport detection
} }
@@ -922,14 +924,14 @@ class WebSocketWriter {
} }
// Handle chat WebSocket connections // Handle chat WebSocket connections
function handleChatConnection(ws) { function handleChatConnection(ws, request) {
console.log('[INFO] Chat WebSocket connected'); console.log('[INFO] Chat WebSocket connected');
// Add to connected clients for project updates // Add to connected clients for project updates
connectedClients.add(ws); connectedClients.add(ws);
// Wrap WebSocket with writer for consistent interface with SSEStreamWriter // 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) => { ws.on('message', async (message) => {
try { try {
@@ -1918,6 +1920,9 @@ async function startServer() {
// Initialize authentication database // Initialize authentication database
await initializeDatabase(); await initializeDatabase();
// Configure Web Push (VAPID keys)
configureWebPush();
// Check if running in production mode (dist folder exists) // Check if running in production mode (dist folder exists)
const distIndexPath = path.join(__dirname, '../dist/index.html'); const distIndexPath = path.join(__dirname, '../dist/index.html');
const isProduction = fs.existsSync(distIndexPath); const isProduction = fs.existsSync(distIndexPath);

View File

@@ -85,7 +85,7 @@ const authenticateWebSocket = (token) => {
try { try {
const user = userDb.getFirstUser(); const user = userDb.getFirstUser();
if (user) { if (user) {
return { userId: user.id, username: user.username }; return { id: user.id, userId: user.id, username: user.username };
} }
return null; return null;
} catch (error) { } catch (error) {
@@ -101,7 +101,10 @@ const authenticateWebSocket = (token) => {
try { try {
const decoded = jwt.verify(token, JWT_SECRET); const decoded = jwt.verify(token, JWT_SECRET);
return decoded; return {
...decoded,
id: decoded.userId
};
} catch (error) { } catch (error) {
console.error('WebSocket token verification error:', error); console.error('WebSocket token verification error:', error);
return null; return null;
@@ -114,4 +117,4 @@ export {
generateToken, generateToken,
authenticateWebSocket, authenticateWebSocket,
JWT_SECRET JWT_SECRET
}; };

View File

@@ -1,5 +1,6 @@
import express from 'express'; 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(); 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; export default router;

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ import type {
McpServer, McpServer,
McpToolsResult, McpToolsResult,
McpTestResult, McpTestResult,
NotificationPreferencesState,
ProjectSortOrder, ProjectSortOrder,
SettingsMainTab, SettingsMainTab,
SettingsProject, SettingsProject,
@@ -94,9 +95,14 @@ type CodexSettingsStorage = {
permissionMode?: CodexPermissionMode; permissionMode?: CodexPermissionMode;
}; };
type NotificationPreferencesResponse = {
success?: boolean;
preferences?: NotificationPreferencesState;
};
type ActiveLoginProvider = AgentProvider | ''; 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 => { const normalizeMainTab = (tab: string): SettingsMainTab => {
// Keep backwards compatibility with older callers that still pass "tools". // Keep backwards compatibility with older callers that still pass "tools".
@@ -184,6 +190,18 @@ const createEmptyCursorPermissions = (): CursorPermissionsState => ({
...DEFAULT_CURSOR_PERMISSIONS, ...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) { export function useSettingsController({ isOpen, initialTab, projects, onClose }: UseSettingsControllerArgs) {
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue; const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
const closeTimerRef = useRef<number | null>(null); const closeTimerRef = useRef<number | null>(null);
@@ -203,6 +221,9 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
const [cursorPermissions, setCursorPermissions] = useState<CursorPermissionsState>(() => ( const [cursorPermissions, setCursorPermissions] = useState<CursorPermissionsState>(() => (
createEmptyCursorPermissions() createEmptyCursorPermissions()
)); ));
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferencesState>(() => (
createDefaultNotificationPreferences()
));
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default'); const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
const [mcpServers, setMcpServers] = useState<McpServer[]>([]); const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
@@ -655,6 +676,22 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
); );
setCodexPermissionMode(toCodexPermissionMode(savedCodexSettings.permissionMode)); setCodexPermissionMode(toCodexPermissionMode(savedCodexSettings.permissionMode));
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([ await Promise.all([
fetchMcpServers(), fetchMcpServers(),
fetchCursorMcpServers(), fetchCursorMcpServers(),
@@ -664,6 +701,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
console.error('Error loading settings:', error); console.error('Error loading settings:', error);
setClaudePermissions(createEmptyClaudePermissions()); setClaudePermissions(createEmptyClaudePermissions());
setCursorPermissions(createEmptyCursorPermissions()); setCursorPermissions(createEmptyCursorPermissions());
setNotificationPreferences(createDefaultNotificationPreferences());
setCodexPermissionMode('default'); setCodexPermissionMode('default');
setProjectSortOrder('name'); setProjectSortOrder('name');
} }
@@ -684,7 +722,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
void checkAuthStatus(loginProvider); void checkAuthStatus(loginProvider);
}, [checkAuthStatus, loginProvider]); }, [checkAuthStatus, loginProvider]);
const saveSettings = useCallback(() => { const saveSettings = useCallback(async () => {
setIsSaving(true); setIsSaving(true);
setSaveStatus(null); setSaveStatus(null);
@@ -710,6 +748,14 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
lastUpdated: now, 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'); setSaveStatus('success');
if (closeTimerRef.current !== null) { if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current); window.clearTimeout(closeTimerRef.current);
@@ -730,6 +776,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
cursorPermissions.allowedCommands, cursorPermissions.allowedCommands,
cursorPermissions.disallowedCommands, cursorPermissions.disallowedCommands,
cursorPermissions.skipPermissions, cursorPermissions.skipPermissions,
notificationPreferences,
onClose, onClose,
projectSortOrder, projectSortOrder,
]); ]);
@@ -805,6 +852,8 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
setClaudePermissions, setClaudePermissions,
cursorPermissions, cursorPermissions,
setCursorPermissions, setCursorPermissions,
notificationPreferences,
setNotificationPreferences,
codexPermissionMode, codexPermissionMode,
setCodexPermissionMode, setCodexPermissionMode,
mcpServers, mcpServers,

View File

@@ -1,6 +1,6 @@
import type { Dispatch, SetStateAction } from 'react'; 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 AgentProvider = 'claude' | 'cursor' | 'codex';
export type AgentCategory = 'account' | 'permissions' | 'mcp'; export type AgentCategory = 'account' | 'permissions' | 'mcp';
export type ProjectSortOrder = 'name' | 'date'; export type ProjectSortOrder = 'name' | 'date';
@@ -104,6 +104,18 @@ export type ClaudePermissionsState = {
skipPermissions: boolean; skipPermissions: boolean;
}; };
export type NotificationPreferencesState = {
channels: {
inApp: boolean;
webPush: boolean;
};
events: {
actionRequired: boolean;
stop: boolean;
error: boolean;
};
};
export type CursorPermissionsState = { export type CursorPermissionsState = {
allowedCommands: string[]; allowedCommands: string[];
disallowedCommands: string[]; disallowedCommands: string[];

View File

@@ -9,8 +9,10 @@ import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab'; import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab'; import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab'; import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab'; import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
import { useSettingsController } from '../hooks/useSettingsController'; import { useSettingsController } from '../hooks/useSettingsController';
import { useWebPush } from '../../../hooks/useWebPush';
import type { AgentProvider, SettingsProject, SettingsProps } from '../types/types'; import type { AgentProvider, SettingsProject, SettingsProps } from '../types/types';
type LoginModalProps = { type LoginModalProps = {
@@ -38,6 +40,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
updateCodeEditorSetting, updateCodeEditorSetting,
claudePermissions, claudePermissions,
setClaudePermissions, setClaudePermissions,
notificationPreferences,
setNotificationPreferences,
cursorPermissions, cursorPermissions,
setCursorPermissions, setCursorPermissions,
codexPermissionMode, codexPermissionMode,
@@ -79,6 +83,30 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
onClose, 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) { if (!isOpen) {
return null; return null;
} }
@@ -164,6 +192,18 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
</div> </div>
)} )}
{activeTab === 'notifications' && (
<NotificationsSettingsTab
notificationPreferences={notificationPreferences}
onNotificationPreferencesChange={setNotificationPreferences}
pushPermission={pushPermission}
isPushSubscribed={isPushSubscribed}
isPushLoading={isPushLoading}
onEnablePush={handleEnablePush}
onDisablePush={handleDisablePush}
/>
)}
{activeTab === 'api' && ( {activeTab === 'api' && (
<div className="space-y-6 md:space-y-8"> <div className="space-y-6 md:space-y-8">
<CredentialsSettingsTab /> <CredentialsSettingsTab />

View File

@@ -19,6 +19,7 @@ const TAB_CONFIG: MainTabConfig[] = [
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch }, { id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key }, { id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
{ id: 'tasks', labelKey: 'mainTabs.tasks' }, { id: 'tasks', labelKey: 'mainTabs.tasks' },
{ id: 'notifications', labelKey: 'mainTabs.notifications' },
]; ];
export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) { export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {
@@ -26,7 +27,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa
return ( return (
<div className="border-b border-border"> <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) => { {TAB_CONFIG.map((tab) => {
const Icon = tab.icon; const Icon = tab.icon;
const isActive = activeTab === tab.id; const isActive = activeTab === tab.id;
@@ -37,7 +38,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa
role="tab" role="tab"
aria-selected={isActive} aria-selected={isActive}
onClick={() => onChange(tab.id)} 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 isActive
? 'border-blue-600 text-blue-600 dark:text-blue-400' ? 'border-blue-600 text-blue-600 dark:text-blue-400'
: 'border-transparent text-muted-foreground hover:text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'

View File

@@ -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 (
<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>
) : (
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={isPushSubscribed}
disabled={isPushLoading}
onChange={() => {
if (isPushSubscribed) {
onDisablePush();
} else {
onEnablePush();
}
}}
className="w-4 h-4"
/>
{isPushLoading
? t('notifications.webPush.loading')
: isPushSubscribed
? t('notifications.webPush.enabled')
: t('notifications.webPush.disabled')}
</label>
)}
</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

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

@@ -191,6 +191,36 @@
"failedToCreateFolder": "Failed to create folder" "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": { "versionUpdate": {
"title": "Update Available", "title": "Update Available",
"newVersionReady": "A new version is ready", "newVersionReady": "A new version is ready",

View File

@@ -88,7 +88,26 @@
"appearance": "Appearance", "appearance": "Appearance",
"git": "Git", "git": "Git",
"apiTokens": "API & Tokens", "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": { "appearanceSettings": {
"darkMode": { "darkMode": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,14 +7,10 @@ import 'katex/dist/katex.min.css'
// Initialize i18n // Initialize i18n
import './i18n/config.js' 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) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(registrations => { navigator.serviceWorker.register('/sw.js').catch(err => {
registrations.forEach(registration => { console.warn('Service worker registration failed:', err);
registration.unregister();
});
}).catch(err => {
console.warn('Failed to unregister service workers:', err);
}); });
} }