feat: signal when chat runs complete

Users can miss chat completions while the app is in the background.

They can also miss completions when their attention is elsewhere.

Add opt-out sound notifications and a temporary title marker.

This makes completion noticeable without external audio assets or persistent browser notifications.
This commit is contained in:
Haileyesus
2026-06-09 13:51:51 +03:00
parent f4a1614a0a
commit d70dc077bf
16 changed files with 316 additions and 2 deletions

View File

@@ -0,0 +1,83 @@
const NOTIFICATION_SOUND_ENABLED_STORAGE_KEY = 'notificationSoundEnabled';
const AudioContextConstructor =
typeof window !== 'undefined'
? window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
: undefined;
let audioContext: AudioContext | null = null;
export const isNotificationSoundEnabled = (): boolean => {
if (typeof localStorage === 'undefined') {
return true;
}
return localStorage.getItem(NOTIFICATION_SOUND_ENABLED_STORAGE_KEY) !== 'false';
};
export const setNotificationSoundEnabled = (enabled: boolean): void => {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(NOTIFICATION_SOUND_ENABLED_STORAGE_KEY, String(enabled));
};
const getAudioContext = (): AudioContext | null => {
if (!AudioContextConstructor) {
return null;
}
if (!audioContext) {
audioContext = new AudioContextConstructor();
}
return audioContext;
};
const playTone = (
context: AudioContext,
frequency: number,
startsAt: number,
duration: number,
peakVolume: number,
): void => {
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(frequency, startsAt);
// Shape the volume so the synthesized tone starts and stops cleanly.
gain.gain.setValueAtTime(0.0001, startsAt);
gain.gain.exponentialRampToValueAtTime(peakVolume, startsAt + 0.015);
gain.gain.exponentialRampToValueAtTime(0.0001, startsAt + duration);
oscillator.connect(gain);
gain.connect(context.destination);
oscillator.start(startsAt);
oscillator.stop(startsAt + duration + 0.02);
};
export const playChatCompletionSound = async ({ force = false } = {}): Promise<void> => {
if (!force && !isNotificationSoundEnabled()) {
return;
}
const context = getAudioContext();
if (!context) {
return;
}
try {
if (context.state === 'suspended') {
await context.resume();
}
const now = context.currentTime;
playTone(context, 740, now, 0.12, 0.075);
playTone(context, 988, now + 0.11, 0.16, 0.06);
} catch (error) {
// Browsers may block audio until the page receives a user gesture.
console.warn('Unable to play notification sound:', error);
}
};