mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
MAESTRO: refine mobile web hooks
This commit is contained in:
72
src/__tests__/web/hooks/useMobileAutoReconnect.test.ts
Normal file
72
src/__tests__/web/hooks/useMobileAutoReconnect.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
101
src/__tests__/web/hooks/useMobileKeyboardHandler.test.ts
Normal file
101
src/__tests__/web/hooks/useMobileKeyboardHandler.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
96
src/__tests__/web/hooks/useMobileSessionManagement.test.ts
Normal file
96
src/__tests__/web/hooks/useMobileSessionManagement.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
123
src/__tests__/web/hooks/useMobileViewState.test.ts
Normal file
123
src/__tests__/web/hooks/useMobileViewState.test.ts
Normal file
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user