diff --git a/src/__tests__/web/hooks/useMobileAutoReconnect.test.ts b/src/__tests__/web/hooks/useMobileAutoReconnect.test.ts new file mode 100644 index 00000000..e6207fe9 --- /dev/null +++ b/src/__tests__/web/hooks/useMobileAutoReconnect.test.ts @@ -0,0 +1,72 @@ +/** + * Tests for useMobileAutoReconnect hook + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useMobileAutoReconnect } from '../../../web/hooks/useMobileAutoReconnect'; + +const DEFAULT_COUNTDOWN = 30; + +describe('useMobileAutoReconnect', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('counts down and reconnects when disconnected and online', () => { + const connect = vi.fn(); + + const { result } = renderHook(({ connectionState, isOffline }) => + useMobileAutoReconnect({ + connectionState, + isOffline, + connect, + }), { + initialProps: { connectionState: 'disconnected', isOffline: false }, + } + ); + + expect(result.current.reconnectCountdown).toBe(DEFAULT_COUNTDOWN); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.reconnectCountdown).toBe(DEFAULT_COUNTDOWN - 1); + + act(() => { + vi.advanceTimersByTime(29000); + }); + + expect(connect).toHaveBeenCalledTimes(1); + expect(result.current.reconnectCountdown).toBe(DEFAULT_COUNTDOWN); + }); + + it('does not reconnect while offline', () => { + const connect = vi.fn(); + + const { result } = renderHook(({ connectionState, isOffline }) => + useMobileAutoReconnect({ + connectionState, + isOffline, + connect, + }), { + initialProps: { connectionState: 'disconnected', isOffline: true }, + } + ); + + expect(result.current.reconnectCountdown).toBe(DEFAULT_COUNTDOWN); + + act(() => { + vi.advanceTimersByTime(60000); + }); + + expect(connect).not.toHaveBeenCalled(); + expect(result.current.reconnectCountdown).toBe(DEFAULT_COUNTDOWN); + }); +}); diff --git a/src/__tests__/web/hooks/useMobileKeyboardHandler.test.ts b/src/__tests__/web/hooks/useMobileKeyboardHandler.test.ts new file mode 100644 index 00000000..aa1e038c --- /dev/null +++ b/src/__tests__/web/hooks/useMobileKeyboardHandler.test.ts @@ -0,0 +1,101 @@ +/** + * Tests for useMobileKeyboardHandler hook + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useMobileKeyboardHandler, type MobileKeyboardSession } from '../../../web/hooks/useMobileKeyboardHandler'; +import type { AITabData } from '../../../web/hooks/useWebSocket'; + +function createTabs(): AITabData[] { + return [ + { id: 'tab-1', agentSessionId: null, name: 'One', starred: false, inputValue: '', createdAt: 0, state: 'idle' }, + { id: 'tab-2', agentSessionId: null, name: 'Two', starred: false, inputValue: '', createdAt: 1, state: 'idle' }, + { id: 'tab-3', agentSessionId: null, name: 'Three', starred: false, inputValue: '', createdAt: 2, state: 'idle' }, + ]; +} + +describe('useMobileKeyboardHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('toggles input mode with Cmd+J', () => { + const handleModeToggle = vi.fn(); + const handleSelectTab = vi.fn(); + const activeSession: MobileKeyboardSession = { inputMode: 'ai' }; + + renderHook(() => useMobileKeyboardHandler({ + activeSessionId: 'session-1', + activeSession, + handleModeToggle, + handleSelectTab, + })); + + const event = new KeyboardEvent('keydown', { key: 'j', metaKey: true, cancelable: true }); + + act(() => { + document.dispatchEvent(event); + }); + + expect(handleModeToggle).toHaveBeenCalledTimes(1); + expect(handleModeToggle).toHaveBeenCalledWith('terminal'); + }); + + it('cycles to previous and next tabs with Cmd+[ and Cmd+]', () => { + const handleModeToggle = vi.fn(); + const handleSelectTab = vi.fn(); + const tabs = createTabs(); + const activeSession: MobileKeyboardSession = { + inputMode: 'ai', + aiTabs: tabs, + activeTabId: 'tab-2', + }; + + renderHook(() => useMobileKeyboardHandler({ + activeSessionId: 'session-1', + activeSession, + handleModeToggle, + handleSelectTab, + })); + + const prevEvent = new KeyboardEvent('keydown', { key: '[', metaKey: true, cancelable: true }); + const nextEvent = new KeyboardEvent('keydown', { key: ']', metaKey: true, cancelable: true }); + + act(() => { + document.dispatchEvent(prevEvent); + }); + + expect(handleSelectTab).toHaveBeenCalledWith('tab-1'); + + act(() => { + document.dispatchEvent(nextEvent); + }); + + expect(handleSelectTab).toHaveBeenCalledWith('tab-3'); + }); + + it('does not handle shortcuts when there is no active session', () => { + const handleModeToggle = vi.fn(); + const handleSelectTab = vi.fn(); + + renderHook(() => useMobileKeyboardHandler({ + activeSessionId: null, + activeSession: null, + handleModeToggle, + handleSelectTab, + })); + + const event = new KeyboardEvent('keydown', { key: 'j', metaKey: true, cancelable: true }); + + act(() => { + document.dispatchEvent(event); + }); + + expect(handleModeToggle).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/web/hooks/useMobileSessionManagement.test.ts b/src/__tests__/web/hooks/useMobileSessionManagement.test.ts new file mode 100644 index 00000000..d6c5a144 --- /dev/null +++ b/src/__tests__/web/hooks/useMobileSessionManagement.test.ts @@ -0,0 +1,96 @@ +/** + * Tests for useMobileSessionManagement hook + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useMobileSessionManagement, type UseMobileSessionManagementDeps } from '../../../web/hooks/useMobileSessionManagement'; +import type { Session } from '../../../web/hooks/useSessions'; + +const baseDeps: UseMobileSessionManagementDeps = { + savedActiveSessionId: null, + savedActiveTabId: null, + isOffline: true, + sendRef: { current: null }, + triggerHaptic: vi.fn(), + hapticTapPattern: 10, +}; + +describe('useMobileSessionManagement', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('selects a session and syncs active tab', () => { + const sendSpy = vi.fn(); + const { result } = renderHook(() => useMobileSessionManagement({ + ...baseDeps, + sendRef: { current: sendSpy }, + })); + + const session: Session = { + id: 'session-1', + name: 'Session 1', + toolType: 'claude-code', + state: 'idle', + inputMode: 'ai', + cwd: '/tmp', + aiTabs: [], + activeTabId: 'tab-1', + } as Session; + + act(() => { + result.current.setSessions([session]); + }); + + act(() => { + result.current.handleSelectSession('session-1'); + }); + + expect(result.current.activeSessionId).toBe('session-1'); + expect(result.current.activeTabId).toBe('tab-1'); + expect(sendSpy).toHaveBeenCalledWith({ + type: 'select_session', + sessionId: 'session-1', + tabId: 'tab-1', + }); + }); + + it('clears activeTabId when the active session is removed', () => { + const { result } = renderHook(() => useMobileSessionManagement({ + ...baseDeps, + savedActiveSessionId: 'session-1', + savedActiveTabId: 'tab-1', + })); + + act(() => { + result.current.sessionsHandlers.onSessionRemoved('session-1'); + }); + + expect(result.current.activeSessionId).toBeNull(); + expect(result.current.activeTabId).toBeNull(); + }); + + it('adds output logs for the active session and tab', async () => { + const { result } = renderHook(() => useMobileSessionManagement({ + ...baseDeps, + savedActiveSessionId: 'session-1', + savedActiveTabId: 'tab-1', + })); + + await waitFor(() => { + expect(result.current.activeSessionIdRef.current).toBe('session-1'); + }); + + act(() => { + result.current.sessionsHandlers.onSessionOutput('session-1', 'hello', 'ai', 'tab-1'); + }); + + expect(result.current.sessionLogs.aiLogs).toHaveLength(1); + expect(result.current.sessionLogs.aiLogs[0].text).toBe('hello'); + }); +}); diff --git a/src/__tests__/web/hooks/useMobileViewState.test.ts b/src/__tests__/web/hooks/useMobileViewState.test.ts new file mode 100644 index 00000000..3000487c --- /dev/null +++ b/src/__tests__/web/hooks/useMobileViewState.test.ts @@ -0,0 +1,123 @@ +/** + * Tests for useMobileViewState hook + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useMobileViewState } from '../../../web/hooks/useMobileViewState'; + +const mockLoadViewState = vi.hoisted(() => vi.fn()); +const mockLoadScrollState = vi.hoisted(() => vi.fn()); +const mockDebouncedSaveViewState = vi.hoisted(() => vi.fn()); + +vi.mock('../../../web/utils/viewState', () => ({ + loadViewState: mockLoadViewState, + loadScrollState: mockLoadScrollState, + debouncedSaveViewState: mockDebouncedSaveViewState, +})); + +describe('useMobileViewState', () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(window, 'innerHeight', { value: 600, writable: true }); + + mockLoadViewState.mockReturnValue({ + showAllSessions: false, + showHistoryPanel: false, + showTabSearch: false, + activeSessionId: null, + activeTabId: null, + inputMode: 'ai', + historyFilter: 'all', + historySearchOpen: false, + historySearchQuery: '', + savedAt: Date.now(), + }); + + mockLoadScrollState.mockReturnValue({ + messageHistory: 0, + allSessions: 0, + historyPanel: 0, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('loads saved view and scroll state', () => { + const { result } = renderHook(() => useMobileViewState()); + + expect(result.current.savedState).toEqual(expect.objectContaining({ + showAllSessions: false, + activeSessionId: null, + inputMode: 'ai', + })); + expect(result.current.savedScrollState).toEqual({ + messageHistory: 0, + allSessions: 0, + historyPanel: 0, + }); + }); + + it('tracks small screen state and updates on resize', () => { + const { result } = renderHook(() => useMobileViewState()); + + expect(result.current.isSmallScreen).toBe(true); + + act(() => { + Object.defineProperty(window, 'innerHeight', { value: 800, writable: true }); + window.dispatchEvent(new Event('resize')); + }); + + expect(result.current.isSmallScreen).toBe(false); + }); + + it('persists view and history state via debounced save', () => { + const { result } = renderHook(() => useMobileViewState()); + + act(() => { + result.current.persistViewState({ + showAllSessions: true, + showHistoryPanel: false, + showTabSearch: true, + }); + }); + + expect(mockDebouncedSaveViewState).toHaveBeenCalledWith({ + showAllSessions: true, + showHistoryPanel: false, + showTabSearch: true, + }); + + act(() => { + result.current.persistHistoryState({ + historyFilter: 'AUTO', + historySearchQuery: 'search', + historySearchOpen: true, + }); + }); + + expect(mockDebouncedSaveViewState).toHaveBeenCalledWith({ + historyFilter: 'AUTO', + historySearchQuery: 'search', + historySearchOpen: true, + }); + }); + + it('persists session selection state', () => { + const { result } = renderHook(() => useMobileViewState()); + + act(() => { + result.current.persistSessionSelection({ + activeSessionId: 'session-1', + activeTabId: 'tab-1', + }); + }); + + expect(mockDebouncedSaveViewState).toHaveBeenCalledWith({ + activeSessionId: 'session-1', + activeTabId: 'tab-1', + }); + }); +}); diff --git a/src/web/hooks/useMobileSessionManagement.ts b/src/web/hooks/useMobileSessionManagement.ts index d978daa0..ce83e4fc 100644 --- a/src/web/hooks/useMobileSessionManagement.ts +++ b/src/web/hooks/useMobileSessionManagement.ts @@ -383,30 +383,51 @@ export function useMobileSessionManagement( previousSessionStatesRef.current.delete(sessionId); setSessions(prev => prev.filter(s => s.id !== sessionId)); - setActiveSessionId(prev => prev === sessionId ? null : prev); + setActiveSessionId(prev => { + if (prev === sessionId) { + setActiveTabId(null); + return null; + } + return prev; + }); }, onActiveSessionChanged: (sessionId: string) => { // Desktop app switched to a different session - sync with web webLogger.debug(`Desktop active session changed: ${sessionId}`, 'Mobile'); setActiveSessionId(sessionId); + setActiveTabId(null); }, onSessionOutput: (sessionId: string, data: string, source: 'ai' | 'terminal', tabId?: string) => { // Real-time output from AI or terminal - append to session logs const currentActiveId = activeSessionIdRef.current; const currentActiveTabId = activeTabIdRef.current; - console.log(`[MobileApp] onSessionOutput: session=${sessionId}, activeSession=${currentActiveId}, tabId=${tabId || 'none'}, activeTabId=${currentActiveTabId || 'none'}, source=${source}, dataLen=${data?.length || 0}`); webLogger.debug(`Session output: ${sessionId} (${source}) ${data.length} chars`, 'Mobile'); + webLogger.debug('Session output detail', 'Mobile', { + sessionId, + activeSessionId: currentActiveId, + tabId: tabId || 'none', + activeTabId: currentActiveTabId || 'none', + source, + dataLen: data?.length || 0, + }); // Only update if this is the active session if (currentActiveId !== sessionId) { - console.log(`[MobileApp] Skipping output - not active session`); + webLogger.debug('Skipping output - not active session', 'Mobile', { + sessionId, + activeSessionId: currentActiveId, + }); return; } // For AI output with tabId, only update if this is the active tab // This prevents output from newly created tabs appearing in the wrong tab's logs if (source === 'ai' && tabId && currentActiveTabId && tabId !== currentActiveTabId) { - console.log(`[MobileApp] Skipping output - not active tab (output tab: ${tabId}, active tab: ${currentActiveTabId})`); + webLogger.debug('Skipping output - not active tab', 'Mobile', { + sessionId, + outputTabId: tabId, + activeTabId: currentActiveTabId, + }); return; } @@ -427,7 +448,11 @@ export function useMobileSessionManagement( ...lastLog, text: lastLog.text + data, }; - console.log(`[MobileApp] Appended to existing log entry, new length: ${updatedLogs[updatedLogs.length - 1].text.length}`); + webLogger.debug('Appended to existing log entry', 'Mobile', { + sessionId, + source, + newLength: updatedLogs[updatedLogs.length - 1].text.length, + }); return { ...prev, [logKey]: updatedLogs }; } else { // Create new entry @@ -437,7 +462,11 @@ export function useMobileSessionManagement( source: 'stdout', text: data, }; - console.log(`[MobileApp] Created new log entry, text length: ${data.length}`); + webLogger.debug('Created new log entry', 'Mobile', { + sessionId, + source, + dataLength: data.length, + }); return { ...prev, [logKey]: [...existingLogs, newEntry] }; } }); @@ -452,12 +481,20 @@ export function useMobileSessionManagement( onUserInput: (sessionId: string, command: string, inputMode: 'ai' | 'terminal') => { // User input from desktop app - add to session logs so web interface stays in sync const currentActiveId = activeSessionIdRef.current; - console.log(`[MobileApp] onUserInput: session=${sessionId}, activeSession=${currentActiveId}, mode=${inputMode}, cmdLen=${command.length}, match=${currentActiveId === sessionId}`); - webLogger.debug(`User input from desktop: ${sessionId} (${inputMode}) ${command.substring(0, 50)}`, 'Mobile'); + webLogger.debug(`User input from desktop: ${sessionId} (${inputMode}) ${command.substring(0, 50)}`, 'Mobile', { + sessionId, + activeSessionId: currentActiveId, + inputMode, + commandLength: command.length, + isActiveSession: currentActiveId === sessionId, + }); // Only add if this is the active session if (currentActiveId !== sessionId) { - console.log(`[MobileApp] Skipping user input - not active session`); + webLogger.debug('Skipping user input - not active session', 'Mobile', { + sessionId, + activeSessionId: currentActiveId, + }); return; }