From 6b765e86d6a01a7be11a550f8f4d8c994d2218fe Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Fri, 19 Jun 2026 23:38:33 +0100 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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'