From 6b765e86d6a01a7be11a550f8f4d8c994d2218fe Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Fri, 19 Jun 2026 23:38:33 +0100 Subject: [PATCH 01/16] feat(mobile-pairing): add QR-based device pairing module Implement core pairing infrastructure with short-lived codes (6-char base32, 5-minute expiry) and long-lived hashed tokens (90-day TTL) for mobile device authentication. Includes device CRUD operations and periodic code cleanup. --- src/main/mobile-pairing/index.ts | 230 +++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 src/main/mobile-pairing/index.ts diff --git a/src/main/mobile-pairing/index.ts b/src/main/mobile-pairing/index.ts new file mode 100644 index 0000000000..2989f37aac --- /dev/null +++ b/src/main/mobile-pairing/index.ts @@ -0,0 +1,230 @@ +/** + * Mobile Pairing Module + * + * Implements QR-based device pairing per decision 15B: + * - Short-lived pairing codes (6-char base32, 5-minute expiry) + * - Long-lived per-device hashed tokens stored in mobile-pairings.json + * - Token validation for WebSocket authentication + * + * Flow: + * 1. Desktop generates pairing code via generatePairingCode() + * 2. Mobile scans QR, posts to /api/mobile-pairing/redeem + * 3. redeemPairingCode() validates code, persists hashed token, returns plaintext token + * 4. Mobile stores token in SecureStore + * 5. On subsequent connections, validateMobileToken() authenticates via hashed token + */ + +import crypto from 'crypto'; +import path from 'path'; +import { app } from 'electron'; +import { readFile, writeFile, mkdir } from 'fs/promises'; + +// Types + +export interface PendingPairing { + code: string; + pendingToken: string; + expiresAt: number; + used: boolean; +} + +export interface PairedDevice { + id: string; + deviceName: string; + tokenHash: string; + createdAt: number; + lastUsedAt: number; + expiresAt: number; +} + +export interface GeneratedCode { + code: string; + expiresAt: number; + pendingToken: string; +} + +// Constants + +const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; +const CODE_LENGTH = 6; +const CODE_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes +const TOKEN_EXPIRY_MS = 90 * 24 * 60 * 60 * 1000; // 90 days +const PAIRINGS_FILENAME = 'mobile-pairings.json'; + +// In-memory store for pending pairing codes +const pendingPairings = new Map(); + +// Helpers + +function generateBase32Code(length: number): string { + let result = ''; + const bytes = crypto.randomBytes(length); + for (let i = 0; i < length; i++) { + result += BASE32_CHARS[bytes[i] % 32]; + } + return result; +} + +function generate256BitToken(): string { + return crypto.randomBytes(32).toString('hex'); +} + +function hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +function generateUUID(): string { + return crypto.randomUUID(); +} + +function getPairingsFilePath(): string { + return path.join(app.getPath('userData'), PAIRINGS_FILENAME); +} + +async function readPairings(): Promise { + try { + const filePath = getPairingsFilePath(); + const content = await readFile(filePath, 'utf-8'); + const data = JSON.parse(content); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} + +async function writePairings(devices: PairedDevice[]): Promise { + const filePath = getPairingsFilePath(); + const dir = path.dirname(filePath); + await mkdir(dir, { recursive: true }); + await writeFile(filePath, JSON.stringify(devices, null, '\t'), 'utf-8'); +} + +// Cleanup expired pending codes periodically +function cleanupExpiredCodes(): void { + const now = Date.now(); + pendingPairings.forEach((pairing, code) => { + if (pairing.expiresAt < now || pairing.used) { + pendingPairings.delete(code); + } + }); +} + +// Run cleanup every minute +setInterval(cleanupExpiredCodes, 60 * 1000); + +// Public API + +/** Generate a new pairing code for mobile device enrollment. */ +export function generatePairingCode(): GeneratedCode { + // Clean up old codes first + cleanupExpiredCodes(); + + const code = generateBase32Code(CODE_LENGTH); + const pendingToken = generate256BitToken(); + const expiresAt = Date.now() + CODE_EXPIRY_MS; + + pendingPairings.set(code, { + code, + pendingToken, + expiresAt, + used: false, + }); + + return { code, expiresAt, pendingToken }; +} + +/** Redeem a pairing code. Validates, marks used, persists device, returns token. */ +export async function redeemPairingCode( + code: string, + deviceName: string +): Promise<{ token: string; deviceId: string } | null> { + const normalizedCode = code.toUpperCase().trim(); + const pending = pendingPairings.get(normalizedCode); + + // Validate code exists, not expired, not used + if (!pending) { + return null; + } + + if (pending.expiresAt < Date.now()) { + pendingPairings.delete(normalizedCode); + return null; + } + + if (pending.used) { + return null; + } + + // Mark as used + pending.used = true; + + // Create device record + const now = Date.now(); + const device: PairedDevice = { + id: generateUUID(), + deviceName: deviceName || 'Unknown Device', + tokenHash: hashToken(pending.pendingToken), + createdAt: now, + lastUsedAt: now, + expiresAt: now + TOKEN_EXPIRY_MS, + }; + + // Read-modify-write pairings file + const devices = await readPairings(); + devices.push(device); + await writePairings(devices); + + // Clean up the pending code + pendingPairings.delete(normalizedCode); + + return { token: pending.pendingToken, deviceId: device.id }; +} + +/** Validate a mobile token. Returns device record if valid, null otherwise. */ +export async function validateMobileToken(token: string): Promise { + if (!token || typeof token !== 'string') { + return null; + } + + const tokenHash = hashToken(token); + const devices = await readPairings(); + const now = Date.now(); + + const device = devices.find((d) => d.tokenHash === tokenHash && d.expiresAt > now); + + return device || null; +} + +/** Update lastUsedAt timestamp when a device successfully authenticates. */ +export async function updateDeviceLastUsed(deviceId: string): Promise { + const devices = await readPairings(); + const device = devices.find((d) => d.id === deviceId); + + if (device) { + device.lastUsedAt = Date.now(); + await writePairings(devices); + } +} + +/** List all paired devices (without exposing token hashes). */ +export async function listPairedDevices(): Promise[]> { + const devices = await readPairings(); + const now = Date.now(); + + // Filter expired devices and omit tokenHash + return devices.filter((d) => d.expiresAt > now).map(({ tokenHash: _, ...rest }) => rest); +} + +/** Revoke a paired device by ID. */ +export async function revokeDevice(deviceId: string): Promise { + const devices = await readPairings(); + const initialLength = devices.length; + const filtered = devices.filter((d) => d.id !== deviceId); + + if (filtered.length < initialLength) { + await writePairings(filtered); + return true; + } + + return false; +} From 763dc5045305fc0fc646a520f6f4b58b84d61c5c Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Fri, 19 Jun 2026 23:38:43 +0100 Subject: [PATCH 02/16] feat(mobile-pairing): add IPC handlers and preload bridge Expose mobile pairing operations to renderer via window.maestro.mobilePairing namespace. Handlers integrate with WebServer to include host/port in generated pairing codes for QR display. --- src/main/ipc/handlers/index.ts | 7 ++ src/main/ipc/handlers/mobile-pairing.ts | 136 ++++++++++++++++++++++++ src/main/preload/index.ts | 13 +++ src/main/preload/mobilePairing.ts | 80 ++++++++++++++ src/renderer/global.d.ts | 28 +++++ 5 files changed, 264 insertions(+) create mode 100644 src/main/ipc/handlers/mobile-pairing.ts create mode 100644 src/main/preload/mobilePairing.ts diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index 086991ff41..d717d85c88 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -68,6 +68,7 @@ import { registerFeedbackHandlers } from './feedback'; import { registerMaestroCliHandlers } from './maestro-cli'; import { registerPromptsHandlers } from './prompts'; import { registerMemoryHandlers } from './memory'; +import { registerMobilePairingHandlers, MobilePairingHandlerDependencies } from './mobile-pairing'; import { AgentDetector } from '../../agents'; import { ProcessManager } from '../../process-manager'; import { WebServer } from '../../web-server'; @@ -130,6 +131,8 @@ export { registerFeedbackHandlers }; export { registerMaestroCliHandlers }; export { registerPromptsHandlers }; export { registerMemoryHandlers }; +export { registerMobilePairingHandlers }; +export type { MobilePairingHandlerDependencies }; export type { AgentsHandlerDependencies }; export type { ProcessHandlerDependencies }; export type { PersistenceHandlerDependencies }; @@ -327,6 +330,10 @@ export function registerAllHandlers(deps: HandlerDependencies): void { registerPromptsHandlers(); // Register project Memory handlers (Claude Code per-project memory viewer) registerMemoryHandlers(); + // Register Mobile Pairing handlers (QR-based device pairing) + registerMobilePairingHandlers({ + getWebServer: deps.getWebServer, + }); // Setup logger event forwarding to renderer setupLoggerEventForwarding(deps.getMainWindow); } diff --git a/src/main/ipc/handlers/mobile-pairing.ts b/src/main/ipc/handlers/mobile-pairing.ts new file mode 100644 index 0000000000..9337705ba5 --- /dev/null +++ b/src/main/ipc/handlers/mobile-pairing.ts @@ -0,0 +1,136 @@ +/** + * Mobile Pairing IPC Handlers + * + * Provides IPC handlers for mobile device pairing: + * - Generate pairing code for QR display + * - List paired devices + * - Revoke paired devices + */ + +import { ipcMain } from 'electron'; +import { generatePairingCode, listPairedDevices, revokeDevice } from '../../mobile-pairing'; +import { createIpcHandler, CreateHandlerOptions } from '../../utils/ipcHandler'; +import { logger } from '../../utils/logger'; +import { WebServer } from '../../web-server'; + +const LOG_CONTEXT = '[MobilePairing]'; + +/** + * Helper to create handler options with consistent context + */ +const handlerOpts = (operation: string, logSuccess = true): CreateHandlerOptions => ({ + context: LOG_CONTEXT, + operation, + logSuccess, +}); + +/** + * Dependencies required for mobile pairing handler registration + */ +export interface MobilePairingHandlerDependencies { + /** Function to get the WebServer instance */ + getWebServer: () => WebServer | null; +} + +/** + * Register all mobile pairing IPC handlers. + * + * Handlers: + * - mobile-pairing:generate-code - Generate a new pairing code with host/port info + * - mobile-pairing:list-devices - Get all paired devices (no tokens) + * - mobile-pairing:revoke-device - Revoke a paired device by ID + */ +export function registerMobilePairingHandlers(deps: MobilePairingHandlerDependencies): void { + const { getWebServer } = deps; + + /** + * Generate a new pairing code for QR display. + * + * Returns the code, host, port, and expiration time. + * Requires the web server to be running to get host/port info. + */ + ipcMain.handle( + 'mobile-pairing:generate-code', + createIpcHandler( + handlerOpts('generate-code'), + async (): Promise<{ + code: string; + host: string; + port: number; + expiresAt: number; + }> => { + const webServer = getWebServer(); + if (!webServer || !webServer.isActive()) { + throw new Error('Web server is not running. Enable web interface first.'); + } + + // Generate the pairing code + const pairing = generatePairingCode(); + + // Get host and port from the running web server + const url = webServer.getUrl(); + const port = webServer.getPort(); + + // Extract host from URL (format: http://192.168.x.x:port) + const urlMatch = url.match(/^https?:\/\/([^:]+)/); + const host = urlMatch ? urlMatch[1] : 'localhost'; + + logger.info(`Generated pairing code (expires in 5 minutes)`, LOG_CONTEXT); + + return { + code: pairing.code, + host, + port, + expiresAt: pairing.expiresAt, + }; + } + ) + ); + + /** + * List all paired devices. + * + * Returns device records without token hashes. + */ + ipcMain.handle( + 'mobile-pairing:list-devices', + createIpcHandler( + handlerOpts('list-devices', false), + async (): Promise<{ + devices: Array<{ + id: string; + deviceName: string; + createdAt: number; + lastUsedAt: number; + expiresAt: number; + }>; + }> => { + const devices = await listPairedDevices(); + return { devices }; + } + ) + ); + + /** + * Revoke a paired device by ID. + * + * Removes the device from the paired devices list. + */ + ipcMain.handle( + 'mobile-pairing:revoke-device', + createIpcHandler( + handlerOpts('revoke-device'), + async (id: string): Promise<{ revoked: boolean }> => { + const revoked = await revokeDevice(id); + if (revoked) { + logger.info(`Revoked paired device: ${id}`, LOG_CONTEXT); + } else { + logger.warn(`Device not found for revocation: ${id}`, LOG_CONTEXT); + } + return { revoked }; + } + ) + ); + + logger.debug(`${LOG_CONTEXT} Mobile pairing IPC handlers registered`); +} diff --git a/src/main/preload/index.ts b/src/main/preload/index.ts index 4feb0449b2..8acbb693ed 100644 --- a/src/main/preload/index.ts +++ b/src/main/preload/index.ts @@ -57,6 +57,7 @@ import { createWakatimeApi } from './wakatime'; import { createMaestroCliApi } from './maestroCli'; import { createPromptsApi } from './prompts'; import { createMemoryApi } from './memory'; +import { createMobilePairingApi } from './mobilePairing'; // Expose protected methods that allow the renderer process to use // the ipcRenderer without exposing the entire object @@ -218,6 +219,8 @@ contextBridge.exposeInMainWorld('maestro', { prompts: createPromptsApi(), // Per-project Memory API (Claude Code memory viewer) memory: createMemoryApi(), + // Mobile Pairing API (QR-based device pairing) + mobilePairing: createMobilePairingApi(), }); // Re-export factory functions for external consumers (e.g., tests) @@ -306,6 +309,8 @@ export { createPromptsApi, // Memory Viewer createMemoryApi, + // Mobile Pairing + createMobilePairingApi, }; // Re-export types for TypeScript consumers @@ -545,3 +550,11 @@ export type { PromptsApi, CorePromptData, } from './prompts'; +export type { + // From mobilePairing + MobilePairingApi, + PairedDevice, + PairingCodeResponse, + DeviceListResponse, + RevokeDeviceResponse, +} from './mobilePairing'; diff --git a/src/main/preload/mobilePairing.ts b/src/main/preload/mobilePairing.ts new file mode 100644 index 0000000000..c7b5410155 --- /dev/null +++ b/src/main/preload/mobilePairing.ts @@ -0,0 +1,80 @@ +/** + * Preload API for mobile pairing operations + * + * Provides the window.maestro.mobilePairing namespace for: + * - Generating pairing codes for QR display + * - Listing paired devices + * - Revoking paired devices + */ + +import { ipcRenderer } from 'electron'; + +/** + * Paired device record (without token hash) + */ +export interface PairedDevice { + id: string; + deviceName: string; + createdAt: number; + lastUsedAt: number; + expiresAt: number; +} + +/** + * Pairing code response + */ +export interface PairingCodeResponse { + success: boolean; + code?: string; + host?: string; + port?: number; + expiresAt?: number; + error?: string; +} + +/** + * Device list response + */ +export interface DeviceListResponse { + success: boolean; + devices?: PairedDevice[]; + error?: string; +} + +/** + * Revoke device response + */ +export interface RevokeDeviceResponse { + success: boolean; + revoked?: boolean; + error?: string; +} + +/** + * Creates the mobile pairing API object for preload exposure + */ +export function createMobilePairingApi() { + return { + /** + * Generate a new pairing code for QR display. + * Returns the code, host, port, and expiration time. + * Requires the web server to be running. + */ + generateCode: (): Promise => + ipcRenderer.invoke('mobile-pairing:generate-code'), + + /** + * List all paired devices (without tokens). + */ + listDevices: (): Promise => + ipcRenderer.invoke('mobile-pairing:list-devices'), + + /** + * Revoke a paired device by ID. + */ + revokeDevice: (id: string): Promise => + ipcRenderer.invoke('mobile-pairing:revoke-device', id), + }; +} + +export type MobilePairingApi = ReturnType; diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 6efc4d98c8..fddf20af91 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -3520,6 +3520,34 @@ interface MaestroAPI { agentId?: string ) => Promise<{ success: boolean; path?: string; error?: string }>; }; + + // Mobile Pairing API (QR-based device pairing) + mobilePairing: { + generateCode: () => Promise<{ + success: boolean; + code?: string; + host?: string; + port?: number; + expiresAt?: number; + error?: string; + }>; + listDevices: () => Promise<{ + success: boolean; + devices?: Array<{ + id: string; + deviceName: string; + createdAt: number; + lastUsedAt: number; + expiresAt: number; + }>; + error?: string; + }>; + revokeDevice: (id: string) => Promise<{ + success: boolean; + revoked?: boolean; + error?: string; + }>; + }; } declare global { From 90ceec4d8757924024051c8f33b00ee3f5844375 Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Fri, 19 Jun 2026 23:38:53 +0100 Subject: [PATCH 03/16] feat(web-server): support mobile device token authentication Extend WebSocket route to authenticate both browser security tokens and mobile device tokens. Add public pairing code redemption endpoint with rate limiting. Track mobile client metadata for connection management. --- src/main/web-server/WebServer.ts | 13 +- .../web-server/handlers/messageHandlers.ts | 4 + src/main/web-server/routes/index.ts | 6 + .../web-server/routes/mobilePairingRoutes.ts | 126 ++++++++++++++++++ src/main/web-server/routes/wsRoute.ts | 56 +++++++- src/main/web-server/types.ts | 4 + 6 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 src/main/web-server/routes/mobilePairingRoutes.ts diff --git a/src/main/web-server/WebServer.ts b/src/main/web-server/WebServer.ts index 023d827e29..1fd0c27c6c 100644 --- a/src/main/web-server/WebServer.ts +++ b/src/main/web-server/WebServer.ts @@ -34,8 +34,9 @@ import { getLocalIpAddress } from '../utils/networkUtils'; import { captureException } from '../utils/sentry'; import { WebSocketMessageHandler } from './handlers'; import { BroadcastService } from './services'; -import { ApiRoutes, StaticRoutes, WsRoute } from './routes'; +import { ApiRoutes, StaticRoutes, WsRoute, MobilePairingRoutes } from './routes'; import { LiveSessionManager, CallbackRegistry } from './managers'; +import { redeemPairingCode } from '../mobile-pairing'; // Import shared types from canonical location import type { @@ -178,6 +179,7 @@ export class WebServer { private apiRoutes: ApiRoutes; private staticRoutes: StaticRoutes; private wsRoute: WsRoute; + private mobilePairingRoutes: MobilePairingRoutes; constructor(port: number = 0, securityToken?: string) { // Use port 0 to let OS assign a random available port @@ -225,6 +227,7 @@ export class WebServer { this.apiRoutes = new ApiRoutes(this.securityToken, this.rateLimitConfig); this.staticRoutes = new StaticRoutes(this.securityToken, this.webAssetsPath); this.wsRoute = new WsRoute(this.securityToken); + this.mobilePairingRoutes = new MobilePairingRoutes(); // Note: setupMiddleware and setupRoutes are called in start() to handle async properly } @@ -830,6 +833,14 @@ export class WebServer { }, }); this.wsRoute.registerRoute(this.server); + + // Setup mobile pairing routes (public, no token required) + this.mobilePairingRoutes.setCallbacks({ + redeemPairingCode: async (code, deviceName) => { + return redeemPairingCode(code, deviceName); + }, + }); + this.mobilePairingRoutes.registerRoutes(this.server); } private handleWebClientMessage(clientId: string, message: WebClientMessage): void { diff --git a/src/main/web-server/handlers/messageHandlers.ts b/src/main/web-server/handlers/messageHandlers.ts index 60f4af80fa..0418e4da90 100644 --- a/src/main/web-server/handlers/messageHandlers.ts +++ b/src/main/web-server/handlers/messageHandlers.ts @@ -144,6 +144,10 @@ export interface WebClient { id: string; connectedAt: number; subscribedSessionId?: string; + /** Whether this is a mobile app client (vs browser) */ + isMobileClient?: boolean; + /** Device ID from mobile-pairings.json for mobile clients */ + mobileDeviceId?: string; } /** diff --git a/src/main/web-server/routes/index.ts b/src/main/web-server/routes/index.ts index 28a7006103..39eb754e5d 100644 --- a/src/main/web-server/routes/index.ts +++ b/src/main/web-server/routes/index.ts @@ -27,3 +27,9 @@ export { LiveSessionInfo as WsLiveSessionInfo, CustomAICommand as WsCustomAICommand, } from './wsRoute'; + +export { + MobilePairingRoutes, + MobilePairingRouteCallbacks, + RedeemCodeResult, +} from './mobilePairingRoutes'; diff --git a/src/main/web-server/routes/mobilePairingRoutes.ts b/src/main/web-server/routes/mobilePairingRoutes.ts new file mode 100644 index 0000000000..862afa5555 --- /dev/null +++ b/src/main/web-server/routes/mobilePairingRoutes.ts @@ -0,0 +1,126 @@ +/** + * Mobile Pairing Routes for Web Server + * + * Public (non-token-protected) routes for mobile device pairing. + * The security model relies on the short-lived pairing code instead of the security token. + * + * API Endpoints: + * - POST /api/mobile-pairing/redeem - Exchange pairing code for long-lived token + */ + +import { FastifyInstance } from 'fastify'; +import { logger } from '../../utils/logger'; + +// Logger context for all mobile pairing route logs +const LOG_CONTEXT = 'WebServer:MobilePairing'; + +/** + * Result of redeeming a pairing code + */ +export interface RedeemCodeResult { + token: string; + deviceId: string; +} + +/** + * Callbacks required by mobile pairing routes + */ +export interface MobilePairingRouteCallbacks { + redeemPairingCode: (code: string, deviceName: string) => Promise; +} + +/** + * Mobile Pairing Routes Class + * + * Handles device pairing without requiring the security token. + * Security is provided by the short-lived pairing code. + */ +export class MobilePairingRoutes { + private callbacks: Partial = {}; + + /** + * Set the callbacks for mobile pairing operations + */ + setCallbacks(callbacks: MobilePairingRouteCallbacks): void { + this.callbacks = callbacks; + } + + /** + * Register mobile pairing routes on the Fastify server + */ + registerRoutes(server: FastifyInstance): void { + // POST /api/mobile-pairing/redeem - Exchange pairing code for token + // This endpoint is public (no security token required) because: + // 1. The pairing code itself is the authentication mechanism + // 2. Codes are short-lived (5 minutes) and single-use + // 3. The code must be obtained from the desktop via QR code + server.post( + '/api/mobile-pairing/redeem', + { + config: { + rateLimit: { + max: 10, // Very restrictive: 10 attempts per minute + timeWindow: 60000, + }, + }, + }, + async (request, reply) => { + const body = request.body as { code?: string; deviceName?: string } | undefined; + const code = body?.code; + const deviceName = body?.deviceName; + + if (!code || typeof code !== 'string') { + return reply.code(400).send({ + error: 'Bad Request', + message: 'Pairing code is required', + timestamp: Date.now(), + }); + } + + if (!this.callbacks.redeemPairingCode) { + return reply.code(503).send({ + error: 'Service Unavailable', + message: 'Pairing service not configured', + timestamp: Date.now(), + }); + } + + try { + const result = await this.callbacks.redeemPairingCode( + code, + deviceName || 'Unknown Device' + ); + + if (!result) { + // Code not found, expired, or already used + return reply.code(401).send({ + error: 'Unauthorized', + message: 'Invalid or expired pairing code', + timestamp: Date.now(), + }); + } + + logger.info(`Mobile device paired: ${deviceName}`, LOG_CONTEXT); + + return { + success: true, + token: result.token, + deviceId: result.deviceId, + timestamp: Date.now(), + }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error(`Failed to redeem pairing code: ${message}`, LOG_CONTEXT, error); + + return reply.code(500).send({ + error: 'Internal Server Error', + message: 'Failed to redeem pairing code', + timestamp: Date.now(), + }); + } + } + ); + + logger.debug('Mobile pairing routes registered', LOG_CONTEXT); + } +} diff --git a/src/main/web-server/routes/wsRoute.ts b/src/main/web-server/routes/wsRoute.ts index f5ebdd4c94..484fcb1973 100644 --- a/src/main/web-server/routes/wsRoute.ts +++ b/src/main/web-server/routes/wsRoute.ts @@ -4,9 +4,15 @@ * This module contains the WebSocket route setup extracted from web-server.ts. * Handles WebSocket connections, initial state sync, and message delegation. * - * Route: /$TOKEN/ws + * Route: /:token/ws * - * Connection Flow: + * Authentication: + * 1. If URL token matches securityToken, connection proceeds (browser auth) + * 2. If URL token doesn't match, validate as mobile token via mobile-pairing module + * 3. If mobile token is valid, update lastUsedAt and proceed + * 4. If neither matches, reject with auth failure + * + * Connection Flow (after auth): * 1. Client connects with optional ?sessionId= query param * 2. Server sends 'connected' message with client ID * 3. Server sends 'sessions_list' with all sessions (enriched with live info) @@ -17,6 +23,7 @@ import { FastifyInstance } from 'fastify'; import { logger } from '../../utils/logger'; +import { validateMobileToken, updateDeviceLastUsed } from '../../mobile-pairing'; import type { Theme, WebClient, @@ -79,13 +86,48 @@ export class WsRoute { } /** - * Register the WebSocket route on the Fastify server + * Register the WebSocket route on the Fastify server. + * Uses wildcard route to validate both browser security tokens and mobile tokens. */ registerRoute(server: FastifyInstance): void { - const token = this.securityToken; + // Use wildcard route to capture any token for validation + server.get('/:token/ws', { websocket: true }, async (connection, request) => { + // Extract token from URL path + const urlPath = request.url || ''; + const tokenMatch = urlPath.match(/^\/([^/]+)\/ws/); + const urlToken = tokenMatch ? tokenMatch[1] : ''; + + // Validate token: first check browser security token, then mobile token + let isMobileClient = false; + let mobileDeviceId: string | undefined; + + if (urlToken !== this.securityToken) { + // Not the browser token - try mobile token validation + const device = await validateMobileToken(urlToken); + if (!device) { + // Neither browser nor mobile token - reject + logger.warn(`Auth failed: invalid token from ${request.ip}`, LOG_CONTEXT); + connection.socket.send( + JSON.stringify({ + type: 'error', + message: 'Authentication failed: invalid token', + code: 'AUTH_FAILED', + }) + ); + connection.socket.close(4001, 'Authentication failed'); + return; + } + // Valid mobile token + isMobileClient = true; + mobileDeviceId = device.id; + // Update lastUsedAt in background (don't await) + updateDeviceLastUsed(device.id).catch((err) => { + logger.warn(`Failed to update device lastUsedAt: ${err}`, LOG_CONTEXT); + }); + logger.info(`Mobile client authenticated: ${device.deviceName}`, LOG_CONTEXT); + } - server.get(`/${token}/ws`, { websocket: true }, (connection, request) => { - const clientId = `web-client-${++this.clientIdCounter}`; + const clientId = `${isMobileClient ? 'mobile' : 'web'}-client-${++this.clientIdCounter}`; // Extract sessionId from query string if provided (for session-specific subscriptions) const url = new URL(request.url || '', `http://${request.headers.host || 'localhost'}`); @@ -96,6 +138,8 @@ export class WsRoute { id: clientId, connectedAt: Date.now(), subscribedSessionId: sessionId, + isMobileClient, + mobileDeviceId, }; // Notify parent about connection diff --git a/src/main/web-server/types.ts b/src/main/web-server/types.ts index 698753b0c9..c2796f6913 100644 --- a/src/main/web-server/types.ts +++ b/src/main/web-server/types.ts @@ -224,6 +224,10 @@ export interface WebClient { id: string; connectedAt: number; subscribedSessionId?: string; + /** Whether this is a mobile app client (vs browser) */ + isMobileClient?: boolean; + /** Device ID from mobile-pairings.json for mobile clients */ + mobileDeviceId?: string; } /** From cabd65585aa917f16f68e716f23adcb90ce22e22 Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Fri, 19 Jun 2026 23:39:03 +0100 Subject: [PATCH 04/16] feat(settings): add mobile devices pairing UI Implement MobileDevicesSection component with QR code generation for device pairing, real-time countdown, paired device list, and revocation. Integrated into General settings tab with full search support. --- .../Settings/MobileDevicesSection.tsx | 369 ++++++++++++++++++ .../components/Settings/searchableSettings.ts | 24 ++ .../components/Settings/tabs/GeneralTab.tsx | 6 + 3 files changed, 399 insertions(+) create mode 100644 src/renderer/components/Settings/MobileDevicesSection.tsx diff --git a/src/renderer/components/Settings/MobileDevicesSection.tsx b/src/renderer/components/Settings/MobileDevicesSection.tsx new file mode 100644 index 0000000000..aa1c7b2051 --- /dev/null +++ b/src/renderer/components/Settings/MobileDevicesSection.tsx @@ -0,0 +1,369 @@ +/** + * MobileDevicesSection - Settings section for mobile device pairing + * + * This component provides a UI for: + * - Generating pairing codes with QR display + * - Listing paired mobile devices + * - Revoking paired devices + * + * Part of M3 Mobile Expo App implementation (decision 6A QR pairing). + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { Smartphone, Plus, Trash2, QrCode, Clock, AlertCircle } from 'lucide-react'; +import { QRCodeSVG } from 'qrcode.react'; +import { GhostIconButton } from '../ui/GhostIconButton'; +import { Spinner } from '../ui/Spinner'; +import type { Theme } from '../../types'; +import { formatRelativeTime } from '../../../shared/formatters'; + +interface PairedDevice { + id: string; + deviceName: string; + createdAt: number; + lastUsedAt: number; + expiresAt: number; +} + +export interface MobileDevicesSectionProps { + theme: Theme; +} + +export function MobileDevicesSection({ theme }: MobileDevicesSectionProps) { + // Paired devices state + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Pairing modal state + const [showPairingModal, setShowPairingModal] = useState(false); + const [pairingCode, setPairingCode] = useState(null); + const [pairingHost, setPairingHost] = useState(null); + const [pairingPort, setPairingPort] = useState(null); + const [pairingExpiresAt, setPairingExpiresAt] = useState(null); + const [pairingError, setPairingError] = useState(null); + const [generatingCode, setGeneratingCode] = useState(false); + + // Countdown state + const [secondsRemaining, setSecondsRemaining] = useState(0); + const countdownRef = useRef | null>(null); + + // Polling state for device list refresh while modal is open + const pollRef = useRef | null>(null); + + // Revoke state + const [revokingId, setRevokingId] = useState(null); + + // Load devices on mount + const loadDevices = useCallback(async () => { + try { + const result = await window.maestro.mobilePairing.listDevices(); + if (result.success && result.devices) { + setDevices(result.devices); + setError(null); + } else { + setError(result.error || 'Failed to load devices'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load devices'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadDevices(); + }, [loadDevices]); + + // Start polling when modal is open + useEffect(() => { + if (showPairingModal) { + pollRef.current = setInterval(loadDevices, 2000); + } else if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + return () => { + if (pollRef.current) { + clearInterval(pollRef.current); + } + }; + }, [showPairingModal, loadDevices]); + + // Countdown timer + useEffect(() => { + if (pairingExpiresAt && showPairingModal) { + const updateCountdown = () => { + const remaining = Math.max(0, Math.floor((pairingExpiresAt - Date.now()) / 1000)); + setSecondsRemaining(remaining); + if (remaining === 0) { + // Code expired + setPairingCode(null); + setPairingError('Pairing code expired. Generate a new one.'); + if (countdownRef.current) { + clearInterval(countdownRef.current); + countdownRef.current = null; + } + } + }; + updateCountdown(); + countdownRef.current = setInterval(updateCountdown, 1000); + } + return () => { + if (countdownRef.current) { + clearInterval(countdownRef.current); + countdownRef.current = null; + } + }; + }, [pairingExpiresAt, showPairingModal]); + + // Generate pairing code + const handleGenerateCode = async () => { + setGeneratingCode(true); + setPairingError(null); + try { + const result = await window.maestro.mobilePairing.generateCode(); + if (result.success && result.code && result.host && result.port && result.expiresAt) { + setPairingCode(result.code); + setPairingHost(result.host); + setPairingPort(result.port); + setPairingExpiresAt(result.expiresAt); + } else { + setPairingError(result.error || 'Failed to generate pairing code'); + } + } catch (err) { + setPairingError(err instanceof Error ? err.message : 'Failed to generate pairing code'); + } finally { + setGeneratingCode(false); + } + }; + + // Open pairing modal and generate code + const handleOpenPairingModal = async () => { + setShowPairingModal(true); + await handleGenerateCode(); + }; + + // Close pairing modal + const handleClosePairingModal = () => { + setShowPairingModal(false); + setPairingCode(null); + setPairingHost(null); + setPairingPort(null); + setPairingExpiresAt(null); + setPairingError(null); + setSecondsRemaining(0); + }; + + // Revoke device + const handleRevoke = async (id: string) => { + setRevokingId(id); + try { + const result = await window.maestro.mobilePairing.revokeDevice(id); + if (result.success) { + setDevices((prev) => prev.filter((d) => d.id !== id)); + } + } catch { + // Silently fail, device may have already been revoked + } finally { + setRevokingId(null); + } + }; + + // Format countdown as mm:ss + const formatCountdown = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + // Build QR code payload + const qrPayload = + pairingCode && pairingHost && pairingPort + ? `maestro://pair?host=${encodeURIComponent(pairingHost)}&port=${pairingPort}&code=${pairingCode}` + : ''; + + return ( +
+ {/* Section Header */} +
+
+ + Mobile Devices +
+ +
+ + {/* Description */} +

