diff --git a/src/__tests__/main/web-server/web-server-factory.test.ts b/src/__tests__/main/web-server/web-server-factory.test.ts index 60ffc9ad01..541d0e1174 100644 --- a/src/__tests__/main/web-server/web-server-factory.test.ts +++ b/src/__tests__/main/web-server/web-server-factory.test.ts @@ -41,6 +41,35 @@ vi.mock('../../../main/web-server/WebServer', () => { setRefreshFileTreeCallback = vi.fn(); setRefreshAutoRunDocsCallback = vi.fn(); setConfigureAutoRunCallback = vi.fn(); + setGetAutoRunDocsCallback = vi.fn(); + setGetAutoRunDocContentCallback = vi.fn(); + setSaveAutoRunDocCallback = vi.fn(); + setStopAutoRunCallback = vi.fn(); + setGetSettingsCallback = vi.fn(); + setSetSettingCallback = vi.fn(); + setGetGroupsCallback = vi.fn(); + setCreateGroupCallback = vi.fn(); + setRenameGroupCallback = vi.fn(); + setDeleteGroupCallback = vi.fn(); + setMoveSessionToGroupCallback = vi.fn(); + setCreateSessionCallback = vi.fn(); + setDeleteSessionCallback = vi.fn(); + setRenameSessionCallback = vi.fn(); + setGetGitStatusCallback = vi.fn(); + setGetGitDiffCallback = vi.fn(); + setGetGroupChatsCallback = vi.fn(); + setStartGroupChatCallback = vi.fn(); + setGetGroupChatStateCallback = vi.fn(); + setStopGroupChatCallback = vi.fn(); + setSendGroupChatMessageCallback = vi.fn(); + setMergeContextCallback = vi.fn(); + setTransferContextCallback = vi.fn(); + setSummarizeContextCallback = vi.fn(); + setGetCueSubscriptionsCallback = vi.fn(); + setToggleCueSubscriptionCallback = vi.fn(); + setGetCueActivityCallback = vi.fn(); + setGetUsageDashboardCallback = vi.fn(); + setGetAchievementsCallback = vi.fn(); constructor(port: number, securityToken?: string) { this.port = port; diff --git a/src/__tests__/renderer/hooks/useRemoteIntegration.test.ts b/src/__tests__/renderer/hooks/useRemoteIntegration.test.ts index 377e514030..69b5e43094 100644 --- a/src/__tests__/renderer/hooks/useRemoteIntegration.test.ts +++ b/src/__tests__/renderer/hooks/useRemoteIntegration.test.ts @@ -135,6 +135,59 @@ describe('useRemoteIntegration', () => { }), sendRemoteNewTabResponse: vi.fn(), sendRemoteConfigureAutoRunResponse: vi.fn(), + onRemoteGetAutoRunDocs: vi.fn().mockImplementation(() => { + return () => {}; + }), + onRemoteGetAutoRunDocContent: vi.fn().mockImplementation(() => { + return () => {}; + }), + onRemoteSaveAutoRunDoc: vi.fn().mockImplementation(() => { + return () => {}; + }), + sendRemoteSaveAutoRunDocResponse: vi.fn(), + sendRemoteGetAutoRunDocsResponse: vi.fn(), + sendRemoteGetAutoRunDocContentResponse: vi.fn(), + onRemoteStopAutoRun: vi.fn().mockImplementation(() => { + return () => {}; + }), + onRemoteSetSetting: vi.fn().mockImplementation(() => { + return () => {}; + }), + sendRemoteSetSettingResponse: vi.fn(), + onRemoteCreateSession: vi.fn().mockImplementation(() => { + return () => {}; + }), + sendRemoteCreateSessionResponse: vi.fn(), + onRemoteDeleteSession: vi.fn().mockImplementation(() => { + return () => {}; + }), + onRemoteRenameSession: vi.fn().mockImplementation(() => { + return () => {}; + }), + sendRemoteRenameSessionResponse: vi.fn(), + onRemoteCreateGroup: vi.fn().mockImplementation(() => { + return () => {}; + }), + sendRemoteCreateGroupResponse: vi.fn(), + onRemoteRenameGroup: vi.fn().mockImplementation(() => { + return () => {}; + }), + sendRemoteRenameGroupResponse: vi.fn(), + onRemoteDeleteGroup: vi.fn().mockImplementation(() => { + return () => {}; + }), + onRemoteMoveSessionToGroup: vi.fn().mockImplementation(() => { + return () => {}; + }), + sendRemoteMoveSessionToGroupResponse: vi.fn(), + onRemoteGetGitStatus: vi.fn().mockImplementation(() => { + return () => {}; + }), + sendRemoteGetGitStatusResponse: vi.fn(), + onRemoteGetGitDiff: vi.fn().mockImplementation(() => { + return () => {}; + }), + sendRemoteGetGitDiffResponse: vi.fn(), }; const mockLive = { diff --git a/src/__tests__/web/hooks/useLongPressMenu.test.ts b/src/__tests__/web/hooks/useLongPressMenu.test.ts index 12c40aaa86..214387905c 100644 --- a/src/__tests__/web/hooks/useLongPressMenu.test.ts +++ b/src/__tests__/web/hooks/useLongPressMenu.test.ts @@ -31,24 +31,15 @@ describe('useLongPressMenu', () => { vi.restoreAllMocks(); }); - it('opens the menu after long press', () => { + it('triggers onOpenCommandPalette after long press', () => { + const onOpenCommandPalette = vi.fn(); const button = document.createElement('button'); - button.getBoundingClientRect = vi.fn(() => ({ - left: 10, - top: 20, - width: 30, - height: 40, - right: 40, - bottom: 60, - x: 10, - y: 20, - toJSON: () => {}, - })) as unknown as () => DOMRect; const { result } = renderHook(() => useLongPressMenu({ inputMode: 'ai', value: 'hello', + onOpenCommandPalette, }) ); @@ -61,28 +52,18 @@ describe('useLongPressMenu', () => { vi.advanceTimersByTime(500); }); - expect(result.current.isMenuOpen).toBe(true); - expect(result.current.menuAnchor).toEqual({ x: 25, y: 20 }); + expect(onOpenCommandPalette).toHaveBeenCalledTimes(1); }); it('cancels long press on touch move', () => { + const onOpenCommandPalette = vi.fn(); const button = document.createElement('button'); - button.getBoundingClientRect = vi.fn(() => ({ - left: 0, - top: 0, - width: 10, - height: 10, - right: 10, - bottom: 10, - x: 0, - y: 0, - toJSON: () => {}, - })) as unknown as () => DOMRect; const { result } = renderHook(() => useLongPressMenu({ inputMode: 'ai', value: 'hello', + onOpenCommandPalette, }) ); @@ -96,44 +77,18 @@ describe('useLongPressMenu', () => { vi.advanceTimersByTime(500); }); - expect(result.current.isMenuOpen).toBe(false); + expect(onOpenCommandPalette).not.toHaveBeenCalled(); }); - it('handles quick action selection', () => { - const onModeToggle = vi.fn(); - const { result } = renderHook(() => - useLongPressMenu({ - inputMode: 'ai', - onModeToggle, - value: 'hello', - }) - ); - - act(() => { - result.current.handleQuickAction('switch_mode'); - }); - - expect(onModeToggle).toHaveBeenCalledWith('terminal'); - }); - - it('closes the menu when requested', () => { + it('does not trigger onOpenCommandPalette when touch ends before duration', () => { + const onOpenCommandPalette = vi.fn(); const button = document.createElement('button'); - button.getBoundingClientRect = vi.fn(() => ({ - left: 0, - top: 0, - width: 10, - height: 10, - right: 10, - bottom: 10, - x: 0, - y: 0, - toJSON: () => {}, - })) as unknown as () => DOMRect; const { result } = renderHook(() => useLongPressMenu({ inputMode: 'ai', value: 'hello', + onOpenCommandPalette, }) ); @@ -143,15 +98,24 @@ describe('useLongPressMenu', () => { act(() => { result.current.handleTouchStart(createTouchEvent(button)); + result.current.handleTouchEnd(createTouchEvent(button)); vi.advanceTimersByTime(500); }); - expect(result.current.isMenuOpen).toBe(true); + expect(onOpenCommandPalette).not.toHaveBeenCalled(); + }); - act(() => { - result.current.closeMenu(); - }); + it('returns expected handler functions', () => { + const { result } = renderHook(() => + useLongPressMenu({ + inputMode: 'ai', + value: 'hello', + }) + ); - expect(result.current.isMenuOpen).toBe(false); + expect(typeof result.current.handleTouchStart).toBe('function'); + expect(typeof result.current.handleTouchEnd).toBe('function'); + expect(typeof result.current.handleTouchMove).toBe('function'); + expect(result.current.sendButtonRef).toBeDefined(); }); }); diff --git a/src/__tests__/web/mobile/AllSessionsView.test.tsx b/src/__tests__/web/mobile/AllSessionsView.test.tsx index dd048820a0..e0f77b95fd 100644 --- a/src/__tests__/web/mobile/AllSessionsView.test.tsx +++ b/src/__tests__/web/mobile/AllSessionsView.test.tsx @@ -299,7 +299,7 @@ describe('AllSessionsView', () => { }); describe('session card interaction', () => { - it('calls onSelectSession and onClose when card is clicked', async () => { + it('calls onSelectSession and onClose when card is tapped', async () => { const onSelectSession = vi.fn(); const onClose = vi.fn(); const sessions = [createMockSession({ id: 'session-1', name: 'Click Me' })]; @@ -307,7 +307,10 @@ describe('AllSessionsView', () => { render(); const sessionCard = screen.getByRole('button', { name: /Click Me/i }); - fireEvent.click(sessionCard); + // JSDOM has ontouchstart in window, so the click handler is bypassed. + // Use touch events to simulate a tap (as the source uses handleTouchEnd for selection). + fireEvent.touchStart(sessionCard, { touches: [{ clientX: 0, clientY: 0 }] }); + fireEvent.touchEnd(sessionCard); expect(mockTriggerHaptic).toHaveBeenCalledWith([10]); // HAPTIC_PATTERNS.tap expect(onSelectSession).toHaveBeenCalledWith('session-1'); @@ -957,8 +960,11 @@ describe('AllSessionsView', () => { }); // 3. Select Backend + // JSDOM has ontouchstart in window, so the click handler is bypassed. + // Use touch events to simulate a tap (as the source uses handleTouchEnd for selection). const backendCard = screen.getByRole('button', { name: /Backend session/i }); - fireEvent.click(backendCard); + fireEvent.touchStart(backendCard, { touches: [{ clientX: 0, clientY: 0 }] }); + fireEvent.touchEnd(backendCard); expect(onSelectSession).toHaveBeenCalledWith('s2'); expect(onClose).toHaveBeenCalled(); diff --git a/src/__tests__/web/mobile/App.test.tsx b/src/__tests__/web/mobile/App.test.tsx index d098da8405..8cc6860a08 100644 --- a/src/__tests__/web/mobile/App.test.tsx +++ b/src/__tests__/web/mobile/App.test.tsx @@ -51,6 +51,7 @@ vi.mock('../../../web/main', () => ({ // Mock useWebSocket hook const mockConnect = vi.fn(); const mockSend = vi.fn(() => true); +const mockSendRequest = vi.fn(() => Promise.resolve({})); const mockDisconnect = vi.fn(); let mockWebSocketState = 'connected'; let mockWebSocketError: string | null = null; @@ -69,6 +70,7 @@ vi.mock('../../../web/hooks/useWebSocket', () => ({ state: mockWebSocketState, connect: mockConnect, send: mockSend, + sendRequest: mockSendRequest, disconnect: mockDisconnect, error: mockWebSocketError, reconnectAttempts: mockReconnectAttempts, @@ -152,6 +154,12 @@ vi.mock('../../../web/mobile/constants', () => ({ success: [30], error: [50], }, + GESTURE_THRESHOLDS: { + swipeDistance: 50, + swipeTime: 300, + pullToRefresh: 80, + longPress: 500, + }, })); // Mock webLogger @@ -504,6 +512,23 @@ vi.mock('../../../web/mobile/SlashCommandAutocomplete', () => ({ ], })); +vi.mock('../../../web/mobile/RightDrawer', () => ({ + RightDrawer: ({ + activeTab, + onClose, + }: { + sessionId: string; + activeTab?: string; + onClose: () => void; + }) => ( +
+ +
+ ), +})); + // Now import the component import MobileApp from '../../../web/mobile/App'; import type { Session } from '../../../web/hooks/useSessions'; @@ -579,6 +604,7 @@ describe('MobileApp', () => { (window as any).__MAESTRO_CONFIG__ = {}; // Reset mock function return values + mockSendRequest.mockResolvedValue({}); mockIsOffline.mockReturnValue(false); mockIsDashboard.mockReturnValue(true); mockIsSession.mockReturnValue(false); @@ -1442,7 +1468,7 @@ describe('MobileApp', () => { }); describe('history panel', () => { - it('opens history panel', async () => { + it('opens history panel via right drawer', async () => { render(); await act(async () => { @@ -1453,10 +1479,13 @@ describe('MobileApp', () => { fireEvent.click(screen.getByTestId('open-history')); - expect(screen.getByTestId('mobile-history-panel')).toBeInTheDocument(); + // History is now inside the RightDrawer with activeTab='history' + const drawer = screen.getByTestId('right-drawer'); + expect(drawer).toBeInTheDocument(); + expect(drawer).toHaveAttribute('data-active-tab', 'history'); }); - it('closes history panel', async () => { + it('closes history panel via right drawer', async () => { render(); await act(async () => { @@ -1466,13 +1495,13 @@ describe('MobileApp', () => { }); fireEvent.click(screen.getByTestId('open-history')); - expect(screen.getByTestId('mobile-history-panel')).toBeInTheDocument(); + expect(screen.getByTestId('right-drawer')).toBeInTheDocument(); - fireEvent.click(screen.getByTestId('close-history')); - expect(screen.queryByTestId('mobile-history-panel')).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId('close-right-drawer')); + expect(screen.queryByTestId('right-drawer')).not.toBeInTheDocument(); }); - it('handles onSearchChange callback to update search state', async () => { + it('opens history panel and can close it again', async () => { render(); await act(async () => { @@ -1483,21 +1512,20 @@ describe('MobileApp', () => { // Open history panel fireEvent.click(screen.getByTestId('open-history')); - expect(screen.getByTestId('mobile-history-panel')).toBeInTheDocument(); + const drawer = screen.getByTestId('right-drawer'); + expect(drawer).toBeInTheDocument(); + expect(drawer).toHaveAttribute('data-active-tab', 'history'); - // Trigger onSearchChange callback - fireEvent.click(screen.getByTestId('trigger-search-change')); + // Close drawer + fireEvent.click(screen.getByTestId('close-right-drawer')); + expect(screen.queryByTestId('right-drawer')).not.toBeInTheDocument(); - // Close and reopen to verify state persistence - fireEvent.click(screen.getByTestId('close-history')); + // Reopen fireEvent.click(screen.getByTestId('open-history')); - - // Verify the search query and open state were persisted - expect(screen.getByTestId('history-initial-search-query')).toHaveTextContent('test query'); - expect(screen.getByTestId('history-initial-search-open')).toHaveTextContent('true'); + expect(screen.getByTestId('right-drawer')).toBeInTheDocument(); }); - it('handles onFilterChange callback to update filter state', async () => { + it('opens history panel with history tab active', async () => { render(); await act(async () => { @@ -1508,17 +1536,15 @@ describe('MobileApp', () => { // Open history panel fireEvent.click(screen.getByTestId('open-history')); - expect(screen.getByTestId('mobile-history-panel')).toBeInTheDocument(); - - // Trigger onFilterChange callback - fireEvent.click(screen.getByTestId('trigger-filter-change')); + const drawer = screen.getByTestId('right-drawer'); + expect(drawer).toBeInTheDocument(); + expect(drawer).toHaveAttribute('data-active-tab', 'history'); - // Close and reopen to verify state persistence - fireEvent.click(screen.getByTestId('close-history')); + // Close and reopen to verify drawer opens with history tab + fireEvent.click(screen.getByTestId('close-right-drawer')); + expect(screen.queryByTestId('right-drawer')).not.toBeInTheDocument(); fireEvent.click(screen.getByTestId('open-history')); - - // Verify the filter was persisted - expect(screen.getByTestId('history-initial-filter')).toHaveTextContent('AUTO'); + expect(screen.getByTestId('right-drawer')).toHaveAttribute('data-active-tab', 'history'); }); }); diff --git a/src/__tests__/web/mobile/CommandInputBar.test.tsx b/src/__tests__/web/mobile/CommandInputBar.test.tsx index 2bf93f2474..5543504609 100644 --- a/src/__tests__/web/mobile/CommandInputBar.test.tsx +++ b/src/__tests__/web/mobile/CommandInputBar.test.tsx @@ -91,23 +91,9 @@ vi.mock('../../../web/mobile/SlashCommandAutocomplete', () => ({ ], })); -vi.mock('../../../web/mobile/QuickActionsMenu', () => ({ - QuickActionsMenu: vi.fn( - ({ isOpen, onClose, onSelectAction, inputMode, anchorPosition, hasActiveSession }) => - isOpen ? ( -
- {inputMode} - {String(hasActiveSession)} - - -
- ) : null - ), -})); +// Note: QuickActionsMenu is no longer rendered inside CommandInputBar. +// Long-press on the send button now calls the onOpenCommandPalette prop directly, +// and the App-level component is responsible for showing QuickActionsMenu. vi.mock('../../../web/utils/logger', () => ({ webLogger: { @@ -699,8 +685,9 @@ describe('CommandInputBar', () => { expect(screen.queryByTestId('quick-actions-menu')).not.toBeInTheDocument(); }); - it('shows quick actions menu on long-press of send button', async () => { - renderComponent({ value: 'test' }); + it('calls onOpenCommandPalette on long-press of send button', async () => { + const onOpenCommandPalette = vi.fn(); + renderComponent({ value: 'test', onOpenCommandPalette }); const sendButton = screen.getByRole('button', { name: /send/i }); @@ -714,7 +701,7 @@ describe('CommandInputBar', () => { await vi.advanceTimersByTimeAsync(600); }); - expect(screen.getByTestId('quick-actions-menu')).toBeInTheDocument(); + expect(onOpenCommandPalette).toHaveBeenCalledTimes(1); }); it('cancels long-press if touch ends before duration', () => { @@ -763,9 +750,9 @@ describe('CommandInputBar', () => { vi.useFakeTimers({ shouldAdvanceTime: true }); }); - it('handles switch_mode action from quick actions', async () => { - const onModeToggle = vi.fn(); - renderComponent({ value: 'test', inputMode: 'ai', onModeToggle }); + it('does not call onOpenCommandPalette if touch ends before long-press duration', async () => { + const onOpenCommandPalette = vi.fn(); + renderComponent({ value: 'test', inputMode: 'ai', onOpenCommandPalette }); const sendButton = screen.getByRole('button', { name: /send/i }); @@ -773,18 +760,19 @@ describe('CommandInputBar', () => { touches: [{ clientX: 100, clientY: 100 }], }); + // End touch before 500ms + fireEvent.touchEnd(sendButton); + await act(async () => { await vi.advanceTimersByTimeAsync(600); }); - const switchModeButton = screen.getByTestId('qa-switch-mode'); - fireEvent.click(switchModeButton); - - expect(onModeToggle).toHaveBeenCalledWith('terminal'); + expect(onOpenCommandPalette).not.toHaveBeenCalled(); }); - it('closes quick actions menu on close button', async () => { - renderComponent({ value: 'test' }); + it('does not call onOpenCommandPalette if touch moves before long-press duration', async () => { + const onOpenCommandPalette = vi.fn(); + renderComponent({ value: 'test', onOpenCommandPalette }); const sendButton = screen.getByRole('button', { name: /send/i }); @@ -792,14 +780,16 @@ describe('CommandInputBar', () => { touches: [{ clientX: 100, clientY: 100 }], }); + // Move touch - should cancel the timer + fireEvent.touchMove(sendButton, { + touches: [{ clientX: 150, clientY: 150 }], + }); + await act(async () => { await vi.advanceTimersByTimeAsync(600); }); - const closeButton = screen.getByTestId('qa-close'); - fireEvent.click(closeButton); - - expect(screen.queryByTestId('quick-actions-menu')).not.toBeInTheDocument(); + expect(onOpenCommandPalette).not.toHaveBeenCalled(); }); }); diff --git a/src/__tests__/web/mobile/QuickActionsMenu.test.tsx b/src/__tests__/web/mobile/QuickActionsMenu.test.tsx index 00e0bede95..d88e55fabf 100644 --- a/src/__tests__/web/mobile/QuickActionsMenu.test.tsx +++ b/src/__tests__/web/mobile/QuickActionsMenu.test.tsx @@ -1,8 +1,8 @@ /** * Tests for QuickActionsMenu component * - * QuickActionsMenu is a popup menu shown on long-press of send button - * providing quick actions like mode switching. + * QuickActionsMenu is a full-screen command palette providing quick access + * to all app actions with search, keyboard navigation, and recent actions. */ import React from 'react'; @@ -13,6 +13,7 @@ import { render, screen, fireEvent, cleanup } from '@testing-library/react'; vi.mock('../../../web/components/ThemeProvider', () => ({ useThemeColors: () => ({ bgSidebar: '#1e1e2e', + bgMain: '#181825', border: '#45475a', textMain: '#cdd6f4', textDim: '#a6adc8', @@ -20,27 +21,59 @@ vi.mock('../../../web/components/ThemeProvider', () => ({ }), })); +// Mock localStorage for the test environment +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: vi.fn(() => { + store = {}; + }), + length: 0, + key: vi.fn(), + }; +})(); +Object.defineProperty(window, 'localStorage', { value: localStorageMock, writable: true }); + import { QuickActionsMenu, QuickActionsMenuProps, - QuickAction, + CommandPaletteAction, } from '../../../web/mobile/QuickActionsMenu'; +/** Helper to build a minimal CommandPaletteAction */ +function makeAction(overrides: Partial = {}): CommandPaletteAction { + return { + id: 'test-action', + label: 'Test Action', + category: 'Navigation', + icon: icon, + action: vi.fn(), + ...overrides, + }; +} + describe('QuickActionsMenu', () => { + const defaultActions: CommandPaletteAction[] = [ + makeAction({ id: 'action-1', label: 'Go to Home', category: 'Navigation' }), + makeAction({ id: 'action-2', label: 'Start Agent', category: 'Agent' }), + ]; + const defaultProps: QuickActionsMenuProps = { isOpen: true, onClose: vi.fn(), - onSelectAction: vi.fn(), - inputMode: 'ai', - anchorPosition: { x: 200, y: 500 }, - hasActiveSession: true, + actions: defaultActions, }; beforeEach(() => { vi.clearAllMocks(); - // Mock window dimensions - Object.defineProperty(window, 'innerWidth', { value: 400, writable: true }); - Object.defineProperty(window, 'innerHeight', { value: 800, writable: true }); + localStorageMock.clear(); }); afterEach(() => { @@ -53,230 +86,170 @@ describe('QuickActionsMenu', () => { expect(container.firstChild).toBeNull(); }); - it('returns null when anchorPosition is null', () => { - const { container } = render(); - expect(container.firstChild).toBeNull(); - }); - - it('renders when isOpen is true and anchorPosition is provided', () => { + it('renders when isOpen is true', () => { render(); - expect(screen.getByRole('menu')).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); }); it('renders backdrop overlay', () => { render(); - // Backdrop has aria-hidden="true" const backdrop = document.querySelector('[aria-hidden="true"]'); expect(backdrop).toBeInTheDocument(); expect(backdrop).toHaveStyle({ position: 'fixed' }); }); - }); - describe('Menu positioning', () => { - it('centers menu horizontally on anchor position', () => { - render(); - const menu = screen.getByRole('menu'); - // menuWidth is 200, so centered would be 200 - 100 = 100 - // But clamped by Math.max(16, Math.min(100, 400-200-16)) = Math.max(16, 100) = 100 - expect(menu).toHaveStyle({ width: '200px' }); + it('renders with empty actions array', () => { + render(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); }); + }); - it('clamps menu to left edge with minimum 16px margin', () => { - render(); - const menu = screen.getByRole('menu'); - // x=50, centered would be 50 - 100 = -50, clamped to 16 - expect(menu.style.left).toBe('16px'); + describe('Search input', () => { + it('renders search input with placeholder', () => { + render(); + expect(screen.getByPlaceholderText('Search actions...')).toBeInTheDocument(); }); - it('clamps menu to right edge with minimum 16px margin', () => { - render(); - const menu = screen.getByRole('menu'); - // x=380, centered would be 380 - 100 = 280 - // maxRight = 400 - 200 - 16 = 184 - // Math.min(280, 184) = 184 - expect(menu.style.left).toBe('184px'); + it('has aria-label on search input', () => { + render(); + expect(screen.getByLabelText('Search actions')).toBeInTheDocument(); }); - it('positions menu above anchor point', () => { - render(); - const menu = screen.getByRole('menu'); - // bottom: calc(100vh - 500px + 12px) - expect(menu.style.bottom).toContain('500px'); + it('filters actions by search query', () => { + render(); + const input = screen.getByPlaceholderText('Search actions...'); + + fireEvent.change(input, { target: { value: 'Home' } }); + + expect(screen.getByText('Go to Home')).toBeInTheDocument(); + expect(screen.queryByText('Start Agent')).not.toBeInTheDocument(); }); - }); - describe('Menu styling', () => { - it('applies correct z-index', () => { + it('filters actions by category', () => { render(); - const menu = screen.getByRole('menu'); - expect(menu).toHaveStyle({ zIndex: '200' }); + const input = screen.getByPlaceholderText('Search actions...'); + + fireEvent.change(input, { target: { value: 'Agent' } }); + + expect(screen.getByText('Start Agent')).toBeInTheDocument(); + expect(screen.queryByText('Go to Home')).not.toBeInTheDocument(); }); - it('has animation class', () => { + it('shows "No matching actions" when nothing matches', () => { render(); - const menu = screen.getByRole('menu'); - expect(menu.style.animation).toContain('quickActionsPopIn'); + const input = screen.getByPlaceholderText('Search actions...'); + + fireEvent.change(input, { target: { value: 'zzznomatch' } }); + + expect(screen.getByText('No matching actions')).toBeInTheDocument(); }); - it('has proper border radius', () => { + it('shows clear button when search has text', () => { render(); - const menu = screen.getByRole('menu'); - expect(menu).toHaveStyle({ borderRadius: '12px' }); - }); - }); + const input = screen.getByPlaceholderText('Search actions...'); - describe('Menu items - AI mode', () => { - it('renders "Switch to Terminal" when in AI mode', () => { - render(); - expect(screen.getByText('Switch to Terminal')).toBeInTheDocument(); - }); + fireEvent.change(input, { target: { value: 'test' } }); - it('renders terminal icon when in AI mode', () => { - render(); - const menuItem = screen.getByRole('menuitem'); - // Terminal icon has polyline with points="4 17 10 11 4 5" - const svg = menuItem.querySelector('svg'); - expect(svg).toBeInTheDocument(); - const polyline = svg?.querySelector('polyline'); - expect(polyline).toBeInTheDocument(); - expect(polyline?.getAttribute('points')).toBe('4 17 10 11 4 5'); + expect(screen.getByLabelText('Clear search')).toBeInTheDocument(); }); - }); - describe('Menu items - Terminal mode', () => { - it('renders "Switch to AI" when in terminal mode', () => { - render(); - expect(screen.getByText('Switch to AI')).toBeInTheDocument(); - }); + it('clears search when clear button is clicked', () => { + render(); + const input = screen.getByPlaceholderText('Search actions...'); + + fireEvent.change(input, { target: { value: 'test' } }); + fireEvent.click(screen.getByLabelText('Clear search')); - it('renders AI sparkle icon when in terminal mode', () => { - render(); - const menuItem = screen.getByRole('menuitem'); - // AI icon has circle with cx="12" cy="12" r="4" - const svg = menuItem.querySelector('svg'); - expect(svg).toBeInTheDocument(); - const circle = svg?.querySelector('circle'); - expect(circle).toBeInTheDocument(); - expect(circle?.getAttribute('cx')).toBe('12'); + expect((input as HTMLInputElement).value).toBe(''); }); }); - describe('Disabled state', () => { - it('disables menu item when hasActiveSession is false', () => { - render(); - const menuItem = screen.getByRole('menuitem'); - expect(menuItem).toBeDisabled(); - expect(menuItem).toHaveAttribute('aria-disabled', 'true'); + describe('Action list rendering', () => { + it('renders all available actions', () => { + render(); + expect(screen.getByText('Go to Home')).toBeInTheDocument(); + expect(screen.getByText('Start Agent')).toBeInTheDocument(); }); - it('applies reduced opacity when disabled', () => { - render(); - const menuItem = screen.getByRole('menuitem'); - expect(menuItem).toHaveStyle({ opacity: '0.5' }); + it('renders category headers', () => { + render(); + expect(screen.getByText('Navigation')).toBeInTheDocument(); + expect(screen.getByText('Agent')).toBeInTheDocument(); }); - it('does not trigger onSelectAction when clicking disabled item', () => { - const onSelectAction = vi.fn(); - render( - - ); - fireEvent.click(screen.getByRole('menuitem')); - expect(onSelectAction).not.toHaveBeenCalled(); + it('renders action buttons with role="option"', () => { + render(); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(2); }); - it('enables menu item when hasActiveSession is true', () => { - render(); - const menuItem = screen.getByRole('menuitem'); - expect(menuItem).not.toBeDisabled(); + it('renders listbox with role="listbox"', () => { + render(); + expect(screen.getByRole('listbox')).toBeInTheDocument(); }); - }); - describe('Action selection', () => { - it('calls onSelectAction with switch_mode when item is clicked', () => { - const onSelectAction = vi.fn(); - render(); - fireEvent.click(screen.getByRole('menuitem')); - expect(onSelectAction).toHaveBeenCalledWith('switch_mode'); + it('skips actions where available() returns false', () => { + const actions: CommandPaletteAction[] = [ + makeAction({ id: 'visible', label: 'Visible Action', available: () => true }), + makeAction({ id: 'hidden', label: 'Hidden Action', available: () => false }), + ]; + render(); + expect(screen.getByText('Visible Action')).toBeInTheDocument(); + expect(screen.queryByText('Hidden Action')).not.toBeInTheDocument(); }); - it('calls onClose after action is selected', () => { - const onClose = vi.fn(); - render(); - fireEvent.click(screen.getByRole('menuitem')); - expect(onClose).toHaveBeenCalled(); + it('shows action when available() is undefined (defaults to visible)', () => { + const actions: CommandPaletteAction[] = [ + makeAction({ id: 'no-guard', label: 'No Guard Action', available: undefined }), + ]; + render(); + expect(screen.getByText('No Guard Action')).toBeInTheDocument(); }); - it('calls onSelectAction before onClose', () => { - const callOrder: string[] = []; - const onSelectAction = vi.fn(() => callOrder.push('select')); - const onClose = vi.fn(() => callOrder.push('close')); - render( - - ); - fireEvent.click(screen.getByRole('menuitem')); - expect(callOrder).toEqual(['select', 'close']); + it('renders action shortcuts when provided', () => { + const actions: CommandPaletteAction[] = [ + makeAction({ id: 'with-shortcut', label: 'Shortcut Action', shortcut: 'Cmd+K' }), + ]; + render(); + expect(screen.getByText('Cmd+K')).toBeInTheDocument(); }); }); - describe('Outside click handling', () => { - it('closes menu when backdrop is clicked', () => { - const onClose = vi.fn(); - render(); - const backdrop = document.querySelector('[aria-hidden="true"]') as HTMLElement; - fireEvent.click(backdrop); - expect(onClose).toHaveBeenCalled(); - }); + describe('Action execution', () => { + it('calls action.action() when an item is clicked', () => { + const actionFn = vi.fn(); + const actions = [makeAction({ id: 'clickable', label: 'Clickable', action: actionFn })]; + render(); - it('closes menu on mousedown outside', () => { - const onClose = vi.fn(); - render( -
- -
Outside
-
- ); - fireEvent.mouseDown(screen.getByTestId('outside')); - expect(onClose).toHaveBeenCalled(); + fireEvent.click(screen.getByText('Clickable')); + expect(actionFn).toHaveBeenCalled(); }); - it('closes menu on touchstart outside', () => { + it('calls onClose when an item is clicked', () => { const onClose = vi.fn(); - render( -
- -
Outside
-
- ); - fireEvent.touchStart(screen.getByTestId('outside')); - expect(onClose).toHaveBeenCalled(); - }); + const actions = [makeAction({ id: 'clickable', label: 'Clickable' })]; + render(); - it('does not close menu when clicking inside menu', () => { - const onClose = vi.fn(); - render(); - const menu = screen.getByRole('menu'); - fireEvent.mouseDown(menu); - // onClose should not be called for inside click - // (it will only be called from handleItemClick) - expect(onClose).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText('Clickable')); + expect(onClose).toHaveBeenCalled(); }); + }); - it('removes event listeners when menu closes', () => { - const onClose = vi.fn(); - const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener'); - - const { rerender } = render(); + describe('Recent actions', () => { + it('shows Recent section header after an action is used', () => { + const actionFn = vi.fn(); + const actions = [makeAction({ id: 'recent-test', label: 'Recent Test', action: actionFn })]; - rerender(); + // Simulate a prior usage by pre-populating localStorage + localStorage.setItem('maestro-command-palette-recent', JSON.stringify(['recent-test'])); - expect(removeEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function)); - expect(removeEventListenerSpy).toHaveBeenCalledWith('touchstart', expect.any(Function)); + render(); + expect(screen.getByText('Recent')).toBeInTheDocument(); + }); - removeEventListenerSpy.mockRestore(); + it('does not show Recent section when localStorage is empty', () => { + render(); + expect(screen.queryByText('Recent')).not.toBeInTheDocument(); }); }); @@ -291,9 +264,8 @@ describe('QuickActionsMenu', () => { it('does not close menu on other keys', () => { const onClose = vi.fn(); render(); - fireEvent.keyDown(document, { key: 'Enter' }); fireEvent.keyDown(document, { key: 'Tab' }); - fireEvent.keyDown(document, { key: 'ArrowDown' }); + fireEvent.keyDown(document, { key: 'a' }); expect(onClose).not.toHaveBeenCalled(); }); @@ -302,212 +274,207 @@ describe('QuickActionsMenu', () => { const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener'); const { rerender } = render(); - rerender(); expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function)); - removeEventListenerSpy.mockRestore(); }); }); - describe('Touch feedback', () => { - it('applies highlight color on touch start (enabled)', () => { - render(); - const menuItem = screen.getByRole('menuitem'); - - fireEvent.touchStart(menuItem); - // Should apply accent color with alpha (browser converts to rgba) - // #89b4fa20 becomes rgba(137, 180, 250, 0.125) - expect(menuItem.style.backgroundColor).toContain('rgba(137, 180, 250'); - }); + describe('Keyboard navigation', () => { + it('moves selection down with ArrowDown', () => { + render(); + const options = screen.getAllByRole('option'); - it('resets background on touch end', () => { - render(); - const menuItem = screen.getByRole('menuitem'); + // First item should be selected initially + expect(options[0]).toHaveAttribute('aria-selected', 'true'); - fireEvent.touchStart(menuItem); - fireEvent.touchEnd(menuItem); - expect(menuItem.style.backgroundColor).toBe('transparent'); + fireEvent.keyDown(document, { key: 'ArrowDown' }); + expect(options[1]).toHaveAttribute('aria-selected', 'true'); }); - it('does not apply highlight on touch start when disabled', () => { - render(); - const menuItem = screen.getByRole('menuitem'); - const initialBg = menuItem.style.backgroundColor; + it('moves selection up with ArrowUp', () => { + render(); + const options = screen.getAllByRole('option'); - fireEvent.touchStart(menuItem); - // Should not change when disabled - expect(menuItem.style.backgroundColor).toBe(initialBg); + // Move down first + fireEvent.keyDown(document, { key: 'ArrowDown' }); + expect(options[1]).toHaveAttribute('aria-selected', 'true'); + + // Then up + fireEvent.keyDown(document, { key: 'ArrowUp' }); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); }); - }); - describe('Mouse hover feedback', () => { - it('applies highlight color on mouse enter (enabled)', () => { - render(); - const menuItem = screen.getByRole('menuitem'); + it('executes selected action with Enter key', () => { + const actionFn = vi.fn(); + const actions = [makeAction({ id: 'enter-test', label: 'Enter Test', action: actionFn })]; + render(); - fireEvent.mouseEnter(menuItem); - // Browser converts #89b4fa20 to rgba(137, 180, 250, 0.125) - expect(menuItem.style.backgroundColor).toContain('rgba(137, 180, 250'); + fireEvent.keyDown(document, { key: 'Enter' }); + expect(actionFn).toHaveBeenCalled(); }); - it('resets background on mouse leave', () => { - render(); - const menuItem = screen.getByRole('menuitem'); + it('resets selection to 0 when search query changes', () => { + render(); + const options = screen.getAllByRole('option'); - fireEvent.mouseEnter(menuItem); - fireEvent.mouseLeave(menuItem); - expect(menuItem.style.backgroundColor).toBe('transparent'); - }); + // Move selection down + fireEvent.keyDown(document, { key: 'ArrowDown' }); + expect(options[1]).toHaveAttribute('aria-selected', 'true'); - it('does not apply highlight on mouse enter when disabled', () => { - render(); - const menuItem = screen.getByRole('menuitem'); - const initialBg = menuItem.style.backgroundColor; + // Change search — selection should reset + const input = screen.getByPlaceholderText('Search actions...'); + fireEvent.change(input, { target: { value: 'Go' } }); - fireEvent.mouseEnter(menuItem); - expect(menuItem.style.backgroundColor).toBe(initialBg); + // Only one option now, and it should be selected + const newOptions = screen.getAllByRole('option'); + expect(newOptions[0]).toHaveAttribute('aria-selected', 'true'); }); }); - describe('Menu item styling', () => { - it('has minimum touch target height', () => { - render(); - const menuItem = screen.getByRole('menuitem'); - expect(menuItem).toHaveStyle({ minHeight: '44px' }); + describe('Backdrop interaction', () => { + it('closes menu when backdrop is clicked', () => { + const onClose = vi.fn(); + render(); + const backdrop = document.querySelector('[aria-hidden="true"]') as HTMLElement; + fireEvent.click(backdrop); + expect(onClose).toHaveBeenCalled(); }); - it('has correct font styling', () => { + it('backdrop covers full viewport', () => { render(); - const menuItem = screen.getByRole('menuitem'); - expect(menuItem).toHaveStyle({ - fontSize: '15px', - fontWeight: '500', + const backdrop = document.querySelector('[aria-hidden="true"]') as HTMLElement; + expect(backdrop).toHaveStyle({ + position: 'fixed', + top: '0px', + left: '0px', + right: '0px', + bottom: '0px', }); }); - it('has cursor pointer when enabled', () => { - render(); - const menuItem = screen.getByRole('menuitem'); - expect(menuItem).toHaveStyle({ cursor: 'pointer' }); - }); - - it('has cursor default when disabled', () => { - render(); - const menuItem = screen.getByRole('menuitem'); - expect(menuItem).toHaveStyle({ cursor: 'default' }); + it('backdrop has semi-transparent background', () => { + render(); + const backdrop = document.querySelector('[aria-hidden="true"]') as HTMLElement; + expect(backdrop.style.backgroundColor).toContain('rgba(0, 0, 0'); }); }); - describe('CSS keyframes injection', () => { - it('injects quickActionsPopIn keyframes', () => { + describe('Accessibility', () => { + it('has role="dialog" on container', () => { render(); - const styleElement = document.querySelector('style'); - expect(styleElement).toBeInTheDocument(); - expect(styleElement?.textContent).toContain('quickActionsPopIn'); + expect(screen.getByRole('dialog')).toBeInTheDocument(); }); - it('injects quickActionsFadeIn keyframes', () => { + it('has aria-label on dialog container', () => { render(); - const styleElement = document.querySelector('style'); - expect(styleElement?.textContent).toContain('quickActionsFadeIn'); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAttribute('aria-label', 'Command palette'); }); - it('keyframes include transform: scale animation', () => { + it('has aria-modal on dialog container', () => { render(); - const styleElement = document.querySelector('style'); - expect(styleElement?.textContent).toContain('scale(0.9)'); - expect(styleElement?.textContent).toContain('scale(1)'); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAttribute('aria-modal', 'true'); }); - }); - describe('Accessibility', () => { - it('has role="menu" on container', () => { + it('has role="option" on action buttons', () => { render(); - expect(screen.getByRole('menu')).toBeInTheDocument(); + const options = screen.getAllByRole('option'); + expect(options.length).toBeGreaterThan(0); }); - it('has aria-label on menu container', () => { + it('backdrop has aria-hidden', () => { render(); - const menu = screen.getByRole('menu'); - expect(menu).toHaveAttribute('aria-label', 'Quick actions'); + const backdrop = document.querySelector('[aria-hidden="true"]'); + expect(backdrop).toBeInTheDocument(); }); + }); - it('has role="menuitem" on action buttons', () => { + describe('Menu styling', () => { + it('applies correct z-index', () => { render(); - expect(screen.getByRole('menuitem')).toBeInTheDocument(); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveStyle({ zIndex: '300' }); }); - it('has aria-disabled on disabled items', () => { - render(); - const menuItem = screen.getByRole('menuitem'); - expect(menuItem).toHaveAttribute('aria-disabled', 'true'); + it('has animation', () => { + render(); + const dialog = screen.getByRole('dialog'); + expect(dialog.style.animation).toContain('quickActionsPopIn'); }); - it('backdrop has aria-hidden', () => { + it('has proper border radius', () => { render(); - const backdrop = document.querySelector('[aria-hidden="true"]'); - expect(backdrop).toBeInTheDocument(); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveStyle({ borderRadius: '16px' }); }); }); - describe('Backdrop styling', () => { - it('backdrop covers full viewport', () => { + describe('CSS keyframes injection', () => { + it('injects quickActionsPopIn keyframes', () => { render(); - const backdrop = document.querySelector('[aria-hidden="true"]') as HTMLElement; - expect(backdrop).toHaveStyle({ - position: 'fixed', - top: '0px', - left: '0px', - right: '0px', - bottom: '0px', - }); + const styleElement = document.querySelector('style'); + expect(styleElement).toBeInTheDocument(); + expect(styleElement?.textContent).toContain('quickActionsPopIn'); }); - it('backdrop has lower z-index than menu', () => { + it('injects quickActionsFadeIn keyframes', () => { render(); - const backdrop = document.querySelector('[aria-hidden="true"]') as HTMLElement; - const menu = screen.getByRole('menu'); - expect(backdrop).toHaveStyle({ zIndex: '199' }); - expect(menu).toHaveStyle({ zIndex: '200' }); + const styleElement = document.querySelector('style'); + expect(styleElement?.textContent).toContain('quickActionsFadeIn'); }); - it('backdrop has semi-transparent background', () => { + it('keyframes include transform: scale animation', () => { render(); - const backdrop = document.querySelector('[aria-hidden="true"]') as HTMLElement; - expect(backdrop.style.backgroundColor).toContain('rgba(0, 0, 0'); + const styleElement = document.querySelector('style'); + expect(styleElement?.textContent).toContain('scale(0.9'); + expect(styleElement?.textContent).toContain('scale(1)'); }); + }); - it('backdrop has fade animation', () => { + describe('Footer hint', () => { + it('renders keyboard navigation hints', () => { render(); - const backdrop = document.querySelector('[aria-hidden="true"]') as HTMLElement; - expect(backdrop.style.animation).toContain('quickActionsFadeIn'); + expect(screen.getByText('navigate')).toBeInTheDocument(); + expect(screen.getByText('select')).toBeInTheDocument(); + expect(screen.getByText('close')).toBeInTheDocument(); }); }); - describe('Type exports', () => { - it('QuickAction type is properly defined', () => { - const action: QuickAction = 'switch_mode'; - expect(action).toBe('switch_mode'); + describe('Touch feedback', () => { + it('applies highlight color on touch start', () => { + const actions = [makeAction({ id: 'touch-test', label: 'Touch Test' })]; + render(); + const option = screen.getByRole('option'); + + fireEvent.touchStart(option); + expect(option.style.backgroundColor).toContain('rgba(137, 180, 250'); }); - }); - describe('Edge cases', () => { - it('handles anchor position at screen edge (0, 0)', () => { - render(); - const menu = screen.getByRole('menu'); - // Should clamp to left edge minimum - expect(menu.style.left).toBe('16px'); + it('resets background on touch end', () => { + const actions = [makeAction({ id: 'touch-test', label: 'Touch Test' })]; + render(); + const option = screen.getByRole('option'); + + fireEvent.touchStart(option); + fireEvent.touchEnd(option); + // After touch end, reverts to either selected highlight or transparent + expect(option.style.backgroundColor).toBeTruthy(); }); + }); - it('handles anchor position at bottom-right corner', () => { - render(); - const menu = screen.getByRole('menu'); - // Should clamp to right edge - expect(parseInt(menu.style.left)).toBeLessThanOrEqual(184); + describe('Type exports', () => { + it('CommandPaletteAction type is properly defined', () => { + const action: CommandPaletteAction = makeAction(); + expect(action.id).toBeDefined(); + expect(action.label).toBeDefined(); + expect(action.category).toBeDefined(); }); + }); + describe('Edge cases', () => { it('handles rapid open/close transitions', () => { const { rerender } = render(); @@ -516,24 +483,28 @@ describe('QuickActionsMenu', () => { rerender(); } - expect(screen.getByRole('menu')).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); }); - it('handles mode switching while open', () => { - const { rerender } = render(); - expect(screen.getByText('Switch to Terminal')).toBeInTheDocument(); + it('handles actions list updating while open', () => { + const { rerender } = render(); + expect(screen.getByText('Go to Home')).toBeInTheDocument(); - rerender(); - expect(screen.getByText('Switch to AI')).toBeInTheDocument(); + const newActions = [ + makeAction({ id: 'new-action', label: 'New Action', category: 'Navigation' }), + ]; + rerender(); + expect(screen.getByText('New Action')).toBeInTheDocument(); + expect(screen.queryByText('Go to Home')).not.toBeInTheDocument(); }); - it('handles hasActiveSession toggle while open', () => { - const { rerender } = render(); - const menuItem = screen.getByRole('menuitem'); - expect(menuItem).not.toBeDisabled(); - - rerender(); - expect(screen.getByRole('menuitem')).toBeDisabled(); + it('handles a large number of actions', () => { + const manyActions: CommandPaletteAction[] = Array.from({ length: 50 }, (_, i) => + makeAction({ id: `action-${i}`, label: `Action ${i}`, category: 'Navigation' }) + ); + render(); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(50); }); }); diff --git a/src/main/preload/process.ts b/src/main/preload/process.ts index 6fb18ec876..94bde82334 100644 --- a/src/main/preload/process.ts +++ b/src/main/preload/process.ts @@ -516,6 +516,569 @@ export function createProcessApi() { ipcRenderer.send(responseChannel, result); }, + /** + * Subscribe to remote get auto-run docs from web interface (request-response) + */ + onRemoteGetAutoRunDocs: ( + callback: (sessionId: string, responseChannel: string) => void + ): (() => void) => { + const handler = (_: unknown, sessionId: string, responseChannel: string) => { + try { + Promise.resolve(callback(sessionId, responseChannel)).catch(() => { + ipcRenderer.send(responseChannel, []); + }); + } catch { + ipcRenderer.send(responseChannel, []); + } + }; + ipcRenderer.on('remote:getAutoRunDocs', handler); + return () => ipcRenderer.removeListener('remote:getAutoRunDocs', handler); + }, + + /** + * Send response for remote get auto-run docs + */ + sendRemoteGetAutoRunDocsResponse: (responseChannel: string, documents: any[]): void => { + ipcRenderer.send(responseChannel, documents); + }, + + /** + * Subscribe to remote get auto-run doc content from web interface (request-response) + */ + onRemoteGetAutoRunDocContent: ( + callback: (sessionId: string, filename: string, responseChannel: string) => void + ): (() => void) => { + const handler = ( + _: unknown, + sessionId: string, + filename: string, + responseChannel: string + ) => { + try { + Promise.resolve(callback(sessionId, filename, responseChannel)).catch(() => { + ipcRenderer.send(responseChannel, ''); + }); + } catch { + ipcRenderer.send(responseChannel, ''); + } + }; + ipcRenderer.on('remote:getAutoRunDocContent', handler); + return () => ipcRenderer.removeListener('remote:getAutoRunDocContent', handler); + }, + + /** + * Send response for remote get auto-run doc content + */ + sendRemoteGetAutoRunDocContentResponse: (responseChannel: string, content: string): void => { + ipcRenderer.send(responseChannel, content); + }, + + /** + * Subscribe to remote save auto-run doc from web interface (request-response) + */ + onRemoteSaveAutoRunDoc: ( + callback: ( + sessionId: string, + filename: string, + content: string, + responseChannel: string + ) => void + ): (() => void) => { + const handler = ( + _: unknown, + sessionId: string, + filename: string, + content: string, + responseChannel: string + ) => { + try { + Promise.resolve(callback(sessionId, filename, content, responseChannel)).catch(() => { + ipcRenderer.send(responseChannel, false); + }); + } catch { + ipcRenderer.send(responseChannel, false); + } + }; + ipcRenderer.on('remote:saveAutoRunDoc', handler); + return () => ipcRenderer.removeListener('remote:saveAutoRunDoc', handler); + }, + + /** + * Send response for remote save auto-run doc + */ + sendRemoteSaveAutoRunDocResponse: (responseChannel: string, success: boolean): void => { + ipcRenderer.send(responseChannel, success); + }, + + /** + * Subscribe to remote stop auto-run from web interface (fire-and-forget) + */ + onRemoteStopAutoRun: (callback: (sessionId: string) => void): (() => void) => { + const handler = (_: unknown, sessionId: string) => callback(sessionId); + ipcRenderer.on('remote:stopAutoRun', handler); + return () => ipcRenderer.removeListener('remote:stopAutoRun', handler); + }, + + /** + * Subscribe to remote set setting from web interface + * Uses request-response pattern with a unique responseChannel + */ + onRemoteSetSetting: ( + callback: (key: string, value: unknown, responseChannel: string) => void + ): (() => void) => { + const handler = (_: unknown, key: string, value: unknown, responseChannel: string) => + callback(key, value, responseChannel); + ipcRenderer.on('remote:setSetting', handler); + return () => ipcRenderer.removeListener('remote:setSetting', handler); + }, + + /** + * Send response for remote set setting + */ + sendRemoteSetSettingResponse: (responseChannel: string, success: boolean): void => { + ipcRenderer.send(responseChannel, success); + }, + + /** + * Subscribe to remote create session from web interface + * Uses request-response pattern with a unique responseChannel + */ + onRemoteCreateSession: ( + callback: ( + name: string, + toolType: string, + cwd: string, + groupId: string | undefined, + responseChannel: string + ) => void + ): (() => void) => { + const handler = ( + _: unknown, + name: string, + toolType: string, + cwd: string, + groupId: string | undefined, + responseChannel: string + ) => callback(name, toolType, cwd, groupId, responseChannel); + ipcRenderer.on('remote:createSession', handler); + return () => ipcRenderer.removeListener('remote:createSession', handler); + }, + + /** + * Send response for remote create session + */ + sendRemoteCreateSessionResponse: ( + responseChannel: string, + result: { sessionId: string } | null + ): void => { + ipcRenderer.send(responseChannel, result); + }, + + /** + * Subscribe to remote delete session from web interface (fire-and-forget) + */ + onRemoteDeleteSession: (callback: (sessionId: string) => void): (() => void) => { + const handler = (_: unknown, sessionId: string) => callback(sessionId); + ipcRenderer.on('remote:deleteSession', handler); + return () => ipcRenderer.removeListener('remote:deleteSession', handler); + }, + + /** + * Subscribe to remote rename session from web interface + * Uses request-response pattern with a unique responseChannel + */ + onRemoteRenameSession: ( + callback: (sessionId: string, newName: string, responseChannel: string) => void + ): (() => void) => { + const handler = (_: unknown, sessionId: string, newName: string, responseChannel: string) => + callback(sessionId, newName, responseChannel); + ipcRenderer.on('remote:renameSession', handler); + return () => ipcRenderer.removeListener('remote:renameSession', handler); + }, + + /** + * Send response for remote rename session + */ + sendRemoteRenameSessionResponse: (responseChannel: string, success: boolean): void => { + ipcRenderer.send(responseChannel, success); + }, + + /** + * Subscribe to remote create group from web interface + * Uses request-response pattern with a unique responseChannel + */ + onRemoteCreateGroup: ( + callback: (name: string, emoji: string | undefined, responseChannel: string) => void + ): (() => void) => { + const handler = ( + _: unknown, + name: string, + emoji: string | undefined, + responseChannel: string + ) => callback(name, emoji, responseChannel); + ipcRenderer.on('remote:createGroup', handler); + return () => ipcRenderer.removeListener('remote:createGroup', handler); + }, + + /** + * Send response for remote create group + */ + sendRemoteCreateGroupResponse: ( + responseChannel: string, + result: { id: string } | null + ): void => { + ipcRenderer.send(responseChannel, result); + }, + + /** + * Subscribe to remote rename group from web interface + * Uses request-response pattern with a unique responseChannel + */ + onRemoteRenameGroup: ( + callback: (groupId: string, name: string, responseChannel: string) => void + ): (() => void) => { + const handler = (_: unknown, groupId: string, name: string, responseChannel: string) => + callback(groupId, name, responseChannel); + ipcRenderer.on('remote:renameGroup', handler); + return () => ipcRenderer.removeListener('remote:renameGroup', handler); + }, + + /** + * Send response for remote rename group + */ + sendRemoteRenameGroupResponse: (responseChannel: string, success: boolean): void => { + ipcRenderer.send(responseChannel, success); + }, + + /** + * Subscribe to remote delete group from web interface (fire-and-forget) + */ + onRemoteDeleteGroup: (callback: (groupId: string) => void): (() => void) => { + const handler = (_: unknown, groupId: string) => callback(groupId); + ipcRenderer.on('remote:deleteGroup', handler); + return () => ipcRenderer.removeListener('remote:deleteGroup', handler); + }, + + /** + * Subscribe to remote move session to group from web interface + * Uses request-response pattern with a unique responseChannel + */ + onRemoteMoveSessionToGroup: ( + callback: (sessionId: string, groupId: string | null, responseChannel: string) => void + ): (() => void) => { + const handler = ( + _: unknown, + sessionId: string, + groupId: string | null, + responseChannel: string + ) => callback(sessionId, groupId, responseChannel); + ipcRenderer.on('remote:moveSessionToGroup', handler); + return () => ipcRenderer.removeListener('remote:moveSessionToGroup', handler); + }, + + /** + * Send response for remote move session to group + */ + sendRemoteMoveSessionToGroupResponse: (responseChannel: string, success: boolean): void => { + ipcRenderer.send(responseChannel, success); + }, + + /** + * Subscribe to remote get git status from web interface + * Uses request-response pattern with a unique responseChannel + */ + onRemoteGetGitStatus: ( + callback: (sessionId: string, responseChannel: string) => void + ): (() => void) => { + const handler = (_: unknown, sessionId: string, responseChannel: string) => { + try { + Promise.resolve(callback(sessionId, responseChannel)).catch(() => { + ipcRenderer.send(responseChannel, { branch: '', files: [], ahead: 0, behind: 0 }); + }); + } catch { + ipcRenderer.send(responseChannel, { branch: '', files: [], ahead: 0, behind: 0 }); + } + }; + ipcRenderer.on('remote:getGitStatus', handler); + return () => ipcRenderer.removeListener('remote:getGitStatus', handler); + }, + + /** + * Send response for remote get git status + */ + sendRemoteGetGitStatusResponse: (responseChannel: string, result: any): void => { + ipcRenderer.send(responseChannel, result); + }, + + /** + * Subscribe to remote get git diff from web interface + * Uses request-response pattern with a unique responseChannel + */ + onRemoteGetGitDiff: ( + callback: (sessionId: string, filePath: string | undefined, responseChannel: string) => void + ): (() => void) => { + const handler = ( + _: unknown, + sessionId: string, + filePath: string | undefined, + responseChannel: string + ) => { + try { + Promise.resolve(callback(sessionId, filePath, responseChannel)).catch(() => { + ipcRenderer.send(responseChannel, { diff: '', files: [] }); + }); + } catch { + ipcRenderer.send(responseChannel, { diff: '', files: [] }); + } + }; + ipcRenderer.on('remote:getGitDiff', handler); + return () => ipcRenderer.removeListener('remote:getGitDiff', handler); + }, + + /** + * Send response for remote get git diff + */ + sendRemoteGetGitDiffResponse: (responseChannel: string, result: any): void => { + ipcRenderer.send(responseChannel, result); + }, + + /** + * Subscribe to remote get group chats from web interface + * Uses request-response pattern with a unique responseChannel + */ + onRemoteGetGroupChats: (callback: (responseChannel: string) => void): (() => void) => { + const handler = (_: unknown, responseChannel: string) => callback(responseChannel); + ipcRenderer.on('remote:getGroupChats', handler); + return () => ipcRenderer.removeListener('remote:getGroupChats', handler); + }, + + /** + * Send response for remote get group chats + */ + sendRemoteGetGroupChatsResponse: (responseChannel: string, result: any): void => { + ipcRenderer.send(responseChannel, result); + }, + + /** + * Subscribe to remote start group chat from web interface + * Uses request-response pattern with a unique responseChannel + */ + onRemoteStartGroupChat: ( + callback: (topic: string, participantIds: string[], responseChannel: string) => void + ): (() => void) => { + const handler = ( + _: unknown, + topic: string, + participantIds: string[], + responseChannel: string + ) => callback(topic, participantIds, responseChannel); + ipcRenderer.on('remote:startGroupChat', handler); + return () => ipcRenderer.removeListener('remote:startGroupChat', handler); + }, + + /** + * Send response for remote start group chat + */ + sendRemoteStartGroupChatResponse: ( + responseChannel: string, + result: { chatId: string } | null + ): void => { + ipcRenderer.send(responseChannel, result); + }, + + /** + * Subscribe to remote get group chat state from web interface + * Uses request-response pattern with a unique responseChannel + */ + onRemoteGetGroupChatState: ( + callback: (chatId: string, responseChannel: string) => void + ): (() => void) => { + const handler = (_: unknown, chatId: string, responseChannel: string) => + callback(chatId, responseChannel); + ipcRenderer.on('remote:getGroupChatState', handler); + return () => ipcRenderer.removeListener('remote:getGroupChatState', handler); + }, + + /** + * Send response for remote get group chat state + */ + sendRemoteGetGroupChatStateResponse: (responseChannel: string, result: any): void => { + ipcRenderer.send(responseChannel, result); + }, + + /** + * Subscribe to remote stop group chat from web interface + * Uses request-response pattern with a unique responseChannel + */ + onRemoteStopGroupChat: ( + callback: (chatId: string, responseChannel: string) => void + ): (() => void) => { + const handler = (_: unknown, chatId: string, responseChannel: string) => + callback(chatId, responseChannel); + ipcRenderer.on('remote:stopGroupChat', handler); + return () => ipcRenderer.removeListener('remote:stopGroupChat', handler); + }, + + /** + * Send response for remote stop group chat + */ + sendRemoteStopGroupChatResponse: (responseChannel: string, success: boolean): void => { + ipcRenderer.send(responseChannel, success); + }, + + /** + * Subscribe to remote send group chat message from web interface + * Uses request-response pattern with a unique responseChannel + */ + onRemoteSendGroupChatMessage: ( + callback: (chatId: string, message: string, responseChannel: string) => void + ): (() => void) => { + const handler = (_: unknown, chatId: string, message: string, responseChannel: string) => + callback(chatId, message, responseChannel); + ipcRenderer.on('remote:sendGroupChatMessage', handler); + return () => ipcRenderer.removeListener('remote:sendGroupChatMessage', handler); + }, + + /** + * Send response for remote send group chat message + */ + sendRemoteSendGroupChatMessageResponse: (responseChannel: string, success: boolean): void => { + ipcRenderer.send(responseChannel, success); + }, + + /** + * Subscribe to remote merge context from web interface + * Uses request-response pattern with a unique responseChannel + */ + onRemoteMergeContext: ( + callback: (sourceSessionId: string, targetSessionId: string, responseChannel: string) => void + ): (() => void) => { + const handler = ( + _: unknown, + sourceSessionId: string, + targetSessionId: string, + responseChannel: string + ) => callback(sourceSessionId, targetSessionId, responseChannel); + ipcRenderer.on('remote:mergeContext', handler); + return () => ipcRenderer.removeListener('remote:mergeContext', handler); + }, + + /** + * Send response for remote merge context + */ + sendRemoteMergeContextResponse: (responseChannel: string, success: boolean): void => { + ipcRenderer.send(responseChannel, success); + }, + + /** + * Subscribe to remote transfer context from web interface + * Uses request-response pattern with a unique responseChannel + */ + onRemoteTransferContext: ( + callback: (sourceSessionId: string, targetSessionId: string, responseChannel: string) => void + ): (() => void) => { + const handler = ( + _: unknown, + sourceSessionId: string, + targetSessionId: string, + responseChannel: string + ) => callback(sourceSessionId, targetSessionId, responseChannel); + ipcRenderer.on('remote:transferContext', handler); + return () => ipcRenderer.removeListener('remote:transferContext', handler); + }, + + /** + * Send response for remote transfer context + */ + sendRemoteTransferContextResponse: (responseChannel: string, success: boolean): void => { + ipcRenderer.send(responseChannel, success); + }, + + /** + * Subscribe to remote summarize context from web interface + * Uses request-response pattern with a unique responseChannel + */ + onRemoteSummarizeContext: ( + callback: (sessionId: string, responseChannel: string) => void + ): (() => void) => { + const handler = (_: unknown, sessionId: string, responseChannel: string) => + callback(sessionId, responseChannel); + ipcRenderer.on('remote:summarizeContext', handler); + return () => ipcRenderer.removeListener('remote:summarizeContext', handler); + }, + + /** + * Send response for remote summarize context + */ + sendRemoteSummarizeContextResponse: (responseChannel: string, success: boolean): void => { + ipcRenderer.send(responseChannel, success); + }, + + /** + * Subscribe to remote get Cue subscriptions from web interface + */ + onRemoteGetCueSubscriptions: ( + callback: (sessionId: string | undefined, responseChannel: string) => void + ): (() => void) => { + const handler = (_: unknown, sessionId: string | undefined, responseChannel: string) => + callback(sessionId, responseChannel); + ipcRenderer.on('remote:getCueSubscriptions', handler); + return () => ipcRenderer.removeListener('remote:getCueSubscriptions', handler); + }, + + /** + * Send response for remote get Cue subscriptions + */ + sendRemoteGetCueSubscriptionsResponse: (responseChannel: string, result: unknown): void => { + ipcRenderer.send(responseChannel, result); + }, + + /** + * Subscribe to remote toggle Cue subscription from web interface + */ + onRemoteToggleCueSubscription: ( + callback: (subscriptionId: string, enabled: boolean, responseChannel: string) => void + ): (() => void) => { + const handler = ( + _: unknown, + subscriptionId: string, + enabled: boolean, + responseChannel: string + ) => callback(subscriptionId, enabled, responseChannel); + ipcRenderer.on('remote:toggleCueSubscription', handler); + return () => ipcRenderer.removeListener('remote:toggleCueSubscription', handler); + }, + + /** + * Send response for remote toggle Cue subscription + */ + sendRemoteToggleCueSubscriptionResponse: (responseChannel: string, success: boolean): void => { + ipcRenderer.send(responseChannel, success); + }, + + /** + * Subscribe to remote get Cue activity from web interface + */ + onRemoteGetCueActivity: ( + callback: (sessionId: string | undefined, limit: number, responseChannel: string) => void + ): (() => void) => { + const handler = ( + _: unknown, + sessionId: string | undefined, + limit: number, + responseChannel: string + ) => callback(sessionId, limit, responseChannel); + ipcRenderer.on('remote:getCueActivity', handler); + return () => ipcRenderer.removeListener('remote:getCueActivity', handler); + }, + + /** + * Send response for remote get Cue activity + */ + sendRemoteGetCueActivityResponse: (responseChannel: string, result: unknown): void => { + ipcRenderer.send(responseChannel, result); + }, + /** * Subscribe to stderr from runCommand (separate stream) */ diff --git a/src/main/web-server/WebServer.ts b/src/main/web-server/WebServer.ts index 064c1a99fc..36fcb34ea5 100644 --- a/src/main/web-server/WebServer.ts +++ b/src/main/web-server/WebServer.ts @@ -44,10 +44,13 @@ import type { AITabData, CustomAICommand, AutoRunState, + AutoRunDocument, CliActivity, + NotificationEvent, SessionBroadcastData, WebClient, WebClientMessage, + WebSettings, GetSessionsCallback, GetSessionDetailCallback, WriteToSessionCallback, @@ -69,6 +72,40 @@ import type { GetThemeCallback, GetCustomCommandsCallback, GetHistoryCallback, + GetAutoRunDocsCallback, + GetAutoRunDocContentCallback, + SaveAutoRunDocCallback, + StopAutoRunCallback, + GetSettingsCallback, + SetSettingCallback, + GetGroupsCallback, + CreateGroupCallback, + RenameGroupCallback, + DeleteGroupCallback, + MoveSessionToGroupCallback, + CreateSessionCallback, + DeleteSessionCallback, + RenameSessionCallback, + GetGitStatusCallback, + GetGitDiffCallback, + GroupData, + GetGroupChatsCallback, + StartGroupChatCallback, + GetGroupChatStateCallback, + StopGroupChatCallback, + SendGroupChatMessageCallback, + GroupChatMessage, + GroupChatState, + MergeContextCallback, + TransferContextCallback, + SummarizeContextCallback, + GetCueSubscriptionsCallback, + ToggleCueSubscriptionCallback, + GetCueActivityCallback, + CueActivityEntry, + CueSubscriptionInfo, + GetUsageDashboardCallback, + GetAchievementsCallback, } from './types'; // Logger context for all web server logs @@ -330,6 +367,126 @@ export class WebServer { this.callbackRegistry.setGetHistoryCallback(callback); } + setGetAutoRunDocsCallback(callback: GetAutoRunDocsCallback): void { + this.callbackRegistry.setGetAutoRunDocsCallback(callback); + } + + setGetAutoRunDocContentCallback(callback: GetAutoRunDocContentCallback): void { + this.callbackRegistry.setGetAutoRunDocContentCallback(callback); + } + + setSaveAutoRunDocCallback(callback: SaveAutoRunDocCallback): void { + this.callbackRegistry.setSaveAutoRunDocCallback(callback); + } + + setStopAutoRunCallback(callback: StopAutoRunCallback): void { + this.callbackRegistry.setStopAutoRunCallback(callback); + } + + setGetSettingsCallback(callback: GetSettingsCallback): void { + this.callbackRegistry.setGetSettingsCallback(callback); + } + + setSetSettingCallback(callback: SetSettingCallback): void { + this.callbackRegistry.setSetSettingCallback(callback); + } + + setGetGroupsCallback(callback: GetGroupsCallback): void { + this.callbackRegistry.setGetGroupsCallback(callback); + } + + setCreateGroupCallback(callback: CreateGroupCallback): void { + this.callbackRegistry.setCreateGroupCallback(callback); + } + + setRenameGroupCallback(callback: RenameGroupCallback): void { + this.callbackRegistry.setRenameGroupCallback(callback); + } + + setDeleteGroupCallback(callback: DeleteGroupCallback): void { + this.callbackRegistry.setDeleteGroupCallback(callback); + } + + setMoveSessionToGroupCallback(callback: MoveSessionToGroupCallback): void { + this.callbackRegistry.setMoveSessionToGroupCallback(callback); + } + + setCreateSessionCallback(callback: CreateSessionCallback): void { + this.callbackRegistry.setCreateSessionCallback(callback); + } + + setDeleteSessionCallback(callback: DeleteSessionCallback): void { + this.callbackRegistry.setDeleteSessionCallback(callback); + } + + setRenameSessionCallback(callback: RenameSessionCallback): void { + this.callbackRegistry.setRenameSessionCallback(callback); + } + + setGetGitStatusCallback(callback: GetGitStatusCallback): void { + this.callbackRegistry.setGetGitStatusCallback(callback); + } + + setGetGitDiffCallback(callback: GetGitDiffCallback): void { + this.callbackRegistry.setGetGitDiffCallback(callback); + } + + setGetGroupChatsCallback(callback: GetGroupChatsCallback): void { + this.callbackRegistry.setGetGroupChatsCallback(callback); + } + + setStartGroupChatCallback(callback: StartGroupChatCallback): void { + this.callbackRegistry.setStartGroupChatCallback(callback); + } + + setGetGroupChatStateCallback(callback: GetGroupChatStateCallback): void { + this.callbackRegistry.setGetGroupChatStateCallback(callback); + } + + setStopGroupChatCallback(callback: StopGroupChatCallback): void { + this.callbackRegistry.setStopGroupChatCallback(callback); + } + + setSendGroupChatMessageCallback(callback: SendGroupChatMessageCallback): void { + this.callbackRegistry.setSendGroupChatMessageCallback(callback); + } + + setMergeContextCallback(callback: MergeContextCallback): void { + this.callbackRegistry.setMergeContextCallback(callback); + } + + setTransferContextCallback(callback: TransferContextCallback): void { + this.callbackRegistry.setTransferContextCallback(callback); + } + + setSummarizeContextCallback(callback: SummarizeContextCallback): void { + this.callbackRegistry.setSummarizeContextCallback(callback); + } + + setGetCueSubscriptionsCallback(callback: GetCueSubscriptionsCallback): void { + this.callbackRegistry.setGetCueSubscriptionsCallback(callback); + } + + setToggleCueSubscriptionCallback(callback: ToggleCueSubscriptionCallback): void { + this.callbackRegistry.setToggleCueSubscriptionCallback(callback); + } + + setGetCueActivityCallback(callback: GetCueActivityCallback): void { + this.callbackRegistry.setGetCueActivityCallback(callback); + } + + setGetUsageDashboardCallback(callback: GetUsageDashboardCallback): void { + this.callbackRegistry.setGetUsageDashboardCallback(callback); + } + + setGetAchievementsCallback(callback: GetAchievementsCallback): void { + this.callbackRegistry.setGetAchievementsCallback(callback); + } + + broadcastGroupsChanged(groups: GroupData[]): void { + this.broadcastService.broadcastGroupsChanged(groups); + } + // ============ Rate Limiting ============ setRateLimitConfig(config: Partial): void { @@ -482,12 +639,60 @@ export class WebServer { this.callbackRegistry.refreshFileTree(sessionId), refreshAutoRunDocs: async (sessionId: string) => this.callbackRegistry.refreshAutoRunDocs(sessionId), - configureAutoRun: async (sessionId: string, config: any) => - this.callbackRegistry.configureAutoRun(sessionId, config), + configureAutoRun: async ( + sessionId: string, + config: Parameters[1] + ) => this.callbackRegistry.configureAutoRun(sessionId, config), getSessions: () => this.callbackRegistry.getSessions(), getLiveSessionInfo: (sessionId: string) => this.liveSessionManager.getLiveSessionInfo(sessionId), isSessionLive: (sessionId: string) => this.liveSessionManager.isSessionLive(sessionId), + getAutoRunDocs: async (sessionId: string) => this.callbackRegistry.getAutoRunDocs(sessionId), + getAutoRunDocContent: async (sessionId: string, filename: string) => + this.callbackRegistry.getAutoRunDocContent(sessionId, filename), + saveAutoRunDoc: async (sessionId: string, filename: string, content: string) => + this.callbackRegistry.saveAutoRunDoc(sessionId, filename, content), + stopAutoRun: async (sessionId: string) => this.callbackRegistry.stopAutoRun(sessionId), + getSettings: () => this.callbackRegistry.getSettings(), + setSetting: async (key: string, value: any) => this.callbackRegistry.setSetting(key, value), + getGroups: () => this.callbackRegistry.getGroups(), + createGroup: async (name: string, emoji?: string) => + this.callbackRegistry.createGroup(name, emoji), + renameGroup: async (groupId: string, name: string) => + this.callbackRegistry.renameGroup(groupId, name), + deleteGroup: async (groupId: string) => this.callbackRegistry.deleteGroup(groupId), + moveSessionToGroup: async (sessionId: string, groupId: string | null) => + this.callbackRegistry.moveSessionToGroup(sessionId, groupId), + createSession: async (name: string, toolType: string, cwd: string, groupId?: string) => + this.callbackRegistry.createSession(name, toolType, cwd, groupId), + deleteSession: async (sessionId: string) => this.callbackRegistry.deleteSession(sessionId), + renameSession: async (sessionId: string, newName: string) => + this.callbackRegistry.renameSession(sessionId, newName), + getGitStatus: async (sessionId: string) => this.callbackRegistry.getGitStatus(sessionId), + getGitDiff: async (sessionId: string, filePath?: string) => + this.callbackRegistry.getGitDiff(sessionId, filePath), + getGroupChats: async () => this.callbackRegistry.getGroupChats(), + startGroupChat: async (topic: string, participantIds: string[]) => + this.callbackRegistry.startGroupChat(topic, participantIds), + getGroupChatState: async (chatId: string) => this.callbackRegistry.getGroupChatState(chatId), + stopGroupChat: async (chatId: string) => this.callbackRegistry.stopGroupChat(chatId), + sendGroupChatMessage: async (chatId: string, message: string) => + this.callbackRegistry.sendGroupChatMessage(chatId, message), + mergeContext: async (sourceSessionId: string, targetSessionId: string) => + this.callbackRegistry.mergeContext(sourceSessionId, targetSessionId), + transferContext: async (sourceSessionId: string, targetSessionId: string) => + this.callbackRegistry.transferContext(sourceSessionId, targetSessionId), + summarizeContext: async (sessionId: string) => + this.callbackRegistry.summarizeContext(sessionId), + getCueSubscriptions: async (sessionId?: string) => + this.callbackRegistry.getCueSubscriptions(sessionId), + toggleCueSubscription: async (subscriptionId: string, enabled: boolean) => + this.callbackRegistry.toggleCueSubscription(subscriptionId, enabled), + getCueActivity: async (sessionId?: string, limit?: number) => + this.callbackRegistry.getCueActivity(sessionId, limit), + getUsageDashboard: async (timeRange: 'day' | 'week' | 'month' | 'all') => + this.callbackRegistry.getUsageDashboard(timeRange), + getAchievements: async () => this.callbackRegistry.getAchievements(), }); } @@ -497,6 +702,10 @@ export class WebServer { this.broadcastService.broadcastToAll(message); } + broadcastNotificationEvent(event: NotificationEvent): void { + this.broadcastService.broadcastNotificationEvent(event); + } + broadcastToSessionClients(sessionId: string, message: object): void { this.broadcastService.broadcastToSession(sessionId, message); } @@ -543,14 +752,46 @@ export class WebServer { this.broadcastService.broadcastCustomCommands(commands); } + broadcastSettingsChanged(settings: WebSettings): void { + this.broadcastService.broadcastSettingsChanged(settings); + } + broadcastAutoRunState(sessionId: string, state: AutoRunState | null): void { this.liveSessionManager.setAutoRunState(sessionId, state); } + broadcastAutoRunDocsChanged(sessionId: string, documents: AutoRunDocument[]): void { + this.broadcastService.broadcastAutoRunDocsChanged(sessionId, documents); + } + broadcastUserInput(sessionId: string, command: string, inputMode: 'ai' | 'terminal'): void { this.broadcastService.broadcastUserInput(sessionId, command, inputMode); } + broadcastGroupChatMessage(chatId: string, message: GroupChatMessage): void { + this.broadcastService.broadcastGroupChatMessage(chatId, message); + } + + broadcastGroupChatStateChange(chatId: string, state: Partial): void { + this.broadcastService.broadcastGroupChatStateChange(chatId, state); + } + + broadcastContextOperationProgress(sessionId: string, operation: string, progress: number): void { + this.broadcastService.broadcastContextOperationProgress(sessionId, operation, progress); + } + + broadcastContextOperationComplete(sessionId: string, operation: string, success: boolean): void { + this.broadcastService.broadcastContextOperationComplete(sessionId, operation, success); + } + + broadcastCueActivity(entry: CueActivityEntry): void { + this.broadcastService.broadcastCueActivity(entry); + } + + broadcastCueSubscriptionsChanged(subscriptions: CueSubscriptionInfo[]): void { + this.broadcastService.broadcastCueSubscriptionsChanged(subscriptions); + } + // ============ Server Lifecycle ============ getWebClientCount(): number { diff --git a/src/main/web-server/handlers/messageHandlers.ts b/src/main/web-server/handlers/messageHandlers.ts index 2799ea59b6..3c5c52770d 100644 --- a/src/main/web-server/handlers/messageHandlers.ts +++ b/src/main/web-server/handlers/messageHandlers.ts @@ -20,11 +20,32 @@ * - refresh_file_tree: Refresh the file tree for a session * - refresh_auto_run_docs: Refresh auto-run documents for a session * - configure_auto_run: Configure and optionally launch an auto-run session + * - get_auto_run_docs: List auto-run documents for a session + * - get_auto_run_state: Get current auto-run state for a session + * - get_auto_run_document: Read content of a specific auto-run document + * - save_auto_run_document: Write content to a specific auto-run document + * - stop_auto_run: Stop an active auto-run for a session + * - get_settings: Fetch current web settings + * - set_setting: Modify a single setting (allowlisted keys only) */ import path from 'path'; import { WebSocket } from 'ws'; import { logger } from '../../utils/logger'; +import type { + AutoRunDocument, + AutoRunState, + WebSettings, + SettingValue, + GroupData, + GitStatusResult, + GitDiffResult, + GroupChatState, + CueSubscriptionInfo, + CueActivityEntry, + UsageDashboardData, + AchievementData, +} from '../types'; // Logger context for all message handler logs const LOG_CONTEXT = 'WebServer'; @@ -118,6 +139,40 @@ export interface MessageHandlerCallbacks { }>; getLiveSessionInfo: (sessionId: string) => LiveSessionInfo | undefined; isSessionLive: (sessionId: string) => boolean; + getAutoRunDocs: (sessionId: string) => Promise; + getAutoRunDocContent: (sessionId: string, filename: string) => Promise; + saveAutoRunDoc: (sessionId: string, filename: string, content: string) => Promise; + stopAutoRun: (sessionId: string) => Promise; + getSettings: () => WebSettings; + setSetting: (key: string, value: SettingValue) => Promise; + getGroups: () => GroupData[]; + createGroup: (name: string, emoji?: string) => Promise<{ id: string } | null>; + renameGroup: (groupId: string, name: string) => Promise; + deleteGroup: (groupId: string) => Promise; + moveSessionToGroup: (sessionId: string, groupId: string | null) => Promise; + createSession: ( + name: string, + toolType: string, + cwd: string, + groupId?: string + ) => Promise<{ sessionId: string } | null>; + deleteSession: (sessionId: string) => Promise; + renameSession: (sessionId: string, newName: string) => Promise; + getGitStatus: (sessionId: string) => Promise; + getGitDiff: (sessionId: string, filePath?: string) => Promise; + getGroupChats: () => Promise; + startGroupChat: (topic: string, participantIds: string[]) => Promise<{ chatId: string } | null>; + getGroupChatState: (chatId: string) => Promise; + stopGroupChat: (chatId: string) => Promise; + sendGroupChatMessage: (chatId: string, message: string) => Promise; + mergeContext: (sourceSessionId: string, targetSessionId: string) => Promise; + transferContext: (sourceSessionId: string, targetSessionId: string) => Promise; + summarizeContext: (sessionId: string) => Promise; + getCueSubscriptions: (sessionId?: string) => Promise; + toggleCueSubscription: (subscriptionId: string, enabled: boolean) => Promise; + getCueActivity: (sessionId?: string, limit?: number) => Promise; + getUsageDashboard: (timeRange: 'day' | 'week' | 'month' | 'all') => Promise; + getAchievements: () => Promise; } /** @@ -232,6 +287,126 @@ export class WebSocketMessageHandler { this.handleConfigureAutoRun(client, message); break; + case 'get_auto_run_docs': + this.handleGetAutoRunDocs(client, message); + break; + + case 'get_auto_run_state': + this.handleGetAutoRunState(client, message); + break; + + case 'get_auto_run_document': + this.handleGetAutoRunDocument(client, message); + break; + + case 'save_auto_run_document': + this.handleSaveAutoRunDocument(client, message); + break; + + case 'stop_auto_run': + this.handleStopAutoRun(client, message); + break; + + case 'get_settings': + this.handleGetSettings(client, message); + break; + + case 'set_setting': + this.handleSetSetting(client, message); + break; + + case 'create_session': + this.handleCreateSession(client, message); + break; + + case 'delete_session': + this.handleDeleteSession(client, message); + break; + + case 'rename_session': + this.handleRenameSession(client, message); + break; + + case 'get_groups': + this.handleGetGroups(client, message); + break; + + case 'create_group': + this.handleCreateGroup(client, message); + break; + + case 'rename_group': + this.handleRenameGroup(client, message); + break; + + case 'delete_group': + this.handleDeleteGroup(client, message); + break; + + case 'move_session_to_group': + this.handleMoveSessionToGroup(client, message); + break; + + case 'get_git_status': + this.handleGetGitStatus(client, message); + break; + + case 'get_git_diff': + this.handleGetGitDiff(client, message); + break; + + case 'get_group_chats': + this.handleGetGroupChats(client, message); + break; + + case 'start_group_chat': + this.handleStartGroupChat(client, message); + break; + + case 'get_group_chat_state': + this.handleGetGroupChatState(client, message); + break; + + case 'send_group_chat_message': + this.handleSendGroupChatMessage(client, message); + break; + + case 'stop_group_chat': + this.handleStopGroupChat(client, message); + break; + + case 'merge_context': + this.handleMergeContext(client, message); + break; + + case 'transfer_context': + this.handleTransferContext(client, message); + break; + + case 'summarize_context': + this.handleSummarizeContext(client, message); + break; + + case 'get_cue_subscriptions': + this.handleGetCueSubscriptions(client, message); + break; + + case 'toggle_cue_subscription': + this.handleToggleCueSubscription(client, message); + break; + + case 'get_cue_activity': + this.handleGetCueActivity(client, message); + break; + + case 'get_usage_dashboard': + this.handleGetUsageDashboard(client, message); + break; + + case 'get_achievements': + this.handleGetAchievements(client, message); + break; + default: this.handleUnknown(client, message); } @@ -941,6 +1116,1047 @@ export class WebSocketMessageHandler { }); } + /** + * Validate that a filename does not contain path traversal sequences. + * Returns true if the filename is safe, false otherwise. + */ + private isValidFilename(filename: string): boolean { + return ( + typeof filename === 'string' && + filename.length > 0 && + !filename.includes('..') && + !filename.includes('/') && + !filename.includes('\\') + ); + } + + /** + * Handle get_auto_run_docs message - list Auto Run documents for a session + */ + private handleGetAutoRunDocs(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string; + logger.info(`[Web] Received get_auto_run_docs message: session=${sessionId}`, LOG_CONTEXT); + + if (!sessionId) { + this.sendError(client, 'Missing sessionId'); + return; + } + + if (!this.callbacks.getAutoRunDocs) { + this.sendError(client, 'Auto-run docs listing not configured'); + return; + } + + this.callbacks + .getAutoRunDocs(sessionId) + .then((documents) => { + this.send(client, { + type: 'auto_run_docs', + sessionId, + documents, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to get auto-run docs: ${error.message}`); + }); + } + + /** + * Handle get_auto_run_state message - get current Auto Run state for a session + */ + private handleGetAutoRunState(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string; + logger.info(`[Web] Received get_auto_run_state message: session=${sessionId}`, LOG_CONTEXT); + + if (!sessionId) { + this.sendError(client, 'Missing sessionId'); + return; + } + + if (!this.callbacks.getSessionDetail) { + this.sendError(client, 'Session detail not configured'); + return; + } + + const detail = this.callbacks.getSessionDetail(sessionId); + const state: AutoRunState | null = + ((detail as any)?.autoRunState as AutoRunState | null) ?? null; + + this.send(client, { + type: 'auto_run_state', + sessionId, + state, + requestId: message.requestId, + }); + } + + /** + * Handle get_auto_run_document message - read content of a specific Auto Run document + */ + private handleGetAutoRunDocument(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string; + const filename = message.filename as string; + logger.info( + `[Web] Received get_auto_run_document message: session=${sessionId}, filename=${filename}`, + LOG_CONTEXT + ); + + if (!sessionId || !filename) { + this.sendError(client, 'Missing sessionId or filename'); + return; + } + + if (!this.isValidFilename(filename)) { + this.sendError( + client, + 'Invalid filename: must not contain path separators or traversal sequences' + ); + return; + } + + if (!this.callbacks.getAutoRunDocContent) { + this.sendError(client, 'Auto-run document reading not configured'); + return; + } + + this.callbacks + .getAutoRunDocContent(sessionId, filename) + .then((content) => { + this.send(client, { + type: 'auto_run_document_content', + sessionId, + filename, + content, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to read auto-run document: ${error.message}`); + }); + } + + /** + * Handle save_auto_run_document message - write content to a specific Auto Run document + */ + private handleSaveAutoRunDocument(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string; + const filename = message.filename as string; + const content = message.content as string; + logger.info( + `[Web] Received save_auto_run_document message: session=${sessionId}, filename=${filename}`, + LOG_CONTEXT + ); + + if (!sessionId || !filename) { + this.sendError(client, 'Missing sessionId or filename'); + return; + } + + if (typeof content !== 'string') { + this.sendError(client, 'Missing or invalid content'); + return; + } + + if (!this.isValidFilename(filename)) { + this.sendError( + client, + 'Invalid filename: must not contain path separators or traversal sequences' + ); + return; + } + + if (!this.callbacks.saveAutoRunDoc) { + this.sendError(client, 'Auto-run document saving not configured'); + return; + } + + this.callbacks + .saveAutoRunDoc(sessionId, filename, content) + .then((success) => { + this.send(client, { + type: 'save_auto_run_document_result', + success, + sessionId, + filename, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to save auto-run document: ${error.message}`); + }); + } + + /** + * Handle stop_auto_run message - stop an active Auto Run for a session + */ + private handleStopAutoRun(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string; + logger.info(`[Web] Received stop_auto_run message: session=${sessionId}`, LOG_CONTEXT); + + if (!sessionId) { + this.sendError(client, 'Missing sessionId'); + return; + } + + if (!this.callbacks.stopAutoRun) { + this.sendError(client, 'Auto-run stopping not configured'); + return; + } + + this.callbacks + .stopAutoRun(sessionId) + .then((success) => { + this.send(client, { + type: 'stop_auto_run_result', + success, + sessionId, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to stop auto-run: ${error.message}`); + }); + } + + /** + * Allowlist of setting keys modifiable from the web interface. + */ + private static readonly ALLOWED_SETTING_KEYS = new Set([ + 'activeThemeId', + 'fontSize', + 'enterToSendAI', + 'enterToSendTerminal', + 'defaultSaveToHistory', + 'defaultShowThinking', + 'autoScroll', + 'notificationsEnabled', + 'audioFeedbackEnabled', + 'colorBlindMode', + 'conductorProfile', + ]); + + /** + * Handle get_settings message - return current settings + */ + private handleGetSettings(client: WebClient, message: WebClientMessage): void { + if (!this.callbacks.getSettings) { + this.sendError(client, 'Settings not configured'); + return; + } + + const settings = this.callbacks.getSettings(); + this.send(client, { + type: 'settings', + settings, + requestId: message.requestId, + }); + } + + /** + * Handle set_setting message - modify a single setting + */ + private handleSetSetting(client: WebClient, message: WebClientMessage): void { + const key = message.key as string; + const value = message.value as SettingValue; + + if (!key || typeof key !== 'string') { + this.sendError(client, 'Missing or invalid setting key'); + return; + } + + if (!WebSocketMessageHandler.ALLOWED_SETTING_KEYS.has(key)) { + this.sendError(client, `Setting key '${key}' is not modifiable from the web interface`); + return; + } + + if (value === undefined) { + this.sendError(client, 'Missing setting value'); + return; + } + + if (!this.callbacks.setSetting) { + this.sendError(client, 'Setting modification not configured'); + return; + } + + this.callbacks + .setSetting(key, value) + .then((success) => { + this.send(client, { + type: 'set_setting_result', + success, + key, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to set setting: ${error.message}`); + }); + } + + /** + * Known agent types for validation + */ + private static readonly VALID_AGENT_TYPES = new Set([ + 'claude-code', + 'codex', + 'opencode', + 'factory-droid', + ]); + + /** + * Handle create_session message - create a new agent session + */ + private handleCreateSession(client: WebClient, message: WebClientMessage): void { + const name = message.name as string; + const toolType = message.toolType as string; + const cwd = message.cwd as string; + const groupId = message.groupId as string | undefined; + + if (!name || typeof name !== 'string') { + this.sendError(client, 'Missing or invalid name'); + return; + } + + if (!toolType || !WebSocketMessageHandler.VALID_AGENT_TYPES.has(toolType)) { + this.sendError( + client, + `Invalid toolType. Must be one of: ${[...WebSocketMessageHandler.VALID_AGENT_TYPES].join(', ')}` + ); + return; + } + + if (!cwd || typeof cwd !== 'string') { + this.sendError(client, 'Missing or invalid cwd'); + return; + } + + if (!this.callbacks.createSession) { + this.sendError(client, 'Session creation not configured'); + return; + } + + this.callbacks + .createSession(name, toolType, cwd, groupId) + .then((result) => { + this.send(client, { + type: 'create_session_result', + success: !!result, + sessionId: result?.sessionId, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to create session: ${error.message}`); + }); + } + + /** + * Handle delete_session message - delete an agent session + */ + private handleDeleteSession(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string; + + if (!sessionId) { + this.sendError(client, 'Missing sessionId'); + return; + } + + if (!this.callbacks.deleteSession) { + this.sendError(client, 'Session deletion not configured'); + return; + } + + this.callbacks + .deleteSession(sessionId) + .then((success) => { + this.send(client, { + type: 'delete_session_result', + success, + sessionId, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to delete session: ${error.message}`); + }); + } + + /** + * Handle rename_session message - rename an agent session + */ + private handleRenameSession(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string; + const newName = message.newName as string; + + if (!sessionId) { + this.sendError(client, 'Missing sessionId'); + return; + } + + if (!newName || typeof newName !== 'string' || newName.length === 0) { + this.sendError(client, 'Missing or empty newName'); + return; + } + + if (newName.length > 100) { + this.sendError(client, 'newName must be 100 characters or less'); + return; + } + + if (!this.callbacks.renameSession) { + this.sendError(client, 'Session renaming not configured'); + return; + } + + this.callbacks + .renameSession(sessionId, newName) + .then((success) => { + this.send(client, { + type: 'rename_session_result', + success, + sessionId, + newName, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to rename session: ${error.message}`); + }); + } + + /** + * Handle get_groups message - return list of groups + */ + private handleGetGroups(client: WebClient, message: WebClientMessage): void { + if (!this.callbacks.getGroups) { + this.sendError(client, 'Groups not configured'); + return; + } + + const groups = this.callbacks.getGroups(); + this.send(client, { + type: 'groups_list', + groups, + requestId: message.requestId, + }); + } + + /** + * Handle create_group message - create a new group + */ + private handleCreateGroup(client: WebClient, message: WebClientMessage): void { + const name = message.name as string; + const emoji = message.emoji as string | undefined; + + if (!name || typeof name !== 'string') { + this.sendError(client, 'Missing or invalid group name'); + return; + } + + if (!this.callbacks.createGroup) { + this.sendError(client, 'Group creation not configured'); + return; + } + + this.callbacks + .createGroup(name, emoji) + .then((result) => { + this.send(client, { + type: 'create_group_result', + success: !!result, + groupId: result?.id, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to create group: ${error.message}`); + }); + } + + /** + * Handle rename_group message - rename a group + */ + private handleRenameGroup(client: WebClient, message: WebClientMessage): void { + const groupId = message.groupId as string; + const name = message.name as string; + + if (!groupId) { + this.sendError(client, 'Missing groupId'); + return; + } + + if (!name || typeof name !== 'string') { + this.sendError(client, 'Missing or invalid group name'); + return; + } + + if (!this.callbacks.renameGroup) { + this.sendError(client, 'Group renaming not configured'); + return; + } + + this.callbacks + .renameGroup(groupId, name) + .then((success) => { + this.send(client, { + type: 'rename_group_result', + success, + groupId, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to rename group: ${error.message}`); + }); + } + + /** + * Handle delete_group message - delete a group + */ + private handleDeleteGroup(client: WebClient, message: WebClientMessage): void { + const groupId = message.groupId as string; + + if (!groupId) { + this.sendError(client, 'Missing groupId'); + return; + } + + if (!this.callbacks.deleteGroup) { + this.sendError(client, 'Group deletion not configured'); + return; + } + + this.callbacks + .deleteGroup(groupId) + .then((success) => { + this.send(client, { + type: 'delete_group_result', + success, + groupId, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to delete group: ${error.message}`); + }); + } + + /** + * Handle move_session_to_group message - move a session to a group (or ungrouped) + */ + private handleMoveSessionToGroup(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string; + const groupId = message.groupId as string | null; + + if (!sessionId) { + this.sendError(client, 'Missing sessionId'); + return; + } + + // groupId can be null (for ungrouped), but must be present in message + if (!('groupId' in message)) { + this.sendError(client, 'Missing groupId (use null for ungrouped)'); + return; + } + + if (!this.callbacks.moveSessionToGroup) { + this.sendError(client, 'Move to group not configured'); + return; + } + + this.callbacks + .moveSessionToGroup(sessionId, groupId) + .then((success) => { + this.send(client, { + type: 'move_session_to_group_result', + success, + sessionId, + groupId, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to move session to group: ${error.message}`); + }); + } + + /** + * Handle get_git_status message - fetch git status for a session + */ + private handleGetGitStatus(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string; + + if (!sessionId) { + this.sendError(client, 'Missing sessionId'); + return; + } + + if (!this.callbacks.getGitStatus) { + this.sendError(client, 'Git status not configured'); + return; + } + + this.callbacks + .getGitStatus(sessionId) + .then((status) => { + this.send(client, { + type: 'git_status', + sessionId, + status, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to get git status: ${error.message}`); + }); + } + + /** + * Handle get_git_diff message - fetch git diff for a session + */ + private handleGetGitDiff(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string; + const filePath = message.filePath as string | undefined; + + if (!sessionId) { + this.sendError(client, 'Missing sessionId'); + return; + } + + if (!this.callbacks.getGitDiff) { + this.sendError(client, 'Git diff not configured'); + return; + } + + this.callbacks + .getGitDiff(sessionId, filePath) + .then((diff) => { + this.send(client, { + type: 'git_diff', + sessionId, + diff, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to get git diff: ${error.message}`); + }); + } + + /** + * Handle get_group_chats message - return list of all group chats + */ + private handleGetGroupChats(client: WebClient, message: WebClientMessage): void { + if (!this.callbacks.getGroupChats) { + this.sendError(client, 'Group chats not configured'); + return; + } + + this.callbacks + .getGroupChats() + .then((chats) => { + this.send(client, { + type: 'group_chats_list', + chats, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to get group chats: ${error.message}`); + }); + } + + /** + * Handle start_group_chat message - start a new group chat + */ + private handleStartGroupChat(client: WebClient, message: WebClientMessage): void { + const topic = message.topic as string; + const participantIds = message.participantIds as string[]; + + if (!topic || typeof topic !== 'string') { + this.sendError(client, 'Missing or invalid topic'); + return; + } + + if (!participantIds || !Array.isArray(participantIds) || participantIds.length < 2) { + this.sendError(client, 'At least 2 participants are required'); + return; + } + + if (!this.callbacks.startGroupChat) { + this.sendError(client, 'Group chat not configured'); + return; + } + + this.callbacks + .startGroupChat(topic, participantIds) + .then((result) => { + this.send(client, { + type: 'start_group_chat_result', + success: !!result, + chatId: result?.chatId, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to start group chat: ${error.message}`); + }); + } + + /** + * Handle get_group_chat_state message - get state of a specific group chat + */ + private handleGetGroupChatState(client: WebClient, message: WebClientMessage): void { + const chatId = message.chatId as string; + + if (!chatId) { + this.sendError(client, 'Missing chatId'); + return; + } + + if (!this.callbacks.getGroupChatState) { + this.sendError(client, 'Group chat not configured'); + return; + } + + this.callbacks + .getGroupChatState(chatId) + .then((state) => { + this.send(client, { + type: 'group_chat_state', + chatId, + state, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to get group chat state: ${error.message}`); + }); + } + + /** + * Handle send_group_chat_message message - send a message to a group chat + */ + private handleSendGroupChatMessage(client: WebClient, message: WebClientMessage): void { + const chatId = message.chatId as string; + const chatMessage = message.message as string; + + if (!chatId) { + this.sendError(client, 'Missing chatId'); + return; + } + + if (!chatMessage || typeof chatMessage !== 'string') { + this.sendError(client, 'Missing or invalid message'); + return; + } + + if (!this.callbacks.sendGroupChatMessage) { + this.sendError(client, 'Group chat not configured'); + return; + } + + this.callbacks + .sendGroupChatMessage(chatId, chatMessage) + .then((success) => { + this.send(client, { + type: 'send_group_chat_message_result', + success, + chatId, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to send group chat message: ${error.message}`); + }); + } + + /** + * Handle stop_group_chat message - stop an active group chat + */ + private handleStopGroupChat(client: WebClient, message: WebClientMessage): void { + const chatId = message.chatId as string; + + if (!chatId) { + this.sendError(client, 'Missing chatId'); + return; + } + + if (!this.callbacks.stopGroupChat) { + this.sendError(client, 'Group chat not configured'); + return; + } + + this.callbacks + .stopGroupChat(chatId) + .then((success) => { + this.send(client, { + type: 'stop_group_chat_result', + success, + chatId, + requestId: message.requestId, + }); + }) + .catch((error) => { + this.sendError(client, `Failed to stop group chat: ${error.message}`); + }); + } + + /** + * Handle merge_context message - merge context from source to target session + */ + private handleMergeContext(client: WebClient, message: WebClientMessage): void { + const sourceSessionId = message.sourceSessionId as string; + const targetSessionId = message.targetSessionId as string; + + if (!sourceSessionId || !targetSessionId) { + this.sendError(client, 'Missing sourceSessionId or targetSessionId'); + return; + } + + if (sourceSessionId === targetSessionId) { + this.sendError(client, 'Source and target sessions must be different'); + return; + } + + if (!this.callbacks.mergeContext) { + this.sendError(client, 'Context merge not configured'); + return; + } + + this.callbacks + .mergeContext(sourceSessionId, targetSessionId) + .then((success) => { + this.send(client, { + type: 'merge_context_result', + success, + requestId: message.requestId, + timestamp: Date.now(), + }); + }) + .catch((error) => { + this.sendError(client, `Failed to merge context: ${error.message}`); + }); + } + + /** + * Handle transfer_context message - transfer context from source to target session + */ + private handleTransferContext(client: WebClient, message: WebClientMessage): void { + const sourceSessionId = message.sourceSessionId as string; + const targetSessionId = message.targetSessionId as string; + + if (!sourceSessionId || !targetSessionId) { + this.sendError(client, 'Missing sourceSessionId or targetSessionId'); + return; + } + + if (sourceSessionId === targetSessionId) { + this.sendError(client, 'Source and target sessions must be different'); + return; + } + + if (!this.callbacks.transferContext) { + this.sendError(client, 'Context transfer not configured'); + return; + } + + this.callbacks + .transferContext(sourceSessionId, targetSessionId) + .then((success) => { + this.send(client, { + type: 'transfer_context_result', + success, + requestId: message.requestId, + timestamp: Date.now(), + }); + }) + .catch((error) => { + this.sendError(client, `Failed to transfer context: ${error.message}`); + }); + } + + /** + * Handle summarize_context message - summarize context for a session + */ + private handleSummarizeContext(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string; + + if (!sessionId) { + this.sendError(client, 'Missing sessionId'); + return; + } + + if (!this.callbacks.summarizeContext) { + this.sendError(client, 'Context summarize not configured'); + return; + } + + this.callbacks + .summarizeContext(sessionId) + .then((success) => { + this.send(client, { + type: 'summarize_context_result', + success, + requestId: message.requestId, + timestamp: Date.now(), + }); + }) + .catch((error) => { + this.sendError(client, `Failed to summarize context: ${error.message}`); + }); + } + + /** + * Handle get_cue_subscriptions message - fetch Cue subscriptions + */ + private handleGetCueSubscriptions(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string | undefined; + + if (!this.callbacks.getCueSubscriptions) { + this.sendError(client, 'Cue subscriptions not available'); + return; + } + + this.callbacks + .getCueSubscriptions(sessionId) + .then((subscriptions) => { + this.send(client, { + type: 'cue_subscriptions', + subscriptions, + requestId: message.requestId, + timestamp: Date.now(), + }); + }) + .catch((error) => { + this.sendError(client, `Failed to get Cue subscriptions: ${error.message}`); + }); + } + + /** + * Handle toggle_cue_subscription message - enable/disable a subscription + */ + private handleToggleCueSubscription(client: WebClient, message: WebClientMessage): void { + const subscriptionId = message.subscriptionId as string; + const enabled = message.enabled as boolean; + + if (!subscriptionId) { + this.sendError(client, 'Missing subscriptionId'); + return; + } + + if (typeof enabled !== 'boolean') { + this.sendError(client, 'Missing or invalid enabled flag'); + return; + } + + if (!this.callbacks.toggleCueSubscription) { + this.sendError(client, 'Cue toggle not available'); + return; + } + + this.callbacks + .toggleCueSubscription(subscriptionId, enabled) + .then((success) => { + this.send(client, { + type: 'toggle_cue_subscription_result', + success, + subscriptionId, + enabled, + requestId: message.requestId, + timestamp: Date.now(), + }); + }) + .catch((error) => { + this.sendError(client, `Failed to toggle Cue subscription: ${error.message}`); + }); + } + + /** + * Handle get_cue_activity message - fetch Cue activity log + */ + private handleGetCueActivity(client: WebClient, message: WebClientMessage): void { + const sessionId = message.sessionId as string | undefined; + const limit = (message.limit as number) ?? 50; + + if (!this.callbacks.getCueActivity) { + this.sendError(client, 'Cue activity not available'); + return; + } + + this.callbacks + .getCueActivity(sessionId, limit) + .then((entries) => { + this.send(client, { + type: 'cue_activity', + entries, + requestId: message.requestId, + timestamp: Date.now(), + }); + }) + .catch((error) => { + this.sendError(client, `Failed to get Cue activity: ${error.message}`); + }); + } + + /** + * Handle get_usage_dashboard message - fetch usage analytics data + */ + private handleGetUsageDashboard(client: WebClient, message: WebClientMessage): void { + const timeRange = (message.timeRange as string) || 'week'; + const validRanges = new Set(['day', 'week', 'month', 'all']); + + if (!validRanges.has(timeRange)) { + this.sendError(client, 'Invalid timeRange. Must be one of: day, week, month, all'); + return; + } + + if (!this.callbacks.getUsageDashboard) { + this.sendError(client, 'Usage dashboard not available'); + return; + } + + this.callbacks + .getUsageDashboard(timeRange as 'day' | 'week' | 'month' | 'all') + .then((data) => { + this.send(client, { + type: 'usage_dashboard', + data, + requestId: message.requestId, + timestamp: Date.now(), + }); + }) + .catch((error) => { + this.sendError(client, `Failed to get usage dashboard: ${error.message}`); + }); + } + + /** + * Handle get_achievements message - fetch achievement data + */ + private handleGetAchievements(client: WebClient, message: WebClientMessage): void { + if (!this.callbacks.getAchievements) { + this.sendError(client, 'Achievements not available'); + return; + } + + this.callbacks + .getAchievements() + .then((achievements) => { + this.send(client, { + type: 'achievements', + achievements, + requestId: message.requestId, + timestamp: Date.now(), + }); + }) + .catch((error) => { + this.sendError(client, `Failed to get achievements: ${error.message}`); + }); + } + /** * Handle unknown message types - echo back for debugging */ diff --git a/src/main/web-server/managers/CallbackRegistry.ts b/src/main/web-server/managers/CallbackRegistry.ts index 3e10acfd84..4a7895c620 100644 --- a/src/main/web-server/managers/CallbackRegistry.ts +++ b/src/main/web-server/managers/CallbackRegistry.ts @@ -28,6 +28,45 @@ import type { GetThemeCallback, GetCustomCommandsCallback, GetHistoryCallback, + GetAutoRunDocsCallback, + GetAutoRunDocContentCallback, + SaveAutoRunDocCallback, + StopAutoRunCallback, + GetSettingsCallback, + SetSettingCallback, + GetGroupsCallback, + CreateGroupCallback, + RenameGroupCallback, + DeleteGroupCallback, + MoveSessionToGroupCallback, + CreateSessionCallback, + DeleteSessionCallback, + RenameSessionCallback, + WebSettings, + SettingValue, + GroupData, + GetGitStatusCallback, + GetGitDiffCallback, + GitStatusResult, + GitDiffResult, + GetGroupChatsCallback, + StartGroupChatCallback, + GetGroupChatStateCallback, + StopGroupChatCallback, + SendGroupChatMessageCallback, + GroupChatState, + MergeContextCallback, + TransferContextCallback, + SummarizeContextCallback, + GetCueSubscriptionsCallback, + ToggleCueSubscriptionCallback, + GetCueActivityCallback, + CueSubscriptionInfo, + CueActivityEntry, + GetUsageDashboardCallback, + GetAchievementsCallback, + UsageDashboardData, + AchievementData, } from '../types'; const LOG_CONTEXT = 'CallbackRegistry'; @@ -57,6 +96,35 @@ export interface WebServerCallbacks { refreshAutoRunDocs: RefreshAutoRunDocsCallback | null; configureAutoRun: ConfigureAutoRunCallback | null; getHistory: GetHistoryCallback | null; + getAutoRunDocs: GetAutoRunDocsCallback | null; + getAutoRunDocContent: GetAutoRunDocContentCallback | null; + saveAutoRunDoc: SaveAutoRunDocCallback | null; + stopAutoRun: StopAutoRunCallback | null; + getSettings: GetSettingsCallback | null; + setSetting: SetSettingCallback | null; + getGroups: GetGroupsCallback | null; + createGroup: CreateGroupCallback | null; + renameGroup: RenameGroupCallback | null; + deleteGroup: DeleteGroupCallback | null; + moveSessionToGroup: MoveSessionToGroupCallback | null; + createSession: CreateSessionCallback | null; + deleteSession: DeleteSessionCallback | null; + renameSession: RenameSessionCallback | null; + getGitStatus: GetGitStatusCallback | null; + getGitDiff: GetGitDiffCallback | null; + getGroupChats: GetGroupChatsCallback | null; + startGroupChat: StartGroupChatCallback | null; + getGroupChatState: GetGroupChatStateCallback | null; + stopGroupChat: StopGroupChatCallback | null; + sendGroupChatMessage: SendGroupChatMessageCallback | null; + mergeContext: MergeContextCallback | null; + transferContext: TransferContextCallback | null; + summarizeContext: SummarizeContextCallback | null; + getCueSubscriptions: GetCueSubscriptionsCallback | null; + toggleCueSubscription: ToggleCueSubscriptionCallback | null; + getCueActivity: GetCueActivityCallback | null; + getUsageDashboard: GetUsageDashboardCallback | null; + getAchievements: GetAchievementsCallback | null; } export class CallbackRegistry { @@ -82,6 +150,35 @@ export class CallbackRegistry { refreshAutoRunDocs: null, configureAutoRun: null, getHistory: null, + getAutoRunDocs: null, + getAutoRunDocContent: null, + saveAutoRunDoc: null, + stopAutoRun: null, + getSettings: null, + setSetting: null, + getGroups: null, + createGroup: null, + renameGroup: null, + deleteGroup: null, + moveSessionToGroup: null, + createSession: null, + deleteSession: null, + renameSession: null, + getGitStatus: null, + getGitDiff: null, + getGroupChats: null, + startGroupChat: null, + getGroupChatState: null, + stopGroupChat: null, + sendGroupChatMessage: null, + mergeContext: null, + transferContext: null, + summarizeContext: null, + getCueSubscriptions: null, + toggleCueSubscription: null, + getCueActivity: null, + getUsageDashboard: null, + getAchievements: null, }; // ============ Getter Methods ============ @@ -198,6 +295,182 @@ export class CallbackRegistry { return this.callbacks.getHistory?.(projectPath, sessionId) ?? []; } + async getAutoRunDocs(sessionId: string): Promise { + if (!this.callbacks.getAutoRunDocs) return []; + return this.callbacks.getAutoRunDocs(sessionId); + } + + async getAutoRunDocContent(sessionId: string, filename: string): Promise { + if (!this.callbacks.getAutoRunDocContent) return ''; + return this.callbacks.getAutoRunDocContent(sessionId, filename); + } + + async saveAutoRunDoc(sessionId: string, filename: string, content: string): Promise { + if (!this.callbacks.saveAutoRunDoc) return false; + return this.callbacks.saveAutoRunDoc(sessionId, filename, content); + } + + async stopAutoRun(sessionId: string): Promise { + if (!this.callbacks.stopAutoRun) return false; + return this.callbacks.stopAutoRun(sessionId); + } + + getSettings(): WebSettings { + if (this.callbacks.getSettings) { + return this.callbacks.getSettings(); + } + return { + theme: 'dracula', + fontSize: 14, + enterToSendAI: false, + enterToSendTerminal: true, + defaultSaveToHistory: true, + defaultShowThinking: 'off', + autoScroll: false, + notificationsEnabled: true, + audioFeedbackEnabled: false, + colorBlindMode: 'false', + conductorProfile: '', + }; + } + + async setSetting(key: string, value: SettingValue): Promise { + if (!this.callbacks.setSetting) return false; + return this.callbacks.setSetting(key, value); + } + + getGroups(): GroupData[] { + return this.callbacks.getGroups?.() ?? []; + } + + async createGroup(name: string, emoji?: string): Promise<{ id: string } | null> { + if (!this.callbacks.createGroup) return null; + return this.callbacks.createGroup(name, emoji); + } + + async renameGroup(groupId: string, name: string): Promise { + if (!this.callbacks.renameGroup) return false; + return this.callbacks.renameGroup(groupId, name); + } + + async deleteGroup(groupId: string): Promise { + if (!this.callbacks.deleteGroup) return false; + return this.callbacks.deleteGroup(groupId); + } + + async moveSessionToGroup(sessionId: string, groupId: string | null): Promise { + if (!this.callbacks.moveSessionToGroup) return false; + return this.callbacks.moveSessionToGroup(sessionId, groupId); + } + + async createSession( + name: string, + toolType: string, + cwd: string, + groupId?: string + ): Promise<{ sessionId: string } | null> { + if (!this.callbacks.createSession) return null; + return this.callbacks.createSession(name, toolType, cwd, groupId); + } + + async deleteSession(sessionId: string): Promise { + if (!this.callbacks.deleteSession) return false; + return this.callbacks.deleteSession(sessionId); + } + + async renameSession(sessionId: string, newName: string): Promise { + if (!this.callbacks.renameSession) return false; + return this.callbacks.renameSession(sessionId, newName); + } + + async getGitStatus(sessionId: string): Promise { + if (!this.callbacks.getGitStatus) return { branch: '', files: [], ahead: 0, behind: 0 }; + return this.callbacks.getGitStatus(sessionId); + } + + async getGitDiff(sessionId: string, filePath?: string): Promise { + if (!this.callbacks.getGitDiff) return { diff: '', files: [] }; + return this.callbacks.getGitDiff(sessionId, filePath); + } + + async getGroupChats(): Promise { + if (!this.callbacks.getGroupChats) return []; + return this.callbacks.getGroupChats(); + } + + async startGroupChat( + topic: string, + participantIds: string[] + ): Promise<{ chatId: string } | null> { + if (!this.callbacks.startGroupChat) return null; + return this.callbacks.startGroupChat(topic, participantIds); + } + + async getGroupChatState(chatId: string): Promise { + if (!this.callbacks.getGroupChatState) return null; + return this.callbacks.getGroupChatState(chatId); + } + + async stopGroupChat(chatId: string): Promise { + if (!this.callbacks.stopGroupChat) return false; + return this.callbacks.stopGroupChat(chatId); + } + + async sendGroupChatMessage(chatId: string, message: string): Promise { + if (!this.callbacks.sendGroupChatMessage) return false; + return this.callbacks.sendGroupChatMessage(chatId, message); + } + + async mergeContext(sourceSessionId: string, targetSessionId: string): Promise { + if (!this.callbacks.mergeContext) return false; + return this.callbacks.mergeContext(sourceSessionId, targetSessionId); + } + + async transferContext(sourceSessionId: string, targetSessionId: string): Promise { + if (!this.callbacks.transferContext) return false; + return this.callbacks.transferContext(sourceSessionId, targetSessionId); + } + + async summarizeContext(sessionId: string): Promise { + if (!this.callbacks.summarizeContext) return false; + return this.callbacks.summarizeContext(sessionId); + } + + async getCueSubscriptions(sessionId?: string): Promise { + if (!this.callbacks.getCueSubscriptions) return []; + return this.callbacks.getCueSubscriptions(sessionId); + } + + async toggleCueSubscription(subscriptionId: string, enabled: boolean): Promise { + if (!this.callbacks.toggleCueSubscription) return false; + return this.callbacks.toggleCueSubscription(subscriptionId, enabled); + } + + async getCueActivity(sessionId?: string, limit?: number): Promise { + if (!this.callbacks.getCueActivity) return []; + return this.callbacks.getCueActivity(sessionId, limit); + } + + async getUsageDashboard( + timeRange: 'day' | 'week' | 'month' | 'all' + ): Promise { + if (!this.callbacks.getUsageDashboard) { + return { + totalTokensIn: 0, + totalTokensOut: 0, + totalCost: 0, + sessionBreakdown: [], + dailyUsage: [], + }; + } + return this.callbacks.getUsageDashboard(timeRange); + } + + async getAchievements(): Promise { + if (!this.callbacks.getAchievements) return []; + return this.callbacks.getAchievements(); + } + // ============ Setter Methods ============ setGetSessionsCallback(callback: GetSessionsCallback): void { @@ -290,6 +563,122 @@ export class CallbackRegistry { this.callbacks.getHistory = callback; } + setGetAutoRunDocsCallback(callback: GetAutoRunDocsCallback): void { + this.callbacks.getAutoRunDocs = callback; + } + + setGetAutoRunDocContentCallback(callback: GetAutoRunDocContentCallback): void { + this.callbacks.getAutoRunDocContent = callback; + } + + setSaveAutoRunDocCallback(callback: SaveAutoRunDocCallback): void { + this.callbacks.saveAutoRunDoc = callback; + } + + setStopAutoRunCallback(callback: StopAutoRunCallback): void { + this.callbacks.stopAutoRun = callback; + } + + setGetSettingsCallback(callback: GetSettingsCallback): void { + this.callbacks.getSettings = callback; + } + + setSetSettingCallback(callback: SetSettingCallback): void { + this.callbacks.setSetting = callback; + } + + setGetGroupsCallback(callback: GetGroupsCallback): void { + this.callbacks.getGroups = callback; + } + + setCreateGroupCallback(callback: CreateGroupCallback): void { + this.callbacks.createGroup = callback; + } + + setRenameGroupCallback(callback: RenameGroupCallback): void { + this.callbacks.renameGroup = callback; + } + + setDeleteGroupCallback(callback: DeleteGroupCallback): void { + this.callbacks.deleteGroup = callback; + } + + setMoveSessionToGroupCallback(callback: MoveSessionToGroupCallback): void { + this.callbacks.moveSessionToGroup = callback; + } + + setCreateSessionCallback(callback: CreateSessionCallback): void { + this.callbacks.createSession = callback; + } + + setDeleteSessionCallback(callback: DeleteSessionCallback): void { + this.callbacks.deleteSession = callback; + } + + setRenameSessionCallback(callback: RenameSessionCallback): void { + this.callbacks.renameSession = callback; + } + + setGetGitStatusCallback(callback: GetGitStatusCallback): void { + this.callbacks.getGitStatus = callback; + } + + setGetGitDiffCallback(callback: GetGitDiffCallback): void { + this.callbacks.getGitDiff = callback; + } + + setGetGroupChatsCallback(callback: GetGroupChatsCallback): void { + this.callbacks.getGroupChats = callback; + } + + setStartGroupChatCallback(callback: StartGroupChatCallback): void { + this.callbacks.startGroupChat = callback; + } + + setGetGroupChatStateCallback(callback: GetGroupChatStateCallback): void { + this.callbacks.getGroupChatState = callback; + } + + setStopGroupChatCallback(callback: StopGroupChatCallback): void { + this.callbacks.stopGroupChat = callback; + } + + setSendGroupChatMessageCallback(callback: SendGroupChatMessageCallback): void { + this.callbacks.sendGroupChatMessage = callback; + } + + setMergeContextCallback(callback: MergeContextCallback): void { + this.callbacks.mergeContext = callback; + } + + setTransferContextCallback(callback: TransferContextCallback): void { + this.callbacks.transferContext = callback; + } + + setSummarizeContextCallback(callback: SummarizeContextCallback): void { + this.callbacks.summarizeContext = callback; + } + + setGetCueSubscriptionsCallback(callback: GetCueSubscriptionsCallback): void { + this.callbacks.getCueSubscriptions = callback; + } + + setToggleCueSubscriptionCallback(callback: ToggleCueSubscriptionCallback): void { + this.callbacks.toggleCueSubscription = callback; + } + + setGetCueActivityCallback(callback: GetCueActivityCallback): void { + this.callbacks.getCueActivity = callback; + } + + setGetUsageDashboardCallback(callback: GetUsageDashboardCallback): void { + this.callbacks.getUsageDashboard = callback; + } + + setGetAchievementsCallback(callback: GetAchievementsCallback): void { + this.callbacks.getAchievements = callback; + } + // ============ Check Methods ============ hasCallback(name: keyof WebServerCallbacks): boolean { diff --git a/src/main/web-server/services/broadcastService.ts b/src/main/web-server/services/broadcastService.ts index d66c9eaae6..dfaebb6033 100644 --- a/src/main/web-server/services/broadcastService.ts +++ b/src/main/web-server/services/broadcastService.ts @@ -15,6 +15,7 @@ * - theme: Theme updates * - custom_commands: Custom AI commands updates * - autorun_state: Auto Run batch processing state + * - autorun_docs_changed: Auto Run document list changes * - user_input: User input from desktop (for web client sync) * - session_output: Session output data */ @@ -28,7 +29,15 @@ import type { AITabData, SessionBroadcastData, AutoRunState, + AutoRunDocument, CliActivity, + NotificationEvent, + WebSettings, + GroupData, + GroupChatMessage, + GroupChatState, + CueActivityEntry, + CueSubscriptionInfo, } from '../types'; // Re-export types for backwards compatibility @@ -61,6 +70,8 @@ export type GetWebClientsCallback = () => Map; */ export class BroadcastService { private getWebClients: GetWebClientsCallback | null = null; + private previousAutoRunStates: Map = + new Map(); /** * Set the callback for getting web clients @@ -100,6 +111,17 @@ export class BroadcastService { } } + /** + * Broadcast a notification event to all connected web clients + */ + broadcastNotificationEvent(event: NotificationEvent): void { + this.broadcastToAll({ + type: 'notification_event', + ...event, + timestamp: Date.now(), + }); + } + /** * Broadcast a session state change to all connected web clients * Called when any session's state changes (idle, busy, error, connecting) @@ -122,6 +144,25 @@ export class BroadcastService { ...additionalData, timestamp: Date.now(), }); + + // Trigger notification events on state transitions + if (state === 'idle') { + this.broadcastNotificationEvent({ + eventType: 'agent_complete', + sessionId, + sessionName: additionalData?.name ?? sessionId, + message: 'Agent finished processing', + severity: 'info', + }); + } else if (state === 'error') { + this.broadcastNotificationEvent({ + eventType: 'agent_error', + sessionId, + sessionName: additionalData?.name ?? sessionId, + message: 'Agent encountered an error', + severity: 'error', + }); + } } /** @@ -208,6 +249,30 @@ export class BroadcastService { }); } + /** + * Broadcast settings change to all connected web clients + * Called when a setting is modified (from web or desktop) + */ + broadcastSettingsChanged(settings: WebSettings): void { + this.broadcastToAll({ + type: 'settings_changed', + settings, + timestamp: Date.now(), + }); + } + + /** + * Broadcast groups change to all connected web clients + * Called when groups are created, renamed, deleted, or sessions are moved + */ + broadcastGroupsChanged(groups: GroupData[]): void { + this.broadcastToAll({ + type: 'groups_changed', + groups, + timestamp: Date.now(), + }); + } + /** * Broadcast AutoRun state to all connected web clients * Called when batch processing starts, progresses, or stops @@ -223,6 +288,54 @@ export class BroadcastService { state, timestamp: Date.now(), }); + + // Detect transitions for notification events + const previous = this.previousAutoRunStates.get(sessionId); + if (state) { + // Detect autorun_complete: running → not running + if (previous?.running && !state.isRunning) { + this.broadcastNotificationEvent({ + eventType: 'autorun_complete', + sessionId, + sessionName: sessionId, + message: `Auto Run finished (${state.completedTasks}/${state.totalTasks} tasks)`, + severity: 'info', + }); + } + + // Detect autorun_task_complete: completedTasks increased + if (previous && state.completedTasks > previous.completedTasks) { + this.broadcastNotificationEvent({ + eventType: 'autorun_task_complete', + sessionId, + sessionName: sessionId, + message: `Task ${state.completedTasks}/${state.totalTasks} completed`, + severity: 'info', + }); + } + + // Update previous state + this.previousAutoRunStates.set(sessionId, { + running: state.isRunning, + completedTasks: state.completedTasks, + }); + } else { + // State cleared — remove tracking + this.previousAutoRunStates.delete(sessionId); + } + } + + /** + * Broadcast Auto Run documents changed to all connected web clients + * Called when Auto Run documents are added, removed, or modified + */ + broadcastAutoRunDocsChanged(sessionId: string, documents: AutoRunDocument[]): void { + this.broadcastToAll({ + type: 'autorun_docs_changed', + sessionId, + documents, + timestamp: Date.now(), + }); } /** @@ -263,4 +376,76 @@ export class BroadcastService { timestamp: Date.now(), }); } + + /** + * Broadcast a group chat message to all connected web clients + */ + broadcastGroupChatMessage(chatId: string, message: GroupChatMessage): void { + this.broadcastToAll({ + type: 'group_chat_message', + chatId, + message, + timestamp: Date.now(), + }); + } + + /** + * Broadcast a group chat state change to all connected web clients + */ + broadcastGroupChatStateChange(chatId: string, state: Partial): void { + this.broadcastToAll({ + type: 'group_chat_state_change', + chatId, + ...state, + timestamp: Date.now(), + }); + } + + /** + * Broadcast context operation progress to all connected web clients + */ + broadcastContextOperationProgress(sessionId: string, operation: string, progress: number): void { + this.broadcastToAll({ + type: 'context_operation_progress', + sessionId, + operation, + progress, + timestamp: Date.now(), + }); + } + + /** + * Broadcast context operation completion to all connected web clients + */ + broadcastContextOperationComplete(sessionId: string, operation: string, success: boolean): void { + this.broadcastToAll({ + type: 'context_operation_complete', + sessionId, + operation, + success, + timestamp: Date.now(), + }); + } + + /** + * Broadcast a Cue activity event to all connected web clients + */ + broadcastCueActivity(entry: CueActivityEntry): void { + this.broadcastToAll({ + type: 'cue_activity_event', + entry, + timestamp: Date.now(), + }); + } + + /** + * Broadcast Cue subscriptions changed to all connected web clients + */ + broadcastCueSubscriptionsChanged(subscriptions: CueSubscriptionInfo[]): void { + this.broadcastToAll({ + type: 'cue_subscriptions_changed', + subscriptions, + timestamp: Date.now(), + }); + } } diff --git a/src/main/web-server/types.ts b/src/main/web-server/types.ts index d03df966b1..26c120f5fe 100644 --- a/src/main/web-server/types.ts +++ b/src/main/web-server/types.ts @@ -329,3 +329,307 @@ export type GetHistoryCallback = ( * Callback to get all connected web clients. */ export type GetWebClientsCallback = () => Map; + +// ============================================================================= +// Web UX Parity Types +// ============================================================================= + +/** + * Union type for setting values exposed to web. + */ +export type SettingValue = string | number | boolean | null; + +/** + * Curated subset of settings exposed to the web interface. + */ +export interface WebSettings { + theme: string; + fontSize: number; + enterToSendAI: boolean; + enterToSendTerminal: boolean; + defaultSaveToHistory: boolean; + defaultShowThinking: string; + autoScroll: boolean; + notificationsEnabled: boolean; + audioFeedbackEnabled: boolean; + colorBlindMode: string; + conductorProfile: string; +} + +/** + * Group info for web. + */ +export interface GroupData { + id: string; + name: string; + emoji: string | null; + sessionIds: string[]; +} + +/** + * Auto Run document metadata. + */ +export interface AutoRunDocument { + filename: string; + path: string; + taskCount: number; + completedCount: number; +} + +/** + * File tree entry. + */ +export interface FileTreeNode { + name: string; + path: string; + isDirectory: boolean; + children?: FileTreeNode[]; + size?: number; +} + +/** + * File content response. + */ +export interface FileContentResult { + content: string; + language: string; + size: number; + truncated: boolean; +} + +/** + * Git status entry. + */ +export interface GitStatusFile { + path: string; + status: string; + staged: boolean; +} + +/** + * Git status response. + */ +export interface GitStatusResult { + branch: string; + files: GitStatusFile[]; + ahead: number; + behind: number; +} + +/** + * Git diff response. + */ +export interface GitDiffResult { + diff: string; + files: string[]; +} + +/** + * Notification preferences configuration. + */ +export interface NotificationPreferences { + agentComplete: boolean; + agentError: boolean; + autoRunComplete: boolean; + autoRunTaskComplete: boolean; + contextWarning: boolean; + soundEnabled: boolean; +} + +/** + * Notification broadcast payload. + */ +export interface NotificationEvent { + eventType: + | 'agent_complete' + | 'agent_error' + | 'autorun_complete' + | 'autorun_task_complete' + | 'context_warning'; + sessionId: string; + sessionName: string; + message: string; + severity: 'info' | 'warning' | 'error'; +} + +// ============================================================================= +// Web UX Parity Callback Types +// ============================================================================= + +export type GetSettingsCallback = () => WebSettings; +export type SetSettingCallback = (key: string, value: SettingValue) => Promise; +export type GetGroupsCallback = () => GroupData[]; +export type CreateGroupCallback = (name: string, emoji?: string) => Promise<{ id: string } | null>; +export type RenameGroupCallback = (groupId: string, name: string) => Promise; +export type DeleteGroupCallback = (groupId: string) => Promise; +export type MoveSessionToGroupCallback = ( + sessionId: string, + groupId: string | null +) => Promise; +export type CreateSessionCallback = ( + name: string, + toolType: string, + cwd: string, + groupId?: string +) => Promise<{ sessionId: string } | null>; +export type DeleteSessionCallback = (sessionId: string) => Promise; +export type RenameSessionCallback = (sessionId: string, newName: string) => Promise; +export type GetAutoRunDocsCallback = (sessionId: string) => Promise; +export type GetAutoRunDocContentCallback = (sessionId: string, filename: string) => Promise; +export type SaveAutoRunDocCallback = ( + sessionId: string, + filename: string, + content: string +) => Promise; +export type StopAutoRunCallback = (sessionId: string) => Promise; +export type GetFileTreeCallback = (sessionId: string, subPath?: string) => Promise; +export type GetFileContentCallback = ( + sessionId: string, + filePath: string +) => Promise; +export type GetGitStatusCallback = (sessionId: string) => Promise; +export type GetGitDiffCallback = (sessionId: string, filePath?: string) => Promise; + +// ============================================================================= +// Group Chat Types +// ============================================================================= + +/** + * Group chat message for web interface. + */ +export interface GroupChatMessage { + id: string; + participantId: string; + participantName: string; + content: string; + timestamp: number; + role: 'user' | 'assistant'; +} + +/** + * Group chat state for web interface. + */ +export interface GroupChatState { + id: string; + topic: string; + participants: Array<{ sessionId: string; name: string; toolType: string }>; + messages: GroupChatMessage[]; + isActive: boolean; + currentTurn?: string; +} + +// ============================================================================= +// Group Chat Callback Types +// ============================================================================= + +export type StartGroupChatCallback = ( + topic: string, + participantIds: string[] +) => Promise<{ chatId: string } | null>; +export type GetGroupChatStateCallback = (chatId: string) => Promise; +export type StopGroupChatCallback = (chatId: string) => Promise; +export type SendGroupChatMessageCallback = (chatId: string, message: string) => Promise; +export type GetGroupChatsCallback = () => Promise; + +// ============================================================================= +// Context Management Callback Types +// ============================================================================= + +export type MergeContextCallback = ( + sourceSessionId: string, + targetSessionId: string +) => Promise; +export type TransferContextCallback = ( + sourceSessionId: string, + targetSessionId: string +) => Promise; +export type SummarizeContextCallback = (sessionId: string) => Promise; + +// ============================================================================= +// Cue Automation Types +// ============================================================================= + +/** Web-specific Cue subscription metadata (simplified from engine types) */ +export interface CueSubscriptionInfo { + id: string; + name: string; + eventType: string; + pattern?: string; + schedule?: string; + sessionId: string; + sessionName: string; + enabled: boolean; + lastTriggered?: number; + triggerCount: number; +} + +/** Web-specific Cue activity log entry (simplified from engine types) */ +export interface CueActivityEntry { + id: string; + subscriptionId: string; + subscriptionName: string; + eventType: string; + sessionId: string; + timestamp: number; + status: 'triggered' | 'running' | 'completed' | 'failed'; + result?: string; + duration?: number; +} + +// ============================================================================= +// Cue Automation Callback Types +// ============================================================================= + +export type GetCueSubscriptionsCallback = (sessionId?: string) => Promise; +export type ToggleCueSubscriptionCallback = ( + subscriptionId: string, + enabled: boolean +) => Promise; +export type GetCueActivityCallback = ( + sessionId?: string, + limit?: number +) => Promise; + +// ============================================================================= +// Usage Dashboard Types +// ============================================================================= + +/** Usage dashboard aggregate data for web interface */ +export interface UsageDashboardData { + totalTokensIn: number; + totalTokensOut: number; + totalCost: number; + sessionBreakdown: Array<{ + sessionId: string; + sessionName: string; + tokensIn: number; + tokensOut: number; + cost: number; + }>; + dailyUsage: Array<{ + date: string; + tokensIn: number; + tokensOut: number; + cost: number; + }>; +} + +/** Achievement data for web interface */ +export interface AchievementData { + id: string; + name: string; + description: string; + unlocked: boolean; + unlockedAt?: number; + progress?: number; + maxProgress?: number; +} + +// ============================================================================= +// Usage Dashboard Callback Types +// ============================================================================= + +export type GetUsageDashboardCallback = ( + timeRange: 'day' | 'week' | 'month' | 'all' +) => Promise; +export type GetAchievementsCallback = () => Promise; diff --git a/src/main/web-server/web-server-factory.ts b/src/main/web-server/web-server-factory.ts index 58b36e71e7..47c1d25b5e 100644 --- a/src/main/web-server/web-server-factory.ts +++ b/src/main/web-server/web-server-factory.ts @@ -603,6 +603,1138 @@ export function createWebServerFactory(deps: WebServerFactoryDependencies) { }); }); + // Set up callback for web server to fetch Auto Run documents list + // Uses IPC request-response pattern with timeout + server.setGetAutoRunDocsCallback(async (sessionId: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for getAutoRunDocs', 'WebServer'); + return []; + } + + return new Promise((resolve) => { + const responseChannel = `remote:getAutoRunDocs:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result || []); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for getAutoRunDocs', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve([]); + return; + } + mainWindow.webContents.send('remote:getAutoRunDocs', sessionId, responseChannel); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn(`getAutoRunDocs callback timed out for session ${sessionId}`, 'WebServer'); + resolve([]); + }, 10000); + }); + }); + + // Set up callback for web server to fetch Auto Run document content + // Uses IPC request-response pattern with timeout + server.setGetAutoRunDocContentCallback(async (sessionId: string, filename: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for getAutoRunDocContent', 'WebServer'); + return ''; + } + + return new Promise((resolve) => { + const responseChannel = `remote:getAutoRunDocContent:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result ?? ''); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for getAutoRunDocContent', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve(''); + return; + } + mainWindow.webContents.send( + 'remote:getAutoRunDocContent', + sessionId, + filename, + responseChannel + ); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn( + `getAutoRunDocContent callback timed out for session ${sessionId}`, + 'WebServer' + ); + resolve(''); + }, 10000); + }); + }); + + // Set up callback for web server to save Auto Run document content + // Uses IPC request-response pattern with timeout + server.setSaveAutoRunDocCallback( + async (sessionId: string, filename: string, content: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for saveAutoRunDoc', 'WebServer'); + return false; + } + + return new Promise((resolve) => { + const responseChannel = `remote:saveAutoRunDoc:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result ?? false); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for saveAutoRunDoc', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve(false); + return; + } + mainWindow.webContents.send( + 'remote:saveAutoRunDoc', + sessionId, + filename, + content, + responseChannel + ); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn(`saveAutoRunDoc callback timed out for session ${sessionId}`, 'WebServer'); + resolve(false); + }, 10000); + }); + } + ); + + // Set up callback for web server to read settings + // Reads directly from settingsStore — maps store keys to WebSettings shape + server.setGetSettingsCallback(() => { + return { + theme: settingsStore.get('activeThemeId', 'dracula') as string, + fontSize: settingsStore.get('fontSize', 14) as number, + enterToSendAI: settingsStore.get('enterToSendAI', false) as boolean, + enterToSendTerminal: settingsStore.get('enterToSendTerminal', true) as boolean, + defaultSaveToHistory: settingsStore.get('defaultSaveToHistory', true) as boolean, + defaultShowThinking: settingsStore.get('defaultShowThinking', 'off') as string, + autoScroll: settingsStore.get('autoScrollAiMode', false) as boolean, + notificationsEnabled: settingsStore.get('osNotificationsEnabled', true) as boolean, + audioFeedbackEnabled: settingsStore.get('audioFeedbackEnabled', false) as boolean, + colorBlindMode: settingsStore.get('colorBlindMode', 'false') as string, + conductorProfile: settingsStore.get('conductorProfile', '') as string, + }; + }); + + // Set up callback for web server to modify settings + // Uses IPC request-response pattern — forwards to renderer which applies via existing settings infrastructure + // After a successful set, re-reads all settings and broadcasts the change to all web clients + server.setSetSettingCallback(async (key: string, value: unknown) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for setSetting', 'WebServer'); + return false; + } + + return new Promise((resolve) => { + const responseChannel = `remote:setSetting:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + const success = result ?? false; + + // After successful setting change, broadcast updated settings to all web clients + if (success) { + const settings = { + theme: settingsStore.get('activeThemeId', 'dracula') as string, + fontSize: settingsStore.get('fontSize', 14) as number, + enterToSendAI: settingsStore.get('enterToSendAI', false) as boolean, + enterToSendTerminal: settingsStore.get('enterToSendTerminal', true) as boolean, + defaultSaveToHistory: settingsStore.get('defaultSaveToHistory', true) as boolean, + defaultShowThinking: settingsStore.get('defaultShowThinking', 'off') as string, + autoScroll: settingsStore.get('autoScrollAiMode', false) as boolean, + notificationsEnabled: settingsStore.get('osNotificationsEnabled', true) as boolean, + audioFeedbackEnabled: settingsStore.get('audioFeedbackEnabled', false) as boolean, + colorBlindMode: settingsStore.get('colorBlindMode', 'false') as string, + conductorProfile: settingsStore.get('conductorProfile', '') as string, + }; + server.broadcastSettingsChanged(settings); + } + + resolve(success); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for setSetting', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve(false); + return; + } + mainWindow.webContents.send('remote:setSetting', key, value, responseChannel); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn(`setSetting callback timed out for key ${key}`, 'WebServer'); + resolve(false); + }, 5000); + }); + }); + + // Set up callback for web server to read groups + // Direct read from groupsStore, derive sessionIds from sessions + server.setGetGroupsCallback(() => { + const groups = groupsStore.get('groups', []); + const sessions = sessionsStore.get('sessions', []); + return groups.map((g) => ({ + id: g.id, + name: g.name, + emoji: g.emoji || null, + sessionIds: sessions.filter((s) => s.groupId === g.id).map((s) => s.id), + })); + }); + + // Set up callback for web server to create a session + // Uses IPC request-response pattern — renderer creates the session and responds with sessionId + server.setCreateSessionCallback( + async (name: string, toolType: string, cwd: string, groupId?: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for createSession', 'WebServer'); + return null; + } + + return new Promise((resolve) => { + const responseChannel = `remote:createSession:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result || null); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for createSession', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve(null); + return; + } + mainWindow.webContents.send( + 'remote:createSession', + name, + toolType, + cwd, + groupId, + responseChannel + ); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn(`createSession callback timed out`, 'WebServer'); + resolve(null); + }, 10000); + }); + } + ); + + // Set up callback for web server to delete a session + // Fire-and-forget pattern + server.setDeleteSessionCallback(async (sessionId: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for deleteSession', 'WebServer'); + return false; + } + + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for deleteSession', 'WebServer'); + return false; + } + mainWindow.webContents.send('remote:deleteSession', sessionId); + return true; + }); + + // Set up callback for web server to rename a session + // Uses IPC request-response pattern + server.setRenameSessionCallback(async (sessionId: string, newName: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for renameSession', 'WebServer'); + return false; + } + + return new Promise((resolve) => { + const responseChannel = `remote:renameSession:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result ?? false); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for renameSession', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve(false); + return; + } + mainWindow.webContents.send('remote:renameSession', sessionId, newName, responseChannel); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn(`renameSession callback timed out for session ${sessionId}`, 'WebServer'); + resolve(false); + }, 5000); + }); + }); + + // Set up callback for web server to create a group + // Uses IPC request-response pattern + server.setCreateGroupCallback(async (name: string, emoji?: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for createGroup', 'WebServer'); + return null; + } + + return new Promise((resolve) => { + const responseChannel = `remote:createGroup:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result || null); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for createGroup', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve(null); + return; + } + mainWindow.webContents.send('remote:createGroup', name, emoji, responseChannel); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn(`createGroup callback timed out`, 'WebServer'); + resolve(null); + }, 5000); + }); + }); + + // Set up callback for web server to rename a group + // Uses IPC request-response pattern + server.setRenameGroupCallback(async (groupId: string, name: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for renameGroup', 'WebServer'); + return false; + } + + return new Promise((resolve) => { + const responseChannel = `remote:renameGroup:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result ?? false); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for renameGroup', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve(false); + return; + } + mainWindow.webContents.send('remote:renameGroup', groupId, name, responseChannel); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn(`renameGroup callback timed out for group ${groupId}`, 'WebServer'); + resolve(false); + }, 5000); + }); + }); + + // Set up callback for web server to delete a group + // Fire-and-forget pattern + server.setDeleteGroupCallback(async (groupId: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for deleteGroup', 'WebServer'); + return false; + } + + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for deleteGroup', 'WebServer'); + return false; + } + mainWindow.webContents.send('remote:deleteGroup', groupId); + return true; + }); + + // Set up callback for web server to move a session to a group + // Uses IPC request-response pattern + server.setMoveSessionToGroupCallback(async (sessionId: string, groupId: string | null) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for moveSessionToGroup', 'WebServer'); + return false; + } + + return new Promise((resolve) => { + const responseChannel = `remote:moveSessionToGroup:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result ?? false); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for moveSessionToGroup', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve(false); + return; + } + mainWindow.webContents.send( + 'remote:moveSessionToGroup', + sessionId, + groupId, + responseChannel + ); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn( + `moveSessionToGroup callback timed out for session ${sessionId}`, + 'WebServer' + ); + resolve(false); + }, 5000); + }); + }); + + // Set up callback for web server to get git status + // Uses IPC request-response pattern with timeout + server.setGetGitStatusCallback(async (sessionId: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for getGitStatus', 'WebServer'); + return { branch: '', files: [], ahead: 0, behind: 0 }; + } + + return new Promise((resolve) => { + const responseChannel = `remote:getGitStatus:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result || { branch: '', files: [], ahead: 0, behind: 0 }); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for getGitStatus', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve({ branch: '', files: [], ahead: 0, behind: 0 }); + return; + } + mainWindow.webContents.send('remote:getGitStatus', sessionId, responseChannel); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn(`getGitStatus callback timed out for session ${sessionId}`, 'WebServer'); + resolve({ branch: '', files: [], ahead: 0, behind: 0 }); + }, 10000); + }); + }); + + // Set up callback for web server to get git diff + // Uses IPC request-response pattern with timeout + server.setGetGitDiffCallback(async (sessionId: string, filePath?: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for getGitDiff', 'WebServer'); + return { diff: '', files: [] }; + } + + return new Promise((resolve) => { + const responseChannel = `remote:getGitDiff:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result || { diff: '', files: [] }); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for getGitDiff', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve({ diff: '', files: [] }); + return; + } + mainWindow.webContents.send('remote:getGitDiff', sessionId, filePath, responseChannel); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn(`getGitDiff callback timed out for session ${sessionId}`, 'WebServer'); + resolve({ diff: '', files: [] }); + }, 10000); + }); + }); + + // Set up callback for web server to stop Auto Run + // Fire-and-forget pattern (like interrupt) + server.setStopAutoRunCallback(async (sessionId: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for stopAutoRun', 'WebServer'); + return false; + } + + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for stopAutoRun', 'WebServer'); + return false; + } + mainWindow.webContents.send('remote:stopAutoRun', sessionId); + return true; + }); + + // ============ Group Chat Callbacks ============ + + // Get all group chats — uses IPC request-response pattern + server.setGetGroupChatsCallback(async () => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for getGroupChats', 'WebServer'); + return []; + } + + return new Promise((resolve) => { + const responseChannel = `remote:getGroupChats:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result || []); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for getGroupChats', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve([]); + return; + } + mainWindow.webContents.send('remote:getGroupChats', responseChannel); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn(`getGroupChats callback timed out`, 'WebServer'); + resolve([]); + }, 10000); + }); + }); + + // Start a group chat — uses IPC request-response pattern + server.setStartGroupChatCallback(async (topic: string, participantIds: string[]) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for startGroupChat', 'WebServer'); + return null; + } + + return new Promise((resolve) => { + const responseChannel = `remote:startGroupChat:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result || null); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for startGroupChat', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve(null); + return; + } + mainWindow.webContents.send( + 'remote:startGroupChat', + topic, + participantIds, + responseChannel + ); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn(`startGroupChat callback timed out`, 'WebServer'); + resolve(null); + }, 15000); + }); + }); + + // Get group chat state — uses IPC request-response pattern + server.setGetGroupChatStateCallback(async (chatId: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for getGroupChatState', 'WebServer'); + return null; + } + + return new Promise((resolve) => { + const responseChannel = `remote:getGroupChatState:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result || null); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for getGroupChatState', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve(null); + return; + } + mainWindow.webContents.send('remote:getGroupChatState', chatId, responseChannel); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn(`getGroupChatState callback timed out for chat ${chatId}`, 'WebServer'); + resolve(null); + }, 10000); + }); + }); + + // Stop group chat — uses IPC request-response pattern + server.setStopGroupChatCallback(async (chatId: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for stopGroupChat', 'WebServer'); + return false; + } + + return new Promise((resolve) => { + const responseChannel = `remote:stopGroupChat:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result ?? false); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for stopGroupChat', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve(false); + return; + } + mainWindow.webContents.send('remote:stopGroupChat', chatId, responseChannel); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn(`stopGroupChat callback timed out for chat ${chatId}`, 'WebServer'); + resolve(false); + }, 10000); + }); + }); + + // Send message to group chat — uses IPC request-response pattern + server.setSendGroupChatMessageCallback(async (chatId: string, message: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for sendGroupChatMessage', 'WebServer'); + return false; + } + + return new Promise((resolve) => { + const responseChannel = `remote:sendGroupChatMessage:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result ?? false); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for sendGroupChatMessage', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve(false); + return; + } + mainWindow.webContents.send( + 'remote:sendGroupChatMessage', + chatId, + message, + responseChannel + ); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn(`sendGroupChatMessage callback timed out for chat ${chatId}`, 'WebServer'); + resolve(false); + }, 10000); + }); + }); + + // ============ Context Management Callbacks ============ + + // Merge context — uses IPC request-response pattern + server.setMergeContextCallback(async (sourceSessionId: string, targetSessionId: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for mergeContext', 'WebServer'); + return false; + } + + return new Promise((resolve) => { + const responseChannel = `remote:mergeContext:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result ?? false); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for mergeContext', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve(false); + return; + } + mainWindow.webContents.send( + 'remote:mergeContext', + sourceSessionId, + targetSessionId, + responseChannel + ); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn( + `mergeContext callback timed out for sessions ${sourceSessionId} → ${targetSessionId}`, + 'WebServer' + ); + resolve(false); + }, 30000); + }); + }); + + // Transfer context — uses IPC request-response pattern + server.setTransferContextCallback(async (sourceSessionId: string, targetSessionId: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for transferContext', 'WebServer'); + return false; + } + + return new Promise((resolve) => { + const responseChannel = `remote:transferContext:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result ?? false); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for transferContext', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve(false); + return; + } + mainWindow.webContents.send( + 'remote:transferContext', + sourceSessionId, + targetSessionId, + responseChannel + ); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn( + `transferContext callback timed out for sessions ${sourceSessionId} → ${targetSessionId}`, + 'WebServer' + ); + resolve(false); + }, 30000); + }); + }); + + // Summarize context — uses IPC request-response pattern + server.setSummarizeContextCallback(async (sessionId: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for summarizeContext', 'WebServer'); + return false; + } + + return new Promise((resolve) => { + const responseChannel = `remote:summarizeContext:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result ?? false); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for summarizeContext', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve(false); + return; + } + mainWindow.webContents.send('remote:summarizeContext', sessionId, responseChannel); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn(`summarizeContext callback timed out for session ${sessionId}`, 'WebServer'); + resolve(false); + }, 60000); + }); + }); + + // ============ Cue Automation Callbacks ============ + + // Get Cue subscriptions — uses IPC request-response pattern + server.setGetCueSubscriptionsCallback(async (sessionId?: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for getCueSubscriptions', 'WebServer'); + return []; + } + + return new Promise((resolve) => { + const responseChannel = `remote:getCueSubscriptions:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result ?? []); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for getCueSubscriptions', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve([]); + return; + } + mainWindow.webContents.send('remote:getCueSubscriptions', sessionId, responseChannel); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn('getCueSubscriptions callback timed out', 'WebServer'); + resolve([]); + }, 30000); + }); + }); + + // Toggle Cue subscription — uses IPC request-response pattern + server.setToggleCueSubscriptionCallback(async (subscriptionId: string, enabled: boolean) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for toggleCueSubscription', 'WebServer'); + return false; + } + + return new Promise((resolve) => { + const responseChannel = `remote:toggleCueSubscription:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result ?? false); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for toggleCueSubscription', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve(false); + return; + } + mainWindow.webContents.send( + 'remote:toggleCueSubscription', + subscriptionId, + enabled, + responseChannel + ); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn( + `toggleCueSubscription callback timed out for ${subscriptionId}`, + 'WebServer' + ); + resolve(false); + }, 10000); + }); + }); + + // Get Cue activity log — uses IPC request-response pattern + server.setGetCueActivityCallback(async (sessionId?: string, limit?: number) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for getCueActivity', 'WebServer'); + return []; + } + + return new Promise((resolve) => { + const responseChannel = `remote:getCueActivity:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result ?? []); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for getCueActivity', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve([]); + return; + } + mainWindow.webContents.send( + 'remote:getCueActivity', + sessionId, + limit ?? 50, + responseChannel + ); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn('getCueActivity callback timed out', 'WebServer'); + resolve([]); + }, 30000); + }); + }); + + // ============ Usage Dashboard & Achievements Callbacks ============ + + // Get usage dashboard data — aggregates from session usage stats via IPC + server.setGetUsageDashboardCallback(async (timeRange: 'day' | 'week' | 'month' | 'all') => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for getUsageDashboard', 'WebServer'); + return { + totalTokensIn: 0, + totalTokensOut: 0, + totalCost: 0, + sessionBreakdown: [], + dailyUsage: [], + }; + } + + return new Promise((resolve) => { + const responseChannel = `remote:getUsageDashboard:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve( + result ?? { + totalTokensIn: 0, + totalTokensOut: 0, + totalCost: 0, + sessionBreakdown: [], + dailyUsage: [], + } + ); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for getUsageDashboard', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve({ + totalTokensIn: 0, + totalTokensOut: 0, + totalCost: 0, + sessionBreakdown: [], + dailyUsage: [], + }); + return; + } + mainWindow.webContents.send('remote:getUsageDashboard', timeRange, responseChannel); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn('getUsageDashboard callback timed out', 'WebServer'); + resolve({ + totalTokensIn: 0, + totalTokensOut: 0, + totalCost: 0, + sessionBreakdown: [], + dailyUsage: [], + }); + }, 15000); + }); + }); + + // Get achievements data — aggregates from settings store via IPC + server.setGetAchievementsCallback(async () => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for getAchievements', 'WebServer'); + return []; + } + + return new Promise((resolve) => { + const responseChannel = `remote:getAchievements:response:${randomUUID()}`; + let resolved = false; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result ?? []); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for getAchievements', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve([]); + return; + } + mainWindow.webContents.send('remote:getAchievements', responseChannel); + + const timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn('getAchievements callback timed out', 'WebServer'); + resolve([]); + }, 10000); + }); + }); + return server; }; } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 68c2285797..5aab92bec7 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -20,6 +20,7 @@ import { MaestroWizard, useWizard, WizardResumeModal, + AUTO_RUN_FOLDER_NAME, type SerializableWizardState, type WizardStep, } from './components/Wizard'; @@ -169,7 +170,15 @@ import { ToastContainer } from './components/Toast'; // Import types and constants // Note: GroupChat, GroupChatState are imported from types (re-exported from shared) -import type { RightPanelTab, Session, QueuedItem, CustomAICommand, ThinkingItem } from './types'; +import type { + RightPanelTab, + Session, + QueuedItem, + CustomAICommand, + ThinkingItem, + AITab, + ToolType, +} from './types'; import { THEMES } from './constants/themes'; import { generateId } from './utils/ids'; import { getContextColor } from './utils/theme'; @@ -193,6 +202,7 @@ import { // validateNewSession moved to useSymphonyContribution, useSessionCrud hooks // formatLogsForClipboard moved to useTabExportHandlers hook // getSlashCommandDescription moved to useWizardHandlers +import { useSettingsStore } from './stores/settingsStore'; import { useUIStore } from './stores/uiStore'; import { useTabStore } from './stores/tabStore'; import { useFileExplorerStore } from './stores/fileExplorerStore'; @@ -1309,6 +1319,7 @@ function MaestroConsoleInner() { // --- BATCH HANDLERS (Auto Run processing, quit confirmation, error handling) --- const { startBatchRun, + stopBatchRun, getBatchState, handleStopBatchRun, handleKillBatchRun, @@ -1991,6 +2002,350 @@ function MaestroConsoleInner() { return () => window.removeEventListener('maestro:configureAutoRun', handler); }, [sessionsRef, startBatchRun]); + // Handle remote get auto-run docs from web interface + useEffect(() => { + const handler = async (e: Event) => { + const { sessionId, responseChannel } = (e as CustomEvent).detail; + try { + const session = sessionsRef.current.find((s) => s.id === sessionId); + if (!session?.autoRunFolderPath) { + window.maestro.process.sendRemoteGetAutoRunDocsResponse(responseChannel, []); + return; + } + const sshRemoteId = + session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId || undefined; + const listResult = await window.maestro.autorun.listDocs( + session.autoRunFolderPath, + sshRemoteId + ); + const files = listResult.success ? listResult.files || [] : []; + window.maestro.process.sendRemoteGetAutoRunDocsResponse(responseChannel, files); + } catch (error) { + console.error('[Remote] Failed to get auto-run docs:', error); + window.maestro.process.sendRemoteGetAutoRunDocsResponse(responseChannel, []); + } + }; + window.addEventListener('maestro:getAutoRunDocs', handler); + return () => window.removeEventListener('maestro:getAutoRunDocs', handler); + }, [sessionsRef]); + + // Handle remote get auto-run doc content from web interface + useEffect(() => { + const handler = async (e: Event) => { + const { sessionId, filename, responseChannel } = (e as CustomEvent).detail; + try { + const session = sessionsRef.current.find((s) => s.id === sessionId); + if (!session?.autoRunFolderPath) { + window.maestro.process.sendRemoteGetAutoRunDocContentResponse(responseChannel, ''); + return; + } + const sshRemoteId = + session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId || undefined; + const contentResult = await window.maestro.autorun.readDoc( + session.autoRunFolderPath, + filename, + sshRemoteId + ); + const content = contentResult.success ? contentResult.content || '' : ''; + window.maestro.process.sendRemoteGetAutoRunDocContentResponse(responseChannel, content); + } catch (error) { + console.error('[Remote] Failed to get auto-run doc content:', error); + window.maestro.process.sendRemoteGetAutoRunDocContentResponse(responseChannel, ''); + } + }; + window.addEventListener('maestro:getAutoRunDocContent', handler); + return () => window.removeEventListener('maestro:getAutoRunDocContent', handler); + }, [sessionsRef]); + + // Handle remote save auto-run doc from web interface + useEffect(() => { + const handler = async (e: Event) => { + const { sessionId, filename, content, responseChannel } = (e as CustomEvent).detail; + try { + const session = sessionsRef.current.find((s) => s.id === sessionId); + if (!session?.autoRunFolderPath) { + window.maestro.process.sendRemoteSaveAutoRunDocResponse(responseChannel, false); + return; + } + const sshRemoteId = + session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId || undefined; + const writeResult = await window.maestro.autorun.writeDoc( + session.autoRunFolderPath, + filename, + content, + sshRemoteId + ); + window.maestro.process.sendRemoteSaveAutoRunDocResponse( + responseChannel, + writeResult.success ?? false + ); + } catch (error) { + console.error('[Remote] Failed to save auto-run doc:', error); + window.maestro.process.sendRemoteSaveAutoRunDocResponse(responseChannel, false); + } + }; + window.addEventListener('maestro:saveAutoRunDoc', handler); + return () => window.removeEventListener('maestro:saveAutoRunDoc', handler); + }, [sessionsRef]); + + // Handle remote stop auto-run from web interface (fire-and-forget, no confirmation dialog) + useEffect(() => { + const handler = (e: Event) => { + const { sessionId } = (e as CustomEvent).detail; + stopBatchRun(sessionId); + }; + window.addEventListener('maestro:stopAutoRun', handler); + return () => window.removeEventListener('maestro:stopAutoRun', handler); + }, [stopBatchRun]); + + // Handle remote create session from web interface + useEffect(() => { + const handler = async (e: Event) => { + const { name, toolType, cwd, groupId, responseChannel } = (e as CustomEvent).detail; + try { + // Get agent definition to validate + const agent = await (window as any).maestro.agents.get(toolType); + if (!agent) { + window.maestro.process.sendRemoteCreateSessionResponse(responseChannel, null); + return; + } + + const currentDefaults = useSettingsStore.getState(); + const newId = generateId(); + const initialTabId = generateId(); + const initialTab: AITab = { + id: initialTabId, + agentSessionId: null, + name: null, + starred: false, + logs: [], + inputValue: '', + stagedImages: [], + createdAt: Date.now(), + state: 'idle', + saveToHistory: currentDefaults.defaultSaveToHistory, + showThinking: currentDefaults.defaultShowThinking, + }; + + const newSession: Session = { + id: newId, + name, + toolType: toolType as ToolType, + state: 'idle', + cwd, + fullPath: cwd, + projectRoot: cwd, + isGitRepo: false, + aiLogs: [], + shellLogs: [ + { + id: generateId(), + timestamp: Date.now(), + source: 'system', + text: 'Shell Session Ready.', + }, + ], + workLog: [], + contextUsage: 0, + inputMode: toolType === 'terminal' ? 'terminal' : 'ai', + aiPid: 0, + terminalPid: 0, + port: 3000 + Math.floor(Math.random() * 100), + isLive: false, + changedFiles: [], + fileTree: [], + fileExplorerExpanded: [], + fileExplorerScrollPos: 0, + fileTreeAutoRefreshInterval: 180, + shellCwd: cwd, + aiCommandHistory: [], + shellCommandHistory: [], + executionQueue: [], + activeTimeMs: 0, + aiTabs: [initialTab], + activeTabId: initialTabId, + closedTabHistory: [], + filePreviewTabs: [], + activeFileTabId: null, + terminalTabs: [], + activeTerminalTabId: null, + unifiedTabOrder: [{ type: 'ai' as const, id: initialTabId }], + unifiedClosedTabHistory: [], + groupId: groupId || undefined, + autoRunFolderPath: `${cwd}/${AUTO_RUN_FOLDER_NAME}`, + }; + + setSessions((prev) => [...prev, newSession]); + setActiveSessionId(newId); + (window as any).maestro.stats.recordSessionCreated({ + sessionId: newId, + agentType: toolType, + projectPath: cwd, + createdAt: Date.now(), + isRemote: false, + }); + + window.maestro.process.sendRemoteCreateSessionResponse(responseChannel, { + sessionId: newId, + }); + } catch (error) { + console.error('[Remote] Failed to create session:', error); + window.maestro.process.sendRemoteCreateSessionResponse(responseChannel, null); + } + }; + window.addEventListener('maestro:remoteCreateSession', handler); + return () => window.removeEventListener('maestro:remoteCreateSession', handler); + }, [setSessions, setActiveSessionId]); + + // Handle remote delete session from web interface (skip confirmation dialog) + useEffect(() => { + const handler = async (e: Event) => { + const { sessionId } = (e as CustomEvent).detail; + const session = sessionsRef.current.find((s) => s.id === sessionId); + if (!session) return; + + // Kill processes + try { + await window.maestro.process.kill(`${sessionId}-ai`); + } catch { + /* ignore */ + } + try { + await window.maestro.process.kill(`${sessionId}-terminal`); + } catch { + /* ignore */ + } + for (const tab of session.terminalTabs || []) { + try { + await window.maestro.process.kill(`${sessionId}-terminal-${tab.id}`); + } catch { + /* ignore */ + } + } + + // Remove session + setSessions((prev) => { + const filtered = prev.filter((s) => s.id !== sessionId); + if (filtered.length > 0 && useSessionStore.getState().activeSessionId === sessionId) { + setActiveSessionId(filtered[0].id); + } + return filtered; + }); + }; + window.addEventListener('maestro:remoteDeleteSession', handler); + return () => window.removeEventListener('maestro:remoteDeleteSession', handler); + }, [sessionsRef, setSessions, setActiveSessionId]); + + // Handle remote rename session from web interface + useEffect(() => { + const handler = (e: Event) => { + const { sessionId, newName, responseChannel } = (e as CustomEvent).detail; + const session = sessionsRef.current.find((s) => s.id === sessionId); + if (!session) { + window.maestro.process.sendRemoteRenameSessionResponse(responseChannel, false); + return; + } + + setSessions((prev) => { + const updated = prev.map((s) => (s.id === sessionId ? { ...s, name: newName } : s)); + const sess = updated.find((s) => s.id === sessionId); + // Persist name to agent storage + const providerSessionId = + sess?.agentSessionId || + sess?.aiTabs?.find((t) => t.id === sess.activeTabId)?.agentSessionId || + sess?.aiTabs?.[0]?.agentSessionId; + if (providerSessionId && sess?.projectRoot) { + const agentId = sess.toolType || 'claude-code'; + if (agentId === 'claude-code') { + (window as any).maestro.claude + .updateSessionName(sess.projectRoot, providerSessionId, newName) + .catch(() => {}); + } else { + (window as any).maestro.agentSessions + .setSessionName(agentId, sess.projectRoot, providerSessionId, newName) + .catch(() => {}); + } + } + return updated; + }); + + window.maestro.process.sendRemoteRenameSessionResponse(responseChannel, true); + }; + window.addEventListener('maestro:remoteRenameSession', handler); + return () => window.removeEventListener('maestro:remoteRenameSession', handler); + }, [sessionsRef, setSessions]); + + // Handle remote create group from web interface + useEffect(() => { + const handler = (e: Event) => { + const { name, emoji, responseChannel } = (e as CustomEvent).detail; + const trimmed = name.trim(); + if (!trimmed) { + window.maestro.process.sendRemoteCreateGroupResponse(responseChannel, null); + return; + } + const newGroupId = `group-${generateId()}`; + setGroups((prev) => [ + ...prev, + { id: newGroupId, name: trimmed.toUpperCase(), emoji: emoji || '📂', collapsed: false }, + ]); + window.maestro.process.sendRemoteCreateGroupResponse(responseChannel, { id: newGroupId }); + }; + window.addEventListener('maestro:remoteCreateGroup', handler); + return () => window.removeEventListener('maestro:remoteCreateGroup', handler); + }, [setGroups]); + + // Handle remote rename group from web interface + useEffect(() => { + const handler = (e: Event) => { + const { groupId, name, responseChannel } = (e as CustomEvent).detail; + const trimmed = name.trim(); + if (!trimmed) { + window.maestro.process.sendRemoteRenameGroupResponse(responseChannel, false); + return; + } + setGroups((prev) => + prev.map((g) => (g.id === groupId ? { ...g, name: trimmed.toUpperCase() } : g)) + ); + window.maestro.process.sendRemoteRenameGroupResponse(responseChannel, true); + }; + window.addEventListener('maestro:remoteRenameGroup', handler); + return () => window.removeEventListener('maestro:remoteRenameGroup', handler); + }, [setGroups]); + + // Handle remote delete group from web interface (fire-and-forget) + useEffect(() => { + const handler = (e: Event) => { + const { groupId } = (e as CustomEvent).detail; + // Ungroup sessions in this group + setSessions((prev) => + prev.map((s) => (s.groupId === groupId ? { ...s, groupId: undefined } : s)) + ); + // Remove the group + setGroups((prev) => prev.filter((g) => g.id !== groupId)); + }; + window.addEventListener('maestro:remoteDeleteGroup', handler); + return () => window.removeEventListener('maestro:remoteDeleteGroup', handler); + }, [setSessions, setGroups]); + + // Handle remote move session to group from web interface + useEffect(() => { + const handler = (e: Event) => { + const { sessionId, groupId, responseChannel } = (e as CustomEvent).detail; + const session = sessionsRef.current.find((s) => s.id === sessionId); + if (!session) { + window.maestro.process.sendRemoteMoveSessionToGroupResponse(responseChannel, false); + return; + } + setSessions((prev) => + prev.map((s) => (s.id === sessionId ? { ...s, groupId: groupId || undefined } : s)) + ); + window.maestro.process.sendRemoteMoveSessionToGroupResponse(responseChannel, true); + }; + window.addEventListener('maestro:remoteMoveSessionToGroup', handler); + return () => window.removeEventListener('maestro:remoteMoveSessionToGroup', handler); + }, [sessionsRef, setSessions]); + // --- GROUP MANAGEMENT --- // Extracted hook for group CRUD operations (toggle, rename, create, drag-drop) const { diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index cc15504af5..306a0591eb 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -367,6 +367,67 @@ interface MaestroAPI { responseChannel: string, result: { success: boolean; playbookId?: string; error?: string } ) => void; + onRemoteGetAutoRunDocs: ( + callback: (sessionId: string, responseChannel: string) => void + ) => () => void; + sendRemoteGetAutoRunDocsResponse: (responseChannel: string, documents: any[]) => void; + onRemoteGetAutoRunDocContent: ( + callback: (sessionId: string, filename: string, responseChannel: string) => void + ) => () => void; + sendRemoteGetAutoRunDocContentResponse: (responseChannel: string, content: string) => void; + onRemoteSaveAutoRunDoc: ( + callback: ( + sessionId: string, + filename: string, + content: string, + responseChannel: string + ) => void + ) => () => void; + sendRemoteSaveAutoRunDocResponse: (responseChannel: string, success: boolean) => void; + onRemoteStopAutoRun: (callback: (sessionId: string) => void) => () => void; + onRemoteSetSetting: ( + callback: (key: string, value: unknown, responseChannel: string) => void + ) => () => void; + sendRemoteSetSettingResponse: (responseChannel: string, success: boolean) => void; + onRemoteCreateSession: ( + callback: ( + name: string, + toolType: string, + cwd: string, + groupId: string | undefined, + responseChannel: string + ) => void + ) => () => void; + sendRemoteCreateSessionResponse: ( + responseChannel: string, + result: { sessionId: string } | null + ) => void; + onRemoteDeleteSession: (callback: (sessionId: string) => void) => () => void; + onRemoteRenameSession: ( + callback: (sessionId: string, newName: string, responseChannel: string) => void + ) => () => void; + sendRemoteRenameSessionResponse: (responseChannel: string, success: boolean) => void; + onRemoteCreateGroup: ( + callback: (name: string, emoji: string | undefined, responseChannel: string) => void + ) => () => void; + sendRemoteCreateGroupResponse: (responseChannel: string, result: { id: string } | null) => void; + onRemoteRenameGroup: ( + callback: (groupId: string, name: string, responseChannel: string) => void + ) => () => void; + sendRemoteRenameGroupResponse: (responseChannel: string, success: boolean) => void; + onRemoteDeleteGroup: (callback: (groupId: string) => void) => () => void; + onRemoteMoveSessionToGroup: ( + callback: (sessionId: string, groupId: string | null, responseChannel: string) => void + ) => () => void; + sendRemoteMoveSessionToGroupResponse: (responseChannel: string, success: boolean) => void; + onRemoteGetGitStatus: ( + callback: (sessionId: string, responseChannel: string) => void + ) => () => void; + sendRemoteGetGitStatusResponse: (responseChannel: string, result: any) => void; + onRemoteGetGitDiff: ( + callback: (sessionId: string, filePath: string | undefined, responseChannel: string) => void + ) => () => void; + sendRemoteGetGitDiffResponse: (responseChannel: string, result: any) => void; onStderr: (callback: (sessionId: string, data: string) => void) => () => void; onCommandExit: (callback: (sessionId: string, code: number) => void) => () => void; onUsage: (callback: (sessionId: string, usageStats: UsageStats) => void) => () => void; diff --git a/src/renderer/hooks/batch/useBatchHandlers.ts b/src/renderer/hooks/batch/useBatchHandlers.ts index befc5bf7e4..80b4bb83db 100644 --- a/src/renderer/hooks/batch/useBatchHandlers.ts +++ b/src/renderer/hooks/batch/useBatchHandlers.ts @@ -61,6 +61,8 @@ export interface UseBatchHandlersDeps { export interface UseBatchHandlersReturn { /** Start a batch run for a session */ startBatchRun: (sessionId: string, config: BatchRunConfig, folderPath: string) => Promise; + /** Stop a batch run directly (no confirmation dialog, used by web remote) */ + stopBatchRun: (sessionId: string) => void; /** Get batch state for a specific session */ getBatchState: (sessionId: string) => BatchRunState; /** Stop batch run with confirmation dialog */ @@ -703,6 +705,7 @@ export function useBatchHandlers(deps: UseBatchHandlersDeps): UseBatchHandlersRe return { startBatchRun, + stopBatchRun, getBatchState, handleStopBatchRun, handleKillBatchRun, diff --git a/src/renderer/hooks/remote/useRemoteIntegration.ts b/src/renderer/hooks/remote/useRemoteIntegration.ts index eaf5e757ab..13c3bf0109 100644 --- a/src/renderer/hooks/remote/useRemoteIntegration.ts +++ b/src/renderer/hooks/remote/useRemoteIntegration.ts @@ -496,6 +496,296 @@ export function useRemoteIntegration(deps: UseRemoteIntegrationDeps): UseRemoteI }; }, []); + // Handle remote get auto-run docs from web interface + useEffect(() => { + const unsubscribe = window.maestro.process.onRemoteGetAutoRunDocs( + (sessionId: string, responseChannel: string) => { + window.dispatchEvent( + new CustomEvent('maestro:getAutoRunDocs', { + detail: { sessionId, responseChannel }, + }) + ); + } + ); + return () => { + unsubscribe(); + }; + }, []); + + // Handle remote get auto-run doc content from web interface + useEffect(() => { + const unsubscribe = window.maestro.process.onRemoteGetAutoRunDocContent( + (sessionId: string, filename: string, responseChannel: string) => { + window.dispatchEvent( + new CustomEvent('maestro:getAutoRunDocContent', { + detail: { sessionId, filename, responseChannel }, + }) + ); + } + ); + return () => { + unsubscribe(); + }; + }, []); + + // Handle remote save auto-run doc from web interface + useEffect(() => { + const unsubscribe = window.maestro.process.onRemoteSaveAutoRunDoc( + (sessionId: string, filename: string, content: string, responseChannel: string) => { + window.dispatchEvent( + new CustomEvent('maestro:saveAutoRunDoc', { + detail: { sessionId, filename, content, responseChannel }, + }) + ); + } + ); + return () => { + unsubscribe(); + }; + }, []); + + // Handle remote stop auto-run from web interface + useEffect(() => { + const unsubscribe = window.maestro.process.onRemoteStopAutoRun((sessionId: string) => { + window.dispatchEvent( + new CustomEvent('maestro:stopAutoRun', { + detail: { sessionId }, + }) + ); + }); + return () => { + unsubscribe(); + }; + }, []); + + // Handle remote set setting from web interface + // Uses the existing settings infrastructure via window.maestro.settings.set() + useEffect(() => { + const unsubscribe = window.maestro.process.onRemoteSetSetting( + async (key: string, value: unknown, responseChannel: string) => { + try { + await window.maestro.settings.set(key, value); + window.maestro.process.sendRemoteSetSettingResponse(responseChannel, true); + } catch { + window.maestro.process.sendRemoteSetSettingResponse(responseChannel, false); + } + } + ); + return () => { + unsubscribe(); + }; + }, []); + + // Handle remote get git status from web interface + // Uses existing git IPC infrastructure (window.maestro.git.status + window.maestro.git.branch) + useEffect(() => { + const unsubscribe = window.maestro.process.onRemoteGetGitStatus( + async (sessionId: string, responseChannel: string) => { + try { + // Look up the session's cwd + const session = sessionsRef.current.find((s) => s.id === sessionId); + if (!session) { + window.maestro.process.sendRemoteGetGitStatusResponse(responseChannel, { + branch: '', + files: [], + ahead: 0, + behind: 0, + }); + return; + } + + const cwd = session.cwd; + + // Run git status --porcelain and git branch in parallel + const [statusResult, branchResult] = await Promise.all([ + window.maestro.git.status(cwd), + window.maestro.git.branch(cwd), + ]); + + // Parse status output + const statusLines = (statusResult.stdout || '') + .replace(/\s+$/, '') + .split('\n') + .filter((line: string) => line.length > 0); + + const files = statusLines.map((line: string) => { + const status = line.substring(0, 2); + const pathField = line.substring(3); + const renameParts = pathField.split(' -> '); + const filePath = renameParts[renameParts.length - 1] || pathField; + // Staged if index column (first char) is not space or ? + const staged = status[0] !== ' ' && status[0] !== '?'; + return { path: filePath, status: status.trim(), staged }; + }); + + const branch = (branchResult.stdout || '').trim(); + + // Get ahead/behind info + let ahead = 0; + let behind = 0; + try { + const infoResult = await window.maestro.git.info(cwd); + ahead = infoResult.ahead || 0; + behind = infoResult.behind || 0; + } catch { + // ahead/behind not available, that's fine + } + + window.maestro.process.sendRemoteGetGitStatusResponse(responseChannel, { + branch, + files, + ahead, + behind, + }); + } catch { + window.maestro.process.sendRemoteGetGitStatusResponse(responseChannel, { + branch: '', + files: [], + ahead: 0, + behind: 0, + }); + } + } + ); + return () => { + unsubscribe(); + }; + }, []); + + // Handle remote get git diff from web interface + // Uses existing git IPC infrastructure (window.maestro.git.diff) + useEffect(() => { + const unsubscribe = window.maestro.process.onRemoteGetGitDiff( + async (sessionId: string, filePath: string | undefined, responseChannel: string) => { + try { + // Look up the session's cwd + const session = sessionsRef.current.find((s) => s.id === sessionId); + if (!session) { + window.maestro.process.sendRemoteGetGitDiffResponse(responseChannel, { + diff: '', + files: [], + }); + return; + } + + const cwd = session.cwd; + const diffResult = await window.maestro.git.diff(cwd, filePath); + const diff = diffResult.stdout || ''; + + // Extract changed file paths from diff output + const fileMatches = diff.match(/^diff --git a\/.+ b\/(.+)$/gm) || []; + const files = fileMatches + .map((line: string) => { + const match = line.match(/^diff --git a\/.+ b\/(.+)$/); + return match ? match[1] : ''; + }) + .filter(Boolean); + + window.maestro.process.sendRemoteGetGitDiffResponse(responseChannel, { + diff, + files, + }); + } catch { + window.maestro.process.sendRemoteGetGitDiffResponse(responseChannel, { + diff: '', + files: [], + }); + } + } + ); + return () => { + unsubscribe(); + }; + }, []); + + // Handle remote session/group management from web interface + // These dispatch CustomEvents for App.tsx to handle via existing session/group management hooks + useEffect(() => { + const unsubscribeCreateSession = window.maestro.process.onRemoteCreateSession( + ( + name: string, + toolType: string, + cwd: string, + groupId: string | undefined, + responseChannel: string + ) => { + window.dispatchEvent( + new CustomEvent('maestro:remoteCreateSession', { + detail: { name, toolType, cwd, groupId, responseChannel }, + }) + ); + } + ); + + const unsubscribeDeleteSession = window.maestro.process.onRemoteDeleteSession( + (sessionId: string) => { + window.dispatchEvent( + new CustomEvent('maestro:remoteDeleteSession', { + detail: { sessionId }, + }) + ); + } + ); + + const unsubscribeRenameSession = window.maestro.process.onRemoteRenameSession( + (sessionId: string, newName: string, responseChannel: string) => { + window.dispatchEvent( + new CustomEvent('maestro:remoteRenameSession', { + detail: { sessionId, newName, responseChannel }, + }) + ); + } + ); + + const unsubscribeCreateGroup = window.maestro.process.onRemoteCreateGroup( + (name: string, emoji: string | undefined, responseChannel: string) => { + window.dispatchEvent( + new CustomEvent('maestro:remoteCreateGroup', { + detail: { name, emoji, responseChannel }, + }) + ); + } + ); + + const unsubscribeRenameGroup = window.maestro.process.onRemoteRenameGroup( + (groupId: string, name: string, responseChannel: string) => { + window.dispatchEvent( + new CustomEvent('maestro:remoteRenameGroup', { + detail: { groupId, name, responseChannel }, + }) + ); + } + ); + + const unsubscribeDeleteGroup = window.maestro.process.onRemoteDeleteGroup((groupId: string) => { + window.dispatchEvent( + new CustomEvent('maestro:remoteDeleteGroup', { + detail: { groupId }, + }) + ); + }); + + const unsubscribeMoveSessionToGroup = window.maestro.process.onRemoteMoveSessionToGroup( + (sessionId: string, groupId: string | null, responseChannel: string) => { + window.dispatchEvent( + new CustomEvent('maestro:remoteMoveSessionToGroup', { + detail: { sessionId, groupId, responseChannel }, + }) + ); + } + ); + + return () => { + unsubscribeCreateSession(); + unsubscribeDeleteSession(); + unsubscribeRenameSession(); + unsubscribeCreateGroup(); + unsubscribeRenameGroup(); + unsubscribeDeleteGroup(); + unsubscribeMoveSessionToGroup(); + }; + }, []); + // Broadcast tab changes to web clients when tabs, activeTabId, or tab properties change // PERFORMANCE FIX: This effect was previously missing its dependency array, causing it to // run on EVERY render (including every keystroke). Now it only runs when isLiveMode changes, diff --git a/src/renderer/stores/agentStore.ts b/src/renderer/stores/agentStore.ts index 33d83c26a6..b651724264 100644 --- a/src/renderer/stores/agentStore.ts +++ b/src/renderer/stores/agentStore.ts @@ -366,9 +366,6 @@ export const useAgentStore = create()((set, get) => ({ } else if (item.type === 'command' && item.command) { // Process a slash command - find matching command // Check user-defined commands first, then agent-discovered commands with prompts - const agentCmd = session.agentCommands?.find( - (cmd) => cmd.command === item.command && cmd.prompt - ); const matchingCommand = deps.customAICommands.find((cmd) => cmd.command === item.command) || deps.speckitCommands.find((cmd) => cmd.command === item.command) || diff --git a/src/web/hooks/index.ts b/src/web/hooks/index.ts index 8fe2b5c424..5bd05cb003 100644 --- a/src/web/hooks/index.ts +++ b/src/web/hooks/index.ts @@ -22,6 +22,8 @@ export type { SessionRemovedMessage, ThemeMessage, ErrorMessage, + GroupData, + GroupsChangedMessage, TypedServerMessage, WebSocketEventHandlers, UseWebSocketOptions, @@ -182,3 +184,27 @@ export type { UseMobileAutoReconnectDeps, UseMobileAutoReconnectReturn, } from './useMobileAutoReconnect'; + +export { useAgentManagement, default as useAgentManagementDefault } from './useAgentManagement'; + +export type { UseAgentManagementReturn } from './useAgentManagement'; + +export { useGitStatus, default as useGitStatusDefault } from './useGitStatus'; + +export type { + GitStatusFile, + GitStatusResult, + GitDiffResult, + UseGitStatusReturn, +} from './useGitStatus'; + +export { useGroupChat, default as useGroupChatDefault } from './useGroupChat'; + +export type { UseGroupChatReturn } from './useGroupChat'; + +export type { + GroupChatMessage, + GroupChatState, + GroupChatMessageBroadcast, + GroupChatStateChangeBroadcast, +} from './useWebSocket'; diff --git a/src/web/hooks/useAgentManagement.ts b/src/web/hooks/useAgentManagement.ts new file mode 100644 index 0000000000..10e3ee0e09 --- /dev/null +++ b/src/web/hooks/useAgentManagement.ts @@ -0,0 +1,255 @@ +/** + * useAgentManagement hook for agent and group CRUD operations from the web client. + * + * Provides functions for creating, deleting, and renaming agents, + * as well as managing groups (create, rename, delete, move agents). + * Maintains groups state, auto-loaded on mount and refreshed via broadcasts. + */ + +import { useState, useCallback, useEffect, useRef } from 'react'; +import type { UseWebSocketReturn, GroupData } from './useWebSocket'; + +/** + * Return value from useAgentManagement hook. + */ +export interface UseAgentManagementReturn { + /** Current list of groups */ + groups: GroupData[]; + /** Whether groups are currently loading */ + isLoading: boolean; + + /** Create a new agent. Returns { sessionId } on success, null on failure. */ + createAgent: ( + name: string, + toolType: string, + cwd: string, + groupId?: string + ) => Promise<{ sessionId: string } | null>; + /** Delete an agent by session ID. */ + deleteAgent: (sessionId: string) => Promise; + /** Rename an agent. */ + renameAgent: (sessionId: string, newName: string) => Promise; + + /** Fetch the latest groups list. */ + getGroups: () => Promise; + /** Create a new group. Returns { id } on success, null on failure. */ + createGroup: (name: string, emoji?: string) => Promise<{ id: string } | null>; + /** Rename a group. */ + renameGroup: (groupId: string, name: string) => Promise; + /** Delete a group. */ + deleteGroup: (groupId: string) => Promise; + /** Move an agent to a group (or null for ungrouped). */ + moveToGroup: (sessionId: string, groupId: string | null) => Promise; + + /** Handler for groups_changed broadcasts — wire to onGroupsChanged in WebSocket handlers */ + handleGroupsChanged: (groups: GroupData[]) => void; +} + +/** + * Hook for managing agents and groups via WebSocket. + * + * @param sendRequest - WebSocket sendRequest function for request-response operations + * @param isConnected - Whether the WebSocket is currently connected + */ +export function useAgentManagement( + sendRequest: UseWebSocketReturn['sendRequest'], + isConnected: boolean +): UseAgentManagementReturn { + const [groups, setGroups] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const hasFetchedRef = useRef(false); + + // Fetch groups on mount and when connection is established + useEffect(() => { + if (!isConnected) { + hasFetchedRef.current = false; + return; + } + if (hasFetchedRef.current) return; + + hasFetchedRef.current = true; + setIsLoading(true); + + sendRequest<{ groups?: GroupData[] }>('get_groups') + .then((response) => { + if (response.groups) { + setGroups(response.groups); + } + }) + .catch(() => { + // Groups fetch failed — will retry on reconnect + }) + .finally(() => { + setIsLoading(false); + }); + }, [isConnected, sendRequest]); + + /** + * Update groups from a broadcast message. + * Intended to be wired to onGroupsChanged in the WebSocket handlers. + */ + const handleGroupsChanged = useCallback((newGroups: GroupData[]) => { + setGroups(newGroups); + }, []); + + /** + * Fetch the latest groups list on demand. + */ + const getGroups = useCallback(async (): Promise => { + try { + const response = await sendRequest<{ groups?: GroupData[] }>('get_groups'); + const fetched = response.groups ?? []; + setGroups(fetched); + return fetched; + } catch { + return groups; + } + }, [sendRequest, groups]); + + /** + * Create a new agent. + */ + const createAgent = useCallback( + async ( + name: string, + toolType: string, + cwd: string, + groupId?: string + ): Promise<{ sessionId: string } | null> => { + try { + const response = await sendRequest<{ + success?: boolean; + sessionId?: string; + }>('create_session', { name, toolType, cwd, groupId }); + if (response.success && response.sessionId) { + return { sessionId: response.sessionId }; + } + return null; + } catch { + return null; + } + }, + [sendRequest] + ); + + /** + * Delete an agent by session ID. + */ + const deleteAgent = useCallback( + async (sessionId: string): Promise => { + try { + const response = await sendRequest<{ success?: boolean }>('delete_session', { sessionId }); + return response.success ?? false; + } catch { + return false; + } + }, + [sendRequest] + ); + + /** + * Rename an agent. + */ + const renameAgent = useCallback( + async (sessionId: string, newName: string): Promise => { + try { + const response = await sendRequest<{ success?: boolean }>('rename_session', { + sessionId, + newName, + }); + return response.success ?? false; + } catch { + return false; + } + }, + [sendRequest] + ); + + /** + * Create a new group. + */ + const createGroup = useCallback( + async (name: string, emoji?: string): Promise<{ id: string } | null> => { + try { + const response = await sendRequest<{ + success?: boolean; + groupId?: string; + }>('create_group', { name, emoji }); + if (response.success && response.groupId) { + return { id: response.groupId }; + } + return null; + } catch { + return null; + } + }, + [sendRequest] + ); + + /** + * Rename a group. + */ + const renameGroup = useCallback( + async (groupId: string, name: string): Promise => { + try { + const response = await sendRequest<{ success?: boolean }>('rename_group', { + groupId, + name, + }); + return response.success ?? false; + } catch { + return false; + } + }, + [sendRequest] + ); + + /** + * Delete a group. + */ + const deleteGroup = useCallback( + async (groupId: string): Promise => { + try { + const response = await sendRequest<{ success?: boolean }>('delete_group', { groupId }); + return response.success ?? false; + } catch { + return false; + } + }, + [sendRequest] + ); + + /** + * Move an agent to a group (or null to ungroup). + */ + const moveToGroup = useCallback( + async (sessionId: string, groupId: string | null): Promise => { + try { + const response = await sendRequest<{ success?: boolean }>('move_session_to_group', { + sessionId, + groupId, + }); + return response.success ?? false; + } catch { + return false; + } + }, + [sendRequest] + ); + + return { + groups, + isLoading, + createAgent, + deleteAgent, + renameAgent, + getGroups, + createGroup, + renameGroup, + deleteGroup, + moveToGroup, + handleGroupsChanged, + }; +} + +export default useAgentManagement; diff --git a/src/web/hooks/useAutoRun.ts b/src/web/hooks/useAutoRun.ts new file mode 100644 index 0000000000..7da23e18ee --- /dev/null +++ b/src/web/hooks/useAutoRun.ts @@ -0,0 +1,161 @@ +/** + * useAutoRun hook for Auto Run state management in the web interface. + * + * Provides document listing, content loading/saving, launch/stop controls, + * and real-time document change tracking via WebSocket broadcasts. + */ + +import { useState, useCallback } from 'react'; +import type { UseWebSocketReturn, AutoRunState } from './useWebSocket'; + +/** + * Auto Run document metadata (mirrors server-side AutoRunDocument). + */ +export interface AutoRunDocument { + filename: string; + path: string; + taskCount: number; + completedCount: number; +} + +/** + * Currently selected document with content. + */ +export interface SelectedDocument { + filename: string; + content: string; +} + +/** + * Launch configuration for Auto Run. + */ +export interface LaunchConfig { + documents: Array<{ filename: string }>; + prompt?: string; + loopEnabled?: boolean; + maxLoops?: number; +} + +/** + * Return value from useAutoRun hook. + */ +export interface UseAutoRunReturn { + documents: AutoRunDocument[]; + autoRunState: AutoRunState | null; + isLoadingDocs: boolean; + selectedDoc: SelectedDocument | null; + loadDocuments: (sessionId: string) => Promise; + loadDocumentContent: (sessionId: string, filename: string) => Promise; + saveDocumentContent: (sessionId: string, filename: string, content: string) => Promise; + launchAutoRun: (sessionId: string, config: LaunchConfig) => boolean; + stopAutoRun: (sessionId: string) => Promise; +} + +/** + * Hook for managing Auto Run state and operations. + * + * @param sendRequest - WebSocket sendRequest function for request-response operations + * @param send - WebSocket send function for fire-and-forget messages + * @param onMessage - Optional message handler registration callback + */ +export function useAutoRun( + sendRequest: UseWebSocketReturn['sendRequest'], + send: UseWebSocketReturn['send'], + autoRunState: AutoRunState | null = null +): UseAutoRunReturn { + const [documents, setDocuments] = useState([]); + const [isLoadingDocs, setIsLoadingDocs] = useState(false); + const [selectedDoc, setSelectedDoc] = useState(null); + + const loadDocuments = useCallback( + async (sessionId: string) => { + setIsLoadingDocs(true); + try { + const response = await sendRequest<{ documents?: AutoRunDocument[] }>('get_auto_run_docs', { + sessionId, + }); + setDocuments(response.documents ?? []); + } catch { + setDocuments([]); + } finally { + setIsLoadingDocs(false); + } + }, + [sendRequest] + ); + + const loadDocumentContent = useCallback( + async (sessionId: string, filename: string) => { + try { + const response = await sendRequest<{ content?: string }>('get_auto_run_document', { + sessionId, + filename, + }); + setSelectedDoc({ + filename, + content: response.content ?? '', + }); + } catch { + setSelectedDoc({ filename, content: '' }); + } + }, + [sendRequest] + ); + + const saveDocumentContent = useCallback( + async (sessionId: string, filename: string, content: string): Promise => { + try { + const response = await sendRequest<{ success?: boolean }>('save_auto_run_document', { + sessionId, + filename, + content, + }); + return response.success ?? false; + } catch { + return false; + } + }, + [sendRequest] + ); + + const launchAutoRun = useCallback( + (sessionId: string, config: LaunchConfig): boolean => { + return send({ + type: 'configure_auto_run', + sessionId, + documents: config.documents, + prompt: config.prompt, + loopEnabled: config.loopEnabled, + maxLoops: config.maxLoops, + launch: true, + }); + }, + [send] + ); + + const stopAutoRun = useCallback( + async (sessionId: string): Promise => { + try { + const response = await sendRequest<{ success?: boolean }>('stop_auto_run', { sessionId }); + return response.success ?? false; + } catch { + return false; + } + }, + [sendRequest] + ); + + return { + documents, + autoRunState, + isLoadingDocs, + selectedDoc, + loadDocuments, + loadDocumentContent, + saveDocumentContent, + launchAutoRun, + stopAutoRun, + }; +} + +export default useAutoRun; diff --git a/src/web/hooks/useCue.ts b/src/web/hooks/useCue.ts new file mode 100644 index 0000000000..885d897e42 --- /dev/null +++ b/src/web/hooks/useCue.ts @@ -0,0 +1,162 @@ +/** + * useCue hook for Cue automation management in the web interface. + * + * Provides Cue subscription listing, toggling, and activity viewing + * with real-time updates via WebSocket broadcasts. + */ + +import { useState, useCallback, useEffect } from 'react'; +import type { UseWebSocketReturn } from './useWebSocket'; + +/** Web-specific Cue subscription metadata (simplified from engine types) */ +export interface CueSubscriptionInfo { + id: string; + name: string; + eventType: string; + pattern?: string; + schedule?: string; + sessionId: string; + sessionName: string; + enabled: boolean; + lastTriggered?: number; + triggerCount: number; +} + +/** Web-specific Cue activity log entry (simplified from engine types) */ +export interface CueActivityEntry { + id: string; + subscriptionId: string; + subscriptionName: string; + eventType: string; + sessionId: string; + timestamp: number; + status: 'triggered' | 'running' | 'completed' | 'failed'; + result?: string; + duration?: number; +} + +/** + * Return value from useCue hook. + */ +export interface UseCueReturn { + /** All known Cue subscriptions */ + subscriptions: CueSubscriptionInfo[]; + /** Recent Cue activity entries (most recent first) */ + activity: CueActivityEntry[]; + /** Whether data is being loaded */ + isLoading: boolean; + /** Load subscriptions from the server */ + loadSubscriptions: (sessionId?: string) => Promise; + /** Toggle a subscription's enabled state */ + toggleSubscription: (subscriptionId: string, enabled: boolean) => Promise; + /** Load activity entries from the server */ + loadActivity: (sessionId?: string, limit?: number) => Promise; + /** Handle incoming Cue activity broadcast */ + handleCueActivityEvent: (entry: CueActivityEntry) => void; + /** Handle incoming Cue subscriptions changed broadcast */ + handleCueSubscriptionsChanged: (subscriptions: CueSubscriptionInfo[]) => void; +} + +const MAX_ACTIVITY_ENTRIES = 100; + +/** + * Hook for managing Cue automation state and operations. + * + * @param sendRequest - WebSocket sendRequest function for request-response operations + * @param send - WebSocket send function for fire-and-forget messages + * @param isConnected - Whether the WebSocket is connected + */ +export function useCue( + sendRequest: UseWebSocketReturn['sendRequest'], + _send: UseWebSocketReturn['send'], + isConnected: boolean +): UseCueReturn { + const [subscriptions, setSubscriptions] = useState([]); + const [activity, setActivity] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const loadSubscriptions = useCallback( + async (sessionId?: string) => { + setIsLoading(true); + try { + const response = await sendRequest<{ subscriptions?: CueSubscriptionInfo[] }>( + 'get_cue_subscriptions', + sessionId ? { sessionId } : undefined + ); + setSubscriptions(response.subscriptions ?? []); + } catch { + setSubscriptions([]); + } finally { + setIsLoading(false); + } + }, + [sendRequest] + ); + + const toggleSubscription = useCallback( + async (subscriptionId: string, enabled: boolean): Promise => { + try { + const response = await sendRequest<{ success?: boolean }>('toggle_cue_subscription', { + subscriptionId, + enabled, + }); + return response.success ?? false; + } catch { + return false; + } + }, + [sendRequest] + ); + + const loadActivity = useCallback( + async (sessionId?: string, limit?: number) => { + setIsLoading(true); + try { + const response = await sendRequest<{ entries?: CueActivityEntry[] }>('get_cue_activity', { + ...(sessionId ? { sessionId } : {}), + ...(limit ? { limit } : {}), + }); + setActivity(response.entries ?? []); + } catch { + setActivity([]); + } finally { + setIsLoading(false); + } + }, + [sendRequest] + ); + + const handleCueActivityEvent = useCallback((entry: CueActivityEntry) => { + setActivity((prev) => { + const updated = [entry, ...prev]; + return updated.length > MAX_ACTIVITY_ENTRIES + ? updated.slice(0, MAX_ACTIVITY_ENTRIES) + : updated; + }); + }, []); + + const handleCueSubscriptionsChanged = useCallback((subs: CueSubscriptionInfo[]) => { + setSubscriptions(subs); + }, []); + + // Auto-load on mount when connected + useEffect(() => { + if (isConnected) { + loadSubscriptions(); + loadActivity(); + } + }, [isConnected, loadSubscriptions, loadActivity]); + + return { + subscriptions, + activity, + isLoading, + loadSubscriptions, + toggleSubscription, + loadActivity, + handleCueActivityEvent, + handleCueSubscriptionsChanged, + }; +} + +export default useCue; diff --git a/src/web/hooks/useGitStatus.ts b/src/web/hooks/useGitStatus.ts new file mode 100644 index 0000000000..9c6b3bda8a --- /dev/null +++ b/src/web/hooks/useGitStatus.ts @@ -0,0 +1,169 @@ +/** + * useGitStatus hook for git status and diff information from the web client. + * + * Provides git status and diff loading via WebSocket request-response, + * with auto-load on sessionId changes. + */ + +import { useState, useCallback, useEffect, useRef } from 'react'; +import type { UseWebSocketReturn } from './useWebSocket'; + +/** + * Git file status entry. + */ +export interface GitStatusFile { + path: string; + status: string; + staged: boolean; +} + +/** + * Git status result from the server. + */ +export interface GitStatusResult { + branch: string; + files: GitStatusFile[]; + ahead: number; + behind: number; +} + +/** + * Git diff result from the server. + */ +export interface GitDiffResult { + diff: string; + files: string[]; +} + +/** + * Return value from useGitStatus hook. + */ +export interface UseGitStatusReturn { + status: GitStatusResult | null; + diff: GitDiffResult | null; + isLoading: boolean; + loadStatus: (sessionId: string) => Promise; + loadDiff: (sessionId: string, filePath?: string) => Promise; + refresh: (sessionId: string) => Promise; +} + +/** + * Hook for managing git status and diff state via WebSocket. + * + * @param sendRequest - WebSocket sendRequest function for request-response operations + * @param isConnected - Whether the WebSocket is currently connected + * @param sessionId - Optional session ID to auto-load status when it changes + */ +export function useGitStatus( + sendRequest: UseWebSocketReturn['sendRequest'], + isConnected: boolean, + sessionId?: string +): UseGitStatusReturn { + const [status, setStatus] = useState(null); + const [diff, setDiff] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const lastSessionIdRef = useRef(undefined); + + /** + * Load git status for a session. + */ + const loadStatus = useCallback( + async (sid: string): Promise => { + if (!isConnected) return; + + setIsLoading(true); + try { + const response = await sendRequest<{ status?: GitStatusResult }>('get_git_status', { + sessionId: sid, + }); + if (response.status) { + setStatus(response.status); + } + } catch { + // Status fetch failed — will retry on next call + } finally { + setIsLoading(false); + } + }, + [sendRequest, isConnected] + ); + + /** + * Load git diff for a session, optionally for a specific file. + */ + const loadDiff = useCallback( + async (sid: string, filePath?: string): Promise => { + if (!isConnected) return; + + setIsLoading(true); + try { + const response = await sendRequest<{ diff?: GitDiffResult }>('get_git_diff', { + sessionId: sid, + filePath, + }); + if (response.diff) { + setDiff(response.diff); + } + } catch { + // Diff fetch failed — will retry on next call + } finally { + setIsLoading(false); + } + }, + [sendRequest, isConnected] + ); + + /** + * Refresh both status and diff for a session. + */ + const refresh = useCallback( + async (sid: string): Promise => { + if (!isConnected) return; + + setIsLoading(true); + try { + const [statusResponse, diffResponse] = await Promise.all([ + sendRequest<{ status?: GitStatusResult }>('get_git_status', { sessionId: sid }), + sendRequest<{ diff?: GitDiffResult }>('get_git_diff', { sessionId: sid }), + ]); + if (statusResponse.status) { + setStatus(statusResponse.status); + } + if (diffResponse.diff) { + setDiff(diffResponse.diff); + } + } catch { + // Refresh failed — will retry on next call + } finally { + setIsLoading(false); + } + }, + [sendRequest, isConnected] + ); + + // Auto-load status when sessionId changes + useEffect(() => { + if (!isConnected || !sessionId) { + return; + } + if (lastSessionIdRef.current === sessionId) { + return; + } + + lastSessionIdRef.current = sessionId; + setStatus(null); + setDiff(null); + loadStatus(sessionId); + }, [isConnected, sessionId, loadStatus]); + + return { + status, + diff, + isLoading, + loadStatus, + loadDiff, + refresh, + }; +} + +export default useGitStatus; diff --git a/src/web/hooks/useGroupChat.ts b/src/web/hooks/useGroupChat.ts new file mode 100644 index 0000000000..5dd769ec74 --- /dev/null +++ b/src/web/hooks/useGroupChat.ts @@ -0,0 +1,210 @@ +/** + * useGroupChat hook for group chat management in the web interface. + * + * Provides group chat listing, creation, messaging, and real-time + * state updates via WebSocket broadcasts. + */ + +import { useState, useCallback, useEffect } from 'react'; +import type { UseWebSocketReturn, GroupChatState, GroupChatMessage } from './useWebSocket'; + +/** + * Return value from useGroupChat hook. + */ +export interface UseGroupChatReturn { + /** All known group chats */ + chats: GroupChatState[]; + /** Currently active/viewed chat */ + activeChat: GroupChatState | null; + /** Whether chats are being loaded */ + isLoading: boolean; + /** Load all group chats from the server */ + loadChats: () => Promise; + /** Start a new group chat */ + startChat: (topic: string, participantIds: string[]) => Promise; + /** Load full state for a specific chat */ + loadChatState: (chatId: string) => Promise; + /** Send a message to a group chat */ + sendMessage: (chatId: string, message: string) => Promise; + /** Stop a group chat */ + stopChat: (chatId: string) => Promise; + /** Set the active chat by ID (or null to deselect) */ + setActiveChatId: (chatId: string | null) => void; + /** Handle incoming group chat message broadcast */ + handleGroupChatMessage: (chatId: string, message: GroupChatMessage) => void; + /** Handle incoming group chat state change broadcast */ + handleGroupChatStateChange: (chatId: string, state: Partial) => void; +} + +/** + * Hook for managing group chat state and operations. + * + * @param sendRequest - WebSocket sendRequest function for request-response operations + * @param send - WebSocket send function for fire-and-forget messages + * @param isConnected - Whether the WebSocket is connected + */ +export function useGroupChat( + sendRequest: UseWebSocketReturn['sendRequest'], + _send: UseWebSocketReturn['send'], + isConnected: boolean +): UseGroupChatReturn { + const [chats, setChats] = useState([]); + const [activeChat, setActiveChat] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const loadChats = useCallback(async () => { + setIsLoading(true); + try { + const response = await sendRequest<{ chats?: GroupChatState[] }>('get_group_chats'); + setChats(response.chats ?? []); + } catch { + setChats([]); + } finally { + setIsLoading(false); + } + }, [sendRequest]); + + const startChat = useCallback( + async (topic: string, participantIds: string[]): Promise => { + try { + const response = await sendRequest<{ success?: boolean; chatId?: string }>( + 'start_group_chat', + { topic, participantIds } + ); + if (response.success && response.chatId) { + // Reload chats to get the new one + await loadChats(); + return response.chatId; + } + return null; + } catch { + return null; + } + }, + [sendRequest, loadChats] + ); + + const loadChatState = useCallback( + async (chatId: string) => { + try { + const response = await sendRequest<{ state?: GroupChatState | null }>( + 'get_group_chat_state', + { chatId } + ); + if (response.state) { + setActiveChat(response.state); + // Also update in the chats list + setChats((prev) => prev.map((c) => (c.id === chatId ? response.state! : c))); + } + } catch { + // Keep current state on error + } + }, + [sendRequest] + ); + + const sendMessage = useCallback( + async (chatId: string, message: string): Promise => { + try { + const response = await sendRequest<{ success?: boolean }>('send_group_chat_message', { + chatId, + message, + }); + return response.success ?? false; + } catch { + return false; + } + }, + [sendRequest] + ); + + const stopChat = useCallback( + async (chatId: string): Promise => { + try { + const response = await sendRequest<{ success?: boolean }>('stop_group_chat', { chatId }); + return response.success ?? false; + } catch { + return false; + } + }, + [sendRequest] + ); + + const setActiveChatId = useCallback( + (chatId: string | null) => { + if (chatId === null) { + setActiveChat(null); + } else { + const chat = chats.find((c) => c.id === chatId); + setActiveChat(chat ?? null); + } + }, + [chats] + ); + + const handleGroupChatMessage = useCallback((chatId: string, message: GroupChatMessage) => { + // Update activeChat if it matches + setActiveChat((prev) => { + if (prev && prev.id === chatId) { + return { ...prev, messages: [...prev.messages, message] }; + } + return prev; + }); + + // Update in chats list + setChats((prev) => + prev.map((c) => { + if (c.id === chatId) { + return { ...c, messages: [...c.messages, message] }; + } + return c; + }) + ); + }, []); + + const handleGroupChatStateChange = useCallback( + (chatId: string, state: Partial) => { + // Update activeChat if it matches + setActiveChat((prev) => { + if (prev && prev.id === chatId) { + return { ...prev, ...state }; + } + return prev; + }); + + // Update in chats list + setChats((prev) => + prev.map((c) => { + if (c.id === chatId) { + return { ...c, ...state }; + } + return c; + }) + ); + }, + [] + ); + + // Auto-load chats on mount when connected + useEffect(() => { + if (isConnected) { + loadChats(); + } + }, [isConnected, loadChats]); + + return { + chats, + activeChat, + isLoading, + loadChats, + startChat, + loadChatState, + sendMessage, + stopChat, + setActiveChatId, + handleGroupChatMessage, + handleGroupChatStateChange, + }; +} + +export default useGroupChat; diff --git a/src/web/hooks/useLongPressMenu.ts b/src/web/hooks/useLongPressMenu.ts index a87a48f694..ab8d4c7df5 100644 --- a/src/web/hooks/useLongPressMenu.ts +++ b/src/web/hooks/useLongPressMenu.ts @@ -1,35 +1,25 @@ /** - * useLongPressMenu - Long-press menu hook for mobile touch interactions + * useLongPressMenu - Long-press gesture hook for mobile touch interactions * - * Provides long-press gesture detection to show a quick actions menu. - * Used for send button long-press to open mode switching menu. + * Detects long-press on the send button and triggers the command palette. * * Features: * - Configurable long-press duration (default 500ms) * - Touch event handlers (start, end, move) * - Automatic timer cleanup on touch move/end - * - Menu anchor position calculation * - Haptic feedback integration * - Proper cleanup on unmount * * @module useLongPressMenu */ -import { useRef, useState, useCallback, useEffect } from 'react'; -import type { QuickAction } from '../mobile/QuickActionsMenu'; +import { useRef, useCallback, useEffect } from 'react'; /** Default duration in ms to trigger long-press for quick actions menu */ const DEFAULT_LONG_PRESS_DURATION = 500; /** * Trigger haptic feedback using the Vibration API - * Uses short vibrations for tactile confirmation on mobile devices - * - * @param pattern - Vibration pattern in milliseconds or single duration - * - 'light' (10ms) - subtle tap for button presses - * - 'medium' (25ms) - standard confirmation feedback - * - 'strong' (50ms) - important action confirmation - * - number - custom duration in milliseconds */ function triggerHapticFeedback(pattern: 'light' | 'medium' | 'strong' | number = 'medium'): void { if (typeof navigator !== 'undefined' && 'vibrate' in navigator) { @@ -39,7 +29,7 @@ function triggerHapticFeedback(pattern: 'light' | 'medium' | 'strong' | number = try { navigator.vibrate(duration); } catch { - // Silently fail if vibration is not allowed (e.g., permissions, battery saver) + // Silently fail if vibration is not allowed } } } @@ -56,14 +46,12 @@ export interface UseLongPressMenuOptions { disabled?: boolean; /** Current input value (to check if send should be disabled) */ value?: string; + /** Callback to open the command palette */ + onOpenCommandPalette?: () => void; } /** Return value from useLongPressMenu hook */ export interface UseLongPressMenuReturn { - /** Whether the quick actions menu is open */ - isMenuOpen: boolean; - /** Anchor position for the menu (relative to viewport) */ - menuAnchor: { x: number; y: number } | null; /** Ref for the send button element */ sendButtonRef: React.RefObject; /** Handler for touch start event */ @@ -72,70 +60,22 @@ export interface UseLongPressMenuReturn { handleTouchEnd: (e: React.TouchEvent) => void; /** Handler for touch move event */ handleTouchMove: () => void; - /** Handler for quick action selection */ - handleQuickAction: (action: QuickAction) => void; - /** Close the quick actions menu */ - closeMenu: () => void; } /** - * Hook for long-press menu on send button - * - * @param options - Configuration options - * @returns Long-press menu state and handlers + * Hook for long-press gesture on send button * - * @example - * ```tsx - * const { - * isMenuOpen, - * menuAnchor, - * sendButtonRef, - * handleTouchStart, - * handleTouchEnd, - * handleTouchMove, - * handleQuickAction, - * closeMenu, - * } = useLongPressMenu({ - * inputMode, - * onModeToggle, - * }); - * - * return ( - * <> - * - * - * - * ); - * ``` + * On long-press, opens the command palette via the onOpenCommandPalette callback. */ export function useLongPressMenu({ - inputMode, - onModeToggle, longPressDuration = DEFAULT_LONG_PRESS_DURATION, disabled = false, value = '', + onOpenCommandPalette, }: UseLongPressMenuOptions): UseLongPressMenuReturn { - // Quick actions menu state - const [isMenuOpen, setIsMenuOpen] = useState(false); - const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number } | null>(null); const longPressTimerRef = useRef | null>(null); const sendButtonRef = useRef(null); - /** - * Clear long-press timer (used when touch ends or moves) - */ const clearLongPressTimer = useCallback(() => { if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); @@ -143,99 +83,39 @@ export function useLongPressMenu({ } }, []); - /** - * Handle long-press start on send button - * Starts a timer that will show the quick actions menu - */ const handleTouchStart = useCallback( (e: React.TouchEvent) => { - // Clear any existing timer clearLongPressTimer(); - // Get the button position for menu anchor - const button = sendButtonRef.current; - if (button) { - const rect = button.getBoundingClientRect(); - const anchor = { - x: rect.left + rect.width / 2, - y: rect.top, - }; - - // Start long-press timer - longPressTimerRef.current = setTimeout(() => { - // Trigger haptic feedback for long-press activation - triggerHapticFeedback('medium'); - - // Show quick actions menu - setMenuAnchor(anchor); - setIsMenuOpen(true); - - // Prevent the normal touch behavior - longPressTimerRef.current = null; - }, longPressDuration); - } + longPressTimerRef.current = setTimeout(() => { + triggerHapticFeedback('medium'); + onOpenCommandPalette?.(); + longPressTimerRef.current = null; + }, longPressDuration); // Scale down slightly on touch for tactile feedback if (!disabled && value.trim()) { e.currentTarget.style.transform = 'scale(0.95)'; } }, - [clearLongPressTimer, disabled, value, longPressDuration] + [clearLongPressTimer, disabled, value, longPressDuration, onOpenCommandPalette] ); - /** - * Handle touch end on send button - * Clears the long-press timer and handles normal tap - */ const handleTouchEnd = useCallback( (e: React.TouchEvent) => { e.currentTarget.style.transform = 'scale(1)'; - - // If quick actions menu is not open and timer was running, this was a normal tap - // The form onSubmit will handle the actual submission clearLongPressTimer(); }, [clearLongPressTimer] ); - /** - * Handle touch move on send button - * Cancels long-press if user moves finger - */ const handleTouchMove = useCallback(() => { clearLongPressTimer(); }, [clearLongPressTimer]); - /** - * Handle quick action selection from menu - */ - const handleQuickAction = useCallback( - (action: QuickAction) => { - // Trigger haptic feedback - triggerHapticFeedback('medium'); - - if (action === 'switch_mode') { - // Toggle to the opposite mode - const newMode = inputMode === 'ai' ? 'terminal' : 'ai'; - onModeToggle?.(newMode); - } - }, - [inputMode, onModeToggle] - ); - - /** - * Close quick actions menu - */ - const closeMenu = useCallback(() => { - setIsMenuOpen(false); - }, []); - - /** - * Cleanup timers on unmount - */ + // Cleanup timers on unmount useEffect(() => { return () => { - // Clean up long-press timer if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); } @@ -243,14 +123,10 @@ export function useLongPressMenu({ }, []); return { - isMenuOpen, - menuAnchor, sendButtonRef, handleTouchStart, handleTouchEnd, handleTouchMove, - handleQuickAction, - closeMenu, }; } diff --git a/src/web/hooks/useMobileKeyboardHandler.ts b/src/web/hooks/useMobileKeyboardHandler.ts index 0cf7c92714..883802cb8d 100644 --- a/src/web/hooks/useMobileKeyboardHandler.ts +++ b/src/web/hooks/useMobileKeyboardHandler.ts @@ -2,6 +2,8 @@ * useMobileKeyboardHandler - Mobile keyboard shortcuts handler hook * * Handles keyboard shortcuts for the mobile web interface: + * - Cmd+K / Ctrl+K: Toggle command palette + * - Escape: Close command palette * - Cmd+J / Ctrl+J: Toggle between AI and Terminal mode * - Cmd+[ / Ctrl+[: Switch to previous tab * - Cmd+] / Ctrl+]: Switch to next tab @@ -53,6 +55,12 @@ export interface UseMobileKeyboardHandlerDeps { handleModeToggle: (mode: MobileInputMode) => void; /** Handler to select a tab */ handleSelectTab: (tabId: string) => void; + /** Handler to open the command palette (Cmd+K / Ctrl+K) */ + onOpenCommandPalette?: () => void; + /** Handler to close the command palette (Escape) */ + onCloseCommandPalette?: () => void; + /** Whether the command palette is currently open */ + isCommandPaletteOpen?: boolean; } /** @@ -64,10 +72,37 @@ export interface UseMobileKeyboardHandlerDeps { * @param deps - Dependencies including session state and handlers */ export function useMobileKeyboardHandler(deps: UseMobileKeyboardHandlerDeps): void { - const { activeSessionId, activeSession, handleModeToggle, handleSelectTab } = deps; + const { + activeSessionId, + activeSession, + handleModeToggle, + handleSelectTab, + onOpenCommandPalette, + onCloseCommandPalette, + isCommandPaletteOpen, + } = deps; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + // Cmd+K / Ctrl+K: Open command palette + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + if (isCommandPaletteOpen && onCloseCommandPalette) { + e.preventDefault(); + onCloseCommandPalette(); + } else if (onOpenCommandPalette) { + e.preventDefault(); + onOpenCommandPalette(); + } + return; + } + + // Escape: Close command palette if open + if (e.key === 'Escape' && isCommandPaletteOpen && onCloseCommandPalette) { + e.preventDefault(); + onCloseCommandPalette(); + return; + } + // Check for Cmd+J (Mac) or Ctrl+J (Windows/Linux) to toggle AI/CLI mode if ((e.metaKey || e.ctrlKey) && e.key === 'j') { e.preventDefault(); @@ -118,7 +153,15 @@ export function useMobileKeyboardHandler(deps: UseMobileKeyboardHandlerDeps): vo document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); - }, [activeSessionId, activeSession, handleModeToggle, handleSelectTab]); + }, [ + activeSessionId, + activeSession, + handleModeToggle, + handleSelectTab, + onOpenCommandPalette, + onCloseCommandPalette, + isCommandPaletteOpen, + ]); } export default useMobileKeyboardHandler; diff --git a/src/web/hooks/useNotifications.ts b/src/web/hooks/useNotifications.ts index 79271d97c0..6b6b644cd3 100644 --- a/src/web/hooks/useNotifications.ts +++ b/src/web/hooks/useNotifications.ts @@ -10,9 +10,62 @@ * - Persist permission request state to avoid repeated prompts */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { webLogger } from '../utils/logger'; +/** + * Notification event types from the server + */ +export interface NotificationEvent { + eventType: + | 'agent_complete' + | 'agent_error' + | 'autorun_complete' + | 'autorun_task_complete' + | 'context_warning'; + sessionId: string; + sessionName: string; + message: string; + severity: 'info' | 'warning' | 'error'; +} + +/** + * Notification preferences configuration + */ +export interface NotificationPreferences { + agentComplete: boolean; + agentError: boolean; + autoRunComplete: boolean; + autoRunTaskComplete: boolean; + contextWarning: boolean; + soundEnabled: boolean; +} + +/** + * Map from event type to preference key + */ +const EVENT_TYPE_TO_PREF: Record< + NotificationEvent['eventType'], + keyof Omit +> = { + agent_complete: 'agentComplete', + agent_error: 'agentError', + autorun_complete: 'autoRunComplete', + autorun_task_complete: 'autoRunTaskComplete', + context_warning: 'contextWarning', +}; + +const DEFAULT_PREFERENCES: NotificationPreferences = { + agentComplete: true, + agentError: true, + autoRunComplete: true, + autoRunTaskComplete: true, + contextWarning: true, + soundEnabled: false, +}; + +const NOTIFICATION_PREFS_KEY = 'maestro-notification-prefs'; + /** * Notification permission states */ @@ -64,6 +117,12 @@ export interface UseNotificationsReturn { resetPromptState: () => void; /** Show a notification (if permission granted) */ showNotification: (title: string, options?: NotificationOptions) => Notification | null; + /** Current notification preferences */ + preferences: NotificationPreferences; + /** Update notification preferences (partial merge) */ + setPreferences: (prefs: Partial) => void; + /** Handle an incoming notification event from the server */ + handleNotificationEvent: (event: NotificationEvent) => void; } /** @@ -220,6 +279,105 @@ export function useNotifications(options: UseNotificationsOptions = {}): UseNoti requestPermission, ]); + // Notification preferences state — persisted to localStorage + const [preferences, setPreferencesState] = useState(() => { + if (typeof localStorage === 'undefined') return DEFAULT_PREFERENCES; + try { + const stored = localStorage.getItem(NOTIFICATION_PREFS_KEY); + if (stored) { + return { ...DEFAULT_PREFERENCES, ...JSON.parse(stored) }; + } + } catch { + // Ignore parse errors + } + return DEFAULT_PREFERENCES; + }); + + const setPreferences = useCallback((prefs: Partial) => { + setPreferencesState((prev) => { + const merged = { ...prev, ...prefs }; + try { + localStorage.setItem(NOTIFICATION_PREFS_KEY, JSON.stringify(merged)); + } catch { + // Ignore storage errors + } + return merged; + }); + }, []); + + // Keep a ref to preferences so handleNotificationEvent always has current values + const preferencesRef = useRef(preferences); + preferencesRef.current = preferences; + + // Keep a ref to showNotification so handleNotificationEvent doesn't need it as a dep + const showNotificationRef = useRef(showNotification); + showNotificationRef.current = showNotification; + + /** + * Play a short notification beep using Web Audio API + */ + const playNotificationSound = useCallback(() => { + try { + const ctx = new AudioContext(); + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); + + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(800, ctx.currentTime); + gainNode.gain.setValueAtTime(0.3, ctx.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.2); + + oscillator.start(ctx.currentTime); + oscillator.stop(ctx.currentTime + 0.2); + + // Clean up after sound completes + oscillator.onended = () => ctx.close(); + } catch { + // Audio not available + } + }, []); + + /** + * Handle an incoming notification event from the server + */ + const handleNotificationEvent = useCallback( + (event: NotificationEvent) => { + const prefs = preferencesRef.current; + const prefKey = EVENT_TYPE_TO_PREF[event.eventType]; + + // Check if this event type is enabled + if (!prefKey || !prefs[prefKey]) return; + + // Check if we have permission + if (getNotificationPermission() !== 'granted') return; + + const notification = showNotificationRef.current(event.sessionName, { + body: event.message, + tag: `maestro-${event.eventType}-${event.sessionId}`, + icon: '/icon-192.png', + }); + + if (notification) { + notification.onclick = () => { + window.focus(); + window.dispatchEvent( + new CustomEvent('maestro-notification-click', { + detail: { sessionId: event.sessionId }, + }) + ); + }; + } + + if (prefs.soundEnabled) { + playNotificationSound(); + } + }, + [playNotificationSound] + ); + // Listen for permission changes (e.g., user changes in browser settings) useEffect(() => { if (!isSupported) return; @@ -253,6 +411,9 @@ export function useNotifications(options: UseNotificationsOptions = {}): UseNoti declineNotifications, resetPromptState, showNotification, + preferences, + setPreferences, + handleNotificationEvent, }; } diff --git a/src/web/hooks/useSettings.ts b/src/web/hooks/useSettings.ts new file mode 100644 index 0000000000..d346a8608c --- /dev/null +++ b/src/web/hooks/useSettings.ts @@ -0,0 +1,210 @@ +/** + * useSettings hook for reading and writing settings from the web client. + * + * Fetches settings on mount/reconnect, listens for broadcast changes, + * and provides typed setters for each configurable setting. + */ + +import { useState, useCallback, useEffect, useRef } from 'react'; +import type { UseWebSocketReturn, SettingsChangedMessage } from './useWebSocket'; + +/** + * Web-facing settings shape (mirrors server-side WebSettings). + */ +export type WebSettings = SettingsChangedMessage['settings']; + +/** + * Setting value type for setSetting. + */ +export type SettingValue = string | number | boolean | null; + +/** + * Return value from useSettings hook. + */ +export interface UseSettingsReturn { + settings: WebSettings | null; + isLoading: boolean; + setSetting: (key: string, value: SettingValue) => Promise; + setTheme: (themeId: string) => Promise; + setFontSize: (size: number) => Promise; + setEnterToSendAI: (value: boolean) => Promise; + setEnterToSendTerminal: (value: boolean) => Promise; + setAutoScroll: (value: boolean) => Promise; + setDefaultSaveToHistory: (value: boolean) => Promise; + setDefaultShowThinking: (value: string) => Promise; + setNotificationsEnabled: (value: boolean) => Promise; + setAudioFeedbackEnabled: (value: boolean) => Promise; + setColorBlindMode: (value: string) => Promise; + setConductorProfile: (value: string) => Promise; + /** Handler for settings_changed broadcasts — wire to onSettingsChanged in WebSocket handlers */ + handleSettingsChanged: (settings: WebSettings) => void; +} + +/** + * Map from allowlisted setting keys to their WebSettings field names. + */ +const SETTING_KEY_TO_FIELD: Record = { + activeThemeId: 'theme', + fontSize: 'fontSize', + enterToSendAI: 'enterToSendAI', + enterToSendTerminal: 'enterToSendTerminal', + defaultSaveToHistory: 'defaultSaveToHistory', + defaultShowThinking: 'defaultShowThinking', + autoScroll: 'autoScroll', + notificationsEnabled: 'notificationsEnabled', + audioFeedbackEnabled: 'audioFeedbackEnabled', + colorBlindMode: 'colorBlindMode', + conductorProfile: 'conductorProfile', +}; + +/** + * Hook for managing settings state and operations via WebSocket. + * + * @param sendRequest - WebSocket sendRequest function for request-response operations + * @param isConnected - Whether the WebSocket is currently connected + */ +export function useSettings( + sendRequest: UseWebSocketReturn['sendRequest'], + isConnected: boolean +): UseSettingsReturn { + const [settings, setSettings] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const hasFetchedRef = useRef(false); + + // Fetch settings on mount and when connection is established + useEffect(() => { + if (!isConnected) { + hasFetchedRef.current = false; + return; + } + if (hasFetchedRef.current) return; + + hasFetchedRef.current = true; + setIsLoading(true); + + sendRequest<{ settings?: WebSettings }>('get_settings') + .then((response) => { + if (response.settings) { + setSettings(response.settings); + } + }) + .catch(() => { + // Settings fetch failed — will retry on reconnect + }) + .finally(() => { + setIsLoading(false); + }); + }, [isConnected, sendRequest]); + + /** + * Update settings from a broadcast message. + * Intended to be wired to onSettingsChanged in the WebSocket handlers. + */ + const handleSettingsChanged = useCallback((newSettings: WebSettings) => { + setSettings(newSettings); + }, []); + + /** + * Set a single setting by its allowlisted key. + * Optimistically updates local state, rolls back on failure. + */ + const setSetting = useCallback( + async (key: string, value: SettingValue): Promise => { + const field = SETTING_KEY_TO_FIELD[key]; + if (!field) return false; + + // Optimistic update + const prev = settings; + if (settings) { + setSettings({ ...settings, [field]: value } as WebSettings); + } + + try { + const response = await sendRequest<{ success?: boolean }>('set_setting', { key, value }); + if (!response.success) { + // Rollback on explicit server rejection + if (prev) setSettings(prev); + } + return response.success ?? false; + } catch { + // Rollback on failure + if (prev) setSettings(prev); + return false; + } + }, + [sendRequest, settings] + ); + + // Typed convenience setters + const setTheme = useCallback( + (themeId: string) => setSetting('activeThemeId', themeId), + [setSetting] + ); + + const setFontSize = useCallback((size: number) => setSetting('fontSize', size), [setSetting]); + + const setEnterToSendAI = useCallback( + (value: boolean) => setSetting('enterToSendAI', value), + [setSetting] + ); + + const setEnterToSendTerminal = useCallback( + (value: boolean) => setSetting('enterToSendTerminal', value), + [setSetting] + ); + + const setAutoScroll = useCallback( + (value: boolean) => setSetting('autoScroll', value), + [setSetting] + ); + + const setDefaultSaveToHistory = useCallback( + (value: boolean) => setSetting('defaultSaveToHistory', value), + [setSetting] + ); + + const setDefaultShowThinking = useCallback( + (value: string) => setSetting('defaultShowThinking', value), + [setSetting] + ); + + const setNotificationsEnabled = useCallback( + (value: boolean) => setSetting('notificationsEnabled', value), + [setSetting] + ); + + const setAudioFeedbackEnabled = useCallback( + (value: boolean) => setSetting('audioFeedbackEnabled', value), + [setSetting] + ); + + const setColorBlindMode = useCallback( + (value: string) => setSetting('colorBlindMode', value), + [setSetting] + ); + + const setConductorProfile = useCallback( + (value: string) => setSetting('conductorProfile', value), + [setSetting] + ); + + return { + settings, + isLoading, + setSetting, + setTheme, + setFontSize, + setEnterToSendAI, + setEnterToSendTerminal, + setAutoScroll, + setDefaultSaveToHistory, + setDefaultShowThinking, + setNotificationsEnabled, + setAudioFeedbackEnabled, + setColorBlindMode, + setConductorProfile, + handleSettingsChanged, + }; +} + +export default useSettings; diff --git a/src/web/hooks/useWebSocket.ts b/src/web/hooks/useWebSocket.ts index dd3ecc5b0e..7ba338a08a 100644 --- a/src/web/hooks/useWebSocket.ts +++ b/src/web/hooks/useWebSocket.ts @@ -118,11 +118,23 @@ export type ServerMessageType = | 'active_session_changed' | 'session_output' | 'session_exit' + | 'session_live' + | 'session_offline' | 'user_input' | 'theme' | 'custom_commands' | 'autorun_state' + | 'autorun_docs_changed' + | 'notification_event' + | 'settings_changed' + | 'groups_changed' | 'tabs_changed' + | 'group_chat_message' + | 'group_chat_state_change' + | 'context_operation_progress' + | 'context_operation_complete' + | 'cue_activity_event' + | 'cue_subscriptions_changed' | 'pong' | 'subscribed' | 'echo' @@ -286,6 +298,79 @@ export interface AutoRunStateMessage extends ServerMessage { state: AutoRunState | null; } +/** + * AutoRun documents changed message from server + * Sent when Auto Run document list changes on the desktop + */ +export interface AutoRunDocsChangedMessage extends ServerMessage { + type: 'autorun_docs_changed'; + sessionId: string; + documents: Array<{ + filename: string; + path: string; + taskCount: number; + completedCount: number; + }>; +} + +/** + * Notification event message from server + * Sent when a notification-worthy event occurs (agent complete, error, autorun, etc.) + */ +export interface NotificationEventMessage extends ServerMessage { + type: 'notification_event'; + eventType: + | 'agent_complete' + | 'agent_error' + | 'autorun_complete' + | 'autorun_task_complete' + | 'context_warning'; + sessionId: string; + sessionName: string; + message: string; + severity: 'info' | 'warning' | 'error'; +} + +/** + * Settings changed message from server + * Sent when settings are modified (from web or desktop) + */ +export interface SettingsChangedMessage extends ServerMessage { + type: 'settings_changed'; + settings: { + theme: string; + fontSize: number; + enterToSendAI: boolean; + enterToSendTerminal: boolean; + defaultSaveToHistory: boolean; + defaultShowThinking: string; + autoScroll: boolean; + notificationsEnabled: boolean; + audioFeedbackEnabled: boolean; + colorBlindMode: string; + conductorProfile: string; + }; +} + +/** + * Group data for web clients + */ +export interface GroupData { + id: string; + name: string; + emoji: string | null; + sessionIds: string[]; +} + +/** + * Groups changed message from server + * Sent when groups are created, renamed, deleted, or membership changes + */ +export interface GroupsChangedMessage extends ServerMessage { + type: 'groups_changed'; + groups: GroupData[]; +} + /** * Tabs changed message from server * Sent when tabs are added, removed, or active tab changes in a session @@ -297,6 +382,48 @@ export interface TabsChangedMessage extends ServerMessage { activeTabId: string; } +/** + * Group chat message data + */ +export interface GroupChatMessage { + id: string; + participantId: string; + participantName: string; + content: string; + timestamp: number; + role: 'user' | 'assistant'; +} + +/** + * Group chat state data + */ +export interface GroupChatState { + id: string; + topic: string; + participants: Array<{ sessionId: string; name: string; toolType: string }>; + messages: GroupChatMessage[]; + isActive: boolean; + currentTurn?: string; +} + +/** + * Group chat message broadcast from server + */ +export interface GroupChatMessageBroadcast extends ServerMessage { + type: 'group_chat_message'; + chatId: string; + message: GroupChatMessage; +} + +/** + * Group chat state change broadcast from server + */ +export interface GroupChatStateChangeBroadcast extends ServerMessage { + type: 'group_chat_state_change'; + chatId: string; + [key: string]: unknown; +} + /** * Error message from server */ @@ -324,7 +451,13 @@ export type TypedServerMessage = | ThemeMessage | CustomCommandsMessage | AutoRunStateMessage + | AutoRunDocsChangedMessage + | NotificationEventMessage + | SettingsChangedMessage + | GroupsChangedMessage | TabsChangedMessage + | GroupChatMessageBroadcast + | GroupChatStateChangeBroadcast | ErrorMessage | ServerMessage; @@ -363,8 +496,23 @@ export interface WebSocketEventHandlers { onCustomCommands?: (commands: CustomCommand[]) => void; /** Called when AutoRun state changes (batch processing on desktop) */ onAutoRunStateChange?: (sessionId: string, state: AutoRunState | null) => void; + /** Called when AutoRun document list changes */ + onAutoRunDocsChanged?: ( + sessionId: string, + documents: AutoRunDocsChangedMessage['documents'] + ) => void; + /** Called when a notification event is received */ + onNotificationEvent?: (event: NotificationEventMessage) => void; + /** Called when settings are changed (from web or desktop) */ + onSettingsChanged?: (settings: SettingsChangedMessage['settings']) => void; + /** Called when groups are changed (created, renamed, deleted, membership) */ + onGroupsChanged?: (groups: GroupData[]) => void; /** Called when tabs change in a session */ onTabsChanged?: (sessionId: string, aiTabs: AITabData[], activeTabId: string) => void; + /** Called when a group chat message is broadcast */ + onGroupChatMessage?: (chatId: string, message: GroupChatMessage) => void; + /** Called when group chat state changes */ + onGroupChatStateChange?: (chatId: string, state: Partial) => void; /** Called when connection state changes */ onConnectionChange?: (state: WebSocketState) => void; /** Called when an error occurs */ @@ -419,6 +567,12 @@ export interface UseWebSocketReturn { ping: () => void; /** Send a raw message to the server */ send: (message: object) => boolean; + /** Send a request and wait for a correlated response */ + sendRequest: ( + type: string, + payload?: Record, + timeoutMs?: number + ) => Promise; } /** @@ -501,6 +655,17 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet const seenMsgIdsRef = useRef>(new Set()); // Ref for handleMessage to avoid stale closure issues const handleMessageRef = useRef<((event: MessageEvent) => void) | null>(null); + // Pending request-response map for sendRequest correlation + const pendingRequestsRef = useRef< + Map< + string, + { + resolve: (data: any) => void; + reject: (err: Error) => void; + timer: ReturnType; + } + > + >(new Map()); // Keep handlers ref up to date SYNCHRONOUSLY to avoid race conditions // This must happen before any WebSocket messages are processed @@ -541,11 +706,22 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet try { const message = JSON.parse(event.data) as TypedServerMessage; - // Debug: Log all incoming messages (not just session_output) - console.log( - `[WebSocket] Message received: type=${message.type}`, - message.type === 'session_output' ? message : '' - ); + // Check for request-response correlation before dispatching + const requestId = (message as any).requestId as string | undefined; + if (requestId && pendingRequestsRef.current.has(requestId)) { + const pending = pendingRequestsRef.current.get(requestId)!; + clearTimeout(pending.timer); + pendingRequestsRef.current.delete(requestId); + if ((message as any).type === 'error') { + pending.reject(new Error((message as any).message ?? 'Server error')); + } else { + pending.resolve(message); + } + return; + } + + // Log all incoming messages for debugging + webLogger.debug(`Message received: type=${message.type}`, 'WebSocket'); // Call the generic message handler handlersRef.current?.onMessage?.(message); @@ -635,8 +811,9 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet // Dedupe using message ID if available if (outputMsg.msgId) { if (seenMsgIdsRef.current.has(outputMsg.msgId)) { - console.log( - `[WebSocket] DEDUPE: Skipping duplicate session_output msgId=${outputMsg.msgId}` + webLogger.debug( + `DEDUPE: Skipping duplicate session_output msgId=${outputMsg.msgId}`, + 'WebSocket' ); break; } @@ -647,8 +824,9 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet seenMsgIdsRef.current = new Set(idsArray.slice(-500)); } } - console.log( - `[WebSocket] Received session_output: msgId=${outputMsg.msgId || 'none'}, session=${outputMsg.sessionId}, tabId=${outputMsg.tabId || 'none'}, source=${outputMsg.source}, dataLen=${outputMsg.data?.length || 0}, hasHandler=${!!handlersRef.current?.onSessionOutput}` + webLogger.debug( + `Received session_output: msgId=${outputMsg.msgId || 'none'}, session=${outputMsg.sessionId}, tabId=${outputMsg.tabId || 'none'}, source=${outputMsg.source}, dataLen=${outputMsg.data?.length || 0}`, + 'WebSocket' ); handlersRef.current?.onSessionOutput?.( outputMsg.sessionId, @@ -697,6 +875,30 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet break; } + case 'autorun_docs_changed': { + const docsMsg = message as AutoRunDocsChangedMessage; + handlersRef.current?.onAutoRunDocsChanged?.(docsMsg.sessionId, docsMsg.documents); + break; + } + + case 'notification_event': { + const notifMsg = message as NotificationEventMessage; + handlersRef.current?.onNotificationEvent?.(notifMsg); + break; + } + + case 'settings_changed': { + const settingsMsg = message as SettingsChangedMessage; + handlersRef.current?.onSettingsChanged?.(settingsMsg.settings); + break; + } + + case 'groups_changed': { + const groupsMsg = message as GroupsChangedMessage; + handlersRef.current?.onGroupsChanged?.(groupsMsg.groups); + break; + } + case 'tabs_changed': { const tabsMsg = message as TabsChangedMessage; handlersRef.current?.onTabsChanged?.( @@ -707,6 +909,22 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet break; } + case 'group_chat_message': { + const gcMsg = message as GroupChatMessageBroadcast; + handlersRef.current?.onGroupChatMessage?.(gcMsg.chatId, gcMsg.message); + break; + } + + case 'group_chat_state_change': { + const gcStateMsg = message as GroupChatStateChangeBroadcast; + const { chatId: gcChatId, type: _gcType, timestamp: _gcTs, ...gcState } = gcStateMsg; + handlersRef.current?.onGroupChatStateChange?.( + gcChatId, + gcState as Partial + ); + break; + } + case 'error': { const errorMsg = message as ErrorMessage; setError(errorMsg.message); @@ -798,6 +1016,12 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet webLogger.error('WebSocket connection error', 'WebSocket', event); setError('WebSocket connection error'); handlersRef.current?.onError?.('WebSocket connection error'); + // Reject all pending requests + for (const [, pending] of pendingRequestsRef.current) { + clearTimeout(pending.timer); + pending.reject(new Error('Connection lost')); + } + pendingRequestsRef.current.clear(); }; ws.onclose = (event) => { @@ -807,6 +1031,12 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet wsRef.current = null; setState('disconnected'); handlersRef.current?.onConnectionChange?.('disconnected'); + // Reject all pending requests + for (const [, pending] of pendingRequestsRef.current) { + clearTimeout(pending.timer); + pending.reject(new Error('Connection lost')); + } + pendingRequestsRef.current.clear(); // Attempt to reconnect if not a clean close if (event.code !== 1000 && shouldReconnectRef.current) { @@ -887,6 +1117,40 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet return false; }, []); + /** + * Send a request and wait for a correlated response. + * The server must echo back the requestId in its response. + */ + const sendRequest = useCallback( + ( + type: string, + payload?: Record, + timeoutMs: number = 10000 + ): Promise => { + return new Promise((resolve, reject) => { + const requestId = + typeof crypto !== 'undefined' && crypto.randomUUID + ? crypto.randomUUID() + : Date.now().toString(36) + Math.random().toString(36); + + const timer = setTimeout(() => { + pendingRequestsRef.current.delete(requestId); + reject(new Error('Request timed out')); + }, timeoutMs); + + pendingRequestsRef.current.set(requestId, { resolve, reject, timer }); + + const sent = send({ type, ...payload, requestId }); + if (!sent) { + clearTimeout(timer); + pendingRequestsRef.current.delete(requestId); + reject(new Error('WebSocket not connected')); + } + }); + }, + [send] + ); + // Cleanup on unmount - track mount ID to handle StrictMode double-mount useEffect(() => { const thisMountId = ++mountIdRef.current; @@ -928,6 +1192,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet authenticate, ping, send, + sendRequest, }; } diff --git a/src/web/mobile/AchievementsPanel.tsx b/src/web/mobile/AchievementsPanel.tsx new file mode 100644 index 0000000000..315854a754 --- /dev/null +++ b/src/web/mobile/AchievementsPanel.tsx @@ -0,0 +1,364 @@ +/** + * AchievementsPanel component for Maestro mobile web interface + * + * Read-only viewer for achievements with progress tracking, + * sorted with unlocked first then locked by progress. + */ + +import { useState, useCallback, useEffect, useMemo } from 'react'; +import { X } from 'lucide-react'; +import { useThemeColors } from '../components/ThemeProvider'; + +interface AchievementData { + id: string; + name: string; + description: string; + unlocked: boolean; + unlockedAt?: number; + progress?: number; + maxProgress?: number; +} + +export interface AchievementsPanelProps { + onClose: () => void; + sendRequest: ( + type: string, + payload?: Record, + timeoutMs?: number + ) => Promise; +} + +function formatRelativeTime(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp; + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 30) { + const months = Math.floor(days / 30); + return `${months}mo ago`; + } + if (days > 0) return `${days}d ago`; + if (hours > 0) return `${hours}h ago`; + if (minutes > 0) return `${minutes}m ago`; + return 'just now'; +} + +export function AchievementsPanel({ onClose, sendRequest }: AchievementsPanelProps) { + const colors = useThemeColors(); + const [achievements, setAchievements] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchAchievements = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const result = await sendRequest<{ achievements: AchievementData[] }>( + 'get_achievements', + {}, + 15000 + ); + setAchievements(result.achievements); + } catch { + setError('Failed to load achievements'); + } finally { + setIsLoading(false); + } + }, [sendRequest]); + + useEffect(() => { + fetchAchievements(); + }, [fetchAchievements]); + + const unlockedCount = useMemo( + () => achievements.filter((a) => a.unlocked).length, + [achievements] + ); + + const sortedAchievements = useMemo(() => { + return [...achievements].sort((a, b) => { + // Unlocked first + if (a.unlocked && !b.unlocked) return -1; + if (!a.unlocked && b.unlocked) return 1; + // Among unlocked: most recent first + if (a.unlocked && b.unlocked) { + return (b.unlockedAt ?? 0) - (a.unlockedAt ?? 0); + } + // Among locked: closest to completion first + const aProgress = a.maxProgress ? (a.progress ?? 0) / a.maxProgress : 0; + const bProgress = b.maxProgress ? (b.progress ?? 0) / b.maxProgress : 0; + return bProgress - aProgress; + }); + }, [achievements]); + + const overallProgress = achievements.length > 0 ? (unlockedCount / achievements.length) * 100 : 0; + + return ( +
+ {/* Header */} +
+

+ Achievements +

+ +
+ + {/* Content */} +
+ {isLoading ? ( +
+ Loading achievements... +
+ ) : error ? ( +
+ {error} +
+ ) : achievements.length === 0 ? ( +
+ No achievements available +
+ ) : ( + <> + {/* Stats Bar */} +
+
+ + {unlockedCount} / {achievements.length} unlocked + + + {Math.round(overallProgress)}% + +
+
+
+
+
+ + {/* Achievement Grid */} +
+ {sortedAchievements.map((achievement) => ( + + ))} +
+ + )} +
+
+ ); +} + +function AchievementCard({ + achievement, + colors, +}: { + achievement: AchievementData; + colors: ReturnType; +}) { + const hasProgress = + achievement.progress != null && achievement.maxProgress != null && achievement.maxProgress > 0; + const progressPercent = hasProgress + ? ((achievement.progress ?? 0) / (achievement.maxProgress ?? 1)) * 100 + : 0; + + return ( +
+
+ {achievement.unlocked ? '\u{1F3C6}' : '\u{1F512}'} +
+
+ {achievement.name} +
+
+ {achievement.description} +
+ + {hasProgress && !achievement.unlocked && ( +
+
+
+
+
+ {achievement.progress} / {achievement.maxProgress} +
+
+ )} + + {achievement.unlocked && achievement.unlockedAt && ( +
+ {formatRelativeTime(achievement.unlockedAt)} +
+ )} +
+ ); +} + +export default AchievementsPanel; diff --git a/src/web/mobile/AgentCreationSheet.tsx b/src/web/mobile/AgentCreationSheet.tsx new file mode 100644 index 0000000000..330bd75dff --- /dev/null +++ b/src/web/mobile/AgentCreationSheet.tsx @@ -0,0 +1,518 @@ +/** + * AgentCreationSheet component for Maestro mobile web interface + * + * Bottom sheet modal for creating a new agent. + * Allows selecting agent type, name, working directory, and optional group. + */ + +import { useState, useCallback, useEffect, useRef } from 'react'; +import { useThemeColors } from '../components/ThemeProvider'; +import { triggerHaptic, HAPTIC_PATTERNS } from './constants'; +import type { GroupData } from '../hooks/useWebSocket'; + +/** Agent types available for creation from the web interface */ +const CREATABLE_AGENT_TYPES = [ + { id: 'claude-code', name: 'Claude Code', emoji: '🤖' }, + { id: 'codex', name: 'Codex', emoji: '📦' }, + { id: 'opencode', name: 'OpenCode', emoji: '🔓' }, + { id: 'factory-droid', name: 'Factory Droid', emoji: '🏭' }, +] as const; + +export interface AgentCreationSheetProps { + groups: GroupData[]; + defaultCwd: string; + createAgent: ( + name: string, + toolType: string, + cwd: string, + groupId?: string + ) => Promise<{ sessionId: string } | null>; + onCreated: (sessionId: string) => void; + onClose: () => void; +} + +export function AgentCreationSheet({ + groups, + defaultCwd, + createAgent, + onCreated, + onClose, +}: AgentCreationSheetProps) { + const colors = useThemeColors(); + const [selectedType, setSelectedType] = useState('claude-code'); + const [name, setName] = useState(''); + const [cwd, setCwd] = useState(defaultCwd); + const [groupId, setGroupId] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isVisible, setIsVisible] = useState(false); + const nameInputRef = useRef(null); + + const handleClose = useCallback(() => { + triggerHaptic(HAPTIC_PATTERNS.tap); + setIsVisible(false); + setTimeout(() => onClose(), 300); + }, [onClose]); + + // Animate in on mount + useEffect(() => { + requestAnimationFrame(() => setIsVisible(true)); + }, []); + + // Close on escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + handleClose(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [handleClose]); + + const handleBackdropTap = useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + handleClose(); + } + }, + [handleClose] + ); + + const handleSelectType = useCallback((typeId: string) => { + triggerHaptic(HAPTIC_PATTERNS.tap); + setSelectedType(typeId); + // Update default name when type changes + const agentType = CREATABLE_AGENT_TYPES.find((t) => t.id === typeId); + if (agentType) { + setName(''); + } + }, []); + + const getDefaultName = useCallback(() => { + const agentType = CREATABLE_AGENT_TYPES.find((t) => t.id === selectedType); + return agentType ? agentType.name : 'New Agent'; + }, [selectedType]); + + const handleCreate = useCallback(async () => { + if (isSubmitting) return; + const agentName = name.trim() || getDefaultName(); + if (!cwd.trim()) return; + + setIsSubmitting(true); + triggerHaptic(HAPTIC_PATTERNS.send); + + try { + const result = await createAgent(agentName, selectedType, cwd.trim(), groupId || undefined); + if (result) { + triggerHaptic(HAPTIC_PATTERNS.success); + onCreated(result.sessionId); + handleClose(); + } else { + triggerHaptic(HAPTIC_PATTERNS.error); + setIsSubmitting(false); + } + } catch { + triggerHaptic(HAPTIC_PATTERNS.error); + setIsSubmitting(false); + } + }, [ + isSubmitting, + name, + getDefaultName, + cwd, + selectedType, + groupId, + createAgent, + onCreated, + handleClose, + ]); + + return ( +
+ {/* Sheet */} +
+ {/* Drag handle */} +
+
+
+ + {/* Header */} +
+

+ Create Agent +

+ +
+ + {/* Scrollable content */} +
+ {/* Agent type selector */} +
+ + Agent Type + +
+ {CREATABLE_AGENT_TYPES.map((agentType) => { + const isSelected = selectedType === agentType.id; + return ( + + ); + })} +
+
+ + {/* Name input */} +
+ + setName(e.target.value)} + placeholder={getDefaultName()} + style={{ + width: '100%', + padding: '12px 14px', + borderRadius: '10px', + border: `1px solid ${colors.border}`, + backgroundColor: colors.bgSidebar, + color: colors.textMain, + fontSize: '14px', + outline: 'none', + WebkitAppearance: 'none', + boxSizing: 'border-box', + minHeight: '44px', + }} + onFocus={(e) => { + (e.target as HTMLInputElement).style.borderColor = colors.accent; + }} + onBlur={(e) => { + (e.target as HTMLInputElement).style.borderColor = colors.border; + }} + /> +
+ + {/* Working directory */} +
+ + setCwd(e.target.value)} + placeholder="/path/to/project" + style={{ + width: '100%', + padding: '12px 14px', + borderRadius: '10px', + border: `1px solid ${colors.border}`, + backgroundColor: colors.bgSidebar, + color: colors.textMain, + fontSize: '14px', + outline: 'none', + WebkitAppearance: 'none', + boxSizing: 'border-box', + fontFamily: 'monospace', + minHeight: '44px', + }} + onFocus={(e) => { + (e.target as HTMLInputElement).style.borderColor = colors.accent; + }} + onBlur={(e) => { + (e.target as HTMLInputElement).style.borderColor = colors.border; + }} + /> +
+ + {/* Group selector */} +
+ +
+ {/* No group option */} + + {groups.map((group) => { + const isSelected = groupId === group.id; + return ( + + ); + })} +
+
+
+ + {/* Create button */} +
+ +
+
+
+ ); +} + +export default AgentCreationSheet; diff --git a/src/web/mobile/AllSessionsView.tsx b/src/web/mobile/AllSessionsView.tsx index 07fe602b82..f2348e90bb 100644 --- a/src/web/mobile/AllSessionsView.tsx +++ b/src/web/mobile/AllSessionsView.tsx @@ -12,6 +12,8 @@ * - Status indicator, mode badge, and working directory visible * - Swipe down to dismiss / back button at top * - Search/filter sessions + * - Long-press context menu for rename, move, delete + * - Floating "+" button to create new agents */ import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'; @@ -21,6 +23,21 @@ import type { Session, GroupInfo } from '../hooks/useSessions'; import { triggerHaptic, HAPTIC_PATTERNS } from './constants'; import { truncatePath } from '../../shared/formatters'; import { getAgentDisplayName } from '../../shared/agentMetadata'; +import type { GroupData } from '../hooks/useWebSocket'; + +/** Duration in ms to trigger long-press */ +const LONG_PRESS_DURATION = 500; + +/** + * Context menu action types for session management + */ +type ContextMenuAction = 'rename' | 'move' | 'delete'; + +interface ContextMenuState { + session: Session; + x: number; + y: number; +} /** * Session card component for the All Sessions view @@ -35,6 +52,18 @@ interface SessionCardProps { interface MobileSessionCardPropsInternal extends SessionCardProps { /** Display name (may include parent prefix for worktree children) */ displayName: string; + /** Whether this card is currently being renamed inline */ + isRenaming: boolean; + /** Current rename value */ + renameValue: string; + /** Callback for rename value changes */ + onRenameChange: (value: string) => void; + /** Callback to confirm rename */ + onRenameConfirm: () => void; + /** Callback to cancel rename */ + onRenameCancel: () => void; + /** Long-press handler */ + onLongPress: (session: Session, x: number, y: number) => void; } function MobileSessionCard({ @@ -42,8 +71,25 @@ function MobileSessionCard({ isActive, onSelect, displayName, + isRenaming, + renameValue, + onRenameChange, + onRenameConfirm, + onRenameCancel, + onLongPress, }: MobileSessionCardPropsInternal) { const colors = useThemeColors(); + const longPressTimerRef = useRef | null>(null); + const isLongPressTriggeredRef = useRef(false); + const renameInputRef = useRef(null); + + // Focus rename input when entering rename mode + useEffect(() => { + if (isRenaming && renameInputRef.current) { + renameInputRef.current.focus(); + renameInputRef.current.select(); + } + }, [isRenaming]); // Map session state to status for StatusDot const getStatus = (): SessionStatus => { @@ -68,14 +114,93 @@ function MobileSessionCard({ return getAgentDisplayName(session.toolType); }; + const clearLongPressTimer = useCallback(() => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + }, []); + + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + isLongPressTriggeredRef.current = false; + const touch = e.touches[0]; + const x = touch.clientX; + const y = touch.clientY; + longPressTimerRef.current = setTimeout(() => { + isLongPressTriggeredRef.current = true; + triggerHaptic(HAPTIC_PATTERNS.success); + onLongPress(session, x, y); + }, LONG_PRESS_DURATION); + }, + [session, onLongPress] + ); + + const handleTouchEnd = useCallback(() => { + clearLongPressTimer(); + if (!isLongPressTriggeredRef.current) { + triggerHaptic(HAPTIC_PATTERNS.tap); + onSelect(session.id); + } + isLongPressTriggeredRef.current = false; + }, [clearLongPressTimer, onSelect, session.id]); + + const handleTouchMove = useCallback(() => { + clearLongPressTimer(); + }, [clearLongPressTimer]); + + const handleTouchCancel = useCallback(() => { + clearLongPressTimer(); + isLongPressTriggeredRef.current = false; + }, [clearLongPressTimer]); + + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + triggerHaptic(HAPTIC_PATTERNS.success); + onLongPress(session, e.clientX, e.clientY); + }, + [session, onLongPress] + ); + const handleClick = useCallback(() => { - triggerHaptic(HAPTIC_PATTERNS.tap); - onSelect(session.id); + // For non-touch devices + if (!('ontouchstart' in window)) { + triggerHaptic(HAPTIC_PATTERNS.tap); + onSelect(session.id); + } }, [session.id, onSelect]); + // Cleanup timer on unmount + useEffect(() => { + return () => clearLongPressTimer(); + }, [clearLongPressTimer]); + + const handleRenameKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.stopPropagation(); + e.preventDefault(); + onRenameConfirm(); + } else if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + onRenameCancel(); + } + }, + [onRenameConfirm, onRenameCancel] + ); + + const CardContainer = isRenaming ? 'div' : 'button'; + return ( - + + ); +} + +/** + * Context menu component for session management actions + */ +function SessionContextMenu({ + session, + x, + y, + onAction, + onClose, +}: { + session: Session; + x: number; + y: number; + onAction: (action: ContextMenuAction, session: Session) => void; + onClose: () => void; +}) { + const colors = useThemeColors(); + const menuRef = useRef(null); + + // Position the menu within viewport bounds + const calculatePosition = (): React.CSSProperties => { + const menuWidth = 180; + const menuHeight = 150; + const padding = 12; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let left = x; + let top = y; + + if (left + menuWidth > viewportWidth - padding) { + left = viewportWidth - menuWidth - padding; + } + if (left < padding) { + left = padding; + } + if (top + menuHeight > viewportHeight - padding) { + top = viewportHeight - menuHeight - padding; + } + + return { + position: 'fixed', + left: `${left}px`, + top: `${top}px`, + width: `${menuWidth}px`, + zIndex: 310, + }; + }; + + // Close on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent | TouchEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + const timer = setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('touchstart', handleClickOutside); + }, 50); + return () => { + clearTimeout(timer); + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('touchstart', handleClickOutside); + }; + }, [onClose]); + + const menuItemStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: '10px', + width: '100%', + padding: '12px 14px', + border: 'none', + backgroundColor: 'transparent', + color: colors.textMain, + fontSize: '14px', + fontWeight: 500, + cursor: 'pointer', + textAlign: 'left', + touchAction: 'manipulation', + WebkitTapHighlightColor: 'transparent', + }; + + return ( + <> + {/* Backdrop */} + + ); +} + +export default AutoRunDocumentViewer; diff --git a/src/web/mobile/AutoRunIndicator.tsx b/src/web/mobile/AutoRunIndicator.tsx index b37049e9a4..7327541980 100644 --- a/src/web/mobile/AutoRunIndicator.tsx +++ b/src/web/mobile/AutoRunIndicator.tsx @@ -14,6 +14,8 @@ interface AutoRunIndicatorProps { state: AutoRunState | null; /** Name of the session running AutoRun */ sessionName?: string; + /** Handler when the indicator is tapped - opens the full Auto Run panel */ + onTap?: () => void; } /** @@ -21,7 +23,7 @@ interface AutoRunIndicatorProps { * Shows task progress when batch processing is active * PROMINENT: Uses bold colors and large text for visibility */ -export function AutoRunIndicator({ state, sessionName }: AutoRunIndicatorProps) { +export function AutoRunIndicator({ state, sessionName, onTap }: AutoRunIndicatorProps) { const colors = useThemeColors(); // Don't render if no state or not running @@ -35,6 +37,9 @@ export function AutoRunIndicator({ state, sessionName }: AutoRunIndicatorProps) return (
{/* Animated indicator icon - white circle with icon */} diff --git a/src/web/mobile/AutoRunPanel.tsx b/src/web/mobile/AutoRunPanel.tsx new file mode 100644 index 0000000000..5059b43eb0 --- /dev/null +++ b/src/web/mobile/AutoRunPanel.tsx @@ -0,0 +1,560 @@ +/** + * AutoRunPanel component for Maestro mobile web interface + * + * Full-screen management panel for Auto Run documents. + * Provides document listing, launch/stop controls, and navigation + * to document viewer and setup sheet. + */ + +import { useState, useCallback, useEffect } from 'react'; +import { useThemeColors } from '../components/ThemeProvider'; +import { useAutoRun, type AutoRunDocument } from '../hooks/useAutoRun'; +import type { AutoRunState, UseWebSocketReturn } from '../hooks/useWebSocket'; +import { triggerHaptic, HAPTIC_PATTERNS } from './constants'; + +/** + * Document card component for the Auto Run panel + */ +interface DocumentCardProps { + document: AutoRunDocument; + onTap: (filename: string) => void; +} + +function DocumentCard({ document, onTap }: DocumentCardProps) { + const colors = useThemeColors(); + const progress = + document.taskCount > 0 ? Math.round((document.completedCount / document.taskCount) * 100) : 0; + + const handleTap = useCallback(() => { + triggerHaptic(HAPTIC_PATTERNS.tap); + onTap(document.filename); + }, [document.filename, onTap]); + + return ( + + ); +} + +/** + * Props for AutoRunPanel component + */ +export interface AutoRunPanelProps { + sessionId: string; + autoRunState: AutoRunState | null; + onClose: () => void; + onOpenDocument?: (filename: string) => void; + onOpenSetup?: () => void; + sendRequest: UseWebSocketReturn['sendRequest']; + send: UseWebSocketReturn['send']; +} + +/** + * AutoRunPanel component + * + * Full-screen panel for managing Auto Run documents, launching/stopping runs, + * and navigating to document viewer and setup sheet. + */ +export function AutoRunPanel({ + sessionId, + autoRunState, + onClose, + onOpenDocument, + onOpenSetup, + sendRequest, + send, +}: AutoRunPanelProps) { + const colors = useThemeColors(); + const [isStopping, setIsStopping] = useState(false); + + const { documents, isLoadingDocs, loadDocuments, stopAutoRun } = useAutoRun( + sendRequest, + send, + autoRunState + ); + + // Load documents on mount and when sessionId changes + useEffect(() => { + loadDocuments(sessionId); + }, [sessionId, loadDocuments]); + + // Reset stopping state when autoRun stops + useEffect(() => { + if (!autoRunState?.isRunning) { + setIsStopping(false); + } + }, [autoRunState?.isRunning]); + + const handleClose = useCallback(() => { + triggerHaptic(HAPTIC_PATTERNS.tap); + onClose(); + }, [onClose]); + + const handleRefresh = useCallback(() => { + triggerHaptic(HAPTIC_PATTERNS.tap); + loadDocuments(sessionId); + }, [sessionId, loadDocuments]); + + const handleStop = useCallback(async () => { + triggerHaptic(HAPTIC_PATTERNS.interrupt); + setIsStopping(true); + const success = await stopAutoRun(sessionId); + if (!success) { + setIsStopping(false); + } + }, [sessionId, stopAutoRun]); + + const handleConfigure = useCallback(() => { + triggerHaptic(HAPTIC_PATTERNS.tap); + onOpenSetup?.(); + }, [onOpenSetup]); + + const handleDocumentTap = useCallback( + (filename: string) => { + onOpenDocument?.(filename); + }, + [onOpenDocument] + ); + + // Close on escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const isRunning = autoRunState?.isRunning ?? false; + const totalTasks = autoRunState?.totalTasks; + const completedTasks = autoRunState?.completedTasks ?? 0; + const currentTaskIndex = autoRunState?.currentTaskIndex ?? 0; + const progress = + totalTasks != null && totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; + const totalDocs = autoRunState?.totalDocuments; + const currentDocIndex = autoRunState?.currentDocumentIndex; + + return ( +
+ {/* Header */} +
+

+ Auto Run +

+ +
+ {/* Refresh button */} + + + {/* Close button */} + +
+
+ + {/* Status bar (when running) */} + {isRunning && ( +
+ {/* Progress badge */} +
+ {progress}% +
+ + {/* Status text */} +
+
+ {totalTasks != null && totalTasks > 0 && ( + + Task {currentTaskIndex + 1}/{totalTasks} + + )} + {totalDocs != null && currentDocIndex != null && totalDocs > 1 && ( + + Doc {currentDocIndex + 1}/{totalDocs} + + )} +
+ + {/* Progress bar */} +
+
+
+
+
+ )} + + {/* Controls bar */} +
+ {/* Configure & Launch button */} + + + {/* Stop button (visible only when running) */} + {isRunning && ( + + )} +
+ + {/* Document list */} +
+ {isLoadingDocs ? ( +
+ Loading documents... +
+ ) : documents.length === 0 ? ( + /* Empty state */ +
+

+ No Auto Run documents found +

+

+ Create documents in the{' '} + + .maestro/auto-run/ + {' '} + directory to get started +

+
+ ) : ( +
+ {documents.map((doc) => ( + + ))} +
+ )} +
+ + {/* Animation keyframes */} + +
+ ); +} + +export default AutoRunPanel; diff --git a/src/web/mobile/AutoRunSetupSheet.tsx b/src/web/mobile/AutoRunSetupSheet.tsx new file mode 100644 index 0000000000..13b3a17934 --- /dev/null +++ b/src/web/mobile/AutoRunSetupSheet.tsx @@ -0,0 +1,574 @@ +/** + * AutoRunSetupSheet component for Maestro mobile web interface + * + * Bottom sheet modal for configuring Auto Run before launch. + * Allows document selection, custom prompt, and loop settings. + */ + +import { useState, useCallback, useEffect, useRef } from 'react'; +import { useThemeColors } from '../components/ThemeProvider'; +import { triggerHaptic, HAPTIC_PATTERNS } from './constants'; +import type { AutoRunDocument, LaunchConfig } from '../hooks/useAutoRun'; + +/** + * Props for AutoRunSetupSheet component + */ +export interface AutoRunSetupSheetProps { + sessionId: string; + documents: AutoRunDocument[]; + onLaunch: (config: LaunchConfig) => void; + onClose: () => void; +} + +/** + * AutoRunSetupSheet component + * + * Bottom sheet modal that slides up from the bottom of the screen. + * Provides document selection, optional prompt, and loop configuration. + */ +export function AutoRunSetupSheet({ + sessionId: _sessionId, + documents, + onLaunch, + onClose, +}: AutoRunSetupSheetProps) { + const colors = useThemeColors(); + const [selectedFiles, setSelectedFiles] = useState>( + () => new Set(documents.map((d) => d.filename)) + ); + const [prompt, setPrompt] = useState(''); + const [loopEnabled, setLoopEnabled] = useState(false); + const [maxLoops, setMaxLoops] = useState(3); + const [isVisible, setIsVisible] = useState(false); + const sheetRef = useRef(null); + + const handleClose = useCallback(() => { + triggerHaptic(HAPTIC_PATTERNS.tap); + setIsVisible(false); + setTimeout(() => onClose(), 300); + }, [onClose]); + + // Reinitialize draft when sessionId or documents change + useEffect(() => { + setSelectedFiles(new Set(documents.map((d) => d.filename))); + setPrompt(''); + setLoopEnabled(false); + setMaxLoops(3); + }, [_sessionId, documents]); + + // Animate in on mount + useEffect(() => { + requestAnimationFrame(() => setIsVisible(true)); + }, []); + + // Close on escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + handleClose(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [handleClose]); + + const handleBackdropTap = useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + handleClose(); + } + }, + [handleClose] + ); + + const handleToggleFile = useCallback((filename: string) => { + triggerHaptic(HAPTIC_PATTERNS.tap); + setSelectedFiles((prev) => { + const next = new Set(prev); + if (next.has(filename)) { + next.delete(filename); + } else { + next.add(filename); + } + return next; + }); + }, []); + + const handleToggleAll = useCallback(() => { + triggerHaptic(HAPTIC_PATTERNS.tap); + if (selectedFiles.size === documents.length) { + setSelectedFiles(new Set()); + } else { + setSelectedFiles(new Set(documents.map((d) => d.filename))); + } + }, [selectedFiles.size, documents]); + + const handleLoopToggle = useCallback(() => { + triggerHaptic(HAPTIC_PATTERNS.tap); + setLoopEnabled((prev) => !prev); + }, []); + + const handleMaxLoopsChange = useCallback((e: React.ChangeEvent) => { + const value = parseInt(e.target.value, 10); + if (!isNaN(value)) { + setMaxLoops(Math.max(1, Math.min(100, value))); + } + }, []); + + const handleLaunch = useCallback(() => { + if (selectedFiles.size === 0) return; + triggerHaptic(HAPTIC_PATTERNS.success); + const config: LaunchConfig = { + documents: Array.from(selectedFiles).map((filename) => ({ filename })), + prompt: prompt.trim() || undefined, + loopEnabled: loopEnabled || undefined, + maxLoops: loopEnabled ? maxLoops : undefined, + }; + onLaunch(config); + }, [selectedFiles, prompt, loopEnabled, maxLoops, onLaunch]); + + const allSelected = selectedFiles.size === documents.length && documents.length > 0; + + return ( +
+ {/* Sheet */} +
+ {/* Drag handle */} +
+
+
+ + {/* Header */} +
+

+ Configure Auto Run +

+ +
+ + {/* Scrollable content */} +
+ {/* Document selector section */} +
+ {/* Section label + Select All toggle */} +
+ + Documents + + +
+ + {/* Document checkbox list */} +
+ {documents.map((doc) => { + const isSelected = selectedFiles.has(doc.filename); + return ( + + ); + })} +
+
+ + {/* Prompt input section */} +
+ +