MAESTRO: Add configurable toast notification duration setting

Add user-configurable duration for toast notifications under
Settings > Notifications. Users can select preset durations
(5s, 10s, 20s, 30s) or "Never" which keeps toasts on screen
until manually dismissed via the X button. Default is 20 seconds.

When set to "Never", toasts stack without auto-dismissing and
the progress bar is hidden.
This commit is contained in:
Pedram Amini
2025-11-26 16:36:15 -06:00
parent db1449d199
commit a5767adf5b
4 changed files with 117 additions and 9 deletions

View File

@@ -46,7 +46,7 @@ export default function MaestroConsole() {
const { hasOpenLayers, hasOpenModal } = useLayerStack();
// --- TOAST NOTIFICATIONS ---
const { addToast } = useToast();
const { addToast, setDefaultDuration: setToastDefaultDuration } = useToast();
// --- SETTINGS (from useSettings hook) ---
const settings = useSettings();
@@ -73,6 +73,7 @@ export default function MaestroConsole() {
osNotificationsEnabled, setOsNotificationsEnabled,
audioFeedbackEnabled, setAudioFeedbackEnabled,
audioFeedbackCommand, setAudioFeedbackCommand,
toastDuration, setToastDuration,
shortcuts, setShortcuts,
customAICommands, setCustomAICommands,
} = settings;
@@ -187,6 +188,11 @@ export default function MaestroConsole() {
}
}, [logViewerOpen]);
// Sync toast duration setting to ToastContext
useEffect(() => {
setToastDefaultDuration(toastDuration);
}, [toastDuration, setToastDefaultDuration]);
// Close file preview when switching sessions
useEffect(() => {
if (previewFile !== null) {
@@ -3198,6 +3204,8 @@ export default function MaestroConsole() {
setAudioFeedbackEnabled={setAudioFeedbackEnabled}
audioFeedbackCommand={audioFeedbackCommand}
setAudioFeedbackCommand={setAudioFeedbackCommand}
toastDuration={toastDuration}
setToastDuration={setToastDuration}
customAICommands={customAICommands}
setCustomAICommands={setCustomAICommands}
initialTab={settingsTab}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { X, Key, Moon, Sun, Keyboard, Check, Terminal, Bell, Volume2, Cpu } from 'lucide-react';
import { X, Key, Moon, Sun, Keyboard, Check, Terminal, Bell, Volume2, Cpu, Clock } from 'lucide-react';
import type { AgentConfig, Theme, Shortcut, ShellInfo, CustomAICommand } from '../types';
import { useLayerStack } from '../contexts/LayerStackContext';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
@@ -55,6 +55,8 @@ interface SettingsModalProps {
setAudioFeedbackEnabled: (value: boolean) => void;
audioFeedbackCommand: string;
setAudioFeedbackCommand: (value: string) => void;
toastDuration: number;
setToastDuration: (value: number) => void;
customAICommands: CustomAICommand[];
setCustomAICommands: (commands: CustomAICommand[]) => void;
initialTab?: 'general' | 'llm' | 'shortcuts' | 'theme' | 'network' | 'notifications' | 'aicommands';
@@ -1445,6 +1447,79 @@ export function SettingsModal(props: SettingsModalProps) {
</div>
</div>
{/* Toast Duration */}
<div>
<label className="block text-xs font-bold opacity-70 uppercase mb-2 flex items-center gap-2">
<Clock className="w-3 h-3" />
Toast Notification Duration
</label>
<div className="flex gap-2">
<button
onClick={() => props.setToastDuration(5)}
className={`flex-1 py-2 px-3 rounded border transition-all ${props.toastDuration === 5 ? 'ring-2' : ''}`}
style={{
borderColor: theme.colors.border,
backgroundColor: props.toastDuration === 5 ? theme.colors.accentDim : 'transparent',
ringColor: theme.colors.accent,
color: theme.colors.textMain
}}
>
5s
</button>
<button
onClick={() => props.setToastDuration(10)}
className={`flex-1 py-2 px-3 rounded border transition-all ${props.toastDuration === 10 ? 'ring-2' : ''}`}
style={{
borderColor: theme.colors.border,
backgroundColor: props.toastDuration === 10 ? theme.colors.accentDim : 'transparent',
ringColor: theme.colors.accent,
color: theme.colors.textMain
}}
>
10s
</button>
<button
onClick={() => props.setToastDuration(20)}
className={`flex-1 py-2 px-3 rounded border transition-all ${props.toastDuration === 20 ? 'ring-2' : ''}`}
style={{
borderColor: theme.colors.border,
backgroundColor: props.toastDuration === 20 ? theme.colors.accentDim : 'transparent',
ringColor: theme.colors.accent,
color: theme.colors.textMain
}}
>
20s
</button>
<button
onClick={() => props.setToastDuration(30)}
className={`flex-1 py-2 px-3 rounded border transition-all ${props.toastDuration === 30 ? 'ring-2' : ''}`}
style={{
borderColor: theme.colors.border,
backgroundColor: props.toastDuration === 30 ? theme.colors.accentDim : 'transparent',
ringColor: theme.colors.accent,
color: theme.colors.textMain
}}
>
30s
</button>
<button
onClick={() => props.setToastDuration(0)}
className={`flex-1 py-2 px-3 rounded border transition-all ${props.toastDuration === 0 ? 'ring-2' : ''}`}
style={{
borderColor: theme.colors.border,
backgroundColor: props.toastDuration === 0 ? theme.colors.accentDim : 'transparent',
ringColor: theme.colors.accent,
color: theme.colors.textMain
}}
>
Never
</button>
</div>
<p className="text-xs opacity-50 mt-2">
How long toast notifications remain on screen. "Never" means they stay until manually dismissed.
</p>
</div>
{/* Info about when notifications are triggered */}
<div className="p-3 rounded-lg" style={{ backgroundColor: theme.colors.bgActivity, border: `1px solid ${theme.colors.border}` }}>
<div className="text-xs font-medium mb-2" style={{ color: theme.colors.textMain }}>When are notifications triggered?</div>

View File

@@ -17,32 +17,45 @@ interface ToastContextType {
addToast: (toast: Omit<Toast, 'id' | 'timestamp'>) => void;
removeToast: (id: string) => void;
clearToasts: () => void;
defaultDuration: number;
setDefaultDuration: (duration: number) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export function ToastProvider({ children }: { children: React.ReactNode }) {
interface ToastProviderProps {
children: React.ReactNode;
defaultDuration?: number; // Duration in seconds, 0 = never auto-dismiss
}
export function ToastProvider({ children, defaultDuration: initialDuration = 20 }: ToastProviderProps) {
const [toasts, setToasts] = useState<Toast[]>([]);
const [defaultDuration, setDefaultDuration] = useState(initialDuration);
const toastIdCounter = useRef(0);
const addToast = useCallback((toast: Omit<Toast, 'id' | 'timestamp'>) => {
const id = `toast-${Date.now()}-${toastIdCounter.current++}`;
// Convert seconds to ms, use 0 for "never dismiss"
const durationMs = toast.duration !== undefined
? toast.duration
: (defaultDuration > 0 ? defaultDuration * 1000 : 0);
const newToast: Toast = {
...toast,
id,
timestamp: Date.now(),
duration: toast.duration ?? 5000, // Default 5 seconds
duration: durationMs,
};
setToasts(prev => [...prev, newToast]);
// Auto-remove after duration
if (newToast.duration && newToast.duration > 0) {
// Auto-remove after duration (only if duration > 0)
if (durationMs > 0) {
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, newToast.duration);
}, durationMs);
}
}, []);
}, [defaultDuration]);
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
@@ -53,7 +66,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
}, []);
return (
<ToastContext.Provider value={{ toasts, addToast, removeToast, clearToasts }}>
<ToastContext.Provider value={{ toasts, addToast, removeToast, clearToasts, defaultDuration, setDefaultDuration }}>
{children}
</ToastContext.Provider>
);

View File

@@ -79,6 +79,8 @@ export interface UseSettingsReturn {
setAudioFeedbackEnabled: (value: boolean) => void;
audioFeedbackCommand: string;
setAudioFeedbackCommand: (value: string) => void;
toastDuration: number;
setToastDuration: (value: number) => void;
// Shortcuts
shortcuts: Record<string, Shortcut>;
@@ -132,6 +134,7 @@ export function useSettings(): UseSettingsReturn {
const [osNotificationsEnabled, setOsNotificationsEnabledState] = useState(true); // Default: on
const [audioFeedbackEnabled, setAudioFeedbackEnabledState] = useState(false); // Default: off
const [audioFeedbackCommand, setAudioFeedbackCommandState] = useState('say'); // Default: macOS say command
const [toastDuration, setToastDurationState] = useState(20); // Default: 20 seconds, 0 = never auto-dismiss
// Shortcuts
const [shortcuts, setShortcutsState] = useState<Record<string, Shortcut>>(DEFAULT_SHORTCUTS);
@@ -260,6 +263,11 @@ export function useSettings(): UseSettingsReturn {
window.maestro.settings.set('audioFeedbackCommand', value);
};
const setToastDuration = (value: number) => {
setToastDurationState(value);
window.maestro.settings.set('toastDuration', value);
};
const setCustomAICommands = (value: CustomAICommand[]) => {
setCustomAICommandsState(value);
window.maestro.settings.set('customAICommands', value);
@@ -295,6 +303,7 @@ export function useSettings(): UseSettingsReturn {
const savedOsNotificationsEnabled = await window.maestro.settings.get('osNotificationsEnabled');
const savedAudioFeedbackEnabled = await window.maestro.settings.get('audioFeedbackEnabled');
const savedAudioFeedbackCommand = await window.maestro.settings.get('audioFeedbackCommand');
const savedToastDuration = await window.maestro.settings.get('toastDuration');
const savedCustomAICommands = await window.maestro.settings.get('customAICommands');
// Migration: if old setting exists but new ones don't, migrate
@@ -329,6 +338,7 @@ export function useSettings(): UseSettingsReturn {
if (savedOsNotificationsEnabled !== undefined) setOsNotificationsEnabledState(savedOsNotificationsEnabled);
if (savedAudioFeedbackEnabled !== undefined) setAudioFeedbackEnabledState(savedAudioFeedbackEnabled);
if (savedAudioFeedbackCommand !== undefined) setAudioFeedbackCommandState(savedAudioFeedbackCommand);
if (savedToastDuration !== undefined) setToastDurationState(savedToastDuration);
// Merge saved shortcuts with defaults (in case new shortcuts were added)
if (savedShortcuts !== undefined) {
@@ -407,6 +417,8 @@ export function useSettings(): UseSettingsReturn {
setAudioFeedbackEnabled,
audioFeedbackCommand,
setAudioFeedbackCommand,
toastDuration,
setToastDuration,
shortcuts,
setShortcuts,
customAICommands,