diff --git a/chatgpt-extension/approval-bridge.js b/chatgpt-extension/approval-bridge.js index 9a1ebf23..71000b98 100644 --- a/chatgpt-extension/approval-bridge.js +++ b/chatgpt-extension/approval-bridge.js @@ -6,6 +6,7 @@ let approvalSocket = null; let approvalReconnectTimer = null; let approvalReconnectAttempt = 0; let approvalConnectionGeneration = 0; +let approvalSoundEnabled = true; async function startApprovalBridge() { const stored = await chrome.storage.local.get(APPROVAL_BASE_URL_KEY); @@ -171,8 +172,13 @@ async function resyncApprovalQueue() { } } +function configureApprovalSound(enabled) { + approvalSoundEnabled = enabled !== false; + void broadcastApprovalState(); +} + async function approvalBridgeState() { - return { items: sortedApprovalItems(), baseUrl: approvalBaseUrl, connected: Boolean(approvalSocket && approvalSocket.readyState === WebSocket.OPEN) }; + return { items: sortedApprovalItems(), baseUrl: approvalBaseUrl, connected: Boolean(approvalSocket && approvalSocket.readyState === WebSocket.OPEN), soundEnabled: approvalSoundEnabled }; } async function resolveGlobalApproval(message) { @@ -219,7 +225,7 @@ async function resolveGlobalApproval(message) { } async function broadcastApprovalState() { - const payload = { type: 'chatcmd-global-approval-state', items: sortedApprovalItems() }; + const payload = { type: 'chatcmd-global-approval-state', items: sortedApprovalItems(), soundEnabled: approvalSoundEnabled }; const tabs = await chatGptTabs(); await Promise.all(tabs.filter((tab) => tab.id).map(async (tab) => { try { await sendToChatGpt(tab.id, payload, { quiet: true }); } catch { /* tab can still be loading */ } diff --git a/chatgpt-extension/background.js b/chatgpt-extension/background.js index 80e76f67..5a1ac88c 100644 --- a/chatgpt-extension/background.js +++ b/chatgpt-extension/background.js @@ -28,6 +28,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } if (message.type === 'chatcmd-local-command') { if (message.localBaseUrl) void configureApprovalBridge(message.localBaseUrl).catch(() => undefined); + if (typeof message.approvalSoundEnabled === 'boolean') configureApprovalSound(message.approvalSoundEnabled); if (message.action === 'ping') { void chatGptTabStatus(message.conversationUrl, sender.tab?.id) .then((status) => sendResponse({ ok: true, extensionVersion: chrome.runtime.getManifest().version, ...status })) diff --git a/chatgpt-extension/content-chatgpt-approval-ui.js b/chatgpt-extension/content-chatgpt-approval-ui.js index 8f529bf3..98882458 100644 --- a/chatgpt-extension/content-chatgpt-approval-ui.js +++ b/chatgpt-extension/content-chatgpt-approval-ui.js @@ -8,6 +8,8 @@ let rejecting = false; let reason = ''; let lastSoundKey = null; + let approvalSoundEnabled = true; + let approvalAudio = null; let previousTitle = null; let countdownTimer = null; @@ -22,7 +24,8 @@ return shadow; } - function render(nextItems) { + function render(nextItems, soundEnabled = approvalSoundEnabled) { + approvalSoundEnabled = soundEnabled !== false; items = Array.isArray(nextItems) ? nextItems : []; const current = items[0]; const shadow = ensureRoot(); @@ -176,23 +179,11 @@ } function playApprovalSound() { + if (!approvalSoundEnabled) return; try { - const AudioContextCtor = globalThis.AudioContext || globalThis.webkitAudioContext; - if (!AudioContextCtor) return; - const context = new AudioContextCtor(); - const now = context.currentTime; - for (const [offset, frequency] of [[0, 660], [.13, 880]]) { - const oscillator = context.createOscillator(); - const gain = context.createGain(); - oscillator.frequency.value = frequency; - gain.gain.setValueAtTime(0.0001, now + offset); - gain.gain.exponentialRampToValueAtTime(0.08, now + offset + .015); - gain.gain.exponentialRampToValueAtTime(0.0001, now + offset + .11); - oscillator.connect(gain).connect(context.destination); - oscillator.start(now + offset); - oscillator.stop(now + offset + .12); - } - setTimeout(() => void context.close(), 500); + approvalAudio ??= new Audio(chrome.runtime.getURL('sounds/sound_exe.mp3')); + approvalAudio.currentTime = 0; + void approvalAudio.play().catch(() => undefined); } catch { /* Browser autoplay policy can block audio until user interaction. */ } } @@ -208,13 +199,13 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (message?.type !== 'chatcmd-global-approval-state') return false; - render(message.items); + render(message.items, message.soundEnabled); sendResponse({ ok: true }); return false; }); void globalThis.ChatCmdRuntime.sendMessage({ type: 'chatcmd-approval-state-request' }, (response) => { - if (response?.ok) render(response.items); + if (response?.ok) render(response.items, response.soundEnabled); }); globalThis.ChatCmdGlobalApprovalUi = Object.freeze({ render }); diff --git a/chatgpt-extension/manifest.json b/chatgpt-extension/manifest.json index a32eae30..1e3e2077 100644 --- a/chatgpt-extension/manifest.json +++ b/chatgpt-extension/manifest.json @@ -6,6 +6,9 @@ "permissions": ["tabs", "storage", "scripting", "alarms"], "host_permissions": ["https://chatgpt.com/*", "http://localhost/*", "http://127.0.0.1/*"], "background": { "service_worker": "background.js" }, + "web_accessible_resources": [ + { "resources": ["sounds/sound_exe.mp3"], "matches": ["https://chatgpt.com/*"] } + ], "content_scripts": [ { "matches": ["https://chatgpt.com/*"], diff --git a/chatgpt-extension/sounds/sound_exe.mp3 b/chatgpt-extension/sounds/sound_exe.mp3 new file mode 100644 index 00000000..4987f3a8 Binary files /dev/null and b/chatgpt-extension/sounds/sound_exe.mp3 differ diff --git a/web/src/App.tsx b/web/src/App.tsx index 1662cd13..1c839b20 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -73,6 +73,7 @@ function SoundNotificationsBridge() { const onEvent = useCallback((event: TimelineEvent) => { const payload = event.payload && typeof event.payload === 'object' && !Array.isArray(event.payload) ? event.payload as Record : {}; if (isNewConversationEvent(event, payload)) soundNotifications.playNewAgent(); + if (event.type === 'approval.pending' || event.type === 'subagent.approval_pending') soundNotifications.playApproval(); if (isFinalResponseEvent(event, payload)) soundNotifications.playFinishedTask(); }, []); useRealtime(onEvent); diff --git a/web/src/chatgptBridge.ts b/web/src/chatgptBridge.ts index 30c4ebbe..0e0a8a4a 100644 --- a/web/src/chatgptBridge.ts +++ b/web/src/chatgptBridge.ts @@ -7,7 +7,7 @@ export const REQUIRED_CHATGPT_EXTENSION_VERSION = '0.1.9'; type BridgeCommand = | { action: 'compact-resume'; nonce: string; jobId: string; taskId: string; localBaseUrl: string } - | { action: 'ping'; nonce: string; conversationUrl?: string } + | { action: 'ping'; nonce: string; conversationUrl?: string; approvalSoundEnabled: boolean } | { action: 'prepare-tab'; nonce: string; newConversationUrl?: string } | { action: 'open-tab'; nonce: string; conversationUrl: string } | { action: 'focus-tab'; nonce: string; conversationUrl: string } @@ -27,7 +27,7 @@ export type ChatGptExtensionStatus = { ready: boolean; extensionVersion?: string export async function chatGptExtensionStatus(conversationUrl?: string): Promise { try { - const response = await bridge({ action: 'ping', nonce: nonce(), conversationUrl }, 1_500); + const response = await bridge({ action: 'ping', nonce: nonce(), conversationUrl, approvalSoundEnabled: approvalSoundPreference() }, 1_500); return { ready: true, extensionVersion: response.extensionVersion, @@ -122,4 +122,14 @@ function isResponse(value: unknown): value is BridgeResponse & { type: string } return record.type === RESPONSE_TYPE && typeof record.nonce === 'string' && typeof record.ok === 'boolean'; } +function approvalSoundPreference() { + try { + const value = JSON.parse(localStorage.getItem('chatcmd.preferences') ?? '{}') as Record; + if (typeof value.newAgentSound === 'boolean') return value.newAgentSound; + return value.sound !== false; + } catch { + return true; + } +} + function nonce() { return crypto.randomUUID(); } diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 679aae01..78952e73 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -3,6 +3,7 @@ import { AlertTriangle, Database, Download, Info, LockKeyhole, MonitorCog, Save, import { FormEvent, useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { api } from '../api'; +import { chatGptExtensionStatus } from '../chatgptBridge'; import { ErrorState, Loading, Modal, PageHeading, ProblemBanner, StatusBadge } from '../components'; import { applyAppFont, applyTaskFontScale, GOOGLE_FONT_PRESETS, normalizeFontFamily, normalizeTaskFontScale, TASK_FONT_SCALE_PRESETS } from '../fontPreferences'; import { getAppLanguage, setAppLanguage, tr, translatedStatus } from '../i18n'; @@ -62,6 +63,7 @@ export function SettingsPage() { result.setData(next); setValue(next); localStorage.setItem('chatcmd.preferences', JSON.stringify({ theme: next.theme, fontFamily: next.fontFamily, taskFontScale: next.taskFontScale, language: next.language, sound: next.sound, newAgentSound: next.newAgentSound, finishedTaskSound: next.finishedTaskSound })); + void chatGptExtensionStatus(); document.documentElement.dataset.theme = next.theme; applyAppFont(next.fontFamily); applyTaskFontScale(next.taskFontScale); diff --git a/web/src/tasks/GlobalConversationApprovalQueue.tsx b/web/src/tasks/GlobalConversationApprovalQueue.tsx index 66cd3745..7af0eae1 100644 --- a/web/src/tasks/GlobalConversationApprovalQueue.tsx +++ b/web/src/tasks/GlobalConversationApprovalQueue.tsx @@ -102,7 +102,7 @@ export function GlobalConversationApprovalQueue() { if (!current) return null; - return void decide(false)} dangerous> + return {}} dismissible={false} dangerous>

{tr('{count} conversation(s) waiting. Requests are shown one at a time so none are missed.', { count: queue.length })}

{tr('Approval expires in {seconds} seconds.', { seconds: remaining })}

{current.title?.trim() || current.id}

diff --git a/web/src/tasks/GlobalPlanQuestionQueue.tsx b/web/src/tasks/GlobalPlanQuestionQueue.tsx index 56c00382..c6250bf2 100644 --- a/web/src/tasks/GlobalPlanQuestionQueue.tsx +++ b/web/src/tasks/GlobalPlanQuestionQueue.tsx @@ -1,9 +1,10 @@ import { Ban, Check, Clock3, ListChecks, LoaderCircle, MessageSquareMore, ShieldCheck, Sparkles } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ApiError, api } from '../api'; import { Modal } from '../components'; import { tr } from '../i18n'; import { useRealtime } from '../realtime'; +import { soundNotifications } from '../soundNotifications'; import type { PlanQuestion, PlanQuestionAnswer, TimelineEvent } from '../types'; function sortQueue(items: PlanQuestion[]) { @@ -24,6 +25,7 @@ export function GlobalPlanQuestionQueue() { const [customAnswer, setCustomAnswer] = useState(''); const [remaining, setRemaining] = useState(0); const current = queue[0]; + const activeSoundQuestion = useRef(null); const reload = useCallback(async () => { try { @@ -51,6 +53,16 @@ export function GlobalPlanQuestionQueue() { setCustomAnswer(''); }, [current?.id]); + useEffect(() => { + if (!current) { + activeSoundQuestion.current = null; + return; + } + if (activeSoundQuestion.current === current.id) return; + activeSoundQuestion.current = current.id; + soundNotifications.playApproval(); + }, [current]); + const deadline = current?.deadlineAtMs ?? 0; useEffect(() => { if (!current) { setRemaining(0); return; }