diff --git a/src/__tests__/renderer/components/ForcedParallelWarningModal.test.tsx b/src/__tests__/renderer/components/ForcedParallelWarningModal.test.tsx new file mode 100644 index 000000000..35f1bd7df --- /dev/null +++ b/src/__tests__/renderer/components/ForcedParallelWarningModal.test.tsx @@ -0,0 +1,215 @@ +/** + * Tests for ForcedParallelWarningModal component + * + * Tests the one-time acknowledgment modal for forced parallel execution: + * - Rendering when open/closed + * - Confirm and Cancel button handlers + * - Layer stack integration + * - Warning content display + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { ForcedParallelWarningModal } from '../../../renderer/components/ForcedParallelWarningModal'; +import { LayerStackProvider } from '../../../renderer/contexts/LayerStackContext'; +import type { Theme } from '../../../renderer/types'; + +// Mock lucide-react +vi.mock('lucide-react', () => ({ + X: () => , + AlertTriangle: () => , +})); + +const testTheme: Theme = { + id: 'test-theme', + name: 'Test Theme', + mode: 'dark', + colors: { + bgMain: '#1e1e1e', + bgSidebar: '#252526', + bgActivity: '#333333', + textMain: '#d4d4d4', + textDim: '#808080', + accent: '#007acc', + border: '#404040', + error: '#f14c4c', + warning: '#cca700', + success: '#89d185', + info: '#3794ff', + textInverse: '#000000', + }, +}; + +const renderWithLayerStack = (ui: React.ReactElement) => { + return render({ui}); +}; + +describe('ForcedParallelWarningModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('rendering', () => { + it('renders when isOpen is true', () => { + renderWithLayerStack( + + ); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'Forced Parallel Execution' }) + ).toBeInTheDocument(); + }); + + it('does not render when isOpen is false', () => { + renderWithLayerStack( + + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('displays warning content', () => { + renderWithLayerStack( + + ); + + expect( + screen.getByText(/sends messages immediately, even when the agent is already working/i) + ).toBeInTheDocument(); + expect( + screen.getByText(/intended for advanced users who understand the risks/i) + ).toBeInTheDocument(); + }); + + it('displays alert triangle icon', () => { + renderWithLayerStack( + + ); + + expect(screen.getByTestId('alert-triangle-icon')).toBeInTheDocument(); + }); + + it('displays confirm and cancel buttons', () => { + renderWithLayerStack( + + ); + + expect(screen.getByRole('button', { name: 'I understand, enable it' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + }); + + describe('button handlers', () => { + it('calls onConfirm when confirm button is clicked', () => { + const onConfirm = vi.fn(); + renderWithLayerStack( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'I understand, enable it' })); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('calls onCancel when cancel button is clicked', () => { + const onCancel = vi.fn(); + renderWithLayerStack( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('calls onCancel when X close button is clicked', () => { + const onCancel = vi.fn(); + renderWithLayerStack( + + ); + + const closeButton = screen.getByTestId('x-icon').closest('button'); + fireEvent.click(closeButton!); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + }); + + describe('focus management', () => { + it('focuses confirm button on mount', async () => { + renderWithLayerStack( + + ); + + await waitFor(() => { + expect(document.activeElement).toBe( + screen.getByRole('button', { name: 'I understand, enable it' }) + ); + }); + }); + }); + + describe('layer stack integration', () => { + it('registers and unregisters without errors', () => { + const { unmount } = renderWithLayerStack( + + ); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(() => unmount()).not.toThrow(); + }); + }); +}); diff --git a/src/__tests__/renderer/hooks/useInputKeyDown.test.ts b/src/__tests__/renderer/hooks/useInputKeyDown.test.ts index 4283d4dc0..052cb8de5 100644 --- a/src/__tests__/renderer/hooks/useInputKeyDown.test.ts +++ b/src/__tests__/renderer/hooks/useInputKeyDown.test.ts @@ -892,6 +892,143 @@ describe('Tab completion trigger', () => { }); }); +// ============================================================================ +// Forced parallel send shortcut +// ============================================================================ + +describe('Forced parallel send shortcut', () => { + it('Cmd+Shift+Enter calls processInput with forceParallel in AI mode', () => { + setActiveSession({ inputMode: 'ai' }); + useSettingsStore.setState({ + forcedParallelExecution: true, + shortcuts: { + ...useSettingsStore.getState().shortcuts, + forcedParallelSend: { + id: 'forcedParallelSend', + label: 'Forced Parallel Send', + keys: ['Meta', 'Shift', 'Enter'], + }, + }, + } as any); + const deps = createMockDeps(); + const { result } = renderHook(() => useInputKeyDown(deps)); + const e = createKeyEvent('Enter', { metaKey: true, shiftKey: true }); + + act(() => { + result.current.handleInputKeyDown(e); + }); + + expect(e.preventDefault).toHaveBeenCalled(); + expect(deps.processInput).toHaveBeenCalledWith(undefined, { forceParallel: true }); + }); + + it('Ctrl+Shift+Enter calls processInput with forceParallel in AI mode', () => { + setActiveSession({ inputMode: 'ai' }); + useSettingsStore.setState({ + forcedParallelExecution: true, + shortcuts: { + ...useSettingsStore.getState().shortcuts, + forcedParallelSend: { + id: 'forcedParallelSend', + label: 'Forced Parallel Send', + keys: ['Meta', 'Shift', 'Enter'], + }, + }, + } as any); + const deps = createMockDeps(); + const { result } = renderHook(() => useInputKeyDown(deps)); + const e = createKeyEvent('Enter', { ctrlKey: true, shiftKey: true }); + + act(() => { + result.current.handleInputKeyDown(e); + }); + + expect(e.preventDefault).toHaveBeenCalled(); + expect(deps.processInput).toHaveBeenCalledWith(undefined, { forceParallel: true }); + }); + + it('does NOT trigger forced parallel in terminal mode', () => { + setActiveSession({ inputMode: 'terminal' }); + useSettingsStore.setState({ + forcedParallelExecution: true, + shortcuts: { + ...useSettingsStore.getState().shortcuts, + forcedParallelSend: { + id: 'forcedParallelSend', + label: 'Forced Parallel Send', + keys: ['Meta', 'Shift', 'Enter'], + }, + }, + } as any); + const deps = createMockDeps(); + const { result } = renderHook(() => useInputKeyDown(deps)); + const e = createKeyEvent('Enter', { metaKey: true, shiftKey: true }); + + act(() => { + result.current.handleInputKeyDown(e); + }); + + // Should NOT call processInput at all in terminal mode + expect(deps.processInput).not.toHaveBeenCalled(); + }); + + it('does NOT trigger forced parallel when feature is disabled', () => { + setActiveSession({ inputMode: 'ai' }); + useSettingsStore.setState({ + forcedParallelExecution: false, + shortcuts: { + ...useSettingsStore.getState().shortcuts, + forcedParallelSend: { + id: 'forcedParallelSend', + label: 'Forced Parallel Send', + keys: ['Meta', 'Shift', 'Enter'], + }, + }, + } as any); + const deps = createMockDeps(); + const { result } = renderHook(() => useInputKeyDown(deps)); + const e = createKeyEvent('Enter', { metaKey: true, shiftKey: true }); + + act(() => { + result.current.handleInputKeyDown(e); + }); + + // Should NOT call processInput with forceParallel when feature is disabled + expect(deps.processInput).not.toHaveBeenCalledWith(undefined, { forceParallel: true }); + }); + + it('respects custom shortcut configuration', () => { + setActiveSession({ inputMode: 'ai' }); + useSettingsStore.setState({ + forcedParallelExecution: true, + shortcuts: { + ...useSettingsStore.getState().shortcuts, + forcedParallelSend: { + id: 'forcedParallelSend', + label: 'Forced Parallel Send', + keys: ['Alt', 'Enter'], + }, + }, + } as any); + const deps = createMockDeps(); + const { result } = renderHook(() => useInputKeyDown(deps)); + + // Default shortcut (Meta+Shift+Enter) should NOT trigger + const e1 = createKeyEvent('Enter', { metaKey: true, shiftKey: true }); + act(() => { + result.current.handleInputKeyDown(e1); + }); + expect(deps.processInput).not.toHaveBeenCalledWith(undefined, { forceParallel: true }); + + // Custom shortcut (Alt+Enter) SHOULD trigger + const e2 = createKeyEvent('Enter', { altKey: true }); + act(() => { + result.current.handleInputKeyDown(e2); + }); + expect(deps.processInput).toHaveBeenCalledWith(undefined, { forceParallel: true }); + }); +}); + // ============================================================================ // Edge cases // ============================================================================ diff --git a/src/__tests__/renderer/hooks/useInputProcessing.test.ts b/src/__tests__/renderer/hooks/useInputProcessing.test.ts index 670e96f9d..09beec564 100644 --- a/src/__tests__/renderer/hooks/useInputProcessing.test.ts +++ b/src/__tests__/renderer/hooks/useInputProcessing.test.ts @@ -17,6 +17,7 @@ vi.mock('../../../renderer/hooks/agent/useAgentCapabilities', async () => { }); import { useInputProcessing } from '../../../renderer/hooks/input/useInputProcessing'; +import { useSettingsStore } from '../../../renderer/stores/settingsStore'; import type { Session, AITab, @@ -876,6 +877,108 @@ describe('useInputProcessing', () => { }); }); + describe('forced parallel execution', () => { + it('bypasses queue when forceParallel is true and setting is enabled', async () => { + useSettingsStore.setState({ forcedParallelExecution: true } as any); + + const busySession = createMockSession({ + state: 'busy', + aiTabs: [createMockTab({ state: 'busy' })], + }); + const deps = createDeps({ + activeSession: busySession, + sessionsRef: { current: [busySession] }, + inputValue: 'forced message', + }); + const { result } = renderHook(() => useInputProcessing(deps)); + + await act(async () => { + await result.current.processInput(undefined, { forceParallel: true }); + }); + + // Should NOT queue — should process immediately (spawn called) + expect(window.maestro.process.spawn).toHaveBeenCalled(); + }); + + it('bypasses queue when forceParallel is true and AutoRun is active', async () => { + useSettingsStore.setState({ forcedParallelExecution: true } as any); + + const runningBatchState: BatchRunState = { + ...defaultBatchState, + isRunning: true, + }; + mockGetBatchState.mockReturnValue(runningBatchState); + + const session = createMockSession({ state: 'busy' }); + const deps = createDeps({ + activeSession: session, + sessionsRef: { current: [session] }, + inputValue: 'forced during autorun', + activeBatchRunState: runningBatchState, + }); + const { result } = renderHook(() => useInputProcessing(deps)); + + await act(async () => { + await result.current.processInput(undefined, { forceParallel: true }); + }); + + // Should process immediately, not queue + expect(window.maestro.process.spawn).toHaveBeenCalled(); + }); + + it('still queues when forceParallel is true but setting is disabled', async () => { + useSettingsStore.setState({ forcedParallelExecution: false } as any); + + const busySession = createMockSession({ + state: 'busy', + aiTabs: [createMockTab({ state: 'busy' })], + }); + const deps = createDeps({ + activeSession: busySession, + inputValue: 'should be queued', + }); + const { result } = renderHook(() => useInputProcessing(deps)); + + await act(async () => { + await result.current.processInput(undefined, { forceParallel: true }); + }); + + // Should add to execution queue because setting is off + expect(mockSetSessions).toHaveBeenCalled(); + const setSessionsCall = mockSetSessions.mock.calls[0][0]; + const updatedSessions = setSessionsCall([busySession]); + expect(updatedSessions[0].executionQueue.length).toBe(1); + }); + + it('queues normally when forceParallel is absent and session is busy', async () => { + useSettingsStore.setState({ forcedParallelExecution: true } as any); + + const busySession = createMockSession({ + state: 'busy', + aiTabs: [createMockTab({ state: 'busy' })], + }); + const deps = createDeps({ + activeSession: busySession, + inputValue: 'regular message', + }); + const { result } = renderHook(() => useInputProcessing(deps)); + + await act(async () => { + await result.current.processInput(); // No forceParallel option + }); + + // Should queue normally + expect(mockSetSessions).toHaveBeenCalled(); + const setSessionsCall = mockSetSessions.mock.calls[0][0]; + const updatedSessions = setSessionsCall([busySession]); + expect(updatedSessions[0].executionQueue.length).toBe(1); + }); + + afterEach(() => { + useSettingsStore.setState({ forcedParallelExecution: false } as any); + }); + }); + describe('flushBatchedUpdates', () => { it('calls flushBatchedUpdates before processing', async () => { const deps = createDeps({ inputValue: 'test message' }); diff --git a/src/__tests__/renderer/stores/settingsStore.test.ts b/src/__tests__/renderer/stores/settingsStore.test.ts index 095052d06..da9b654db 100644 --- a/src/__tests__/renderer/stores/settingsStore.test.ts +++ b/src/__tests__/renderer/stores/settingsStore.test.ts @@ -94,6 +94,8 @@ function resetStore() { directorNotesSettings: { provider: 'claude-code', defaultLookbackDays: 7 }, wakatimeApiKey: '', wakatimeEnabled: false, + forcedParallelExecution: false, + forcedParallelAcknowledged: false, }); } @@ -120,7 +122,7 @@ describe('settingsStore', () => { // ======================================================================== describe('initial state', () => { - it('has correct default values for all 66 fields', () => { + it('has correct default values for all 68 fields', () => { const state = useSettingsStore.getState(); expect(state.settingsLoaded).toBe(false); @@ -196,6 +198,8 @@ describe('settingsStore', () => { }); expect(state.wakatimeApiKey).toBe(''); expect(state.wakatimeEnabled).toBe(false); + expect(state.forcedParallelExecution).toBe(false); + expect(state.forcedParallelAcknowledged).toBe(false); }); }); @@ -619,6 +623,31 @@ describe('settingsStore', () => { expect(window.maestro.settings.set).toHaveBeenCalledWith('wakatimeEnabled', true); }); }); + + describe('Forced Parallel Execution', () => { + it('setForcedParallelExecution updates state and persists', () => { + useSettingsStore.getState().setForcedParallelExecution(true); + expect(useSettingsStore.getState().forcedParallelExecution).toBe(true); + expect(window.maestro.settings.set).toHaveBeenCalledWith('forcedParallelExecution', true); + }); + + it('setForcedParallelAcknowledged updates state and persists', () => { + useSettingsStore.getState().setForcedParallelAcknowledged(true); + expect(useSettingsStore.getState().forcedParallelAcknowledged).toBe(true); + expect(window.maestro.settings.set).toHaveBeenCalledWith( + 'forcedParallelAcknowledged', + true + ); + }); + + it('forcedParallelExecution defaults to false', () => { + expect(useSettingsStore.getState().forcedParallelExecution).toBe(false); + }); + + it('forcedParallelAcknowledged defaults to false', () => { + expect(useSettingsStore.getState().forcedParallelAcknowledged).toBe(false); + }); + }); }); // ======================================================================== diff --git a/src/renderer/components/ForcedParallelWarningModal.tsx b/src/renderer/components/ForcedParallelWarningModal.tsx new file mode 100644 index 000000000..96f736f26 --- /dev/null +++ b/src/renderer/components/ForcedParallelWarningModal.tsx @@ -0,0 +1,63 @@ +import { useRef } from 'react'; +import { AlertTriangle } from 'lucide-react'; +import type { Theme } from '../types'; +import { MODAL_PRIORITIES } from '../constants/modalPriorities'; +import { Modal, ModalFooter } from './ui/Modal'; + +interface ForcedParallelWarningModalProps { + isOpen: boolean; + onConfirm: () => void; + onCancel: () => void; + theme: Theme; +} + +export function ForcedParallelWarningModal({ + isOpen, + onConfirm, + onCancel, + theme, +}: ForcedParallelWarningModalProps) { + const confirmButtonRef = useRef(null); + + if (!isOpen) return null; + + return ( + + } + > +
+
+ +
+
+

+ This sends messages immediately, even when the agent is already working. If two + operations modify the same files simultaneously, one may overwrite the other's changes. +

+

+ This is intended for advanced users who understand the risks. Use the assigned shortcut + key to force-send while the agent is busy. Regular send keys will continue to queue + normally. +

+
+
+
+ ); +} diff --git a/src/renderer/components/Settings/tabs/GeneralTab.tsx b/src/renderer/components/Settings/tabs/GeneralTab.tsx index 01cdcfddd..5cfa4a1cf 100644 --- a/src/renderer/components/Settings/tabs/GeneralTab.tsx +++ b/src/renderer/components/Settings/tabs/GeneralTab.tsx @@ -6,7 +6,7 @@ * Updates, Pre-release, Privacy, Storage Location. */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { X, Check, @@ -29,10 +29,17 @@ import { ArrowDownToLine, ExternalLink, Keyboard, + Trash2, + AlertTriangle, } from 'lucide-react'; import { useSettings } from '../../../hooks'; import type { Theme, ShellInfo } from '../../../types'; -import { formatMetaKey, formatEnterToSend } from '../../../utils/shortcutFormatter'; +import { + formatMetaKey, + formatEnterToSend, + formatShortcutKeys, +} from '../../../utils/shortcutFormatter'; +import { ForcedParallelWarningModal } from '../../ForcedParallelWarningModal'; import { getOpenInLabel, isLinuxPlatform } from '../../../utils/platformUtils'; import { ToggleButtonGroup } from '../../ToggleButtonGroup'; import { SettingCheckbox } from '../../SettingCheckbox'; @@ -88,6 +95,25 @@ export function GeneralTab({ theme, isOpen }: GeneralTabProps) { setEnableBetaUpdates, crashReportingEnabled, setCrashReportingEnabled, + // Stats + statsCollectionEnabled, + setStatsCollectionEnabled, + defaultStatsTimeRange, + setDefaultStatsTimeRange, + // WakaTime + wakatimeEnabled, + setWakatimeEnabled, + wakatimeApiKey, + setWakatimeApiKey, + wakatimeDetailedTracking, + setWakatimeDetailedTracking, + // Forced Parallel Execution + forcedParallelExecution, + setForcedParallelExecution, + forcedParallelAcknowledged, + setForcedParallelAcknowledged, + // Shortcuts + shortcuts, } = useSettings(); // Shell state @@ -105,7 +131,106 @@ export function GeneralTab({ theme, isOpen }: GeneralTabProps) { const [syncError, setSyncError] = useState(null); const [syncMigratedCount, setSyncMigratedCount] = useState(null); - // Load sync settings when modal opens + // Stats data management state + const [statsDbSize, setStatsDbSize] = useState(null); + const [statsEarliestDate, setStatsEarliestDate] = useState(null); + const [statsClearing, setStatsClearing] = useState(false); + const [statsClearResult, setStatsClearResult] = useState<{ + success: boolean; + deletedQueryEvents: number; + deletedAutoRunSessions: number; + deletedAutoRunTasks: number; + error?: string; + } | null>(null); + + // WakaTime CLI check and API key validation state + const [wakatimeCliStatus, setWakatimeCliStatus] = useState<{ + available: boolean; + version?: string; + } | null>(null); + const [wakatimeKeyValid, setWakatimeKeyValid] = useState(null); + const [wakatimeKeyValidating, setWakatimeKeyValidating] = useState(false); + const handleWakatimeApiKeyChange = useCallback( + (value: string) => { + setWakatimeApiKey(value); + setWakatimeKeyValid(null); + }, + [setWakatimeApiKey] + ); + + // Forced Parallel Execution modal state + const [showForcedParallelWarning, setShowForcedParallelWarning] = useState(false); + + const handleForcedParallelToggle = useCallback(() => { + if (!forcedParallelExecution && !forcedParallelAcknowledged) { + // First time enabling — show warning modal + setShowForcedParallelWarning(true); + } else { + // Already acknowledged or turning off + setForcedParallelExecution(!forcedParallelExecution); + } + }, [forcedParallelExecution, forcedParallelAcknowledged, setForcedParallelExecution]); + + const handleForcedParallelConfirm = useCallback(() => { + setForcedParallelAcknowledged(true); + setForcedParallelExecution(true); + setShowForcedParallelWarning(false); + }, [setForcedParallelAcknowledged, setForcedParallelExecution]); + + const handleForcedParallelCancel = useCallback(() => { + setShowForcedParallelWarning(false); + }, []); + + // Check WakaTime CLI availability when section renders or toggle is enabled + useEffect(() => { + if (!isOpen || !wakatimeEnabled) return; + let cancelled = false; + let retryTimer: ReturnType | null = null; + + window.maestro.wakatime + .checkCli() + .then((status) => { + if (cancelled) return; + setWakatimeCliStatus(status); + if (!status.available) { + retryTimer = setTimeout(() => { + if (!cancelled) { + window.maestro.wakatime + .checkCli() + .then((retryStatus) => { + if (!cancelled) setWakatimeCliStatus(retryStatus); + }) + .catch(() => { + if (!cancelled) setWakatimeCliStatus({ available: false }); + }); + } + }, 3000); + } + }) + .catch(() => { + if (cancelled) return; + setWakatimeCliStatus({ available: false }); + retryTimer = setTimeout(() => { + if (!cancelled) { + window.maestro.wakatime + .checkCli() + .then((retryStatus) => { + if (!cancelled) setWakatimeCliStatus(retryStatus); + }) + .catch(() => { + if (!cancelled) setWakatimeCliStatus({ available: false }); + }); + } + }, 3000); + }); + + return () => { + cancelled = true; + if (retryTimer) clearTimeout(retryTimer); + }; + }, [isOpen, wakatimeEnabled]); + + // Load sync settings and stats data when modal opens useEffect(() => { if (!isOpen) return; @@ -533,6 +658,80 @@ export function GeneralTab({ theme, isOpen }: GeneralTabProps) { : `Press ${formatMetaKey()}+Enter to send. Enter creates new line.`}

+ + {/* Forced Parallel Execution */} +
+
+
+ Forced Parallel Execution +
+
+ + {shortcuts?.forcedParallelSend + ? formatShortcutKeys(shortcuts.forcedParallelSend.keys) + : '⌘ ⇧ ↩'} + + +
+
+
+ + + When enabled, use{' '} + + {shortcuts?.forcedParallelSend + ? formatShortcutKeys(shortcuts.forcedParallelSend.keys) + : '⌘ ⇧ ↩'} + {' '} + to send messages even while the agent is busy. Parallel writes to the same files may + cause one to overwrite the other. + +
+
+ + {/* Default History Toggle */} diff --git a/src/renderer/constants/modalPriorities.ts b/src/renderer/constants/modalPriorities.ts index 4477432f7..49ba2fa78 100644 --- a/src/renderer/constants/modalPriorities.ts +++ b/src/renderer/constants/modalPriorities.ts @@ -29,6 +29,9 @@ export const MODAL_PRIORITIES = { /** Agent error modal - critical, shows recovery options */ AGENT_ERROR: 1010, + /** Forced parallel execution warning - one-time acknowledgment */ + FORCED_PARALLEL_WARNING: 1005, + /** Confirmation dialogs - highest priority, always on top */ CONFIRM: 1000, diff --git a/src/renderer/constants/shortcuts.ts b/src/renderer/constants/shortcuts.ts index 8b1c7ba2c..f1e85e03f 100644 --- a/src/renderer/constants/shortcuts.ts +++ b/src/renderer/constants/shortcuts.ts @@ -99,6 +99,11 @@ export const DEFAULT_SHORTCUTS: Record = { label: 'Reset Font Size', keys: ['Meta', 'Shift', '0'], }, + forcedParallelSend: { + id: 'forcedParallelSend', + label: 'Forced Parallel Send', + keys: ['Meta', 'Shift', 'Enter'], + }, }; // Non-editable shortcuts (displayed in help but not configurable) diff --git a/src/renderer/hooks/input/useInputHandlers.ts b/src/renderer/hooks/input/useInputHandlers.ts index b2a63c8ea..a33ff28b5 100644 --- a/src/renderer/hooks/input/useInputHandlers.ts +++ b/src/renderer/hooks/input/useInputHandlers.ts @@ -100,9 +100,11 @@ export interface UseInputHandlersReturn { /** Set staged images for the current message */ setStagedImages: (images: string[] | ((prev: string[]) => string[])) => void; /** Process and send the current input */ - processInput: (text?: string) => void; + processInput: (text?: string, options?: { forceParallel?: boolean }) => void; /** Ref to latest processInput for use in memoized callbacks */ - processInputRef: React.MutableRefObject<(text?: string) => void>; + processInputRef: React.MutableRefObject< + (text?: string, options?: { forceParallel?: boolean }) => void + >; /** Keyboard event handler for the input textarea */ handleInputKeyDown: (e: React.KeyboardEvent) => void; /** Handler for input blur (persists input to session state) */ @@ -421,7 +423,9 @@ export function useInputHandlers(deps: UseInputHandlersDeps): UseInputHandlersRe }); // processInputRef — maintained for access in memoized callbacks without stale closures - const processInputRef = useRef<(text?: string) => void>(() => {}); + const processInputRef = useRef<(text?: string, options?: { forceParallel?: boolean }) => void>( + () => {} + ); useEffect(() => { processInputRef.current = processInput; }, [processInput]); diff --git a/src/renderer/hooks/input/useInputKeyDown.ts b/src/renderer/hooks/input/useInputKeyDown.ts index e3c8fabf0..c09cedefd 100644 --- a/src/renderer/hooks/input/useInputKeyDown.ts +++ b/src/renderer/hooks/input/useInputKeyDown.ts @@ -40,7 +40,7 @@ export interface InputKeyDownDeps { /** Sync file tree to highlight the tab completion suggestion */ syncFileTreeToTabCompletion: (suggestion: TabCompletionSuggestion | undefined) => void; /** Process and send the current input */ - processInput: () => void; + processInput: (overrideInputValue?: string, options?: { forceParallel?: boolean }) => void; /** Get tab completion suggestions for a given input */ getTabCompletionSuggestions: (input: string) => TabCompletionSuggestion[]; /** Ref to the input textarea */ @@ -237,6 +237,34 @@ export function useInputKeyDown(deps: InputKeyDownDeps): InputKeyDownReturn { const enterToSendTerminal = settings.enterToSendTerminal; if (e.key === 'Enter') { + // Check for forced parallel send shortcut (only in AI mode, only when feature enabled) + // Note: This check is inside the `e.key === 'Enter'` guard, so the shortcut's + // main key must be Enter. Non-Enter shortcuts are not supported by design. + if (settings.forcedParallelExecution && activeSession?.inputMode === 'ai') { + const shortcuts = settings.shortcuts; + const fpShortcut = shortcuts.forcedParallelSend; + if (fpShortcut) { + const fpKeys = fpShortcut.keys.map((k: string) => k.toLowerCase()); + const fpNeedsMeta = + fpKeys.includes('meta') || fpKeys.includes('ctrl') || fpKeys.includes('command'); + const fpNeedsShift = fpKeys.includes('shift'); + const fpNeedsAlt = fpKeys.includes('alt'); + const fpMainKey = fpKeys[fpKeys.length - 1]; + const metaPressed = e.metaKey || e.ctrlKey; + + if ( + metaPressed === fpNeedsMeta && + e.shiftKey === fpNeedsShift && + e.altKey === fpNeedsAlt && + e.key.toLowerCase() === fpMainKey + ) { + e.preventDefault(); + processInput(undefined, { forceParallel: true }); + return; + } + } + } + const currentEnterToSend = activeSession?.inputMode === 'terminal' ? enterToSendTerminal : enterToSendAI; diff --git a/src/renderer/hooks/input/useInputProcessing.ts b/src/renderer/hooks/input/useInputProcessing.ts index 5b6a46a92..b8fc1522b 100644 --- a/src/renderer/hooks/input/useInputProcessing.ts +++ b/src/renderer/hooks/input/useInputProcessing.ts @@ -15,6 +15,7 @@ import { filterYoloArgs } from '../../utils/agentArgs'; import { hasCapabilityCached } from '../agent/useAgentCapabilities'; import { gitService } from '../../services/git'; import { imageOnlyDefaultPrompt, maestroSystemPrompt } from '../../../prompts'; +import { useSettingsStore } from '../../stores/settingsStore'; /** * Default prompt used when user sends only an image without text. @@ -89,9 +90,14 @@ export type BatchState = BatchRunState; */ export interface UseInputProcessingReturn { /** Process the current input (send message or execute command) */ - processInput: (overrideInputValue?: string) => Promise; + processInput: ( + overrideInputValue?: string, + options?: { forceParallel?: boolean } + ) => Promise; /** Ref to processInput for use in callbacks that need latest version */ - processInputRef: React.MutableRefObject<((overrideInputValue?: string) => Promise) | null>; + processInputRef: React.MutableRefObject< + ((overrideInputValue?: string, options?: { forceParallel?: boolean }) => Promise) | null + >; } /** @@ -137,13 +143,15 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces } = deps; // Ref for the processInput function so external code can access the latest version - const processInputRef = useRef<((overrideInputValue?: string) => Promise) | null>(null); + const processInputRef = useRef< + ((overrideInputValue?: string, options?: { forceParallel?: boolean }) => Promise) | null + >(null); /** * Process user input - handles slash commands, queuing, and message sending. */ const processInput = useCallback( - async (overrideInputValue?: string) => { + async (overrideInputValue?: string, options?: { forceParallel?: boolean }) => { // Flush any pending batched updates before processing user input // This ensures AI output appears before the user's new message flushBatchedUpdates?.(); @@ -415,14 +423,21 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces // so we need to explicitly check the batch state to prevent write conflicts const isAutoRunActive = getBatchState(activeSession.id).isRunning; + // Forced parallel: user explicitly chose to bypass queue via modifier shortcut + const forceParallel = + options?.forceParallel === true && useSettingsStore.getState().forcedParallelExecution; + // Determine if we should queue this message // Read-only tabs can run in parallel - only queue if this specific tab is busy // Write mode tabs must wait for any busy tab to finish // EXCEPTION: Write commands bypass queue when all running/queued items are read-only // ALSO: Always queue write commands when AutoRun is active (to prevent file conflicts) - const shouldQueue = isReadOnlyMode - ? activeTab?.state === 'busy' // Read-only: only queue if THIS tab is busy - : (activeSession.state === 'busy' && !canWriteBypassQueue()) || isAutoRunActive; // Write mode: queue if busy OR AutoRun active + // EXCEPTION: Forced parallel bypasses all queue logic (user explicitly chose to send immediately) + const shouldQueue = forceParallel + ? false + : isReadOnlyMode + ? activeTab?.state === 'busy' // Read-only: only queue if THIS tab is busy + : (activeSession.state === 'busy' && !canWriteBypassQueue()) || isAutoRunActive; // Write mode: queue if busy OR AutoRun active // Debug logging to diagnose queue issues console.log('[processInput] Queue decision:', { @@ -431,6 +446,7 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces tabState: activeTab?.state, isReadOnlyMode, isAutoRunActive, + forceParallel, shouldQueue, queueLength: activeSession.executionQueue.length, }); @@ -858,10 +874,15 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces const targetPid = currentMode === 'ai' ? activeSession.aiPid : activeSession.terminalPid; // For batch mode (Claude), include tab ID in session ID to prevent process collision // This ensures each tab's process has a unique identifier + // For forced parallel sends, append a unique suffix so concurrent spawns from the same + // tab get distinct process keys and don't clobber each other's bookkeeping const activeTabForSpawn = getActiveTab(activeSession); + const isForceParallel = + options?.forceParallel === true && useSettingsStore.getState().forcedParallelExecution; + const forceParallelSuffix = isForceParallel ? `-fp-${Date.now()}` : ''; const targetSessionId = currentMode === 'ai' - ? `${activeSession.id}-ai-${activeTabForSpawn?.id || 'default'}` + ? `${activeSession.id}-ai-${activeTabForSpawn?.id || 'default'}${forceParallelSuffix}` : `${activeSession.id}-terminal`; // Check if this is an AI agent in batch mode diff --git a/src/renderer/hooks/settings/useSettings.ts b/src/renderer/hooks/settings/useSettings.ts index d601a880b..53c8efec6 100644 --- a/src/renderer/hooks/settings/useSettings.ts +++ b/src/renderer/hooks/settings/useSettings.ts @@ -300,6 +300,12 @@ export interface UseSettingsReturn { symphonyRegistryUrls: string[]; setSymphonyRegistryUrls: (value: string[]) => void; + // Forced Parallel Execution + forcedParallelExecution: boolean; + setForcedParallelExecution: (value: boolean) => void; + forcedParallelAcknowledged: boolean; + setForcedParallelAcknowledged: (value: boolean) => void; + // Director's Notes settings directorNotesSettings: DirectorNotesSettings; setDirectorNotesSettings: (value: DirectorNotesSettings) => void; diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index 2422325d9..2dabe9809 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -197,6 +197,8 @@ export interface SettingsStoreState { customThemeBaseId: ThemeId; enterToSendAI: boolean; enterToSendTerminal: boolean; + forcedParallelExecution: boolean; + forcedParallelAcknowledged: boolean; defaultSaveToHistory: boolean; defaultShowThinking: ThinkingMode; leftSidebarWidth: number; @@ -283,6 +285,8 @@ export interface SettingsStoreActions { setCustomThemeBaseId: (value: ThemeId) => void; setEnterToSendAI: (value: boolean) => void; setEnterToSendTerminal: (value: boolean) => void; + setForcedParallelExecution: (value: boolean) => void; + setForcedParallelAcknowledged: (value: boolean) => void; setDefaultSaveToHistory: (value: boolean) => void; setDefaultShowThinking: (value: ThinkingMode) => void; setLeftSidebarWidth: (value: number) => void; @@ -431,6 +435,8 @@ export const useSettingsStore = create()((set, get) => { customThemeBaseId: 'dracula', enterToSendAI: false, enterToSendTerminal: true, + forcedParallelExecution: false, + forcedParallelAcknowledged: false, defaultSaveToHistory: true, defaultShowThinking: 'off', leftSidebarWidth: 256, @@ -583,6 +589,16 @@ export const useSettingsStore = create()((set, get) => { window.maestro.settings.set('enterToSendTerminal', value); }, + setForcedParallelExecution: (value) => { + set({ forcedParallelExecution: value }); + window.maestro.settings.set('forcedParallelExecution', value); + }, + + setForcedParallelAcknowledged: (value) => { + set({ forcedParallelAcknowledged: value }); + window.maestro.settings.set('forcedParallelAcknowledged', value); + }, + setDefaultSaveToHistory: (value) => { set({ defaultSaveToHistory: value }); window.maestro.settings.set('defaultSaveToHistory', value); @@ -1492,6 +1508,11 @@ export async function loadAllSettings(): Promise { if (allSettings['enterToSendTerminal'] !== undefined) patch.enterToSendTerminal = allSettings['enterToSendTerminal'] as boolean; + if (allSettings['forcedParallelExecution'] !== undefined) + patch.forcedParallelExecution = allSettings['forcedParallelExecution'] as boolean; + if (allSettings['forcedParallelAcknowledged'] !== undefined) + patch.forcedParallelAcknowledged = allSettings['forcedParallelAcknowledged'] as boolean; + if (allSettings['defaultSaveToHistory'] !== undefined) patch.defaultSaveToHistory = allSettings['defaultSaveToHistory'] as boolean; @@ -1906,6 +1927,8 @@ export function getSettingsActions() { setCustomThemeBaseId: state.setCustomThemeBaseId, setEnterToSendAI: state.setEnterToSendAI, setEnterToSendTerminal: state.setEnterToSendTerminal, + setForcedParallelExecution: state.setForcedParallelExecution, + setForcedParallelAcknowledged: state.setForcedParallelAcknowledged, setDefaultSaveToHistory: state.setDefaultSaveToHistory, setDefaultShowThinking: state.setDefaultShowThinking, setLeftSidebarWidth: state.setLeftSidebarWidth,