diff --git a/src/App.vue b/src/App.vue index db9aefc6f..2f4ced0bd 100644 --- a/src/App.vue +++ b/src/App.vue @@ -398,6 +398,23 @@ @update:model-value="onDictationLanguageChange" /> + + {{ t('Browser notifications') }} + + {{ browserNotificationStatusText }} + + + {{ t('Telegram') }} {{ telegramStatusText }} @@ -1199,6 +1216,8 @@ const { selectedModelId, selectedReasoningEffort, selectedSpeedMode, + browserNotificationsEnabled, + browserNotificationPermission, installedSkills, accountRateLimitSnapshots, messages, @@ -1231,6 +1250,8 @@ const { setSelectedReasoningEffort, updateSelectedSpeedMode, + setBrowserNotificationsEnabled, + refreshBrowserNotificationPermission, respondToPendingServerRequest, renameProject, removeProject, @@ -1334,6 +1355,7 @@ const DICTATION_LANGUAGE_KEY = 'codex-web-local.dictation-language.v1' const CHAT_WIDTH_KEY = 'codex-web-local.chat-width.v1' const MOBILE_RESUME_RELOAD_MIN_HIDDEN_MS = 400 +const THREAD_NOTIFICATION_CLICK_EVENT = 'codex-thread-notification-click' const sendWithEnter = ref(loadBoolPref(SEND_WITH_ENTER_KEY, true)) const inProgressSendMode = ref<'steer' | 'queue'>(loadInProgressSendModePref()) const darkMode = ref<'system' | 'light' | 'dark'>(loadDarkModePref()) @@ -1768,10 +1790,25 @@ const telegramStatusText = computed(() => { const error = telegramStatus.value.lastError ? `, ${t('error')}: ${telegramStatus.value.lastError}` : '' return `${base}, ${mapped}${error}` }) +const browserNotificationStatusText = computed(() => { + if (browserNotificationPermission.value === 'unsupported') return t('Unsupported') + if (browserNotificationPermission.value === 'denied') return t('Blocked') + if (browserNotificationsEnabled.value) return t('On') + return '' +}) +const browserNotificationHelpText = computed(() => { + if (browserNotificationPermission.value === 'unsupported') return t('This browser does not support notifications.') + if (browserNotificationPermission.value === 'denied') return t('Notifications are blocked in browser settings.') + return browserNotificationsEnabled.value + ? t('Notify when background thread tasks finish or need action.') + : t('Enable notifications for completed tasks and pending chat actions.') +}) onMounted(() => { + refreshBrowserNotificationPermission() document.addEventListener('pointerdown', onDocumentPointerDown) window.addEventListener('keydown', onWindowKeyDown) + window.addEventListener(THREAD_NOTIFICATION_CLICK_EVENT, onThreadNotificationClick) document.addEventListener('visibilitychange', onDocumentVisibilityChange) window.addEventListener('pageshow', onWindowPageShow) window.addEventListener('focus', onWindowFocus) @@ -1796,6 +1833,7 @@ onMounted(() => { onUnmounted(() => { document.removeEventListener('pointerdown', onDocumentPointerDown) window.removeEventListener('keydown', onWindowKeyDown) + window.removeEventListener(THREAD_NOTIFICATION_CLICK_EVENT, onThreadNotificationClick) document.removeEventListener('visibilitychange', onDocumentVisibilityChange) window.removeEventListener('pageshow', onWindowPageShow) window.removeEventListener('focus', onWindowFocus) @@ -2760,6 +2798,7 @@ function onSettingsAreaClick(event: MouseEvent): void { function onDocumentVisibilityChange(): void { if (typeof document === 'undefined') return + refreshBrowserNotificationPermission() if (!isMobile.value) return if (document.visibilityState === 'hidden') { @@ -2777,6 +2816,7 @@ function onWindowPageShow(event: PageTransitionEvent): void { } function onWindowFocus(): void { + refreshBrowserNotificationPermission() if (route.name === 'home') { void loadWorkspaceRootOptionsState() void refreshDefaultProjectName() @@ -3758,6 +3798,10 @@ function onDictationLanguageChange(nextValue: string): void { window.localStorage.setItem(DICTATION_LANGUAGE_KEY, value) } +function onToggleBrowserNotifications(): void { + void setBrowserNotificationsEnabled(!browserNotificationsEnabled.value) +} + function loadDictationLanguagePref(): string { if (typeof window === 'undefined') return 'auto' const value = window.localStorage.getItem(DICTATION_LANGUAGE_KEY)?.trim() || 'auto' @@ -3885,6 +3929,23 @@ function threadExistsInSidebar(threadId: string): boolean { return projectGroups.value.some((group) => group.threads.some((thread) => thread.id === threadId)) } +function readThreadIdFromNotificationEvent(event: Event): string { + if (!(event instanceof CustomEvent)) return '' + const detail = event.detail + if (!detail || typeof detail !== 'object' || Array.isArray(detail)) return '' + const threadId = (detail as Record).threadId + return typeof threadId === 'string' ? threadId.trim() : '' +} + +function onThreadNotificationClick(event: Event): void { + const threadId = readThreadIdFromNotificationEvent(event) + if (!threadId) return + void (async () => { + await selectThread(threadId) + await router.replace({ name: 'thread', params: { threadId } }) + })() +} + async function syncThreadSelectionWithRoute(): Promise { if (isRouteSyncInProgress.value) { hasPendingRouteSync = true @@ -5046,6 +5107,13 @@ async function loadWorktreeBranches(sourceCwd: string): Promise { @apply text-xs text-zinc-500 bg-zinc-100 rounded px-1.5 py-0.5; } +.sidebar-settings-notification-control { + @apply inline-flex items-center gap-2; +} + +.sidebar-settings-row:disabled { + @apply cursor-not-allowed opacity-70; +} .sidebar-settings-toggle { @apply relative w-9 h-5 rounded-full bg-zinc-300 transition-colors shrink-0; diff --git a/src/composables/useDesktopState.ts b/src/composables/useDesktopState.ts index e64bac2b2..e3ec2759f 100644 --- a/src/composables/useDesktopState.ts +++ b/src/composables/useDesktopState.ts @@ -92,8 +92,12 @@ const RECENT_THREAD_MESSAGE_LOAD_REUSE_MS = 2000 const REASONING_EFFORT_OPTIONS: ReasoningEffort[] = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'] const GLOBAL_SERVER_REQUEST_SCOPE = '__global__' const MODEL_FALLBACK_ID = 'gpt-5.4-mini' +const BROWSER_NOTIFICATIONS_STORAGE_KEY = 'codex-web-local.browser-notifications-enabled.v1' export type ProjectSortMode = 'recent' | 'manual' +type BrowserNotificationPermission = NotificationPermission | 'unsupported' +const THREAD_NOTIFICATION_CLICK_EVENT = 'codex-thread-notification-click' + function loadReadStateMap(): Record { if (typeof window === 'undefined') return {} @@ -130,6 +134,25 @@ function saveUnreadCutoffIso(cutoffIso: string): void { window.localStorage.setItem(UNREAD_CUTOFF_STORAGE_KEY, cutoffIso) } +function hasBrowserNotificationSupport(): boolean { + return typeof window !== 'undefined' && 'Notification' in window +} + +function readBrowserNotificationPermission(): BrowserNotificationPermission { + if (!hasBrowserNotificationSupport()) return 'unsupported' + return window.Notification.permission +} + +function loadBrowserNotificationsEnabled(): boolean { + if (typeof window === 'undefined') return false + return window.localStorage.getItem(BROWSER_NOTIFICATIONS_STORAGE_KEY) === 'true' +} + +function saveBrowserNotificationsEnabled(enabled: boolean): void { + if (typeof window === 'undefined') return + window.localStorage.setItem(BROWSER_NOTIFICATIONS_STORAGE_KEY, enabled ? 'true' : 'false') +} + function isThreadUpdatedAfterCutoff(updatedAtIso: string, cutoffIso: string): boolean { if (!updatedAtIso || !cutoffIso) return false const updatedAtMs = new Date(updatedAtIso).getTime() @@ -1505,6 +1528,8 @@ export function useDesktopState() { const codexRateLimit = ref(null) const threadTokenUsageByThreadId = ref>(loadThreadTokenUsageMap()) const terminalOpenByThreadId = ref>(loadThreadTerminalOpenMap()) + const browserNotificationsEnabled = ref(loadBrowserNotificationsEnabled()) + const browserNotificationPermission = ref(readBrowserNotificationPermission()) const threadTitleById = ref>({}) @@ -2083,6 +2108,65 @@ export function useDesktopState() { return requests.length > 0 ? 'response' : null } + function findThreadById(threadId: string): UiThread | null { + return flattenThreads(projectGroups.value).find((thread) => thread.id === threadId) ?? + flattenThreads(sourceGroups.value).find((thread) => thread.id === threadId) ?? + null + } + + function threadNotificationTitle(threadId: string): string { + const thread = findThreadById(threadId) + return threadTitleById.value[threadId] || thread?.title || thread?.preview || 'Codex thread' + } + + function shouldSkipThreadBrowserNotification(threadId: string): boolean { + if (!threadId || threadId === GLOBAL_SERVER_REQUEST_SCOPE) return false + return selectedThreadId.value === threadId && typeof document !== 'undefined' && document.visibilityState === 'visible' + } + + function showBrowserNotification(title: string, options: NotificationOptions & { threadId?: string } = {}): void { + browserNotificationPermission.value = readBrowserNotificationPermission() + if (!browserNotificationsEnabled.value || browserNotificationPermission.value !== 'granted') return + if (!hasBrowserNotificationSupport()) return + + const { threadId, ...notificationOptions } = options + try { + const notification = new window.Notification(title, { + icon: '/icons/pwa-192x192.png', + ...notificationOptions, + }) + notification.onclick = () => { + window.focus() + if (threadId && threadId !== GLOBAL_SERVER_REQUEST_SCOPE) { + window.dispatchEvent(new CustomEvent(THREAD_NOTIFICATION_CLICK_EVENT, { detail: { threadId } })) + } + notification.close() + } + } catch { + // Browser notification construction can still fail after permission checks. + } + } + + function notifyPendingServerRequest(request: UiServerRequest): void { + if (shouldSkipThreadBrowserNotification(request.threadId)) return + const isApproval = isApprovalRequestMethod(request.method) + const title = isApproval ? 'Codex needs approval' : 'Codex needs a response' + showBrowserNotification(title, { + body: threadNotificationTitle(request.threadId), + tag: `codex-pending-${request.threadId || GLOBAL_SERVER_REQUEST_SCOPE}-${isApproval ? 'approval' : 'response'}`, + threadId: request.threadId, + }) + } + + function notifyTurnCompleted(turn: TurnCompletedInfo): void { + if (shouldSkipThreadBrowserNotification(turn.threadId)) return + showBrowserNotification('Codex task complete', { + body: threadNotificationTitle(turn.threadId), + tag: `codex-complete-${turn.threadId}`, + threadId: turn.threadId, + }) + } + function applyThreadFlags(): void { const withTitles = applyCachedTitlesToGroups(sourceGroups.value) const flaggedGroups: UiProjectGroup[] = withTitles.map((group) => ({ @@ -3061,6 +3145,7 @@ export function useDesktopState() { const request = normalizeServerRequest(notification.params) if (!request) return true upsertPendingServerRequest(request) + notifyPendingServerRequest(request) return true } @@ -3733,6 +3818,7 @@ export function useDesktopState() { if (!shouldRetryWithFallback) { clearPendingTurnRequest(completedTurn.threadId) scheduleQueueStateRefresh(completedTurn.threadId) + notifyTurnCompleted(completedTurn) } } @@ -5499,6 +5585,38 @@ export function useDesktopState() { threadTokenUsageByThreadId.value = {} } + async function setBrowserNotificationsEnabled(enabled: boolean): Promise { + if (!enabled) { + browserNotificationsEnabled.value = false + saveBrowserNotificationsEnabled(false) + browserNotificationPermission.value = readBrowserNotificationPermission() + return + } + + browserNotificationPermission.value = readBrowserNotificationPermission() + if (browserNotificationPermission.value === 'unsupported' || browserNotificationPermission.value === 'denied') { + browserNotificationsEnabled.value = false + saveBrowserNotificationsEnabled(false) + return + } + + if (browserNotificationPermission.value !== 'granted') { + browserNotificationPermission.value = await window.Notification.requestPermission() + } + + const canEnable = browserNotificationPermission.value === 'granted' + browserNotificationsEnabled.value = canEnable + saveBrowserNotificationsEnabled(canEnable) + } + + function refreshBrowserNotificationPermission(): void { + browserNotificationPermission.value = readBrowserNotificationPermission() + if (browserNotificationPermission.value !== 'granted' && browserNotificationsEnabled.value) { + browserNotificationsEnabled.value = false + saveBrowserNotificationsEnabled(false) + } + } + const selectedThreadQueuedMessages = computed(() => { const threadId = selectedThreadId.value if (!threadId) return [] @@ -5572,6 +5690,8 @@ export function useDesktopState() { selectedModelId, selectedReasoningEffort, selectedSpeedMode, + browserNotificationsEnabled, + browserNotificationPermission, installedSkills, accountRateLimitSnapshots, messages, @@ -5610,6 +5730,8 @@ export function useDesktopState() { setSelectedReasoningEffort, updateSelectedSpeedMode, + setBrowserNotificationsEnabled, + refreshBrowserNotificationPermission, respondToPendingServerRequest, renameProject, removeProject, diff --git a/tests.md b/tests.md index 427b74175..f5b9bb6e8 100644 --- a/tests.md +++ b/tests.md @@ -4832,3 +4832,39 @@ The sidebar Chats section lists all projectless chats and no longer shows the pe #### Rollback/Cleanup - None. + +--- + +### Browser notifications for completed and pending thread turns + +#### Feature/Change Name +Browser notifications can be enabled for background thread completion and pending chat approvals or responses. + +#### Prerequisites/Setup +1. Dev server running (`pnpm run dev`) +2. Browser notification permission is not blocked for the dev server origin +3. Light theme and dark theme both available from the appearance switcher + +#### Steps +1. In light theme, open the sidebar settings menu. +2. Click `Browser notifications` and grant the browser permission prompt. +3. Confirm the row shows `On`. +4. Start or open a thread, send a prompt that completes after a short delay, then switch to another thread or hide the browser tab before completion. +5. Confirm a browser notification appears with `Codex task complete` and the thread title or preview. +6. Start a turn that asks for approval or user input, then switch to another thread or hide the browser tab. +7. Confirm a browser notification appears with either `Codex needs approval` or `Codex needs a response`. +8. Click the notification and confirm the app focuses and selects the relevant thread. +9. Return to settings, click `Browser notifications` again, and confirm notifications turn off. +10. Switch to dark theme and repeat steps 1-3, confirming the setting row and status remain readable. + +#### Expected Results +- Notifications are opt-in and persisted after permission is granted. +- Completed background turns show a `Codex task complete` notification. +- Pending approval and response requests show a user-action notification. +- Notifications are suppressed for the currently visible selected thread. +- Clicking a notification focuses the app and selects the related thread. +- The settings control is readable in light theme and dark theme. + +#### Rollback/Cleanup +- Turn off `Browser notifications` in sidebar settings. +- If needed, reset browser notification permission for the dev server origin in browser site settings.