yaver-feedback-react-native 0.6.0 → 0.7.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.
@@ -2,262 +2,278 @@ 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:
24
+ *
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
32
  *
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)
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 () => {
76
+ const closeSoon = useCallback((delayMs = 1200) => {
77
+ setTimeout(() => {
78
+ if (mountedRef.current) setVisible(false);
79
+ }, delayMs);
80
+ }, []);
81
+
82
+ const handleClose = useCallback(() => {
83
+ setVisible(false);
84
+ setError(null);
85
+ setToast(null);
86
+ setAction('idle');
87
+ }, []);
88
+
89
+ // ─── 1. Hot reload ─────────────────────────────────────────────────
90
+ const handleHotReload = useCallback(async () => {
91
+ const client = YaverFeedback.getP2PClient();
92
+ if (!client) {
93
+ setError('Not connected to the agent yet.');
94
+ return;
95
+ }
96
+ setAction('hot-reloading');
97
+ setError(null);
83
98
  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]);
99
+ await client.reloadApp('dev');
100
+ setToast('Reload sent');
101
+ closeSoon(800);
102
+ } catch (err: unknown) {
103
+ setError(err instanceof Error ? err.message : String(err));
104
+ } finally {
105
+ if (mountedRef.current) setAction('idle');
106
+ }
107
+ }, [closeSoon]);
108
+
109
+ // ─── 2. Screenshot + Fix ───────────────────────────────────────────
110
+ //
111
+ // Hide the modal first so the screenshot captures the actual screen
112
+ // (the bug) — not the modal card. Await a short animation delay,
113
+ // snapshot, upload the feedback bundle with any buffered errors, then
114
+ // kick `/feedback/{id}/fix` to create the repair task.
115
+ const handleScreenshotAndFix = useCallback(async () => {
116
+ const client = YaverFeedback.getP2PClient();
117
+ const config = YaverFeedback.getConfig();
118
+ if (!client || !config?.agentUrl) {
119
+ setError('Not connected to the agent yet.');
120
+ return;
121
+ }
122
+ setAction('capturing');
123
+ setError(null);
92
124
 
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
- }
112
- }
113
- } catch (err) {
114
- if (mountedRef.current) {
115
- setError(String(err));
116
- }
125
+ // Step 1: Hide the modal so the screenshot contains the real screen.
126
+ setVisible(false);
127
+ // Wait out the slide-down animation on both platforms.
128
+ await new Promise((resolve) => setTimeout(resolve, 350));
129
+
130
+ let path: string | null = null;
131
+ try {
132
+ path = await captureScreenshot();
133
+ } catch (err: unknown) {
134
+ setVisible(true);
135
+ setError(err instanceof Error ? err.message : String(err));
136
+ setAction('idle');
137
+ return;
117
138
  }
118
- }, [mode]);
119
139
 
