From d7906cd92861d98c587788b2e4a658896d11c979 Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Fri, 13 Mar 2026 18:54:31 +0300 Subject: [PATCH] feat: show session name in notification and don't reload tab on clicking --- the notification --- public/sw.js | 16 ++++-- server/claude-sdk.js | 8 ++- server/cursor-cli.js | 4 +- server/gemini-cli.js | 4 +- server/openai-codex.js | 4 ++ server/services/notification-orchestrator.js | 56 ++++++++++++++++--- src/components/app/AppContent.tsx | 34 +++++++++++ .../chat/hooks/useChatComposerState.ts | 25 ++++++++- 8 files changed, 131 insertions(+), 20 deletions(-) diff --git a/public/sw.js b/public/sw.js index a2a99b2..f521dda 100755 --- a/public/sw.js +++ b/public/sw.js @@ -68,7 +68,7 @@ self.addEventListener('push', event => { icon: '/logo-256.png', badge: '/logo-128.png', data: payload.data || {}, - tag: payload.data?.code || 'default', + tag: payload.data?.tag || `${payload.data?.sessionId || 'global'}:${payload.data?.code || 'default'}`, renotify: true }; @@ -82,16 +82,20 @@ self.addEventListener('notificationclick', event => { event.notification.close(); const sessionId = event.notification.data?.sessionId; + const provider = event.notification.data?.provider || null; const urlPath = sessionId ? `/session/${sessionId}` : '/'; event.waitUntil( - self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => { + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(async clientList => { for (const client of clientList) { if (client.url.includes(self.location.origin)) { - client.focus(); - if (sessionId) { - client.navigate(self.location.origin + urlPath); - } + await client.focus(); + client.postMessage({ + type: 'notification:navigate', + sessionId: sessionId || null, + provider, + urlPath + }); return; } } diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 06a0745..2bc80b0 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -467,7 +467,7 @@ async function loadMcpConfig(cwd) { * @returns {Promise} */ async function queryClaudeSDK(command, options = {}, ws) { - const { sessionId } = options; + const { sessionId, sessionSummary } = options; let capturedSessionId = sessionId; let sessionCreatedSent = false; let tempImagePaths = []; @@ -507,7 +507,7 @@ async function queryClaudeSDK(command, options = {}, ws) { sessionId: capturedSessionId || sessionId || null, kind: 'action_required', code: 'agent.notification', - meta: { message }, + meta: { message, sessionName: sessionSummary }, severity: 'warning', requiresUserAction: true, dedupeKey: `claude:hook:notification:${capturedSessionId || sessionId || 'none'}:${message}` @@ -553,7 +553,7 @@ async function queryClaudeSDK(command, options = {}, ws) { sessionId: capturedSessionId || sessionId || null, kind: 'action_required', code: 'permission.required', - meta: { toolName }, + meta: { toolName, sessionName: sessionSummary }, severity: 'warning', requiresUserAction: true, dedupeKey: `claude:permission:${capturedSessionId || sessionId || 'none'}:${requestId}` @@ -707,6 +707,7 @@ async function queryClaudeSDK(command, options = {}, ws) { userId: ws?.userId || null, provider: 'claude', sessionId: capturedSessionId || sessionId || null, + sessionName: sessionSummary, stopReason: 'completed' }); console.log('claude-complete event sent'); @@ -732,6 +733,7 @@ async function queryClaudeSDK(command, options = {}, ws) { userId: ws?.userId || null, provider: 'claude', sessionId: capturedSessionId || sessionId || null, + sessionName: sessionSummary, error }); diff --git a/server/cursor-cli.js b/server/cursor-cli.js index d05e667..d354723 100644 --- a/server/cursor-cli.js +++ b/server/cursor-cli.js @@ -24,7 +24,7 @@ function isWorkspaceTrustPrompt(text = '') { async function spawnCursor(command, options = {}, ws) { return new Promise(async (resolve, reject) => { - const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model } = options; + const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, sessionSummary } = options; let capturedSessionId = sessionId; // Track session ID throughout the process let sessionCreatedSent = false; // Track if we've already sent session-created event let hasRetriedWithTrust = false; @@ -97,6 +97,7 @@ async function spawnCursor(command, options = {}, ws) { userId: ws?.userId || null, provider: 'cursor', sessionId: finalSessionId, + sessionName: sessionSummary, stopReason: 'completed' }); return; @@ -106,6 +107,7 @@ async function spawnCursor(command, options = {}, ws) { userId: ws?.userId || null, provider: 'cursor', sessionId: finalSessionId, + sessionName: sessionSummary, error: error || `Cursor CLI exited with code ${code}` }); }; diff --git a/server/gemini-cli.js b/server/gemini-cli.js index 00e98a6..3a2c968 100644 --- a/server/gemini-cli.js +++ b/server/gemini-cli.js @@ -14,7 +14,7 @@ import { notifyRunFailed, notifyRunStopped } from './services/notification-orche let activeGeminiProcesses = new Map(); // Track active processes by session ID async function spawnGemini(command, options = {}, ws) { - const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode, images } = options; + const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode, images, sessionSummary } = options; let capturedSessionId = sessionId; // Track session ID throughout the process let sessionCreatedSent = false; // Track if we've already sent session-created event let assistantBlocks = []; // Accumulate the full response blocks including tools @@ -189,6 +189,7 @@ async function spawnGemini(command, options = {}, ws) { userId: ws?.userId || null, provider: 'gemini', sessionId: finalSessionId, + sessionName: sessionSummary, stopReason: 'completed' }); return; @@ -198,6 +199,7 @@ async function spawnGemini(command, options = {}, ws) { userId: ws?.userId || null, provider: 'gemini', sessionId: finalSessionId, + sessionName: sessionSummary, error: error || terminalFailureReason || `Gemini CLI exited with code ${code}` }); }; diff --git a/server/openai-codex.js b/server/openai-codex.js index 7c7a00e..a12f7e0 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -192,6 +192,7 @@ function mapPermissionModeToCodexOptions(permissionMode) { export async function queryCodex(command, options = {}, ws) { const { sessionId, + sessionSummary, cwd, projectPath, model, @@ -276,6 +277,7 @@ export async function queryCodex(command, options = {}, ws) { userId: ws?.userId || null, provider: 'codex', sessionId: currentSessionId, + sessionName: sessionSummary, error: terminalFailure }); } @@ -306,6 +308,7 @@ export async function queryCodex(command, options = {}, ws) { userId: ws?.userId || null, provider: 'codex', sessionId: currentSessionId, + sessionName: sessionSummary, stopReason: 'completed' }); } @@ -330,6 +333,7 @@ export async function queryCodex(command, options = {}, ws) { userId: ws?.userId || null, provider: 'codex', sessionId: currentSessionId, + sessionName: sessionSummary, error }); } diff --git a/server/services/notification-orchestrator.js b/server/services/notification-orchestrator.js index 0deeb5a..bb573e1 100644 --- a/server/services/notification-orchestrator.js +++ b/server/services/notification-orchestrator.js @@ -1,5 +1,5 @@ import webPush from 'web-push'; -import { notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js'; +import { notificationPreferencesDb, pushSubscriptionsDb, sessionNamesDb } from '../database/db.js'; const KIND_TO_PREF_KEY = { action_required: 'actionRequired', @@ -7,6 +7,14 @@ const KIND_TO_PREF_KEY = { error: 'error' }; +const PROVIDER_LABELS = { + claude: 'Claude', + cursor: 'Cursor', + codex: 'Codex', + gemini: 'Gemini', + system: 'System' +}; + const recentEventKeys = new Map(); const DEDUPE_WINDOW_MS = 20000; @@ -76,6 +84,32 @@ function normalizeErrorMessage(error) { return String(error); } +function normalizeSessionName(sessionName) { + if (typeof sessionName !== 'string') { + return null; + } + + const normalized = sessionName.replace(/\s+/g, ' ').trim(); + if (!normalized) { + return null; + } + + return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized; +} + +function resolveSessionName(event) { + const explicitSessionName = normalizeSessionName(event.meta?.sessionName); + if (explicitSessionName) { + return explicitSessionName; + } + + if (!event.sessionId || !event.provider) { + return null; + } + + return normalizeSessionName(sessionNamesDb.getName(event.sessionId, event.provider)); +} + function buildPushBody(event) { const CODE_MAP = { 'permission.required': event.meta?.toolName @@ -86,13 +120,19 @@ function buildPushBody(event) { 'agent.notification': event.meta?.message ? String(event.meta.message) : 'You have a new notification', 'push.enabled': 'Push notifications are now enabled!' }; + const providerLabel = PROVIDER_LABELS[event.provider] || 'Assistant'; + const sessionName = resolveSessionName(event); + const message = CODE_MAP[event.code] || 'You have a new notification'; return { - title: 'Claude Code UI', - body: CODE_MAP[event.code] || 'You have a new notification', + title: sessionName || 'Claude Code UI', + body: `${providerLabel}: ${message}`, data: { sessionId: event.sessionId || null, - code: event.code + code: event.code, + provider: event.provider || null, + sessionName, + tag: `${event.provider || 'assistant'}:${event.sessionId || 'none'}:${event.code}` } }; } @@ -147,7 +187,7 @@ function notifyUserIfEnabled({ userId, event }) { }); } -function notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'completed' }) { +function notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'completed', sessionName = null }) { notifyUserIfEnabled({ userId, event: createNotificationEvent({ @@ -155,14 +195,14 @@ function notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'co sessionId, kind: 'stop', code: 'run.stopped', - meta: { stopReason }, + meta: { stopReason, sessionName }, severity: 'info', dedupeKey: `${provider}:run:stop:${sessionId || 'none'}:${stopReason}` }) }); } -function notifyRunFailed({ userId, provider, sessionId = null, error }) { +function notifyRunFailed({ userId, provider, sessionId = null, error, sessionName = null }) { const errorMessage = normalizeErrorMessage(error); notifyUserIfEnabled({ @@ -172,7 +212,7 @@ function notifyRunFailed({ userId, provider, sessionId = null, error }) { sessionId, kind: 'error', code: 'run.failed', - meta: { error: errorMessage }, + meta: { error: errorMessage, sessionName }, severity: 'error', dedupeKey: `${provider}:run:error:${sessionId || 'none'}:${errorMessage}` }) diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 5649c0c..ed14fdb 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -72,6 +72,40 @@ export default function AppContent() { }; }, [openSettings]); + useEffect(() => { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { + return undefined; + } + + const handleServiceWorkerMessage = (event: MessageEvent) => { + const message = event.data; + if (!message || message.type !== 'notification:navigate') { + return; + } + + if (typeof message.provider === 'string' && message.provider.trim()) { + localStorage.setItem('selected-provider', message.provider); + } + + setActiveTab('chat'); + setSidebarOpen(false); + void refreshProjectsSilently(); + + if (typeof message.sessionId === 'string' && message.sessionId) { + navigate(`/session/${message.sessionId}`); + return; + } + + navigate('/'); + }; + + navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage); + + return () => { + navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage); + }; + }, [navigate, refreshProjectsSilently, setActiveTab, setSidebarOpen]); + // Permission recovery: query pending permissions on WebSocket reconnect or session change useEffect(() => { const isReconnect = isConnected && !wasConnectedRef.current; diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index bdba41d..6aab9ee 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -82,6 +82,24 @@ const createFakeSubmitEvent = () => { const isTemporarySessionId = (sessionId: string | null | undefined) => Boolean(sessionId && sessionId.startsWith('new-session-')); +const getNotificationSessionSummary = ( + selectedSession: ProjectSession | null, + fallbackInput: string, +): string | null => { + const sessionSummary = selectedSession?.summary || selectedSession?.name || selectedSession?.title; + if (typeof sessionSummary === 'string' && sessionSummary.trim()) { + const normalized = sessionSummary.replace(/\s+/g, ' ').trim(); + return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized; + } + + const normalizedFallback = fallbackInput.replace(/\s+/g, ' ').trim(); + if (!normalizedFallback) { + return null; + } + + return normalizedFallback.length > 80 ? `${normalizedFallback.slice(0, 77)}...` : normalizedFallback; +}; + export function useChatComposerState({ selectedProject, selectedSession, @@ -603,6 +621,7 @@ export function useChatComposerState({ const toolsSettings = getToolsSettings(); const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || ''; + const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput); if (provider === 'cursor') { sendMessage({ @@ -616,6 +635,7 @@ export function useChatComposerState({ resume: Boolean(effectiveSessionId), model: cursorModel, skipPermissions: toolsSettings?.skipPermissions || false, + sessionSummary, toolsSettings, }, }); @@ -630,6 +650,7 @@ export function useChatComposerState({ sessionId: effectiveSessionId, resume: Boolean(effectiveSessionId), model: codexModel, + sessionSummary, permissionMode: permissionMode === 'plan' ? 'default' : permissionMode, }, }); @@ -644,6 +665,7 @@ export function useChatComposerState({ sessionId: effectiveSessionId, resume: Boolean(effectiveSessionId), model: geminiModel, + sessionSummary, permissionMode, toolsSettings, }, @@ -660,6 +682,7 @@ export function useChatComposerState({ toolsSettings, permissionMode, model: claudeModel, + sessionSummary, images: uploadedImages, }, }); @@ -681,6 +704,7 @@ export function useChatComposerState({ safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); }, [ + selectedSession, attachedImages, claudeModel, codexModel, @@ -697,7 +721,6 @@ export function useChatComposerState({ resetCommandMenuState, scrollToBottom, selectedProject, - selectedSession?.id, sendMessage, setCanAbortSession, setChatMessages,