Compare commits

..

7 Commits

Author SHA1 Message Date
viper151
bc52d9ea28 Merge branch 'main' into feat/notifications 2026-03-02 16:04:55 +01:00
Menny Even Danan
503c384685 chore: add Gemini-CLI support to README (#453) 2026-03-02 10:56:36 +03:00
louis-thorp-datacom
506d43144b fix(claude): move model usage log to result message only (#454)
The modelUsage debug log ran on every streamed SDK message, but
modelUsage is only populated on result messages. This produced
repeated "Model was sent using: []" console output for every
non-result message during streaming.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:49:06 +03:00
Xì Gà
9e22f42a3d feat: update document title based on selected project (#448)
Show dynamic browser tab title based on selected project's name, post-fixed with "CloudCLI UI" when a project is selected, improving navigation across tabs.
2026-02-27 18:51:26 +03:00
Xì Gà
9c0e864532 fix(claude): correct project encoded path (#451)
fix wrong regex replace of Claude project path

related #447, reopen due to forced-push

to reproduce error steps, let's try

Create a folder with @ in name like @test
Add this folder as new project in CloudCLI
Choose Claude tool in new Session
Star by typing sth 'hi'
In the dev tools, you will see errors ajax response said that session does not find for 'some-session-id'

The main problem is current encode path doesn't encode '@' to '-' as Claude did
I reversed code Claude-SDK, file 'cli.js' to find exactly regex (using in PR) that used to encode path under ~/.claude/projects/<encoded-project-name-path/<session-id>.jsonl
2026-02-27 18:46:23 +03:00
simosmik
8339b8e624 Merge branch 'main' into feat/notifications
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:47:27 +00:00
simosmik
061f0fd297 feat: introduce notification system and claude notifications 2026-02-27 14:44:44 +00:00
30 changed files with 1213 additions and 48 deletions

View File

@@ -4,7 +4,7 @@
</div>
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview) and [Codex](https://developers.openai.com/codex). You can use it locally or remotely to view your active projects and sessions in Claude Code, Cursor, or Codex and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere.
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview), [Codex](https://developers.openai.com/codex), and [Gemini-CLI](https://geminicli.com/). You can use it locally or remotely to view your active projects and sessions and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere.
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<div align="right"><i><b>English</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
@@ -44,14 +44,14 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
## Features
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Claude Code, Cursor, or Codex from mobile
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with Claude Code, Cursor, or Codex
- **Integrated Shell Terminal** - Direct access to Claude Code, Cursor CLI, or Codex through built-in shell functionality
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Agents from mobile
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with the Agents
- **Integrated Shell Terminal** - Direct access to the Agents CLI through built-in shell functionality
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
- **Session Management** - Resume conversations, manage multiple sessions, and track history
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, and GPT-5.2
- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, GPT-5.2, and Gemini.
## Quick Start
@@ -61,7 +61,8 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
- [Node.js](https://nodejs.org/) v22 or higher
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured, and/or
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) installed and configured, and/or
- [Codex](https://developers.openai.com/codex) installed and configured
- [Codex](https://developers.openai.com/codex) installed and configured, and/or
- [Gemini-CLI](https://geminicli.com/) installed and configured
### One-click Operation (Recommended)
@@ -279,7 +280,7 @@ session counts
### Backend (Node.js + Express)
- **Express Server** - RESTful API with static file serving
- **WebSocket Server** - Communication for chats and project refresh
- **Agent Integration (Claude Code / Cursor CLI / Codex)** - Process spawning and management
- **Agent Integration (Claude Code / Cursor CLI / Codex / Gemini CLI)** - Process spawning and management
- **File System API** - Exposing file browser for projects
### Frontend (React + Vite)
@@ -327,6 +328,7 @@ This project is open source and free to use, modify, and distribute under the GP
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic's official CLI
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor's official CLI
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI
- **[React](https://react.dev/)** - User interface library
- **[Vite](https://vitejs.dev/)** - Fast build tool and dev server
- **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework

76
package-lock.json generated
View File

@@ -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": {
@@ -3384,7 +3385,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"
@@ -3519,6 +3519,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",
@@ -3755,6 +3767,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",
@@ -6334,6 +6352,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",
@@ -6375,7 +6402,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",
@@ -8288,6 +8314,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",
@@ -12758,6 +12790,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",

View File

@@ -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": {

View File

@@ -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 => {
);
})
);
});
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 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) {
@@ -593,9 +660,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
console.log('No session_id in message or already captured. message.session_id:', message.session_id, 'capturedSessionId:', capturedSessionId);
}
// logs which model was used in the message
console.log("---> Model was sent using:", Object.keys(message.modelUsage || {}));
// Transform and send message to WebSocket
const transformedMessage = transformMessage(message);
ws.send({
@@ -606,6 +670,10 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Extract and send token budget updates from result messages
if (message.type === 'result') {
const models = Object.keys(message.modelUsage || {});
if (models.length > 0) {
console.log("---> Model was sent using:", models);
}
const tokenBudget = extractTokenBudget(message);
if (tokenBudget) {
console.log('Token budget from modelUsage:', tokenBudget);
@@ -653,6 +721,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;
}

View File

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

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_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

@@ -65,6 +65,7 @@ import userRoutes from './routes/user.js';
import codexRoutes from './routes/codex.js';
import geminiRoutes from './routes/gemini.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';
@@ -896,7 +897,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();
@@ -907,9 +908,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
}
@@ -930,14 +932,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 {
@@ -1218,7 +1220,7 @@ function handleShellConnection(ws) {
if (hasSession && sessionId) {
try {
// Gemini CLI enforces its own native session IDs, unlike other agents that accept arbitrary string names.
// The UI only knows about its internal generated `sessionId` (e.g. gemini_1234).
// The UI only knows about its internal generated `sessionId` (e.g. gemini_1234).
// We must fetch the mapping from the backend session manager to pass the native `cliSessionId` to the shell.
const sess = sessionManager.getSession(sessionId);
if (sess && sess.cliSessionId) {
@@ -1791,8 +1793,8 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
// Construct the JSONL file path
// Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
// The encoding replaces /, spaces, ~, and _ with -
const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-');
// The encoding replaces any non-alphanumeric character (except -) with -
const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
@@ -1980,6 +1982,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);

View File

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

View File

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

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',
'api',
'tasks',
'notifications',
];
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex'];

View File

@@ -20,6 +20,7 @@ import type {
McpServer,
McpToolsResult,
McpTestResult,
NotificationPreferencesState,
ProjectSortOrder,
SettingsMainTab,
SettingsProject,
@@ -95,9 +96,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".
@@ -185,6 +191,18 @@ const createEmptyCursorPermissions = (): CursorPermissionsState => ({
...DEFAULT_CURSOR_PERMISSIONS,
});
const createDefaultNotificationPreferences = (): NotificationPreferencesState => ({
channels: {
inApp: true,
webPush: false,
},
events: {
actionRequired: true,
stop: true,
error: true,
},
});
export function useSettingsController({ isOpen, initialTab, projects, onClose }: UseSettingsControllerArgs) {
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
const closeTimerRef = useRef<number | null>(null);
@@ -204,6 +222,9 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
const [cursorPermissions, setCursorPermissions] = useState<CursorPermissionsState>(() => (
createEmptyCursorPermissions()
));
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferencesState>(() => (
createDefaultNotificationPreferences()
));
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
const [geminiPermissionMode, setGeminiPermissionMode] = useState<GeminiPermissionMode>('default');
@@ -669,6 +690,22 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
);
setGeminiPermissionMode(savedGeminiSettings.permissionMode || 'default');
try {
const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences');
if (notificationResponse.ok) {
const notificationData = await toResponseJson<NotificationPreferencesResponse>(notificationResponse);
if (notificationData.success && notificationData.preferences) {
setNotificationPreferences(notificationData.preferences);
} else {
setNotificationPreferences(createDefaultNotificationPreferences());
}
} else {
setNotificationPreferences(createDefaultNotificationPreferences());
}
} catch {
setNotificationPreferences(createDefaultNotificationPreferences());
}
await Promise.all([
fetchMcpServers(),
fetchCursorMcpServers(),
@@ -678,6 +715,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
console.error('Error loading settings:', error);
setClaudePermissions(createEmptyClaudePermissions());
setCursorPermissions(createEmptyCursorPermissions());
setNotificationPreferences(createDefaultNotificationPreferences());
setCodexPermissionMode('default');
setProjectSortOrder('name');
}
@@ -698,7 +736,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
void checkAuthStatus(loginProvider);
}, [checkAuthStatus, loginProvider]);
const saveSettings = useCallback(() => {
const saveSettings = useCallback(async () => {
setIsSaving(true);
setSaveStatus(null);
@@ -729,6 +767,14 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
lastUpdated: now,
}));
const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences', {
method: 'PUT',
body: JSON.stringify(notificationPreferences),
});
if (!notificationResponse.ok) {
throw new Error('Failed to save notification preferences');
}
setSaveStatus('success');
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
@@ -749,6 +795,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
cursorPermissions.allowedCommands,
cursorPermissions.disallowedCommands,
cursorPermissions.skipPermissions,
notificationPreferences,
onClose,
projectSortOrder,
]);
@@ -825,6 +872,8 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
setClaudePermissions,
cursorPermissions,
setCursorPermissions,
notificationPreferences,
setNotificationPreferences,
codexPermissionMode,
setCodexPermissionMode,
mcpServers,

View File

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

View File

@@ -9,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,
@@ -82,6 +86,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;
}
@@ -171,6 +199,18 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
</div>
)}
{activeTab === 'notifications' && (
<NotificationsSettingsTab
notificationPreferences={notificationPreferences}
onNotificationPreferencesChange={setNotificationPreferences}
pushPermission={pushPermission}
isPushSubscribed={isPushSubscribed}
isPushLoading={isPushLoading}
onEnablePush={handleEnablePush}
onDisablePush={handleDisablePush}
/>
)}
{activeTab === 'api' && (
<div className="space-y-6 md:space-y-8">
<CredentialsSettingsTab />

View File

@@ -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 (
<div className="border-b border-border">
<div className="flex px-4 md:px-6" role="tablist" aria-label={t('mainTabs.label', { defaultValue: 'Settings' })}>
<div className="flex px-4 md:px-6 overflow-x-auto scrollbar-hide" role="tablist" aria-label={t('mainTabs.label', { defaultValue: 'Settings' })}>
{TAB_CONFIG.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
@@ -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'

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>
</ul>
</div>
</div>
);
}

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import type { TFunction } from 'i18next';
import type { LoadingProgress, Project, ProjectSession, SessionProvider } from '../../../../types/app';
import type {
@@ -103,6 +104,15 @@ export default function SidebarProjectList({
/>
);
useEffect(() => {
let baseTitle = 'CloudCLI UI';
const displayName = selectedProject?.displayName?.trim();
if (displayName) {
baseTitle = `${displayName} - ${baseTitle}`;
}
document.title = baseTitle;
}, [selectedProject]);
const showProjects = !isLoading && projects.length > 0 && filteredProjects.length > 0;
return (

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"
}
},
"notifications": {
"genericTool": "a tool",
"codes": {
"generic": {
"info": {
"title": "Notification"
}
},
"permission": {
"required": {
"title": "Action Required",
"body": "{{toolName}} is waiting for your decision."
}
},
"run": {
"stopped": {
"title": "Run Stopped",
"body": "Reason: {{reason}}"
},
"failed": {
"title": "Run Failed"
}
},
"agent": {
"notification": {
"title": "Agent Notification"
}
}
}
},
"versionUpdate": {
"title": "Update Available",
"newVersionReady": "A new version is ready",

View File

@@ -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": {

View File

@@ -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": "新しいバージョンが利用可能です",

View File

@@ -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": {

View File

@@ -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": "새 버전이 준비되었습니다",

View File

@@ -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": {

View File

@@ -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": "新版本已准备就绪",

View File

@@ -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": {

View File

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