MAESTRO: refine mobile web hooks

This commit is contained in:
Pedram Amini
2025-12-21 15:47:56 -06:00
parent 28b198bd9f
commit 1d42e40932
5 changed files with 438 additions and 9 deletions

View 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);
});
});

View 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();
});
});

View 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');
});
});

View 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',
});
});
});

View File

@@ -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;
}