yaver-feedback-react-native 0.6.1 → 0.7.1

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.
@@ -2,262 +2,304 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import {
3
3
  ActivityIndicator,
4
4
  DeviceEventEmitter,
5
- FlatList,
6
5
  Modal,
7
6
  Platform,
8
- ScrollView,
7
+ Pressable,
9
8
  StyleSheet,
10
9
  Text,
11
- TouchableOpacity,
12
10
  View,
13
11
  } from 'react-native';
14
12
  import { YaverFeedback } from './YaverFeedback';
15
- import { BlackBox } from './BlackBox';
16
- import { captureScreenshot, startAudioRecording, stopAudioRecording } from './capture';
13
+ import {
14
+ captureScreenshot,
15
+ startVideoRecording,
16
+ stopVideoRecording,
17
+ } from './capture';
17
18
  import { uploadFeedback } from './upload';
18
- import { TimelineEvent, DeviceInfo, FeedbackBundle, AgentCommentary } from './types';
19
+ import { DeviceInfo, FeedbackBundle } from './types';
19
20
  import { AuthOverlay } from './AuthOverlay';
20
21
 
21
- type FeedbackMode = 'live' | 'narrated' | 'batch';
22
-
23
- const MODE_LABELS: Record<FeedbackMode, string> = {
24
- live: 'Live',
25
- narrated: 'Narrated',
26
- batch: 'Batch',
27
- };
28
-
29
22
  /**
30
- * Full-screen modal for composing and sending a feedback report.
31
- * Renders when triggered by shake, floating button, or manual call.
23
+ * Simplified feedback modal 5 actions:
32
24
  *
33
- * Supports three feedback modes:
34
- * - Live: stream events to the agent as they happen
35
- * - Narrated: record everything, send on stop
36
- * - Batch: dump everything at end (default)
25
+ * 1. Hot Reload — instant JS reload (most common use case)
26
+ * 2. Screenshot + Fix — capture the underlying app (modal hidden
27
+ * during capture), attach errors, trigger
28
+ * a fix task on the agent
29
+ * 3. Vibing — open a vibing session on the agent
30
+ * 4. Start / Stop Recording — screen-recording toggle
31
+ * 5. Send Video — submit the last recorded video
32
+ *
33
+ * The header has an explicit X close icon on the right.
34
+ * Live / Narrated / Batch modes, voice notes, and the streaming indicator
35
+ * were removed in 0.7.0 — those flows never worked end-to-end against
36
+ * the Go agent (see MISSINGS_FEEDBACK_SDK.md).
37
37
  */
