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