120
- const handleToggleAudio = useCallback(async () => {
121
- if (isRecordingAudio) {
122
- 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]);
140
+ // Step 2: Re-show the modal for progress + ack.
141
+ setVisible(true);
142
+ await new Promise((resolve) => setTimeout(resolve, 150));
133
143
 
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
- }
154
- } catch (err) {
155
- if (mountedRef.current) {
156
- setIsRecordingAudio(false);
157
- setError(String(err));
158
- }
159
- }
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));
144
+ try {
145
+ const { Dimensions } = require('react-native');
146
+ const { width, height } = Dimensions.get('window');
147
+ const deviceInfo: DeviceInfo = {
148
+ platform: Platform.OS,
149
+ osVersion: String(Platform.Version),
150
+ model: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
151
+ screenWidth: width,
152
+ screenHeight: height,
153
+ };
154
+
155
+ const capturedErrors = YaverFeedback.getCapturedErrors();
156
+ const bundle: FeedbackBundle = {
157
+ metadata: {
158
+ timestamp: new Date().toISOString(),
159
+ device: deviceInfo,
160
+ app: {},
161
+ userNote: '[Screenshot + Fix]',
162
+ },
163
+ screenshots: [path],
164
+ errors: capturedErrors.length > 0 ? capturedErrors : undefined,
165
+ };
166
+
167
+ const uploaded = await uploadFeedback(
168
+ config.agentUrl,
169
+ config.authToken ?? '',
170
+ bundle,
171
+ );
172
+ // The agent returns the new report id as `id` (see
173
+ // feedback_http.go::ReceiveFeedback). Trigger the fix loop if we got
174
+ // one back; otherwise just ack the upload.
175
+ const reportId =
176
+ (uploaded as { id?: string; reportId?: string } | null | undefined)?.id ??
177
+ (uploaded as { reportId?: string } | null | undefined)?.reportId;
178
+ if (reportId) {
179
+ try {
180
+ await client.triggerFix(reportId);
181
+ setToast('Fix task started');
182
+ } catch (err: unknown) {
183
+ setToast('Report uploaded — fix trigger failed');
184
+ setError(err instanceof Error ? err.message : String(err));
169
185
  }
186
+ } else {
187
+ setToast('Report uploaded');
170
188
  }
189
+ closeSoon(1400);
190
+ } catch (err: unknown) {
191
+ setError(err instanceof Error ? err.message : String(err));
192
+ } finally {
193
+ if (mountedRef.current) setAction('idle');
194
+ }
195
+ }, [closeSoon]);
196
+
197
+ // ─── 3. Vibing ─────────────────────────────────────────────────────
198
+ const handleVibing = useCallback(async () => {
199
+ const client = YaverFeedback.getP2PClient();
200
+ if (!client) {
201
+ setError('Not connected to the agent yet.');
202
+ return;
203
+ }
204
+ setAction('vibing');
205
+ setError(null);
206
+ try {
207
+ const capturedErrors = YaverFeedback.getCapturedErrors();
208
+ const errNote =
209
+ capturedErrors.length > 0
210
+ ? `\n\nRecent captured errors:\n` +
211
+ capturedErrors
212
+ .slice(-3)
213
+ .map((e) => `- ${e.message}`)
214
+ .join('\n')
215
+ : '';
216
+ const prompt =
217
+ 'The user opened the feedback modal on their phone and tapped Vibing. ' +
218
+ 'Investigate whatever they are likely to be asking about — pick the ' +
219
+ 'next small improvement or fix based on recent activity and the ' +
220
+ 'current screen.' +
221
+ errNote;
222
+ await client.vibing(prompt);
223
+ setToast('Vibing task created');
224
+ closeSoon(1200);
225
+ } catch (err: unknown) {
226
+ setError(err instanceof Error ? err.message : String(err));
227
+ } finally {
228
+ if (mountedRef.current) setAction('idle');
171
229
  }
172
- }, [isRecordingAudio, mode]);
230
+ }, [closeSoon]);
173
231
 
174
- const handleVoiceCommand = useCallback(async () => {
175
- if (isVoiceCommand) {
176
- // Stop voice command and send as a task
232
+ // ─── 4. Toggle screen recording ────────────────────────────────────
233
+ const handleToggleRecording = useCallback(async () => {
234
+ setError(null);
235
+ if (isRecordingVideo) {
177
236
  try {
178
- const result = await stopAudioRecording();
179
- 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) {
237
+ const result = await stopVideoRecording();
203
238
  if (mountedRef.current) {
204
- setIsVoiceCommand(false);
205
- setError(String(err));
239
+ setIsRecordingVideo(false);
240
+ setLastVideo(result);
241
+ setToast(`Recording stopped — ${Math.round(result.duration)}s`);
206
242
  }
243
+ } catch (err: unknown) {
244
+ setIsRecordingVideo(false);
245
+ setError(err instanceof Error ? err.message : String(err));
207
246
  }
208
247
  } else {
209
248
  try {
210
- await startAudioRecording();
249
+ await startVideoRecording();
211
250
  if (mountedRef.current) {
212
- setIsVoiceCommand(true);
213
- }
214
- } catch (err) {
215
- if (mountedRef.current) {
216
- setError(String(err));
251
+ setIsRecordingVideo(true);
252
+ setToast('Recording…');
253
+ setLastVideo(null);
217
254
  }
255
+ } catch (err: unknown) {
256
+ setError(err instanceof Error ? err.message : String(err));
218
257
  }
219
258
  }
220
- }, [isVoiceCommand]);
259
+ }, [isRecordingVideo]);
221
260
 
222
- const handleReload = useCallback(async () => {
261
+ // ─── 5. Send the last recorded video ───────────────────────────────
262
+ const handleSendVideo = useCallback(async () => {
223
263
  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
- }
264
+ if (!config?.agentUrl) {
265
+ setError('Not connected to the agent yet.');
266
+ return;
247
267
  }
248
- }, []);
249
-
250
- const handleSend = useCallback(async () => {
251
- const config = YaverFeedback.getConfig();
252
- if (!config || !config.agentUrl) return;
253
-
254
- setIsSending(true);
268
+ if (!lastVideo) {
269
+ setError('No video recorded yet.');
270
+ return;
271
+ }
272
+ setAction('sending-video');
255
273
  setError(null);
256
-
257
274
  try {
258
275
  const { Dimensions } = require('react-native');
259
276
  const { width, height } = Dimensions.get('window');
260
-
261
277
  const deviceInfo: DeviceInfo = {
262
278
  platform: Platform.OS,
263
279
  osVersion: String(Platform.Version),
@@ -265,421 +281,224 @@ export const FeedbackModal: React.FC = () => {
265
281
  screenWidth: width,
266
282
  screenHeight: height,
267
283
  };
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
284
  const bundle: FeedbackBundle = {
279
285
  metadata: {
280
286
  timestamp: new Date().toISOString(),
281
287
  device: deviceInfo,
282
288
  app: {},
289
+ userNote: '[Screen recording]',
283
290
  },
284
- screenshots,
285
- audio: audioEvent?.path,
286
- errors: capturedErrors.length > 0 ? capturedErrors : undefined,
291
+ screenshots: [],
292
+ video: lastVideo.path,
293
+ errors: YaverFeedback.getCapturedErrors().length
294
+ ? YaverFeedback.getCapturedErrors()
295
+ : undefined,
287
296
  };
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
- }
297
+ await uploadFeedback(config.agentUrl, config.authToken ?? '', bundle);
298
+ setToast('Video sent');
299
+ setLastVideo(null);
300
+ closeSoon(1200);
301
+ } catch (err: unknown) {
302
+ setError(err instanceof Error ? err.message : String(err));
304
303
  } finally {
305
- if (mountedRef.current) {
306
- setIsSending(false);
307
- }
304
+ if (mountedRef.current) setAction('idle');
308
305
  }
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
- }, []);
306
+ }, [lastVideo, closeSoon]);
320
307
 
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
- );
308
+ const busy = action !== 'idle';
330
309
 
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
310
  return (
335
311
  <>
336
312
  <AuthOverlay />
337
313
  {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]}
