elsabro 2.3.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +698 -20
  2. package/bin/install.js +0 -0
  3. package/flows/development-flow.json +452 -0
  4. package/flows/quick-flow.json +118 -0
  5. package/hooks/hooks-config-updated.json +285 -0
  6. package/hooks/skill-discovery.sh +539 -0
  7. package/package.json +3 -2
  8. package/references/SYSTEM_INDEX.md +400 -5
  9. package/references/agent-marketplace.md +2274 -0
  10. package/references/agent-protocol.md +1126 -0
  11. package/references/ai-code-suggestions.md +2413 -0
  12. package/references/checkpointing.md +595 -0
  13. package/references/collaboration-patterns.md +851 -0
  14. package/references/collaborative-sessions.md +1081 -0
  15. package/references/configuration-management.md +1810 -0
  16. package/references/cost-tracking.md +1095 -0
  17. package/references/enterprise-sso.md +2001 -0
  18. package/references/error-contracts-v2.md +968 -0
  19. package/references/event-driven.md +1031 -0
  20. package/references/flow-orchestration.md +940 -0
  21. package/references/flow-visualization.md +1557 -0
  22. package/references/ide-integrations.md +3513 -0
  23. package/references/interrupt-system.md +681 -0
  24. package/references/kubernetes-deployment.md +3099 -0
  25. package/references/memory-system.md +683 -0
  26. package/references/mobile-companion.md +3236 -0
  27. package/references/multi-llm-providers.md +2494 -0
  28. package/references/multi-project-memory.md +1182 -0
  29. package/references/observability.md +793 -0
  30. package/references/output-schemas.md +858 -0
  31. package/references/performance-profiler.md +955 -0
  32. package/references/plugin-system.md +1526 -0
  33. package/references/prompt-management.md +292 -0
  34. package/references/sandbox-execution.md +303 -0
  35. package/references/security-system.md +1253 -0
  36. package/references/skill-marketplace-integration.md +3901 -0
  37. package/references/streaming.md +696 -0
  38. package/references/testing-framework.md +1151 -0
  39. package/references/time-travel.md +802 -0
  40. package/references/tool-registry.md +886 -0
  41. package/references/voice-commands.md +3296 -0
  42. package/templates/agent-marketplace-config.json +220 -0
  43. package/templates/agent-protocol-config.json +136 -0
  44. package/templates/ai-suggestions-config.json +100 -0
  45. package/templates/checkpoint-state.json +61 -0
  46. package/templates/collaboration-config.json +157 -0
  47. package/templates/collaborative-sessions-config.json +153 -0
  48. package/templates/configuration-config.json +245 -0
  49. package/templates/cost-tracking-config.json +148 -0
  50. package/templates/enterprise-sso-config.json +438 -0
  51. package/templates/events-config.json +148 -0
  52. package/templates/flow-visualization-config.json +196 -0
  53. package/templates/ide-integrations-config.json +442 -0
  54. package/templates/kubernetes-config.json +764 -0
  55. package/templates/memory-state.json +84 -0
  56. package/templates/mobile-companion-config.json +600 -0
  57. package/templates/multi-llm-config.json +544 -0
  58. package/templates/multi-project-memory-config.json +145 -0
  59. package/templates/observability-config.json +109 -0
  60. package/templates/performance-profiler-config.json +125 -0
  61. package/templates/plugin-config.json +170 -0
  62. package/templates/prompt-management-config.json +86 -0
  63. package/templates/sandbox-config.json +185 -0
  64. package/templates/schemas-config.json +65 -0
  65. package/templates/security-config.json +120 -0
  66. package/templates/skill-marketplace-config.json +441 -0
  67. package/templates/streaming-config.json +72 -0
  68. package/templates/testing-config.json +81 -0
  69. package/templates/timetravel-config.json +62 -0
  70. package/templates/tool-registry-config.json +109 -0
  71. package/templates/voice-commands-config.json +658 -0