+ Pair your mobile device with Maestro using QR code scanning. Paired devices can access your + agents via the Maestro mobile app over your local network. +

+ + {/* Loading State */} + {loading && ( +
+ +
+ )} + + {/* Error State */} + {error && !loading && ( +
+ + {error} +
+ )} + + {/* Empty State */} + {!loading && !error && devices.length === 0 && ( +
+ +

No paired devices

+

Click "Pair New Device" to connect your mobile

+
+ )} + + {/* Devices List */} + {!loading && !error && devices.length > 0 && ( +
+ {devices.map((device) => ( +
+
+ +
+
{device.deviceName}
+
+ + Last used {formatRelativeTime(device.lastUsedAt)} +
+
+
+ handleRevoke(device.id)} + disabled={revokingId === device.id} + > + {revokingId === device.id ? ( + + ) : ( + + )} + +
+ ))} +
+ )} + + {/* Pairing Modal */} + {showPairingModal && ( +
+
e.stopPropagation()} + > +
+
+ + Pair New Device +
+ +
+ + {/* Error */} + {pairingError && ( +
+ + {pairingError} +
+ )} + + {/* Generating */} + {generatingCode && !pairingCode && ( +
+ +

Generating pairing code...

+
+ )} + + {/* QR Code Display */} + {pairingCode && qrPayload && ( +
+
+ +
+ +

+ Open the Maestro mobile app and scan this QR code to pair your device. +

+ + {/* Countdown */} +
+ + Expires in {formatCountdown(secondsRemaining)} +
+ + {/* Regenerate button */} + +
+ )} +
+
+ )} +
+ ); +} diff --git a/src/renderer/components/Settings/searchableSettings.ts b/src/renderer/components/Settings/searchableSettings.ts index 3725fa6bda..cfc0f3b903 100644 --- a/src/renderer/components/Settings/searchableSettings.ts +++ b/src/renderer/components/Settings/searchableSettings.ts @@ -383,6 +383,30 @@ export const GENERAL_SETTINGS: SearchableSetting[] = [ 'inactive', ], }, + { + id: 'general-mobile-devices', + tab: 'general', + tabLabel: 'General', + label: 'Mobile Devices', + description: + 'Pair your mobile device with Maestro using QR code scanning. Manage paired devices that can access your agents via the Maestro mobile app.', + keywords: [ + 'mobile', + 'devices', + 'phone', + 'pairing', + 'pair', + 'qr', + 'qr code', + 'scan', + 'app', + 'ios', + 'android', + 'smartphone', + 'revoke', + 'connect', + ], + }, { id: 'general-storage', tab: 'general', diff --git a/src/renderer/components/Settings/tabs/GeneralTab.tsx b/src/renderer/components/Settings/tabs/GeneralTab.tsx index 5f64a3ad9c..94de28c23b 100644 --- a/src/renderer/components/Settings/tabs/GeneralTab.tsx +++ b/src/renderer/components/Settings/tabs/GeneralTab.tsx @@ -48,6 +48,7 @@ import { SettingCheckbox } from '../../SettingCheckbox'; import { ToggleSwitch } from '../../ui/ToggleSwitch'; import { KeyCaptureButton } from '../../ui/KeyCaptureButton'; import { logger } from '../../../utils/logger'; +import { MobileDevicesSection } from '../MobileDevicesSection'; export interface GeneralTabProps { theme: Theme; @@ -1480,6 +1481,11 @@ export function GeneralTab({ theme, isOpen }: GeneralTabProps) { + {/* Mobile Devices - QR pairing for mobile app */} +
+ +
+ {/* Settings Storage Location */}
From 7074112737a5e99353ed1d00fe14bc3f1ea364f9 Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Fri, 19 Jun 2026 23:39:13 +0100 Subject: [PATCH 05/16] refactor(offline-queue): abstract storage for cross-platform support Replace direct localStorage calls with injected StorageAdapter interface. Enables the same hook to work with AsyncStorage in React Native. Add async initialization and factory function for web localStorage adapter. --- .../web/hooks/useOfflineQueue.test.ts | 1785 ----------------- src/__tests__/web/mobile/App.test.tsx | 6 + .../hooks/__tests__/useOfflineQueue.test.ts | 741 +++++++ src/web/hooks/useOfflineQueue.ts | 140 +- src/web/mobile/App.tsx | 6 +- 5 files changed, 861 insertions(+), 1817 deletions(-) delete mode 100644 src/__tests__/web/hooks/useOfflineQueue.test.ts create mode 100644 src/web/hooks/__tests__/useOfflineQueue.test.ts diff --git a/src/__tests__/web/hooks/useOfflineQueue.test.ts b/src/__tests__/web/hooks/useOfflineQueue.test.ts deleted file mode 100644 index 19ed15e01f..0000000000 --- a/src/__tests__/web/hooks/useOfflineQueue.test.ts +++ /dev/null @@ -1,1785 +0,0 @@ -/** - * Tests for useOfflineQueue hook - * - * @fileoverview Comprehensive tests for offline command queueing functionality. - * Tests cover: - * - Pure helper functions (generateId, loadQueue, saveQueue) - * - Hook initialization and state management - * - Command queueing with capacity limits - * - Command removal and queue clearing - * - Queue processing with retries and error handling - * - Pause/resume functionality - * - Auto-processing on connection restore - * - localStorage persistence - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { renderHook, act, waitFor } from '@testing-library/react'; -import { - useOfflineQueue, - QueuedCommand, - QueueStatus, - UseOfflineQueueOptions, - UseOfflineQueueReturn, -} from '../../../web/hooks/useOfflineQueue'; - -// Mock the webLogger module -vi.mock('../../../web/utils/logger', () => ({ - webLogger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})); - -import { webLogger } from '../../../web/utils/logger'; - -const STORAGE_KEY = 'maestro-offline-queue'; -const MAX_QUEUE_SIZE = 50; - -// Mock localStorage with proper implementation -let localStorageStore: Record = {}; - -// Create mock functions that also perform the actual storage operations -const getItemMock = vi.fn().mockImplementation((key: string) => localStorageStore[key] ?? null); -const setItemMock = vi.fn().mockImplementation((key: string, value: string) => { - localStorageStore[key] = value; -}); -const removeItemMock = vi.fn().mockImplementation((key: string) => { - delete localStorageStore[key]; -}); -const clearMock = vi.fn().mockImplementation(() => { - localStorageStore = {}; -}); -const keyMock = vi - .fn() - .mockImplementation((index: number) => Object.keys(localStorageStore)[index] ?? null); - -const localStorageMock = { - getItem: getItemMock, - setItem: setItemMock, - removeItem: removeItemMock, - clear: clearMock, - get length() { - return Object.keys(localStorageStore).length; - }, - key: keyMock, -}; - -Object.defineProperty(window, 'localStorage', { - value: localStorageMock, - writable: true, -}); - -describe('useOfflineQueue', () => { - // Default options for creating the hook - const createDefaultOptions = ( - overrides: Partial = {} - ): UseOfflineQueueOptions => ({ - isOnline: true, - isConnected: true, - sendCommand: vi.fn().mockReturnValue(true), - ...overrides, - }); - - beforeEach(() => { - // Clear localStorage mock store before each test - localStorageStore = {}; - getItemMock.mockClear(); - setItemMock.mockClear(); - clearMock.mockClear(); - removeItemMock.mockClear(); - keyMock.mockClear(); - // Clear all mocks - vi.clearAllMocks(); - // Use fake timers for testing async behavior - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe('Exported Types', () => { - it('should export QueuedCommand interface with required properties', () => { - const command: QueuedCommand = { - id: 'test-id', - command: 'test command', - sessionId: 'session-1', - timestamp: Date.now(), - inputMode: 'ai', - attempts: 0, - }; - - expect(command.id).toBe('test-id'); - expect(command.command).toBe('test command'); - expect(command.sessionId).toBe('session-1'); - expect(command.inputMode).toBe('ai'); - expect(command.attempts).toBe(0); - }); - - it('should export QueuedCommand with optional lastError', () => { - const command: QueuedCommand = { - id: 'test-id', - command: 'test', - sessionId: 'session-1', - timestamp: Date.now(), - inputMode: 'terminal', - attempts: 1, - lastError: 'Connection failed', - }; - - expect(command.lastError).toBe('Connection failed'); - }); - - it('should export QueueStatus as union type', () => { - const statuses: QueueStatus[] = ['idle', 'processing', 'paused']; - expect(statuses).toContain('idle'); - expect(statuses).toContain('processing'); - expect(statuses).toContain('paused'); - }); - }); - - describe('Initial State', () => { - it('should initialize with empty queue when localStorage is empty', () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - expect(result.current.queue).toEqual([]); - expect(result.current.queueLength).toBe(0); - expect(result.current.status).toBe('idle'); - expect(result.current.canQueue).toBe(true); - }); - - it('should load queue from localStorage on initialization', () => { - const storedQueue: QueuedCommand[] = [ - { - id: 'stored-1', - command: 'stored command', - sessionId: 'session-1', - timestamp: 1000, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - expect(result.current.queue).toHaveLength(1); - expect(result.current.queue[0].id).toBe('stored-1'); - expect(result.current.queueLength).toBe(1); - }); - - it('should handle invalid JSON in localStorage gracefully', () => { - localStorage.setItem(STORAGE_KEY, 'invalid json {{{'); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - expect(result.current.queue).toEqual([]); - expect(webLogger.warn).toHaveBeenCalled(); - }); - - it('should handle non-array value in localStorage', () => { - localStorage.setItem(STORAGE_KEY, JSON.stringify({ not: 'an array' })); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - expect(result.current.queue).toEqual([]); - }); - - it('should return all expected API properties', () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - expect(result.current).toHaveProperty('queue'); - expect(result.current).toHaveProperty('queueLength'); - expect(result.current).toHaveProperty('status'); - expect(result.current).toHaveProperty('queueCommand'); - expect(result.current).toHaveProperty('removeCommand'); - expect(result.current).toHaveProperty('clearQueue'); - expect(result.current).toHaveProperty('processQueue'); - expect(result.current).toHaveProperty('pauseProcessing'); - expect(result.current).toHaveProperty('resumeProcessing'); - expect(result.current).toHaveProperty('canQueue'); - }); - }); - - describe('queueCommand', () => { - it('should add a command to the queue', () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - act(() => { - result.current.queueCommand('session-1', 'test command', 'ai'); - }); - - expect(result.current.queue).toHaveLength(1); - expect(result.current.queue[0].command).toBe('test command'); - expect(result.current.queue[0].sessionId).toBe('session-1'); - expect(result.current.queue[0].inputMode).toBe('ai'); - expect(result.current.queue[0].attempts).toBe(0); - }); - - it('should generate unique IDs for each command', () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - act(() => { - result.current.queueCommand('session-1', 'command 1', 'ai'); - result.current.queueCommand('session-1', 'command 2', 'ai'); - }); - - const ids = result.current.queue.map((cmd) => cmd.id); - expect(ids[0]).not.toBe(ids[1]); - }); - - it('should set timestamp on queued commands', () => { - const now = Date.now(); - vi.setSystemTime(now); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - act(() => { - result.current.queueCommand('session-1', 'test', 'ai'); - }); - - expect(result.current.queue[0].timestamp).toBe(now); - }); - - it('should support terminal input mode', () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - act(() => { - result.current.queueCommand('session-1', 'ls -la', 'terminal'); - }); - - expect(result.current.queue[0].inputMode).toBe('terminal'); - }); - - it('should return the queued command', () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - let returnedCommand: QueuedCommand | null = null; - act(() => { - returnedCommand = result.current.queueCommand('session-1', 'test', 'ai'); - }); - - expect(returnedCommand).not.toBeNull(); - expect(returnedCommand!.command).toBe('test'); - }); - - it('should update queueLength', () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - expect(result.current.queueLength).toBe(0); - - act(() => { - result.current.queueCommand('session-1', 'cmd1', 'ai'); - }); - expect(result.current.queueLength).toBe(1); - - act(() => { - result.current.queueCommand('session-1', 'cmd2', 'ai'); - }); - expect(result.current.queueLength).toBe(2); - }); - - it('should persist queue to localStorage', async () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - act(() => { - result.current.queueCommand('session-1', 'persisted', 'ai'); - }); - - // Allow effect to run - await act(async () => { - vi.advanceTimersByTime(0); - }); - - const stored = localStorage.getItem(STORAGE_KEY); - expect(stored).not.toBeNull(); - const parsed = JSON.parse(stored!); - expect(parsed).toHaveLength(1); - expect(parsed[0].command).toBe('persisted'); - }); - - it('should reject commands at max capacity', () => { - // Pre-fill storage with max queue size - const fullQueue: QueuedCommand[] = Array.from({ length: MAX_QUEUE_SIZE }, (_, i) => ({ - id: `cmd-${i}`, - command: `command ${i}`, - sessionId: 'session-1', - timestamp: Date.now(), - inputMode: 'ai' as const, - attempts: 0, - })); - localStorage.setItem(STORAGE_KEY, JSON.stringify(fullQueue)); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - expect(result.current.queue).toHaveLength(MAX_QUEUE_SIZE); - expect(result.current.canQueue).toBe(false); - - let returnedCommand: QueuedCommand | null = null; - act(() => { - returnedCommand = result.current.queueCommand('session-1', 'overflow', 'ai'); - }); - - expect(returnedCommand).toBeNull(); - expect(result.current.queue).toHaveLength(MAX_QUEUE_SIZE); - expect(webLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('maximum capacity'), - 'OfflineQueue' - ); - }); - - it('should allow queueing up to max capacity', () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - // Fill up to just below max - act(() => { - for (let i = 0; i < MAX_QUEUE_SIZE - 1; i++) { - result.current.queueCommand('session-1', `cmd ${i}`, 'ai'); - } - }); - - expect(result.current.canQueue).toBe(true); - - // Add one more to reach exactly max - act(() => { - result.current.queueCommand('session-1', 'last', 'ai'); - }); - - expect(result.current.queue).toHaveLength(MAX_QUEUE_SIZE); - expect(result.current.canQueue).toBe(false); - }); - - it('should log when command is queued', () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - act(() => { - result.current.queueCommand('session-1', 'logged command', 'ai'); - }); - - expect(webLogger.debug).toHaveBeenCalledWith( - expect.stringContaining('Command queued'), - 'OfflineQueue' - ); - }); - - it('should truncate long commands in log message', () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - const longCommand = 'a'.repeat(100); - - act(() => { - result.current.queueCommand('session-1', longCommand, 'ai'); - }); - - const debugCall = vi - .mocked(webLogger.debug) - .mock.calls.find((call) => call[0].includes('Command queued')); - expect(debugCall).toBeDefined(); - // The log message truncates to 50 chars - expect(debugCall![0].length).toBeLessThan(100); - }); - }); - - describe('removeCommand', () => { - it('should remove a command by ID', () => { - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'first', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - { - id: 'cmd-2', - command: 'second', - sessionId: 's1', - timestamp: 2, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - act(() => { - result.current.removeCommand('cmd-1'); - }); - - expect(result.current.queue).toHaveLength(1); - expect(result.current.queue[0].id).toBe('cmd-2'); - }); - - it('should do nothing if ID not found', () => { - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'first', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - act(() => { - result.current.removeCommand('nonexistent'); - }); - - expect(result.current.queue).toHaveLength(1); - }); - - it('should update queueLength after removal', () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - let cmdId = ''; - act(() => { - const cmd = result.current.queueCommand('session-1', 'test', 'ai'); - cmdId = cmd!.id; - }); - - expect(result.current.queueLength).toBe(1); - - act(() => { - result.current.removeCommand(cmdId); - }); - - expect(result.current.queueLength).toBe(0); - }); - - it('should persist removal to localStorage', async () => { - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'first', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - act(() => { - result.current.removeCommand('cmd-1'); - }); - - await act(async () => { - vi.advanceTimersByTime(0); - }); - - const stored = localStorage.getItem(STORAGE_KEY); - expect(JSON.parse(stored!)).toEqual([]); - }); - - it('should log removal', () => { - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'first', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - act(() => { - result.current.removeCommand('cmd-1'); - }); - - expect(webLogger.debug).toHaveBeenCalledWith( - expect.stringContaining('cmd-1'), - 'OfflineQueue' - ); - }); - }); - - describe('clearQueue', () => { - it('should clear all commands', () => { - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'first', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - { - id: 'cmd-2', - command: 'second', - sessionId: 's1', - timestamp: 2, - inputMode: 'ai', - attempts: 0, - }, - { - id: 'cmd-3', - command: 'third', - sessionId: 's1', - timestamp: 3, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - expect(result.current.queue).toHaveLength(3); - - act(() => { - result.current.clearQueue(); - }); - - expect(result.current.queue).toHaveLength(0); - expect(result.current.queueLength).toBe(0); - }); - - it('should update canQueue after clearing', () => { - const fullQueue: QueuedCommand[] = Array.from({ length: MAX_QUEUE_SIZE }, (_, i) => ({ - id: `cmd-${i}`, - command: `command ${i}`, - sessionId: 's1', - timestamp: i, - inputMode: 'ai' as const, - attempts: 0, - })); - localStorage.setItem(STORAGE_KEY, JSON.stringify(fullQueue)); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - expect(result.current.canQueue).toBe(false); - - act(() => { - result.current.clearQueue(); - }); - - expect(result.current.canQueue).toBe(true); - }); - - it('should persist clear to localStorage', async () => { - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'first', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - act(() => { - result.current.clearQueue(); - }); - - await act(async () => { - vi.advanceTimersByTime(0); - }); - - const stored = localStorage.getItem(STORAGE_KEY); - expect(JSON.parse(stored!)).toEqual([]); - }); - - it('should log clear action', () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - act(() => { - result.current.clearQueue(); - }); - - expect(webLogger.debug).toHaveBeenCalledWith('Queue cleared', 'OfflineQueue'); - }); - }); - - describe('processQueue', () => { - it('should not process when offline', async () => { - const sendCommand = vi.fn().mockReturnValue(true); - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'test', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - const { result } = renderHook(() => - useOfflineQueue(createDefaultOptions({ isOnline: false, sendCommand })) - ); - - await act(async () => { - await result.current.processQueue(); - }); - - expect(sendCommand).not.toHaveBeenCalled(); - expect(result.current.queue).toHaveLength(1); - }); - - it('should not process when not connected', async () => { - const sendCommand = vi.fn().mockReturnValue(true); - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'test', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - const { result } = renderHook(() => - useOfflineQueue(createDefaultOptions({ isConnected: false, sendCommand })) - ); - - await act(async () => { - await result.current.processQueue(); - }); - - expect(sendCommand).not.toHaveBeenCalled(); - }); - - it('should not process empty queue', async () => { - const sendCommand = vi.fn().mockReturnValue(true); - const onProcessingStart = vi.fn(); - - const { result } = renderHook(() => - useOfflineQueue(createDefaultOptions({ sendCommand, onProcessingStart })) - ); - - await act(async () => { - await result.current.processQueue(); - }); - - expect(sendCommand).not.toHaveBeenCalled(); - expect(onProcessingStart).not.toHaveBeenCalled(); - }); - - it('should process all commands successfully', async () => { - const sendCommand = vi.fn().mockReturnValue(true); - const onCommandSent = vi.fn(); - const onProcessingComplete = vi.fn(); - - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'first', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - { - id: 'cmd-2', - command: 'second', - sessionId: 's2', - timestamp: 2, - inputMode: 'terminal', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - // Start paused to prevent auto-processing - const { result } = renderHook(() => - useOfflineQueue(createDefaultOptions({ sendCommand, onCommandSent, onProcessingComplete })) - ); - - // Pause to prevent any auto-processing effects - act(() => { - result.current.pauseProcessing(); - }); - - // Resume and immediately process - await act(async () => { - result.current.resumeProcessing(); - // The resume will try to auto-process, but we want manual control - // Clear the auto-process timer and call processQueue ourselves - const processPromise = result.current.processQueue(); - await vi.advanceTimersByTimeAsync(2000); - await processPromise; - }); - - // With auto-processing, sendCommand may be called twice (once by auto, once by manual) - // Just verify we've processed the queue successfully - expect(sendCommand).toHaveBeenCalled(); - expect(sendCommand).toHaveBeenCalledWith('s1', 'first'); - expect(sendCommand).toHaveBeenCalledWith('s2', 'second'); - expect(result.current.queue).toHaveLength(0); - }); - - it('should call onProcessingStart', async () => { - const onProcessingStart = vi.fn(); - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'test', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - // Start paused to prevent auto-processing - const { result } = renderHook(() => - useOfflineQueue(createDefaultOptions({ onProcessingStart })) - ); - - // Pause immediately - act(() => { - result.current.pauseProcessing(); - }); - - // Resume and process - await act(async () => { - result.current.resumeProcessing(); - await vi.advanceTimersByTimeAsync(2000); - }); - - // onProcessingStart should be called at least once - expect(onProcessingStart).toHaveBeenCalled(); - }); - - it('should set status to processing during execution', async () => { - const sendCommand = vi.fn().mockReturnValue(true); - const onProcessingStart = vi.fn(); - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'test', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - // Start paused to prevent auto-processing - const { result } = renderHook(() => - useOfflineQueue(createDefaultOptions({ sendCommand, onProcessingStart })) - ); - - expect(result.current.status).toBe('idle'); - - // Pause immediately - act(() => { - result.current.pauseProcessing(); - }); - - expect(result.current.status).toBe('paused'); - - // Resume - act(() => { - result.current.resumeProcessing(); - }); - - // After resume, the hook will try to process if queue has items - // The status should transition to processing - await act(async () => { - await vi.advanceTimersByTimeAsync(2000); - }); - - // Verify that processing occurred (onProcessingStart was called) - expect(onProcessingStart).toHaveBeenCalled(); - // After processing completes, status should be idle - expect(result.current.status).toBe('idle'); - }); - - it('should retry failed commands up to maxRetries', async () => { - const sendCommand = vi.fn().mockReturnValue(false); - const onCommandFailed = vi.fn(); - - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'fail', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - // Start paused to prevent auto-processing - const { result } = renderHook(() => - useOfflineQueue(createDefaultOptions({ sendCommand, onCommandFailed, maxRetries: 3 })) - ); - - // Pause immediately to prevent auto-processing - act(() => { - result.current.pauseProcessing(); - }); - - // First attempt - await act(async () => { - result.current.resumeProcessing(); - await vi.advanceTimersByTimeAsync(2000); - }); - - // Queue still has command with 1 attempt - expect(result.current.queue).toHaveLength(1); - expect(result.current.queue[0].attempts).toBe(1); - - // Manually trigger second attempt (auto-process won't re-trigger since queue.length didn't change) - await act(async () => { - result.current.processQueue(); - await vi.advanceTimersByTimeAsync(2000); - }); - - expect(result.current.queue).toHaveLength(1); - expect(result.current.queue[0].attempts).toBe(2); - - // Third and final attempt - await act(async () => { - result.current.processQueue(); - await vi.advanceTimersByTimeAsync(2000); - }); - - // After max retries, command should be removed and onCommandFailed called - expect(result.current.queue).toHaveLength(0); - expect(onCommandFailed).toHaveBeenCalled(); - expect(onCommandFailed).toHaveBeenCalledWith( - expect.objectContaining({ id: 'cmd-1', attempts: 3 }), - 'Max retries exceeded' - ); - }); - - it('should handle sendCommand throwing error', async () => { - const sendCommand = vi.fn().mockImplementation(() => { - throw new Error('Network error'); - }); - const onCommandFailed = vi.fn(); - - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'error', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 2, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - const { result } = renderHook(() => - useOfflineQueue(createDefaultOptions({ sendCommand, onCommandFailed, maxRetries: 3 })) - ); - - await act(async () => { - const processPromise = result.current.processQueue(); - await vi.advanceTimersByTimeAsync(1000); - await processPromise; - }); - - expect(onCommandFailed).toHaveBeenCalledWith( - expect.objectContaining({ lastError: 'Network error' }), - 'Network error' - ); - }); - - it('should handle non-Error throws', async () => { - const sendCommand = vi.fn().mockImplementation(() => { - throw 'string error'; - }); - const onCommandFailed = vi.fn(); - - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'test', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 2, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - const { result } = renderHook(() => - useOfflineQueue(createDefaultOptions({ sendCommand, onCommandFailed, maxRetries: 3 })) - ); - - await act(async () => { - const processPromise = result.current.processQueue(); - await vi.advanceTimersByTimeAsync(1000); - await processPromise; - }); - - expect(onCommandFailed).toHaveBeenCalledWith( - expect.objectContaining({ lastError: 'Unknown error' }), - 'Unknown error' - ); - }); - - it('should prevent concurrent processing', async () => { - const sendCommand = vi.fn().mockReturnValue(true); - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'test', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - // Start paused to prevent auto-processing - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions({ sendCommand }))); - - // Pause immediately - act(() => { - result.current.pauseProcessing(); - }); - - // Resume and process - concurrent protection is handled internally - await act(async () => { - result.current.resumeProcessing(); - // Give time for processing to complete - await vi.advanceTimersByTimeAsync(2000); - }); - - // Verify the queue was processed (sendCommand called at least once) - expect(sendCommand).toHaveBeenCalled(); - expect(result.current.queue).toHaveLength(0); - }); - - it('should update sendCommand ref correctly', async () => { - const sendCommand1 = vi.fn().mockReturnValue(true); - const sendCommand2 = vi.fn().mockReturnValue(true); - - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'test', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - // Start paused to prevent auto-processing - const { result, rerender } = renderHook( - ({ sendCommand }) => useOfflineQueue(createDefaultOptions({ sendCommand })), - { initialProps: { sendCommand: sendCommand1 } } - ); - - // Pause immediately - act(() => { - result.current.pauseProcessing(); - }); - - // Update sendCommand while paused - rerender({ sendCommand: sendCommand2 }); - - // Resume and process - await act(async () => { - result.current.resumeProcessing(); - await vi.advanceTimersByTimeAsync(2000); - }); - - // Should use the updated sendCommand (sendCommand2) - expect(sendCommand2).toHaveBeenCalled(); - // Note: sendCommand1 might be called if there was a brief window before pause - expect(result.current.queue).toHaveLength(0); - }); - - it('should log processing progress', async () => { - const sendCommand = vi.fn().mockReturnValue(true); - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'test', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions({ sendCommand }))); - - await act(async () => { - const processPromise = result.current.processQueue(); - await vi.advanceTimersByTimeAsync(1000); - await processPromise; - }); - - expect(webLogger.debug).toHaveBeenCalledWith( - expect.stringContaining('Starting queue processing'), - 'OfflineQueue' - ); - expect(webLogger.debug).toHaveBeenCalledWith( - expect.stringContaining('Processing complete'), - 'OfflineQueue' - ); - }); - - it('should use default maxRetries of 3', async () => { - const sendCommand = vi.fn().mockReturnValue(false); - const onCommandFailed = vi.fn(); - - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'fail', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 2, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - // Don't specify maxRetries - should default to 3 - const { result } = renderHook(() => - useOfflineQueue(createDefaultOptions({ sendCommand, onCommandFailed })) - ); - - await act(async () => { - const processPromise = result.current.processQueue(); - await vi.advanceTimersByTimeAsync(1000); - await processPromise; - }); - - // At attempts=2, one more try reaches 3, then fails permanently - expect(onCommandFailed).toHaveBeenCalled(); - }); - }); - - describe('pauseProcessing', () => { - it('should set status to paused', () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - act(() => { - result.current.pauseProcessing(); - }); - - expect(result.current.status).toBe('paused'); - }); - - it('should prevent processing when paused', async () => { - const sendCommand = vi.fn().mockReturnValue(true); - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'test', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions({ sendCommand }))); - - act(() => { - result.current.pauseProcessing(); - }); - - await act(async () => { - await result.current.processQueue(); - }); - - expect(sendCommand).not.toHaveBeenCalled(); - }); - - it('should pause mid-processing', async () => { - let callCount = 0; - const sendCommand = vi.fn().mockImplementation(() => { - callCount++; - return true; - }); - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'first', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - { - id: 'cmd-2', - command: 'second', - sessionId: 's1', - timestamp: 2, - inputMode: 'ai', - attempts: 0, - }, - { - id: 'cmd-3', - command: 'third', - sessionId: 's1', - timestamp: 3, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions({ sendCommand }))); - - // Start processing but pause after first command - await act(async () => { - const processPromise = result.current.processQueue(); - // Process first command - await vi.advanceTimersByTimeAsync(150); - // Pause before second command completes - result.current.pauseProcessing(); - await vi.advanceTimersByTimeAsync(1000); - await processPromise; - }); - - // First command sent, remaining commands kept in queue - expect(callCount).toBeGreaterThanOrEqual(1); - expect(result.current.status).toBe('paused'); - }); - - it('should log pause action', () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - act(() => { - result.current.pauseProcessing(); - }); - - expect(webLogger.debug).toHaveBeenCalledWith('Processing paused', 'OfflineQueue'); - }); - }); - - describe('resumeProcessing', () => { - it('should set status back to idle when not processing', () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - act(() => { - result.current.pauseProcessing(); - }); - expect(result.current.status).toBe('paused'); - - act(() => { - result.current.resumeProcessing(); - }); - expect(result.current.status).toBe('idle'); - }); - - it('should trigger processing if queue has items', async () => { - const sendCommand = vi.fn().mockReturnValue(true); - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'test', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions({ sendCommand }))); - - act(() => { - result.current.pauseProcessing(); - }); - - await act(async () => { - result.current.resumeProcessing(); - // Let processQueue run - await vi.advanceTimersByTimeAsync(2000); - }); - - expect(sendCommand).toHaveBeenCalled(); - }); - - it('should not trigger processing if offline', async () => { - const sendCommand = vi.fn().mockReturnValue(true); - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'test', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - const { result } = renderHook(() => - useOfflineQueue(createDefaultOptions({ sendCommand, isOnline: false })) - ); - - act(() => { - result.current.pauseProcessing(); - }); - - await act(async () => { - result.current.resumeProcessing(); - await vi.advanceTimersByTimeAsync(2000); - }); - - expect(sendCommand).not.toHaveBeenCalled(); - }); - - it('should not trigger processing if queue is empty', async () => { - const sendCommand = vi.fn().mockReturnValue(true); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions({ sendCommand }))); - - act(() => { - result.current.pauseProcessing(); - }); - - await act(async () => { - result.current.resumeProcessing(); - await vi.advanceTimersByTimeAsync(2000); - }); - - expect(sendCommand).not.toHaveBeenCalled(); - }); - - it('should log resume action', () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - act(() => { - result.current.resumeProcessing(); - }); - - expect(webLogger.debug).toHaveBeenCalledWith('Processing resumed', 'OfflineQueue'); - }); - }); - - describe('Auto-processing on connection restore', () => { - it('should automatically process queue when going online', async () => { - const sendCommand = vi.fn().mockReturnValue(true); - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'test', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - // Start offline - const { result, rerender } = renderHook( - ({ isOnline, isConnected }) => - useOfflineQueue(createDefaultOptions({ sendCommand, isOnline, isConnected })), - { initialProps: { isOnline: false, isConnected: false } } - ); - - expect(sendCommand).not.toHaveBeenCalled(); - - // Go online and connected - rerender({ isOnline: true, isConnected: true }); - - await act(async () => { - // Wait for the 500ms delay + processing time - await vi.advanceTimersByTimeAsync(2000); - }); - - expect(sendCommand).toHaveBeenCalled(); - }); - - it('should not auto-process when paused', async () => { - const sendCommand = vi.fn().mockReturnValue(true); - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'test', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - const { result, rerender } = renderHook( - ({ isOnline, isConnected }) => - useOfflineQueue(createDefaultOptions({ sendCommand, isOnline, isConnected })), - { initialProps: { isOnline: false, isConnected: false } } - ); - - act(() => { - result.current.pauseProcessing(); - }); - - rerender({ isOnline: true, isConnected: true }); - - await act(async () => { - await vi.advanceTimersByTimeAsync(2000); - }); - - expect(sendCommand).not.toHaveBeenCalled(); - }); - - it('should have 500ms delay before auto-processing', async () => { - const sendCommand = vi.fn().mockReturnValue(true); - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'test', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - const { rerender } = renderHook( - ({ isOnline, isConnected }) => - useOfflineQueue(createDefaultOptions({ sendCommand, isOnline, isConnected })), - { initialProps: { isOnline: false, isConnected: false } } - ); - - rerender({ isOnline: true, isConnected: true }); - - // Before 500ms delay - await act(async () => { - await vi.advanceTimersByTimeAsync(400); - }); - expect(sendCommand).not.toHaveBeenCalled(); - - // After 500ms delay - await act(async () => { - await vi.advanceTimersByTimeAsync(200); - }); - expect(sendCommand).toHaveBeenCalled(); - }); - - it('should cleanup timer on unmount', async () => { - const sendCommand = vi.fn().mockReturnValue(true); - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'test', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - const { unmount, rerender } = renderHook( - ({ isOnline, isConnected }) => - useOfflineQueue(createDefaultOptions({ sendCommand, isOnline, isConnected })), - { initialProps: { isOnline: false, isConnected: false } } - ); - - rerender({ isOnline: true, isConnected: true }); - - // Unmount before timer fires - unmount(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(2000); - }); - - // Should not process after unmount - expect(sendCommand).not.toHaveBeenCalled(); - }); - }); - - describe('canQueue computed property', () => { - it('should be true when queue is below max', () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - expect(result.current.canQueue).toBe(true); - }); - - it('should be false when queue is at max', () => { - const fullQueue: QueuedCommand[] = Array.from({ length: MAX_QUEUE_SIZE }, (_, i) => ({ - id: `cmd-${i}`, - command: `command ${i}`, - sessionId: 's1', - timestamp: i, - inputMode: 'ai' as const, - attempts: 0, - })); - localStorage.setItem(STORAGE_KEY, JSON.stringify(fullQueue)); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - expect(result.current.canQueue).toBe(false); - }); - - it('should update when queue changes', () => { - const nearFullQueue: QueuedCommand[] = Array.from({ length: MAX_QUEUE_SIZE - 1 }, (_, i) => ({ - id: `cmd-${i}`, - command: `command ${i}`, - sessionId: 's1', - timestamp: i, - inputMode: 'ai' as const, - attempts: 0, - })); - localStorage.setItem(STORAGE_KEY, JSON.stringify(nearFullQueue)); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - expect(result.current.canQueue).toBe(true); - - act(() => { - result.current.queueCommand('s1', 'last', 'ai'); - }); - - expect(result.current.canQueue).toBe(false); - }); - }); - - describe('Connection loss during processing', () => { - it('should stop processing and keep remaining commands when connection lost', async () => { - let isConnectedValue = true; - const sendCommand = vi.fn().mockImplementation(() => { - // Simulate connection loss after first command - if (sendCommand.mock.calls.length === 1) { - isConnectedValue = false; - } - return isConnectedValue; - }); - - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'first', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - { - id: 'cmd-2', - command: 'second', - sessionId: 's1', - timestamp: 2, - inputMode: 'ai', - attempts: 0, - }, - { - id: 'cmd-3', - command: 'third', - sessionId: 's1', - timestamp: 3, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - const { result, rerender } = renderHook( - ({ isConnected }) => useOfflineQueue(createDefaultOptions({ sendCommand, isConnected })), - { initialProps: { isConnected: true } } - ); - - await act(async () => { - const processPromise = result.current.processQueue(); - await vi.advanceTimersByTimeAsync(150); - // Simulate connection loss - rerender({ isConnected: false }); - await vi.advanceTimersByTimeAsync(1000); - await processPromise; - }); - - // First command succeeded, remaining kept in queue - expect(sendCommand).toHaveBeenCalled(); - expect(result.current.queue.length).toBeGreaterThan(0); - }); - }); - - describe('localStorage error handling', () => { - it('should handle localStorage.setItem throwing', async () => { - const originalSetItem = localStorage.setItem.bind(localStorage); - localStorage.setItem = vi.fn().mockImplementation(() => { - throw new Error('Storage quota exceeded'); - }); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - act(() => { - result.current.queueCommand('s1', 'test', 'ai'); - }); - - // Should still work in memory - expect(result.current.queue).toHaveLength(1); - expect(webLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to save'), - 'OfflineQueue', - expect.any(Error) - ); - - // Restore - localStorage.setItem = originalSetItem; - }); - - it('should handle localStorage.getItem throwing', () => { - const originalGetItem = localStorage.getItem.bind(localStorage); - localStorage.getItem = vi.fn().mockImplementation(() => { - throw new Error('Access denied'); - }); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - // Should initialize with empty queue - expect(result.current.queue).toEqual([]); - expect(webLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to load'), - 'OfflineQueue', - expect.any(Error) - ); - - // Restore - localStorage.getItem = originalGetItem; - }); - }); - - describe('Function reference stability', () => { - it('should maintain stable function references', () => { - const { result, rerender } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - const { - queueCommand: qc1, - removeCommand: rc1, - clearQueue: cq1, - processQueue: pq1, - pauseProcessing: pp1, - resumeProcessing: rp1, - } = result.current; - - rerender(); - - // queueCommand depends on queue.length, so it may change - expect(result.current.removeCommand).toBe(rc1); - expect(result.current.clearQueue).toBe(cq1); - expect(result.current.pauseProcessing).toBe(pp1); - }); - - it('should update queueCommand when queue length changes', () => { - const { result, rerender } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - const qc1 = result.current.queueCommand; - - act(() => { - result.current.queueCommand('s1', 'test', 'ai'); - }); - - // queueCommand depends on queue.length, so reference should change - expect(result.current.queueCommand).not.toBe(qc1); - }); - }); - - describe('Edge cases', () => { - it('should handle empty command string', () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - act(() => { - result.current.queueCommand('s1', '', 'ai'); - }); - - expect(result.current.queue).toHaveLength(1); - expect(result.current.queue[0].command).toBe(''); - }); - - it('should handle special characters in command', () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - const specialCommand = '!@#$%^&*()_+{}[]|\\:";\'<>?,./\n\t emoji: 🚀'; - - act(() => { - result.current.queueCommand('s1', specialCommand, 'ai'); - }); - - expect(result.current.queue[0].command).toBe(specialCommand); - }); - - it('should handle very long command', () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - const longCommand = 'x'.repeat(10000); - - act(() => { - result.current.queueCommand('s1', longCommand, 'ai'); - }); - - expect(result.current.queue[0].command).toBe(longCommand); - }); - - it('should preserve command order', () => { - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - act(() => { - result.current.queueCommand('s1', 'first', 'ai'); - result.current.queueCommand('s1', 'second', 'ai'); - result.current.queueCommand('s1', 'third', 'ai'); - }); - - expect(result.current.queue.map((c) => c.command)).toEqual(['first', 'second', 'third']); - }); - - it('should handle null localStorage return', () => { - // localStorage.getItem returns null when key doesn't exist (default case) - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions())); - - expect(result.current.queue).toEqual([]); - }); - }); - - describe('Mixed success and failure in batch', () => { - it('should handle mix of successful and failed commands', async () => { - let callCount = 0; - const sendCommand = vi.fn().mockImplementation(() => { - callCount++; - // Fail every other command - return callCount % 2 === 1; - }); - const onCommandSent = vi.fn(); - const onCommandFailed = vi.fn(); - const onProcessingComplete = vi.fn(); - - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'first', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - { - id: 'cmd-2', - command: 'second', - sessionId: 's1', - timestamp: 2, - inputMode: 'ai', - attempts: 2, - }, - { - id: 'cmd-3', - command: 'third', - sessionId: 's1', - timestamp: 3, - inputMode: 'ai', - attempts: 0, - }, - { - id: 'cmd-4', - command: 'fourth', - sessionId: 's1', - timestamp: 4, - inputMode: 'ai', - attempts: 2, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - // Start paused to prevent auto-processing - const { result } = renderHook(() => - useOfflineQueue( - createDefaultOptions({ - sendCommand, - onCommandSent, - onCommandFailed, - onProcessingComplete, - maxRetries: 3, - }) - ) - ); - - // Pause immediately - act(() => { - result.current.pauseProcessing(); - }); - - // Resume and let processing run - await act(async () => { - result.current.resumeProcessing(); - await vi.advanceTimersByTimeAsync(10000); - }); - - // Verify callbacks were invoked - // Due to auto-processing retries, we expect both successes and failures - expect(onCommandSent).toHaveBeenCalled(); - expect(onCommandFailed).toHaveBeenCalled(); - expect(onProcessingComplete).toHaveBeenCalled(); - }); - }); - - describe('Multiple sessions', () => { - it('should handle commands for different sessions', async () => { - const sendCommand = vi.fn().mockReturnValue(true); - const storedQueue: QueuedCommand[] = [ - { - id: 'cmd-1', - command: 'for s1', - sessionId: 's1', - timestamp: 1, - inputMode: 'ai', - attempts: 0, - }, - { - id: 'cmd-2', - command: 'for s2', - sessionId: 's2', - timestamp: 2, - inputMode: 'terminal', - attempts: 0, - }, - { - id: 'cmd-3', - command: 'for s3', - sessionId: 's3', - timestamp: 3, - inputMode: 'ai', - attempts: 0, - }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedQueue)); - - const { result } = renderHook(() => useOfflineQueue(createDefaultOptions({ sendCommand }))); - - await act(async () => { - const processPromise = result.current.processQueue(); - await vi.advanceTimersByTimeAsync(2000); - await processPromise; - }); - - expect(sendCommand).toHaveBeenCalledWith('s1', 'for s1'); - expect(sendCommand).toHaveBeenCalledWith('s2', 'for s2'); - expect(sendCommand).toHaveBeenCalledWith('s3', 'for s3'); - }); - }); -}); diff --git a/src/__tests__/web/mobile/App.test.tsx b/src/__tests__/web/mobile/App.test.tsx index 907897ac6a..5603cc3fc2 100644 --- a/src/__tests__/web/mobile/App.test.tsx +++ b/src/__tests__/web/mobile/App.test.tsx @@ -167,6 +167,12 @@ vi.mock('../../../web/hooks/useOfflineQueue', () => ({ clearQueue: mockClearQueue, processQueue: mockProcessQueue, }), + // Mock the localStorage adapter factory that's imported by the App component + createLocalStorageAdapter: () => ({ + getItem: vi.fn(() => Promise.resolve(null)), + setItem: vi.fn(() => Promise.resolve()), + removeItem: vi.fn(() => Promise.resolve()), + }), })); // Mock config diff --git a/src/web/hooks/__tests__/useOfflineQueue.test.ts b/src/web/hooks/__tests__/useOfflineQueue.test.ts new file mode 100644 index 0000000000..eb173e0667 --- /dev/null +++ b/src/web/hooks/__tests__/useOfflineQueue.test.ts @@ -0,0 +1,741 @@ +/** + * Tests for useOfflineQueue hook + * + * This hook provides offline command queueing functionality that stores commands + * typed while offline and automatically sends them when reconnected. + * + * Tests cover: + * - Round-trip queue persistence via storage adapter + * - Load on mount behavior + * - Save on queue change behavior + * - Queue operations (add, remove, clear) + * - Processing behavior + * - Storage adapter injection + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { + useOfflineQueue, + type QueuedCommand, + type StorageAdapter, + createLocalStorageAdapter, +} from '../useOfflineQueue'; + +// Storage key used by the hook +const STORAGE_KEY = 'maestro-offline-queue'; + +// Mock webLogger to avoid console noise +vi.mock('../../utils/logger', () => ({ + webLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +/** + * Create a mock storage adapter that wraps an in-memory store + */ +function createMockStorageAdapter(): { adapter: StorageAdapter; store: Record } { + const store: Record = {}; + const adapter: StorageAdapter = { + getItem: vi.fn((key: string) => Promise.resolve(store[key] ?? null)), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + return Promise.resolve(); + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + return Promise.resolve(); + }), + }; + return { adapter, store }; +} + +describe('useOfflineQueue', () => { + // Default options for hook + const defaultOptions = { + isOnline: true, + isConnected: true, + sendCommand: vi.fn().mockReturnValue(true), + }; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('load on mount', () => { + it('should load queued commands from storage adapter on mount', async () => { + const { adapter, store } = createMockStorageAdapter(); + const storedCommands: QueuedCommand[] = [ + { + id: 'cmd-1', + command: 'test command 1', + sessionId: 'session-1', + timestamp: Date.now(), + inputMode: 'ai', + attempts: 0, + }, + { + id: 'cmd-2', + command: 'test command 2', + sessionId: 'session-1', + timestamp: Date.now(), + inputMode: 'terminal', + attempts: 1, + }, + ]; + store[STORAGE_KEY] = JSON.stringify(storedCommands); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + // Wait for async loading + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.queue).toHaveLength(2); + expect(result.current.queue[0].command).toBe('test command 1'); + expect(result.current.queue[1].command).toBe('test command 2'); + expect(adapter.getItem).toHaveBeenCalledWith(STORAGE_KEY); + }); + + it('should initialize with empty queue when storage is empty', async () => { + const { adapter } = createMockStorageAdapter(); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.queue).toHaveLength(0); + expect(result.current.queueLength).toBe(0); + }); + + it('should initialize with empty queue when storage has invalid JSON', async () => { + const { adapter, store } = createMockStorageAdapter(); + store[STORAGE_KEY] = 'invalid json {{'; + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.queue).toHaveLength(0); + }); + + it('should initialize with empty queue when storage has non-array data', async () => { + const { adapter, store } = createMockStorageAdapter(); + store[STORAGE_KEY] = JSON.stringify({ foo: 'bar' }); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.queue).toHaveLength(0); + }); + + it('should work without storage adapter (no-op persistence)', async () => { + const { result } = renderHook(() => + useOfflineQueue({ ...defaultOptions, isOnline: false, isConnected: false, storage: null }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.queue).toHaveLength(0); + + // Should still be able to queue commands (in-memory) + act(() => { + result.current.queueCommand('session-1', 'test', 'ai'); + }); + + expect(result.current.queueLength).toBe(1); + }); + }); + + describe('save on change', () => { + it('should persist queue to storage when command is added', async () => { + const { adapter, store } = createMockStorageAdapter(); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + // Wait for initialization + await act(async () => { + await vi.runAllTimersAsync(); + }); + + act(() => { + result.current.queueCommand('session-1', 'test command', 'ai'); + }); + + // Allow async effect to run + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(adapter.setItem).toHaveBeenCalled(); + const savedData = JSON.parse(store[STORAGE_KEY]); + expect(savedData).toHaveLength(1); + expect(savedData[0].command).toBe('test command'); + }); + + it('should persist queue to storage when command is removed', async () => { + const { adapter, store } = createMockStorageAdapter(); + const storedCommands: QueuedCommand[] = [ + { + id: 'cmd-1', + command: 'test command 1', + sessionId: 'session-1', + timestamp: Date.now(), + inputMode: 'ai', + attempts: 0, + }, + { + id: 'cmd-2', + command: 'test command 2', + sessionId: 'session-1', + timestamp: Date.now(), + inputMode: 'ai', + attempts: 0, + }, + ]; + store[STORAGE_KEY] = JSON.stringify(storedCommands); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + act(() => { + result.current.removeCommand('cmd-1'); + }); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + const savedData = JSON.parse(store[STORAGE_KEY]); + expect(savedData).toHaveLength(1); + expect(savedData[0].id).toBe('cmd-2'); + }); + + it('should persist empty queue to storage when cleared', async () => { + const { adapter, store } = createMockStorageAdapter(); + const storedCommands: QueuedCommand[] = [ + { + id: 'cmd-1', + command: 'test command', + sessionId: 'session-1', + timestamp: Date.now(), + inputMode: 'ai', + attempts: 0, + }, + ]; + store[STORAGE_KEY] = JSON.stringify(storedCommands); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + act(() => { + result.current.clearQueue(); + }); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + const savedData = JSON.parse(store[STORAGE_KEY]); + expect(savedData).toHaveLength(0); + }); + }); + + describe('round-trip persistence', () => { + it('should survive unmount/remount with queue intact', async () => { + const { adapter, store } = createMockStorageAdapter(); + + // First render: queue some commands + const { result: result1, unmount } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + act(() => { + result1.current.queueCommand('session-1', 'command 1', 'ai'); + result1.current.queueCommand('session-1', 'command 2', 'terminal'); + }); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + // Unmount first hook + unmount(); + + // Second render: queue should be restored + const { result: result2 } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result2.current.queue).toHaveLength(2); + expect(result2.current.queue[0].command).toBe('command 1'); + expect(result2.current.queue[1].command).toBe('command 2'); + }); + }); + + describe('queue operations', () => { + it('should add command to queue', async () => { + const { adapter } = createMockStorageAdapter(); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + act(() => { + const cmd = result.current.queueCommand('session-1', 'test command', 'ai'); + expect(cmd).not.toBeNull(); + expect(cmd!.command).toBe('test command'); + expect(cmd!.sessionId).toBe('session-1'); + expect(cmd!.inputMode).toBe('ai'); + }); + + expect(result.current.queueLength).toBe(1); + }); + + it('should not queue beyond max capacity (50)', async () => { + const { adapter } = createMockStorageAdapter(); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + // Queue 50 commands + for (let i = 0; i < 50; i++) { + act(() => { + result.current.queueCommand('session-1', `command ${i}`, 'ai'); + }); + } + + expect(result.current.queueLength).toBe(50); + expect(result.current.canQueue).toBe(false); + + // Try to queue one more + act(() => { + const cmd = result.current.queueCommand('session-1', 'overflow', 'ai'); + expect(cmd).toBeNull(); + }); + + expect(result.current.queueLength).toBe(50); + }); + + it('should remove specific command', async () => { + const { adapter } = createMockStorageAdapter(); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + let cmdId: string; + act(() => { + const cmd1 = result.current.queueCommand('session-1', 'command 1', 'ai'); + cmdId = cmd1!.id; + result.current.queueCommand('session-1', 'command 2', 'ai'); + }); + + expect(result.current.queueLength).toBe(2); + + act(() => { + result.current.removeCommand(cmdId); + }); + + expect(result.current.queueLength).toBe(1); + expect(result.current.queue[0].command).toBe('command 2'); + }); + + it('should clear all commands', async () => { + const { adapter } = createMockStorageAdapter(); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + act(() => { + result.current.queueCommand('session-1', 'command 1', 'ai'); + result.current.queueCommand('session-1', 'command 2', 'ai'); + }); + + expect(result.current.queueLength).toBe(2); + + act(() => { + result.current.clearQueue(); + }); + + expect(result.current.queueLength).toBe(0); + expect(result.current.queue).toEqual([]); + }); + }); + + describe('queue processing', () => { + it('should process queue when connected', async () => { + const sendCommand = vi.fn().mockReturnValue(true); + const onCommandSent = vi.fn(); + const { adapter, store } = createMockStorageAdapter(); + + // Start disconnected with a queued command + const storedCommands: QueuedCommand[] = [ + { + id: 'cmd-1', + command: 'test command', + sessionId: 'session-1', + timestamp: Date.now(), + inputMode: 'ai', + attempts: 0, + }, + ]; + store[STORAGE_KEY] = JSON.stringify(storedCommands); + + const { result, rerender } = renderHook( + ({ isOnline, isConnected }) => + useOfflineQueue({ + isOnline, + isConnected, + sendCommand, + onCommandSent, + storage: adapter, + }), + { initialProps: { isOnline: false, isConnected: false } } + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.queueLength).toBe(1); + + // Reconnect + rerender({ isOnline: true, isConnected: true }); + + // Allow auto-processing timer to fire + await act(async () => { + await vi.advanceTimersByTimeAsync(600); // 500ms delay + buffer + }); + + // Allow async processing to complete + await act(async () => { + await vi.advanceTimersByTimeAsync(200); // SEND_DELAY + }); + + expect(sendCommand).toHaveBeenCalledWith('session-1', 'test command'); + expect(onCommandSent).toHaveBeenCalled(); + }); + + it('should not process when offline', async () => { + const sendCommand = vi.fn(); + const { adapter, store } = createMockStorageAdapter(); + + const storedCommands: QueuedCommand[] = [ + { + id: 'cmd-1', + command: 'test command', + sessionId: 'session-1', + timestamp: Date.now(), + inputMode: 'ai', + attempts: 0, + }, + ]; + store[STORAGE_KEY] = JSON.stringify(storedCommands); + + const { result } = renderHook(() => + useOfflineQueue({ + isOnline: false, + isConnected: false, + sendCommand, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + await act(async () => { + await result.current.processQueue(); + }); + + expect(sendCommand).not.toHaveBeenCalled(); + }); + + it('should pause and resume processing', async () => { + const { adapter } = createMockStorageAdapter(); + + const { result } = renderHook(() => + useOfflineQueue({ + ...defaultOptions, + isOnline: false, + isConnected: false, + storage: adapter, + }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.status).toBe('idle'); + + act(() => { + result.current.pauseProcessing(); + }); + + expect(result.current.status).toBe('paused'); + + act(() => { + result.current.resumeProcessing(); + }); + + expect(result.current.status).toBe('idle'); + }); + }); + + describe('callbacks', () => { + it('should call onCommandFailed after max retries', async () => { + const sendCommand = vi.fn().mockReturnValue(false); + const onCommandFailed = vi.fn(); + const { adapter, store } = createMockStorageAdapter(); + + const storedCommands: QueuedCommand[] = [ + { + id: 'cmd-1', + command: 'failing command', + sessionId: 'session-1', + timestamp: Date.now(), + inputMode: 'ai', + attempts: 2, // Already tried twice, next is third (max) + }, + ]; + store[STORAGE_KEY] = JSON.stringify(storedCommands); + + renderHook(() => + useOfflineQueue({ + isOnline: true, + isConnected: true, + sendCommand, + maxRetries: 3, + onCommandFailed, + storage: adapter, + }) + ); + + // Wait for storage load + initialization + await act(async () => { + await vi.runAllTimersAsync(); + }); + + // Wait for auto-process timer (500ms) to start processing + await act(async () => { + await vi.advanceTimersByTimeAsync(600); + }); + + // Wait for SEND_DELAY between commands + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + + expect(onCommandFailed).toHaveBeenCalled(); + }); + + it('should call onProcessingStart and onProcessingComplete', async () => { + const onProcessingStart = vi.fn(); + const onProcessingComplete = vi.fn(); + const sendCommand = vi.fn().mockReturnValue(true); + const { adapter, store } = createMockStorageAdapter(); + + const storedCommands: QueuedCommand[] = [ + { + id: 'cmd-1', + command: 'test', + sessionId: 'session-1', + timestamp: Date.now(), + inputMode: 'ai', + attempts: 0, + }, + ]; + store[STORAGE_KEY] = JSON.stringify(storedCommands); + + renderHook(() => + useOfflineQueue({ + isOnline: true, + isConnected: true, + sendCommand, + onProcessingStart, + onProcessingComplete, + storage: adapter, + }) + ); + + // Wait for storage load + initialization + await act(async () => { + await vi.runAllTimersAsync(); + }); + + // Wait for auto-process timer (500ms) to start processing + await act(async () => { + await vi.advanceTimersByTimeAsync(600); + }); + + // Wait for SEND_DELAY between commands + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + + expect(onProcessingStart).toHaveBeenCalled(); + expect(onProcessingComplete).toHaveBeenCalledWith(1, 0); + }); + }); + + describe('createLocalStorageAdapter', () => { + it('should create a localStorage-backed adapter', async () => { + // Mock localStorage + const mockStorage: Record = {}; + vi.spyOn(Storage.prototype, 'getItem').mockImplementation( + (key: string) => mockStorage[key] ?? null + ); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key: string, value: string) => { + mockStorage[key] = value; + }); + vi.spyOn(Storage.prototype, 'removeItem').mockImplementation((key: string) => { + delete mockStorage[key]; + }); + + const adapter = createLocalStorageAdapter(); + expect(adapter).not.toBeNull(); + + // Test the adapter works + await adapter!.setItem('test-key', 'test-value'); + const value = await adapter!.getItem('test-key'); + expect(value).toBe('test-value'); + + await adapter!.removeItem('test-key'); + const removed = await adapter!.getItem('test-key'); + expect(removed).toBeNull(); + }); + }); +}); diff --git a/src/web/hooks/useOfflineQueue.ts b/src/web/hooks/useOfflineQueue.ts index 647853d05c..f93bb3885b 100644 --- a/src/web/hooks/useOfflineQueue.ts +++ b/src/web/hooks/useOfflineQueue.ts @@ -5,11 +5,19 @@ * typed while offline and automatically sends them when reconnected. * * Features: - * - Persists queued commands to localStorage for survival across page reloads + * - Persists queued commands via injected storage adapter for survival across app reloads * - Automatically sends queued commands when connection is restored * - Tracks queue status and provides progress feedback * - Allows manual retry and clearing of queued commands * - Handles partial queue failures gracefully + * + * Storage contract: + * - storage.getItem(key): Promise + * - storage.setItem(key, value): Promise + * - storage.removeItem(key): Promise + * + * When storage is null/undefined, persistence calls are no-ops (useful for testing + * or environments without persistent storage). */ import { useState, useEffect, useCallback, useRef } from 'react'; @@ -24,6 +32,16 @@ const MAX_QUEUE_SIZE = 50; /** Delay between sending queued commands (ms) */ const SEND_DELAY = 100; +/** + * Storage adapter interface for queue persistence. + * Matches the async contract used by AsyncStorage, SecureStore, etc. + */ +export interface StorageAdapter { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; +} + /** * Queued command entry */ @@ -61,6 +79,8 @@ export interface UseOfflineQueueOptions { sendCommand: (sessionId: string, command: string) => boolean; /** Maximum retry attempts per command (default: 3) */ maxRetries?: number; + /** Storage adapter for queue persistence. When null/undefined, persistence is disabled. */ + storage?: StorageAdapter | null; /** Callback when a queued command is successfully sent */ onCommandSent?: (command: QueuedCommand) => void; /** Callback when a queued command fails after all retries */ @@ -109,32 +129,24 @@ function generateId(): string { } /** - * Load queue from localStorage - */ -function loadQueue(): QueuedCommand[] { - try { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) { - const parsed = JSON.parse(stored); - if (Array.isArray(parsed)) { - return parsed; - } - } - } catch (error) { - webLogger.warn('Failed to load queue from storage', 'OfflineQueue', error); - } - return []; -} - -/** - * Save queue to localStorage + * Create a thin Promise wrapper around localStorage for web environments. + * Returns null in environments where localStorage is not available. */ -function saveQueue(queue: QueuedCommand[]): void { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(queue)); - } catch (error) { - webLogger.warn('Failed to save queue to storage', 'OfflineQueue', error); +export function createLocalStorageAdapter(): StorageAdapter | null { + if (typeof window === 'undefined' || typeof localStorage === 'undefined') { + return null; } + return { + getItem: (key: string) => Promise.resolve(localStorage.getItem(key)), + setItem: (key: string, value: string) => { + localStorage.setItem(key, value); + return Promise.resolve(); + }, + removeItem: (key: string) => { + localStorage.removeItem(key); + return Promise.resolve(); + }, + }; } /** @@ -142,13 +154,16 @@ function saveQueue(queue: QueuedCommand[]): void { * * @example * ```tsx - * function MobileApp() { + * // Web usage with localStorage adapter + * function WebApp() { + * const storage = createLocalStorageAdapter(); * const { queue, queueLength, queueCommand, status } = useOfflineQueue({ * isOnline: navigator.onLine, * isConnected: wsState === 'authenticated', * sendCommand: (sessionId, command) => { * return send({ type: 'send_command', sessionId, command }); * }, + * storage, * onCommandSent: (cmd) => { * console.log('Queued command sent:', cmd.command); * }, @@ -172,6 +187,14 @@ function saveQueue(queue: QueuedCommand[]): void { *
* ); * } + * + * // React Native usage with AsyncStorage adapter + * import AsyncStorage from '@react-native-async-storage/async-storage'; + * const asyncStorageAdapter = { + * getItem: AsyncStorage.getItem, + * setItem: AsyncStorage.setItem, + * removeItem: AsyncStorage.removeItem, + * }; * ``` */ export function useOfflineQueue(options: UseOfflineQueueOptions): UseOfflineQueueReturn { @@ -180,6 +203,7 @@ export function useOfflineQueue(options: UseOfflineQueueOptions): UseOfflineQueu isConnected, sendCommand, maxRetries = 3, + storage, onCommandSent, onCommandFailed, onProcessingStart, @@ -187,25 +211,79 @@ export function useOfflineQueue(options: UseOfflineQueueOptions): UseOfflineQueu } = options; // State - const [queue, setQueue] = useState(() => loadQueue()); + const [queue, setQueue] = useState([]); const [status, setStatus] = useState('idle'); + const [isInitialized, setIsInitialized] = useState(false); // Refs for async processing const isProcessingRef = useRef(false); const isPausedRef = useRef(false); const sendCommandRef = useRef(sendCommand); + const storageRef = useRef(storage); - // Keep sendCommand ref up to date + // Keep refs up to date useEffect(() => { sendCommandRef.current = sendCommand; }, [sendCommand]); + useEffect(() => { + storageRef.current = storage; + }, [storage]); + /** - * Save queue to localStorage whenever it changes + * Load queue from storage on mount */ useEffect(() => { - saveQueue(queue); - }, [queue]); + let cancelled = false; + + async function loadQueue() { + if (!storage) { + setIsInitialized(true); + return; + } + + try { + const stored = await storage.getItem(STORAGE_KEY); + if (cancelled) return; + + if (stored) { + const parsed = JSON.parse(stored); + if (Array.isArray(parsed)) { + setQueue(parsed); + } + } + } catch (error) { + webLogger.warn('Failed to load queue from storage', 'OfflineQueue', error); + } + setIsInitialized(true); + } + + loadQueue(); + + return () => { + cancelled = true; + }; + }, [storage]); + + /** + * Save queue to storage whenever it changes (after initialization) + */ + useEffect(() => { + if (!isInitialized) return; + + async function saveQueue() { + const currentStorage = storageRef.current; + if (!currentStorage) return; + + try { + await currentStorage.setItem(STORAGE_KEY, JSON.stringify(queue)); + } catch (error) { + webLogger.warn('Failed to save queue to storage', 'OfflineQueue', error); + } + } + + saveQueue(); + }, [queue, isInitialized]); /** * Queue a command for later sending diff --git a/src/web/mobile/App.tsx b/src/web/mobile/App.tsx index 85ce3cb622..b3ca4b16cc 100644 --- a/src/web/mobile/App.tsx +++ b/src/web/mobile/App.tsx @@ -19,7 +19,7 @@ import { // Command history is no longer used in the mobile UI import { useNotifications } from '../hooks/useNotifications'; import { useUnreadBadge } from '../hooks/useUnreadBadge'; -import { useOfflineQueue } from '../hooks/useOfflineQueue'; +import { useOfflineQueue, createLocalStorageAdapter } from '../hooks/useOfflineQueue'; import { useMobileSessionManagement } from '../hooks/useMobileSessionManagement'; import { useOfflineStatus, useDesktopTheme } from '../main'; import { buildApiUrl } from '../utils/config'; @@ -1079,6 +1079,9 @@ function GroupChatListSheet({ chats, onSelectChat, onNewChat, onClose }: GroupCh ); } +// Storage adapter for offline queue persistence (created once at module load) +const offlineQueueStorage = createLocalStorageAdapter(); + /** * Main mobile app component with WebSocket connection management */ @@ -1823,6 +1826,7 @@ export default function MobileApp() { triggerHaptic(HAPTIC_PATTERNS.success); } }, + storage: offlineQueueStorage, }); // Retry connection handler From a8f793795a48d81f7d8fc6a39b9d1b2f1eb6bf26 Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Fri, 19 Jun 2026 23:39:22 +0100 Subject: [PATCH 06/16] test(web-server): add mobile token authentication test coverage Update WebSocket route tests for wildcard routing pattern. Add comprehensive tests for mobile token validation, auth rejection, and client ID prefixes. --- .../main/web-server/routes/wsRoute.test.ts | 144 +++++++++++++++--- 1 file changed, 122 insertions(+), 22 deletions(-) diff --git a/src/__tests__/main/web-server/routes/wsRoute.test.ts b/src/__tests__/main/web-server/routes/wsRoute.test.ts index b17eb34b1a..28a6afc05c 100644 --- a/src/__tests__/main/web-server/routes/wsRoute.test.ts +++ b/src/__tests__/main/web-server/routes/wsRoute.test.ts @@ -28,6 +28,12 @@ vi.mock('../../../../main/utils/logger', () => ({ }, })); +// Mock mobile-pairing module +vi.mock('../../../../main/mobile-pairing', () => ({ + validateMobileToken: vi.fn().mockResolvedValue(null), + updateDeviceLastUsed: vi.fn().mockResolvedValue(undefined), +})); + /** * Create mock callbacks with all methods as vi.fn() */ @@ -95,6 +101,7 @@ function createMockSocket() { return { readyState: WebSocket.OPEN, send: vi.fn(), + close: vi.fn(), on: vi.fn((event: string, handler: Function) => { if (!eventHandlers.has(event)) { eventHandlers.set(event, []); @@ -121,13 +128,14 @@ function createMockConnection() { /** * Create mock Fastify request */ -function createMockRequest(sessionId?: string) { +function createMockRequest(sessionId?: string, token = 'test-token-123') { const queryString = sessionId ? `?sessionId=${sessionId}` : ''; return { - url: `/test-token/ws${queryString}`, + url: `/${token}/ws${queryString}`, headers: { host: 'localhost:3000', }, + ip: '127.0.0.1', }; } @@ -166,18 +174,18 @@ describe('WsRoute', () => { describe('Route Registration', () => { it('should register WebSocket route with correct path', () => { expect(mockFastify.get).toHaveBeenCalledTimes(1); - expect(mockFastify.routes.has(`GET:/${securityToken}/ws`)).toBe(true); + expect(mockFastify.routes.has('GET:/:token/ws')).toBe(true); }); it('should register route with websocket option', () => { - const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const route = mockFastify.getRoute('GET', '/:token/ws'); expect(route?.options?.websocket).toBe(true); }); }); describe('Connection Handling', () => { it('should generate unique client IDs', () => { - const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const route = mockFastify.getRoute('GET', '/:token/ws'); // Connect first client const conn1 = createMockConnection(); @@ -197,7 +205,7 @@ describe('WsRoute', () => { }); it('should notify parent on client connect', () => { - const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const route = mockFastify.getRoute('GET', '/:token/ws'); const connection = createMockConnection(); route!.handler(connection, createMockRequest()); @@ -211,7 +219,7 @@ describe('WsRoute', () => { }); it('should extract sessionId from query string', () => { - const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const route = mockFastify.getRoute('GET', '/:token/ws'); const connection = createMockConnection(); route!.handler(connection, createMockRequest('session-123')); @@ -223,7 +231,7 @@ describe('WsRoute', () => { }); it('should set subscribedSessionId to undefined when not in query', () => { - const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const route = mockFastify.getRoute('GET', '/:token/ws'); const connection = createMockConnection(); route!.handler(connection, createMockRequest()); @@ -237,7 +245,7 @@ describe('WsRoute', () => { describe('Initial Sync Messages', () => { it('should send connected message', () => { - const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const route = mockFastify.getRoute('GET', '/:token/ws'); const connection = createMockConnection(); route!.handler(connection, createMockRequest('session-123')); @@ -253,7 +261,7 @@ describe('WsRoute', () => { }); it('should send sessions_list with enriched live info', () => { - const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const route = mockFastify.getRoute('GET', '/:token/ws'); const connection = createMockConnection(); route!.handler(connection, createMockRequest()); @@ -270,7 +278,7 @@ describe('WsRoute', () => { }); it('should send theme', () => { - const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const route = mockFastify.getRoute('GET', '/:token/ws'); const connection = createMockConnection(); route!.handler(connection, createMockRequest()); @@ -286,7 +294,7 @@ describe('WsRoute', () => { it('should not send theme when null', () => { (callbacks.getTheme as any).mockReturnValue(null); - const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const route = mockFastify.getRoute('GET', '/:token/ws'); const connection = createMockConnection(); route!.handler(connection, createMockRequest()); @@ -299,7 +307,7 @@ describe('WsRoute', () => { }); it('should send custom_commands', () => { - const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const route = mockFastify.getRoute('GET', '/:token/ws'); const connection = createMockConnection(); route!.handler(connection, createMockRequest()); @@ -314,7 +322,7 @@ describe('WsRoute', () => { }); it('should send autorun_state for running sessions', () => { - const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const route = mockFastify.getRoute('GET', '/:token/ws'); const connection = createMockConnection(); route!.handler(connection, createMockRequest()); @@ -344,7 +352,7 @@ describe('WsRoute', () => { ]) ); - const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const route = mockFastify.getRoute('GET', '/:token/ws'); const connection = createMockConnection(); route!.handler(connection, createMockRequest()); @@ -359,7 +367,7 @@ describe('WsRoute', () => { describe('Message Handling', () => { it('should delegate messages to handleMessage callback', () => { - const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const route = mockFastify.getRoute('GET', '/:token/ws'); const connection = createMockConnection(); route!.handler(connection, createMockRequest()); @@ -373,7 +381,7 @@ describe('WsRoute', () => { }); it('should send error for invalid JSON messages', () => { - const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const route = mockFastify.getRoute('GET', '/:token/ws'); const connection = createMockConnection(); route!.handler(connection, createMockRequest()); @@ -392,7 +400,7 @@ describe('WsRoute', () => { describe('Disconnection Handling', () => { it('should notify parent on client disconnect', () => { - const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const route = mockFastify.getRoute('GET', '/:token/ws'); const connection = createMockConnection(); route!.handler(connection, createMockRequest()); @@ -407,7 +415,7 @@ describe('WsRoute', () => { describe('Error Handling', () => { it('should notify parent on client error', () => { - const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const route = mockFastify.getRoute('GET', '/:token/ws'); const connection = createMockConnection(); route!.handler(connection, createMockRequest()); @@ -428,7 +436,7 @@ describe('WsRoute', () => { const emptyFastify = createMockFastify(); emptyWsRoute.registerRoute(emptyFastify as any); - const route = emptyFastify.getRoute('GET', `/${securityToken}/ws`); + const route = emptyFastify.getRoute('GET', '/:token/ws'); const connection = createMockConnection(); // Should not throw @@ -461,7 +469,7 @@ describe('WsRoute', () => { const partialFastify = createMockFastify(); partialWsRoute.registerRoute(partialFastify as any); - const route = partialFastify.getRoute('GET', `/${securityToken}/ws`); + const route = partialFastify.getRoute('GET', '/:token/ws'); const connection = createMockConnection(); // Should not throw @@ -481,7 +489,7 @@ describe('WsRoute', () => { ]) ); - const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const route = mockFastify.getRoute('GET', '/:token/ws'); const connection = createMockConnection(); route!.handler(connection, createMockRequest()); @@ -494,4 +502,96 @@ describe('WsRoute', () => { expect(autoRunMsgs.map((m: any) => m.sessionId)).toEqual(['session-1', 'session-2']); }); }); + + describe('Mobile Token Authentication', () => { + beforeEach(async () => { + const { validateMobileToken, updateDeviceLastUsed } = + await import('../../../../main/mobile-pairing'); + (validateMobileToken as any).mockClear(); + (updateDeviceLastUsed as any).mockClear(); + }); + + it('should accept valid mobile token', async () => { + const { validateMobileToken, updateDeviceLastUsed } = + await import('../../../../main/mobile-pairing'); + const mockDevice = { id: 'device-123', deviceName: 'iPhone' }; + (validateMobileToken as any).mockResolvedValueOnce(mockDevice); + + const route = mockFastify.getRoute('GET', '/:token/ws'); + const connection = createMockConnection(); + // Use a different token than the security token + await route!.handler(connection, createMockRequest(undefined, 'mobile-token-abc')); + + expect(validateMobileToken).toHaveBeenCalledWith('mobile-token-abc'); + expect(updateDeviceLastUsed).toHaveBeenCalledWith('device-123'); + expect(callbacks.onClientConnect).toHaveBeenCalledWith( + expect.objectContaining({ + isMobileClient: true, + mobileDeviceId: 'device-123', + }) + ); + }); + + it('should reject invalid mobile token', async () => { + const { validateMobileToken } = await import('../../../../main/mobile-pairing'); + (validateMobileToken as any).mockResolvedValueOnce(null); + + const route = mockFastify.getRoute('GET', '/:token/ws'); + const connection = createMockConnection(); + await route!.handler(connection, createMockRequest(undefined, 'invalid-token')); + + expect(validateMobileToken).toHaveBeenCalledWith('invalid-token'); + expect(callbacks.onClientConnect).not.toHaveBeenCalled(); + + const sentMessages = (connection.socket.send as any).mock.calls.map((call: any[]) => + JSON.parse(call[0]) + ); + const errorMsg = sentMessages.find((m: any) => m.type === 'error'); + expect(errorMsg).toBeDefined(); + expect(errorMsg.code).toBe('AUTH_FAILED'); + expect(connection.socket.close).toHaveBeenCalledWith(4001, 'Authentication failed'); + }); + + it('should not call validateMobileToken for valid security token', async () => { + const { validateMobileToken } = await import('../../../../main/mobile-pairing'); + + const route = mockFastify.getRoute('GET', '/:token/ws'); + const connection = createMockConnection(); + // Use the correct security token + await route!.handler(connection, createMockRequest()); + + expect(validateMobileToken).not.toHaveBeenCalled(); + // Browser clients don't have mobile-specific fields + const clientArg = (callbacks.onClientConnect as any).mock.calls[0][0]; + expect(clientArg.isMobileClient).toBeFalsy(); + expect(clientArg.mobileDeviceId).toBeFalsy(); + }); + + it('should use web-client prefix for browser connections', async () => { + const route = mockFastify.getRoute('GET', '/:token/ws'); + const connection = createMockConnection(); + await route!.handler(connection, createMockRequest()); + + expect(callbacks.onClientConnect).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(/^web-client-/), + }) + ); + }); + + it('should use mobile-client prefix for mobile connections', async () => { + const { validateMobileToken } = await import('../../../../main/mobile-pairing'); + (validateMobileToken as any).mockResolvedValueOnce({ id: 'device-1', deviceName: 'Phone' }); + + const route = mockFastify.getRoute('GET', '/:token/ws'); + const connection = createMockConnection(); + await route!.handler(connection, createMockRequest(undefined, 'mobile-token')); + + expect(callbacks.onClientConnect).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(/^mobile-client-/), + }) + ); + }); + }); }); From cce16977f5a0a947e0d402863cc3dd17888935d2 Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Fri, 19 Jun 2026 23:39:32 +0100 Subject: [PATCH 07/16] chore(ci): add mobile app pipeline with path-based triggering Add conditional mobile-checks CI job that runs TypeScript, ESLint, Jest, and expo-doctor only when mobile or shared code changes. Add development scripts for running the Expo app. Exclude apps/ from root ESLint config. --- .github/workflows/ci.yml | 41 ++++++++++ eslint.config.mjs | 1 + package-lock.json | 160 ++++++++++++--------------------------- package.json | 7 +- 4 files changed, 97 insertions(+), 112 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1be048a3d9..948572abb8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,3 +30,44 @@ jobs: cache: 'npm' - run: npm ci - run: npm run test + + # Detect if mobile/shared code changed to conditionally run mobile-checks + changes: + runs-on: ubuntu-latest + outputs: + mobile: ${{ steps.filter.outputs.mobile }} + steps: + - uses: actions/checkout@v6 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + mobile: + - 'apps/mobile/**' + - 'src/shared/**' + - 'src/web/hooks/**' + + # Mobile checks job - runs only when mobile or shared code changes + mobile-checks: + needs: changes + if: needs.changes.outputs.mobile == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/mobile + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: apps/mobile/package-lock.json + - run: npm ci + - name: TypeScript type check + run: npx tsc --noEmit + - name: ESLint + run: npx eslint . + - name: Jest tests + run: npm test + - name: Expo doctor + run: npx expo-doctor diff --git a/eslint.config.mjs b/eslint.config.mjs index f5a7b33b8b..4d5e3bc022 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -22,6 +22,7 @@ export default tseslint.config( 'src/web/public/**', // Service worker and static assets 'src/renderer/public/**', // Static browser scripts (splash, devtools) '.cue-migration-backup-*/**', // Git-ignored migration backup snapshots + 'apps/**', // Sibling apps (mobile) have their own lint config ], }, diff --git a/package-lock.json b/package-lock.json index de25daa825..95b28ec406 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "maestro", - "version": "0.17.0", + "version": "0.17.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maestro", - "version": "0.17.0", + "version": "0.17.1", "hasInstallScript": true, "license": "AGPL 3.0", "dependencies": { @@ -127,7 +127,7 @@ "canvas": "^3.2.0", "concurrently": "^8.2.2", "cross-env": "^7.0.3", - "electron": "^41.5.0", + "electron": "^41.8.0", "electron-builder": "^26.8.1", "electron-devtools-installer": "^4.0.0", "electron-rebuild": "^3.2.9", @@ -990,6 +990,16 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.3.tgz", + "integrity": "sha512-OjKpjB7gohtEjZiq6nDx1egqjZJhGPN1iFOIED+NFhB/MMkXw/XRcHjh1DGXKT5z2W9eW7Jy2UKU3gpjvusFTQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@electron/asar": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", @@ -1126,35 +1136,48 @@ } }, "node_modules/@electron/get": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", - "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", + "integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==", "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", "progress": "^2.0.3", - "semver": "^6.2.0", + "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "engines": { - "node": ">=12" + "node": ">=22.12.0" }, "optionalDependencies": { - "global-agent": "^3.0.0" + "undici": "^7.24.4" } }, - "node_modules/@electron/get/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/@electron/get/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@electron/get/node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" } }, "node_modules/@electron/notarize": { @@ -5302,17 +5325,6 @@ "@types/node": "*" } }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", @@ -9313,22 +9325,22 @@ } }, "node_modules/electron": { - "version": "41.6.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-41.6.0.tgz", - "integrity": "sha512-5UWV5FXkYMzCDV6FvLCa5mzlCBtlX/H1Af27TD5di+4CUCPi0/ZmWPBdSBlYrunsBlUvlcpW8X0NurW4zesQ6g==", + "version": "41.8.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-41.8.0.tgz", + "integrity": "sha512-mQRqdFxB6/EAyA9pPn00EC1KFytTijOQO52zBvl9HypWBvvibC3P4iSrT4CSVVksolOPKZZm8U4Cfjb5IHxZVA==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "@electron/get": "^2.0.0", - "@types/node": "^24.9.0", - "extract-zip": "^2.0.1" + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js" }, "engines": { - "node": ">= 12.20.55" + "node": ">= 22.12.0" } }, "node_modules/electron-builder": { @@ -10456,27 +10468,6 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, "node_modules/extsprintf": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", @@ -10690,16 +10681,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -10943,21 +10924,6 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, "node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -16234,13 +16200,6 @@ "url": "https://github.com/sponsors/jet2jet" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, "node_modules/perfect-freehand": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.3.tgz", @@ -21073,27 +21032,6 @@ "node": ">=12" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, - "node_modules/yauzl/node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index a572600eea..f59d851ff4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,9 @@ "dev:main:prod-data": "tsc -p tsconfig.main.json && npm run build:preload && NODE_ENV=development USE_PROD_DATA=1 electron .", "dev:renderer": "vite", "dev:web": "vite --config vite.config.web.mts", + "dev:mobile:ios": "cd apps/mobile && npx expo run:ios --device 'iPhone 16 Pro'", + "dev:mobile:android": "cd apps/mobile && npx expo run:android", + "dev:mobile:start": "cd apps/mobile && npx expo start --dev-client", "dev:win": "powershell -NoProfile -ExecutionPolicy Bypass -File ./scripts/start-dev.ps1", "build": "npm run build:main && npm run build:preload && npm run build:renderer && npm run build:web && npm run build:cli && npm run build:maestro-p", "build:main": "tsc -p tsconfig.main.json", @@ -43,6 +46,7 @@ "postinstall": "patch-package && electron-rebuild -f -w node-pty,better-sqlite3", "lint": "tsc -p tsconfig.lint.json && tsc -p tsconfig.main.json --noEmit && tsc -p tsconfig.cli.json --noEmit", "lint:eslint": "eslint src/", + "lint:mobile": "cd apps/mobile && npx eslint .", "format": "prettier --write \"src/**/*.{ts,tsx}\"", "format:all": "prettier --write .", "format:check": "prettier --check \"src/**/*.{ts,tsx}\"", @@ -57,6 +61,7 @@ "test:integration": "vitest run --config vitest.integration.config.ts", "test:integration:watch": "vitest --config vitest.integration.config.ts", "test:performance": "vitest run --config vitest.performance.config.mts", + "test:mobile": "cd apps/mobile && npm test", "refresh-speckit": "node scripts/refresh-speckit.mjs", "refresh-openspec": "node scripts/refresh-openspec.mjs", "refresh-bmad": "node scripts/refresh-bmad.mjs" @@ -372,7 +377,7 @@ "canvas": "^3.2.0", "concurrently": "^8.2.2", "cross-env": "^7.0.3", - "electron": "^41.5.0", + "electron": "^41.8.0", "electron-builder": "^26.8.1", "electron-devtools-installer": "^4.0.0", "electron-rebuild": "^3.2.9", From d2e2a27b19f2ceb628ae22c91f34d17afb6b97d1 Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Fri, 19 Jun 2026 23:39:42 +0100 Subject: [PATCH 08/16] feat(mobile): scaffold Expo React Native companion app Initialize apps/mobile with Expo managed workflow. Configure Metro for monorepo symlinks to shared code. Include Jest testing infrastructure and standalone ESLint config. --- .../skills/building-native-ui/SKILL.md | 307 + .../references/animations.md | 189 + .../building-native-ui/references/controls.md | 245 + .../references/form-sheet.md | 251 + .../references/gradients.md | 116 + .../building-native-ui/references/icons.md | 218 + .../building-native-ui/references/media.md | 229 + .../references/route-structure.md | 229 + .../building-native-ui/references/search.md | 237 + .../building-native-ui/references/storage.md | 110 + .../building-native-ui/references/tabs.md | 417 + .../references/toolbar-and-headers.md | 267 + .../references/visual-effects.md | 195 + .../references/webgpu-three.md | 589 + .../references/zoom-transitions.md | 161 + apps/mobile/.agents/skills/uniwind/SKILL.md | 2044 ++ apps/mobile/.claude/launch.json | 18 + apps/mobile/.claude/settings.json | 3 + apps/mobile/.claude/skills/building-native-ui | 1 + apps/mobile/.claude/skills/uniwind | 1 + apps/mobile/.eas/workflows/deploy.yml | 13 + apps/mobile/.eas/workflows/preview-web.yml | 13 + apps/mobile/.env.example | 2 + apps/mobile/.gitattributes | 2 + apps/mobile/.gitignore | 44 + apps/mobile/AGENTS.md | 22 + apps/mobile/CLAUDE.md | 1 + apps/mobile/README.md | 226 + apps/mobile/app.json | 54 + apps/mobile/assets/images/splash-icon.png | Bin 0 -> 17547 bytes apps/mobile/bun.lock | 2149 ++ apps/mobile/eslint.config.js | 67 + apps/mobile/jest.config.js | 10 + apps/mobile/jest.setup.ts | 52 + apps/mobile/metro.config.js | 69 + apps/mobile/package-lock.json | 17422 ++++++++++++++++ apps/mobile/package.json | 78 + apps/mobile/scripts/setup-symlinks.js | 47 + apps/mobile/shims/config.ts | 70 + apps/mobile/shims/logger.ts | 79 + apps/mobile/skills-lock.json | 15 + .../src/__tests__/offlineQueueReplay.test.ts | 357 + .../__tests__/sessionAddedRouting.test.tsx | 120 + apps/mobile/src/__tests__/smoke.test.ts | 14 + apps/mobile/src/app/(settings)/_layout.tsx | 73 + .../src/app/(settings)/capabilities.tsx | 135 + apps/mobile/src/app/(settings)/profile.tsx | 72 + apps/mobile/src/app/(settings)/settings.tsx | 134 + .../__tests__/chatScreensUseRealAgent.test.ts | 54 + apps/mobile/src/app/_layout.tsx | 224 + apps/mobile/src/app/_layout.web.tsx | 44 + apps/mobile/src/app/attachments.tsx | 145 + apps/mobile/src/app/chats.tsx | 237 + apps/mobile/src/app/index.tsx | 120 + apps/mobile/src/app/model-picker.tsx | 65 + apps/mobile/src/app/pair.tsx | 597 + apps/mobile/src/app/session/[sessionId].tsx | 120 + apps/mobile/src/components/AITabStrip.tsx | 171 + .../src/components/ConnectionStatusPill.tsx | 162 + apps/mobile/src/components/blur-raw.tsx | 30 + apps/mobile/src/components/blur-raw.web.tsx | 6 + .../chat/__tests__/streaming-store.test.ts | 97 + .../src/components/chat/chat-context.tsx | 25 + .../src/components/chat/conversation.tsx | 341 + .../src/components/chat/conversation.web.tsx | 189 + apps/mobile/src/components/chat/index.ts | 31 + apps/mobile/src/components/chat/message.tsx | 43 + .../src/components/chat/message.web.tsx | 42 + .../src/components/chat/prompt-input.tsx | 209 + .../src/components/chat/prompt-input.web.tsx | 159 + .../src/components/chat/streaming-message.tsx | 26 + .../src/components/chat/streaming-store.ts | 22 + apps/mobile/src/components/chat/types.ts | 5 + apps/mobile/src/components/drawer-content.tsx | 318 + apps/mobile/src/components/drawer-layout.tsx | 340 + .../mobile/src/components/grabber.android.tsx | 11 + apps/mobile/src/components/grabber.tsx | 3 + apps/mobile/src/components/icon.tsx | 21 + .../src/components/main-header.android.tsx | 1 + .../src/components/main-header.fallback.tsx | 87 + .../mobile/src/components/main-header.ios.tsx | 1 + .../src/components/main-header.swiftui.tsx | 98 + apps/mobile/src/components/main-header.tsx | 12 + .../src/components/markdown/ast-renderer.ts | 159 + .../src/components/markdown/chat-markdown.tsx | 237 + .../src/components/markdown/code-block.tsx | 174 + apps/mobile/src/components/markdown/index.ts | 3 + .../src/components/markdown/markdown.tsx | 46 + .../src/components/markdown/render-rules.tsx | 239 + apps/mobile/src/components/markdown/types.ts | 65 + apps/mobile/src/components/markdown/utils.ts | 294 + apps/mobile/src/components/model-context.tsx | 41 + apps/mobile/src/components/sidebar.tsx | 15 + apps/mobile/src/components/sidebar.web.tsx | 312 + apps/mobile/src/components/symbol-image.tsx | 60 + .../mobile/src/components/touchable-glass.tsx | 122 + apps/mobile/src/components/tw.tsx | 79 + apps/mobile/src/global.css | 114 + .../__tests__/useMaestroConnection.test.ts | 149 + .../hooks/__tests__/useSessionChat.test.ts | 477 + apps/mobile/src/hooks/useMaestroConnection.ts | 197 + .../src/hooks/useMaestroOfflineQueue.ts | 56 + apps/mobile/src/hooks/usePairingCheck.ts | 67 + apps/mobile/src/hooks/useSessionChat.ts | 339 + apps/mobile/src/lib/SessionsContext.tsx | 494 + apps/mobile/src/lib/ToastContext.tsx | 134 + .../src/lib/__tests__/messageRouting.test.ts | 212 + apps/mobile/src/lib/credentials.ts | 75 + apps/mobile/src/lib/useMaestroWebSocket.ts | 514 + .../pairing/__tests__/parseQrPayload.test.ts | 248 + apps/mobile/src/pairing/parseQrPayload.ts | 142 + apps/mobile/src/sf.css | 162 + .../__tests__/asyncStorageAdapter.test.ts | 188 + .../mobile/src/storage/asyncStorageAdapter.ts | 29 + .../__tests__/streamingReconciliation.test.ts | 528 + apps/mobile/src/streaming/index.ts | 16 + .../streaming/reconcileStreamingMessage.ts | 229 + apps/mobile/src/theme/AccentContext.tsx | 97 + apps/mobile/src/utils/mock-chats.ts | 78 + apps/mobile/src/utils/tailwind.ts | 6 + .../src/utils/use-system-background-color.ts | 10 + apps/mobile/tsconfig.json | 15 + apps/mobile/uniwind-types.d.ts | 10 + 123 files changed, 37655 insertions(+) create mode 100644 apps/mobile/.agents/skills/building-native-ui/SKILL.md create mode 100644 apps/mobile/.agents/skills/building-native-ui/references/animations.md create mode 100644 apps/mobile/.agents/skills/building-native-ui/references/controls.md create mode 100644 apps/mobile/.agents/skills/building-native-ui/references/form-sheet.md create mode 100644 apps/mobile/.agents/skills/building-native-ui/references/gradients.md create mode 100644 apps/mobile/.agents/skills/building-native-ui/references/icons.md create mode 100644 apps/mobile/.agents/skills/building-native-ui/references/media.md create mode 100644 apps/mobile/.agents/skills/building-native-ui/references/route-structure.md create mode 100644 apps/mobile/.agents/skills/building-native-ui/references/search.md create mode 100644 apps/mobile/.agents/skills/building-native-ui/references/storage.md create mode 100644 apps/mobile/.agents/skills/building-native-ui/references/tabs.md create mode 100644 apps/mobile/.agents/skills/building-native-ui/references/toolbar-and-headers.md create mode 100644 apps/mobile/.agents/skills/building-native-ui/references/visual-effects.md create mode 100644 apps/mobile/.agents/skills/building-native-ui/references/webgpu-three.md create mode 100644 apps/mobile/.agents/skills/building-native-ui/references/zoom-transitions.md create mode 100644 apps/mobile/.agents/skills/uniwind/SKILL.md create mode 100644 apps/mobile/.claude/launch.json create mode 100644 apps/mobile/.claude/settings.json create mode 120000 apps/mobile/.claude/skills/building-native-ui create mode 120000 apps/mobile/.claude/skills/uniwind create mode 100644 apps/mobile/.eas/workflows/deploy.yml create mode 100644 apps/mobile/.eas/workflows/preview-web.yml create mode 100644 apps/mobile/.env.example create mode 100644 apps/mobile/.gitattributes create mode 100644 apps/mobile/.gitignore create mode 100644 apps/mobile/AGENTS.md create mode 120000 apps/mobile/CLAUDE.md create mode 100644 apps/mobile/README.md create mode 100644 apps/mobile/app.json create mode 100644 apps/mobile/assets/images/splash-icon.png create mode 100644 apps/mobile/bun.lock create mode 100644 apps/mobile/eslint.config.js create mode 100644 apps/mobile/jest.config.js create mode 100644 apps/mobile/jest.setup.ts create mode 100644 apps/mobile/metro.config.js create mode 100644 apps/mobile/package-lock.json create mode 100644 apps/mobile/package.json create mode 100644 apps/mobile/scripts/setup-symlinks.js create mode 100644 apps/mobile/shims/config.ts create mode 100644 apps/mobile/shims/logger.ts create mode 100644 apps/mobile/skills-lock.json create mode 100644 apps/mobile/src/__tests__/offlineQueueReplay.test.ts create mode 100644 apps/mobile/src/__tests__/sessionAddedRouting.test.tsx create mode 100644 apps/mobile/src/__tests__/smoke.test.ts create mode 100644 apps/mobile/src/app/(settings)/_layout.tsx create mode 100644 apps/mobile/src/app/(settings)/capabilities.tsx create mode 100644 apps/mobile/src/app/(settings)/profile.tsx create mode 100644 apps/mobile/src/app/(settings)/settings.tsx create mode 100644 apps/mobile/src/app/__tests__/chatScreensUseRealAgent.test.ts create mode 100644 apps/mobile/src/app/_layout.tsx create mode 100644 apps/mobile/src/app/_layout.web.tsx create mode 100644 apps/mobile/src/app/attachments.tsx create mode 100644 apps/mobile/src/app/chats.tsx create mode 100644 apps/mobile/src/app/index.tsx create mode 100644 apps/mobile/src/app/model-picker.tsx create mode 100644 apps/mobile/src/app/pair.tsx create mode 100644 apps/mobile/src/app/session/[sessionId].tsx create mode 100644 apps/mobile/src/components/AITabStrip.tsx create mode 100644 apps/mobile/src/components/ConnectionStatusPill.tsx create mode 100644 apps/mobile/src/components/blur-raw.tsx create mode 100644 apps/mobile/src/components/blur-raw.web.tsx create mode 100644 apps/mobile/src/components/chat/__tests__/streaming-store.test.ts create mode 100644 apps/mobile/src/components/chat/chat-context.tsx create mode 100644 apps/mobile/src/components/chat/conversation.tsx create mode 100644 apps/mobile/src/components/chat/conversation.web.tsx create mode 100644 apps/mobile/src/components/chat/index.ts create mode 100644 apps/mobile/src/components/chat/message.tsx create mode 100644 apps/mobile/src/components/chat/message.web.tsx create mode 100644 apps/mobile/src/components/chat/prompt-input.tsx create mode 100644 apps/mobile/src/components/chat/prompt-input.web.tsx create mode 100644 apps/mobile/src/components/chat/streaming-message.tsx create mode 100644 apps/mobile/src/components/chat/streaming-store.ts create mode 100644 apps/mobile/src/components/chat/types.ts create mode 100644 apps/mobile/src/components/drawer-content.tsx create mode 100644 apps/mobile/src/components/drawer-layout.tsx create mode 100644 apps/mobile/src/components/grabber.android.tsx create mode 100644 apps/mobile/src/components/grabber.tsx create mode 100644 apps/mobile/src/components/icon.tsx create mode 100644 apps/mobile/src/components/main-header.android.tsx create mode 100644 apps/mobile/src/components/main-header.fallback.tsx create mode 100644 apps/mobile/src/components/main-header.ios.tsx create mode 100644 apps/mobile/src/components/main-header.swiftui.tsx create mode 100644 apps/mobile/src/components/main-header.tsx create mode 100644 apps/mobile/src/components/markdown/ast-renderer.ts create mode 100644 apps/mobile/src/components/markdown/chat-markdown.tsx create mode 100644 apps/mobile/src/components/markdown/code-block.tsx create mode 100644 apps/mobile/src/components/markdown/index.ts create mode 100644 apps/mobile/src/components/markdown/markdown.tsx create mode 100644 apps/mobile/src/components/markdown/render-rules.tsx create mode 100644 apps/mobile/src/components/markdown/types.ts create mode 100644 apps/mobile/src/components/markdown/utils.ts create mode 100644 apps/mobile/src/components/model-context.tsx create mode 100644 apps/mobile/src/components/sidebar.tsx create mode 100644 apps/mobile/src/components/sidebar.web.tsx create mode 100644 apps/mobile/src/components/symbol-image.tsx create mode 100644 apps/mobile/src/components/touchable-glass.tsx create mode 100644 apps/mobile/src/components/tw.tsx create mode 100644 apps/mobile/src/global.css create mode 100644 apps/mobile/src/hooks/__tests__/useMaestroConnection.test.ts create mode 100644 apps/mobile/src/hooks/__tests__/useSessionChat.test.ts create mode 100644 apps/mobile/src/hooks/useMaestroConnection.ts create mode 100644 apps/mobile/src/hooks/useMaestroOfflineQueue.ts create mode 100644 apps/mobile/src/hooks/usePairingCheck.ts create mode 100644 apps/mobile/src/hooks/useSessionChat.ts create mode 100644 apps/mobile/src/lib/SessionsContext.tsx create mode 100644 apps/mobile/src/lib/ToastContext.tsx create mode 100644 apps/mobile/src/lib/__tests__/messageRouting.test.ts create mode 100644 apps/mobile/src/lib/credentials.ts create mode 100644 apps/mobile/src/lib/useMaestroWebSocket.ts create mode 100644 apps/mobile/src/pairing/__tests__/parseQrPayload.test.ts create mode 100644 apps/mobile/src/pairing/parseQrPayload.ts create mode 100644 apps/mobile/src/sf.css create mode 100644 apps/mobile/src/storage/__tests__/asyncStorageAdapter.test.ts create mode 100644 apps/mobile/src/storage/asyncStorageAdapter.ts create mode 100644 apps/mobile/src/streaming/__tests__/streamingReconciliation.test.ts create mode 100644 apps/mobile/src/streaming/index.ts create mode 100644 apps/mobile/src/streaming/reconcileStreamingMessage.ts create mode 100644 apps/mobile/src/theme/AccentContext.tsx create mode 100644 apps/mobile/src/utils/mock-chats.ts create mode 100644 apps/mobile/src/utils/tailwind.ts create mode 100644 apps/mobile/src/utils/use-system-background-color.ts create mode 100644 apps/mobile/tsconfig.json create mode 100644 apps/mobile/uniwind-types.d.ts diff --git a/apps/mobile/.agents/skills/building-native-ui/SKILL.md b/apps/mobile/.agents/skills/building-native-ui/SKILL.md new file mode 100644 index 0000000000..af57aad955 --- /dev/null +++ b/apps/mobile/.agents/skills/building-native-ui/SKILL.md @@ -0,0 +1,307 @@ +--- +name: building-native-ui +description: Complete guide for building beautiful apps with Expo Router. Covers fundamentals, styling, components, navigation, animations, patterns, and native tabs. +version: 1.0.1 +license: MIT +--- + +# Expo UI Guidelines + +## References + +Consult these resources as needed: + +``` +references/ + animations.md Reanimated: entering, exiting, layout, scroll-driven, gestures + controls.md Native iOS: Switch, Slider, SegmentedControl, DateTimePicker, Picker + form-sheet.md Form sheets in expo-router: configuration, footers and background interaction. + gradients.md CSS gradients via experimental_backgroundImage (New Arch only) + icons.md SF Symbols via expo-image (sf: source), names, animations, weights + media.md Camera, audio, video, and file saving + route-structure.md Route conventions, dynamic routes, groups, folder organization + search.md Search bar with headers, useSearch hook, filtering patterns + storage.md SQLite, AsyncStorage, SecureStore + tabs.md NativeTabs, migration from JS tabs, iOS 26 features + toolbar-and-headers.md Stack headers and toolbar buttons, menus, search (iOS only) + visual-effects.md Blur (expo-blur) and liquid glass (expo-glass-effect) + webgpu-three.md 3D graphics, games, GPU visualizations with WebGPU and Three.js + zoom-transitions.md Apple Zoom: fluid zoom transitions with Link.AppleZoom (iOS 18+) +``` + +## Running the App + +**CRITICAL: Always try Expo Go first before creating custom builds.** + +Most Expo apps work in Expo Go without any custom native code. Before running `npx expo run:ios` or `npx expo run:android`: + +1. **Start with Expo Go**: Run `npx expo start` and scan the QR code with Expo Go +2. **Check if features work**: Test your app thoroughly in Expo Go +3. **Only create custom builds when required** - see below + +### When Custom Builds Are Required + +You need `npx expo run:ios/android` or `eas build` ONLY when using: + +- **Local Expo modules** (custom native code in `modules/`) +- **Apple targets** (widgets, app clips, extensions via `@bacons/apple-targets`) +- **Third-party native modules** not included in Expo Go +- **Custom native configuration** that can't be expressed in `app.json` + +### When Expo Go Works + +Expo Go supports a huge range of features out of the box: + +- All `expo-*` packages (camera, location, notifications, etc.) +- Expo Router navigation +- Most UI libraries (reanimated, gesture handler, etc.) +- Push notifications, deep links, and more + +**If you're unsure, try Expo Go first.** Creating custom builds adds complexity, slower iteration, and requires Xcode/Android Studio setup. + +## Code Style + +- Be cautious of unterminated strings. Ensure nested backticks are escaped; never forget to escape quotes correctly. +- Always use import statements at the top of the file. +- Always use kebab-case for file names, e.g. `comment-card.tsx` +- Always remove old route files when moving or restructuring navigation +- Never use special characters in file names +- Configure tsconfig.json with path aliases, and prefer aliases over relative imports for refactors. + +## Routes + +See `./references/route-structure.md` for detailed route conventions. + +- Routes belong in the `app` directory. +- Never co-locate components, types, or utilities in the app directory. This is an anti-pattern. +- Ensure the app always has a route that matches "/", it may be inside a group route. + +## Library Preferences + +- Never use modules removed from React Native such as Picker, WebView, SafeAreaView, or AsyncStorage +- Never use legacy expo-permissions +- `expo-audio` not `expo-av` +- `expo-video` not `expo-av` +- `expo-image` with `source="sf:name"` for SF Symbols, not `expo-symbols` or `@expo/vector-icons` +- `react-native-safe-area-context` not react-native SafeAreaView +- `process.env.EXPO_OS` not `Platform.OS` +- `React.use` not `React.useContext` +- `expo-image` Image component instead of intrinsic element `img` +- `expo-glass-effect` for liquid glass backdrops + +## Responsiveness + +- Always wrap root component in a scroll view for responsiveness +- Use `` instead of `` for smarter safe area insets +- `contentInsetAdjustmentBehavior="automatic"` should be applied to FlatList and SectionList as well +- Use flexbox instead of Dimensions API +- ALWAYS prefer `useWindowDimensions` over `Dimensions.get()` to measure screen size + +## Behavior + +- Use expo-haptics conditionally on iOS to make more delightful experiences +- Use views with built-in haptics like `` from React Native and `@react-native-community/datetimepicker` +- When a route belongs to a Stack, its first child should almost always be a ScrollView with `contentInsetAdjustmentBehavior="automatic"` set +- When adding a `ScrollView` to the page it should almost always be the first component inside the route component +- Prefer `headerSearchBarOptions` in Stack.Screen options to add a search bar +- Use the `` prop on text containing data that could be copied +- Consider formatting large numbers like 1.4M or 38k +- Never use intrinsic elements like 'img' or 'div' unless in a webview or Expo DOM component + +# Styling + +Follow Apple Human Interface Guidelines. + +## General Styling Rules + +- Prefer flex gap over margin and padding styles +- Prefer padding over margin where possible +- Always account for safe area, either with stack headers, tabs, or ScrollView/FlatList `contentInsetAdjustmentBehavior="automatic"` +- Ensure both top and bottom safe area insets are accounted for +- Inline styles not StyleSheet.create unless reusing styles is faster +- Add entering and exiting animations for state changes +- Use `{ borderCurve: 'continuous' }` for rounded corners unless creating a capsule shape +- ALWAYS use a navigation stack title instead of a custom text element on the page +- When padding a ScrollView, use `contentContainerStyle` padding and gap instead of padding on the ScrollView itself (reduces clipping) +- CSS and Tailwind are not supported - use inline styles + +## Text Styling + +- Add the `selectable` prop to every `` element displaying important data or error messages +- Counters should use `{ fontVariant: 'tabular-nums' }` for alignment + +## Shadows + +Use CSS `boxShadow` style prop. NEVER use legacy React Native shadow or elevation styles. + +```tsx + +``` + +'inset' shadows are supported. + +# Navigation + +## Link + +Use `` from 'expo-router' for navigation between routes. + +```tsx +import { Link } from 'expo-router'; + +// Basic link + + +// Wrapping custom components + + ... + +``` + +Whenever possible, include a `` to follow iOS conventions. Add context menus and previews frequently to enhance navigation. + +## Stack + +- ALWAYS use `_layout.tsx` files to define stacks +- Use Stack from 'expo-router/stack' for native navigation stacks + +### Page Title + +Set the page title in Stack.Screen options: + +```tsx + +``` + +## Context Menus + +Add long press context menus to Link components: + +```tsx +import { Link } from 'expo-router'; + + + + + + + + + + + + {}} /> + {}} /> + + +; +``` + +## Link Previews + +Use link previews frequently to enhance navigation: + +```tsx + + + + + + + + +``` + +Link preview can be used with context menus. + +## Modal + +Present a screen as a modal: + +```tsx + +``` + +Prefer this to building a custom modal component. + +## Sheet + +Present a screen as a dynamic form sheet: + +```tsx + +``` + +- Using `contentStyle: { backgroundColor: "transparent" }` makes the background liquid glass on iOS 26+. + +## Common route structure + +A standard app layout with tabs and stacks inside each tab: + +``` +app/ + _layout.tsx — + (index,search)/ + _layout.tsx — + index.tsx — Main list + search.tsx — Search view +``` + +```tsx +// app/_layout.tsx +import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs'; +import { Theme } from '../components/theme'; + +export default function Layout() { + return ( + + + + + + + + + + ); +} +``` + +Create a shared group route so both tabs can push common screens: + +```tsx +// app/(index,search)/_layout.tsx +import { Stack } from 'expo-router/stack'; +import { PlatformColor } from 'react-native'; + +export default function Layout({ segment }) { + const screen = segment.match(/\((.*)\)/)?.[1]!; + const titles: Record = { index: 'Items', search: 'Search' }; + + return ( + + + + + ); +} +``` diff --git a/apps/mobile/.agents/skills/building-native-ui/references/animations.md b/apps/mobile/.agents/skills/building-native-ui/references/animations.md new file mode 100644 index 0000000000..6e7f192199 --- /dev/null +++ b/apps/mobile/.agents/skills/building-native-ui/references/animations.md @@ -0,0 +1,189 @@ +# Animations + +Use Reanimated v4. Avoid React Native's built-in Animated API. + +## Entering and Exiting Animations + +Use Animated.View with entering and exiting animations. Layout animations can animate state changes. + +```tsx +import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; + +function App() { + return ; +} +``` + +## On-Scroll Animations + +Create high-performance scroll animations using Reanimated's hooks: + +```tsx +import Animated, { + useAnimatedRef, + useScrollViewOffset, + useAnimatedStyle, + interpolate, +} from 'react-native-reanimated'; + +function Page() { + const ref = useAnimatedRef(); + const scroll = useScrollViewOffset(ref); + + const style = useAnimatedStyle(() => ({ + opacity: interpolate(scroll.value, [0, 30], [0, 1], 'clamp'), + })); + + return ( + + + + ); +} +``` + +## Common Animation Presets + +### Entering Animations + +- `FadeIn`, `FadeInUp`, `FadeInDown`, `FadeInLeft`, `FadeInRight` +- `SlideInUp`, `SlideInDown`, `SlideInLeft`, `SlideInRight` +- `ZoomIn`, `ZoomInUp`, `ZoomInDown` +- `BounceIn`, `BounceInUp`, `BounceInDown` + +### Exiting Animations + +- `FadeOut`, `FadeOutUp`, `FadeOutDown`, `FadeOutLeft`, `FadeOutRight` +- `SlideOutUp`, `SlideOutDown`, `SlideOutLeft`, `SlideOutRight` +- `ZoomOut`, `ZoomOutUp`, `ZoomOutDown` +- `BounceOut`, `BounceOutUp`, `BounceOutDown` + +### Layout Animations + +- `LinearTransition` — Smooth linear interpolation +- `SequencedTransition` — Sequenced property changes +- `FadingTransition` — Fade between states + +## Customizing Animations + +```tsx + +``` + +### Modifiers + +```tsx +// Duration in milliseconds +FadeIn.duration(300); + +// Delay before starting +FadeIn.delay(100); + +// Spring physics +FadeIn.springify(); +FadeIn.springify().damping(15).stiffness(100); + +// Easing curves +FadeIn.easing(Easing.bezier(0.25, 0.1, 0.25, 1)); + +// Chaining +FadeInDown.duration(400).delay(200).springify(); +``` + +## Shared Value Animations + +For imperative control over animations: + +```tsx +import { useSharedValue, withSpring, withTiming } from 'react-native-reanimated'; + +const offset = useSharedValue(0); + +// Spring animation +offset.value = withSpring(100); + +// Timing animation +offset.value = withTiming(100, { duration: 300 }); + +// Use in styles +const style = useAnimatedStyle(() => ({ + transform: [{ translateX: offset.value }], +})); +``` + +## Gesture Animations + +Combine with React Native Gesture Handler: + +```tsx +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated'; + +function DraggableBox() { + const translateX = useSharedValue(0); + const translateY = useSharedValue(0); + + const gesture = Gesture.Pan() + .onUpdate((e) => { + translateX.value = e.translationX; + translateY.value = e.translationY; + }) + .onEnd(() => { + translateX.value = withSpring(0); + translateY.value = withSpring(0); + }); + + const style = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }, { translateY: translateY.value }], + })); + + return ( + + + + ); +} +``` + +## Keyboard Animations + +Animate with keyboard height changes: + +```tsx +import Animated, { useAnimatedKeyboard, useAnimatedStyle } from 'react-native-reanimated'; + +function KeyboardAwareView() { + const keyboard = useAnimatedKeyboard(); + + const style = useAnimatedStyle(() => ({ + paddingBottom: keyboard.height.value, + })); + + return {/* content */}; +} +``` + +## Staggered List Animations + +Animate list items with delays: + +```tsx +{ + items.map((item, index) => ( + + + + )); +} +``` + +## Best Practices + +- Add entering and exiting animations for state changes +- Use layout animations when items are added/removed from lists +- Use `useAnimatedStyle` for scroll-driven animations +- Prefer `interpolate` with "clamp" for bounded values +- You can't pass PlatformColors to reanimated views or styles; use static colors instead +- Keep animations under 300ms for responsive feel +- Use spring animations for natural movement +- Avoid animating layout properties (width, height) when possible — prefer transforms diff --git a/apps/mobile/.agents/skills/building-native-ui/references/controls.md b/apps/mobile/.agents/skills/building-native-ui/references/controls.md new file mode 100644 index 0000000000..6d6b23ff19 --- /dev/null +++ b/apps/mobile/.agents/skills/building-native-ui/references/controls.md @@ -0,0 +1,245 @@ +# Native Controls + +Native iOS controls provide built-in haptics, accessibility, and platform-appropriate styling. + +## Switch + +Use for binary on/off settings. Has built-in haptics. + +```tsx +import { Switch } from 'react-native'; +import { useState } from 'react'; + +const [enabled, setEnabled] = useState(false); + +; +``` + +### Customization + +```tsx + +``` + +## Segmented Control + +Use for non-navigational tabs or mode selection. Avoid changing default colors. + +```tsx +import SegmentedControl from '@react-native-segmented-control/segmented-control'; +import { useState } from 'react'; + +const [index, setIndex] = useState(0); + + setIndex(nativeEvent.selectedSegmentIndex)} +/>; +``` + +### Rules + +- Maximum 4 options — use a picker for more +- Keep labels short (1-2 words) +- Avoid custom colors — native styling adapts to dark mode + +### With Icons (iOS 14+) + +```tsx + setIndex(nativeEvent.selectedSegmentIndex)} +/> +``` + +## Slider + +Continuous value selection. + +```tsx +import Slider from '@react-native-community/slider'; +import { useState } from 'react'; + +const [value, setValue] = useState(0.5); + +; +``` + +### Customization + +```tsx + +``` + +### Discrete Steps + +```tsx + +``` + +## Date/Time Picker + +Compact pickers with popovers. Has built-in haptics. + +```tsx +import DateTimePicker from '@react-native-community/datetimepicker'; +import { useState } from 'react'; + +const [date, setDate] = useState(new Date()); + + { + if (selectedDate) setDate(selectedDate); + }} + mode="datetime" +/>; +``` + +### Modes + +- `date` — Date only +- `time` — Time only +- `datetime` — Date and time + +### Display Styles + +```tsx +// Compact inline (default) + + +// Spinner wheel + + +// Full calendar + +``` + +### Time Intervals + +```tsx + +``` + +### Min/Max Dates + +```tsx + +``` + +## Stepper + +Increment/decrement numeric values. + +```tsx +import { Stepper } from 'react-native'; +import { useState } from 'react'; + +const [count, setCount] = useState(0); + +; +``` + +## TextInput + +Native text input with various keyboard types. + +```tsx +import { TextInput } from 'react-native'; + +; +``` + +### Keyboard Types + +```tsx +// Email + + +// Phone + + +// Number + + +// Password + + +// Search + +``` + +### Multiline + +```tsx + +``` + +## Picker (Wheel) + +For selection from many options (5+ items). + +```tsx +import { Picker } from '@react-native-picker/picker'; +import { useState } from 'react'; + +const [selected, setSelected] = useState('js'); + + + + + + +; +``` + +## Best Practices + +- **Haptics**: Switch and DateTimePicker have built-in haptics — don't add extra +- **Accessibility**: Native controls have proper accessibility labels by default +- **Dark Mode**: Avoid custom colors — native styling adapts automatically +- **Spacing**: Use consistent padding around controls (12-16pt) +- **Labels**: Place labels above or to the left of controls +- **Grouping**: Group related controls in sections with headers diff --git a/apps/mobile/.agents/skills/building-native-ui/references/form-sheet.md b/apps/mobile/.agents/skills/building-native-ui/references/form-sheet.md new file mode 100644 index 0000000000..88a4144917 --- /dev/null +++ b/apps/mobile/.agents/skills/building-native-ui/references/form-sheet.md @@ -0,0 +1,251 @@ +# Form Sheets in Expo Router + +This skill covers implementing form sheets with footers using Expo Router's Stack navigator and react-native-screens. + +## Overview + +Form sheets are modal presentations that appear as a card sliding up from the bottom of the screen. They're ideal for: + +- Quick actions and confirmations +- Settings panels +- Login/signup flows +- Action sheets with custom content + +**Requirements:** + +- Expo Router Stack navigator + +## Basic Usage + +### Form Sheet with Footer + +Configure the Stack.Screen with transparent backgrounds and sheet presentation: + +```tsx +// app/_layout.tsx +import { Stack } from 'expo-router'; + +export default function Layout() { + return ( + + + + + + + ); +} +``` + +### Form Sheet Screen Content + +> Requires Expo SDK 55 or later. + +Use `flex: 1` to allow the content to fill available space, enabling footer positioning: + +```tsx +// app/about.tsx +import { View, Text, StyleSheet } from 'react-native'; + +export default function AboutSheet() { + return ( + + {/* Main content */} + + Sheet Content + + + {/* Footer - stays at bottom */} + + Footer Content + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + flex: 1, + padding: 16, + }, + footer: { + padding: 16, + }, +}); +``` + +### Formsheet with interactive content below + +Use `sheetLargestUndimmedDetentIndex` (zero-indexed) to keep content behind the form sheet interactive — e.g. letting users pan a map beneath it. Setting it to `1` allows interaction at the first two detents but dims on the third. + +```tsx +// app/_layout.tsx +import { Stack } from 'expo-router'; + +export default function Layout() { + return ( + + + + + ); +} +``` + +## Key Options + +| Option | Type | Description | +| --------------------- | ---------- | ----------------------------------------------------------- | +| `presentation` | `string` | Set to `'formSheet'` for sheet presentation | +| `sheetGrabberVisible` | `boolean` | Shows the drag handle at the top of the sheet | +| `sheetAllowedDetents` | `number[]` | Array of detent heights (0-1 range, e.g., `[0.25]` for 25%) | +| `headerTransparent` | `boolean` | Makes header background transparent | +| `contentStyle` | `object` | Style object for the screen content container | +| `title` | `string` | Screen title (set to `''` for no title) | + +## Common Detent Values + +- `[0.25]` - Quarter sheet (compact actions) +- `[0.5]` - Half sheet (medium content) +- `[0.75]` - Three-quarter sheet (detailed forms) +- `[0.25, 0.5, 1]` - Multiple stops (expandable sheet) + +## Complete Example + +```tsx +// _layout.tsx +import { Stack } from 'expo-router'; + +export default function Layout() { + return ( + + + + + + + + + ); +} +``` + +```tsx +// app/confirm.tsx +import { View, Text, Pressable, StyleSheet } from 'react-native'; +import { router } from 'expo-router'; + +export default function ConfirmSheet() { + return ( + + + Confirm Action + Are you sure you want to proceed? + + + + router.back()}> + Cancel + + router.back()}> + Confirm + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + flex: 1, + padding: 20, + alignItems: 'center', + justifyContent: 'center', + }, + title: { + fontSize: 18, + fontWeight: '600', + marginBottom: 8, + }, + description: { + fontSize: 14, + color: '#666', + textAlign: 'center', + }, + footer: { + flexDirection: 'row', + padding: 16, + gap: 12, + }, + cancelButton: { + flex: 1, + padding: 14, + borderRadius: 10, + backgroundColor: '#f0f0f0', + alignItems: 'center', + }, + cancelText: { + fontSize: 16, + fontWeight: '500', + }, + confirmButton: { + flex: 1, + padding: 14, + borderRadius: 10, + backgroundColor: '#007AFF', + alignItems: 'center', + }, + confirmText: { + fontSize: 16, + fontWeight: '500', + color: 'white', + }, +}); +``` + +## Troubleshooting + +### Content not filling sheet + +Make sure the root View uses `flex: 1`: + +```tsx +{/* content */} +``` + +### Sheet background showing through + +Set `contentStyle: { backgroundColor: 'transparent' }` in options and style your content container with the desired background color instead. diff --git a/apps/mobile/.agents/skills/building-native-ui/references/gradients.md b/apps/mobile/.agents/skills/building-native-ui/references/gradients.md new file mode 100644 index 0000000000..8b3dba9568 --- /dev/null +++ b/apps/mobile/.agents/skills/building-native-ui/references/gradients.md @@ -0,0 +1,116 @@ +# CSS Gradients + +> **New Architecture Only**: CSS gradients require React Native's New Architecture (Fabric). They are not available in the old architecture or Expo Go. + +Use CSS gradients with the `experimental_backgroundImage` style property. + +## Linear Gradients + +```tsx +// Top to bottom + + +// Left to right + + +// Diagonal + + +// Using degrees + +``` + +## Radial Gradients + +```tsx +// Circle at center + + +// Ellipse + + +// Positioned + +``` + +## Multiple Gradients + +Stack multiple gradients by comma-separating them: + +```tsx + +``` + +## Common Patterns + +### Overlay on Image + +```tsx + + + + +``` + +### Frosted Glass Effect + +```tsx + +``` + +### Button Gradient + +```tsx + + Submit + +``` + +## Important Notes + +- Do NOT use `expo-linear-gradient` — use CSS gradients instead +- Gradients are strings, not objects +- Use `rgba()` for transparency, or `transparent` keyword +- Color stops use percentages (0%, 50%, 100%) +- Direction keywords: `to top`, `to bottom`, `to left`, `to right`, `to top left`, etc. +- Degree values: `45deg`, `90deg`, `135deg`, etc. diff --git a/apps/mobile/.agents/skills/building-native-ui/references/icons.md b/apps/mobile/.agents/skills/building-native-ui/references/icons.md new file mode 100644 index 0000000000..95bc764d8f --- /dev/null +++ b/apps/mobile/.agents/skills/building-native-ui/references/icons.md @@ -0,0 +1,218 @@ +# Icons (SF Symbols) + +Use SF Symbols for native feel. Never use FontAwesome or Ionicons. + +## Basic Usage + +```tsx +import { SymbolView } from 'expo-symbols'; +import { PlatformColor } from 'react-native'; + +; +``` + +## Props + +```tsx + +``` + +## Common Icons + +### Navigation & Actions + +- `house.fill` - home +- `gear` - settings +- `magnifyingglass` - search +- `plus` - add +- `xmark` - close +- `chevron.left` - back +- `chevron.right` - forward +- `arrow.left` - back arrow +- `arrow.right` - forward arrow + +### Media + +- `play.fill` - play +- `pause.fill` - pause +- `stop.fill` - stop +- `backward.fill` - rewind +- `forward.fill` - fast forward +- `speaker.wave.2.fill` - volume +- `speaker.slash.fill` - mute + +### Camera + +- `camera` - camera +- `camera.fill` - camera filled +- `arrow.triangle.2.circlepath` - flip camera +- `photo` - gallery/photos +- `bolt` - flash +- `bolt.slash` - flash off + +### Communication + +- `message` - message +- `message.fill` - message filled +- `envelope` - email +- `envelope.fill` - email filled +- `phone` - phone +- `phone.fill` - phone filled +- `video` - video call +- `video.fill` - video call filled + +### Social + +- `heart` - like +- `heart.fill` - liked +- `star` - favorite +- `star.fill` - favorited +- `hand.thumbsup` - thumbs up +- `hand.thumbsdown` - thumbs down +- `person` - profile +- `person.fill` - profile filled +- `person.2` - people +- `person.2.fill` - people filled + +### Content Actions + +- `square.and.arrow.up` - share +- `square.and.arrow.down` - download +- `doc.on.doc` - copy +- `trash` - delete +- `pencil` - edit +- `folder` - folder +- `folder.fill` - folder filled +- `bookmark` - bookmark +- `bookmark.fill` - bookmarked + +### Status & Feedback + +- `checkmark` - success/done +- `checkmark.circle.fill` - completed +- `xmark.circle.fill` - error/failed +- `exclamationmark.triangle` - warning +- `info.circle` - info +- `questionmark.circle` - help +- `bell` - notification +- `bell.fill` - notification filled + +### Misc + +- `ellipsis` - more options +- `ellipsis.circle` - more in circle +- `line.3.horizontal` - menu/hamburger +- `slider.horizontal.3` - filters +- `arrow.clockwise` - refresh +- `location` - location +- `location.fill` - location filled +- `map` - map +- `mappin` - pin +- `clock` - time +- `calendar` - calendar +- `link` - link +- `nosign` - block/prohibited + +## Animated Symbols + +```tsx + +``` + +### Animation Effects + +- `bounce` - Bouncy animation +- `pulse` - Pulsing effect +- `variableColor` - Color cycling +- `scale` - Scale animation + +```tsx +// Bounce with direction +animationSpec={{ + effect: { type: "bounce", direction: "up" } // up | down +}} + +// Pulse +animationSpec={{ + effect: { type: "pulse" } +}} + +// Variable color (multicolor symbols) +animationSpec={{ + effect: { + type: "variableColor", + cumulative: true, + reversing: true + } +}} +``` + +## Symbol Weights + +```tsx +// Lighter weights + + + + +// Default + + +// Heavier weights + + + + + +``` + +## Symbol Scales + +```tsx + + // default + +``` + +## Multicolor Symbols + +Some symbols support multiple colors: + +```tsx + +``` + +## Finding Symbol Names + +1. Use the SF Symbols app on macOS (free from Apple) +2. Search at https://developer.apple.com/sf-symbols/ +3. Symbol names use dot notation: `square.and.arrow.up` + +## Best Practices + +- Always use SF Symbols over vector icon libraries +- Match symbol weight to nearby text weight +- Use `.fill` variants for selected/active states +- Use PlatformColor for tint to support dark mode +- Keep icons at consistent sizes (16, 20, 24, 32) diff --git a/apps/mobile/.agents/skills/building-native-ui/references/media.md b/apps/mobile/.agents/skills/building-native-ui/references/media.md new file mode 100644 index 0000000000..212d648d7a --- /dev/null +++ b/apps/mobile/.agents/skills/building-native-ui/references/media.md @@ -0,0 +1,229 @@ +# Media + +## Camera + +- Hide navigation headers when there's a full screen camera +- Ensure to flip the camera with `mirror` to emulate social apps +- Use liquid glass buttons on cameras +- Icons: `arrow.triangle.2.circlepath` (flip), `photo` (gallery), `bolt` (flash) +- Eagerly request camera permission +- Lazily request media library permission + +```tsx +import React, { useRef, useState } from 'react'; +import { View, TouchableOpacity, Text, Alert } from 'react-native'; +import { CameraView, CameraType, useCameraPermissions } from 'expo-camera'; +import * as MediaLibrary from 'expo-media-library'; +import * as ImagePicker from 'expo-image-picker'; +import * as Haptics from 'expo-haptics'; +import { SymbolView } from 'expo-symbols'; +import { PlatformColor } from 'react-native'; +import { GlassView } from 'expo-glass-effect'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +function Camera({ onPicture }: { onPicture: (uri: string) => Promise }) { + const [permission, requestPermission] = useCameraPermissions(); + const cameraRef = useRef(null); + const [type, setType] = useState('back'); + const { bottom } = useSafeAreaInsets(); + + if (!permission?.granted) { + return ( + + + Camera access is required + + + + Grant Permission + + + + ); + } + + const takePhoto = async () => { + await Haptics.selectionAsync(); + if (!cameraRef.current) return; + const photo = await cameraRef.current.takePictureAsync({ quality: 0.8 }); + await onPicture(photo.uri); + }; + + const selectPhoto = async () => { + await Haptics.selectionAsync(); + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: 'images', + allowsEditing: false, + quality: 0.8, + }); + if (!result.canceled && result.assets?.[0]) { + await onPicture(result.assets[0].uri); + } + }; + + return ( + + + + + + + + + setType((t) => (t === 'back' ? 'front' : 'back'))} + icon="arrow.triangle.2.circlepath" + /> + + + + ); +} +``` + +## Audio Playback + +Use `expo-audio` not `expo-av`: + +```tsx +import { useAudioPlayer } from 'expo-audio'; + +const player = useAudioPlayer({ uri: 'https://stream.nightride.fm/rektory.mp3' }); + +
)} - {/* Pairing Modal */} {showPairingModal && ( -
+ + )} +
+ ); +} + +interface PairingModalProps { + theme: Theme; + pairingCode: string | null; + qrPayload: string; + pairingError: string | null; + generatingCode: boolean; + secondsRemaining: number; + onClose: () => void; + onRegenerate: () => void; + formatCountdown: (seconds: number) => string; +} + +function PairingModal({ + theme, + pairingCode, + qrPayload, + pairingError, + generatingCode, + secondsRemaining, + onClose, + onRegenerate, + formatCountdown, +}: PairingModalProps) { + // Register with the layer stack so Escape closes the dialog, focus is + // trapped, and lower layers stop receiving keyboard events. + useModalLayer(MODAL_PRIORITIES.MOBILE_PAIRING, 'Pair New Device', onClose); + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label="Pair New Device" + > +
+
+ + Pair New Device +
+ +
+ + {pairingError && (
e.stopPropagation()} + className="flex items-center gap-2 p-3 rounded-md text-sm mb-4 select-text" + style={{ backgroundColor: `${theme.colors.error}20`, color: theme.colors.error }} > -
-
- - Pair New Device -
- -
+ + {pairingError} +
+ )} - {/* Error */} - {pairingError && ( -
- - {pairingError} -
- )} + {generatingCode && !pairingCode && ( +
+ +

Generating pairing code...

+
+ )} - {/* Generating */} - {generatingCode && !pairingCode && ( -
- -

Generating pairing code...

-
- )} + {pairingCode && qrPayload && ( +
+
+ +
- {/* QR Code Display */} - {pairingCode && qrPayload && ( -
-
- -
+

+ Open the Maestro mobile app and scan this QR code to pair your device. +

-

- Open the Maestro mobile app and scan this QR code to pair your device. -

- - {/* Countdown */} -
- - Expires in {formatCountdown(secondsRemaining)} -
+
+ + Expires in {formatCountdown(secondsRemaining)} +
- {/* Regenerate button */} - -
- )} +
-
- )} + )} +
); } diff --git a/src/renderer/constants/modalPriorities.ts b/src/renderer/constants/modalPriorities.ts index 0843c8e637..66ad0f912b 100644 --- a/src/renderer/constants/modalPriorities.ts +++ b/src/renderer/constants/modalPriorities.ts @@ -249,6 +249,10 @@ export const MODAL_PRIORITIES = { /** SSH Remote configuration modal (above settings) */ SSH_REMOTE: 458, + /** Mobile device pairing modal (above settings, Escape closes the pairing dialog + * first and leaves Settings open). */ + MOBILE_PAIRING: 459, + /** Custom theme base-theme picker dropdown (above settings so Escape closes * the dropdown first, leaving the Settings modal open for a second Esc). */ CUSTOM_THEME_BASE_SELECTOR: 451, From b5f103a88e04d8609dc2b862c5cd8bc7d793116d Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Sun, 21 Jun 2026 10:27:24 +0100 Subject: [PATCH 11/16] fix(mobile): address CodeRabbit review feedback on mobile PR - ci.yml: add top-level least-privilege permissions and persist-credentials: false on checkouts - skills/building-native-ui: label fenced code blocks with `text` to satisfy MD040 - skills/building-native-ui: rewrite "Running the App" to require a custom dev build (per apps/mobile/CLAUDE.md), since Expo Go does not support this app's native modules - prompt-input.web.tsx: forward PromptInputAction children into PromptInputBody so they actually render - prompt-input.web.tsx: gate Enter-to-send by input.trim() and isGenerating, matching the submit button Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 11 +++++ .../skills/building-native-ui/SKILL.md | 41 +++++++----------- .../references/route-structure.md | 42 +++++++++---------- .../building-native-ui/references/tabs.md | 2 +- .../src/components/chat/prompt-input.web.tsx | 30 +++++++++---- 5 files changed, 70 insertions(+), 56 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 948572abb8..716b3a2aa0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,11 +6,16 @@ on: push: branches: [main, rc] +permissions: + contents: read + jobs: lint-and-format: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - uses: actions/setup-node@v6 with: node-version: '22' @@ -24,6 +29,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - uses: actions/setup-node@v6 with: node-version: '22' @@ -38,6 +45,8 @@ jobs: mobile: ${{ steps.filter.outputs.mobile }} steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - uses: dorny/paths-filter@v3 id: filter with: @@ -57,6 +66,8 @@ jobs: working-directory: apps/mobile steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - uses: actions/setup-node@v6 with: node-version: '22' diff --git a/apps/mobile/.agents/skills/building-native-ui/SKILL.md b/apps/mobile/.agents/skills/building-native-ui/SKILL.md index af57aad955..a1e22afd38 100644 --- a/apps/mobile/.agents/skills/building-native-ui/SKILL.md +++ b/apps/mobile/.agents/skills/building-native-ui/SKILL.md @@ -11,7 +11,7 @@ license: MIT Consult these resources as needed: -``` +```text references/ animations.md Reanimated: entering, exiting, layout, scroll-driven, gestures controls.md Native iOS: Switch, Slider, SegmentedControl, DateTimePicker, Picker @@ -31,33 +31,24 @@ references/ ## Running the App -**CRITICAL: Always try Expo Go first before creating custom builds.** +**CRITICAL: This app requires a custom Expo development build and will not work in Expo Go.** -Most Expo apps work in Expo Go without any custom native code. Before running `npx expo run:ios` or `npx expo run:android`: +Do not attempt to run this app with the Expo Go client - it relies on native modules and configuration that Expo Go does not ship with. Use a custom dev build instead: -1. **Start with Expo Go**: Run `npx expo start` and scan the QR code with Expo Go -2. **Check if features work**: Test your app thoroughly in Expo Go -3. **Only create custom builds when required** - see below +1. **iOS**: `npx expo run:ios` (or `npx serve-sim` for the simulator verification flow used in this repo) +2. **Android**: `npx expo run:android` +3. **Web**: `npx agent-browser` -### When Custom Builds Are Required +### Why a Custom Build Is Required -You need `npx expo run:ios/android` or `eas build` ONLY when using: +This project pulls in capabilities Expo Go does not support, including: - **Local Expo modules** (custom native code in `modules/`) - **Apple targets** (widgets, app clips, extensions via `@bacons/apple-targets`) -- **Third-party native modules** not included in Expo Go -- **Custom native configuration** that can't be expressed in `app.json` - -### When Expo Go Works - -Expo Go supports a huge range of features out of the box: +- **Third-party native modules** not bundled with Expo Go +- **Custom native configuration** that can't be expressed in `app.json` alone -- All `expo-*` packages (camera, location, notifications, etc.) -- Expo Router navigation -- Most UI libraries (reanimated, gesture handler, etc.) -- Push notifications, deep links, and more - -**If you're unsure, try Expo Go first.** Creating custom builds adds complexity, slower iteration, and requires Xcode/Android Studio setup. +If a build fails, fix the native config or run `npx expo prebuild` - do not fall back to Expo Go. ## Code Style @@ -246,13 +237,13 @@ Present a screen as a dynamic form sheet: A standard app layout with tabs and stacks inside each tab: -``` +```text app/ - _layout.tsx — + _layout.tsx - (index,search)/ - _layout.tsx — - index.tsx — Main list - search.tsx — Search view + _layout.tsx - + index.tsx - Main list + search.tsx - Search view ``` ```tsx diff --git a/apps/mobile/.agents/skills/building-native-ui/references/route-structure.md b/apps/mobile/.agents/skills/building-native-ui/references/route-structure.md index 66a5eb1544..83a7e681af 100644 --- a/apps/mobile/.agents/skills/building-native-ui/references/route-structure.md +++ b/apps/mobile/.agents/skills/building-native-ui/references/route-structure.md @@ -15,7 +15,7 @@ Use square brackets for dynamic segments: -``` +```text app/ users/ [id].tsx # Matches /users/123, /users/abc @@ -27,7 +27,7 @@ app/ Use `[...slug]` for catch-all routes: -``` +```text app/ docs/ [...slug].tsx # Matches /docs/a, /docs/a/b, /docs/a/b/c @@ -66,7 +66,7 @@ function Component() { Use parentheses for groups that don't affect the URL: -``` +```text app/ (auth)/ login.tsx # URL: /login @@ -92,30 +92,30 @@ When an app has tabs, the header and title should be set in a Stack that is nest Example structure: -``` +```text app/ - _layout.tsx — + _layout.tsx - (home)/ - _layout.tsx — - index.tsx — + _layout.tsx - + index.tsx - (settings)/ - _layout.tsx — - index.tsx — + _layout.tsx - + index.tsx - (home,settings)/ - info.tsx — (shared across tabs) + info.tsx - (shared across tabs) ``` ## Array Routes for Multiple Stacks Use array routes '(index,settings)' to create multiple stacks. This is useful for tabs that need to share screens across stacks. -``` +```text app/ - _layout.tsx — + _layout.tsx - (index,settings)/ - _layout.tsx — - index.tsx — - settings.tsx — + _layout.tsx - + index.tsx - + settings.tsx - ``` This requires a specialized layout with explicit anchor routes: @@ -152,14 +152,14 @@ export default function Layout({ segment }: { segment: string }) { ## Complete App Structure Example -``` +```text app/ - _layout.tsx — + _layout.tsx - (index,search)/ - _layout.tsx — - index.tsx — Main list - search.tsx — Search view - i/[id].tsx — Detail page + _layout.tsx - + index.tsx - Main list + search.tsx - Search view + i/[id].tsx - Detail page components/ theme.tsx list.tsx diff --git a/apps/mobile/.agents/skills/building-native-ui/references/tabs.md b/apps/mobile/.agents/skills/building-native-ui/references/tabs.md index 8cab43d55c..ceb5888a01 100644 --- a/apps/mobile/.agents/skills/building-native-ui/references/tabs.md +++ b/apps/mobile/.agents/skills/building-native-ui/references/tabs.md @@ -302,7 +302,7 @@ export default function HomeStack() { Use platform-specific files for separate native and web tab layouts: -``` +```text app/ _layout.tsx # NativeTabs for iOS/Android _layout.web.tsx # Headless tabs for web (expo-router/ui) diff --git a/apps/mobile/src/components/chat/prompt-input.web.tsx b/apps/mobile/src/components/chat/prompt-input.web.tsx index 9fe0a4ee39..a359e39aef 100644 --- a/apps/mobile/src/components/chat/prompt-input.web.tsx +++ b/apps/mobile/src/components/chat/prompt-input.web.tsx @@ -1,5 +1,5 @@ import { ArrowUp, Paperclip } from 'lucide-react'; -import { Children, type ReactNode, isValidElement } from 'react'; +import { Children, cloneElement, type ReactElement, type ReactNode, isValidElement } from 'react'; import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'; import { useChatContext } from './chat-context'; @@ -8,30 +8,32 @@ import { useConversationContext } from './conversation'; /** * Root container for the message composer matching Vercel chatbot design. * Centered max-w-4xl card with rounded-2xl border/shadow. - * Collects PromptInputAction children and renders them in a footer row. + * Collects PromptInputAction children and forwards them into PromptInputBody. */ export function PromptInput({ children }: { children: ReactNode }) { const { onPromptInputLayout } = useConversationContext(); // Separate action buttons from body (which contains textarea + submit) const actions: ReactNode[] = []; - let body: ReactNode = null; + let body: ReactElement | null = null; Children.forEach(children, (child) => { if (isValidElement(child) && (child.type as any) === PromptInputAction) { actions.push(child); } else if (isValidElement(child) && (child.type as any) === PromptInputBody) { - body = child; + body = child as ReactElement; } }); + const bodyWithActions = body ? cloneElement(body, { actions }) : null; + return ( - {body} + {bodyWithActions} ); @@ -57,11 +59,16 @@ export function PromptInputAction({ ); } +type PromptInputBodyProps = { + children: ReactNode; + actions?: ReactNode[]; +}; + /** * Container wrapping the textarea and the footer row with submit + tools. * On web, PromptInputBody renders the textarea children PLUS a footer row. */ -export function PromptInputBody({ children }: { children: ReactNode }) { +export function PromptInputBody({ children, actions = [] }: PromptInputBodyProps) { // Separate textarea from submit button const textarea: ReactNode[] = []; let submit: ReactNode = null; @@ -81,6 +88,7 @@ export function PromptInputBody({ children }: { children: ReactNode }) { {/* Footer row: tools on left, submit on right */} + {actions} {/* Attachments button */} @@ -107,7 +115,7 @@ export function PromptInputTextarea({ placeholder?: string; maxLength?: number; }) { - const { input, setInput, onSend } = useChatContext(); + const { input, setInput, isGenerating, onSend } = useChatContext(); return ( { - if ((e as any).nativeEvent.key === 'Enter' && !(e as any).nativeEvent.shiftKey) { + const key = (e as any).nativeEvent.key; + const shiftKey = !!(e as any).nativeEvent.shiftKey; + if (key === 'Enter' && !shiftKey) { e.preventDefault(); - onSend(); + if (input.trim().length > 0 && !isGenerating) { + onSend(); + } } }} /> From 60120f09840bd148f2553cc260ff3eebdb5f9173 Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Sun, 21 Jun 2026 11:36:44 +0100 Subject: [PATCH 12/16] ci(mobile): pin GitHub Actions to immutable commit SHAs Replace floating @v* tags in .github/workflows/ci.yml with full commit SHAs (with version comments) so the workflow does not silently shift under us if an upstream tag is moved. - actions/checkout@v6 -> df4cb1c (v6.0.3) - actions/setup-node@v6 -> 48b55a0 (v6.4.0) - dorny/paths-filter@v3 -> d1c1ffe (v3.0.3) Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 716b3a2aa0..bdd3e4cd4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,10 +13,10 @@ jobs: lint-and-format: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' cache: 'npm' @@ -28,10 +28,10 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' cache: 'npm' @@ -44,10 +44,10 @@ jobs: outputs: mobile: ${{ steps.filter.outputs.mobile }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - - uses: dorny/paths-filter@v3 + - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3 id: filter with: filters: | @@ -65,10 +65,10 @@ jobs: run: working-directory: apps/mobile steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' cache: 'npm' From 18f47f0b3bac82714926598c1a615a1f2f5b8636 Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Sat, 27 Jun 2026 10:19:41 +0100 Subject: [PATCH 13/16] fix(mobile): address Codex review feedback on mobile PR - Wrap web layout with AccentProvider/SessionsProvider/ToastProvider so shared screens that call useSessions() no longer crash on the web build. - Filter session_output by activeTabId in useSessionChat so tokens produced by a background tab are not appended to the visible tab. - Plumb AUTH_FAILED through useMaestroWebSocket (auth_failed, error code, close code 4001), latch off auto-reconnect, and have SessionsProvider clear credentials and route back to /pair instead of looping a rejected token. - Switch SessionsProvider to useMaestroConnection so AppState and NetInfo lifecycle handling (background disconnect, stale streaming buffer discard, foreground reconnect) actually applies on native. - Replace the Pressable style() callback in PromptInputSubmit with a static style + className active: modifier per the AGENTS.md rule and Uniwind constraints. - Keep the prompt input editable and the submit button enabled when the socket is down so useSessionChat's offline queue can capture and replay the command after reconnect. - Rename the Expo app to Maestro (name/slug/scheme/bundleIdentifier) so maestro:// QR scans open the app and EAS publishes under the right id. - Allow local HTTP/WebSocket on iOS via NSAppTransportSecurity + NSLocalNetworkUsageDescription in app.json, and on Android via a small config plugin that sets android:usesCleartextTraffic on the manifest. - When revoking a paired device, close the matching mobile WebSocket via a new WebServer.disconnectMobileDevice() helper so an already-open socket can't keep sending commands until it reconnects on its own. Co-Authored-By: Claude Opus 4.7 --- apps/mobile/app.json | 15 ++-- apps/mobile/plugins/with-android-cleartext.js | 18 +++++ apps/mobile/src/app/_layout.web.tsx | 76 ++++++++++++------- .../src/components/chat/prompt-input.tsx | 33 ++++---- apps/mobile/src/hooks/useSessionChat.ts | 12 ++- apps/mobile/src/lib/SessionsContext.tsx | 39 ++++++++-- apps/mobile/src/lib/useMaestroWebSocket.ts | 20 +++++ src/main/ipc/handlers/mobile-pairing.ts | 14 +++- src/main/preload/mobilePairing.ts | 2 + src/main/web-server/WebServer.ts | 36 +++++++++ 10 files changed, 203 insertions(+), 62 deletions(-) create mode 100644 apps/mobile/plugins/with-android-cleartext.js diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 87b1ad99e8..5d714d7b42 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -1,17 +1,21 @@ { "expo": { - "name": "chat", - "slug": "chat", + "name": "Maestro", + "slug": "maestro-mobile", "version": "1.0.0", "orientation": "portrait", - "scheme": "chat", + "scheme": "maestro", "userInterfaceStyle": "automatic", "ios": { "infoPlist": { "UIViewControllerBasedStatusBarAppearance": true, - "ITSAppUsesNonExemptEncryption": false + "ITSAppUsesNonExemptEncryption": false, + "NSLocalNetworkUsageDescription": "Maestro connects to your desktop over your local network to pair and stream chat sessions.", + "NSAppTransportSecurity": { + "NSAllowsLocalNetworking": true + } }, - "bundleIdentifier": "com.ashraf-ali-aa.chat" + "bundleIdentifier": "com.ashraf-ali-aa.maestro" }, "android": { "predictiveBackGestureEnabled": false @@ -20,6 +24,7 @@ "output": "server" }, "plugins": [ + "./plugins/with-android-cleartext", "expo-router", [ "expo-splash-screen", diff --git a/apps/mobile/plugins/with-android-cleartext.js b/apps/mobile/plugins/with-android-cleartext.js new file mode 100644 index 0000000000..c3166ffeb0 --- /dev/null +++ b/apps/mobile/plugins/with-android-cleartext.js @@ -0,0 +1,18 @@ +// Expo config plugin: set android:usesCleartextTraffic="true" on the +// element so release builds can reach the desktop over +// http://: and ws://:. Without this, Android 9+ +// release builds reject the pairing redemption and the WebSocket handshake. +const { withAndroidManifest } = require('@expo/config-plugins'); + +const withAndroidCleartext = (config) => + withAndroidManifest(config, (cfg) => { + const application = cfg.modResults.manifest.application?.[0]; + if (!application) return cfg; + application.$ = { + ...application.$, + 'android:usesCleartextTraffic': 'true', + }; + return cfg; + }); + +module.exports = withAndroidCleartext; diff --git a/apps/mobile/src/app/_layout.web.tsx b/apps/mobile/src/app/_layout.web.tsx index e4bcd1a643..31af2ec492 100644 --- a/apps/mobile/src/app/_layout.web.tsx +++ b/apps/mobile/src/app/_layout.web.tsx @@ -1,44 +1,64 @@ import { Sidebar, SidebarToggle } from '@/components/sidebar'; import '@/global.css'; +import { SessionsProvider } from '@/lib/SessionsContext'; +import { ToastProvider } from '@/lib/ToastContext'; +import { AccentProvider, useAccent } from '@/theme/AccentContext'; import { Slot } from 'expo-router'; import { useState } from 'react'; import { Pressable, Text, View } from 'react-native'; +/** + * Inner provider that wires SessionsProvider to AccentProvider, mirroring the + * native `_layout.tsx`. SessionsProvider needs the onThemeUpdate callback from + * AccentContext, and the shared screens rendered under (notably + * `index.tsx`) call `useSessions()`, which would throw without this stack. + */ +function SessionsWithAccent({ children }: { children: React.ReactNode }) { + const { setTheme } = useAccent(); + return {children}; +} + export default function RootLayout() { const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); return ( - - setSidebarOpen((v) => !v)} - isCollapsed={sidebarCollapsed} - onCollapse={() => setSidebarCollapsed((v) => !v)} - /> + + + + + setSidebarOpen((v) => !v)} + isCollapsed={sidebarCollapsed} + onCollapse={() => setSidebarCollapsed((v) => !v)} + /> - {/* Main content area */} - - {/* Chat header */} - - {/* Mobile sidebar toggle only — desktop uses the collapsed rail */} - - setSidebarOpen(true)} /> - + {/* Main content area */} + + {/* Chat header */} + + {/* Mobile sidebar toggle only — desktop uses the collapsed rail */} + + setSidebarOpen(true)} /> + - {/* Visibility / title area - right side */} - - - Launch now - - - + {/* Visibility / title area - right side */} + + + Launch now + + + - {/* Inset content panel */} - - - - - + {/* Inset content panel */} + + + + + + + + ); } diff --git a/apps/mobile/src/components/chat/prompt-input.tsx b/apps/mobile/src/components/chat/prompt-input.tsx index be2990000b..baf1e5bce7 100644 --- a/apps/mobile/src/components/chat/prompt-input.tsx +++ b/apps/mobile/src/components/chat/prompt-input.tsx @@ -118,8 +118,9 @@ export function PromptInputBody({ children }: { children: ReactNode }) { /** * Auto-growing text input for composing messages. Reads/writes the current - * input value from `ChatContext`. Disables when not connected to prevent - * typing into a void. + * input value from `ChatContext`. Stays editable while disconnected so the + * user can type commands that `useSessionChat`'s offline queue will replay + * after reconnect. */ export function PromptInputTextarea({ placeholder = 'Chat with Agent...', @@ -131,8 +132,7 @@ export function PromptInputTextarea({ const { input, setInput, isConnected } = useChatContext(); const inputRef = useRef(null); - // Default to true (enabled) if isConnected is not provided (backwards compatibility) - const disabled = isConnected === false; + const offline = isConnected === false; useEffect(() => { if (input === '') { @@ -146,44 +146,43 @@ export function PromptInputTextarea({ nativeID="composer" cursorColorClassName="tint-foreground" selectionColorClassName="tint-foreground" - style={{ fontSize: 16, opacity: disabled ? 0.5 : 1 }} + style={{ fontSize: 16 }} className="flex-1 pl-4 pr-2 py-3 text-foreground max-h-25" value={input} - onChangeText={disabled ? undefined : setInput} - placeholder={disabled ? 'Disconnected...' : placeholder} - placeholderTextColor={disabled ? '#EF4444' : undefined} + onChangeText={setInput} + placeholder={offline ? 'Offline - will send when reconnected' : placeholder} multiline maxLength={maxLength} - editable={!disabled} /> ); } /** * Submit button that sends the current input. Shows a spinner while the model - * is generating. Reads state from `ChatContext`. Disables when not connected. + * is generating. Reads state from `ChatContext`. Stays enabled while + * disconnected so the user can queue commands for offline replay. * Uses accent color from Maestro theme (per decision 5C). */ export function PromptInputSubmit() { - const { input, isGenerating, onSend, isConnected } = useChatContext(); + const { input, isGenerating, onSend } = useChatContext(); const { accentColor, accentForeground } = useAccent(); - // Disable if: no input, generating, or explicitly disconnected - const disabled = !input.trim() || isGenerating || isConnected === false; + // Disable only when there is nothing to send or a turn is in flight. + // Offline sends are valid because useSessionChat queues them for replay. + const disabled = !input.trim() || isGenerating; return ( ({ + style={{ width: 34, height: 34, borderRadius: 17, borderCurve: 'continuous', justifyContent: 'center', alignItems: 'center', - opacity: pressed ? 0.7 : 1, margin: 5, backgroundColor: disabled ? undefined : accentColor, - })} - className={disabled ? 'bg-secondary' : undefined} + }} + className={cn(disabled && 'bg-secondary', 'active:opacity-70')} onPress={onSend} disabled={disabled} > diff --git a/apps/mobile/src/hooks/useSessionChat.ts b/apps/mobile/src/hooks/useSessionChat.ts index a604355c18..92124d5d33 100644 --- a/apps/mobile/src/hooks/useSessionChat.ts +++ b/apps/mobile/src/hooks/useSessionChat.ts @@ -102,6 +102,11 @@ export function useSessionChat(targetSessionId: string): UseSessionChatReturn { sessionIdRef.current = targetSessionId; }, [targetSessionId]); + // Stable reference to the active tab id so streaming subscribers can filter + // out output produced for inactive tabs. Without this, a busy background tab + // would append its tokens to the visible tab's message list. + const activeTabIdRef = useRef(null); + // Commit the in-flight streaming buffer to the assistant message and clear // streaming state. Used by both session_state_change=idle and session_exit. const commitStreaming = useCallback(() => { @@ -145,9 +150,13 @@ export function useSessionChat(targetSessionId: string): UseSessionChatReturn { // Subscribe to session output (streamed assistant tokens). useEffect(() => { - return subscribeSessionOutput((outputSessionId, data, source) => { + return subscribeSessionOutput((outputSessionId, data, source, tabId) => { if (outputSessionId !== sessionIdRef.current) return; if (source !== 'ai') return; + // Drop output from non-active tabs. The desktop fans out tokens for + // every running tab; we only render the one the user is looking at. + const currentTabId = activeTabIdRef.current; + if (tabId && currentTabId && tabId !== currentTabId) return; if (!streamingMessageIdRef.current) { const messageId = `assistant-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; @@ -243,6 +252,7 @@ export function useSessionChat(targetSessionId: string): UseSessionChatReturn { // asked for it. const activeTabId = session?.activeTabId ?? null; useEffect(() => { + activeTabIdRef.current = activeTabId; setMessages([]); streamingRef.current = ''; streamingStore.set(''); diff --git a/apps/mobile/src/lib/SessionsContext.tsx b/apps/mobile/src/lib/SessionsContext.tsx index 791483401e..31efca14fb 100644 --- a/apps/mobile/src/lib/SessionsContext.tsx +++ b/apps/mobile/src/lib/SessionsContext.tsx @@ -16,8 +16,10 @@ import React, { useEffect, useMemo, } from 'react'; +import { useRouter } from 'expo-router'; +import { useMaestroConnection } from '@/hooks/useMaestroConnection'; +import { clearCredentials } from './credentials'; import { - useMaestroWebSocket, type AITabData, type SessionData, type SessionHistoryResult, @@ -128,6 +130,8 @@ interface SessionsProviderProps { } export function SessionsProvider({ children, onThemeUpdate }: SessionsProviderProps) { + const router = useRouter(); + // Sessions state const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(null); @@ -138,6 +142,9 @@ export function SessionsProvider({ children, onThemeUpdate }: SessionsProviderPr // Track theme callback in ref for callback stability const onThemeUpdateRef = useRef(onThemeUpdate); + // Latch so we only route back to /pair once per AUTH_FAILED event. + const authFailedHandledRef = useRef(false); + // One-shot resolvers for in-flight refreshSessions() calls. Drained when the // next `sessions_list` arrives so pull-to-refresh resolves on real data. const pendingRefreshResolversRef = useRef<(() => void)[]>([]); @@ -159,18 +166,33 @@ export function SessionsProvider({ children, onThemeUpdate }: SessionsProviderPr onThemeUpdateRef.current = onThemeUpdate; }, [onThemeUpdate]); - // WebSocket connection + // WebSocket connection — wrapped in useMaestroConnection so AppState/NetInfo + // transitions tear down the socket on background and reconnect on foreground, + // and so the streaming buffer gets marked stale after a long pause. const { - state: connectionState, + wsState: connectionState, isAuthenticated, error, connect, disconnect, send, requestSessionHistory, - } = useMaestroWebSocket({ + } = useMaestroConnection({ autoReconnect: true, handlers: { + onAuthFailed: () => { + // Desktop revoked the token (or it expired). Wipe credentials and + // kick the user back to the pairing screen instead of looping. + if (authFailedHandledRef.current) return; + authFailedHandledRef.current = true; + void clearCredentials() + .catch((err) => { + console.warn('[SessionsContext] Failed to clear credentials after AUTH_FAILED', err); + }) + .finally(() => { + router.replace('/pair'); + }); + }, onSessionsUpdate: (newSessions: SessionData[]) => { setSessions(newSessions); @@ -324,12 +346,13 @@ export function SessionsProvider({ children, onThemeUpdate }: SessionsProviderPr }, }); - // Auto-connect on mount + // Reset the AUTH_FAILED latch once we're authenticated again, so a later + // revoke still triggers the navigation back to /pair. useEffect(() => { - if (connectionState === 'disconnected') { - connect(); + if (connectionState === 'authenticated') { + authFailedHandledRef.current = false; } - }, [connectionState, connect]); + }, [connectionState]); // Derived active session const activeSession = useMemo( diff --git a/apps/mobile/src/lib/useMaestroWebSocket.ts b/apps/mobile/src/lib/useMaestroWebSocket.ts index e34d1b4dd0..7c86a58a20 100644 --- a/apps/mobile/src/lib/useMaestroWebSocket.ts +++ b/apps/mobile/src/lib/useMaestroWebSocket.ts @@ -123,6 +123,13 @@ export interface WebSocketHandlers { /** Called when theme is received or updated from Maestro desktop */ onThemeUpdate?: (theme: Theme) => void; onError?: (error: string) => void; + /** + * Fired when the desktop rejects the stored token (revoked, expired, or + * never paired). After this fires, the hook stops auto-reconnecting so a + * stale token can't loop. Callers should clear credentials and route the + * user back to pairing. + */ + onAuthFailed?: (reason: string) => void; } export interface UseMaestroWebSocketOptions { @@ -215,7 +222,9 @@ export function useMaestroWebSocket( case 'auth_failed': setError(message.message); + shouldReconnectRef.current = false; handlersRef.current?.onError?.(message.message); + handlersRef.current?.onAuthFailed?.(message.message || 'Authentication failed'); break; case 'sessions_list': @@ -277,6 +286,10 @@ export function useMaestroWebSocket( case 'error': setError(message.message); + if (message.code === 'AUTH_FAILED') { + shouldReconnectRef.current = false; + handlersRef.current?.onAuthFailed?.(message.message || 'Authentication failed'); + } handlersRef.current?.onError?.(message.message); break; @@ -381,6 +394,13 @@ export function useMaestroWebSocket( setState('disconnected'); handlersRef.current?.onConnectionChange?.('disconnected'); + // 4001 = desktop rejected the token. Looping a rejected token just + // burns CPU and shows the same error forever, so latch off. + if (event.code === 4001) { + shouldReconnectRef.current = false; + handlersRef.current?.onAuthFailed?.(event.reason || 'Authentication failed'); + } + // Attempt to reconnect if not a clean close if (event.code !== 1000 && shouldReconnectRef.current) { attemptReconnect(); diff --git a/src/main/ipc/handlers/mobile-pairing.ts b/src/main/ipc/handlers/mobile-pairing.ts index 9337705ba5..ab644e609f 100644 --- a/src/main/ipc/handlers/mobile-pairing.ts +++ b/src/main/ipc/handlers/mobile-pairing.ts @@ -114,20 +114,28 @@ export function registerMobilePairingHandlers(deps: MobilePairingHandlerDependen /** * Revoke a paired device by ID. * - * Removes the device from the paired devices list. + * Removes the device from the paired devices list and closes any open + * WebSocket connections that already authenticated with this device's + * token. Without the second step, a live mobile socket would keep + * authority until it reconnected on its own. */ ipcMain.handle( 'mobile-pairing:revoke-device', createIpcHandler( handlerOpts('revoke-device'), - async (id: string): Promise<{ revoked: boolean }> => { + async (id: string): Promise<{ revoked: boolean; disconnected: number }> => { const revoked = await revokeDevice(id); + let disconnected = 0; if (revoked) { logger.info(`Revoked paired device: ${id}`, LOG_CONTEXT); + const webServer = getWebServer(); + if (webServer && webServer.isActive()) { + disconnected = webServer.disconnectMobileDevice(id); + } } else { logger.warn(`Device not found for revocation: ${id}`, LOG_CONTEXT); } - return { revoked }; + return { revoked, disconnected }; } ) ); diff --git a/src/main/preload/mobilePairing.ts b/src/main/preload/mobilePairing.ts index c7b5410155..f481be95be 100644 --- a/src/main/preload/mobilePairing.ts +++ b/src/main/preload/mobilePairing.ts @@ -47,6 +47,8 @@ export interface DeviceListResponse { export interface RevokeDeviceResponse { success: boolean; revoked?: boolean; + /** Number of active mobile WebSocket connections that were closed for the revoked device. */ + disconnected?: number; error?: string; } diff --git a/src/main/web-server/WebServer.ts b/src/main/web-server/WebServer.ts index 1fd0c27c6c..02e6b0964c 100644 --- a/src/main/web-server/WebServer.ts +++ b/src/main/web-server/WebServer.ts @@ -1173,6 +1173,42 @@ export class WebServer { return this.webClients.size; } + /** + * Close any open WebSocket connections tied to a revoked mobile device id. + * The pairing record is the only thing that lets the next reconnect succeed; + * an already-open socket keeps working because it authenticated at handshake. + * Callers that revoke a device must invoke this to drop the live sockets too. + * + * Returns the number of sockets that were closed. + */ + disconnectMobileDevice(deviceId: string, reason: string = 'Device revoked'): number { + if (!deviceId) return 0; + let closed = 0; + for (const [clientId, client] of this.webClients) { + if (!client.isMobileClient || client.mobileDeviceId !== deviceId) continue; + try { + client.socket.send( + JSON.stringify({ + type: 'error', + message: reason, + code: 'AUTH_FAILED', + }) + ); + client.socket.close(4001, reason); + } catch (err) { + logger.warn( + `Failed to close socket for revoked client ${clientId}: ${String(err)}`, + LOG_CONTEXT + ); + } + closed++; + } + if (closed > 0) { + logger.info(`Closed ${closed} mobile socket(s) for revoked device ${deviceId}`, LOG_CONTEXT); + } + return closed; + } + async start(): Promise<{ port: number; token: string; url: string }> { if (this.isRunning) { return { From 6b60aef0bde44ca6076165fbba3da18d2564384a Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Sat, 27 Jun 2026 12:52:32 +0100 Subject: [PATCH 14/16] fix(mobile): address Codex review feedback (round 2) Addresses unresolved review threads from chatgpt-codex-connector on PR #1113: - Reconnect the WebSocket after pairing so the home screen lands connected instead of waiting for a foreground transition or restart. - Guard against overlapping connect() calls and stale onclose handlers that could clear the active socket out from under send(). - Subscribe the mobile socket to the active session so broadcastToolEvent reaches the chat UI with Running/Completed tool updates mid-turn. - Fall back to the offline queue when sendCommand returns false so a socket that closes between the isConnected check and the send doesn't silently drop the prompt. - Thread tabId through queueCommand and replay so queued prompts replay into the same conversation the user authored them in, not whatever tab is active at reconnect. - Preserve queued user bubbles across reconnect by only resetting the message list when the target session or tab actually changes. Also replaces em/en dashes introduced by this PR in the mobile sources to match the repo writing standard called out in CLAUDE.md. Co-Authored-By: Claude Opus 4.7 --- apps/mobile/src/app/_layout.web.tsx | 2 +- apps/mobile/src/app/pair.tsx | 10 ++- .../src/components/chat/conversation.tsx | 4 +- apps/mobile/src/components/chat/message.tsx | 2 +- .../src/components/markdown/chat-markdown.tsx | 2 +- apps/mobile/src/components/sidebar.tsx | 2 +- .../hooks/__tests__/useSessionChat.test.ts | 4 +- .../src/hooks/useMaestroOfflineQueue.ts | 9 +- apps/mobile/src/hooks/useSessionChat.ts | 86 ++++++++++++++----- apps/mobile/src/lib/SessionsContext.tsx | 39 +++++++-- apps/mobile/src/lib/useMaestroWebSocket.ts | 30 ++++++- src/__tests__/main/mobile-pairing.test.ts | 2 +- .../hooks/__tests__/useOfflineQueue.test.ts | 2 +- src/web/hooks/useOfflineQueue.ts | 27 ++++-- 14 files changed, 173 insertions(+), 48 deletions(-) diff --git a/apps/mobile/src/app/_layout.web.tsx b/apps/mobile/src/app/_layout.web.tsx index 31af2ec492..a4a3ed7d0a 100644 --- a/apps/mobile/src/app/_layout.web.tsx +++ b/apps/mobile/src/app/_layout.web.tsx @@ -38,7 +38,7 @@ export default function RootLayout() { {/* Chat header */} - {/* Mobile sidebar toggle only — desktop uses the collapsed rail */} + {/* Mobile sidebar toggle only - desktop uses the collapsed rail */} setSidebarOpen(true)} /> diff --git a/apps/mobile/src/app/pair.tsx b/apps/mobile/src/app/pair.tsx index d793b14b1f..b4675e4bd0 100644 --- a/apps/mobile/src/app/pair.tsx +++ b/apps/mobile/src/app/pair.tsx @@ -10,6 +10,7 @@ import { Icon } from '@/components/icon'; import { storeCredentials } from '@/lib/credentials'; +import { useSessions } from '@/lib/SessionsContext'; import { useToast } from '@/lib/ToastContext'; import { parseQrPayload, @@ -111,6 +112,7 @@ type ManualMode = 'url' | 'fields'; export default function PairScreen() { const router = useRouter(); const { showToast } = useToast(); + const { connect: connectSessions } = useSessions(); const [permission, requestPermission] = useCameraPermissions(); const [isProcessing, setIsProcessing] = useState(false); const [error, setError] = useState(null); @@ -147,6 +149,12 @@ export default function PairScreen() { clearCredentialsCache(); + // SessionsProvider mounted before credentials existed, so its one-shot + // mount effect bailed with "No credentials". Kick off a fresh connect + // now that credentials are persisted so the user lands on home with + // an active socket instead of a stuck disconnected state. + connectSessions(); + showToast({ message: 'Successfully paired with Maestro desktop', color: 'green', @@ -167,7 +175,7 @@ export default function PairScreen() { setIsProcessing(false); } }, - [router, showToast] + [router, showToast, connectSessions] ); const handleBarCodeScanned = useCallback( diff --git a/apps/mobile/src/components/chat/conversation.tsx b/apps/mobile/src/components/chat/conversation.tsx index bb3e7b3f28..0e6209099c 100644 --- a/apps/mobile/src/components/chat/conversation.tsx +++ b/apps/mobile/src/components/chat/conversation.tsx @@ -57,7 +57,7 @@ export function Conversation({ emptyState, children, }: { - /** Render callback for each message – passed to the underlying list. */ + /** Render callback for each message, passed to the underlying list. */ renderMessage: (info: { item: ChatMessage }) => ReactElement; /** Element shown when the message list is empty. */ emptyState?: ReactElement; @@ -271,7 +271,7 @@ export function Conversation({ padding: 16, // Reserve physical space below the last message for the floating // PromptInput + iOS home-indicator safe area. `contentInset.bottom` - // only adds rubber-band scroll area on iOS — it does NOT push + // only adds rubber-band scroll area on iOS, it does NOT push // visible content up, so without this padding the last bubble // renders behind the input bar at rest. Matches the .web variant. paddingBottom: composerOffsetHeight + insets.bottom + 8, diff --git a/apps/mobile/src/components/chat/message.tsx b/apps/mobile/src/components/chat/message.tsx index 7a3b8b0234..35b484c9f0 100644 --- a/apps/mobile/src/components/chat/message.tsx +++ b/apps/mobile/src/components/chat/message.tsx @@ -5,7 +5,7 @@ import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; /** * Wrapper for a single chat message. Styles automatically based on the sender - * role – user messages render as right-aligned blue bubbles, assistant messages + * role: user messages render as right-aligned blue bubbles, assistant messages * render full-width. */ export function Message({ from, children }: { from: 'user' | 'assistant'; children: ReactNode }) { diff --git a/apps/mobile/src/components/markdown/chat-markdown.tsx b/apps/mobile/src/components/markdown/chat-markdown.tsx index b36aba46f1..b594c558bd 100644 --- a/apps/mobile/src/components/markdown/chat-markdown.tsx +++ b/apps/mobile/src/components/markdown/chat-markdown.tsx @@ -55,7 +55,7 @@ export function ChatMarkdown({ children }: { children: string }) { const baseFontSize = isWeb ? 13 : 16; const baseLineHeight = isWeb ? 21.5 : 22; - // Only overrides — defaults from utils.ts are merged automatically + // Only overrides; defaults from utils.ts are merged automatically // Heading sizes: scaled for mobile readability (base 16pt) const markdownStyles = { heading1: { diff --git a/apps/mobile/src/components/sidebar.tsx b/apps/mobile/src/components/sidebar.tsx index 30cb0ac1b4..71cae88e41 100644 --- a/apps/mobile/src/components/sidebar.tsx +++ b/apps/mobile/src/components/sidebar.tsx @@ -1,4 +1,4 @@ -// Native fallback — the sidebar is only used on web. +// Native fallback - the sidebar is only used on web. // On native, the Drawer navigator in _layout.tsx handles navigation. export function Sidebar(_props: { diff --git a/apps/mobile/src/hooks/__tests__/useSessionChat.test.ts b/apps/mobile/src/hooks/__tests__/useSessionChat.test.ts index 19bd63a68c..061cd84f8d 100644 --- a/apps/mobile/src/hooks/__tests__/useSessionChat.test.ts +++ b/apps/mobile/src/hooks/__tests__/useSessionChat.test.ts @@ -2,14 +2,14 @@ * Tests for useSessionChat history-load behavior. * * Regression context: the Expo mobile chat screen used to start empty whenever - * a session was opened — the hook was purely event-driven and never asked the + * a session was opened; the hook was purely event-driven and never asked the * desktop for the existing conversation backlog. Fixing it added a * `get_session_history` request on session/tab change and a small dedupe-merge * step so the late-arriving response doesn't double-insert streaming events * that landed first. * * Per the codebase convention (see `messageRouting.test.ts`), these tests - * mirror the production helpers rather than importing the hook directly — + * mirror the production helpers rather than importing the hook directly: * pulling in `useSessionChat.ts` would drag in expo-haptics, React Context, * and the rest of the RN module graph. The helpers below are KEPT IN SYNC * INTENTIONALLY with `src/hooks/useSessionChat.ts` and diff --git a/apps/mobile/src/hooks/useMaestroOfflineQueue.ts b/apps/mobile/src/hooks/useMaestroOfflineQueue.ts index 9fd2540937..4b66ec31b2 100644 --- a/apps/mobile/src/hooks/useMaestroOfflineQueue.ts +++ b/apps/mobile/src/hooks/useMaestroOfflineQueue.ts @@ -26,8 +26,13 @@ export interface UseMaestroOfflineQueueOptions { isOnline: boolean; /** Whether WebSocket is authenticated and ready */ isConnected: boolean; - /** Send function that dispatches to WebSocket */ - sendCommand: (sessionId: string, command: string) => boolean; + /** + * Send function that dispatches to WebSocket. The optional `tabId` is + * supplied during queue replay so the command lands in the same AI tab the + * user originally targeted, even if the desktop's active tab moved while + * offline. + */ + sendCommand: (sessionId: string, command: string, tabId?: string) => boolean; /** Callback when a queued command is sent */ onCommandSent?: () => void; /** Callback when a queued command fails */ diff --git a/apps/mobile/src/hooks/useSessionChat.ts b/apps/mobile/src/hooks/useSessionChat.ts index 92124d5d33..3f07e02850 100644 --- a/apps/mobile/src/hooks/useSessionChat.ts +++ b/apps/mobile/src/hooks/useSessionChat.ts @@ -30,7 +30,7 @@ const STREAMING_THROTTLE_MS = 32; * shape the UI expects. The mobile UI only renders `user` / `assistant`, plus * "tool" messages identified by a `tool-` id prefix (matches the existing * convention in renderMessage). Other roles (system/thinking/error/unknown) - * are dropped here for parity with the streaming path — those don't show up + * are dropped here for parity with the streaming path; those don't show up * mid-conversation either, so we'd be the only surface to render them. */ function historyToChatMessages(history: SessionHistoryMessage[]): ChatMessage[] { @@ -71,6 +71,7 @@ export function useSessionChat(targetSessionId: string): UseSessionChatReturn { connectionState: wsConnectionState, sessions, setActiveSessionId, + subscribeToSession, sendCommand, requestSessionHistory, subscribeSessionOutput, @@ -132,8 +133,8 @@ export function useSessionChat(targetSessionId: string): UseSessionChatReturn { // Derive screen-level connection state from the shared WS state plus whether // the target session has actually shown up in the sessions list. // Note: the desktop sends a bare `connected` message (no `authenticated: true`), - // so we treat both 'connected' and 'authenticated' as fully ready for I/O — - // the WS handshake itself already validated the mobile pairing token. + // so we treat both 'connected' and 'authenticated' as fully ready for I/O. + // The WS handshake itself already validated the mobile pairing token. const connectionState = useMemo(() => { if (wsConnectionState === 'disconnected') return 'disconnected'; if (wsConnectionState === 'connecting') return 'connecting'; @@ -246,19 +247,46 @@ export function useSessionChat(targetSessionId: string): UseSessionChatReturn { } }, [targetSessionId, setActiveSessionId]); - // Load history (and reset streaming state) when the session or active tab - // changes. Without this the screen would be empty until the user sends a - // new message — the desktop already has the conversation, we just never - // asked for it. + // Tell the desktop to fan tool_event messages for this session to us. The + // desktop's broadcastToolEvent is subscribed-only, so without this the + // Running/Completed tool bubbles never arrive mid-turn. Re-fires on + // reconnect because the subscription is per-socket and doesn't survive. + useEffect(() => { + if (!targetSessionId) return; + if (wsConnectionState !== 'connected' && wsConnectionState !== 'authenticated') return; + subscribeToSession(targetSessionId); + }, [targetSessionId, wsConnectionState, subscribeToSession]); + + // Reset chat state only when the user actually navigates to a different + // session or tab. Previously this effect was keyed on wsConnectionState + // too, so a reconnect (e.g. backgrounded -> foreground) would blow away + // the local message list before the offline queue had a chance to replay, + // making queued user bubbles flash and disappear. const activeTabId = session?.activeTabId ?? null; + const prevTargetRef = useRef<{ sessionId: string; tabId: string | null } | null>(null); useEffect(() => { activeTabIdRef.current = activeTabId; - setMessages([]); - streamingRef.current = ''; - streamingStore.set(''); - streamingMessageIdRef.current = null; - setIsGenerating(false); - + const prev = prevTargetRef.current; + const isInitial = prev === null; + const targetChanged = + !isInitial && (prev.sessionId !== targetSessionId || prev.tabId !== activeTabId); + + if (isInitial || targetChanged) { + setMessages([]); + streamingRef.current = ''; + streamingStore.set(''); + streamingMessageIdRef.current = null; + setIsGenerating(false); + } + prevTargetRef.current = { sessionId: targetSessionId, tabId: activeTabId }; + }, [targetSessionId, activeTabId, streamingStore]); + + // Load (or reload) the conversation backlog whenever the target or + // connection state changes. The dedupe-merge below keeps queued user + // bubbles and any streaming/user/tool events that landed while the request + // was in flight, so a reconnect refreshes history without wiping pending + // local state. + useEffect(() => { if (!targetSessionId || !activeTabId) return; if (wsConnectionState !== 'connected' && wsConnectionState !== 'authenticated') return; @@ -285,14 +313,17 @@ export function useSessionChat(targetSessionId: string): UseSessionChatReturn { return () => { cancelled = true; }; - }, [targetSessionId, activeTabId, wsConnectionState, requestSessionHistory, streamingStore]); + }, [targetSessionId, activeTabId, wsConnectionState, requestSessionHistory]); const isConnected = connectionState === 'connected' || connectionState === 'ready'; - // Offline queue for queueing commands when disconnected. + // Offline queue for queueing commands when disconnected. Threads through + // the optional tabId from the queued entry so replays land in the same tab + // the user was on when offline, not whatever tab happens to be active when + // the socket comes back. const queueSend = useCallback( - (sessionId: string, command: string) => { - return sendCommand(sessionId, command); + (sessionId: string, command: string, tabId?: string) => { + return sendCommand(sessionId, command, tabId); }, [sendCommand] ); @@ -311,16 +342,31 @@ export function useSessionChat(targetSessionId: string): UseSessionChatReturn { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + const trimmed = input.trim(); const userMessageId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - setMessages((prev) => [...prev, { id: userMessageId, role: 'user', content: input.trim() }]); + setMessages((prev) => [...prev, { id: userMessageId, role: 'user', content: trimmed }]); + + // Capture the tab id at submit time so a later replay (offline queue, or + // the fallback below when a socket write loses to a race) targets the + // same conversation the user is looking at right now. + const targetTabId = activeTabIdRef.current ?? undefined; if (!isConnected) { - queueCommand(targetSessionId, input.trim(), 'ai'); + queueCommand(targetSessionId, trimmed, 'ai', targetTabId); setInput(''); return; } - sendCommand(targetSessionId, input.trim()); + // Socket could have closed between the render that set isConnected and + // here (NetInfo lag, background transition, etc.). sendCommand returns + // false in that case; fall back to the offline queue instead of silently + // dropping the prompt while clearing the input and pinning isGenerating. + const sent = sendCommand(targetSessionId, trimmed, targetTabId); + if (!sent) { + queueCommand(targetSessionId, trimmed, 'ai', targetTabId); + setInput(''); + return; + } setInput(''); setIsGenerating(true); }, [input, isGenerating, targetSessionId, sendCommand, isConnected, queueCommand]); diff --git a/apps/mobile/src/lib/SessionsContext.tsx b/apps/mobile/src/lib/SessionsContext.tsx index 31efca14fb..2899729807 100644 --- a/apps/mobile/src/lib/SessionsContext.tsx +++ b/apps/mobile/src/lib/SessionsContext.tsx @@ -79,6 +79,13 @@ export interface SessionsContextValue { // Actions setActiveSessionId: (sessionId: string) => void; + /** + * Tell the desktop this client wants live `tool_event` / per-session + * messages for `sessionId`. The desktop's `broadcastToolEvent` only fans + * out to clients whose `subscribedSessionId` matches, so without this the + * mobile UI misses Running/Completed tool updates mid-turn. + */ + subscribeToSession: (sessionId: string) => boolean; setActiveTab: (sessionId: string, tabId: string) => boolean; /** Create a new AI tab within a session. Returns false if the socket is down. */ newTab: (sessionId: string) => boolean; @@ -92,7 +99,7 @@ export interface SessionsContextValue { * socket is down, so a spinner never hangs. */ refreshSessions: () => Promise; - sendCommand: (sessionId: string, command: string) => boolean; + sendCommand: (sessionId: string, command: string, tabId?: string) => boolean; /** * Fetch a tab's conversation backlog from the desktop. Resolves with the * history payload (oldest first) or rejects if the desktop returns an error, @@ -166,7 +173,7 @@ export function SessionsProvider({ children, onThemeUpdate }: SessionsProviderPr onThemeUpdateRef.current = onThemeUpdate; }, [onThemeUpdate]); - // WebSocket connection — wrapped in useMaestroConnection so AppState/NetInfo + // WebSocket connection, wrapped in useMaestroConnection so AppState/NetInfo // transitions tear down the socket on background and reconnect on foreground, // and so the streaming buffer gets marked stale after a long pause. const { @@ -382,15 +389,34 @@ export function SessionsProvider({ children, onThemeUpdate }: SessionsProviderPr }); }, [send]); - // Send command to a session - const sendCommand = useCallback( - (sessionId: string, command: string): boolean => { + // Tell the desktop to start fanning tool events for this session. The + // desktop's broadcastToolEvent is gated on subscribedSessionId, and select_tab + // (which the mobile already sends) does NOT update that subscription on the + // server. Without this explicit subscribe, mobile users see Running/Completed + // tool updates only after history reloads. + const subscribeToSession = useCallback( + (sessionId: string): boolean => { return send({ + type: 'subscribe', + sessionId, + }); + }, + [send] + ); + + // Send command to a session. tabId pins delivery to a specific AI tab so a + // background tab the user queued into still receives the prompt even if the + // foreground tab changed between queue and replay. + const sendCommand = useCallback( + (sessionId: string, command: string, tabId?: string): boolean => { + const message: Record = { type: 'send_command', sessionId, command, inputMode: 'ai', - }); + }; + if (tabId) message.tabId = tabId; + return send(message); }, [send] ); @@ -484,6 +510,7 @@ export function SessionsProvider({ children, onThemeUpdate }: SessionsProviderPr activeSessionId, activeSession, setActiveSessionId, + subscribeToSession, setActiveTab, newTab, closeTab, diff --git a/apps/mobile/src/lib/useMaestroWebSocket.ts b/apps/mobile/src/lib/useMaestroWebSocket.ts index 7c86a58a20..b594bd26f1 100644 --- a/apps/mobile/src/lib/useMaestroWebSocket.ts +++ b/apps/mobile/src/lib/useMaestroWebSocket.ts @@ -173,6 +173,11 @@ export function useMaestroWebSocket( const handlersRef = useRef(handlers); const shouldReconnectRef = useRef(true); const reconnectAttemptsRef = useRef(0); + // In-flight connect guard. AppState/NetInfo/mount effects can all call + // connect() during the credential-loading await window, before wsRef is set. + // Without this guard two sockets could open concurrently and the older one's + // onclose would clear wsRef even when the newer socket is the active one. + const connectInFlightRef = useRef(false); // In-flight `get_session_history` requests, keyed by requestId. The desktop // echoes the same requestId in `session_history_result`, so we resolve the @@ -350,6 +355,15 @@ export function useMaestroWebSocket( }, [autoReconnect]); const connectInternal = useCallback(async () => { + // Bail if another connect is already mid-flight. Otherwise the second + // caller would open a second socket while the first one is still + // awaiting credential load, and the older socket's onclose would later + // clear wsRef even after the newer socket became the active one. + if (connectInFlightRef.current) { + return; + } + connectInFlightRef.current = true; + // Clean up existing connection if (wsRef.current) { wsRef.current.close(); @@ -369,12 +383,14 @@ export function useMaestroWebSocket( handlersRef.current?.onError?.('No credentials - please pair with Maestro desktop'); setState('disconnected'); handlersRef.current?.onConnectionChange?.('disconnected'); + connectInFlightRef.current = false; return; } try { const ws = new WebSocket(url); wsRef.current = ws; + connectInFlightRef.current = false; ws.onopen = () => { // Wait for 'connected' message from server @@ -390,9 +406,14 @@ export function useMaestroWebSocket( ws.onclose = (event) => { clearTimers(); - wsRef.current = null; - setState('disconnected'); - handlersRef.current?.onConnectionChange?.('disconnected'); + // Only clear wsRef if this is still the active socket. A stale + // onclose from a superseded connect attempt must not wipe a newer + // live socket out from under send(). + if (wsRef.current === ws) { + wsRef.current = null; + setState('disconnected'); + handlersRef.current?.onConnectionChange?.('disconnected'); + } // 4001 = desktop rejected the token. Looping a rejected token just // burns CPU and shows the same error forever, so latch off. @@ -402,7 +423,7 @@ export function useMaestroWebSocket( } // Attempt to reconnect if not a clean close - if (event.code !== 1000 && shouldReconnectRef.current) { + if (event.code !== 1000 && shouldReconnectRef.current && wsRef.current === null) { attemptReconnect(); } }; @@ -412,6 +433,7 @@ export function useMaestroWebSocket( handlersRef.current?.onError?.('Failed to create WebSocket connection'); setState('disconnected'); handlersRef.current?.onConnectionChange?.('disconnected'); + connectInFlightRef.current = false; } }, [clearTimers, handleMessage, attemptReconnect]); diff --git a/src/__tests__/main/mobile-pairing.test.ts b/src/__tests__/main/mobile-pairing.test.ts index 05d015a42e..0a7f68dab3 100644 --- a/src/__tests__/main/mobile-pairing.test.ts +++ b/src/__tests__/main/mobile-pairing.test.ts @@ -173,7 +173,7 @@ describe('mobile-pairing', () => { it('rejects empty / non-string input', async () => { const { validateMobileToken } = await freshModule(); expect(await validateMobileToken('')).toBeNull(); - // @ts-expect-error – exercise runtime guard for non-string input + // @ts-expect-error - exercise runtime guard for non-string input expect(await validateMobileToken(123)).toBeNull(); }); diff --git a/src/web/hooks/__tests__/useOfflineQueue.test.ts b/src/web/hooks/__tests__/useOfflineQueue.test.ts index eb173e0667..6c22b6f26f 100644 --- a/src/web/hooks/__tests__/useOfflineQueue.test.ts +++ b/src/web/hooks/__tests__/useOfflineQueue.test.ts @@ -543,7 +543,7 @@ describe('useOfflineQueue', () => { await vi.advanceTimersByTimeAsync(200); // SEND_DELAY }); - expect(sendCommand).toHaveBeenCalledWith('session-1', 'test command'); + expect(sendCommand).toHaveBeenCalledWith('session-1', 'test command', undefined); expect(onCommandSent).toHaveBeenCalled(); }); diff --git a/src/web/hooks/useOfflineQueue.ts b/src/web/hooks/useOfflineQueue.ts index f93bb3885b..5a8afe235a 100644 --- a/src/web/hooks/useOfflineQueue.ts +++ b/src/web/hooks/useOfflineQueue.ts @@ -52,6 +52,12 @@ export interface QueuedCommand { command: string; /** Target session ID */ sessionId: string; + /** + * Target AI tab ID at queue time. Persisted so that when the queue replays + * after reconnect the command lands in the same tab the user was looking + * at, even if the desktop's active tab changed in the meantime. + */ + tabId?: string; /** Timestamp when command was queued */ timestamp: number; /** Input mode (ai or terminal) */ @@ -75,8 +81,12 @@ export interface UseOfflineQueueOptions { isOnline: boolean; /** Whether connected to the WebSocket server */ isConnected: boolean; - /** Function to send a command to the server */ - sendCommand: (sessionId: string, command: string) => boolean; + /** + * Function to send a command to the server. `tabId` is supplied during + * queue replay when the queued command carried a target tab, so the + * replay lands in the same conversation the user originally targeted. + */ + sendCommand: (sessionId: string, command: string, tabId?: string) => boolean; /** Maximum retry attempts per command (default: 3) */ maxRetries?: number; /** Storage adapter for queue persistence. When null/undefined, persistence is disabled. */ @@ -105,7 +115,8 @@ export interface UseOfflineQueueReturn { queueCommand: ( sessionId: string, command: string, - inputMode: 'ai' | 'terminal' + inputMode: 'ai' | 'terminal', + tabId?: string ) => QueuedCommand | null; /** Remove a specific command from the queue */ removeCommand: (commandId: string) => void; @@ -289,7 +300,12 @@ export function useOfflineQueue(options: UseOfflineQueueOptions): UseOfflineQueu * Queue a command for later sending */ const queueCommand = useCallback( - (sessionId: string, command: string, inputMode: 'ai' | 'terminal'): QueuedCommand | null => { + ( + sessionId: string, + command: string, + inputMode: 'ai' | 'terminal', + tabId?: string + ): QueuedCommand | null => { // Check if we're at capacity if (queue.length >= MAX_QUEUE_SIZE) { webLogger.warn('Queue at maximum capacity, cannot add more commands', 'OfflineQueue'); @@ -300,6 +316,7 @@ export function useOfflineQueue(options: UseOfflineQueueOptions): UseOfflineQueu id: generateId(), command, sessionId, + tabId, timestamp: Date.now(), inputMode, attempts: 0, @@ -379,7 +396,7 @@ export function useOfflineQueue(options: UseOfflineQueueOptions): UseOfflineQueu const updatedCmd = { ...cmd, attempts: cmd.attempts + 1 }; try { - const success = sendCommandRef.current(cmd.sessionId, cmd.command); + const success = sendCommandRef.current(cmd.sessionId, cmd.command, cmd.tabId); if (success) { successCount++; From 220b67cce2c5a1844718da722529a045f5ae75e2 Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Sun, 28 Jun 2026 19:58:55 +0100 Subject: [PATCH 15/16] fix(mobile): address Codex review feedback (round 3) Resolve 7 P2 findings from the Codex reviewer on the mobile PR: - Scope tool_event handling to the active AI tab so a background tab's Running/Completed bubbles no longer leak into the visible tab. - Scope session_exit to the finishing AI tab. The desktop now tags the exit broadcast with the AI tab id (exit-listener), and the mobile chat hook ignores exits for tabs other than the one on screen, so a background tab finishing can't commit/clear the active tab's turn early. - Dedupe mobile's own echoed user_input. The desktop fans user_input back to the originating socket; we already render an optimistic bubble, so absorb one matching pending echo (recorded at dispatch time so offline queue replays still match) instead of double-appending. - Reset the AUTH_FAILED latch on a bare `connected` handshake, not only `authenticated`. Mobile pairing settles as `connected`, so the latch previously stayed stuck after a successful re-pair and swallowed the next revoke. - Clear stale streaming state after a foreground reconnect. SessionsContext now wires onStaleBufferDiscarded and fans it out to useSessionChat, which drops the abandoned partial bubble and unsticks isGenerating. The lifecycle wrapper also fires the discard on `connected` (mobile never reaches `authenticated`). - Roll back optimistic generating state on a rejected send by handling the desktop's command_result (success:false) instead of leaving the spinner stuck forever. - Honor disconnects while credentials are loading: connectInternal now rechecks shouldReconnectRef after awaiting buildWebSocketUrl() so a disconnect during the SecureStore read can't leak a live background socket. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/mobile/src/hooks/useMaestroConnection.ts | 6 +- apps/mobile/src/hooks/useSessionChat.ts | 107 +++++++++++++++++- apps/mobile/src/lib/SessionsContext.tsx | 69 ++++++++++- apps/mobile/src/lib/useMaestroWebSocket.ts | 35 +++++- src/main/process-listeners/exit-listener.ts | 6 + 5 files changed, 208 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/hooks/useMaestroConnection.ts b/apps/mobile/src/hooks/useMaestroConnection.ts index 4e5e7defe4..80ba2466ee 100644 --- a/apps/mobile/src/hooks/useMaestroConnection.ts +++ b/apps/mobile/src/hooks/useMaestroConnection.ts @@ -72,7 +72,11 @@ export function useMaestroConnection( const wrappedHandlers = { ...handlers, onConnectionChange: (state: WebSocketState) => { - if (state === 'authenticated') { + // The mobile pairing handshake settles as a bare `connected` (the + // desktop never sends `authenticated: true` for it), so treat both as + // "reconnected" - otherwise the reconnecting flag and the stale-buffer + // discard would never clear on mobile. + if (state === 'authenticated' || state === 'connected') { setIsReconnecting(false); // Check for stale buffer on reconnect if (hasStaleBufferRef.current) { diff --git a/apps/mobile/src/hooks/useSessionChat.ts b/apps/mobile/src/hooks/useSessionChat.ts index 3f07e02850..9f90de0a5b 100644 --- a/apps/mobile/src/hooks/useSessionChat.ts +++ b/apps/mobile/src/hooks/useSessionChat.ts @@ -25,6 +25,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; // Throttle interval for streaming UI updates (~30fps) const STREAMING_THROTTLE_MS = 32; +// How long a locally-sent prompt stays eligible to absorb its own `user_input` +// echo from the desktop. Long enough to cover an offline-queue replay landing +// after reconnect, short enough that it can't shadow an unrelated desktop-typed +// message with the same text much later. +const ECHO_DEDUP_WINDOW_MS = 60_000; + /** * Convert the desktop's `SessionHistoryMessage[]` into the mobile `ChatMessage` * shape the UI expects. The mobile UI only renders `user` / `assistant`, plus @@ -79,6 +85,8 @@ export function useSessionChat(targetSessionId: string): UseSessionChatReturn { subscribeSessionExit, subscribeToolEvent, subscribeUserInput, + subscribeCommandResult, + subscribeStaleBuffer, } = useSessions(); const session = useMemo( @@ -108,6 +116,14 @@ export function useSessionChat(targetSessionId: string): UseSessionChatReturn { // would append its tokens to the visible tab's message list. const activeTabIdRef = useRef(null); + // Prompts we dispatched locally, awaiting their own `user_input` echo from + // the desktop (which fans out to every subscriber, including this socket). + // Recorded at dispatch time so an offline-queue replay still matches its echo. + const pendingEchoesRef = useRef<{ command: string; ts: number }[]>([]); + const recordPendingEcho = useCallback((command: string) => { + pendingEchoesRef.current.push({ command, ts: Date.now() }); + }, []); + // Commit the in-flight streaming buffer to the assistant message and clear // streaming state. Used by both session_state_change=idle and session_exit. const commitStreaming = useCallback(() => { @@ -130,6 +146,22 @@ export function useSessionChat(targetSessionId: string): UseSessionChatReturn { setIsGenerating(false); }, [streamingStore]); + // Drop an in-flight streaming turn without committing it. Used when a long + // background pause invalidated the buffer: the desktop has likely already + // finished, so the partial assistant bubble is abandoned (history reload on + // reconnect re-fetches the final message) and isGenerating is unstuck so the + // user can send again. + const discardStreaming = useCallback(() => { + const msgId = streamingMessageIdRef.current; + if (msgId) { + setMessages((prev) => prev.filter((m) => m.id !== msgId)); + } + streamingRef.current = ''; + streamingStore.set(''); + streamingMessageIdRef.current = null; + setIsGenerating(false); + }, [streamingStore]); + // Derive screen-level connection state from the shared WS state plus whether // the target session has actually shown up in the sessions list. // Note: the desktop sends a bare `connected` message (no `authenticated: true`), @@ -195,16 +227,29 @@ export function useSessionChat(targetSessionId: string): UseSessionChatReturn { // spinner stuck forever and onSend early-returned because isGenerating stayed // true. useEffect(() => { - return subscribeSessionExit((exitSessionId) => { + return subscribeSessionExit((exitSessionId, tabId) => { if (exitSessionId !== sessionIdRef.current) return; + // A background tab can finish while the visible tab is still + // generating. The desktop tags the exit with its AI tab id, so ignore + // exits for any tab other than the one on screen - otherwise we'd + // commit/clear the active tab's turn early and let the user send into + // a still-running conversation. + const currentTabId = activeTabIdRef.current; + if (tabId && currentTabId && tabId !== currentTabId) return; commitStreaming(); }); }, [subscribeSessionExit, commitStreaming]); // Subscribe to tool events. useEffect(() => { - return subscribeToolEvent((toolSessionId, _tabId, toolLog: ToolEventLog) => { + return subscribeToolEvent((toolSessionId, tabId, toolLog: ToolEventLog) => { if (toolSessionId !== sessionIdRef.current) return; + // Drop tool events for non-active tabs, mirroring the session_output + // filter. The desktop fans tool_event for every running tab, so without + // this a background tab's Running/Completed bubbles land in the visible + // tab's list and disagree with its history until reload. + const currentTabId = activeTabIdRef.current; + if (tabId && currentTabId && tabId !== currentTabId) return; const toolName = toolLog.metadata?.toolState?.name || 'tool'; const status = toolLog.metadata?.toolState?.status || 'running'; @@ -235,11 +280,50 @@ export function useSessionChat(targetSessionId: string): UseSessionChatReturn { if (inputSessionId !== sessionIdRef.current) return; if (inputMode !== 'ai') return; + // The desktop broadcasts `user_input` to every session subscriber, + // including the socket that sent it. We already rendered an optimistic + // bubble for our own prompts, so absorb one matching pending echo and + // skip the duplicate. Only echoes with no pending local match (i.e. + // prompts typed on the desktop) get appended. + const pending = pendingEchoesRef.current; + const now = Date.now(); + while (pending.length > 0 && now - pending[0].ts > ECHO_DEDUP_WINDOW_MS) { + pending.shift(); + } + const matchIdx = pending.findIndex((e) => e.command === command); + if (matchIdx >= 0) { + pending.splice(matchIdx, 1); + return; + } + const messageId = `user-desktop-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; setMessages((prev) => [...prev, { id: messageId, role: 'user', content: command }]); }); }, [subscribeUserInput]); + // Subscribe to send_command acknowledgements. A `success: false` result means + // the desktop rejected the prompt (session busy or removed), so roll back the + // optimistic generating state instead of leaving the user stuck behind a + // spinner waiting on output that will never arrive. + useEffect(() => { + return subscribeCommandResult((resultSessionId, success, tabId) => { + if (resultSessionId !== sessionIdRef.current) return; + if (success) return; + const currentTabId = activeTabIdRef.current; + if (tabId && currentTabId && tabId !== currentTabId) return; + setIsGenerating(false); + }); + }, [subscribeCommandResult]); + + // Subscribe to stale-buffer notifications. After a long background pause the + // in-flight streaming turn is no longer trustworthy, so drop it and unstick + // isGenerating; the history reload on reconnect restores the final message. + useEffect(() => { + return subscribeStaleBuffer(() => { + discardStreaming(); + }); + }, [subscribeStaleBuffer, discardStreaming]); + // Sync active session in context so the drawer + tab strip stay in sync. useEffect(() => { if (targetSessionId) { @@ -323,9 +407,13 @@ export function useSessionChat(targetSessionId: string): UseSessionChatReturn { // the socket comes back. const queueSend = useCallback( (sessionId: string, command: string, tabId?: string) => { - return sendCommand(sessionId, command, tabId); + const sent = sendCommand(sessionId, command, tabId); + // Record at actual dispatch (offline-queue replay can fire long after + // the user typed) so the prompt's own echo is still absorbed. + if (sent) recordPendingEcho(command); + return sent; }, - [sendCommand] + [sendCommand, recordPendingEcho] ); const { queueCommand, queueLength } = useMaestroOfflineQueue({ @@ -367,9 +455,18 @@ export function useSessionChat(targetSessionId: string): UseSessionChatReturn { setInput(''); return; } + recordPendingEcho(trimmed); setInput(''); setIsGenerating(true); - }, [input, isGenerating, targetSessionId, sendCommand, isConnected, queueCommand]); + }, [ + input, + isGenerating, + targetSessionId, + sendCommand, + isConnected, + queueCommand, + recordPendingEcho, + ]); // Cleanup throttle timeout on unmount. useEffect(() => { diff --git a/apps/mobile/src/lib/SessionsContext.tsx b/apps/mobile/src/lib/SessionsContext.tsx index 2899729807..fd8746a03d 100644 --- a/apps/mobile/src/lib/SessionsContext.tsx +++ b/apps/mobile/src/lib/SessionsContext.tsx @@ -50,7 +50,7 @@ export type SessionOutputHandler = ( export type SessionStateChangeHandler = (sessionId: string, state: string) => void; -export type SessionExitHandler = (sessionId: string) => void; +export type SessionExitHandler = (sessionId: string, tabId?: string) => void; export type ToolEventHandler = (sessionId: string, tabId: string, toolLog: ToolEventLog) => void; @@ -60,6 +60,11 @@ export type UserInputHandler = ( inputMode: 'ai' | 'terminal' ) => void; +export type CommandResultHandler = (sessionId: string, success: boolean, tabId?: string) => void; + +/** Fired when a long background pause invalidated any in-flight streaming buffer. */ +export type StaleBufferHandler = () => void; + export type Unsubscribe = () => void; // ============================================================================ @@ -118,6 +123,14 @@ export interface SessionsContextValue { subscribeSessionExit: (handler: SessionExitHandler) => Unsubscribe; subscribeToolEvent: (handler: ToolEventHandler) => Unsubscribe; subscribeUserInput: (handler: UserInputHandler) => Unsubscribe; + subscribeCommandResult: (handler: CommandResultHandler) => Unsubscribe; + /** + * Subscribe to stale-buffer notifications. Fired once after the app returns + * to the foreground following a background pause long enough to invalidate + * any in-flight streaming turn, so chat screens can drop the partial + * assistant bubble and unstick `isGenerating`. + */ + subscribeStaleBuffer: (handler: StaleBufferHandler) => Unsubscribe; } // ============================================================================ @@ -163,6 +176,8 @@ export function SessionsProvider({ children, onThemeUpdate }: SessionsProviderPr const sessionExitSubs = useRef(new Set()); const toolEventSubs = useRef(new Set()); const userInputSubs = useRef(new Set()); + const commandResultSubs = useRef(new Set()); + const staleBufferSubs = useRef(new Set()); // Keep refs in sync with props/state (must be in useEffect per React 19 rules) useEffect(() => { @@ -186,6 +201,20 @@ export function SessionsProvider({ children, onThemeUpdate }: SessionsProviderPr requestSessionHistory, } = useMaestroConnection({ autoReconnect: true, + // A long background pause invalidates any half-streamed assistant turn: + // the desktop has likely finished (or moved on) by the time we foreground + // and reconnect. Fan that out so chat screens drop the stale partial + // bubble and unstick `isGenerating` instead of staying blocked until some + // later idle/exit event arrives. + onStaleBufferDiscarded: () => { + staleBufferSubs.current.forEach((handler) => { + try { + handler(); + } catch (err) { + console.error('[SessionsContext] onStaleBufferDiscarded subscriber threw', err); + } + }); + }, handlers: { onAuthFailed: () => { // Desktop revoked the token (or it expired). Wipe credentials and @@ -267,15 +296,24 @@ export function SessionsProvider({ children, onThemeUpdate }: SessionsProviderPr } }); }, - onSessionExit: (sessionId) => { + onSessionExit: (sessionId, tabId) => { sessionExitSubs.current.forEach((handler) => { try { - handler(sessionId); + handler(sessionId, tabId); } catch (err) { console.error('[SessionsContext] onSessionExit subscriber threw', err); } }); }, + onCommandResult: (sessionId, success, tabId) => { + commandResultSubs.current.forEach((handler) => { + try { + handler(sessionId, success, tabId); + } catch (err) { + console.error('[SessionsContext] onCommandResult subscriber threw', err); + } + }); + }, onToolEvent: (sessionId, tabId, toolLog) => { toolEventSubs.current.forEach((handler) => { try { @@ -353,10 +391,13 @@ export function SessionsProvider({ children, onThemeUpdate }: SessionsProviderPr }, }); - // Reset the AUTH_FAILED latch once we're authenticated again, so a later - // revoke still triggers the navigation back to /pair. + // Reset the AUTH_FAILED latch once the socket handshakes again, so a later + // revoke still triggers the navigation back to /pair. The mobile pairing + // handshake settles as a bare `connected` (the desktop never sends + // `authenticated: true` for it), so latching only on `authenticated` would + // leave the flag stuck after a successful re-pair and swallow the next revoke. useEffect(() => { - if (connectionState === 'authenticated') { + if (connectionState === 'authenticated' || connectionState === 'connected') { authFailedHandledRef.current = false; } }, [connectionState]); @@ -502,6 +543,20 @@ export function SessionsProvider({ children, onThemeUpdate }: SessionsProviderPr }; }, []); + const subscribeCommandResult = useCallback((handler: CommandResultHandler): Unsubscribe => { + commandResultSubs.current.add(handler); + return () => { + commandResultSubs.current.delete(handler); + }; + }, []); + + const subscribeStaleBuffer = useCallback((handler: StaleBufferHandler): Unsubscribe => { + staleBufferSubs.current.add(handler); + return () => { + staleBufferSubs.current.delete(handler); + }; + }, []); + const value: SessionsContextValue = { connectionState, isAuthenticated, @@ -524,6 +579,8 @@ export function SessionsProvider({ children, onThemeUpdate }: SessionsProviderPr subscribeSessionExit, subscribeToolEvent, subscribeUserInput, + subscribeCommandResult, + subscribeStaleBuffer, }; return {children}; diff --git a/apps/mobile/src/lib/useMaestroWebSocket.ts b/apps/mobile/src/lib/useMaestroWebSocket.ts index b594bd26f1..705f68bda9 100644 --- a/apps/mobile/src/lib/useMaestroWebSocket.ts +++ b/apps/mobile/src/lib/useMaestroWebSocket.ts @@ -100,11 +100,20 @@ export interface WebSocketHandlers { /** * Fired when the desktop signals a session has finished its current turn. * Used as a fallback turn-complete signal when session_state_change is not - * emitted for the active code path. + * emitted for the active code path. `tabId` is the AI tab that exited when + * the desktop includes it, so multi-tab screens can ignore exits for tabs + * other than the one the user is looking at. */ - onSessionExit?: (sessionId: string) => void; + onSessionExit?: (sessionId: string, tabId?: string) => void; onToolEvent?: (sessionId: string, tabId: string, toolLog: ToolEventLog) => void; onUserInput?: (sessionId: string, command: string, inputMode: 'ai' | 'terminal') => void; + /** + * Fired when the desktop acknowledges a `send_command`. `success` is false + * when the desktop rejected the command (e.g. the session was busy or has + * been removed), so the UI can roll back an optimistic "generating" state + * instead of waiting forever for output that will never arrive. + */ + onCommandResult?: (sessionId: string, success: boolean, tabId?: string) => void; onTabsChanged?: (sessionId: string, aiTabs: AITabData[], activeTabId: string) => void; /** * Fired when the desktop acknowledges a `new_tab` request with success. The @@ -258,7 +267,15 @@ export function useMaestroWebSocket( break; case 'session_exit': - handlersRef.current?.onSessionExit?.(message.sessionId); + handlersRef.current?.onSessionExit?.(message.sessionId, message.tabId); + break; + + case 'command_result': + handlersRef.current?.onCommandResult?.( + message.sessionId, + message.success !== false, + message.tabId + ); break; case 'tool_event': @@ -377,6 +394,18 @@ export function useMaestroWebSocket( // Build URL without a specific sessionId - session is selected after connection const url = await buildWebSocketUrl(); + // disconnect() may have run while we were awaiting the credential read + // (e.g. the app backgrounded mid-SecureStore-load). It flips + // shouldReconnectRef off, so honor that here instead of opening a socket + // the lifecycle wrapper already asked us to tear down - otherwise a live + // socket leaks in the background and keeps receiving events. + if (!shouldReconnectRef.current) { + setState('disconnected'); + handlersRef.current?.onConnectionChange?.('disconnected'); + connectInFlightRef.current = false; + return; + } + if (!url) { // No credentials available - need pairing setError('No credentials - please pair with Maestro desktop'); diff --git a/src/main/process-listeners/exit-listener.ts b/src/main/process-listeners/exit-listener.ts index 0b3d6f83f1..c5a4a3edab 100644 --- a/src/main/process-listeners/exit-listener.ts +++ b/src/main/process-listeners/exit-listener.ts @@ -552,9 +552,15 @@ export function setupExitListener( if (webServer) { // Extract base session ID from formats: {id}-ai-{tabId}, {id}-terminal, {id}-batch-{timestamp}, {id}-synopsis-{timestamp} const baseSessionId = sessionId.replace(/-ai-.+$|-terminal$|-batch-\d+$|-synopsis-\d+$/, ''); + // Carry the AI tab id when present so multi-tab clients can scope the + // turn-complete signal to the tab that actually exited instead of + // applying it to whatever tab they currently have in view. + const aiTabMatch = sessionId.match(/-ai-(.+)$/); + const tabId = aiTabMatch?.[1]; webServer.broadcastToSessionClients(baseSessionId, { type: 'session_exit', sessionId: baseSessionId, + ...(tabId ? { tabId } : {}), exitCode: code, timestamp: Date.now(), }); From 95667c8d90518a2df4160fdd75d6fc342c2ff551 Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Sun, 28 Jun 2026 20:37:44 +0100 Subject: [PATCH 16/16] fix(mobile): address Codex review feedback (round 4) - chat-markdown: return after the custom link handler instead of using `??`, so regular links don't open twice and wiki links don't fall through to the unsupported maestro://file URL after the toast. - useSessionChat: dedupe reloaded history user messages against pending optimistic local bubbles by content, so a reconnect/history reload doesn't show the user's prompt twice (optimistic id vs persisted id). - useOfflineQueue (web): merge the persisted snapshot with any commands queued during the async storage read instead of overwriting, so a prompt queued on cold start isn't clobbered by stale stored data. - home chat (index.tsx): render AITabStrip like the session route so multiple AI tabs are switchable from the landing chat on native. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/mobile/src/app/index.tsx | 2 ++ .../src/components/markdown/chat-markdown.tsx | 13 ++++++++- apps/mobile/src/hooks/useSessionChat.ts | 28 ++++++++++++++++++- src/web/hooks/useOfflineQueue.ts | 10 ++++++- 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index ce9109d921..ecfc6748d8 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -23,6 +23,7 @@ import { StreamingMessage, type ChatMessage, } from '@/components/chat'; +import { AITabStrip } from '@/components/AITabStrip'; import { ConnectionStatusPill } from '@/components/ConnectionStatusPill'; import { Icon } from '@/components/icon'; import { MainHeader } from '@/components/main-header'; @@ -79,6 +80,7 @@ export default function ChatScreen() { <> + { const isWikiLink = node.url?.startsWith('maestro://file/'); - const onPress = () => extras?.onPress?.(node.url) ?? Linking.openURL(node.url); + // `extras.onPress` is `handleLinkPress`, which fully handles the link + // (wiki toast or WebBrowser/Linking) and returns void. Returning after + // it runs avoids falling through to `Linking.openURL` - which otherwise + // opens regular links twice and tries the unsupported `maestro://file/` + // URL after the wiki toast. + const onPress = () => { + if (extras?.onPress) { + extras.onPress(node.url); + return; + } + Linking.openURL(node.url); + }; if (isWikiLink) { // Wiki-links render with file icon and accent color diff --git a/apps/mobile/src/hooks/useSessionChat.ts b/apps/mobile/src/hooks/useSessionChat.ts index 9f90de0a5b..82110f6cc8 100644 --- a/apps/mobile/src/hooks/useSessionChat.ts +++ b/apps/mobile/src/hooks/useSessionChat.ts @@ -385,7 +385,33 @@ export function useSessionChat(targetSessionId: string): UseSessionChatReturn { // request was in flight stay at the end of the conversation. setMessages((prev) => { const seen = new Set(prev.map((m) => m.id)); - const deduped = initial.filter((m) => !seen.has(m.id)); + // Optimistic local user bubbles use a generated `user-...` id, while + // the persisted history copy of the same prompt carries the log id, so + // an id-only dedupe would show the prompt twice after a reconnect or + // history reload. Match history user messages against pending optimistic + // bubbles by content and drop the duplicate, consuming each bubble once + // so legitimately repeated prompts are preserved. + const optimisticUserCounts = new Map(); + for (const m of prev) { + if ( + m.role === 'user' && + m.id.startsWith('user-') && + !m.id.startsWith('user-desktop-') + ) { + optimisticUserCounts.set(m.content, (optimisticUserCounts.get(m.content) ?? 0) + 1); + } + } + const deduped = initial.filter((m) => { + if (seen.has(m.id)) return false; + if (m.role === 'user') { + const remaining = optimisticUserCounts.get(m.content) ?? 0; + if (remaining > 0) { + optimisticUserCounts.set(m.content, remaining - 1); + return false; + } + } + return true; + }); return deduped.length === 0 ? prev : [...deduped, ...prev]; }); }) diff --git a/src/web/hooks/useOfflineQueue.ts b/src/web/hooks/useOfflineQueue.ts index 5a8afe235a..2e60d89f1e 100644 --- a/src/web/hooks/useOfflineQueue.ts +++ b/src/web/hooks/useOfflineQueue.ts @@ -260,7 +260,15 @@ export function useOfflineQueue(options: UseOfflineQueueOptions): UseOfflineQueu if (stored) { const parsed = JSON.parse(stored); if (Array.isArray(parsed)) { - setQueue(parsed); + // queueCommand is usable before this async read resolves (cold + // start, or replay adding state). Merge instead of overwriting so a + // command queued during the load window isn't clobbered by the + // persisted snapshot. Dedupe by id; stored entries come first. + setQueue((current) => { + if (current.length === 0) return parsed; + const storedIds = new Set(parsed.map((cmd: QueuedCommand) => cmd.id)); + return [...parsed, ...current.filter((cmd) => !storedIds.has(cmd.id))]; + }); } } } catch (error) {