38
+
39
+ interface LastVideo {
40
+ path: string;
41
+ duration: number;
42
+ }
43
+
44
+ type ActionState =
45
+ | 'idle'
46
+ | 'hot-reloading'
47
+ | 'capturing'
48
+ | 'vibing'
49
+ | 'sending-video';
50
+
38
51
  export const FeedbackModal: React.FC = () => {
39
52
  const [visible, setVisible] = useState(false);
40
- const [timeline, setTimeline] = useState<TimelineEvent[]>([]);
41
- const [isRecordingAudio, setIsRecordingAudio] = useState(false);
42
- const [isSending, setIsSending] = useState(false);
53
+ const [action, setAction] = useState<ActionState>('idle');
43
54
  const [error, setError] = useState<string | null>(null);
44
- const [sent, setSent] = useState(false);
45
- const [mode, setMode] = useState<FeedbackMode>('batch');
46
- const [commentary, setCommentary] = useState<AgentCommentary[]>([]);
47
- const [isVoiceCommand, setIsVoiceCommand] = useState(false);
48
- const [isReloading, setIsReloading] = useState(false);
55
+ const [toast, setToast] = useState<string | null>(null);
56
+ const [isRecordingVideo, setIsRecordingVideo] = useState(false);
57
+ const [lastVideo, setLastVideo] = useState<LastVideo | null>(null);
49
58
  const mountedRef = useRef(true);
50
- const commentaryListRef = useRef<FlatList>(null);
51
59
 
52
60
  useEffect(() => {
53
61
  mountedRef.current = true;
54
62
  const sub = DeviceEventEmitter.addListener('yaverFeedback:startReport', () => {
55
63
  if (YaverFeedback.isEnabled()) {
56
64
  setVisible(true);
57
- setTimeline([]);
58
65
  setError(null);
59
- setSent(false);
60
- setCommentary([]);
61
- setMode(YaverFeedback.getFeedbackMode());
66
+ setToast(null);
67
+ setAction('idle');
62
68
  }
63
69
  });
64
-
65
- // Listen for agent commentary events
66
- const commentarySub = DeviceEventEmitter.addListener(
67
- 'yaverFeedback:commentary',
68
- (event: AgentCommentary) => {
69
- if (mountedRef.current) {
70
- setCommentary((prev) => [...prev, event]);
71
- }
72
- },
73
- );
74
-
75
70
  return () => {
76
71
  mountedRef.current = false;
77
72
  sub.remove();
78
- commentarySub.remove();
79
73
  };
80
74
  }, []);
81
75
 
82
- const handleScreenshot = useCallback(async () => {
83
- try {
84
- const path = await captureScreenshot();
85
- if (mountedRef.current) {
86
- const event: TimelineEvent = {
87
- type: 'screenshot',
88
- path,
89
- timestamp: new Date().toISOString(),
90
- };
91
- setTimeline((prev) => [...prev, event]);
76
+ const closeSoon = useCallback((delayMs = 1200) => {
77
+ setTimeout(() => {
78
+ if (mountedRef.current) setVisible(false);
79
+ }, delayMs);
80
+ }, []);
92
81
 
93
- // In live mode, stream the event immediately
94
- if (mode === 'live') {
95
- const client = YaverFeedback.getP2PClient();
96
- if (client) {
97
- try {
98
- await client.streamFeedback(
99
- (async function* () {
100
- yield {
101
- type: 'screenshot',
102
- timestamp: event.timestamp,
103
- data: { path },
104
- };
105
- })(),
106
- );
107
- } catch (err) {
108
- console.warn('[YaverFeedback] Live stream failed:', err);
109
- }
110
- }
111
- }
82
+ const handleClose = useCallback(() => {
83
+ setVisible(false);
84
+ setError(null);
85
+ setToast(null);
86
+ setAction('idle');
87
+ }, []);
88
+
89
+ // Helper: run a P2P call; on network failure, ask YaverFeedback to
90
+ // re-query Convex for the fresh IP and retry once. Solves the common
91
+ // case where the Mac's LAN IP rotated while the SDK held a stale URL.
92
+ const runWithReconnect = useCallback(
93
+ async (fn: (client: NonNullable<ReturnType<typeof YaverFeedback.getP2PClient>>) => Promise<void>) => {
94
+ let client = YaverFeedback.getP2PClient();
95
+ if (!client) {
96
+ const ok = await YaverFeedback.reconnect();
97
+ if (ok) client = YaverFeedback.getP2PClient();
112
98
  }
113
- } catch (err) {
114
- if (mountedRef.current) {
115
- setError(String(err));
99
+ if (!client) {
100
+ throw new Error('Not connected to the agent yet.');
116
101
  }
117
- }
118
- }, [mode]);
119
-
120
- const handleToggleAudio = useCallback(async () => {
121
- if (isRecordingAudio) {
122
102
  try {
123
- const result = await stopAudioRecording();
124
- if (mountedRef.current) {
125
- setIsRecordingAudio(false);
126
- const event: TimelineEvent = {
127
- type: 'audio',
128
- path: result.path,
129
- timestamp: new Date().toISOString(),
130
- duration: result.duration,
131
- };
132
- setTimeline((prev) => [...prev, event]);
133
-
134
- // In live mode, stream the audio event
135
- if (mode === 'live') {
136
- const client = YaverFeedback.getP2PClient();
137
- if (client) {
138
- try {
139
- await client.streamFeedback(
140
- (async function* () {
141
- yield {
142
- type: 'audio',
143
- timestamp: event.timestamp,
144
- data: { path: result.path, duration: result.duration },
145
- };
146
- })(),
147
- );
148
- } catch (err) {
149
- console.warn('[YaverFeedback] Live stream failed:', err);
150
- }
151
- }
152
- }
153
- }
103
+ await fn(client);
154
104
  } catch (err) {
155
- if (mountedRef.current) {
156
- setIsRecordingAudio(false);
157
- setError(String(err));
158
- }
105
+ const msg = err instanceof Error ? err.message : String(err);
106
+ const transient = /Network request failed|timeout|ECONNREFUSED|Failed to fetch|fetch failed|aborted/i.test(msg);
107
+ if (!transient) throw err;
108
+ const ok = await YaverFeedback.reconnect();
109
+ if (!ok) throw err;
110
+ const fresh = YaverFeedback.getP2PClient();
111
+ if (!fresh) throw err;
112
+ await fn(fresh);
159
113
  }
160
- } else {
161
- try {
162
- await startAudioRecording();
163
- if (mountedRef.current) {
164
- setIsRecordingAudio(true);
165
- }
166
- } catch (err) {
167
- if (mountedRef.current) {
168
- setError(String(err));
114
+ },
115
+ [],
116
+ );
117
+
118
+ // ─── 1. Hot reload ─────────────────────────────────────────────────
119
+ const handleHotReload = useCallback(async () => {
120
+ setAction('hot-reloading');
121
+ setError(null);
122
+ try {
123
+ await runWithReconnect(async (client) => {
124
+ await client.reloadApp('dev');
125
+ });
126
+ setToast('Reload sent');
127
+ closeSoon(800);
128
+ } catch (err: unknown) {
129
+ setError(err instanceof Error ? err.message : String(err));
130
+ } finally {
131
+ if (mountedRef.current) setAction('idle');
132
+ }
133
+ }, [closeSoon, runWithReconnect]);
134
+
135
+ // ─── 2. Screenshot + Fix ───────────────────────────────────────────
136
+ //
137
+ // Hide the modal first so the screenshot captures the actual screen
138
+ // (the bug) — not the modal card. Await a short animation delay,
139
+ // snapshot, upload the feedback bundle with any buffered errors, then
140
+ // kick `/feedback/{id}/fix` to create the repair task.
141
+ const handleScreenshotAndFix = useCallback(async () => {
142
+ const client = YaverFeedback.getP2PClient();
143
+ const config = YaverFeedback.getConfig();
144
+ if (!client || !config?.agentUrl) {
145
+ setError('Not connected to the agent yet.');
146
+ return;
147
+ }
148
+ setAction('capturing');
149
+ setError(null);
150
+
151
+ // Step 1: Hide the modal so the screenshot contains the real screen.
152
+ setVisible(false);
153
+ // Wait out the slide-down animation on both platforms.
154
+ await new Promise((resolve) => setTimeout(resolve, 350));
155
+
156
+ let path: string | null = null;
157
+ try {
158
+ path = await captureScreenshot();
159
+ } catch (err: unknown) {
160
+ setVisible(true);
161
+ setError(err instanceof Error ? err.message : String(err));
162
+ setAction('idle');
163
+ return;
164
+ }
165
+
166
+ // Step 2: Re-show the modal for progress + ack.
167
+ setVisible(true);
168
+ await new Promise((resolve) => setTimeout(resolve, 150));
169
+
170
+ try {
171
+ const { Dimensions } = require('react-native');
172
+ const { width, height } = Dimensions.get('window');
173
+ const deviceInfo: DeviceInfo = {
174
+ platform: Platform.OS,
175
+ osVersion: String(Platform.Version),
176
+ model: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
177
+ screenWidth: width,
178
+ screenHeight: height,
179
+ };
180
+
181
+ const capturedErrors = YaverFeedback.getCapturedErrors();
182
+ const bundle: FeedbackBundle = {
183
+ metadata: {
184
+ timestamp: new Date().toISOString(),
185
+ device: deviceInfo,
186
+ app: {},
187
+ userNote: '[Screenshot + Fix]',
188
+ },
189
+ screenshots: [path],
190
+ errors: capturedErrors.length > 0 ? capturedErrors : undefined,
191
+ };
192
+
193
+ const uploaded = await uploadFeedback(
194
+ config.agentUrl,
195
+ config.authToken ?? '',
196
+ bundle,
197
+ );
198
+ // The agent returns the new report id as `id` (see
199
+ // feedback_http.go::ReceiveFeedback). Trigger the fix loop if we got
200
+ // one back; otherwise just ack the upload.
201
+ const reportId =
202
+ (uploaded as { id?: string; reportId?: string } | null | undefined)?.id ??
203
+ (uploaded as { reportId?: string } | null | undefined)?.reportId;
204
+ if (reportId) {
205
+ try {
206
+ await client.triggerFix(reportId);
207
+ setToast('Fix task started');
208
+ } catch (err: unknown) {
209
+ setToast('Report uploaded — fix trigger failed');
210
+ setError(err instanceof Error ? err.message : String(err));
169
211
  }
212
+ } else {
213
+ setToast('Report uploaded');
170
214
  }
215
+ closeSoon(1400);
216
+ } catch (err: unknown) {
217
+ setError(err instanceof Error ? err.message : String(err));
218
+ } finally {
219
+ if (mountedRef.current) setAction('idle');
220
+ }
221
+ }, [closeSoon]);
222
+
223
+ // ─── 3. Vibing ─────────────────────────────────────────────────────
224
+ const handleVibing = useCallback(async () => {
225
+ const client = YaverFeedback.getP2PClient();
226
+ if (!client) {
227
+ setError('Not connected to the agent yet.');
228
+ return;
171
229
  }
172
- }, [isRecordingAudio, mode]);
230
+ setAction('vibing');
231
+ setError(null);
232
+ try {
233
+ const capturedErrors = YaverFeedback.getCapturedErrors();
234
+ const errNote =
235
+ capturedErrors.length > 0
236
+ ? `\n\nRecent captured errors:\n` +
237
+ capturedErrors
238
+ .slice(-3)
239
+ .map((e) => `- ${e.message}`)
240
+ .join('\n')
241
+ : '';
242
+ const prompt =
243
+ 'The user opened the feedback modal on their phone and tapped Vibing. ' +
244
+ 'Investigate whatever they are likely to be asking about — pick the ' +
245
+ 'next small improvement or fix based on recent activity and the ' +
246
+ 'current screen.' +
247
+ errNote;
248
+ await client.vibing(prompt);
249
+ setToast('Vibing task created');
250
+ closeSoon(1200);
251
+ } catch (err: unknown) {
252
+ setError(err instanceof Error ? err.message : String(err));
253
+ } finally {
254
+ if (mountedRef.current) setAction('idle');
255
+ }
256
+ }, [closeSoon]);
173
257
 
174
- const handleVoiceCommand = useCallback(async () => {
175
- if (isVoiceCommand) {
176
- // Stop voice command and send as a task
258
+ // ─── 4. Toggle screen recording ────────────────────────────────────
259
+ const handleToggleRecording = useCallback(async () => {
260
+ setError(null);
261
+ if (isRecordingVideo) {
177
262
  try {
178
- const result = await stopAudioRecording();
263
+ const result = await stopVideoRecording();
179
264
  if (mountedRef.current) {
180
- setIsVoiceCommand(false);
181
-
182
- const client = YaverFeedback.getP2PClient();
183
- if (client) {
184
- try {
185
- await client.streamFeedback(
186
- (async function* () {
187
- yield {
188
- type: 'voice_command',
189
- timestamp: new Date().toISOString(),
190
- data: { path: result.path, duration: result.duration },
191
- };
192
- })(),
193
- );
194
- } catch (err) {
195
- console.warn('[YaverFeedback] Voice command send failed:', err);
196
- if (mountedRef.current) {
197
- setError('Failed to send voice command.');
198
- }
199
- }
200
- }
201
- }
202
- } catch (err) {
203
- if (mountedRef.current) {
204
- setIsVoiceCommand(false);
205
- setError(String(err));
265
+ setIsRecordingVideo(false);
266
+ setLastVideo(result);
267
+ setToast(`Recording stopped ${Math.round(result.duration)}s`);
206
268
  }
269
+ } catch (err: unknown) {
270
+ setIsRecordingVideo(false);
271
+ setError(err instanceof Error ? err.message : String(err));
207
272
  }
208
273
  } else {
209
274
  try {
210
- await startAudioRecording();
211
- if (mountedRef.current) {
212
- setIsVoiceCommand(true);
213
- }
214
- } catch (err) {
275
+ await startVideoRecording();
215
276
  if (mountedRef.current) {
216
- setError(String(err));
277
+ setIsRecordingVideo(true);
278
+ setToast('Recording…');
279
+ setLastVideo(null);
217
280
  }
281
+ } catch (err: unknown) {
282
+ setError(err instanceof Error ? err.message : String(err));
218
283
  }
219
284
  }
220
- }, [isVoiceCommand]);
285
+ }, [isRecordingVideo]);
221
286
 
222
- const handleReload = useCallback(async () => {
287
+ // ─── 5. Send the last recorded video ───────────────────────────────
288
+ const handleSendVideo = useCallback(async () => {
223
289
  const config = YaverFeedback.getConfig();
224
- if (!config?.agentUrl) return;
225
-
226
- setIsReloading(true);
227
- try {
228
- const response = await fetch(`${config.agentUrl.replace(/\/$/, '')}/dev/reload-app`, {
229
- method: 'POST',
230
- headers: {
231
- Authorization: `Bearer ${config.authToken}`,
232
- 'Content-Type': 'application/json',
233
- },
234
- body: JSON.stringify({ mode: 'dev' }),
235
- });
236
- if (response.ok) {
237
- BlackBox.lifecycle('Hot reload triggered from feedback SDK');
238
- }
239
- } catch (err) {
240
- if (mountedRef.current) {
241
- setError('Reload failed: ' + String(err));
242
- }
243
- } finally {
244
- if (mountedRef.current) {
245
- setIsReloading(false);
246
- }
290
+ if (!config?.agentUrl) {
291
+ setError('Not connected to the agent yet.');
292
+ return;
247
293
  }
248
- }, []);
249
-
250
- const handleSend = useCallback(async () => {
251
- const config = YaverFeedback.getConfig();
252
- if (!config || !config.agentUrl) return;
253
-
254
- setIsSending(true);
294
+ if (!lastVideo) {
295
+ setError('No video recorded yet.');
296
+ return;
297
+ }
298
+ setAction('sending-video');
255
299
  setError(null);
256
-
257
300
  try {
258
301
  const { Dimensions } = require('react-native');
259
302
  const { width, height } = Dimensions.get('window');
260
-
261
303
  const deviceInfo: DeviceInfo = {
262
304
  platform: Platform.OS,
263
305
  osVersion: String(Platform.Version),
@@ -265,421 +307,224 @@ export const FeedbackModal: React.FC = () => {
265
307
  screenWidth: width,
266
308
  screenHeight: height,
267
309
  };
268
-
269
- const screenshots = timeline
270
- .filter((e) => e.type === 'screenshot')
271
- .map((e) => e.path);
272
-
273
- const audioEvent = timeline.find((e) => e.type === 'audio');
274
-
275
- // Include captured errors from the error buffer
276
- const capturedErrors = YaverFeedback.getCapturedErrors();
277
-
278
310
  const bundle: FeedbackBundle = {
279
311
  metadata: {
280
312
  timestamp: new Date().toISOString(),
281
313
  device: deviceInfo,
282
314
  app: {},
315
+ userNote: '[Screen recording]',
283
316
  },
284
- screenshots,
285
- audio: audioEvent?.path,
286
- errors: capturedErrors.length > 0 ? capturedErrors : undefined,
317
+ screenshots: [],
318
+ video: lastVideo.path,
319
+ errors: YaverFeedback.getCapturedErrors().length
320
+ ? YaverFeedback.getCapturedErrors()
321
+ : undefined,
287
322
  };
288
-
289
- await uploadFeedback(config.agentUrl, config.authToken, bundle);
290
-
291
- if (mountedRef.current) {
292
- setSent(true);
293
- // Auto-close after a short delay
294
- setTimeout(() => {
295
- if (mountedRef.current) {
296
- setVisible(false);
297
- }
298
- }, 1500);
299
- }
300
- } catch (err) {
301
- if (mountedRef.current) {
302
- setError(String(err));
303
- }
323
+ await uploadFeedback(config.agentUrl, config.authToken ?? '', bundle);
324
+ setToast('Video sent');
325
+ setLastVideo(null);
326
+ closeSoon(1200);
327
+ } catch (err: unknown) {
328
+ setError(err instanceof Error ? err.message : String(err));
304
329
  } finally {
305
- if (mountedRef.current) {
306
- setIsSending(false);
307
- }
330
+ if (mountedRef.current) setAction('idle');
308
331
  }
309
- }, [timeline]);
310
-
311
- const handleCancel = useCallback(() => {
312
- setVisible(false);
313
- setTimeline([]);
314
- setError(null);
315
- setSent(false);
316
- setIsRecordingAudio(false);
317
- setIsVoiceCommand(false);
318
- setCommentary([]);
319
- }, []);
332
+ }, [lastVideo, closeSoon]);
320
333
 
321
- const renderCommentaryItem = useCallback(
322
- ({ item }: { item: AgentCommentary }) => (
323
- <View style={styles.commentaryBubble}>
324
- <Text style={styles.commentaryType}>{item.type}</Text>
325
- <Text style={styles.commentaryMessage}>{item.message}</Text>
326
- </View>
327
- ),
328
- [],
329
- );
334
+ const busy = action !== 'idle';
330
335
 
331
- // The AuthOverlay must stay mounted so it can respond to login / picker
332
- // events even when the feedback modal itself isn't visible. The wrapping
333
- // fragment keeps the original return shape intact for the modal branch.
334
336
  return (
335
337
  <>
336
338
  <AuthOverlay />
337
339
  {visible && (
338
- <Modal
339
- visible={visible}
340
- animationType="slide"
341
- transparent
342
- onRequestClose={handleCancel}
343
- >
344
- <View style={styles.overlay}>
345
- <View style={styles.modal}>
346
- <Text style={styles.title}>Send Feedback</Text>
347
-
348
- {/* Mode selector */}
349
- <View style={styles.modeSelector}>
350
- {(['live', 'narrated', 'batch'] as FeedbackMode[]).map((m) => (
351
- <TouchableOpacity
352
- key={m}
353
- style={[styles.modeButton, mode === m && styles.modeButtonActive]}
354
- onPress={() => setMode(m)}
355
- >
356
- <Text
357
- style={[styles.modeButtonText, mode === m && styles.modeButtonTextActive]}
340
+ <Modal
341
+ visible={visible}
342
+ animationType="slide"
343
+ transparent
344
+ onRequestClose={handleClose}
345
+ >
346
+ <Pressable style={styles.overlay} onPress={handleClose}>
347
+ <Pressable style={styles.modal} onPress={(e) => e.stopPropagation()}>
348
+ <View style={styles.header}>
349
+ <Text style={styles.title}>Send Feedback</Text>
350
+ <Pressable
351
+ onPress={handleClose}
352
+ hitSlop={12}
353
+ style={styles.closeBtn}
354
+ accessibilityRole="button"
355
+ accessibilityLabel="Close"
358
356
  >
359
- {MODE_LABELS[m]}
360
- </Text>
361
- </TouchableOpacity>
362
- ))}
363
- </View>
364
-
365
- {/* Agent commentary (chat-like view) */}
366
- {commentary.length > 0 && (
367
- <FlatList
368
- ref={commentaryListRef}
369
- data={commentary}
370
- renderItem={renderCommentaryItem}
371
- keyExtractor={(item) => item.id}
372
- style={styles.commentaryList}
373
- onContentSizeChange={() =>
374
- commentaryListRef.current?.scrollToEnd({ animated: true })
375
- }
376
- />
377
- )}
378
-
379
- {/* Timeline of captured items */}
380
- {timeline.length > 0 && (
381
- <ScrollView style={styles.timeline} horizontal>
382
- {timeline.map((event, idx) => (
383
- <View key={idx} style={styles.timelineItem}>
384
- <Text style={styles.timelineIcon}>
385
- {event.type === 'screenshot'
386
- ? '[img]'
387
- : event.type === 'audio'
388
- ? '[mic]'
389
- : '[vid]'}
390
- </Text>
391
- <Text style={styles.timelineLabel}>{event.type}</Text>
392
- {event.duration != null && (
393
- <Text style={styles.timelineDuration}>
394
- {Math.round(event.duration)}s
395
- </Text>
396
- )}
397
- </View>
398
- ))}
399
- </ScrollView>
400
- )}
401
-
402
- {/* Action buttons */}
403
- <View style={styles.actions}>
404
- <TouchableOpacity style={styles.actionButton} onPress={handleScreenshot}>
405
- <Text style={styles.actionText}>Take Screenshot</Text>
406
- </TouchableOpacity>
407
-
408
- <TouchableOpacity
409
- style={[styles.actionButton, isRecordingAudio && styles.actionButtonActive]}
410
- onPress={handleToggleAudio}
411
- disabled={isVoiceCommand}
412
- >
413
- <Text style={styles.actionText}>
414
- {isRecordingAudio ? 'Stop Recording' : 'Voice Note'}
415
- </Text>
416
- </TouchableOpacity>
417
- </View>
418
-
419
- {/* Hot Reload + Streaming status */}
420
- <View style={styles.actions}>
421
- <TouchableOpacity
422
- style={[styles.actionButton, styles.reloadButton]}
423
- onPress={handleReload}
424
- disabled={isReloading}
425
- >
426
- <Text style={styles.actionText}>
427
- {isReloading ? 'Reloading...' : 'Hot Reload'}
428
- </Text>
429
- </TouchableOpacity>
430
-
431
- <View style={[styles.actionButton, styles.streamingIndicator]}>
432
- <View style={[styles.streamingDot, BlackBox.isStreaming && styles.streamingDotActive]} />
433
- <Text style={styles.streamingText}>
434
- {BlackBox.isStreaming ? 'Streaming' : 'Not streaming'}
435
- </Text>
436
- </View>
437
- </View>
438
-
439
- {/* Voice command button */}
440
- {mode === 'live' && (
441
- <TouchableOpacity
442
- style={[styles.voiceCommandButton, isVoiceCommand && styles.voiceCommandActive]}
443
- onPress={handleVoiceCommand}
444
- disabled={isRecordingAudio}
445
- >
446
- <Text style={styles.voiceCommandText}>
447
- {isVoiceCommand ? 'Stop & Send Command' : 'Speak to Fix'}
448
- </Text>
449
- </TouchableOpacity>
450
- )}
451
-
452
- {/* Error display */}
453
- {error && <Text style={styles.error}>{error}</Text>}
454
-
455
- {/* Send / Cancel */}
456
- <View style={styles.footer}>
457
- <TouchableOpacity style={styles.cancelButton} onPress={handleCancel}>
458
- <Text style={styles.cancelText}>Cancel</Text>
459
- </TouchableOpacity>
460
-
461
- {sent ? (
462
- <View style={styles.sendButton}>
463
- <Text style={styles.sendText}>Sent!</Text>
357
+ <Text style={styles.closeIcon}>×</Text>
358
+ </Pressable>
464
359
  </View>
465
- ) : (
466
- <TouchableOpacity
467
- style={[styles.sendButton, isSending && styles.sendButtonDisabled]}
468
- onPress={handleSend}
469
- disabled={isSending || timeline.length === 0}
470
- >
471
- {isSending ? (
472
- <ActivityIndicator color="#fff" size="small" />
473
- ) : (
474
- <Text style={styles.sendText}>Send Report</Text>
475
- )}
476
- </TouchableOpacity>
477
- )}
478
- </View>
479
- </View>
480
- </View>
481
- </Modal>
360
+
361
+ {/* 1. Hot Reload — the common path */}
362
+ <ActionRow
363
+ label={
364
+ action === 'hot-reloading' ? 'Reloading…' : 'Hot Reload'
365
+ }
366
+ tint="#fbbf24"
367
+ onPress={handleHotReload}
368
+ disabled={busy}
369
+ busy={action === 'hot-reloading'}
370
+ />
371
+
372
+ {/* 2. Screenshot + Fix — for bug fixes */}
373
+ <ActionRow
374
+ label={
375
+ action === 'capturing'
376
+ ? 'Capturing…'
377
+ : 'Screenshot & Fix'
378
+ }
379
+ tint="#22c55e"
380
+ onPress={handleScreenshotAndFix}
381
+ disabled={busy}
382
+ busy={action === 'capturing'}
383
+ />
384
+
385
+ {/* 3. Vibing */}
386
+ <ActionRow
387
+ label={action === 'vibing' ? 'Starting…' : 'Vibing'}
388
+ tint="#818cf8"
389
+ onPress={handleVibing}
390
+ disabled={busy}
391
+ busy={action === 'vibing'}
392
+ />
393
+
394
+ {/* 4. Start/Stop Recording */}
395
+ <ActionRow
396
+ label={isRecordingVideo ? 'Stop Recording' : 'Start Recording'}
397
+ tint={isRecordingVideo ? '#ef4444' : '#60a5fa'}
398
+ onPress={handleToggleRecording}
399
+ disabled={busy && action !== 'idle' && !isRecordingVideo}
400
+ />
401
+
402
+ {/* 5. Send Video (only tappable when a clip is ready) */}
403
+ <ActionRow
404
+ label={
405
+ action === 'sending-video'
406
+ ? 'Sending…'
407
+ : lastVideo
408
+ ? `Send Video · ${Math.round(lastVideo.duration)}s`
409
+ : 'Send Video'
410
+ }
411
+ tint="#a78bfa"
412
+ onPress={handleSendVideo}
413
+ disabled={busy || !lastVideo}
414
+ busy={action === 'sending-video'}
415
+ />
416
+
417
+ {toast && <Text style={styles.toast}>{toast}</Text>}
418
+ {error && <Text style={styles.error}>{error}</Text>}
419
+ </Pressable>
420
+ </Pressable>
421
+ </Modal>
482
422
  )}
483
423
  </>
484
424
  );
485
425
  };
486
426
 
427
+ interface ActionRowProps {
428
+ label: string;
429
+ tint: string;
430
+ onPress: () => void;
431
+ disabled?: boolean;
432
+ busy?: boolean;
433
+ }
434
+
435
+ const ActionRow: React.FC<ActionRowProps> = ({
436
+ label,
437
+ tint,
438
+ onPress,
439
+ disabled,
440
+ busy,
441
+ }) => (
442
+ <Pressable
443
+ onPress={onPress}
444
+ disabled={disabled}
445
+ style={({ pressed }) => [
446
+ styles.actionBtn,
447
+ {
448
+ borderColor: tint + '66',
449
+ backgroundColor: tint + '1f',
450
+ },
451
+ disabled && styles.actionBtnDisabled,
452
+ pressed && !disabled && { opacity: 0.7 },
453
+ ]}
454
+ accessibilityRole="button"
455
+ accessibilityLabel={label}
456
+ >
457
+ {busy ? (
458
+ <ActivityIndicator color={tint} size="small" />
459
+ ) : (
460
+ <Text style={[styles.actionText, { color: tint }]}>{label}</Text>
461
+ )}
462
+ </Pressable>
463
+ );
464
+
487
465
  const styles = StyleSheet.create({
488
466
  overlay: {
489
467
  flex: 1,
490
- backgroundColor: 'rgba(0,0,0,0.5)',
468
+ backgroundColor: 'rgba(0,0,0,0.55)',
491
469
  justifyContent: 'flex-end',
492
470
  },
493
471
  modal: {
494
- backgroundColor: '#1a1a2e',
495
- borderTopLeftRadius: 20,
496
- borderTopRightRadius: 20,
497
- padding: 24,
498
- paddingBottom: 40,
499
- maxHeight: '90%',
472
+ backgroundColor: '#141422',
473
+ borderTopLeftRadius: 22,
474
+ borderTopRightRadius: 22,
475
+ padding: 22,
476
+ paddingBottom: 36,
477
+ gap: 12,
478
+ },
479
+ header: {
480
+ flexDirection: 'row',
481
+ alignItems: 'center',
482
+ justifyContent: 'space-between',
483
+ marginBottom: 6,
500
484
  },
501
485
  title: {
502
486
  fontSize: 20,
503
487
  fontWeight: '700',
504
488
  color: '#fff',
505
- marginBottom: 12,
506
- },
507
- modeSelector: {
508
- flexDirection: 'row',
509
- gap: 8,
510
- marginBottom: 16,
511
489
  },
512
- modeButton: {
513
- flex: 1,
514
- paddingVertical: 8,
515
- borderRadius: 8,
490
+ closeBtn: {
491
+ width: 36,
492
+ height: 36,
493
+ borderRadius: 18,
516
494
  alignItems: 'center',
495
+ justifyContent: 'center',
517
496
  backgroundColor: 'rgba(255,255,255,0.08)',
518
- borderWidth: 1,
519
- borderColor: 'rgba(255,255,255,0.1)',
520
- },
521
- modeButtonActive: {
522
- backgroundColor: 'rgba(99,102,241,0.3)',
523
- borderColor: '#6366f1',
524
- },
525
- modeButtonText: {
526
- color: '#999',
527
- fontSize: 13,
528
- fontWeight: '600',
529
- },
530
- modeButtonTextActive: {
531
- color: '#c7c8ff',
532
- },
533
- commentaryList: {
534
- maxHeight: 140,
535
- marginBottom: 12,
536
497
  },
537
- commentaryBubble: {
538
- backgroundColor: 'rgba(99,102,241,0.15)',
539
- borderRadius: 10,
540
- padding: 10,
541
- marginBottom: 6,
542
- borderLeftWidth: 3,
543
- borderLeftColor: '#6366f1',
544
- },
545
- commentaryType: {
546
- color: '#8b8bf5',
547
- fontSize: 10,
548
- fontWeight: '700',
549
- textTransform: 'uppercase',
550
- marginBottom: 2,
551
- },
552
- commentaryMessage: {
553
- color: '#d0d0e0',
554
- fontSize: 13,
555
- lineHeight: 18,
556
- },
557
- timeline: {
558
- maxHeight: 80,
559
- marginBottom: 16,
498
+ closeIcon: {
499
+ color: '#fff',
500
+ fontSize: 22,
501
+ lineHeight: 24,
502
+ fontWeight: '400',
560
503
  },
561
- timelineItem: {
504
+ actionBtn: {
505
+ paddingVertical: 16,
506
+ borderRadius: 14,
562
507
  alignItems: 'center',
563
- marginRight: 16,
564
- backgroundColor: 'rgba(255,255,255,0.1)',
565
- borderRadius: 12,
566
- padding: 10,
567
- minWidth: 70,
568
- },
569
- timelineIcon: {
570
- fontSize: 14,
571
- color: '#ccc',
572
- fontWeight: '600',
573
- },
574
- timelineLabel: {
575
- color: '#ccc',
576
- fontSize: 11,
577
- marginTop: 4,
578
- },
579
- timelineDuration: {
580
- color: '#999',
581
- fontSize: 10,
582
- },
583
- actions: {
584
- flexDirection: 'row',
585
- gap: 12,
586
- marginBottom: 12,
587
- },
588
- actionButton: {
589
- flex: 1,
590
- backgroundColor: 'rgba(99,102,241,0.2)',
508
+ justifyContent: 'center',
591
509
  borderWidth: 1,
592
- borderColor: 'rgba(99,102,241,0.4)',
593
- borderRadius: 12,
594
- paddingVertical: 14,
595
- alignItems: 'center',
596
510
  },
597
- actionButtonActive: {
598
- backgroundColor: 'rgba(239,68,68,0.3)',
599
- borderColor: 'rgba(239,68,68,0.6)',
511
+ actionBtnDisabled: {
512
+ opacity: 0.35,
600
513
  },
601
514
  actionText: {
602
- color: '#fff',
603
- fontSize: 14,
604
- fontWeight: '600',
605
- },
606
- voiceCommandButton: {
607
- backgroundColor: 'rgba(34,197,94,0.2)',
608
- borderWidth: 1,
609
- borderColor: 'rgba(34,197,94,0.4)',
610
- borderRadius: 12,
611
- paddingVertical: 14,
612
- alignItems: 'center',
613
- marginBottom: 12,
614
- },
615
- voiceCommandActive: {
616
- backgroundColor: 'rgba(34,197,94,0.4)',
617
- borderColor: '#22c55e',
515
+ fontSize: 15,
516
+ fontWeight: '700',
618
517
  },
619
- voiceCommandText: {
518
+ toast: {
620
519
  color: '#22c55e',
621
- fontSize: 14,
622
- fontWeight: '600',
520
+ fontSize: 13,
521
+ textAlign: 'center',
522
+ marginTop: 4,
623
523
  },
624
524
  error: {
625
525
  color: '#ef4444',
626
526
  fontSize: 12,
627
- marginBottom: 12,
628
- },
629
- footer: {
630
- flexDirection: 'row',
631
- gap: 12,
632
- },
633
- cancelButton: {
634
- flex: 1,
635
- paddingVertical: 14,
636
- alignItems: 'center',
637
- borderRadius: 12,
638
- borderWidth: 1,
639
- borderColor: 'rgba(255,255,255,0.2)',
640
- },
641
- cancelText: {
642
- color: '#999',
643
- fontSize: 16,
644
- fontWeight: '600',
645
- },
646
- sendButton: {
647
- flex: 2,
648
- backgroundColor: '#6366f1',
649
- paddingVertical: 14,
650
- alignItems: 'center',
651
- borderRadius: 12,
652
- },
653
- sendButtonDisabled: {
654
- opacity: 0.5,
655
- },
656
- sendText: {
657
- color: '#fff',
658
- fontSize: 16,
659
- fontWeight: '700',
660
- },
661
- reloadButton: {
662
- backgroundColor: 'rgba(251,191,36,0.2)',
663
- borderColor: 'rgba(251,191,36,0.4)',
664
- },
665
- streamingIndicator: {
666
- flexDirection: 'row',
667
- justifyContent: 'center',
668
- backgroundColor: 'rgba(255,255,255,0.05)',
669
- borderColor: 'rgba(255,255,255,0.1)',
670
- },
671
- streamingDot: {
672
- width: 8,
673
- height: 8,
674
- borderRadius: 4,
675
- backgroundColor: '#555',
676
- marginRight: 6,
677
- },
678
- streamingDotActive: {
679
- backgroundColor: '#22c55e',
680
- },
681
- streamingText: {
682
- color: '#999',
683
- fontSize: 12,
527
+ textAlign: 'center',
528
+ marginTop: 4,
684
529
  },
685
530
  });