mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-17 13:52:07 +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:
@@ -2,6 +2,8 @@ import { useEffect, useRef } from 'react';
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
|
||||
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
||||
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
|
||||
import { playChatCompletionSound } from '../../../utils/notificationSound';
|
||||
import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
|
||||
import type { ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
||||
@@ -285,6 +287,9 @@ export function useChatRealtimeHandlers({
|
||||
break;
|
||||
}
|
||||
|
||||
showCompletionTitleIndicator();
|
||||
void playChatCompletionSound();
|
||||
|
||||
const actualSessionId =
|
||||
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
|
||||
? msg.actualSessionId
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { setNotificationSoundEnabled } from '../../../utils/notificationSound';
|
||||
import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus';
|
||||
import {
|
||||
DEFAULT_CODE_EDITOR_SETTINGS,
|
||||
@@ -107,6 +109,7 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
|
||||
channels: {
|
||||
inApp: true,
|
||||
webPush: false,
|
||||
sound: true,
|
||||
},
|
||||
events: {
|
||||
actionRequired: true,
|
||||
@@ -115,6 +118,25 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
|
||||
},
|
||||
});
|
||||
|
||||
const normalizeNotificationPreferences = (
|
||||
preferences?: Partial<NotificationPreferencesState> | null,
|
||||
): NotificationPreferencesState => {
|
||||
const defaults = createDefaultNotificationPreferences();
|
||||
|
||||
return {
|
||||
channels: {
|
||||
inApp: preferences?.channels?.inApp ?? defaults.channels.inApp,
|
||||
webPush: preferences?.channels?.webPush ?? defaults.channels.webPush,
|
||||
sound: preferences?.channels?.sound ?? defaults.channels.sound,
|
||||
},
|
||||
events: {
|
||||
actionRequired: preferences?.events?.actionRequired ?? defaults.events.actionRequired,
|
||||
stop: preferences?.events?.stop ?? defaults.events.stop,
|
||||
error: preferences?.events?.error ?? defaults.events.error,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export function useSettingsController({ isOpen, initialTab }: UseSettingsControllerArgs) {
|
||||
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
|
||||
const closeTimerRef = useRef<number | null>(null);
|
||||
@@ -186,7 +208,7 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
||||
if (notificationResponse.ok) {
|
||||
const notificationData = await toResponseJson<NotificationPreferencesResponse>(notificationResponse);
|
||||
if (notificationData.success && notificationData.preferences) {
|
||||
setNotificationPreferences(notificationData.preferences);
|
||||
setNotificationPreferences(normalizeNotificationPreferences(notificationData.preferences));
|
||||
} else {
|
||||
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||
}
|
||||
@@ -301,6 +323,10 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
||||
void refreshProviderAuthStatuses();
|
||||
}, [initialTab, isOpen, loadSettings, refreshProviderAuthStatuses]);
|
||||
|
||||
useEffect(() => {
|
||||
setNotificationSoundEnabled(notificationPreferences.channels.sound);
|
||||
}, [notificationPreferences.channels.sound]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);
|
||||
localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type { LLMProvider } from '../../../types/app';
|
||||
import type { ProviderAuthStatus } from '../../provider-auth/types';
|
||||
|
||||
@@ -29,6 +30,7 @@ export type NotificationPreferencesState = {
|
||||
channels: {
|
||||
inApp: boolean;
|
||||
webPush: boolean;
|
||||
sound: boolean;
|
||||
};
|
||||
events: {
|
||||
actionRequired: boolean;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Bell, BellOff, BellRing, Loader2 } from 'lucide-react';
|
||||
import { Bell, BellOff, BellRing, Loader2, Play, Volume2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '../../../../shared/view/ui';
|
||||
import { playChatCompletionSound } from '../../../../utils/notificationSound';
|
||||
import type { NotificationPreferencesState } from '../../types/types';
|
||||
|
||||
type NotificationsSettingsTabProps = {
|
||||
@@ -82,6 +85,54 @@ export default function NotificationsSettingsTab({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Volume2 className="h-4 w-4 text-blue-600" />
|
||||
<h4 className="font-medium text-foreground">
|
||||
{t('notifications.sound.title', { defaultValue: 'Sound' })}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('notifications.sound.description', {
|
||||
defaultValue: 'Play a short tone when a chat run finishes.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex shrink-0 items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationPreferences.channels.sound}
|
||||
onChange={(event) =>
|
||||
onNotificationPreferencesChange({
|
||||
...notificationPreferences,
|
||||
channels: {
|
||||
...notificationPreferences.channels,
|
||||
sound: event.target.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
{t('notifications.sound.enabled', { defaultValue: 'Enabled' })}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void playChatCompletionSound({ force: true });
|
||||
}}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
{t('notifications.sound.test', { defaultValue: 'Test sound' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
|
||||
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
|
||||
<div className="space-y-3">
|
||||
|
||||
Reference in New Issue
Block a user