Skip to content
Merged
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
10 changes: 8 additions & 2 deletions chatgpt-extension/approval-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 */ }
Expand Down
1 change: 1 addition & 0 deletions chatgpt-extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
Expand Down
29 changes: 10 additions & 19 deletions chatgpt-extension/content-chatgpt-approval-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
let rejecting = false;
let reason = '';
let lastSoundKey = null;
let approvalSoundEnabled = true;
let approvalAudio = null;
let previousTitle = null;
let countdownTimer = null;

Expand All @@ -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();
Expand Down Expand Up @@ -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. */ }
}

Expand All @@ -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 });
Expand Down
3 changes: 3 additions & 0 deletions chatgpt-extension/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/*"],
Expand Down
Binary file added chatgpt-extension/sounds/sound_exe.mp3
Binary file not shown.
1 change: 1 addition & 0 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> : {};
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);
Expand Down
14 changes: 12 additions & 2 deletions web/src/chatgptBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -27,7 +27,7 @@ export type ChatGptExtensionStatus = { ready: boolean; extensionVersion?: string

export async function chatGptExtensionStatus(conversationUrl?: string): Promise<ChatGptExtensionStatus> {
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,
Expand Down Expand Up @@ -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<string, unknown>;
if (typeof value.newAgentSound === 'boolean') return value.newAgentSound;
return value.sound !== false;
} catch {
return true;
}
}

function nonce() { return crypto.randomUUID(); }
2 changes: 2 additions & 0 deletions web/src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion web/src/tasks/GlobalConversationApprovalQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export function GlobalConversationApprovalQueue() {

if (!current) return null;

return <Modal title="Xin phê duyệt" description={tr('A new ChatGPT Website conversation is waiting for approval before the Agent can execute anything.')} close={() => void decide(false)} dangerous>
return <Modal title="Xin phê duyệt" description={tr('A new ChatGPT Website conversation is waiting for approval before the Agent can execute anything.')} close={() => {}} dismissible={false} dangerous>
<div className="warning-block"><ListChecks /><p>{tr('{count} conversation(s) waiting. Requests are shown one at a time so none are missed.', { count: queue.length })}</p></div>
<div className="warning-block"><Clock3 /><p>{tr('Approval expires in {seconds} seconds.', { seconds: remaining })}</p></div>
<p><strong>{current.title?.trim() || current.id}</strong></p>
Expand Down
14 changes: 13 additions & 1 deletion web/src/tasks/GlobalPlanQuestionQueue.tsx
Original file line number Diff line number Diff line change
@@ -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[]) {
Expand All @@ -24,6 +25,7 @@ export function GlobalPlanQuestionQueue() {
const [customAnswer, setCustomAnswer] = useState('');
const [remaining, setRemaining] = useState(0);
const current = queue[0];
const activeSoundQuestion = useRef<string | null>(null);

const reload = useCallback(async () => {
try {
Expand Down Expand Up @@ -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; }
Expand Down
Loading