314
+ <Modal
315
+ visible={visible}
316
+ animationType="slide"
317
+ transparent
318
+ onRequestClose={handleClose}
319
+ >
320
+ <Pressable style={styles.overlay} onPress={handleClose}>
321
+ <Pressable style={styles.modal} onPress={(e) => e.stopPropagation()}>
322
+ <View style={styles.header}>
323
+ <Text style={styles.title}>Send Feedback</Text>
324
+ <Pressable
325
+ onPress={handleClose}
326
+ hitSlop={12}
327
+ style={styles.closeBtn}
328
+ accessibilityRole="button"
329
+ accessibilityLabel="Close"
358
330
  >
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>
331
+ <Text style={styles.closeIcon}>×</Text>
332
+ </Pressable>
464
333
  </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>
334
+
335
+ {/* 1. Hot Reload — the common path */}
336
+ <ActionRow
337
+ label={
338
+ action === 'hot-reloading' ? 'Reloading…' : 'Hot Reload'
339
+ }
340
+ tint="#fbbf24"
341
+ onPress={handleHotReload}
342
+ disabled={busy}
343
+ busy={action === 'hot-reloading'}
344
+ />
345
+
346
+ {/* 2. Screenshot + Fix — for bug fixes */}
347
+ <ActionRow
348
+ label={
349
+ action === 'capturing'
350
+ ? 'Capturing…'
351
+ : 'Screenshot & Fix'
352
+ }
353
+ tint="#22c55e"
354
+ onPress={handleScreenshotAndFix}
355
+ disabled={busy}
356
+ busy={action === 'capturing'}
357
+ />
358
+
359
+ {/* 3. Vibing */}
360
+ <ActionRow
361
+ label={action === 'vibing' ? 'Starting…' : 'Vibing'}
362
+ tint="#818cf8"
363
+ onPress={handleVibing}
364
+ disabled={busy}
365
+ busy={action === 'vibing'}
366
+ />
367
+
368
+ {/* 4. Start/Stop Recording */}
369
+ <ActionRow
370
+ label={isRecordingVideo ? 'Stop Recording' : 'Start Recording'}
371
+ tint={isRecordingVideo ? '#ef4444' : '#60a5fa'}
372
+ onPress={handleToggleRecording}
373
+ disabled={busy && action !== 'idle' && !isRecordingVideo}
374
+ />
375
+
376
+ {/* 5. Send Video (only tappable when a clip is ready) */}
377
+ <ActionRow
378
+ label={
379
+ action === 'sending-video'
380
+ ? 'Sending…'
381
+ : lastVideo
382
+ ? `Send Video · ${Math.round(lastVideo.duration)}s`
383
+ : 'Send Video'
384
+ }
385
+ tint="#a78bfa"
386
+ onPress={handleSendVideo}
387
+ disabled={busy || !lastVideo}
388
+ busy={action === 'sending-video'}
389
+ />
390
+
391
+ {toast && <Text style={styles.toast}>{toast}</Text>}
392
+ {error && <Text style={styles.error}>{error}</Text>}
393
+ </Pressable>
394
+ </Pressable>
395
+ </Modal>
482
396
  )}
