mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-16 03:52:17 +08:00
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:
83
src/utils/notificationSound.ts
Normal file
83
src/utils/notificationSound.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
86
src/utils/pageTitleNotification.ts
Normal file
86
src/utils/pageTitleNotification.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
const COMPLETION_TITLE_INDICATOR = '[Done]';
|
||||
const TITLE_INDICATOR_CLEAR_DELAY_MS = 2000;
|
||||
|
||||
let clearTimer: number | null = null;
|
||||
let returnListenersAttached = false;
|
||||
|
||||
const getIndicatorPrefix = () => `${COMPLETION_TITLE_INDICATOR} `;
|
||||
|
||||
const stripIndicator = (title: string): string => {
|
||||
const prefix = getIndicatorPrefix();
|
||||
return title.startsWith(prefix) ? title.slice(prefix.length) : title;
|
||||
};
|
||||
|
||||
const pageIsActive = (): boolean => (
|
||||
document.visibilityState === 'visible' && document.hasFocus()
|
||||
);
|
||||
|
||||
const removeReturnListeners = (): void => {
|
||||
if (!returnListenersAttached || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
document.removeEventListener('visibilitychange', handleUserReturn);
|
||||
window.removeEventListener('focus', handleUserReturn, true);
|
||||
window.removeEventListener('click', handleUserReturn, true);
|
||||
returnListenersAttached = false;
|
||||
};
|
||||
|
||||
const clearTitleIndicator = (): void => {
|
||||
if (clearTimer !== null) {
|
||||
window.clearTimeout(clearTimer);
|
||||
clearTimer = null;
|
||||
}
|
||||
|
||||
removeReturnListeners();
|
||||
|
||||
if (document.title.startsWith(getIndicatorPrefix())) {
|
||||
document.title = stripIndicator(document.title);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleClear = (): void => {
|
||||
if (clearTimer !== null) {
|
||||
window.clearTimeout(clearTimer);
|
||||
}
|
||||
|
||||
clearTimer = window.setTimeout(() => {
|
||||
clearTitleIndicator();
|
||||
}, TITLE_INDICATOR_CLEAR_DELAY_MS);
|
||||
};
|
||||
|
||||
function handleUserReturn(): void {
|
||||
if (!pageIsActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Background completions keep the marker indefinitely. A tab click normally
|
||||
// surfaces as visibility/focus, while an in-page click is a useful fallback.
|
||||
scheduleClear();
|
||||
}
|
||||
|
||||
export const showCompletionTitleIndicator = (): void => {
|
||||
if (typeof document === 'undefined' || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseTitle = stripIndicator(document.title || 'CloudCLI UI');
|
||||
document.title = `${getIndicatorPrefix()}${baseTitle}`;
|
||||
|
||||
if (pageIsActive()) {
|
||||
scheduleClear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (clearTimer !== null) {
|
||||
window.clearTimeout(clearTimer);
|
||||
clearTimer = null;
|
||||
}
|
||||
|
||||
if (!returnListenersAttached) {
|
||||
document.addEventListener('visibilitychange', handleUserReturn);
|
||||
window.addEventListener('focus', handleUserReturn, true);
|
||||
window.addEventListener('click', handleUserReturn, true);
|
||||
returnListenersAttached = true;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user