@@ -0,0 +1,3236 @@
1
+ # ELSABRO Mobile Companion v3.7
2
+
3
+ ## Technical Reference Documentation
4
+
5
+ > React Native + Expo SDK 52+ mobile application for ELSABRO AI orchestration system
6
+
7
+ ---
8
+
9
+ ## Table of Contents
10
+
11
+ 1. [Mobile App Architecture](#1-mobileapparchitecture)
12
+ 2. [Session Sync](#2-sessionsync)
13
+ 3. [Push Notification Manager](#3-pushnotificationmanager)
14
+ 4. [Voice Input Handler](#4-voiceinputhandler)
15
+ 5. [Offline Mode](#5-offlinemode)
16
+ 6. [Biometric Auth](#6-biometricauth)
17
+ 7. [Widget Support](#7-widget-support)
18
+ 8. [Commands Reference](#8-commands)
19
+
20
+ ---
21
+
22
+ ## Architecture Overview
23
+
24
+ ```
25
+ +------------------------------------------------------------------+
26
+ | ELSABRO Mobile Companion |
27
+ +------------------------------------------------------------------+
28
+ | |
29
+ | +---------------------+ +---------------------+ |
30
+ | | Presentation | | Navigation | |
31
+ | | Layer | | (expo-router) | |
32
+ | +----------+----------+ +----------+----------+ |
33
+ | | | |
34
+ | +----------v--------------------------v----------+ |
35
+ | | State Management (Zustand) | |
36
+ | | +------------+ +------------+ +----------+ | |
37
+ | | | SessionStore| | AuthStore | | UIStore | | |
38
+ | | +------------+ +------------+ +----------+ | |
39
+ | +------------------------+-----------------------+ |
40
+ | | |
41
+ | +------------------------v-----------------------+ |
42
+ | | Data Layer (React Query) | |
43
+ | | +----------+ +-------------+ +-----------+ | |
44
+ | | | Queries | | Mutations | | Cache | | |
45
+ | | +----------+ +-------------+ +-----------+ | |
46
+ | +------------------------+-----------------------+ |
47
+ | | |
48
+ | +------------------------v-----------------------+ |
49
+ | | Native Modules | |
50
+ | | +-------+ +--------+ +------+ +------------+ | |
51
+ | | |Biometric| |Voice | |Push | |Widgets | | |
52
+ | | +-------+ +--------+ +------+ +------------+ | |
53
+ | +------------------------------------------------+ |
54
+ | | |
55
+ | +------------------------v-----------------------+ |
56
+ | | Network Layer (WebSocket + REST) | |
57
+ | +------------------------------------------------+ |
58
+ | | |
59
+ +---------------------------v---------------------------------------+
60
+ |
61
+ +-------------v--------------+
62
+ | ELSABRO Backend API |
63
+ | (WebSocket + REST) |
64
+ +----------------------------+
65
+ ```
66
+
67
+ ---
68
+
69
+ ## 1. MobileAppArchitecture
70
+
71
+ ### Project Structure (Expo SDK 52+)
72
+
73
+ ```
74
+ elsabro-mobile/
75
+ +-- app/ # expo-router pages
76
+ | +-- (tabs)/ # Tab navigation group
77
+ | | +-- index.tsx # Dashboard
78
+ | | +-- sessions.tsx # Active sessions
79
+ | | +-- agents.tsx # Agent management
80
+ | | +-- settings.tsx # App settings
81
+ | | +-- _layout.tsx # Tab layout
82
+ | +-- session/
83
+ | | +-- [id].tsx # Session detail
84
+ | +-- auth/
85
+ | | +-- login.tsx # Login screen
86
+ | | +-- biometric.tsx # Biometric setup
87
+ | +-- _layout.tsx # Root layout
88
+ | +-- +not-found.tsx # 404 handler
89
+ +-- src/
90
+ | +-- components/ # Reusable components
91
+ | | +-- ui/ # Base UI components
92
+ | | +-- sessions/ # Session-specific
93
+ | | +-- agents/ # Agent-specific
94
+ | +-- stores/ # Zustand stores
95
+ | | +-- sessionStore.ts
96
+ | | +-- authStore.ts
97
+ | | +-- notificationStore.ts
98
+ | | +-- offlineStore.ts
99
+ | +-- hooks/ # Custom hooks
100
+ | | +-- useSession.ts
101
+ | | +-- useWebSocket.ts
102
+ | | +-- useVoice.ts
103
+ | | +-- useBiometric.ts
104
+ | +-- services/ # API services
105
+ | | +-- api.ts
106
+ | | +-- websocket.ts
107
+ | | +-- notifications.ts
108
+ | +-- types/ # TypeScript types
109
+ | +-- utils/ # Utilities
110
+ | +-- constants/ # App constants
111
+ +-- assets/ # Static assets
112
+ +-- widgets/ # Native widgets
113
+ | +-- ios/ # iOS WidgetKit
114
+ | +-- android/ # Android widgets
115
+ +-- app.json # Expo config
116
+ +-- eas.json # EAS Build config
117
+ +-- tsconfig.json
118
+ +-- package.json
119
+ ```
120
+
121
+ ### Core Dependencies
122
+
123
+ ```json
124
+ {
125
+ "dependencies": {
126
+ "expo": "~52.0.0",
127
+ "expo-router": "~4.0.0",
128
+ "expo-notifications": "~0.29.0",
129
+ "expo-local-authentication": "~15.0.0",
130
+ "expo-secure-store": "~14.0.0",
131
+ "expo-speech": "~13.0.0",
132
+ "expo-haptics": "~14.0.0",
133
+ "@react-native-voice/voice": "^3.3.0",
134
+ "@tanstack/react-query": "^5.60.0",
135
+ "zustand": "^5.0.0",
136
+ "react-native-reanimated": "~3.16.0",
137
+ "react-native-gesture-handler": "~2.20.0"
138
+ }
139
+ }
140
+ ```
141
+
142
+ ### Root Layout Configuration
143
+
144
+ ```typescript
145
+ // app/_layout.tsx
146
+ import { Stack } from 'expo-router';
147
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
148
+ import { GestureHandlerRootView } from 'react-native-gesture-handler';
149
+ import { useEffect } from 'react';
150
+ import { useAuthStore } from '@/stores/authStore';
151
+ import { useNotificationStore } from '@/stores/notificationStore';
152
+ import { WebSocketProvider } from '@/providers/WebSocketProvider';
153
+
154
+ const queryClient = new QueryClient({
155
+ defaultOptions: {
156
+ queries: {
157
+ staleTime: 1000 * 60 * 5, // 5 minutes
158
+ gcTime: 1000 * 60 * 30, // 30 minutes
159
+ retry: 3,
160
+ retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
161
+ },
162
+ },
163
+ });
164
+
165
+ export default function RootLayout() {
166
+ const { initialize: initAuth } = useAuthStore();
167
+ const { registerForPushNotifications } = useNotificationStore();
168
+
169
+ useEffect(() => {
170
+ initAuth();
171
+ registerForPushNotifications();
172
+ }, []);
173
+
174
+ return (
175
+ <GestureHandlerRootView style={{ flex: 1 }}>
176
+ <QueryClientProvider client={queryClient}>
177
+ <WebSocketProvider>
178
+ <Stack
179
+ screenOptions={{
180
+ headerShown: false,
181
+ animation: 'slide_from_right',
182
+ }}
183
+ >
184
+ <Stack.Screen name="(tabs)" />
185
+ <Stack.Screen
186
+ name="session/[id]"
187
+ options={{ presentation: 'modal' }}
188
+ />
189
+ <Stack.Screen
190
+ name="auth/login"
191
+ options={{ presentation: 'fullScreenModal' }}
192
+ />
193
+ </Stack>
194
+ </WebSocketProvider>
195
+ </QueryClientProvider>
196
+ </GestureHandlerRootView>
197
+ );
198
+ }
199
+ ```
200
+
201
+ ### Zustand Store Pattern
202
+
203
+ ```typescript
204
+ // src/stores/sessionStore.ts
205
+ import { create } from 'zustand';
206
+ import { persist, createJSONStorage } from 'zustand/middleware';
207
+ import AsyncStorage from '@react-native-async-storage/async-storage';
208
+ import { immer } from 'zustand/middleware/immer';
209
+
210
+ interface Session {
211
+ id: string;
212
+ name: string;
213
+ status: 'active' | 'paused' | 'completed' | 'error';
214
+ agents: string[];
215
+ currentTask: string | null;
216
+ progress: number;
217
+ startedAt: string;
218
+ lastActivity: string;
219
+ }
220
+
221
+ interface SessionState {
222
+ sessions: Record<string, Session>;
223
+ activeSessionId: string | null;
224
+ isConnected: boolean;
225
+ lastSyncTimestamp: number;
226
+ }
227
+
228
+ interface SessionActions {
229
+ setActiveSession: (id: string | null) => void;
230
+ updateSession: (id: string, updates: Partial<Session>) => void;
231
+ addSession: (session: Session) => void;
232
+ removeSession: (id: string) => void;
233
+ syncSessions: (sessions: Session[]) => void;
234
+ setConnected: (connected: boolean) => void;
235
+ }
236
+
237
+ export const useSessionStore = create<SessionState & SessionActions>()(
238
+ persist(
239
+ immer((set, get) => ({
240
+ // State
241
+ sessions: {},
242
+ activeSessionId: null,
243
+ isConnected: false,
244
+ lastSyncTimestamp: 0,
245
+
246
+ // Actions
247
+ setActiveSession: (id) =>
248
+ set((state) => {
249
+ state.activeSessionId = id;
250
+ }),
251
+
252
+ updateSession: (id, updates) =>
253
+ set((state) => {
254
+ if (state.sessions[id]) {
255
+ Object.assign(state.sessions[id], updates);
256
+ state.sessions[id].lastActivity = new Date().toISOString();
257
+ }
258
+ }),
259
+
260
+ addSession: (session) =>
261
+ set((state) => {
262
+ state.sessions[session.id] = session;
263
+ }),
264
+
265
+ removeSession: (id) =>
266
+ set((state) => {
267
+ delete state.sessions[id];
268
+ if (state.activeSessionId === id) {
269
+ state.activeSessionId = null;
270
+ }
271
+ }),
272
+
273
+ syncSessions: (sessions) =>
274
+ set((state) => {
275
+ sessions.forEach((session) => {
276
+ state.sessions[session.id] = session;
277
+ });
278
+ state.lastSyncTimestamp = Date.now();
279
+ }),
280
+
281
+ setConnected: (connected) =>
282
+ set((state) => {
283
+ state.isConnected = connected;
284
+ }),
285
+ })),
286
+ {
287
+ name: 'elsabro-sessions',
288
+ storage: createJSONStorage(() => AsyncStorage),
289
+ partialize: (state) => ({
290
+ sessions: state.sessions,
291
+ activeSessionId: state.activeSessionId,
292
+ lastSyncTimestamp: state.lastSyncTimestamp,
293
+ }),
294
+ }
295
+ )
296
+ );
297
+ ```
298
+
299
+ ### React Query API Layer
300
+
301
+ ```typescript
302
+ // src/services/api.ts
303
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
304
+ import { useAuthStore } from '@/stores/authStore';
305
+
306
+ const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'https://api.elsabro.io';
307
+
308
+ interface ApiOptions {
309
+ method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
310
+ body?: unknown;
311
+ headers?: Record<string, string>;
312
+ }
313
+
314
+ async function apiRequest<T>(endpoint: string, options: ApiOptions = {}): Promise<T> {
315
+ const token = useAuthStore.getState().accessToken;
316
+
317
+ const response = await fetch(`${API_BASE_URL}${endpoint}`, {
318
+ method: options.method || 'GET',
319
+ headers: {
320
+ 'Content-Type': 'application/json',
321
+ 'Authorization': token ? `Bearer ${token}` : '',
322
+ 'X-Client-Version': '3.7.0',
323
+ 'X-Platform': Platform.OS,
324
+ ...options.headers,
325
+ },
326
+ body: options.body ? JSON.stringify(options.body) : undefined,
327
+ });
328
+
329
+ if (!response.ok) {
330
+ const error = await response.json().catch(() => ({}));
331
+ throw new ApiError(response.status, error.message || 'Request failed');
332
+ }
333
+
334
+ return response.json();
335
+ }
336
+
337
+ // Query Keys Factory
338
+ export const queryKeys = {
339
+ sessions: {
340
+ all: ['sessions'] as const,
341
+ list: (filters?: SessionFilters) => [...queryKeys.sessions.all, 'list', filters] as const,
342
+ detail: (id: string) => [...queryKeys.sessions.all, 'detail', id] as const,
343
+ logs: (id: string) => [...queryKeys.sessions.all, 'logs', id] as const,
344
+ },
345
+ agents: {
346
+ all: ['agents'] as const,
347
+ available: () => [...queryKeys.agents.all, 'available'] as const,
348
+ detail: (id: string) => [...queryKeys.agents.all, 'detail', id] as const,
349
+ },
350
+ user: {
351
+ profile: ['user', 'profile'] as const,
352
+ preferences: ['user', 'preferences'] as const,
353
+ },
354
+ };
355
+
356
+ // Sessions Hooks
357
+ export function useSessions(filters?: SessionFilters) {
358
+ return useQuery({
359
+ queryKey: queryKeys.sessions.list(filters),
360
+ queryFn: () => apiRequest<Session[]>('/sessions', {
361
+ method: 'GET',
362
+ }),
363
+ staleTime: 1000 * 30, // 30 seconds for active data
364
+ });
365
+ }
366
+
367
+ export function useSession(id: string) {
368
+ return useQuery({
369
+ queryKey: queryKeys.sessions.detail(id),
370
+ queryFn: () => apiRequest<SessionDetail>(`/sessions/${id}`),
371
+ enabled: !!id,
372
+ refetchInterval: 5000, // Poll every 5 seconds for active session
373
+ });
374
+ }
375
+
376
+ export function useSessionLogs(id: string, options?: { enabled?: boolean }) {
377
+ return useQuery({
378
+ queryKey: queryKeys.sessions.logs(id),
379
+ queryFn: () => apiRequest<SessionLog[]>(`/sessions/${id}/logs`),
380
+ enabled: options?.enabled ?? !!id,
381
+ refetchInterval: 2000,
382
+ });
383
+ }
384
+
385
+ // Mutations
386
+ export function useCreateSession() {
387
+ const queryClient = useQueryClient();
388
+
389
+ return useMutation({
390
+ mutationFn: (data: CreateSessionRequest) =>
391
+ apiRequest<Session>('/sessions', {
392
+ method: 'POST',
393
+ body: data,
394
+ }),
395
+ onSuccess: (newSession) => {
396
+ queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
397
+ queryClient.setQueryData(
398
+ queryKeys.sessions.detail(newSession.id),
399
+ newSession
400
+ );
401
+ },
402
+ });
403
+ }
404
+
405
+ export function usePauseSession() {
406
+ const queryClient = useQueryClient();
407
+
408
+ return useMutation({
409
+ mutationFn: (id: string) =>
410
+ apiRequest<Session>(`/sessions/${id}/pause`, { method: 'POST' }),
411
+ onMutate: async (id) => {
412
+ await queryClient.cancelQueries({ queryKey: queryKeys.sessions.detail(id) });
413
+ const previous = queryClient.getQueryData(queryKeys.sessions.detail(id));
414
+
415
+ queryClient.setQueryData(queryKeys.sessions.detail(id), (old: Session) => ({
416
+ ...old,
417
+ status: 'paused',
418
+ }));
419
+
420
+ return { previous };
421
+ },
422
+ onError: (err, id, context) => {
423
+ if (context?.previous) {
424
+ queryClient.setQueryData(queryKeys.sessions.detail(id), context.previous);
425
+ }
426
+ },
427
+ onSettled: (_, __, id) => {
428
+ queryClient.invalidateQueries({ queryKey: queryKeys.sessions.detail(id) });
429
+ },
430
+ });
431
+ }
432
+
433
+ export function useSendCommand() {
434
+ return useMutation({
435
+ mutationFn: ({ sessionId, command }: { sessionId: string; command: string }) =>
436
+ apiRequest<CommandResponse>(`/sessions/${sessionId}/command`, {
437
+ method: 'POST',
438
+ body: { command },
439
+ }),
440
+ });
441
+ }
442
+ ```
443
+
444
+ ---
445
+
446
+ ## 2. SessionSync
447
+
448
+ ### WebSocket Real-Time Synchronization
449
+
450
+ ```
451
+ +-------------------+ +-------------------+
452
+ | Mobile Client | | ELSABRO Server |
453
+ +-------------------+ +-------------------+
454
+ | |
455
+ | 1. WS Connect + Auth Token |
456
+ |----------------------------->|
457
+ | |
458
+ | 2. Connection Confirmed |
459
+ |<-----------------------------|
460
+ | |
461
+ | 3. Subscribe to Session |
462
+ |----------------------------->|
463
+ | |
464
+ | 4. Initial State Snapshot |
465
+ |<-----------------------------|
466
+ | |
467
+ | 5. Real-time Updates |
468
+ |<-----------------------------|
469
+ |<-----------------------------|
470
+ | |
471
+ | 6. Client Action |
472
+ |----------------------------->|
473
+ | |
474
+ | 7. Action Confirmed |
475
+ |<-----------------------------|
476
+ | |
477
+ ```
478
+
479
+ ### WebSocket Service Implementation
480
+
481
+ ```typescript
482
+ // src/services/websocket.ts
483
+ import { useCallback, useEffect, useRef, useState } from 'react';
484
+ import { AppState, AppStateStatus } from 'react-native';
485
+ import { useAuthStore } from '@/stores/authStore';
486
+ import { useSessionStore } from '@/stores/sessionStore';
487
+ import { useOfflineStore } from '@/stores/offlineStore';
488
+
489
+ const WS_URL = process.env.EXPO_PUBLIC_WS_URL || 'wss://ws.elsabro.io';
490
+
491
+ interface WebSocketMessage {
492
+ type: string;
493
+ payload: unknown;
494
+ timestamp: number;
495
+ messageId: string;
496
+ }
497
+
498
+ interface SessionUpdate {
499
+ sessionId: string;
500
+ type: 'status' | 'progress' | 'log' | 'agent' | 'task' | 'error';
501
+ data: unknown;
502
+ version: number;
503
+ }
504
+
505
+ type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
506
+
507
+ class ElsabroWebSocket {
508
+ private ws: WebSocket | null = null;
509
+ private reconnectAttempts = 0;
510
+ private maxReconnectAttempts = 10;
511
+ private reconnectDelay = 1000;
512
+ private heartbeatInterval: NodeJS.Timeout | null = null;
513
+ private messageQueue: WebSocketMessage[] = [];
514
+ private subscriptions = new Set<string>();
515
+ private stateVersions = new Map<string, number>();
516
+
517
+ private listeners = {
518
+ onConnect: new Set<() => void>(),
519
+ onDisconnect: new Set<(reason: string) => void>(),
520
+ onMessage: new Set<(message: WebSocketMessage) => void>(),
521
+ onError: new Set<(error: Error) => void>(),
522
+ onStateChange: new Set<(state: ConnectionState) => void>(),
523
+ };
524
+
525
+ private connectionState: ConnectionState = 'disconnected';
526
+
527
+ connect(token: string): void {
528
+ if (this.ws?.readyState === WebSocket.OPEN) return;
529
+
530
+ this.setConnectionState('connecting');
531
+
532
+ this.ws = new WebSocket(`${WS_URL}?token=${token}`);
533
+
534
+ this.ws.onopen = () => {
535
+ console.log('[WS] Connected');
536
+ this.setConnectionState('connected');
537
+ this.reconnectAttempts = 0;
538
+ this.startHeartbeat();
539
+ this.flushMessageQueue();
540
+ this.resubscribeAll();
541
+ this.listeners.onConnect.forEach(cb => cb());
542
+ };
543
+
544
+ this.ws.onmessage = (event) => {
545
+ try {
546
+ const message: WebSocketMessage = JSON.parse(event.data);
547
+ this.handleMessage(message);
548
+ } catch (error) {
549
+ console.error('[WS] Parse error:', error);
550
+ }
551
+ };
552
+
553
+ this.ws.onclose = (event) => {
554
+ console.log('[WS] Disconnected:', event.code, event.reason);
555
+ this.setConnectionState('disconnected');
556
+ this.stopHeartbeat();
557
+ this.listeners.onDisconnect.forEach(cb => cb(event.reason));
558
+ this.attemptReconnect(token);
559
+ };
560
+
561
+ this.ws.onerror = (error) => {
562
+ console.error('[WS] Error:', error);
563
+ this.listeners.onError.forEach(cb => cb(new Error('WebSocket error')));
564
+ };
565
+ }
566
+
567
+ private handleMessage(message: WebSocketMessage): void {
568
+ switch (message.type) {
569
+ case 'session:update':
570
+ this.handleSessionUpdate(message.payload as SessionUpdate);
571
+ break;
572
+ case 'session:sync':
573
+ this.handleFullSync(message.payload);
574
+ break;
575
+ case 'pong':
576
+ // Heartbeat response
577
+ break;
578
+ case 'error':
579
+ console.error('[WS] Server error:', message.payload);
580
+ break;
581
+ default:
582
+ this.listeners.onMessage.forEach(cb => cb(message));
583
+ }
584
+ }
585
+
586
+ private handleSessionUpdate(update: SessionUpdate): void {
587
+ const currentVersion = this.stateVersions.get(update.sessionId) || 0;
588
+
589
+ // Conflict detection - request full sync if version mismatch
590
+ if (update.version > currentVersion + 1) {
591
+ console.log('[WS] Version gap detected, requesting full sync');
592
+ this.requestFullSync(update.sessionId);
593
+ return;
594
+ }
595
+
596
+ if (update.version <= currentVersion) {
597
+ console.log('[WS] Stale update ignored');
598
+ return;
599
+ }
600
+
601
+ this.stateVersions.set(update.sessionId, update.version);
602
+
603
+ const sessionStore = useSessionStore.getState();
604
+
605
+ switch (update.type) {
606
+ case 'status':
607
+ sessionStore.updateSession(update.sessionId, {
608
+ status: update.data as Session['status']
609
+ });
610
+ break;
611
+ case 'progress':
612
+ sessionStore.updateSession(update.sessionId, {
613
+ progress: update.data as number
614
+ });
615
+ break;
616
+ case 'task':
617
+ sessionStore.updateSession(update.sessionId, {
618
+ currentTask: update.data as string
619
+ });
620
+ break;
621
+ case 'error':
622
+ sessionStore.updateSession(update.sessionId, {
623
+ status: 'error',
624
+ error: update.data as string,
625
+ });
626
+ break;
627
+ }
628
+ }
629
+
630
+ private handleFullSync(payload: unknown): void {
631
+ const { sessions, versions } = payload as {
632
+ sessions: Session[];
633
+ versions: Record<string, number>;
634
+ };
635
+
636
+ useSessionStore.getState().syncSessions(sessions);
637
+
638
+ Object.entries(versions).forEach(([id, version]) => {
639
+ this.stateVersions.set(id, version);
640
+ });
641
+ }
642
+
643
+ private requestFullSync(sessionId: string): void {
644
+ this.send({
645
+ type: 'session:sync:request',
646
+ payload: { sessionId },
647
+ timestamp: Date.now(),
648
+ messageId: crypto.randomUUID(),
649
+ });
650
+ }
651
+
652
+ subscribe(sessionId: string): void {
653
+ this.subscriptions.add(sessionId);
654
+
655
+ if (this.isConnected()) {
656
+ this.send({
657
+ type: 'session:subscribe',
658
+ payload: { sessionId },
659
+ timestamp: Date.now(),
660
+ messageId: crypto.randomUUID(),
661
+ });
662
+ }
663
+ }
664
+
665
+ unsubscribe(sessionId: string): void {
666
+ this.subscriptions.delete(sessionId);
667
+ this.stateVersions.delete(sessionId);
668
+
669
+ if (this.isConnected()) {
670
+ this.send({
671
+ type: 'session:unsubscribe',
672
+ payload: { sessionId },
673
+ timestamp: Date.now(),
674
+ messageId: crypto.randomUUID(),
675
+ });
676
+ }
677
+ }
678
+
679
+ private resubscribeAll(): void {
680
+ this.subscriptions.forEach(sessionId => {
681
+ this.send({
682
+ type: 'session:subscribe',
683
+ payload: { sessionId },
684
+ timestamp: Date.now(),
685
+ messageId: crypto.randomUUID(),
686
+ });
687
+ });
688
+ }
689
+
690
+ send(message: WebSocketMessage): void {
691
+ if (this.isConnected()) {
692
+ this.ws!.send(JSON.stringify(message));
693
+ } else {
694
+ // Queue for later
695
+ this.messageQueue.push(message);
696
+ useOfflineStore.getState().addPendingAction({
697
+ id: message.messageId,
698
+ type: 'websocket',
699
+ data: message,
700
+ createdAt: Date.now(),
701
+ });
702
+ }
703
+ }
704
+
705
+ private flushMessageQueue(): void {
706
+ while (this.messageQueue.length > 0) {
707
+ const message = this.messageQueue.shift()!;
708
+ this.ws!.send(JSON.stringify(message));
709
+ }
710
+ }
711
+
712
+ private startHeartbeat(): void {
713
+ this.heartbeatInterval = setInterval(() => {
714
+ if (this.isConnected()) {
715
+ this.send({
716
+ type: 'ping',
717
+ payload: null,
718
+ timestamp: Date.now(),
719
+ messageId: crypto.randomUUID(),
720
+ });
721
+ }
722
+ }, 30000);
723
+ }
724
+
725
+ private stopHeartbeat(): void {
726
+ if (this.heartbeatInterval) {
727
+ clearInterval(this.heartbeatInterval);
728
+ this.heartbeatInterval = null;
729
+ }
730
+ }
731
+
732
+ private attemptReconnect(token: string): void {
733
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
734
+ console.log('[WS] Max reconnect attempts reached');
735
+ return;
736
+ }
737
+
738
+ this.setConnectionState('reconnecting');
739
+ this.reconnectAttempts++;
740
+
741
+ const delay = Math.min(
742
+ this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
743
+ 30000
744
+ ) + Math.random() * 1000; // Jitter
745
+
746
+ setTimeout(() => {
747
+ console.log(`[WS] Reconnect attempt ${this.reconnectAttempts}`);
748
+ this.connect(token);
749
+ }, delay);
750
+ }
751
+
752
+ private setConnectionState(state: ConnectionState): void {
753
+ this.connectionState = state;
754
+ this.listeners.onStateChange.forEach(cb => cb(state));
755
+ useSessionStore.getState().setConnected(state === 'connected');
756
+ }
757
+
758
+ isConnected(): boolean {
759
+ return this.ws?.readyState === WebSocket.OPEN;
760
+ }
761
+
762
+ disconnect(): void {
763
+ this.stopHeartbeat();
764
+ this.ws?.close(1000, 'Client disconnect');
765
+ this.ws = null;
766
+ this.setConnectionState('disconnected');
767
+ }
768
+
769
+ on<K extends keyof typeof this.listeners>(
770
+ event: K,
771
+ callback: Parameters<typeof this.listeners[K]['add']>[0]
772
+ ): () => void {
773
+ (this.listeners[event] as Set<unknown>).add(callback);
774
+ return () => (this.listeners[event] as Set<unknown>).delete(callback);
775
+ }
776
+ }
777
+
778
+ export const wsClient = new ElsabroWebSocket();
779
+
780
+ // React Hook
781
+ export function useWebSocket() {
782
+ const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
783
+ const { accessToken } = useAuthStore();
784
+
785
+ useEffect(() => {
786
+ if (!accessToken) return;
787
+
788
+ wsClient.connect(accessToken);
789
+
790
+ const unsubscribe = wsClient.on('onStateChange', setConnectionState);
791
+
792
+ // Handle app state changes
793
+ const handleAppState = (state: AppStateStatus) => {
794
+ if (state === 'active' && !wsClient.isConnected()) {
795
+ wsClient.connect(accessToken);
796
+ }
797
+ };
798
+
799
+ const subscription = AppState.addEventListener('change', handleAppState);
800
+
801
+ return () => {
802
+ unsubscribe();
803
+ subscription.remove();
804
+ };
805
+ }, [accessToken]);
806
+
807
+ const subscribe = useCallback((sessionId: string) => {
808
+ wsClient.subscribe(sessionId);
809
+ return () => wsClient.unsubscribe(sessionId);
810
+ }, []);
811
+
812
+ return {
813
+ connectionState,
814
+ isConnected: connectionState === 'connected',
815
+ subscribe,
816
+ send: wsClient.send.bind(wsClient),
817
+ };
818
+ }
819
+ ```
820
+
821
+ ### Deep Linking Configuration
822
+
823
+ ```typescript
824
+ // app.json (excerpt)
825
+ {
826
+ "expo": {
827
+ "scheme": "elsabro",
828
+ "ios": {
829
+ "bundleIdentifier": "io.elsabro.companion",
830
+ "associatedDomains": [
831
+ "applinks:elsabro.io",
832
+ "applinks:*.elsabro.io"
833
+ ]
834
+ },
835
+ "android": {
836
+ "package": "io.elsabro.companion",
837
+ "intentFilters": [
838
+ {
839
+ "action": "VIEW",
840
+ "autoVerify": true,
841
+ "data": [
842
+ {
843
+ "scheme": "https",
844
+ "host": "elsabro.io",
845
+ "pathPrefix": "/session"
846
+ },
847
+ {
848
+ "scheme": "elsabro"
849
+ }
850
+ ],
851
+ "category": ["BROWSABLE", "DEFAULT"]
852
+ }
853
+ ]
854
+ }
855
+ }
856
+ }
857
+ ```
858
+
859
+ ```typescript
860
+ // src/hooks/useDeepLink.ts
861
+ import { useEffect } from 'react';
862
+ import { useRouter } from 'expo-router';
863
+ import * as Linking from 'expo-linking';
864
+ import { useAuthStore } from '@/stores/authStore';
865
+
866
+ export function useDeepLink() {
867
+ const router = useRouter();
868
+ const { isAuthenticated } = useAuthStore();
869
+
870
+ useEffect(() => {
871
+ // Handle initial URL
872
+ Linking.getInitialURL().then(handleUrl);
873
+
874
+ // Handle incoming URLs
875
+ const subscription = Linking.addEventListener('url', ({ url }) => {
876
+ handleUrl(url);
877
+ });
878
+
879
+ return () => subscription.remove();
880
+ }, [isAuthenticated]);
881
+
882
+ function handleUrl(url: string | null) {
883
+ if (!url) return;
884
+
885
+ const parsed = Linking.parse(url);
886
+
887
+ // Route patterns:
888
+ // elsabro://session/abc123
889
+ // https://elsabro.io/session/abc123
890
+ // elsabro://session/abc123/approve?taskId=xyz
891
+
892
+ if (parsed.path?.startsWith('session/')) {
893
+ const sessionId = parsed.path.split('/')[1];
894
+
895
+ if (!isAuthenticated) {
896
+ // Store for after auth
897
+ useAuthStore.getState().setPendingDeepLink(url);
898
+ router.push('/auth/login');
899
+ return;
900
+ }
901
+
902
+ if (parsed.queryParams?.action === 'approve') {
903
+ router.push({
904
+ pathname: `/session/${sessionId}`,
905
+ params: {
906
+ action: 'approve',
907
+ taskId: parsed.queryParams.taskId as string,
908
+ },
909
+ });
910
+ } else {
911
+ router.push(`/session/${sessionId}`);
912
+ }
913
+ }
914
+ }
915
+
916
+ return { handleUrl };
917
+ }
918
+ ```
919
+
920
+ ### Device Handoff
921
+
922
+ ```typescript
923
+ // src/services/handoff.ts
924
+ import * as Linking from 'expo-linking';
925
+ import { Platform } from 'react-native';
926
+
927
+ interface HandoffState {
928
+ sessionId: string;
929
+ scrollPosition?: number;
930
+ selectedTab?: string;
931
+ timestamp: number;
932
+ }
933
+
934
+ export function generateHandoffUrl(state: HandoffState): string {
935
+ const params = new URLSearchParams({
936
+ session: state.sessionId,
937
+ ...(state.scrollPosition && { scroll: state.scrollPosition.toString() }),
938
+ ...(state.selectedTab && { tab: state.selectedTab }),
939
+ ts: state.timestamp.toString(),
940
+ });
941
+
942
+ return `https://elsabro.io/handoff?${params.toString()}`;
943
+ }
944
+
945
+ export function generateQRHandoff(state: HandoffState): string {
946
+ // Returns URL that can be encoded as QR code
947
+ return `elsabro://handoff/${state.sessionId}?` +
948
+ `data=${encodeURIComponent(JSON.stringify(state))}`;
949
+ }
950
+
951
+ // iOS Handoff with NSUserActivity (native module required)
952
+ export async function startHandoffActivity(state: HandoffState): Promise<void> {
953
+ if (Platform.OS !== 'ios') return;
954
+
955
+ // Requires native module for NSUserActivity
956
+ const { HandoffModule } = require('@/native/HandoffModule');
957
+
958
+ await HandoffModule.startActivity({
959
+ activityType: 'io.elsabro.session.view',
960
+ title: `ELSABRO Session`,
961
+ userInfo: state,
962
+ webpageURL: generateHandoffUrl(state),
963
+ });
964
+ }
965
+ ```
966
+
967
+ ---
968
+
969
+ ## 3. PushNotificationManager
970
+
971
+ ### Notification Flow Architecture
972
+
973
+ ```
974
+ +------------------+ +------------------+ +------------------+
975
+ | ELSABRO Server |---->| FCM / APNS |---->| Mobile App |
976
+ +------------------+ +------------------+ +------------------+
977
+ | | |
978
+ | 1. Event trigger | 2. Push delivery | 3. Handle
979
+ | (task complete, | (data + display) | notification
980
+ | build failed, | |
981
+ | approval needed) | |
982
+ +------------------------+------------------------+
983
+ ```
984
+
985
+ ### Notification Types
986
+
987
+ | Type | Priority | Display | Actions |
988
+ |------|----------|---------|---------|
989
+ | `task_completed` | Normal | Alert | View, Archive |
990
+ | `build_failed` | High | Alert + Sound | View Logs, Retry |
991
+ | `approval_needed` | Critical | Alert + Badge | Approve, Reject, Details |
992
+ | `session_update` | Low | Silent | - |
993
+ | `agent_error` | High | Alert | View, Restart Agent |
994
+ | `sync_reminder` | Normal | Alert | Sync Now, Later |
995
+
996
+ ### Notification Service Implementation
997
+
998
+ ```typescript
999
+ // src/services/notifications.ts
1000
+ import * as Notifications from 'expo-notifications';
1001
+ import * as Device from 'expo-device';
1002
+ import { Platform } from 'react-native';
1003
+ import Constants from 'expo-constants';
1004
+ import { router } from 'expo-router';
1005
+ import { useSessionStore } from '@/stores/sessionStore';
1006
+
1007
+ // Configure notification handling
1008
+ Notifications.setNotificationHandler({
1009
+ handleNotification: async (notification) => {
1010
+ const data = notification.request.content.data;
1011
+
1012
+ // Silent push for background sync
1013
+ if (data.silent) {
1014
+ await handleSilentPush(data);
1015
+ return {
1016
+ shouldShowAlert: false,
1017
+ shouldPlaySound: false,
1018
+ shouldSetBadge: false,
1019
+ };
1020
+ }
1021
+
1022
+ return {
1023
+ shouldShowAlert: true,
1024
+ shouldPlaySound: data.priority === 'high' || data.priority === 'critical',
1025
+ shouldSetBadge: data.type === 'approval_needed',
1026
+ };
1027
+ },
1028
+ });
1029
+
1030
+ interface NotificationData {
1031
+ type: 'task_completed' | 'build_failed' | 'approval_needed' |
1032
+ 'session_update' | 'agent_error' | 'sync_reminder';
1033
+ sessionId: string;
1034
+ taskId?: string;
1035
+ agentId?: string;
1036
+ priority: 'low' | 'normal' | 'high' | 'critical';
1037
+ silent?: boolean;
1038
+ payload?: Record<string, unknown>;
1039
+ }
1040
+
1041
+ async function handleSilentPush(data: NotificationData): Promise<void> {
1042
+ switch (data.type) {
1043
+ case 'session_update':
1044
+ // Trigger background sync
1045
+ const { syncSessions } = useSessionStore.getState();
1046
+ try {
1047
+ const response = await fetch(`${API_URL}/sessions/sync`, {
1048
+ method: 'POST',
1049
+ headers: getAuthHeaders(),
1050
+ });
1051
+ const sessions = await response.json();
1052
+ syncSessions(sessions);
1053
+ } catch (error) {
1054
+ console.error('Background sync failed:', error);
1055
+ }
1056
+ break;
1057
+ }
1058
+ }
1059
+
1060
+ export async function registerForPushNotifications(): Promise<string | null> {
1061
+ if (!Device.isDevice) {
1062
+ console.log('Push notifications require a physical device');
1063
+ return null;
1064
+ }
1065
+
1066
+ // Check permissions
1067
+ const { status: existingStatus } = await Notifications.getPermissionsAsync();
1068
+ let finalStatus = existingStatus;
1069
+
1070
+ if (existingStatus !== 'granted') {
1071
+ const { status } = await Notifications.requestPermissionsAsync({
1072
+ ios: {
1073
+ allowAlert: true,
1074
+ allowBadge: true,
1075
+ allowSound: true,
1076
+ allowCriticalAlerts: true, // For approval_needed
1077
+ },
1078
+ });
1079
+ finalStatus = status;
1080
+ }
1081
+
1082
+ if (finalStatus !== 'granted') {
1083
+ console.log('Push notification permission denied');
1084
+ return null;
1085
+ }
1086
+
1087
+ // Get push token
1088
+ const projectId = Constants.expoConfig?.extra?.eas?.projectId;
1089
+
1090
+ const tokenData = await Notifications.getExpoPushTokenAsync({
1091
+ projectId,
1092
+ });
1093
+
1094
+ // Platform-specific setup
1095
+ if (Platform.OS === 'android') {
1096
+ await setupAndroidChannels();
1097
+ }
1098
+
1099
+ // Register token with backend
1100
+ await registerTokenWithBackend(tokenData.data);
1101
+
1102
+ return tokenData.data;
1103
+ }
1104
+
1105
+ async function setupAndroidChannels(): Promise<void> {
1106
+ // Critical alerts channel
1107
+ await Notifications.setNotificationChannelAsync('critical', {
1108
+ name: 'Critical Alerts',
1109
+ importance: Notifications.AndroidImportance.MAX,
1110
+ vibrationPattern: [0, 250, 250, 250],
1111
+ lightColor: '#FF0000',
1112
+ sound: 'critical.wav',
1113
+ bypassDnd: true,
1114
+ });
1115
+
1116
+ // Build status channel
1117
+ await Notifications.setNotificationChannelAsync('builds', {
1118
+ name: 'Build Status',
1119
+ importance: Notifications.AndroidImportance.HIGH,
1120
+ sound: 'build.wav',
1121
+ });
1122
+
1123
+ // General updates channel
1124
+ await Notifications.setNotificationChannelAsync('updates', {
1125
+ name: 'Session Updates',
1126
+ importance: Notifications.AndroidImportance.DEFAULT,
1127
+ });
1128
+
1129
+ // Silent sync channel
1130
+ await Notifications.setNotificationChannelAsync('sync', {
1131
+ name: 'Background Sync',
1132
+ importance: Notifications.AndroidImportance.MIN,
1133
+ showBadge: false,
1134
+ });
1135
+ }
1136
+
1137
+ async function registerTokenWithBackend(token: string): Promise<void> {
1138
+ await fetch(`${API_URL}/devices/register`, {
1139
+ method: 'POST',
1140
+ headers: {
1141
+ 'Content-Type': 'application/json',
1142
+ ...getAuthHeaders(),
1143
+ },
1144
+ body: JSON.stringify({
1145
+ token,
1146
+ platform: Platform.OS,
1147
+ deviceId: Device.modelId,
1148
+ }),
1149
+ });
1150
+ }
1151
+
1152
+ // Notification response handler
1153
+ export function setupNotificationListeners(): () => void {
1154
+ // Handle notification when app is foregrounded
1155
+ const foregroundSubscription = Notifications.addNotificationReceivedListener(
1156
+ (notification) => {
1157
+ console.log('Notification received:', notification);
1158
+ }
1159
+ );
1160
+
1161
+ // Handle notification tap
1162
+ const responseSubscription = Notifications.addNotificationResponseReceivedListener(
1163
+ (response) => {
1164
+ const data = response.notification.request.content.data as NotificationData;
1165
+ const actionId = response.actionIdentifier;
1166
+
1167
+ handleNotificationAction(data, actionId);
1168
+ }
1169
+ );
1170
+
1171
+ return () => {
1172
+ foregroundSubscription.remove();
1173
+ responseSubscription.remove();
1174
+ };
1175
+ }
1176
+
1177
+ function handleNotificationAction(data: NotificationData, actionId: string): void {
1178
+ switch (data.type) {
1179
+ case 'approval_needed':
1180
+ if (actionId === 'approve') {
1181
+ // Quick approve without opening app fully
1182
+ quickApprove(data.sessionId, data.taskId!);
1183
+ } else if (actionId === 'reject') {
1184
+ quickReject(data.sessionId, data.taskId!);
1185
+ } else {
1186
+ router.push({
1187
+ pathname: `/session/${data.sessionId}`,
1188
+ params: { action: 'approve', taskId: data.taskId },
1189
+ });
1190
+ }
1191
+ break;
1192
+
1193
+ case 'build_failed':
1194
+ if (actionId === 'retry') {
1195
+ retryBuild(data.sessionId);
1196
+ } else {
1197
+ router.push({
1198
+ pathname: `/session/${data.sessionId}`,
1199
+ params: { tab: 'logs' },
1200
+ });
1201
+ }
1202
+ break;
1203
+
1204
+ default:
1205
+ router.push(`/session/${data.sessionId}`);
1206
+ }
1207
+ }
1208
+
1209
+ // Set up notification categories with actions
1210
+ export async function setupNotificationCategories(): Promise<void> {
1211
+ await Notifications.setNotificationCategoryAsync('approval', [
1212
+ {
1213
+ identifier: 'approve',
1214
+ buttonTitle: 'Approve',
1215
+ options: {
1216
+ isDestructive: false,
1217
+ isAuthenticationRequired: true,
1218
+ },
1219
+ },
1220
+ {
1221
+ identifier: 'reject',
1222
+ buttonTitle: 'Reject',
1223
+ options: {
1224
+ isDestructive: true,
1225
+ isAuthenticationRequired: true,
1226
+ },
1227
+ },
1228
+ {
1229
+ identifier: 'details',
1230
+ buttonTitle: 'View Details',
1231
+ options: {
1232
+ opensAppToForeground: true,
1233
+ },
1234
+ },
1235
+ ]);
1236
+
1237
+ await Notifications.setNotificationCategoryAsync('build', [
1238
+ {
1239
+ identifier: 'view_logs',
1240
+ buttonTitle: 'View Logs',
1241
+ options: {
1242
+ opensAppToForeground: true,
1243
+ },
1244
+ },
1245
+ {
1246
+ identifier: 'retry',
1247
+ buttonTitle: 'Retry Build',
1248
+ options: {
1249
+ isDestructive: false,
1250
+ },
1251
+ },
1252
+ ]);
1253
+ }
1254
+ ```
1255
+
1256
+ ### Rich Notification with Image
1257
+
1258
+ ```typescript
1259
+ // Server-side notification payload example
1260
+ const richNotification = {
1261
+ to: expoPushToken,
1262
+ title: 'Build Failed',
1263
+ body: 'Project "elsabro-api" build #142 failed at test stage',
1264
+ data: {
1265
+ type: 'build_failed',
1266
+ sessionId: 'sess_abc123',
1267
+ priority: 'high',
1268
+ },
1269
+ categoryId: 'build',
1270
+ // Rich media attachment
1271
+ attachments: [
1272
+ {
1273
+ url: 'https://elsabro.io/builds/142/screenshot.png',
1274
+ identifier: 'build-screenshot',
1275
+ },
1276
+ ],
1277
+ // Android specific
1278
+ channelId: 'builds',
1279
+ // iOS specific
1280
+ sound: 'build_failed.wav',
1281
+ badge: 1,
1282
+ };
1283
+ ```
1284
+
1285
+ ---
1286
+
1287
+ ## 4. VoiceInputHandler
1288
+
1289
+ ### Voice Command Architecture
1290
+
1291
+ ```
1292
+ +------------------+ +------------------+ +------------------+
1293
+ | Microphone |---->| STT Engine |---->| Command Parser |
1294
+ | Input | | (@react-native- | | |
1295
+ | | | voice/voice) | | |
1296
+ +------------------+ +------------------+ +------------------+
1297
+ |
1298
+ v
1299
+ +------------------+ +------------------+ +------------------+
1300
+ | Haptic |<----| TTS Engine |<----| Action |
1301
+ | Feedback | | (expo-speech) | | Executor |
1302
+ +------------------+ +------------------+ +------------------+
1303
+ ```
1304
+
1305
+ ### Supported Voice Commands
1306
+
1307
+ | Command Pattern | Action | Example |
1308
+ |-----------------|--------|---------|
1309
+ | "Start session [name]" | Create new session | "Start session API refactor" |
1310
+ | "Pause session" | Pause active session | "Pause session" |
1311
+ | "Resume session" | Resume paused session | "Resume session" |
1312
+ | "Status" | Get current status | "Status" |
1313
+ | "Approve [task]" | Approve pending task | "Approve deployment" |
1314
+ | "Reject [task]" | Reject pending task | "Reject with feedback" |
1315
+ | "Show logs" | Display session logs | "Show logs" |
1316
+ | "Switch to [session]" | Change active session | "Switch to backend work" |
1317
+ | "Stop listening" | Deactivate voice input | "Stop listening" |
1318
+
1319
+ ### Voice Handler Implementation
1320
+
1321
+ ```typescript
1322
+ // src/hooks/useVoice.ts
1323
+ import { useState, useEffect, useCallback, useRef } from 'react';
1324
+ import Voice, {
1325
+ SpeechResultsEvent,
1326
+ SpeechErrorEvent,
1327
+ SpeechStartEvent,
1328
+ } from '@react-native-voice/voice';
1329
+ import * as Speech from 'expo-speech';
1330
+ import * as Haptics from 'expo-haptics';
1331
+ import { useSessionStore } from '@/stores/sessionStore';
1332
+ import { useSendCommand, usePauseSession } from '@/services/api';
1333
+
1334
+ interface VoiceState {
1335
+ isListening: boolean;
1336
+ transcript: string;
1337
+ partialTranscript: string;
1338
+ error: string | null;
1339
+ isProcessing: boolean;
1340
+ }
1341
+
1342
+ interface VoiceCommand {
1343
+ pattern: RegExp;
1344
+ action: (matches: RegExpMatchArray) => Promise<void>;
1345
+ feedback: string;
1346
+ }
1347
+
1348
+ export function useVoice() {
1349
+ const [state, setState] = useState<VoiceState>({
1350
+ isListening: false,
1351
+ transcript: '',
1352
+ partialTranscript: '',
1353
+ error: null,
1354
+ isProcessing: false,
1355
+ });
1356
+
1357
+ const { activeSessionId, sessions } = useSessionStore();
1358
+ const sendCommand = useSendCommand();
1359
+ const pauseSession = usePauseSession();
1360
+
1361
+ const commandTimeoutRef = useRef<NodeJS.Timeout>();
1362
+
1363
+ // Voice command definitions
1364
+ const commands: VoiceCommand[] = [
1365
+ {
1366
+ pattern: /^(start|create|new) session (.+)$/i,
1367
+ action: async (matches) => {
1368
+ const sessionName = matches[2];
1369
+ await createSession(sessionName);
1370
+ },
1371
+ feedback: 'Creating new session',
1372
+ },
1373
+ {
1374
+ pattern: /^pause( session)?$/i,
1375
+ action: async () => {
1376
+ if (activeSessionId) {
1377
+ await pauseSession.mutateAsync(activeSessionId);
1378
+ }
1379
+ },
1380
+ feedback: 'Pausing session',
1381
+ },
1382
+ {
1383
+ pattern: /^resume( session)?$/i,
1384
+ action: async () => {
1385
+ if (activeSessionId) {
1386
+ await sendCommand.mutateAsync({
1387
+ sessionId: activeSessionId,
1388
+ command: '/elsabro:session resume',
1389
+ });
1390
+ }
1391
+ },
1392
+ feedback: 'Resuming session',
1393
+ },
1394
+ {
1395
+ pattern: /^status$/i,
1396
+ action: async () => {
1397
+ const session = activeSessionId ? sessions[activeSessionId] : null;
1398
+ if (session) {
1399
+ await speak(
1400
+ `Session ${session.name} is ${session.status}. ` +
1401
+ `Progress: ${session.progress}%. ` +
1402
+ `Current task: ${session.currentTask || 'none'}.`
1403
+ );
1404
+ } else {
1405
+ await speak('No active session selected.');
1406
+ }
1407
+ },
1408
+ feedback: '',
1409
+ },
1410
+ {
1411
+ pattern: /^approve( task)?( (.+))?$/i,
1412
+ action: async (matches) => {
1413
+ const taskName = matches[3];
1414
+ if (activeSessionId) {
1415
+ await sendCommand.mutateAsync({
1416
+ sessionId: activeSessionId,
1417
+ command: `/elsabro:approve${taskName ? ` ${taskName}` : ''}`,
1418
+ });
1419
+ }
1420
+ },
1421
+ feedback: 'Approving task',
1422
+ },
1423
+ {
1424
+ pattern: /^reject( task)?( with (.+))?$/i,
1425
+ action: async (matches) => {
1426
+ const reason = matches[3] || 'Rejected via voice';
1427
+ if (activeSessionId) {
1428
+ await sendCommand.mutateAsync({
1429
+ sessionId: activeSessionId,
1430
+ command: `/elsabro:reject --reason "${reason}"`,
1431
+ });
1432
+ }
1433
+ },
1434
+ feedback: 'Rejecting task',
1435
+ },
1436
+ {
1437
+ pattern: /^switch to (.+)$/i,
1438
+ action: async (matches) => {
1439
+ const sessionName = matches[1].toLowerCase();
1440
+ const targetSession = Object.values(sessions).find(
1441
+ s => s.name.toLowerCase().includes(sessionName)
1442
+ );
1443
+ if (targetSession) {
1444
+ useSessionStore.getState().setActiveSession(targetSession.id);
1445
+ await speak(`Switched to session: ${targetSession.name}`);
1446
+ } else {
1447
+ await speak(`Session "${sessionName}" not found.`);
1448
+ }
1449
+ },
1450
+ feedback: '',
1451
+ },
1452
+ {
1453
+ pattern: /^stop listening$/i,
1454
+ action: async () => {
1455
+ await stopListening();
1456
+ await speak('Voice input deactivated.');
1457
+ },
1458
+ feedback: '',
1459
+ },
1460
+ ];
1461
+
1462
+ useEffect(() => {
1463
+ Voice.onSpeechStart = handleSpeechStart;
1464
+ Voice.onSpeechEnd = handleSpeechEnd;
1465
+ Voice.onSpeechResults = handleSpeechResults;
1466
+ Voice.onSpeechPartialResults = handlePartialResults;
1467
+ Voice.onSpeechError = handleSpeechError;
1468
+
1469
+ return () => {
1470
+ Voice.destroy().then(Voice.removeAllListeners);
1471
+ };
1472
+ }, []);
1473
+
1474
+ const handleSpeechStart = (e: SpeechStartEvent) => {
1475
+ setState(prev => ({ ...prev, isListening: true, error: null }));
1476
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
1477
+ };
1478
+
1479
+ const handleSpeechEnd = () => {
1480
+ setState(prev => ({ ...prev, isListening: false }));
1481
+ };
1482
+
1483
+ const handleSpeechResults = async (e: SpeechResultsEvent) => {
1484
+ const transcript = e.value?.[0] || '';
1485
+ setState(prev => ({
1486
+ ...prev,
1487
+ transcript,
1488
+ partialTranscript: '',
1489
+ }));
1490
+
1491
+ if (transcript) {
1492
+ await processCommand(transcript);
1493
+ }
1494
+ };
1495
+
1496
+ const handlePartialResults = (e: SpeechResultsEvent) => {
1497
+ const partial = e.value?.[0] || '';
1498
+ setState(prev => ({ ...prev, partialTranscript: partial }));
1499
+ };
1500
+
1501
+ const handleSpeechError = (e: SpeechErrorEvent) => {
1502
+ setState(prev => ({
1503
+ ...prev,
1504
+ error: e.error?.message || 'Speech recognition error',
1505
+ isListening: false,
1506
+ }));
1507
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
1508
+ };
1509
+
1510
+ const processCommand = async (transcript: string) => {
1511
+ setState(prev => ({ ...prev, isProcessing: true }));
1512
+
1513
+ try {
1514
+ for (const command of commands) {
1515
+ const matches = transcript.match(command.pattern);
1516
+ if (matches) {
1517
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
1518
+
1519
+ if (command.feedback) {
1520
+ await speak(command.feedback);
1521
+ }
1522
+
1523
+ await command.action(matches);
1524
+
1525
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
1526
+ return;
1527
+ }
1528
+ }
1529
+
1530
+ // No command matched - treat as general input
1531
+ if (activeSessionId) {
1532
+ await sendCommand.mutateAsync({
1533
+ sessionId: activeSessionId,
1534
+ command: transcript,
1535
+ });
1536
+ await speak('Command sent.');
1537
+ } else {
1538
+ await speak('Command not recognized. Please try again.');
1539
+ }
1540
+ } catch (error) {
1541
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
1542
+ await speak('Error processing command. Please try again.');
1543
+ } finally {
1544
+ setState(prev => ({ ...prev, isProcessing: false }));
1545
+ }
1546
+ };
1547
+
1548
+ const speak = async (text: string): Promise<void> => {
1549
+ return new Promise((resolve) => {
1550
+ Speech.speak(text, {
1551
+ language: 'en-US',
1552
+ pitch: 1.0,
1553
+ rate: 0.9,
1554
+ onDone: resolve,
1555
+ onError: () => resolve(),
1556
+ });
1557
+ });
1558
+ };
1559
+
1560
+ const startListening = useCallback(async () => {
1561
+ try {
1562
+ setState(prev => ({ ...prev, error: null, transcript: '' }));
1563
+
1564
+ await Voice.start('en-US', {
1565
+ EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS: 1500,
1566
+ EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS: 500,
1567
+ });
1568
+
1569
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
1570
+ } catch (error) {
1571
+ setState(prev => ({
1572
+ ...prev,
1573
+ error: 'Failed to start voice recognition',
1574
+ }));
1575
+ }
1576
+ }, []);
1577
+
1578
+ const stopListening = useCallback(async () => {
1579
+ try {
1580
+ await Voice.stop();
1581
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
1582
+ } catch (error) {
1583
+ console.error('Failed to stop voice recognition:', error);
1584
+ }
1585
+ }, []);
1586
+
1587
+ const cancelListening = useCallback(async () => {
1588
+ try {
1589
+ await Voice.cancel();
1590
+ setState(prev => ({
1591
+ ...prev,
1592
+ isListening: false,
1593
+ partialTranscript: '',
1594
+ }));
1595
+ } catch (error) {
1596
+ console.error('Failed to cancel voice recognition:', error);
1597
+ }
1598
+ }, []);
1599
+
1600
+ return {
1601
+ ...state,
1602
+ startListening,
1603
+ stopListening,
1604
+ cancelListening,
1605
+ speak,
1606
+ isAvailable: Voice.isAvailable,
1607
+ };
1608
+ }
1609
+ ```
1610
+
1611
+ ### Voice Input UI Component
1612
+
1613
+ ```typescript
1614
+ // src/components/VoiceInput.tsx
1615
+ import React from 'react';
1616
+ import { View, Text, Pressable, StyleSheet } from 'react-native';
1617
+ import Animated, {
1618
+ useAnimatedStyle,
1619
+ withRepeat,
1620
+ withTiming,
1621
+ useSharedValue,
1622
+ withSequence,
1623
+ } from 'react-native-reanimated';
1624
+ import { Mic, MicOff, Loader } from 'lucide-react-native';
1625
+ import { useVoice } from '@/hooks/useVoice';
1626
+
1627
+ export function VoiceInput() {
1628
+ const {
1629
+ isListening,
1630
+ partialTranscript,
1631
+ transcript,
1632
+ isProcessing,
1633
+ error,
1634
+ startListening,
1635
+ stopListening,
1636
+ } = useVoice();
1637
+
1638
+ const pulseScale = useSharedValue(1);
1639
+
1640
+ React.useEffect(() => {
1641
+ if (isListening) {
1642
+ pulseScale.value = withRepeat(
1643
+ withSequence(
1644
+ withTiming(1.2, { duration: 500 }),
1645
+ withTiming(1, { duration: 500 })
1646
+ ),
1647
+ -1,
1648
+ true
1649
+ );
1650
+ } else {
1651
+ pulseScale.value = withTiming(1, { duration: 200 });
1652
+ }
1653
+ }, [isListening]);
1654
+
1655
+ const animatedStyle = useAnimatedStyle(() => ({
1656
+ transform: [{ scale: pulseScale.value }],
1657
+ }));
1658
+
1659
+ return (
1660
+ <View style={styles.container}>
1661
+ {(partialTranscript || transcript) && (
1662
+ <View style={styles.transcriptContainer}>
1663
+ <Text style={styles.transcript}>
1664
+ {partialTranscript || transcript}
1665
+ </Text>
1666
+ </View>
1667
+ )}
1668
+
1669
+ {error && (
1670
+ <Text style={styles.error}>{error}</Text>
1671
+ )}
1672
+
1673
+ <Animated.View style={[styles.buttonWrapper, animatedStyle]}>
1674
+ <Pressable
1675
+ style={[
1676
+ styles.button,
1677
+ isListening && styles.buttonActive,
1678
+ isProcessing && styles.buttonProcessing,
1679
+ ]}
1680
+ onPress={isListening ? stopListening : startListening}
1681
+ disabled={isProcessing}
1682
+ >
1683
+ {isProcessing ? (
1684
+ <Loader size={32} color="#fff" />
1685
+ ) : isListening ? (
1686
+ <MicOff size={32} color="#fff" />
1687
+ ) : (
1688
+ <Mic size={32} color="#fff" />
1689
+ )}
1690
+ </Pressable>
1691
+ </Animated.View>
1692
+
1693
+ <Text style={styles.hint}>
1694
+ {isListening
1695
+ ? 'Listening... Tap to stop'
1696
+ : isProcessing
1697
+ ? 'Processing...'
1698
+ : 'Tap to speak'}
1699
+ </Text>
1700
+ </View>
1701
+ );
1702
+ }
1703
+
1704
+ const styles = StyleSheet.create({
1705
+ container: {
1706
+ alignItems: 'center',
1707
+ padding: 20,
1708
+ },
1709
+ transcriptContainer: {
1710
+ backgroundColor: '#f0f0f0',
1711
+ borderRadius: 12,
1712
+ padding: 16,
1713
+ marginBottom: 20,
1714
+ width: '100%',
1715
+ },
1716
+ transcript: {
1717
+ fontSize: 16,
1718
+ color: '#333',
1719
+ textAlign: 'center',
1720
+ },
1721
+ error: {
1722
+ color: '#e53935',
1723
+ marginBottom: 10,
1724
+ },
1725
+ buttonWrapper: {
1726
+ marginVertical: 20,
1727
+ },
1728
+ button: {
1729
+ width: 80,
1730
+ height: 80,
1731
+ borderRadius: 40,
1732
+ backgroundColor: '#6366f1',
1733
+ alignItems: 'center',
1734
+ justifyContent: 'center',
1735
+ shadowColor: '#000',
1736
+ shadowOffset: { width: 0, height: 4 },
1737
+ shadowOpacity: 0.3,
1738
+ shadowRadius: 8,
1739
+ elevation: 8,
1740
+ },
1741
+ buttonActive: {
1742
+ backgroundColor: '#ef4444',
1743
+ },
1744
+ buttonProcessing: {
1745
+ backgroundColor: '#9ca3af',
1746
+ },
1747
+ hint: {
1748
+ color: '#666',
1749
+ fontSize: 14,
1750
+ },
1751
+ });
1752
+ ```
1753
+
1754
+ ---
1755
+
1756
+ ## 5. OfflineMode
1757
+
1758
+ ### Offline Architecture
1759
+
1760
+ ```
1761
+ +------------------------------------------------------------------+
1762
+ | Offline Mode System |
1763
+ +------------------------------------------------------------------+
1764
+ | |
1765
+ | +------------------+ +------------------+ |
1766
+ | | Network | | Connectivity | |
1767
+ | | Monitor |---->| Store | |
1768
+ | +------------------+ +------------------+ |
1769
+ | | |
1770
+ | +----------------------+----------------------+ |
1771
+ | | | | |
1772
+ | v v v |
1773
+ | +------------------+ +------------------+ +------------------+|
1774
+ | | Action Queue | | Data Cache | | UI Indicators ||
1775
+ | | (pending ops) | | (AsyncStorage) | | (offline bar) ||
1776
+ | +------------------+ +------------------+ +------------------+|
1777
+ | | | |
1778
+ | v v |
1779
+ | +------------------+ +------------------+ |
1780
+ | | Sync Engine | | Cache Manager | |
1781
+ | | (on reconnect) | | (TTL, LRU) | |
1782
+ | +------------------+ +------------------+ |
1783
+ | |
1784
+ +------------------------------------------------------------------+
1785
+ ```
1786
+
1787
+ ### Offline Store Implementation
1788
+
1789
+ ```typescript
1790
+ // src/stores/offlineStore.ts
1791
+ import { create } from 'zustand';
1792
+ import { persist, createJSONStorage } from 'zustand/middleware';
1793
+ import AsyncStorage from '@react-native-async-storage/async-storage';
1794
+ import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
1795
+ import { immer } from 'zustand/middleware/immer';
1796
+
1797
+ interface PendingAction {
1798
+ id: string;
1799
+ type: 'command' | 'approval' | 'rejection' | 'websocket';
1800
+ data: unknown;
1801
+ createdAt: number;
1802
+ retryCount: number;
1803
+ maxRetries: number;
1804
+ sessionId?: string;
1805
+ }
1806
+
1807
+ interface CachedData {
1808
+ key: string;
1809
+ data: unknown;
1810
+ timestamp: number;
1811
+ ttl: number; // Time to live in ms
1812
+ }
1813
+
1814
+ interface OfflineState {
1815
+ isOnline: boolean;
1816
+ isConnecting: boolean;
1817
+ pendingActions: PendingAction[];
1818
+ cache: Record<string, CachedData>;
1819
+ lastSyncTimestamp: number;
1820
+ syncInProgress: boolean;
1821
+ }
1822
+
1823
+ interface OfflineActions {
1824
+ setOnline: (online: boolean) => void;
1825
+ setConnecting: (connecting: boolean) => void;
1826
+ addPendingAction: (action: Omit<PendingAction, 'retryCount' | 'maxRetries'>) => void;
1827
+ removePendingAction: (id: string) => void;
1828
+ incrementRetry: (id: string) => void;
1829
+ setCache: (key: string, data: unknown, ttl?: number) => void;
1830
+ getCache: <T>(key: string) => T | null;
1831
+ clearExpiredCache: () => void;
1832
+ syncPendingActions: () => Promise<void>;
1833
+ }
1834
+
1835
+ const DEFAULT_TTL = 1000 * 60 * 30; // 30 minutes
1836
+ const MAX_CACHE_SIZE = 100;
1837
+
1838
+ export const useOfflineStore = create<OfflineState & OfflineActions>()(
1839
+ persist(
1840
+ immer((set, get) => ({
1841
+ // State
1842
+ isOnline: true,
1843
+ isConnecting: false,
1844
+ pendingActions: [],
1845
+ cache: {},
1846
+ lastSyncTimestamp: 0,
1847
+ syncInProgress: false,
1848
+
1849
+ // Actions
1850
+ setOnline: (online) =>
1851
+ set((state) => {
1852
+ state.isOnline = online;
1853
+ }),
1854
+
1855
+ setConnecting: (connecting) =>
1856
+ set((state) => {
1857
+ state.isConnecting = connecting;
1858
+ }),
1859
+
1860
+ addPendingAction: (action) =>
1861
+ set((state) => {
1862
+ state.pendingActions.push({
1863
+ ...action,
1864
+ retryCount: 0,
1865
+ maxRetries: 3,
1866
+ });
1867
+ }),
1868
+
1869
+ removePendingAction: (id) =>
1870
+ set((state) => {
1871
+ state.pendingActions = state.pendingActions.filter(a => a.id !== id);
1872
+ }),
1873
+
1874
+ incrementRetry: (id) =>
1875
+ set((state) => {
1876
+ const action = state.pendingActions.find(a => a.id === id);
1877
+ if (action) {
1878
+ action.retryCount++;
1879
+ }
1880
+ }),
1881
+
1882
+ setCache: (key, data, ttl = DEFAULT_TTL) =>
1883
+ set((state) => {
1884
+ // Implement LRU eviction
1885
+ const cacheKeys = Object.keys(state.cache);
1886
+ if (cacheKeys.length >= MAX_CACHE_SIZE) {
1887
+ // Remove oldest entry
1888
+ const oldest = cacheKeys.reduce((a, b) =>
1889
+ state.cache[a].timestamp < state.cache[b].timestamp ? a : b
1890
+ );
1891
+ delete state.cache[oldest];
1892
+ }
1893
+
1894
+ state.cache[key] = {
1895
+ key,
1896
+ data,
1897
+ timestamp: Date.now(),
1898
+ ttl,
1899
+ };
1900
+ }),
1901
+
1902
+ getCache: <T>(key: string): T | null => {
1903
+ const cached = get().cache[key];
1904
+ if (!cached) return null;
1905
+
1906
+ const isExpired = Date.now() - cached.timestamp > cached.ttl;
1907
+ if (isExpired) {
1908
+ get().clearExpiredCache();
1909
+ return null;
1910
+ }
1911
+
1912
+ return cached.data as T;
1913
+ },
1914
+
1915
+ clearExpiredCache: () =>
1916
+ set((state) => {
1917
+ const now = Date.now();
1918
+ Object.keys(state.cache).forEach((key) => {
1919
+ const cached = state.cache[key];
1920
+ if (now - cached.timestamp > cached.ttl) {
1921
+ delete state.cache[key];
1922
+ }
1923
+ });
1924
+ }),
1925
+
1926
+ syncPendingActions: async () => {
1927
+ const state = get();
1928
+ if (state.syncInProgress || !state.isOnline) return;
1929
+
1930
+ set((s) => { s.syncInProgress = true; });
1931
+
1932
+ const actionsToSync = [...state.pendingActions];
1933
+
1934
+ for (const action of actionsToSync) {
1935
+ if (action.retryCount >= action.maxRetries) {
1936
+ // Max retries exceeded, remove and notify user
1937
+ get().removePendingAction(action.id);
1938
+ continue;
1939
+ }
1940
+
1941
+ try {
1942
+ await executeAction(action);
1943
+ get().removePendingAction(action.id);
1944
+ } catch (error) {
1945
+ console.error(`Failed to sync action ${action.id}:`, error);
1946
+ get().incrementRetry(action.id);
1947
+
1948
+ // Exponential backoff
1949
+ const delay = Math.min(1000 * Math.pow(2, action.retryCount), 30000);
1950
+ await new Promise(r => setTimeout(r, delay));
1951
+ }
1952
+ }
1953
+
1954
+ set((s) => {
1955
+ s.syncInProgress = false;
1956
+ s.lastSyncTimestamp = Date.now();
1957
+ });
1958
+ },
1959
+ })),
1960
+ {
1961
+ name: 'elsabro-offline',
1962
+ storage: createJSONStorage(() => AsyncStorage),
1963
+ partialize: (state) => ({
1964
+ pendingActions: state.pendingActions,
1965
+ cache: state.cache,
1966
+ lastSyncTimestamp: state.lastSyncTimestamp,
1967
+ }),
1968
+ }
1969
+ )
1970
+ );
1971
+
1972
+ async function executeAction(action: PendingAction): Promise<void> {
1973
+ const API_URL = process.env.EXPO_PUBLIC_API_URL;
1974
+
1975
+ switch (action.type) {
1976
+ case 'command':
1977
+ await fetch(`${API_URL}/sessions/${action.sessionId}/command`, {
1978
+ method: 'POST',
1979
+ headers: { 'Content-Type': 'application/json' },
1980
+ body: JSON.stringify(action.data),
1981
+ });
1982
+ break;
1983
+
1984
+ case 'approval':
1985
+ await fetch(`${API_URL}/tasks/approve`, {
1986
+ method: 'POST',
1987
+ headers: { 'Content-Type': 'application/json' },
1988
+ body: JSON.stringify(action.data),
1989
+ });
1990
+ break;
1991
+
1992
+ case 'rejection':
1993
+ await fetch(`${API_URL}/tasks/reject`, {
1994
+ method: 'POST',
1995
+ headers: { 'Content-Type': 'application/json' },
1996
+ body: JSON.stringify(action.data),
1997
+ });
1998
+ break;
1999
+
2000
+ default:
2001
+ console.warn('Unknown action type:', action.type);
2002
+ }
2003
+ }
2004
+
2005
+ // Network monitor hook
2006
+ export function useNetworkMonitor() {
2007
+ const { setOnline, setConnecting, syncPendingActions } = useOfflineStore();
2008
+
2009
+ React.useEffect(() => {
2010
+ const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
2011
+ const wasOffline = !useOfflineStore.getState().isOnline;
2012
+ const isNowOnline = state.isConnected && state.isInternetReachable;
2013
+
2014
+ setOnline(isNowOnline ?? false);
2015
+ setConnecting(state.isConnected && !state.isInternetReachable);
2016
+
2017
+ // Trigger sync when coming back online
2018
+ if (wasOffline && isNowOnline) {
2019
+ syncPendingActions();
2020
+ }
2021
+ });
2022
+
2023
+ return () => unsubscribe();
2024
+ }, []);
2025
+ }
2026
+ ```
2027
+
2028
+ ### Offline Indicator Component
2029
+
2030
+ ```typescript
2031
+ // src/components/OfflineIndicator.tsx
2032
+ import React from 'react';
2033
+ import { View, Text, StyleSheet } from 'react-native';
2034
+ import Animated, {
2035
+ useAnimatedStyle,
2036
+ withTiming,
2037
+ withRepeat,
2038
+ useSharedValue,
2039
+ } from 'react-native-reanimated';
2040
+ import { WifiOff, Loader, CloudOff } from 'lucide-react-native';
2041
+ import { useOfflineStore } from '@/stores/offlineStore';
2042
+
2043
+ export function OfflineIndicator() {
2044
+ const { isOnline, isConnecting, pendingActions, syncInProgress } = useOfflineStore();
2045
+ const opacity = useSharedValue(0);
2046
+
2047
+ React.useEffect(() => {
2048
+ opacity.value = withTiming(isOnline ? 0 : 1, { duration: 300 });
2049
+ }, [isOnline]);
2050
+
2051
+ const animatedStyle = useAnimatedStyle(() => ({
2052
+ opacity: opacity.value,
2053
+ transform: [{ translateY: opacity.value === 0 ? -50 : 0 }],
2054
+ }));
2055
+
2056
+ if (isOnline && !syncInProgress) return null;
2057
+
2058
+ return (
2059
+ <Animated.View style={[styles.container, animatedStyle]}>
2060
+ <View style={[
2061
+ styles.bar,
2062
+ isConnecting && styles.barConnecting,
2063
+ syncInProgress && styles.barSyncing,
2064
+ ]}>
2065
+ {syncInProgress ? (
2066
+ <>
2067
+ <Loader size={16} color="#fff" />
2068
+ <Text style={styles.text}>Syncing {pendingActions.length} actions...</Text>
2069
+ </>
2070
+ ) : isConnecting ? (
2071
+ <>
2072
+ <Loader size={16} color="#fff" />
2073
+ <Text style={styles.text}>Connecting...</Text>
2074
+ </>
2075
+ ) : (
2076
+ <>
2077
+ <WifiOff size={16} color="#fff" />
2078
+ <Text style={styles.text}>
2079
+ Offline {pendingActions.length > 0 && `(${pendingActions.length} pending)`}
2080
+ </Text>
2081
+ </>
2082
+ )}
2083
+ </View>
2084
+ </Animated.View>
2085
+ );
2086
+ }
2087
+
2088
+ const styles = StyleSheet.create({
2089
+ container: {
2090
+ position: 'absolute',
2091
+ top: 0,
2092
+ left: 0,
2093
+ right: 0,
2094
+ zIndex: 1000,
2095
+ },
2096
+ bar: {
2097
+ flexDirection: 'row',
2098
+ alignItems: 'center',
2099
+ justifyContent: 'center',
2100
+ gap: 8,
2101
+ paddingVertical: 8,
2102
+ paddingTop: 48, // Account for status bar
2103
+ backgroundColor: '#ef4444',
2104
+ },
2105
+ barConnecting: {
2106
+ backgroundColor: '#f59e0b',
2107
+ },
2108
+ barSyncing: {
2109
+ backgroundColor: '#3b82f6',
2110
+ },
2111
+ text: {
2112
+ color: '#fff',
2113
+ fontSize: 14,
2114
+ fontWeight: '500',
2115
+ },
2116
+ });
2117
+ ```
2118
+
2119
+ ---
2120
+
2121
+ ## 6. BiometricAuth
2122
+
2123
+ ### Authentication Flow
2124
+
2125
+ ```
2126
+ +------------------------------------------------------------------+
2127
+ | Biometric Authentication Flow |
2128
+ +------------------------------------------------------------------+
2129
+ | |
2130
+ | +------------------+ |
2131
+ | | App Launch | |
2132
+ | +--------+---------+ |
2133
+ | | |
2134
+ | v |
2135
+ | +------------------+ +------------------+ |
2136
+ | | Check Biometric |---->| Not Enrolled |---> Password Login |
2137
+ | | Enrollment | +------------------+ |
2138
+ | +--------+---------+ |
2139
+ | | Enrolled |
2140
+ | v |
2141
+ | +------------------+ |
2142
+ | | Prompt Biometric | |
2143
+ | +--------+---------+ |
2144
+ | | |
2145
+ | +-----+-----+ |
2146
+ | | | |
2147
+ | v v |
2148
+ | Success Failure |
2149
+ | | | |
2150
+ | v v |
2151
+ | +------------------+ +------------------+ |
2152
+ | | Retrieve Token | | Show PIN Fallback| |
2153
+ | | from SecureStore | +--------+---------+ |
2154
+ | +--------+---------+ | |
2155
+ | | | |
2156
+ | v v |
2157
+ | +------------------+ +------------------+ |
2158
+ | | Validate Token | | Validate PIN | |
2159
+ | +--------+---------+ +--------+---------+ |
2160
+ | | | |
2161
+ | +------------------------+ |
2162
+ | | |
2163
+ | v |
2164
+ | +------------------+ |
2165
+ | | Authenticated | |
2166
+ | +------------------+ |
2167
+ | |
2168
+ +------------------------------------------------------------------+
2169
+ ```
2170
+
2171
+ ### Biometric Auth Implementation
2172
+
2173
+ ```typescript
2174
+ // src/hooks/useBiometric.ts
2175
+ import { useState, useCallback, useEffect } from 'react';
2176
+ import * as LocalAuthentication from 'expo-local-authentication';
2177
+ import * as SecureStore from 'expo-secure-store';
2178
+ import { Platform } from 'react-native';
2179
+ import * as Haptics from 'expo-haptics';
2180
+
2181
+ interface BiometricState {
2182
+ isAvailable: boolean;
2183
+ biometricType: 'fingerprint' | 'facial' | 'iris' | null;
2184
+ isEnrolled: boolean;
2185
+ isAuthenticating: boolean;
2186
+ error: string | null;
2187
+ }
2188
+
2189
+ const SECURE_KEYS = {
2190
+ ACCESS_TOKEN: 'elsabro_access_token',
2191
+ REFRESH_TOKEN: 'elsabro_refresh_token',
2192
+ PIN_HASH: 'elsabro_pin_hash',
2193
+ BIOMETRIC_ENABLED: 'elsabro_biometric_enabled',
2194
+ } as const;
2195
+
2196
+ export function useBiometric() {
2197
+ const [state, setState] = useState<BiometricState>({
2198
+ isAvailable: false,
2199
+ biometricType: null,
2200
+ isEnrolled: false,
2201
+ isAuthenticating: false,
2202
+ error: null,
2203
+ });
2204
+
2205
+ useEffect(() => {
2206
+ checkBiometricAvailability();
2207
+ }, []);
2208
+
2209
+ const checkBiometricAvailability = async () => {
2210
+ try {
2211
+ const compatible = await LocalAuthentication.hasHardwareAsync();
2212
+ const enrolled = await LocalAuthentication.isEnrolledAsync();
2213
+ const types = await LocalAuthentication.supportedAuthenticationTypesAsync();
2214
+
2215
+ let biometricType: BiometricState['biometricType'] = null;
2216
+
2217
+ if (types.includes(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION)) {
2218
+ biometricType = 'facial';
2219
+ } else if (types.includes(LocalAuthentication.AuthenticationType.FINGERPRINT)) {
2220
+ biometricType = 'fingerprint';
2221
+ } else if (types.includes(LocalAuthentication.AuthenticationType.IRIS)) {
2222
+ biometricType = 'iris';
2223
+ }
2224
+
2225
+ setState(prev => ({
2226
+ ...prev,
2227
+ isAvailable: compatible,
2228
+ isEnrolled: enrolled,
2229
+ biometricType,
2230
+ }));
2231
+ } catch (error) {
2232
+ console.error('Biometric check failed:', error);
2233
+ }
2234
+ };
2235
+
2236
+ const authenticate = useCallback(async (): Promise<boolean> => {
2237
+ setState(prev => ({ ...prev, isAuthenticating: true, error: null }));
2238
+
2239
+ try {
2240
+ const result = await LocalAuthentication.authenticateAsync({
2241
+ promptMessage: 'Authenticate to access ELSABRO',
2242
+ subtitle: Platform.OS === 'android' ? 'Use biometrics or PIN' : undefined,
2243
+ fallbackLabel: 'Use PIN',
2244
+ cancelLabel: 'Cancel',
2245
+ disableDeviceFallback: false,
2246
+ });
2247
+
2248
+ if (result.success) {
2249
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
2250
+ setState(prev => ({ ...prev, isAuthenticating: false }));
2251
+ return true;
2252
+ } else {
2253
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
2254
+ setState(prev => ({
2255
+ ...prev,
2256
+ isAuthenticating: false,
2257
+ error: result.error === 'user_cancel'
2258
+ ? 'Authentication cancelled'
2259
+ : 'Authentication failed',
2260
+ }));
2261
+ return false;
2262
+ }
2263
+ } catch (error) {
2264
+ setState(prev => ({
2265
+ ...prev,
2266
+ isAuthenticating: false,
2267
+ error: 'Authentication error',
2268
+ }));
2269
+ return false;
2270
+ }
2271
+ }, []);
2272
+
2273
+ return {
2274
+ ...state,
2275
+ authenticate,
2276
+ checkBiometricAvailability,
2277
+ };
2278
+ }
2279
+
2280
+ // Secure Storage Utilities
2281
+ export const SecureStorage = {
2282
+ async setToken(token: string): Promise<void> {
2283
+ await SecureStore.setItemAsync(SECURE_KEYS.ACCESS_TOKEN, token, {
2284
+ keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
2285
+ });
2286
+ },
2287
+
2288
+ async getToken(): Promise<string | null> {
2289
+ return SecureStore.getItemAsync(SECURE_KEYS.ACCESS_TOKEN);
2290
+ },
2291
+
2292
+ async setRefreshToken(token: string): Promise<void> {
2293
+ await SecureStore.setItemAsync(SECURE_KEYS.REFRESH_TOKEN, token, {
2294
+ keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
2295
+ });
2296
+ },
2297
+
2298
+ async getRefreshToken(): Promise<string | null> {
2299
+ return SecureStore.getItemAsync(SECURE_KEYS.REFRESH_TOKEN);
2300
+ },
2301
+
2302
+ async setPinHash(hash: string): Promise<void> {
2303
+ await SecureStore.setItemAsync(SECURE_KEYS.PIN_HASH, hash, {
2304
+ keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
2305
+ });
2306
+ },
2307
+
2308
+ async getPinHash(): Promise<string | null> {
2309
+ return SecureStore.getItemAsync(SECURE_KEYS.PIN_HASH);
2310
+ },
2311
+
2312
+ async setBiometricEnabled(enabled: boolean): Promise<void> {
2313
+ await SecureStore.setItemAsync(
2314
+ SECURE_KEYS.BIOMETRIC_ENABLED,
2315
+ enabled ? 'true' : 'false'
2316
+ );
2317
+ },
2318
+
2319
+ async isBiometricEnabled(): Promise<boolean> {
2320
+ const value = await SecureStore.getItemAsync(SECURE_KEYS.BIOMETRIC_ENABLED);
2321
+ return value === 'true';
2322
+ },
2323
+
2324
+ async clearAll(): Promise<void> {
2325
+ await Promise.all([
2326
+ SecureStore.deleteItemAsync(SECURE_KEYS.ACCESS_TOKEN),
2327
+ SecureStore.deleteItemAsync(SECURE_KEYS.REFRESH_TOKEN),
2328
+ SecureStore.deleteItemAsync(SECURE_KEYS.PIN_HASH),
2329
+ SecureStore.deleteItemAsync(SECURE_KEYS.BIOMETRIC_ENABLED),
2330
+ ]);
2331
+ },
2332
+ };
2333
+ ```
2334
+
2335
+ ### Auth Store
2336
+
2337
+ ```typescript
2338
+ // src/stores/authStore.ts
2339
+ import { create } from 'zustand';
2340
+ import { SecureStorage } from '@/hooks/useBiometric';
2341
+
2342
+ interface AuthState {
2343
+ isAuthenticated: boolean;
2344
+ isLoading: boolean;
2345
+ accessToken: string | null;
2346
+ user: User | null;
2347
+ pendingDeepLink: string | null;
2348
+ }
2349
+
2350
+ interface AuthActions {
2351
+ initialize: () => Promise<void>;
2352
+ login: (credentials: LoginCredentials) => Promise<void>;
2353
+ loginWithBiometric: () => Promise<boolean>;
2354
+ logout: () => Promise<void>;
2355
+ refreshToken: () => Promise<void>;
2356
+ setPendingDeepLink: (url: string | null) => void;
2357
+ }
2358
+
2359
+ export const useAuthStore = create<AuthState & AuthActions>((set, get) => ({
2360
+ isAuthenticated: false,
2361
+ isLoading: true,
2362
+ accessToken: null,
2363
+ user: null,
2364
+ pendingDeepLink: null,
2365
+
2366
+ initialize: async () => {
2367
+ try {
2368
+ const token = await SecureStorage.getToken();
2369
+
2370
+ if (token) {
2371
+ // Validate token with server
2372
+ const response = await fetch(`${API_URL}/auth/validate`, {
2373
+ headers: { Authorization: `Bearer ${token}` },
2374
+ });
2375
+
2376
+ if (response.ok) {
2377
+ const user = await response.json();
2378
+ set({
2379
+ isAuthenticated: true,
2380
+ accessToken: token,
2381
+ user,
2382
+ isLoading: false,
2383
+ });
2384
+ return;
2385
+ }
2386
+
2387
+ // Token invalid, try refresh
2388
+ await get().refreshToken();
2389
+ }
2390
+ } catch (error) {
2391
+ console.error('Auth initialization failed:', error);
2392
+ }
2393
+
2394
+ set({ isLoading: false });
2395
+ },
2396
+
2397
+ login: async (credentials) => {
2398
+ const response = await fetch(`${API_URL}/auth/login`, {
2399
+ method: 'POST',
2400
+ headers: { 'Content-Type': 'application/json' },
2401
+ body: JSON.stringify(credentials),
2402
+ });
2403
+
2404
+ if (!response.ok) {
2405
+ throw new Error('Login failed');
2406
+ }
2407
+
2408
+ const { accessToken, refreshToken, user } = await response.json();
2409
+
2410
+ await SecureStorage.setToken(accessToken);
2411
+ await SecureStorage.setRefreshToken(refreshToken);
2412
+
2413
+ set({
2414
+ isAuthenticated: true,
2415
+ accessToken,
2416
+ user,
2417
+ });
2418
+ },
2419
+
2420
+ loginWithBiometric: async () => {
2421
+ const token = await SecureStorage.getToken();
2422
+
2423
+ if (!token) {
2424
+ return false;
2425
+ }
2426
+
2427
+ // Validate stored token
2428
+ const response = await fetch(`${API_URL}/auth/validate`, {
2429
+ headers: { Authorization: `Bearer ${token}` },
2430
+ });
2431
+
2432
+ if (response.ok) {
2433
+ const user = await response.json();
2434
+ set({
2435
+ isAuthenticated: true,
2436
+ accessToken: token,
2437
+ user,
2438
+ });
2439
+ return true;
2440
+ }
2441
+
2442
+ // Token expired, try refresh
2443
+ try {
2444
+ await get().refreshToken();
2445
+ return true;
2446
+ } catch {
2447
+ return false;
2448
+ }
2449
+ },
2450
+
2451
+ logout: async () => {
2452
+ await SecureStorage.clearAll();
2453
+ set({
2454
+ isAuthenticated: false,
2455
+ accessToken: null,
2456
+ user: null,
2457
+ });
2458
+ },
2459
+
2460
+ refreshToken: async () => {
2461
+ const refreshToken = await SecureStorage.getRefreshToken();
2462
+
2463
+ if (!refreshToken) {
2464
+ throw new Error('No refresh token');
2465
+ }
2466
+
2467
+ const response = await fetch(`${API_URL}/auth/refresh`, {
2468
+ method: 'POST',
2469
+ headers: { 'Content-Type': 'application/json' },
2470
+ body: JSON.stringify({ refreshToken }),
2471
+ });
2472
+
2473
+ if (!response.ok) {
2474
+ await get().logout();
2475
+ throw new Error('Refresh failed');
2476
+ }
2477
+
2478
+ const { accessToken, user } = await response.json();
2479
+
2480
+ await SecureStorage.setToken(accessToken);
2481
+
2482
+ set({
2483
+ isAuthenticated: true,
2484
+ accessToken,
2485
+ user,
2486
+ });
2487
+ },
2488
+
2489
+ setPendingDeepLink: (url) => set({ pendingDeepLink: url }),
2490
+ }));
2491
+ ```
2492
+
2493
+ ---
2494
+
2495
+ ## 7. Widget Support
2496
+
2497
+ ### Widget Architecture
2498
+
2499
+ ```
2500
+ +------------------------------------------------------------------+
2501
+ | Widget System Architecture |
2502
+ +------------------------------------------------------------------+
2503
+ | |
2504
+ | iOS (WidgetKit) Android (App Widgets) |
2505
+ | +-------------------------+ +-------------------------+ |
2506
+ | | SwiftUI Widget View | | RemoteViews Layout | |
2507
+ | +------------+------------+ +------------+------------+ |
2508
+ | | | |
2509
+ | v v |
2510
+ | +-------------------------+ +-------------------------+ |
2511
+ | | Widget Timeline | | AppWidgetProvider | |
2512
+ | | Provider | | | |
2513
+ | +------------+------------+ +------------+------------+ |
2514
+ | | | |
2515
+ | v v |
2516
+ | +-------------------------+ +-------------------------+ |
2517
+ | | App Group Shared | | SharedPreferences | |
2518
+ | | UserDefaults | | (MODE_PRIVATE) | |
2519
+ | +------------+------------+ +------------+------------+ |
2520
+ | | | |
2521
+ | +----------------------------------+ |
2522
+ | | |
2523
+ | v |
2524
+ | +------------------------------+ |
2525
+ | | React Native Bridge | |
2526
+ | | (expo-widget-extension) | |
2527
+ | +------------------------------+ |
2528
+ | |
2529
+ +------------------------------------------------------------------+
2530
+ ```
2531
+
2532
+ ### iOS Widget Implementation
2533
+
2534
+ ```swift
2535
+ // widgets/ios/ElsabroWidget/ElsabroWidget.swift
2536
+ import WidgetKit
2537
+ import SwiftUI
2538
+
2539
+ struct SessionEntry: TimelineEntry {
2540
+ let date: Date
2541
+ let session: SessionData?
2542
+ let configuration: ConfigurationIntent
2543
+ }
2544
+
2545
+ struct SessionData: Codable {
2546
+ let id: String
2547
+ let name: String
2548
+ let status: String
2549
+ let progress: Int
2550
+ let currentTask: String?
2551
+ let agentCount: Int
2552
+ }
2553
+
2554
+ struct Provider: IntentTimelineProvider {
2555
+ func placeholder(in context: Context) -> SessionEntry {
2556
+ SessionEntry(
2557
+ date: Date(),
2558
+ session: SessionData(
2559
+ id: "placeholder",
2560
+ name: "Loading...",
2561
+ status: "active",
2562
+ progress: 50,
2563
+ currentTask: nil,
2564
+ agentCount: 0
2565
+ ),
2566
+ configuration: ConfigurationIntent()
2567
+ )
2568
+ }
2569
+
2570
+ func getSnapshot(
2571
+ for configuration: ConfigurationIntent,
2572
+ in context: Context,
2573
+ completion: @escaping (SessionEntry) -> Void
2574
+ ) {
2575
+ let entry = SessionEntry(
2576
+ date: Date(),
2577
+ session: loadSessionData(),
2578
+ configuration: configuration
2579
+ )
2580
+ completion(entry)
2581
+ }
2582
+
2583
+ func getTimeline(
2584
+ for configuration: ConfigurationIntent,
2585
+ in context: Context,
2586
+ completion: @escaping (Timeline<SessionEntry>) -> Void
2587
+ ) {
2588
+ let session = loadSessionData()
2589
+ let entry = SessionEntry(
2590
+ date: Date(),
2591
+ session: session,
2592
+ configuration: configuration
2593
+ )
2594
+
2595
+ // Update every 5 minutes
2596
+ let nextUpdate = Calendar.current.date(
2597
+ byAdding: .minute,
2598
+ value: 5,
2599
+ to: Date()
2600
+ )!
2601
+
2602
+ let timeline = Timeline(
2603
+ entries: [entry],
2604
+ policy: .after(nextUpdate)
2605
+ )
2606
+ completion(timeline)
2607
+ }
2608
+
2609
+ private func loadSessionData() -> SessionData? {
2610
+ guard let userDefaults = UserDefaults(suiteName: "group.io.elsabro.companion"),
2611
+ let data = userDefaults.data(forKey: "activeSession"),
2612
+ let session = try? JSONDecoder().decode(SessionData.self, from: data)
2613
+ else {
2614
+ return nil
2615
+ }
2616
+ return session
2617
+ }
2618
+ }
2619
+
2620
+ struct ElsabroWidgetEntryView: View {
2621
+ var entry: Provider.Entry
2622
+ @Environment(\.widgetFamily) var family
2623
+
2624
+ var body: some View {
2625
+ switch family {
2626
+ case .systemSmall:
2627
+ SmallWidgetView(session: entry.session)
2628
+ case .systemMedium:
2629
+ MediumWidgetView(session: entry.session)
2630
+ case .systemLarge:
2631
+ LargeWidgetView(session: entry.session)
2632
+ default:
2633
+ SmallWidgetView(session: entry.session)
2634
+ }
2635
+ }
2636
+ }
2637
+
2638
+ struct SmallWidgetView: View {
2639
+ let session: SessionData?
2640
+
2641
+ var body: some View {
2642
+ VStack(alignment: .leading, spacing: 8) {
2643
+ HStack {
2644
+ Image(systemName: "sparkles")
2645
+ .foregroundColor(.purple)
2646
+ Text("ELSABRO")
2647
+ .font(.caption)
2648
+ .fontWeight(.bold)
2649
+ }
2650
+
2651
+ if let session = session {
2652
+ Text(session.name)
2653
+ .font(.headline)
2654
+ .lineLimit(1)
2655
+
2656
+ ProgressView(value: Double(session.progress) / 100)
2657
+ .tint(statusColor(session.status))
2658
+
2659
+ Text("\(session.progress)%")
2660
+ .font(.caption2)
2661
+ .foregroundColor(.secondary)
2662
+ } else {
2663
+ Text("No active session")
2664
+ .font(.caption)
2665
+ .foregroundColor(.secondary)
2666
+ }
2667
+ }
2668
+ .padding()
2669
+ .widgetURL(URL(string: "elsabro://session/\(session?.id ?? "")"))
2670
+ }
2671
+
2672
+ private func statusColor(_ status: String) -> Color {
2673
+ switch status {
2674
+ case "active": return .green
2675
+ case "paused": return .orange
2676
+ case "error": return .red
2677
+ default: return .blue
2678
+ }
2679
+ }
2680
+ }
2681
+
2682
+ struct MediumWidgetView: View {
2683
+ let session: SessionData?
2684
+
2685
+ var body: some View {
2686
+ HStack(spacing: 16) {
2687
+ SmallWidgetView(session: session)
2688
+
2689
+ if let session = session {
2690
+ VStack(alignment: .leading, spacing: 4) {
2691
+ Label {
2692
+ Text(session.status.capitalized)
2693
+ } icon: {
2694
+ Circle()
2695
+ .fill(statusColor(session.status))
2696
+ .frame(width: 8, height: 8)
2697
+ }
2698
+ .font(.caption)
2699
+
2700
+ if let task = session.currentTask {
2701
+ Text(task)
2702
+ .font(.caption2)
2703
+ .foregroundColor(.secondary)
2704
+ .lineLimit(2)
2705
+ }
2706
+
2707
+ Spacer()
2708
+
2709
+ HStack {
2710
+ Image(systemName: "person.2.fill")
2711
+ Text("\(session.agentCount) agents")
2712
+ }
2713
+ .font(.caption2)
2714
+ .foregroundColor(.secondary)
2715
+ }
2716
+ }
2717
+ }
2718
+ .padding()
2719
+ }
2720
+
2721
+ private func statusColor(_ status: String) -> Color {
2722
+ switch status {
2723
+ case "active": return .green
2724
+ case "paused": return .orange
2725
+ case "error": return .red
2726
+ default: return .blue
2727
+ }
2728
+ }
2729
+ }
2730
+
2731
+ @main
2732
+ struct ElsabroWidget: Widget {
2733
+ let kind: String = "ElsabroWidget"
2734
+
2735
+ var body: some WidgetConfiguration {
2736
+ IntentConfiguration(
2737
+ kind: kind,
2738
+ intent: ConfigurationIntent.self,
2739
+ provider: Provider()
2740
+ ) { entry in
2741
+ ElsabroWidgetEntryView(entry: entry)
2742
+ }
2743
+ .configurationDisplayName("ELSABRO Session")
2744
+ .description("Monitor your active ELSABRO session")
2745
+ .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
2746
+ }
2747
+ }
2748
+ ```
2749
+
2750
+ ### Android Widget Implementation
2751
+
2752
+ ```kotlin
2753
+ // widgets/android/app/src/main/java/io/elsabro/companion/widget/ElsabroWidget.kt
2754
+ package io.elsabro.companion.widget
2755
+
2756
+ import android.app.PendingIntent
2757
+ import android.appwidget.AppWidgetManager
2758
+ import android.appwidget.AppWidgetProvider
2759
+ import android.content.Context
2760
+ import android.content.Intent
2761
+ import android.net.Uri
2762
+ import android.widget.RemoteViews
2763
+ import com.google.gson.Gson
2764
+ import io.elsabro.companion.R
2765
+
2766
+ data class SessionData(
2767
+ val id: String,
2768
+ val name: String,
2769
+ val status: String,
2770
+ val progress: Int,
2771
+ val currentTask: String?,
2772
+ val agentCount: Int
2773
+ )
2774
+
2775
+ class ElsabroWidget : AppWidgetProvider() {
2776
+
2777
+ override fun onUpdate(
2778
+ context: Context,
2779
+ appWidgetManager: AppWidgetManager,
2780
+ appWidgetIds: IntArray
2781
+ ) {
2782
+ for (appWidgetId in appWidgetIds) {
2783
+ updateAppWidget(context, appWidgetManager, appWidgetId)
2784
+ }
2785
+ }
2786
+
2787
+ override fun onReceive(context: Context, intent: Intent) {
2788
+ super.onReceive(context, intent)
2789
+
2790
+ when (intent.action) {
2791
+ ACTION_REFRESH -> {
2792
+ val appWidgetId = intent.getIntExtra(
2793
+ AppWidgetManager.EXTRA_APPWIDGET_ID,
2794
+ AppWidgetManager.INVALID_APPWIDGET_ID
2795
+ )
2796
+ if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) {
2797
+ val appWidgetManager = AppWidgetManager.getInstance(context)
2798
+ updateAppWidget(context, appWidgetManager, appWidgetId)
2799
+ }
2800
+ }
2801
+ }
2802
+ }
2803
+
2804
+ companion object {
2805
+ const val ACTION_REFRESH = "io.elsabro.companion.WIDGET_REFRESH"
2806
+
2807
+ internal fun updateAppWidget(
2808
+ context: Context,
2809
+ appWidgetManager: AppWidgetManager,
2810
+ appWidgetId: Int
2811
+ ) {
2812
+ val views = RemoteViews(context.packageName, R.layout.elsabro_widget)
2813
+
2814
+ val session = loadSessionData(context)
2815
+
2816
+ if (session != null) {
2817
+ views.setTextViewText(R.id.session_name, session.name)
2818
+ views.setTextViewText(R.id.progress_text, "${session.progress}%")
2819
+ views.setProgressBar(R.id.progress_bar, 100, session.progress, false)
2820
+ views.setTextViewText(R.id.status_text, session.status.capitalize())
2821
+ views.setTextViewText(
2822
+ R.id.current_task,
2823
+ session.currentTask ?: "No current task"
2824
+ )
2825
+ views.setTextViewText(R.id.agent_count, "${session.agentCount} agents")
2826
+
2827
+ // Set status indicator color
2828
+ val statusColor = when (session.status) {
2829
+ "active" -> android.R.color.holo_green_light
2830
+ "paused" -> android.R.color.holo_orange_light
2831
+ "error" -> android.R.color.holo_red_light
2832
+ else -> android.R.color.holo_blue_light
2833
+ }
2834
+ views.setInt(R.id.status_indicator, "setColorFilter",
2835
+ context.getColor(statusColor))
2836
+
2837
+ // Set click intent to open session
2838
+ val openIntent = Intent(
2839
+ Intent.ACTION_VIEW,
2840
+ Uri.parse("elsabro://session/${session.id}")
2841
+ ).apply {
2842
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
2843
+ }
2844
+ val openPendingIntent = PendingIntent.getActivity(
2845
+ context,
2846
+ 0,
2847
+ openIntent,
2848
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
2849
+ )
2850
+ views.setOnClickPendingIntent(R.id.widget_container, openPendingIntent)
2851
+ } else {
2852
+ views.setTextViewText(R.id.session_name, "No active session")
2853
+ views.setTextViewText(R.id.progress_text, "--")
2854
+ views.setProgressBar(R.id.progress_bar, 100, 0, false)
2855
+ }
2856
+
2857
+ // Refresh button
2858
+ val refreshIntent = Intent(context, ElsabroWidget::class.java).apply {
2859
+ action = ACTION_REFRESH
2860
+ putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
2861
+ }
2862
+ val refreshPendingIntent = PendingIntent.getBroadcast(
2863
+ context,
2864
+ appWidgetId,
2865
+ refreshIntent,
2866
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
2867
+ )
2868
+ views.setOnClickPendingIntent(R.id.refresh_button, refreshPendingIntent)
2869
+
2870
+ appWidgetManager.updateAppWidget(appWidgetId, views)
2871
+ }
2872
+
2873
+ private fun loadSessionData(context: Context): SessionData? {
2874
+ val prefs = context.getSharedPreferences(
2875
+ "elsabro_widget_data",
2876
+ Context.MODE_PRIVATE
2877
+ )
2878
+ val json = prefs.getString("activeSession", null) ?: return null
2879
+ return try {
2880
+ Gson().fromJson(json, SessionData::class.java)
2881
+ } catch (e: Exception) {
2882
+ null
2883
+ }
2884
+ }
2885
+ }
2886
+ }
2887
+ ```
2888
+
2889
+ ### React Native Widget Bridge
2890
+
2891
+ ```typescript
2892
+ // src/services/widgetBridge.ts
2893
+ import { NativeModules, Platform } from 'react-native';
2894
+ import AsyncStorage from '@react-native-async-storage/async-storage';
2895
+
2896
+ interface WidgetData {
2897
+ id: string;
2898
+ name: string;
2899
+ status: string;
2900
+ progress: number;
2901
+ currentTask: string | null;
2902
+ agentCount: number;
2903
+ }
2904
+
2905
+ const { WidgetModule } = NativeModules;
2906
+
2907
+ export async function updateWidget(session: WidgetData | null): Promise<void> {
2908
+ const data = session ? JSON.stringify(session) : null;
2909
+
2910
+ if (Platform.OS === 'ios') {
2911
+ // Update shared UserDefaults for iOS widget
2912
+ await WidgetModule.setWidgetData('activeSession', data);
2913
+ await WidgetModule.reloadAllTimelines();
2914
+ } else if (Platform.OS === 'android') {
2915
+ // Update SharedPreferences for Android widget
2916
+ await WidgetModule.updateWidgetData('activeSession', data);
2917
+ await WidgetModule.requestWidgetUpdate();
2918
+ }
2919
+ }
2920
+
2921
+ export async function clearWidgetData(): Promise<void> {
2922
+ if (Platform.OS === 'ios') {
2923
+ await WidgetModule.setWidgetData('activeSession', null);
2924
+ await WidgetModule.reloadAllTimelines();
2925
+ } else if (Platform.OS === 'android') {
2926
+ await WidgetModule.updateWidgetData('activeSession', null);
2927
+ await WidgetModule.requestWidgetUpdate();
2928
+ }
2929
+ }
2930
+
2931
+ // Hook to keep widget updated
2932
+ export function useWidgetSync() {
2933
+ const { activeSessionId, sessions } = useSessionStore();
2934
+
2935
+ useEffect(() => {
2936
+ if (activeSessionId && sessions[activeSessionId]) {
2937
+ const session = sessions[activeSessionId];
2938
+ updateWidget({
2939
+ id: session.id,
2940
+ name: session.name,
2941
+ status: session.status,
2942
+ progress: session.progress,
2943
+ currentTask: session.currentTask,
2944
+ agentCount: session.agents.length,
2945
+ });
2946
+ } else {
2947
+ clearWidgetData();
2948
+ }
2949
+ }, [activeSessionId, sessions]);
2950
+ }
2951
+ ```
2952
+
2953
+ ---
2954
+
2955
+ ## 8. Commands
2956
+
2957
+ ### /elsabro:mobile Commands
2958
+
2959
+ | Command | Description | Options |
2960
+ |---------|-------------|---------|
2961
+ | `/elsabro:mobile setup` | Initialize mobile companion | `--platform ios\|android\|both` |
2962
+ | `/elsabro:mobile sync` | Force synchronization | `--full`, `--sessions-only` |
2963
+ | `/elsabro:mobile notify` | Manage notifications | `--test`, `--status`, `--clear` |
2964
+ | `/elsabro:mobile voice` | Voice input control | `--enable`, `--disable`, `--test` |
2965
+
2966
+ ### Command Implementation
2967
+
2968
+ ```typescript
2969
+ // src/commands/mobile.ts
2970
+ import { useSessionStore } from '@/stores/sessionStore';
2971
+ import { useOfflineStore } from '@/stores/offlineStore';
2972
+ import { useNotificationStore } from '@/stores/notificationStore';
2973
+ import { wsClient } from '@/services/websocket';
2974
+
2975
+ interface CommandResult {
2976
+ success: boolean;
2977
+ message: string;
2978
+ data?: unknown;
2979
+ }
2980
+
2981
+ export async function executeCommand(
2982
+ command: string,
2983
+ args: string[]
2984
+ ): Promise<CommandResult> {
2985
+ const [subcommand, ...options] = args;
2986
+
2987
+ switch (subcommand) {
2988
+ case 'setup':
2989
+ return handleSetup(options);
2990
+ case 'sync':
2991
+ return handleSync(options);
2992
+ case 'notify':
2993
+ return handleNotify(options);
2994
+ case 'voice':
2995
+ return handleVoice(options);
2996
+ default:
2997
+ return {
2998
+ success: false,
2999
+ message: `Unknown subcommand: ${subcommand}`,
3000
+ };
3001
+ }
3002
+ }
3003
+
3004
+ async function handleSetup(options: string[]): Promise<CommandResult> {
3005
+ const platform = options.find(o => o.startsWith('--platform='))
3006
+ ?.split('=')[1] || 'both';
3007
+
3008
+ // Verify dependencies
3009
+ const checks = {
3010
+ notifications: await checkNotificationPermission(),
3011
+ biometrics: await checkBiometricEnrollment(),
3012
+ network: await checkNetworkConnectivity(),
3013
+ };
3014
+
3015
+ return {
3016
+ success: Object.values(checks).every(Boolean),
3017
+ message: 'Mobile setup complete',
3018
+ data: {
3019
+ platform,
3020
+ checks,
3021
+ timestamp: new Date().toISOString(),
3022
+ },
3023
+ };
3024
+ }
3025
+
3026
+ async function handleSync(options: string[]): Promise<CommandResult> {
3027
+ const fullSync = options.includes('--full');
3028
+ const sessionsOnly = options.includes('--sessions-only');
3029
+
3030
+ try {
3031
+ if (fullSync) {
3032
+ // Clear cache and sync everything
3033
+ const offlineStore = useOfflineStore.getState();
3034
+ offlineStore.clearExpiredCache();
3035
+ }
3036
+
3037
+ // Trigger sync
3038
+ wsClient.send({
3039
+ type: 'session:sync:request',
3040
+ payload: {
3041
+ fullSync,
3042
+ sessionsOnly,
3043
+ lastSync: useSessionStore.getState().lastSyncTimestamp,
3044
+ },
3045
+ timestamp: Date.now(),
3046
+ messageId: crypto.randomUUID(),
3047
+ });
3048
+
3049
+ // Also sync pending actions
3050
+ await useOfflineStore.getState().syncPendingActions();
3051
+
3052
+ return {
3053
+ success: true,
3054
+ message: fullSync ? 'Full sync initiated' : 'Sync initiated',
3055
+ data: {
3056
+ pendingActions: useOfflineStore.getState().pendingActions.length,
3057
+ lastSync: useSessionStore.getState().lastSyncTimestamp,
3058
+ },
3059
+ };
3060
+ } catch (error) {
3061
+ return {
3062
+ success: false,
3063
+ message: `Sync failed: ${error}`,
3064
+ };
3065
+ }
3066
+ }
3067
+
3068
+ async function handleNotify(options: string[]): Promise<CommandResult> {
3069
+ if (options.includes('--test')) {
3070
+ // Send test notification
3071
+ await Notifications.scheduleNotificationAsync({
3072
+ content: {
3073
+ title: 'ELSABRO Test',
3074
+ body: 'This is a test notification from ELSABRO Mobile',
3075
+ data: { type: 'test' },
3076
+ },
3077
+ trigger: null,
3078
+ });
3079
+ return { success: true, message: 'Test notification sent' };
3080
+ }
3081
+
3082
+ if (options.includes('--status')) {
3083
+ const permissions = await Notifications.getPermissionsAsync();
3084
+ return {
3085
+ success: true,
3086
+ message: 'Notification status',
3087
+ data: {
3088
+ granted: permissions.granted,
3089
+ canAskAgain: permissions.canAskAgain,
3090
+ status: permissions.status,
3091
+ },
3092
+ };
3093
+ }
3094
+
3095
+ if (options.includes('--clear')) {
3096
+ await Notifications.dismissAllNotificationsAsync();
3097
+ await Notifications.setBadgeCountAsync(0);
3098
+ return { success: true, message: 'Notifications cleared' };
3099
+ }
3100
+
3101
+ return { success: false, message: 'No valid option provided' };
3102
+ }
3103
+
3104
+ async function handleVoice(options: string[]): Promise<CommandResult> {
3105
+ if (options.includes('--enable')) {
3106
+ await AsyncStorage.setItem('voice_enabled', 'true');
3107
+ return { success: true, message: 'Voice input enabled' };
3108
+ }
3109
+
3110
+ if (options.includes('--disable')) {
3111
+ await AsyncStorage.setItem('voice_enabled', 'false');
3112
+ return { success: true, message: 'Voice input disabled' };
3113
+ }
3114
+
3115
+ if (options.includes('--test')) {
3116
+ const isAvailable = await Voice.isAvailable();
3117
+ return {
3118
+ success: true,
3119
+ message: 'Voice input status',
3120
+ data: { available: isAvailable },
3121
+ };
3122
+ }
3123
+
3124
+ return { success: false, message: 'No valid option provided' };
3125
+ }
3126
+ ```
3127
+
3128
+ ---
3129
+
3130
+ ## Appendix: Type Definitions
3131
+
3132
+ ```typescript
3133
+ // src/types/index.ts
3134
+
3135
+ export interface Session {
3136
+ id: string;
3137
+ name: string;
3138
+ status: 'active' | 'paused' | 'completed' | 'error';
3139
+ agents: string[];
3140
+ currentTask: string | null;
3141
+ progress: number;
3142
+ startedAt: string;
3143
+ lastActivity: string;
3144
+ error?: string;
3145
+ }
3146
+
3147
+ export interface SessionDetail extends Session {
3148
+ logs: SessionLog[];
3149
+ tasks: Task[];
3150
+ metrics: SessionMetrics;
3151
+ }
3152
+
3153
+ export interface SessionLog {
3154
+ id: string;
3155
+ timestamp: string;
3156
+ level: 'info' | 'warn' | 'error' | 'debug';
3157
+ message: string;
3158
+ agentId?: string;
3159
+ metadata?: Record<string, unknown>;
3160
+ }
3161
+
3162
+ export interface Task {
3163
+ id: string;
3164
+ name: string;
3165
+ status: 'pending' | 'running' | 'completed' | 'failed' | 'awaiting_approval';
3166
+ assignedAgent: string;
3167
+ createdAt: string;
3168
+ completedAt?: string;
3169
+ }
3170
+
3171
+ export interface SessionMetrics {
3172
+ tokenUsage: number;
3173
+ estimatedCost: number;
3174
+ duration: number;
3175
+ tasksCompleted: number;
3176
+ tasksFailed: number;
3177
+ }
3178
+
3179
+ export interface User {
3180
+ id: string;
3181
+ email: string;
3182
+ name: string;
3183
+ avatar?: string;
3184
+ preferences: UserPreferences;
3185
+ }
3186
+
3187
+ export interface UserPreferences {
3188
+ theme: 'light' | 'dark' | 'system';
3189
+ notifications: NotificationPreferences;
3190
+ voiceEnabled: boolean;
3191
+ hapticFeedback: boolean;
3192
+ }
3193
+
3194
+ export interface NotificationPreferences {
3195
+ taskCompleted: boolean;
3196
+ buildFailed: boolean;
3197
+ approvalNeeded: boolean;
3198
+ sessionUpdates: boolean;
3199
+ }
3200
+
3201
+ export interface LoginCredentials {
3202
+ email: string;
3203
+ password: string;
3204
+ }
3205
+
3206
+ export interface CreateSessionRequest {
3207
+ name: string;
3208
+ agents: string[];
3209
+ config?: Record<string, unknown>;
3210
+ }
3211
+
3212
+ export interface CommandResponse {
3213
+ success: boolean;
3214
+ message: string;
3215
+ data?: unknown;
3216
+ }
3217
+
3218
+ export interface SessionFilters {
3219
+ status?: Session['status'][];
3220
+ search?: string;
3221
+ sortBy?: 'name' | 'startedAt' | 'lastActivity';
3222
+ sortOrder?: 'asc' | 'desc';
3223
+ }
3224
+ ```
3225
+
3226
+ ---
3227
+
3228
+ ## Version History
3229
+
3230
+ | Version | Date | Changes |
3231
+ |---------|------|---------|
3232
+ | 3.7.0 | 2026-02-02 | Initial mobile companion documentation |
3233
+
3234
+ ---
3235
+
3236
+ *ELSABRO Mobile Companion - Technical Reference v3.7*