483
397
  </>
484
398
  );
485
399
  };
486
400
 
401
+ interface ActionRowProps {
402
+ label: string;
403
+ tint: string;
404
+ onPress: () => void;
405
+ disabled?: boolean;
406
+ busy?: boolean;
407
+ }
408
+
409
+ const ActionRow: React.FC<ActionRowProps> = ({
410
+ label,
411
+ tint,
412
+ onPress,
413
+ disabled,
414
+ busy,
415
+ }) => (
416
+ <Pressable
417
+ onPress={onPress}
418
+ disabled={disabled}
419
+ style={({ pressed }) => [
420
+ styles.actionBtn,
421
+ {
422
+ borderColor: tint + '66',
423
+ backgroundColor: tint + '1f',
424
+ },
425
+ disabled && styles.actionBtnDisabled,
426
+ pressed && !disabled && { opacity: 0.7 },
427
+ ]}
428
+ accessibilityRole="button"
429
+ accessibilityLabel={label}
430
+ >
431
+ {busy ? (
432
+ <ActivityIndicator color={tint} size="small" />
433
+ ) : (
434
+ <Text style={[styles.actionText, { color: tint }]}>{label}</Text>
435
+ )}
436
+ </Pressable>
437
+ );
438
+
487
439
  const styles = StyleSheet.create({
488
440
  overlay: {
489
441
  flex: 1,
490
- backgroundColor: 'rgba(0,0,0,0.5)',
442
+ backgroundColor: 'rgba(0,0,0,0.55)',
491
443
  justifyContent: 'flex-end',
492
444
  },
493
445
  modal: {
494
- backgroundColor: '#1a1a2e',
495
- borderTopLeftRadius: 20,
496
- borderTopRightRadius: 20,
497
- padding: 24,
498
- paddingBottom: 40,
499
- maxHeight: '90%',
446
+ backgroundColor: '#141422',
447
+ borderTopLeftRadius: 22,
448
+ borderTopRightRadius: 22,
449
+ padding: 22,
450
+ paddingBottom: 36,
451
+ gap: 12,
452
+ },
453
+ header: {
454
+ flexDirection: 'row',
455
+ alignItems: 'center',
456
+ justifyContent: 'space-between',
457
+ marginBottom: 6,
500
458
  },
501
459
  title: {
502
460
  fontSize: 20,
503
461
  fontWeight: '700',
504
462
  color: '#fff',
505
- marginBottom: 12,
506
- },
507
- modeSelector: {
508
- flexDirection: 'row',
509
- gap: 8,
510
- marginBottom: 16,
511
463
  },
512
- modeButton: {
513
- flex: 1,
514
- paddingVertical: 8,
515
- borderRadius: 8,
464
+ closeBtn: {
465
+ width: 36,
466
+ height: 36,
467
+ borderRadius: 18,
516
468
  alignItems: 'center',
469
+ justifyContent: 'center',
517
470
  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
471
  },
530
- modeButtonTextActive: {
531
- color: '#c7c8ff',
532
- },
533
- commentaryList: {
534
- maxHeight: 140,
535
- marginBottom: 12,
536
- },
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,
472
+ closeIcon: {
473
+ color: '#fff',
474
+ fontSize: 22,
475
+ lineHeight: 24,
476
+ fontWeight: '400',
560
477
  },
561
- timelineItem: {
478
+ actionBtn: {
479
+ paddingVertical: 16,
480
+ borderRadius: 14,
562
481
  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)',
482
+ justifyContent: 'center',
591
483
  borderWidth: 1,
592
- borderColor: 'rgba(99,102,241,0.4)',
593
- borderRadius: 12,
594
- paddingVertical: 14,
595
- alignItems: 'center',
596
484
  },
597
- actionButtonActive: {
598
- backgroundColor: 'rgba(239,68,68,0.3)',
599
- borderColor: 'rgba(239,68,68,0.6)',
485
+ actionBtnDisabled: {
486
+ opacity: 0.35,
600
487
  },
601
488
  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',
489
+ fontSize: 15,
490
+ fontWeight: '700',
618
491
  },
619
- voiceCommandText: {
492
+ toast: {
620
493
  color: '#22c55e',
621
- fontSize: 14,
622
- fontWeight: '600',
494
+ fontSize: 13,
495
+ textAlign: 'center',
496
+ marginTop: 4,
623
497
  },
624
498
  error: {
625
499
  color: '#ef4444',
626
500
  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,
501
+ textAlign: 'center',
502
+ marginTop: 4,
684
503
  },
685
504
  });