Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,23 @@
@update:model-value="onDictationLanguageChange"
/>
</div>
<button
class="sidebar-settings-row"
type="button"
:disabled="browserNotificationPermission === 'unsupported' || browserNotificationPermission === 'denied'"
:title="browserNotificationHelpText"
@click="onToggleBrowserNotifications"
>
<span class="sidebar-settings-label">{{ t('Browser notifications') }}</span>
<span class="sidebar-settings-notification-control">
<span v-if="browserNotificationStatusText" class="sidebar-settings-value">{{ browserNotificationStatusText }}</span>
<span
class="sidebar-settings-toggle"
:class="{ 'is-on': browserNotificationsEnabled }"
aria-hidden="true"
/>
</span>
</button>
<button class="sidebar-settings-row" type="button" aria-live="polite" @click="isTelegramConfigOpen = !isTelegramConfigOpen">
<span class="sidebar-settings-label">{{ t('Telegram') }}</span>
<span class="sidebar-settings-value">{{ telegramStatusText }}</span>
Expand Down Expand Up @@ -1199,6 +1216,8 @@ const {
selectedModelId,
selectedReasoningEffort,
selectedSpeedMode,
browserNotificationsEnabled,
browserNotificationPermission,
installedSkills,
accountRateLimitSnapshots,
messages,
Expand Down Expand Up @@ -1231,6 +1250,8 @@ const {

setSelectedReasoningEffort,
updateSelectedSpeedMode,
setBrowserNotificationsEnabled,
refreshBrowserNotificationPermission,
respondToPendingServerRequest,
renameProject,
removeProject,
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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') {
Expand All @@ -2777,6 +2816,7 @@ function onWindowPageShow(event: PageTransitionEvent): void {
}

function onWindowFocus(): void {
refreshBrowserNotificationPermission()
if (route.name === 'home') {
void loadWorkspaceRootOptionsState()
void refreshDefaultProjectName()
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, unknown>).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<void> {
if (isRouteSyncInProgress.value) {
hasPendingRouteSync = true
Expand Down Expand Up @@ -5046,6 +5107,13 @@ async function loadWorktreeBranches(sourceCwd: string): Promise<void> {
@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;
Expand Down
122 changes: 122 additions & 0 deletions src/composables/useDesktopState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
if (typeof window === 'undefined') return {}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -1505,6 +1528,8 @@ export function useDesktopState() {
const codexRateLimit = ref<UiRateLimitSnapshot | null>(null)
const threadTokenUsageByThreadId = ref<Record<string, UiThreadTokenUsage>>(loadThreadTokenUsageMap())
const terminalOpenByThreadId = ref<Record<string, boolean>>(loadThreadTerminalOpenMap())
const browserNotificationsEnabled = ref(loadBrowserNotificationsEnabled())
const browserNotificationPermission = ref<BrowserNotificationPermission>(readBrowserNotificationPermission())

const threadTitleById = ref<Record<string, string>>({})

Expand Down Expand Up @@ -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) => ({
Expand Down Expand Up @@ -3061,6 +3145,7 @@ export function useDesktopState() {
const request = normalizeServerRequest(notification.params)
if (!request) return true
upsertPendingServerRequest(request)
notifyPendingServerRequest(request)
return true
}

Expand Down Expand Up @@ -3733,6 +3818,7 @@ export function useDesktopState() {
if (!shouldRetryWithFallback) {
clearPendingTurnRequest(completedTurn.threadId)
scheduleQueueStateRefresh(completedTurn.threadId)
notifyTurnCompleted(completedTurn)
}
Comment on lines 3818 to 3822
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Success notify on failure 🐞 Bug ≡ Correctness

useDesktopState.applyRealtimeUpdates() calls notifyTurnCompleted() even when the same turn/completed
event indicates a failure (turn.status === 'failed'), so users can receive a misleading “Codex task
complete” notification for failed turns. This can mask errors and erode trust in the notification
signal.
Agent Prompt
### Issue description
A "Codex task complete" browser notification can be shown for failed turns because `notifyTurnCompleted()` is called without checking whether the turn completed with an error.

### Issue Context
`readTurnErrorMessage()` explicitly extracts an error when a `turn/completed` payload has `turn.status === 'failed'`, but the completion notification is currently sent whenever `!shouldRetryWithFallback`.

### Fix Focus Areas
- src/composables/useDesktopState.ts[3783-3836]
- src/composables/useDesktopState.ts[2905-2912]

### What to change
- Only call `notifyTurnCompleted(completedTurn)` when there is no `turnErrorMessage` (and/or when the completed turn status is successful).
- Optionally add a separate failure notification path (e.g., `notifyTurnFailed`) with an accurate title/body, or suppress notifications for failures entirely.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}

Expand Down Expand Up @@ -5499,6 +5585,38 @@ export function useDesktopState() {
threadTokenUsageByThreadId.value = {}
}

async function setBrowserNotificationsEnabled(enabled: boolean): Promise<void> {
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<QueuedMessage[]>(() => {
const threadId = selectedThreadId.value
if (!threadId) return []
Expand Down Expand Up @@ -5572,6 +5690,8 @@ export function useDesktopState() {
selectedModelId,
selectedReasoningEffort,
selectedSpeedMode,
browserNotificationsEnabled,
browserNotificationPermission,
installedSkills,
accountRateLimitSnapshots,
messages,
Expand Down Expand Up @@ -5610,6 +5730,8 @@ export function useDesktopState() {

setSelectedReasoningEffort,
updateSelectedSpeedMode,
setBrowserNotificationsEnabled,
refreshBrowserNotificationPermission,
respondToPendingServerRequest,
renameProject,
removeProject,
Expand Down
36 changes: 36 additions & 0 deletions tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.