feat(notifications): allow any custom notification command

- Remove TTS command whitelist validation - users can now configure any
  command for custom notifications (e.g., fabric piped to 11s)
- Enable shell mode for spawn to support pipes and command chains
- Add visual feedback to Test button (running/success/error states)
- Remove speech bubble from AI response messages in TerminalOutput
- Rename internal TTS terminology to more generic "notification command"
- Update tests to reflect new behavior
This commit is contained in:
Pedram Amini
2026-02-01 20:06:34 -06:00
parent 9e83588ffd
commit 2dc99e646a
11 changed files with 387 additions and 599 deletions

View File

@@ -1,7 +1,7 @@
/**
* Tests for notification IPC handlers
*
* Note: TTS-related tests are simplified due to the complexity of mocking
* Note: Notification command tests are simplified due to the complexity of mocking
* child_process spawn with all the event listeners and stdin handling.
*/
@@ -88,8 +88,7 @@ import {
getActiveTtsCount,
clearTtsQueue,
getTtsMaxQueueSize,
getAllowedTtsCommands,
validateTtsCommand,
parseNotificationCommand,
} from '../../../../main/ipc/handlers/notifications';
describe('Notification IPC Handlers', () => {
@@ -183,30 +182,30 @@ describe('Notification IPC Handlers', () => {
});
describe('notification:stopSpeak', () => {
it('should return error when no active TTS process', async () => {
it('should return error when no active notification process', async () => {
const handler = handlers.get('notification:stopSpeak')!;
const result = await handler({}, 999);
expect(result.success).toBe(false);
expect(result.error).toBe('No active TTS process with that ID');
expect(result.error).toBe('No active notification process with that ID');
});
});
describe('TTS state utilities', () => {
it('should track TTS queue length', () => {
describe('notification state utilities', () => {
it('should track notification queue length', () => {
expect(getTtsQueueLength()).toBe(0);
});
it('should track active TTS count', () => {
it('should track active notification count', () => {
expect(getActiveTtsCount()).toBe(0);
});
it('should clear TTS queue', () => {
it('should clear notification queue', () => {
clearTtsQueue();
expect(getTtsQueueLength()).toBe(0);
});
it('should reset TTS state', () => {
it('should reset notification state', () => {
resetTtsState();
expect(getTtsQueueLength()).toBe(0);
expect(getActiveTtsCount()).toBe(0);
@@ -215,168 +214,88 @@ describe('Notification IPC Handlers', () => {
it('should return max queue size', () => {
expect(getTtsMaxQueueSize()).toBe(10);
});
});
it('should return allowed TTS commands', () => {
const commands = getAllowedTtsCommands();
expect(commands).toContain('say');
expect(commands).toContain('espeak');
expect(commands).toContain('espeak-ng');
expect(commands).toContain('spd-say');
expect(commands).toContain('festival');
expect(commands).toContain('flite');
describe('notification command parsing', () => {
it('should return default command when none provided', () => {
const result = parseNotificationCommand();
expect(result).toBe('say');
});
it('should return default command for empty string', () => {
const result = parseNotificationCommand('');
expect(result).toBe('say');
});
it('should return default command for whitespace-only string', () => {
const result = parseNotificationCommand(' ');
expect(result).toBe('say');
});
it('should accept any command - user has full control', () => {
const result = parseNotificationCommand('say');
expect(result).toBe('say');
});
it('should accept custom commands with full paths', () => {
const result = parseNotificationCommand('/usr/local/bin/my-tts');
expect(result).toBe('/usr/local/bin/my-tts');
});
it('should accept commands with arguments', () => {
const result = parseNotificationCommand('say -v Alex');
expect(result).toBe('say -v Alex');
});
it('should accept command chains with pipes', () => {
const result = parseNotificationCommand('tee ~/log.txt | say');
expect(result).toBe('tee ~/log.txt | say');
});
it('should accept fabric pattern commands', () => {
const result = parseNotificationCommand(
'/Users/pedram/go/bin/fabric --pattern ped_summarize_conversational --model gpt-5-mini --raw 2>/dev/null | /Users/pedram/.local/bin/11s --voice NFQv27BRKPFgprCm0xgr'
);
expect(result).toBe(
'/Users/pedram/go/bin/fabric --pattern ped_summarize_conversational --model gpt-5-mini --raw 2>/dev/null | /Users/pedram/.local/bin/11s --voice NFQv27BRKPFgprCm0xgr'
);
});
it('should trim leading and trailing whitespace', () => {
const result = parseNotificationCommand(' say ');
expect(result).toBe('say');
});
it('should accept espeak command', () => {
const result = parseNotificationCommand('espeak');
expect(result).toBe('espeak');
});
it('should accept festival command with flags', () => {
const result = parseNotificationCommand('festival --tts');
expect(result).toBe('festival --tts');
});
});
describe('TTS command validation (security)', () => {
it('should accept default command when none provided', () => {
const result = validateTtsCommand();
expect(result.valid).toBe(true);
expect(result.command).toBe('say');
});
it('should accept empty string and use default', () => {
const result = validateTtsCommand('');
expect(result.valid).toBe(true);
expect(result.command).toBe('say');
});
it('should accept whitespace-only string and use default', () => {
const result = validateTtsCommand(' ');
expect(result.valid).toBe(true);
expect(result.command).toBe('say');
});
it('should accept whitelisted command: say', () => {
const result = validateTtsCommand('say');
expect(result.valid).toBe(true);
expect(result.command).toBe('say');
});
it('should accept whitelisted command: espeak', () => {
const result = validateTtsCommand('espeak');
expect(result.valid).toBe(true);
expect(result.command).toBe('espeak');
});
it('should accept whitelisted command: espeak-ng', () => {
const result = validateTtsCommand('espeak-ng');
expect(result.valid).toBe(true);
expect(result.command).toBe('espeak-ng');
});
it('should accept whitelisted command: spd-say', () => {
const result = validateTtsCommand('spd-say');
expect(result.valid).toBe(true);
expect(result.command).toBe('spd-say');
});
it('should accept whitelisted command: festival', () => {
const result = validateTtsCommand('festival');
expect(result.valid).toBe(true);
expect(result.command).toBe('festival');
});
it('should accept whitelisted command: flite', () => {
const result = validateTtsCommand('flite');
expect(result.valid).toBe(true);
expect(result.command).toBe('flite');
});
it('should reject non-whitelisted command', () => {
const result = validateTtsCommand('rm');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid TTS command');
expect(result.error).toContain('rm');
});
it('should reject command injection attempt with &&', () => {
const result = validateTtsCommand('say && rm -rf /');
expect(result.valid).toBe(false);
expect(result.error).toContain('arguments are not allowed');
});
it('should reject command injection attempt with ;', () => {
const result = validateTtsCommand('say; rm -rf /');
expect(result.valid).toBe(false);
// The semicolon makes 'say;' the base command, which is not whitelisted
expect(result.error).toContain('Invalid TTS command');
});
it('should reject command injection attempt with |', () => {
const result = validateTtsCommand('say | cat /etc/passwd');
expect(result.valid).toBe(false);
expect(result.error).toContain('arguments are not allowed');
});
it('should reject command with arguments (security)', () => {
const result = validateTtsCommand('say -v Alex');
expect(result.valid).toBe(false);
expect(result.error).toContain('arguments are not allowed');
});
it('should reject command with subshell attempt', () => {
const result = validateTtsCommand('$(whoami)');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid TTS command');
});
it('should reject command with backtick attempt', () => {
const result = validateTtsCommand('`whoami`');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid TTS command');
});
it('should reject arbitrary shell command', () => {
const result = validateTtsCommand('/bin/bash -c "echo hacked"');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid TTS command');
});
it('should reject curl command', () => {
const result = validateTtsCommand('curl http://evil.com');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid TTS command');
});
it('should reject wget command', () => {
const result = validateTtsCommand('wget http://evil.com');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid TTS command');
});
it('should handle command with leading whitespace', () => {
const result = validateTtsCommand(' say');
expect(result.valid).toBe(true);
expect(result.command).toBe('say');
});
it('should handle command with trailing whitespace', () => {
// Trailing whitespace is trimmed, so 'say ' becomes 'say' which is valid
const result = validateTtsCommand('say ');
expect(result.valid).toBe(true);
expect(result.command).toBe('say');
});
});
describe('TTS queue size limit', () => {
describe('notification queue size limit', () => {
it('should reject requests when queue is full', async () => {
const handler = handlers.get('notification:speak')!;
const maxSize = getTtsMaxQueueSize();
// The flow is:
// 1. First call: item added to queue, processNextTts() shifts it out to process
// 2. executeTts() creates a spawn that never completes, so isTtsProcessing stays true
// 3. Subsequent calls: items are added to queue but not processed (isTtsProcessing is true)
// 1. First call: item added to queue, processNextNotification() shifts it out to process
// 2. executeNotificationCommand() creates a spawn that never completes, so isNotificationProcessing stays true
// 3. Subsequent calls: items are added to queue but not processed (isNotificationProcessing is true)
// 4. Queue accumulates items 2 through maxSize (first one was shifted out)
// 5. We need maxSize + 1 calls total to fill the queue to maxSize items
// First call - this item gets shifted out of queue immediately for processing
handler({}, 'Message 0');
// Allow the async processNextTts to start (shifts item from queue)
// Allow the async processNextNotification to start (shifts item from queue)
await new Promise((resolve) => setTimeout(resolve, 10));
// Now isTtsProcessing is true, so subsequent items stay in queue
// Now isNotificationProcessing is true, so subsequent items stay in queue
// Add maxSize more items - this should fill the queue to maxSize
for (let i = 1; i <= maxSize; i++) {
handler({}, `Message ${i}`);
@@ -396,7 +315,7 @@ describe('Notification IPC Handlers', () => {
expect(result.error).toContain('queue is full');
expect(result.error).toContain(`max ${maxSize}`);
// Clean up - reset all TTS state including clearing the queue
// Clean up - reset all notification state including clearing the queue
resetTtsState();
});
});

View File

@@ -65,10 +65,31 @@ describe('Notification Preload API', () => {
expect(mockInvoke).toHaveBeenCalledWith('notification:speak', 'Hello', 'espeak');
expect(result.ttsId).toBe(456);
});
it('should accept complex command chains with pipes', async () => {
mockInvoke.mockResolvedValue({ success: true, ttsId: 789 });
const complexCommand = 'tee ~/log.txt | say';
const result = await api.speak('Test message', complexCommand);
expect(mockInvoke).toHaveBeenCalledWith('notification:speak', 'Test message', complexCommand);
expect(result.ttsId).toBe(789);
});
it('should accept commands with full paths and arguments', async () => {
mockInvoke.mockResolvedValue({ success: true, ttsId: 111 });
const fullPathCommand =
'/Users/pedram/go/bin/fabric --pattern ped_summarize_conversational --model gpt-5-mini';
const result = await api.speak('Test', fullPathCommand);
expect(mockInvoke).toHaveBeenCalledWith('notification:speak', 'Test', fullPathCommand);
expect(result.success).toBe(true);
});
});
describe('stopSpeak', () => {
it('should invoke notification:stopSpeak with ttsId', async () => {
it('should invoke notification:stopSpeak with notificationId', async () => {
mockInvoke.mockResolvedValue({ success: true });
const result = await api.stopSpeak(123);
@@ -84,16 +105,16 @@ describe('Notification Preload API', () => {
const cleanup = api.onTtsCompleted(callback);
expect(mockOn).toHaveBeenCalledWith('tts:completed', expect.any(Function));
expect(mockOn).toHaveBeenCalledWith('notification:commandCompleted', expect.any(Function));
expect(typeof cleanup).toBe('function');
});
it('should call callback when event is received', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, ttsId: number) => void;
let registeredHandler: (event: unknown, notificationId: number) => void;
mockOn.mockImplementation(
(_channel: string, handler: (event: unknown, ttsId: number) => void) => {
(_channel: string, handler: (event: unknown, notificationId: number) => void) => {
registeredHandler = handler;
}
);
@@ -108,10 +129,10 @@ describe('Notification Preload API', () => {
it('should remove listener when cleanup is called', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, ttsId: number) => void;
let registeredHandler: (event: unknown, notificationId: number) => void;
mockOn.mockImplementation(
(_channel: string, handler: (event: unknown, ttsId: number) => void) => {
(_channel: string, handler: (event: unknown, notificationId: number) => void) => {
registeredHandler = handler;
}
);
@@ -119,7 +140,10 @@ describe('Notification Preload API', () => {
const cleanup = api.onTtsCompleted(callback);
cleanup();
expect(mockRemoveListener).toHaveBeenCalledWith('tts:completed', registeredHandler!);
expect(mockRemoveListener).toHaveBeenCalledWith(
'notification:commandCompleted',
registeredHandler!
);
});
});
});

View File

@@ -1018,70 +1018,6 @@ describe('TerminalOutput', () => {
});
});
describe('Custom notification functionality', () => {
it('shows speak button when audioFeedbackCommand is provided', () => {
const logs: LogEntry[] = [createLogEntry({ text: 'Text to speak', source: 'stdout' })];
const session = createDefaultSession({
tabs: [{ id: 'tab-1', agentSessionId: 'claude-123', logs, isUnread: false }],
activeTabId: 'tab-1',
});
const props = createDefaultProps({
session,
audioFeedbackCommand: 'say "{text}"',
});
render(<TerminalOutput {...props} />);
expect(screen.getByTitle('Speak text')).toBeInTheDocument();
});
it('does not show speak button for user messages', () => {
const logs: LogEntry[] = [createLogEntry({ text: 'User message', source: 'user' })];
const session = createDefaultSession({
tabs: [{ id: 'tab-1', agentSessionId: 'claude-123', logs, isUnread: false }],
activeTabId: 'tab-1',
});
const props = createDefaultProps({
session,
audioFeedbackCommand: 'say "{text}"',
});
render(<TerminalOutput {...props} />);
expect(screen.queryByTitle('Speak text')).not.toBeInTheDocument();
});
it('calls speak API when speak button is clicked', async () => {
const logs: LogEntry[] = [createLogEntry({ text: 'Text to speak', source: 'stdout' })];
const session = createDefaultSession({
tabs: [{ id: 'tab-1', agentSessionId: 'claude-123', logs, isUnread: false }],
activeTabId: 'tab-1',
});
const props = createDefaultProps({
session,
audioFeedbackCommand: 'say "{text}"',
});
render(<TerminalOutput {...props} />);
const speakButton = screen.getByTitle('Speak text');
await act(async () => {
fireEvent.click(speakButton);
});
expect(window.maestro.notification.speak).toHaveBeenCalledWith(
'Text to speak',
'say "{text}"'
);
});
});
describe('delete functionality', () => {
it('shows delete button for user messages when onDeleteLog is provided', () => {
const logs: LogEntry[] = [createLogEntry({ text: 'User message', source: 'user' })];

View File

@@ -3,11 +3,11 @@
*
* Handles all notification-related IPC operations:
* - Showing OS notifications
* - Text-to-speech (TTS) functionality with queueing
* - Stopping active TTS processes
* - Custom notification commands with queueing
* - Stopping active notification processes
*
* Security Note: TTS commands are validated against a whitelist to prevent
* command injection attacks from the renderer process.
* Note: Custom notification commands are user-configured and can be any command
* that accepts text via stdin. The user has full control over what command is executed.
*/
import { ipcMain, Notification, BrowserWindow } from 'electron';
@@ -20,44 +20,28 @@ import { isWebContentsAvailable } from '../../utils/safe-send';
// ==========================================================================
/**
* Minimum delay between TTS calls to prevent audio overlap.
* Minimum delay between notification command calls to prevent audio overlap.
*
* 15 seconds was chosen to:
* 1. Allow sufficient time for most TTS messages to complete naturally
* 1. Allow sufficient time for most messages to complete naturally
* 2. Prevent rapid-fire notifications from overwhelming the user
* 3. Give users time to process each audio message before the next one
* 3. Give users time to process each notification before the next one
*
* This value balances responsiveness with preventing audio chaos when
* This value balances responsiveness with preventing notification chaos when
* multiple notifications trigger in quick succession.
*/
const TTS_MIN_DELAY_MS = 15000;
const NOTIFICATION_MIN_DELAY_MS = 15000;
/**
* Maximum number of items allowed in the TTS queue.
* Prevents memory issues if TTS requests accumulate faster than they can be processed.
* Maximum number of items allowed in the notification queue.
* Prevents memory issues if requests accumulate faster than they can be processed.
*/
const TTS_MAX_QUEUE_SIZE = 10;
const NOTIFICATION_MAX_QUEUE_SIZE = 10;
/**
* Whitelist of allowed TTS commands to prevent command injection.
*
* These are common TTS commands across different platforms:
* - say: macOS built-in TTS
* - espeak: Linux TTS (common on Ubuntu/Debian)
* - espeak-ng: Modern fork of espeak
* - spd-say: Speech Dispatcher client (Linux)
* - festival: Festival TTS system (Linux)
* - flite: Lightweight TTS (Linux)
*
* SECURITY: Only the base command name is checked. Arguments are NOT allowed
* to be passed through the command parameter to prevent injection attacks.
* Default notification command (macOS TTS)
*/
const ALLOWED_TTS_COMMANDS = ['say', 'espeak', 'espeak-ng', 'spd-say', 'festival', 'flite'];
/**
* Default TTS command (macOS)
*/
const DEFAULT_TTS_COMMAND = 'say';
const DEFAULT_NOTIFICATION_COMMAND = 'say';
// ==========================================================================
// Types
@@ -72,27 +56,27 @@ export interface NotificationShowResponse {
}
/**
* Response from TTS operations
* Response from custom notification command operations
*/
export interface TtsResponse {
export interface NotificationCommandResponse {
success: boolean;
ttsId?: number;
notificationId?: number;
error?: string;
}
/**
* Item in the TTS queue
* Item in the notification command queue
*/
interface TtsQueueItem {
interface NotificationQueueItem {
text: string;
command?: string;
resolve: (result: TtsResponse) => void;
resolve: (result: NotificationCommandResponse) => void;
}
/**
* Active TTS process tracking
* Active notification command process tracking
*/
interface ActiveTtsProcess {
interface ActiveNotificationProcess {
process: ChildProcess;
command: string;
}
@@ -101,90 +85,52 @@ interface ActiveTtsProcess {
// Module State
// ==========================================================================
/** Track active TTS processes by ID for stopping */
const activeTtsProcesses = new Map<number, ActiveTtsProcess>();
/** Track active notification command processes by ID for stopping */
const activeNotificationProcesses = new Map<number, ActiveNotificationProcess>();
/** Counter for generating unique TTS process IDs */
let ttsProcessIdCounter = 0;
/** Counter for generating unique notification process IDs */
let notificationProcessIdCounter = 0;
/** Timestamp when the last TTS completed */
let lastTtsEndTime = 0;
/** Timestamp when the last notification command completed */
let lastNotificationEndTime = 0;
/** Queue of pending TTS requests */
const ttsQueue: TtsQueueItem[] = [];
/** Queue of pending notification command requests */
const notificationQueue: NotificationQueueItem[] = [];
/** Flag indicating if TTS is currently being processed */
let isTtsProcessing = false;
/** Flag indicating if notification command is currently being processed */
let isNotificationProcessing = false;
// ==========================================================================
// Helper Functions
// ==========================================================================
/**
* Validate and sanitize TTS command to prevent command injection.
* Parse the notification command configuration.
*
* SECURITY: This function is critical for preventing arbitrary command execution.
* It ensures only whitelisted commands can be executed, with no arguments allowed
* through the command parameter.
* The user can configure any command they want - this is intentional.
* The command is executed with shell: true to support pipes and command chains.
*
* @param command - The requested TTS command
* @returns Object with validated command or error
* @param command - The user-configured notification command
* @returns The command to execute (or default if empty)
*/
export function validateTtsCommand(command?: string): {
valid: boolean;
command: string;
error?: string;
} {
export function parseNotificationCommand(command?: string): string {
// Use default if no command provided
if (!command || command.trim() === '') {
return { valid: true, command: DEFAULT_TTS_COMMAND };
return DEFAULT_NOTIFICATION_COMMAND;
}
// Extract the base command (first word only, no arguments allowed)
const trimmedCommand = command.trim();
const baseCommand = trimmedCommand.split(/\s+/)[0];
// Check if the base command is in the whitelist
if (!ALLOWED_TTS_COMMANDS.includes(baseCommand)) {
logger.warn('TTS command rejected - not in whitelist', 'TTS', {
requestedCommand: baseCommand,
allowedCommands: ALLOWED_TTS_COMMANDS,
});
return {
valid: false,
command: DEFAULT_TTS_COMMAND,
error: `Invalid TTS command '${baseCommand}'. Allowed commands: ${ALLOWED_TTS_COMMANDS.join(', ')}`,
};
}
// If the command has arguments, reject it for security
if (trimmedCommand !== baseCommand) {
logger.warn('TTS command rejected - arguments not allowed', 'TTS', {
requestedCommand: trimmedCommand,
baseCommand,
});
return {
valid: false,
command: DEFAULT_TTS_COMMAND,
error: `TTS command arguments are not allowed for security reasons. Use only the command name: ${baseCommand}`,
};
}
return { valid: true, command: baseCommand };
return command.trim();
}
/**
* Execute TTS - the actual implementation
* Returns a Promise that resolves when the TTS process completes (not just when it starts)
* Execute notification command - the actual implementation
* Returns a Promise that resolves when the process completes (not just when it starts)
*/
async function executeTts(text: string, command?: string): Promise<TtsResponse> {
// Validate and sanitize the command
const validation = validateTtsCommand(command);
if (!validation.valid) {
return { success: false, error: validation.error };
}
const fullCommand = validation.command;
async function executeNotificationCommand(
text: string,
command?: string
): Promise<NotificationCommandResponse> {
const fullCommand = parseNotificationCommand(command);
const textLength = text?.length || 0;
const textPreview = text
? text.length > 200
@@ -193,7 +139,7 @@ async function executeTts(text: string, command?: string): Promise<TtsResponse>
: '(no text)';
// Log the incoming request with full details for debugging
logger.info('TTS speak request received', 'TTS', {
logger.info('Notification command request received', 'Notification', {
command: fullCommand,
textLength,
textPreview,
@@ -201,23 +147,23 @@ async function executeTts(text: string, command?: string): Promise<TtsResponse>
try {
// Log the full command being executed
logger.debug('TTS executing command', 'TTS', {
logger.debug('Notification executing command', 'Notification', {
command: fullCommand,
textLength,
});
// Spawn the TTS process WITHOUT shell mode to prevent injection
// Spawn the process with shell mode to support pipes and command chains
// The text is passed via stdin, not as command arguments
const child = spawn(fullCommand, [], {
stdio: ['pipe', 'ignore', 'pipe'], // stdin: pipe, stdout: ignore, stderr: pipe for errors
shell: false, // SECURITY: shell: false prevents command injection
shell: true, // Enable shell mode to support pipes (e.g., "cmd1 | cmd2")
});
// Generate a unique ID for this TTS process
const ttsId = ++ttsProcessIdCounter;
activeTtsProcesses.set(ttsId, { process: child, command: fullCommand });
// Generate a unique ID for this notification process
const notificationId = ++notificationProcessIdCounter;
activeNotificationProcesses.set(notificationId, { process: child, command: fullCommand });
// Return a Promise that resolves when the TTS process completes
// Return a Promise that resolves when the process completes
return new Promise((resolve) => {
let resolved = false;
let stderrOutput = '';
@@ -233,27 +179,33 @@ async function executeTts(text: string, command?: string): Promise<TtsResponse>
: undefined;
if (errorCode === 'EPIPE') {
logger.debug('TTS stdin EPIPE - process closed before write completed', 'TTS');
logger.debug(
'Notification stdin EPIPE - process closed before write completed',
'Notification'
);
} else {
logger.error('TTS stdin error', 'TTS', { error: String(err), code: errorCode });
logger.error('Notification stdin error', 'Notification', {
error: String(err),
code: errorCode,
});
}
});
logger.debug('TTS writing to stdin', 'TTS', { textLength });
logger.debug('Notification writing to stdin', 'Notification', { textLength });
child.stdin.write(text, 'utf8', (err) => {
if (err) {
logger.error('TTS stdin write error', 'TTS', { error: String(err) });
logger.error('Notification stdin write error', 'Notification', { error: String(err) });
} else {
logger.debug('TTS stdin write completed', 'TTS');
logger.debug('Notification stdin write completed', 'Notification');
}
child.stdin!.end();
});
} else {
logger.error('TTS no stdin available on child process', 'TTS');
logger.error('Notification no stdin available on child process', 'Notification');
}
child.on('error', (err) => {
logger.error('TTS spawn error', 'TTS', {
logger.error('Notification spawn error', 'Notification', {
error: String(err),
command: fullCommand,
textPreview: text
@@ -262,10 +214,10 @@ async function executeTts(text: string, command?: string): Promise<TtsResponse>
: text
: '(no text)',
});
activeTtsProcesses.delete(ttsId);
activeNotificationProcesses.delete(notificationId);
if (!resolved) {
resolved = true;
resolve({ success: false, ttsId, error: String(err) });
resolve({ success: false, notificationId, error: String(err) });
}
});
@@ -278,8 +230,8 @@ async function executeTts(text: string, command?: string): Promise<TtsResponse>
child.on('close', (code, signal) => {
// Always log close event for debugging production issues
logger.info('TTS process closed', 'TTS', {
ttsId,
logger.info('Notification process closed', 'Notification', {
notificationId,
exitCode: code,
signal,
stderr: stderrOutput || '(none)',
@@ -287,37 +239,37 @@ async function executeTts(text: string, command?: string): Promise<TtsResponse>
});
if (code !== 0 && stderrOutput) {
logger.error('TTS process error output', 'TTS', {
logger.error('Notification process error output', 'Notification', {
exitCode: code,
stderr: stderrOutput,
command: fullCommand,
});
}
activeTtsProcesses.delete(ttsId);
activeNotificationProcesses.delete(notificationId);
// Notify renderer that TTS has completed
// Notify renderer that notification command has completed
BrowserWindow.getAllWindows().forEach((win) => {
if (isWebContentsAvailable(win)) {
win.webContents.send('tts:completed', ttsId);
win.webContents.send('notification:commandCompleted', notificationId);
}
});
// Resolve the promise now that TTS has completed
// Resolve the promise now that process has completed
if (!resolved) {
resolved = true;
resolve({ success: code === 0, ttsId });
resolve({ success: code === 0, notificationId });
}
});
logger.info('TTS process spawned successfully', 'TTS', {
ttsId,
logger.info('Notification process spawned successfully', 'Notification', {
notificationId,
command: fullCommand,
textLength,
});
});
} catch (error) {
logger.error('TTS error starting audio feedback', 'TTS', {
logger.error('Notification error starting command', 'Notification', {
error: String(error),
command: fullCommand,
textPreview,
@@ -327,50 +279,50 @@ async function executeTts(text: string, command?: string): Promise<TtsResponse>
}
/**
* Process the next item in the TTS queue.
* Process the next item in the notification queue.
*
* Uses a flag-first approach to prevent race conditions:
* 1. Check and set the processing flag atomically
* 2. Then check the queue
* This ensures only one processNextTts call can proceed at a time.
* This ensures only one processNextNotification call can proceed at a time.
*/
async function processNextTts(): Promise<void> {
async function processNextNotification(): Promise<void> {
// Check queue first - if empty, nothing to do
if (ttsQueue.length === 0) return;
if (notificationQueue.length === 0) return;
// Set flag BEFORE processing to prevent race condition
// where multiple calls could pass the isTtsProcessing check simultaneously
if (isTtsProcessing) return;
isTtsProcessing = true;
// where multiple calls could pass the isNotificationProcessing check simultaneously
if (isNotificationProcessing) return;
isNotificationProcessing = true;
// Double-check queue after setting flag (another call might have emptied it)
if (ttsQueue.length === 0) {
isTtsProcessing = false;
if (notificationQueue.length === 0) {
isNotificationProcessing = false;
return;
}
const item = ttsQueue.shift()!;
const item = notificationQueue.shift()!;
// Calculate delay needed to maintain minimum gap
const now = Date.now();
const timeSinceLastTts = now - lastTtsEndTime;
const delayNeeded = Math.max(0, TTS_MIN_DELAY_MS - timeSinceLastTts);
const timeSinceLastNotification = now - lastNotificationEndTime;
const delayNeeded = Math.max(0, NOTIFICATION_MIN_DELAY_MS - timeSinceLastNotification);
if (delayNeeded > 0) {
logger.debug(`TTS queue waiting ${delayNeeded}ms before next speech`, 'TTS');
logger.debug(`Notification queue waiting ${delayNeeded}ms before next command`, 'Notification');
await new Promise((resolve) => setTimeout(resolve, delayNeeded));
}
// Execute the TTS
const result = await executeTts(item.text, item.command);
// Execute the notification command
const result = await executeNotificationCommand(item.text, item.command);
item.resolve(result);
// Record when this TTS ended
lastTtsEndTime = Date.now();
isTtsProcessing = false;
// Record when this notification ended
lastNotificationEndTime = Date.now();
isNotificationProcessing = false;
// Process next item in queue
processNextTts();
processNextNotification();
}
// ==========================================================================
@@ -406,60 +358,63 @@ export function registerNotificationsHandlers(): void {
}
);
// Audio feedback using system TTS command - queued to prevent overlap
// Custom notification command - queued to prevent overlap
ipcMain.handle(
'notification:speak',
async (_event, text: string, command?: string): Promise<TtsResponse> => {
async (_event, text: string, command?: string): Promise<NotificationCommandResponse> => {
// Check queue size limit to prevent memory issues
if (ttsQueue.length >= TTS_MAX_QUEUE_SIZE) {
logger.warn('TTS queue is full, rejecting request', 'TTS', {
queueLength: ttsQueue.length,
maxSize: TTS_MAX_QUEUE_SIZE,
if (notificationQueue.length >= NOTIFICATION_MAX_QUEUE_SIZE) {
logger.warn('Notification queue is full, rejecting request', 'Notification', {
queueLength: notificationQueue.length,
maxSize: NOTIFICATION_MAX_QUEUE_SIZE,
});
return {
success: false,
error: `TTS queue is full (max ${TTS_MAX_QUEUE_SIZE} items). Please wait for current items to complete.`,
error: `Notification queue is full (max ${NOTIFICATION_MAX_QUEUE_SIZE} items). Please wait for current items to complete.`,
};
}
// Add to queue and return a promise that resolves when this TTS completes
return new Promise<TtsResponse>((resolve) => {
ttsQueue.push({ text, command, resolve });
logger.debug(`TTS queued, queue length: ${ttsQueue.length}`, 'TTS');
processNextTts();
// Add to queue and return a promise that resolves when this notification completes
return new Promise<NotificationCommandResponse>((resolve) => {
notificationQueue.push({ text, command, resolve });
logger.debug(`Notification queued, queue length: ${notificationQueue.length}`, 'Notification');
processNextNotification();
});
}
);
// Stop a running TTS process
ipcMain.handle('notification:stopSpeak', async (_event, ttsId: number): Promise<TtsResponse> => {
logger.debug('TTS stop requested', 'TTS', { ttsId });
// Stop a running notification command process
ipcMain.handle(
'notification:stopSpeak',
async (_event, notificationId: number): Promise<NotificationCommandResponse> => {
logger.debug('Notification stop requested', 'Notification', { notificationId });
const ttsProcess = activeTtsProcesses.get(ttsId);
if (!ttsProcess) {
logger.debug('TTS no active process found', 'TTS', { ttsId });
return { success: false, error: 'No active TTS process with that ID' };
const notificationProcess = activeNotificationProcesses.get(notificationId);
if (!notificationProcess) {
logger.debug('Notification no active process found', 'Notification', { notificationId });
return { success: false, error: 'No active notification process with that ID' };
}
try {
// Kill the process and all its children
notificationProcess.process.kill('SIGTERM');
activeNotificationProcesses.delete(notificationId);
logger.info('Notification process stopped', 'Notification', {
notificationId,
command: notificationProcess.command,
});
return { success: true };
} catch (error) {
logger.error('Notification error stopping process', 'Notification', {
notificationId,
error: String(error),
});
return { success: false, error: String(error) };
}
}
try {
// Kill the process and all its children
ttsProcess.process.kill('SIGTERM');
activeTtsProcesses.delete(ttsId);
logger.info('TTS process stopped', 'TTS', {
ttsId,
command: ttsProcess.command,
});
return { success: true };
} catch (error) {
logger.error('TTS error stopping process', 'TTS', {
ttsId,
error: String(error),
});
return { success: false, error: String(error) };
}
});
);
}
// ==========================================================================
@@ -467,47 +422,47 @@ export function registerNotificationsHandlers(): void {
// ==========================================================================
/**
* Get the current TTS queue length (for testing)
* Get the current notification queue length (for testing)
*/
export function getTtsQueueLength(): number {
return ttsQueue.length;
export function getNotificationQueueLength(): number {
return notificationQueue.length;
}
/**
* Get the count of active TTS processes (for testing)
* Get the count of active notification processes (for testing)
*/
export function getActiveTtsCount(): number {
return activeTtsProcesses.size;
export function getActiveNotificationCount(): number {
return activeNotificationProcesses.size;
}
/**
* Clear the TTS queue (for testing)
* Clear the notification queue (for testing)
*/
export function clearTtsQueue(): void {
ttsQueue.length = 0;
export function clearNotificationQueue(): void {
notificationQueue.length = 0;
}
/**
* Reset TTS state (for testing)
* Reset notification state (for testing)
*/
export function resetTtsState(): void {
ttsQueue.length = 0;
activeTtsProcesses.clear();
ttsProcessIdCounter = 0;
lastTtsEndTime = 0;
isTtsProcessing = false;
export function resetNotificationState(): void {
notificationQueue.length = 0;
activeNotificationProcesses.clear();
notificationProcessIdCounter = 0;
lastNotificationEndTime = 0;
isNotificationProcessing = false;
}
/**
* Get the maximum TTS queue size (for testing)
* Get the maximum notification queue size (for testing)
*/
export function getTtsMaxQueueSize(): number {
return TTS_MAX_QUEUE_SIZE;
export function getNotificationMaxQueueSize(): number {
return NOTIFICATION_MAX_QUEUE_SIZE;
}
/**
* Get the list of allowed TTS commands (for testing)
*/
export function getAllowedTtsCommands(): string[] {
return [...ALLOWED_TTS_COMMANDS];
}
// Legacy aliases for backward compatibility with existing tests
export const getTtsQueueLength = getNotificationQueueLength;
export const getActiveTtsCount = getActiveNotificationCount;
export const clearTtsQueue = clearNotificationQueue;
export const resetTtsState = resetNotificationState;
export const getTtsMaxQueueSize = getNotificationMaxQueueSize;

View File

@@ -352,7 +352,7 @@ export type {
// From notifications
NotificationApi,
NotificationShowResponse,
TtsResponse,
NotificationCommandResponse,
} from './notifications';
export type {
// From leaderboard

View File

@@ -3,8 +3,8 @@
*
* Provides the window.maestro.notification namespace for:
* - Showing OS notifications
* - Text-to-speech (TTS) functionality
* - TTS completion events
* - Custom notification commands (e.g., TTS, logging, etc.)
* - Notification command completion events
*/
import { ipcRenderer } from 'electron';
@@ -18,11 +18,11 @@ export interface NotificationShowResponse {
}
/**
* Response from TTS operations
* Response from notification command operations
*/
export interface TtsResponse {
export interface NotificationCommandResponse {
success: boolean;
ttsId?: number;
ttsId?: number; // Legacy name kept for backward compatibility
error?: string;
}
@@ -40,29 +40,30 @@ export function createNotificationApi() {
ipcRenderer.invoke('notification:show', title, body),
/**
* Speak text using system TTS
* @param text - Text to speak
* @param command - Optional TTS command (default: 'say' on macOS)
* Execute a custom notification command (e.g., TTS, logging)
* @param text - Text to pass to the command via stdin
* @param command - Command to execute (default: 'say' on macOS)
*/
speak: (text: string, command?: string): Promise<TtsResponse> =>
speak: (text: string, command?: string): Promise<NotificationCommandResponse> =>
ipcRenderer.invoke('notification:speak', text, command),
/**
* Stop a running TTS process
* @param ttsId - ID of the TTS process to stop
* Stop a running notification command process
* @param notificationId - ID of the notification process to stop
*/
stopSpeak: (ttsId: number): Promise<TtsResponse> =>
ipcRenderer.invoke('notification:stopSpeak', ttsId),
stopSpeak: (notificationId: number): Promise<NotificationCommandResponse> =>
ipcRenderer.invoke('notification:stopSpeak', notificationId),
/**
* Subscribe to TTS completion events
* @param handler - Callback when a TTS process completes
* Subscribe to notification command completion events
* @param handler - Callback when a notification command completes
* @returns Cleanup function to unsubscribe
*/
onTtsCompleted: (handler: (ttsId: number) => void): (() => void) => {
const wrappedHandler = (_event: Electron.IpcRendererEvent, ttsId: number) => handler(ttsId);
ipcRenderer.on('tts:completed', wrappedHandler);
return () => ipcRenderer.removeListener('tts:completed', wrappedHandler);
onTtsCompleted: (handler: (notificationId: number) => void): (() => void) => {
const wrappedHandler = (_event: Electron.IpcRendererEvent, notificationId: number) =>
handler(notificationId);
ipcRenderer.on('notification:commandCompleted', wrappedHandler);
return () => ipcRenderer.removeListener('notification:commandCompleted', wrappedHandler);
},
};
}

View File

@@ -12159,9 +12159,6 @@ You are taking over this conversation. Based on the context above, provide a bri
// Unread filter
showUnreadOnly,
// Audio feedback
audioFeedbackCommand,
// Setters
setLogViewerSelectedLevels,
setGitDiffPreview,

View File

@@ -182,9 +182,6 @@ interface MainPanelProps {
onStopBatchRun?: (sessionId?: string) => void;
showConfirmation?: (message: string, onConfirm: () => void) => void;
// TTS settings
audioFeedbackCommand?: string;
// Tab management for AI sessions
onTabSelect?: (tabId: string) => void;
onTabClose?: (tabId: string) => void;
@@ -1641,7 +1638,6 @@ export const MainPanel = React.memo(
onDeleteLog={props.onDeleteLog}
onRemoveQueuedItem={onRemoveQueuedItem}
onInterrupt={handleInterrupt}
audioFeedbackCommand={props.audioFeedbackCommand}
onScrollPositionChange={props.onScrollPositionChange}
onAtBottomChange={props.onAtBottomChange}
initialScrollTop={

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Bell, Volume2, Clock, Square } from 'lucide-react';
import React, { useState, useEffect } from 'react';
import { Bell, Volume2, Clock, Square, Check, AlertCircle, Loader2 } from 'lucide-react';
import type { Theme } from '../types';
import { SettingCheckbox } from './SettingCheckbox';
import { ToggleButtonGroup } from './ToggleButtonGroup';
@@ -16,6 +16,8 @@ interface NotificationsPanelProps {
theme: Theme;
}
type TestStatus = 'idle' | 'running' | 'success' | 'error';
export function NotificationsPanel({
osNotificationsEnabled,
setOsNotificationsEnabled,
@@ -27,8 +29,21 @@ export function NotificationsPanel({
setToastDuration,
theme,
}: NotificationsPanelProps) {
// TTS test state
const [testTtsId, setTestTtsId] = useState<number | null>(null);
// Notification command test state
const [testNotificationId, setTestNotificationId] = useState<number | null>(null);
const [testStatus, setTestStatus] = useState<TestStatus>('idle');
const [testError, setTestError] = useState<string | null>(null);
// Clear success/error status after a delay
useEffect(() => {
if (testStatus === 'success' || testStatus === 'error') {
const timer = setTimeout(() => {
setTestStatus('idle');
setTestError(null);
}, 3000);
return () => clearTimeout(timer);
}
}, [testStatus]);
return (
<div className="space-y-6">
@@ -85,16 +100,20 @@ export function NotificationsPanel({
className="flex-1 p-2 rounded border bg-transparent outline-none text-sm font-mono"
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
/>
{testTtsId !== null ? (
{testNotificationId !== null ? (
<button
onClick={async () => {
console.log('[TTS] Stop test button clicked, ttsId:', testTtsId);
console.log(
'[Notification] Stop test button clicked, id:',
testNotificationId
);
try {
await window.maestro.notification.stopSpeak(testTtsId);
await window.maestro.notification.stopSpeak(testNotificationId);
} catch (err) {
console.error('[TTS] Stop error:', err);
console.error('[Notification] Stop error:', err);
}
setTestTtsId(null);
setTestNotificationId(null);
setTestStatus('idle');
}}
className="px-3 py-2 rounded text-xs font-medium transition-all flex items-center gap-1"
style={{
@@ -109,33 +128,88 @@ export function NotificationsPanel({
) : (
<button
onClick={async () => {
console.log('[TTS] Test button clicked, command:', audioFeedbackCommand);
console.log('[Notification] Test button clicked, command:', audioFeedbackCommand);
setTestStatus('running');
setTestError(null);
try {
const result = await window.maestro.notification.speak(
"Howdy, I'm Maestro, here to conduct your agentic tools into a well-tuned symphony.",
audioFeedbackCommand
);
console.log('[TTS] Speak result:', result);
console.log('[Notification] Speak result:', result);
if (result.success && result.ttsId) {
setTestTtsId(result.ttsId);
setTestNotificationId(result.ttsId);
setTestStatus('success');
// Auto-clear after the message should be done (about 5 seconds for this phrase)
setTimeout(() => setTestTtsId(null), 8000);
setTimeout(() => setTestNotificationId(null), 8000);
} else {
setTestStatus('error');
setTestError(result.error || 'Command failed');
}
} catch (err) {
console.error('[TTS] Speak error:', err);
console.error('[Notification] Speak error:', err);
setTestStatus('error');
setTestError(String(err));
}
}}
className="px-3 py-2 rounded text-xs font-medium transition-all"
disabled={testStatus === 'running'}
className="px-3 py-2 rounded text-xs font-medium transition-all flex items-center gap-1.5 min-w-[70px] justify-center"
style={{
backgroundColor: theme.colors.bgActivity,
color: theme.colors.textMain,
border: `1px solid ${theme.colors.border}`,
backgroundColor:
testStatus === 'success'
? theme.colors.success + '20'
: testStatus === 'error'
? theme.colors.error + '20'
: theme.colors.bgActivity,
color:
testStatus === 'success'
? theme.colors.success
: testStatus === 'error'
? theme.colors.error
: theme.colors.textMain,
border: `1px solid ${
testStatus === 'success'
? theme.colors.success
: testStatus === 'error'
? theme.colors.error
: theme.colors.border
}`,
opacity: testStatus === 'running' ? 0.7 : 1,
}}
>
Test
{testStatus === 'running' ? (
<>
<Loader2 className="w-3 h-3 animate-spin" />
Running
</>
) : testStatus === 'success' ? (
<>
<Check className="w-3 h-3" />
Success
</>
) : testStatus === 'error' ? (
<>
<AlertCircle className="w-3 h-3" />
Failed
</>
) : (
'Test'
)}
</button>
)}
</div>
{/* Error message display */}
{testError && (
<p
className="text-xs mt-2 px-2 py-1 rounded"
style={{
color: theme.colors.error,
backgroundColor: theme.colors.error + '10',
}}
>
{testError}
</p>
)}
<p className="text-xs opacity-50 mt-2" style={{ color: theme.colors.textDim }}>
Command that accepts text via stdin. Chain multiple commands using pipes (e.g.,{' '}
<code

View File

@@ -4,8 +4,6 @@ import {
ChevronUp,
Trash2,
Copy,
Volume2,
Square,
Check,
ArrowDown,
Eye,
@@ -77,10 +75,6 @@ interface LogItemProps {
source?: 'staged' | 'history'
) => void;
copyToClipboard: (text: string) => void;
speakText?: (text: string, logId: string) => void;
stopSpeaking?: () => void;
speakingLogId: string | null;
audioFeedbackCommand?: string;
// ANSI converter
ansiConverter: Convert;
// Markdown rendering mode for AI responses (when true, shows raw text)
@@ -125,10 +119,6 @@ const LogItemComponent = memo(
scrollContainerRef,
setLightboxImage,
copyToClipboard,
speakText,
stopSpeaking,
speakingLogId,
audioFeedbackCommand,
ansiConverter,
markdownEditMode,
onToggleMarkdownEditMode,
@@ -780,28 +770,6 @@ const LogItemComponent = memo(
{markdownEditMode ? <Eye className="w-4 h-4" /> : <FileText className="w-4 h-4" />}
</button>
)}
{/* Speak/Stop Button - only show for non-user messages when TTS is configured */}
{audioFeedbackCommand &&
log.source !== 'user' &&
(speakingLogId === log.id ? (
<button
onClick={stopSpeaking}
className="p-1.5 rounded opacity-100"
style={{ color: theme.colors.error }}
title="Stop speaking"
>
<Square className="w-3.5 h-3.5" fill="currentColor" />
</button>
) : (
<button
onClick={() => speakText?.(log.text, log.id)}
className="p-1.5 rounded opacity-0 group-hover:opacity-50 hover:!opacity-100"
style={{ color: theme.colors.textDim }}
title="Speak text"
>
<Volume2 className="w-3.5 h-3.5" />
</button>
))}
{/* Replay button for user messages in AI mode */}
{isUserMessage && isAIMode && onReplayMessage && (
<button
@@ -913,7 +881,6 @@ const LogItemComponent = memo(
prevProps.filterMode.regex === nextProps.filterMode.regex &&
prevProps.activeLocalFilter === nextProps.activeLocalFilter &&
prevProps.deleteConfirmLogId === nextProps.deleteConfirmLogId &&
prevProps.speakingLogId === nextProps.speakingLogId &&
prevProps.outputSearchQuery === nextProps.outputSearchQuery &&
prevProps.theme === nextProps.theme &&
prevProps.maxOutputLines === nextProps.maxOutputLines &&
@@ -986,7 +953,6 @@ interface TerminalOutputProps {
onDeleteLog?: (logId: string) => number | null; // Returns the index to scroll to after deletion
onRemoveQueuedItem?: (itemId: string) => void; // Callback to remove a queued item from execution queue
onInterrupt?: () => void; // Callback to interrupt the current process
audioFeedbackCommand?: string; // TTS command for speech synthesis
onScrollPositionChange?: (scrollTop: number) => void; // Callback to save scroll position
onAtBottomChange?: (isAtBottom: boolean) => void; // Callback when user scrolls to/away from bottom
initialScrollTop?: number; // Initial scroll position to restore
@@ -1022,7 +988,6 @@ export const TerminalOutput = memo(
onDeleteLog,
onRemoveQueuedItem,
onInterrupt: _onInterrupt,
audioFeedbackCommand,
onScrollPositionChange,
onAtBottomChange,
initialScrollTop,
@@ -1082,10 +1047,6 @@ export const TerminalOutput = memo(
// Save markdown modal state
const [saveModalContent, setSaveModalContent] = useState<string | null>(null);
// TTS state - track which log is currently speaking and its TTS ID
const [speakingLogId, setSpeakingLogId] = useState<string | null>(null);
const [activeTtsId, setActiveTtsId] = useState<number | null>(null);
// New message indicator state
const [isAtBottom, setIsAtBottom] = useState(true);
const [hasNewMessages, setHasNewMessages] = useState(false);
@@ -1121,72 +1082,6 @@ export const TerminalOutput = memo(
setSaveModalContent(text);
}, []);
// Speak text using TTS command
const speakText = useCallback(
async (text: string, logId: string) => {
console.log(
'[TTS] speakText called, text length:',
text.length,
'command:',
audioFeedbackCommand,
'logId:',
logId
);
if (!audioFeedbackCommand) {
console.log('[TTS] No audioFeedbackCommand configured, skipping');
return;
}
try {
// Set the speaking state before starting
setSpeakingLogId(logId);
const result = await window.maestro.notification.speak(text, audioFeedbackCommand);
console.log('[TTS] Speak result:', result);
if (result.success && result.ttsId) {
setActiveTtsId(result.ttsId);
} else {
// If speak failed, clear the speaking state
setSpeakingLogId(null);
}
} catch (err) {
console.error('[TTS] Failed to speak text:', err);
setSpeakingLogId(null);
}
},
[audioFeedbackCommand]
);
// Stop the currently speaking TTS
const stopSpeaking = useCallback(async () => {
console.log('[TTS] stopSpeaking called, activeTtsId:', activeTtsId);
if (activeTtsId === null) {
console.log('[TTS] No active TTS to stop');
setSpeakingLogId(null);
return;
}
try {
const result = await window.maestro.notification.stopSpeak(activeTtsId);
console.log('[TTS] Stop result:', result);
} catch (err) {
console.error('[TTS] Failed to stop speaking:', err);
}
// Always clear state after stopping
setSpeakingLogId(null);
setActiveTtsId(null);
}, [activeTtsId]);
// Listen for TTS completion events from main process
useEffect(() => {
const cleanup = window.maestro.notification.onTtsCompleted((completedTtsId: number) => {
console.log('[TTS] TTS completed event received for ID:', completedTtsId);
// Only clear if this is the currently active TTS
if (completedTtsId === activeTtsId) {
setSpeakingLogId(null);
setActiveTtsId(null);
}
});
return cleanup;
}, [activeTtsId]);
// Layer stack integration for search overlay
const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack();
const layerIdRef = useRef<string>();
@@ -1710,10 +1605,6 @@ export const TerminalOutput = memo(
scrollContainerRef={scrollContainerRef}
setLightboxImage={setLightboxImage}
copyToClipboard={copyToClipboard}
speakText={speakText}
stopSpeaking={stopSpeaking}
speakingLogId={speakingLogId}
audioFeedbackCommand={audioFeedbackCommand}
ansiConverter={ansiConverter}
markdownEditMode={markdownEditMode}
onToggleMarkdownEditMode={toggleMarkdownEditMode}

View File

@@ -133,9 +133,6 @@ export interface UseMainPanelPropsDeps {
// Unread filter
showUnreadOnly: boolean;
// Audio feedback
audioFeedbackCommand: string;
// Setters (these are stable callbacks - should be memoized at definition site)
setLogViewerSelectedLevels: (levels: string[]) => void;
setGitDiffPreview: (preview: string | null) => void;
@@ -384,7 +381,6 @@ export function useMainPanelProps(deps: UseMainPanelPropsDeps) {
onDeleteLog: deps.handleDeleteLog,
onRemoveQueuedItem: deps.handleRemoveQueuedItem,
onOpenQueueBrowser: deps.handleOpenQueueBrowser,
audioFeedbackCommand: deps.audioFeedbackCommand,
// Tab management handlers
onTabSelect: deps.handleTabSelect,
onTabClose: deps.handleTabClose,
@@ -563,7 +559,6 @@ export function useMainPanelProps(deps: UseMainPanelPropsDeps) {
deps.ghCliAvailable,
deps.hasGist,
deps.showUnreadOnly,
deps.audioFeedbackCommand,
// Stable callbacks (shouldn't cause re-renders, but included for completeness)
deps.setLogViewerSelectedLevels,
deps.setGitDiffPreview,