yaver-feedback-react-native 0.8.1 → 0.8.3

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,9 +2,12 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import {
3
3
  ActivityIndicator,
4
4
  DeviceEventEmitter,
5
+ Keyboard,
6
+ KeyboardAvoidingView,
5
7
  Modal,
6
8
  Platform,
7
9
  Pressable,
10
+ ScrollView,
8
11
  StyleSheet,
9
12
  Text,
10
13
  TextInput,
@@ -13,40 +16,50 @@ import {
13
16
  import { YaverFeedback } from './YaverFeedback';
14
17
  import {
15
18
  captureScreenshot,
16
- pickFeedbackFile,
17
- startVideoRecording,
18
- stopVideoRecording,
19
+ // Launch scope for the feedback test SDK is intentionally smaller for now.
20
+ // Keep the dormant file-upload and screen-recording helpers nearby, but
21
+ // comment them out until we bring them back with stronger test coverage.
22
+ // pickFeedbackFile,
23
+ // startVideoRecording,
24
+ // stopVideoRecording,
19
25
  } from './capture';
20
26
  import { uploadFeedback } from './upload';
21
27
  import { DeviceInfo, FeedbackBundle } from './types';
22
28
  import { AuthOverlay } from './AuthOverlay';
23
29
  import { QuickActionIcon } from './QuickActionIcon';
30
+ import { listReachableDevices, RemoteDevice } from './auth';
31
+ import {
32
+ QUICK_ICON_COLOR_PRESETS,
33
+ QuickIconColorPreset,
34
+ } from './preferences';
24
35
 
25
36
  /**
26
- * Simplified feedback modal — 4 actions:
37
+ * Simplified feedback modal — launch scope is 3 actions:
27
38
  *
28
39
  * 1. Hot Reload — instant JS reload (most common use case)
29
40
  * 2. Vibing — open a vibing session on the agent
30
- * 3. Screenshot / Upload — capture the underlying app (modal hidden
31
- * during capture) or upload an existing
32
- * media file through the Go agent
33
- * 4. Screen Recording — start recording, then stop + upload
41
+ * 3. Screenshot & Fix — capture the underlying app (modal hidden
42
+ * during capture), upload it, and trigger
43
+ * the fix loop
34
44
  *
35
45
  * The footer also has an explicit Cancel button so the icon tap path
36
46
  * feels like a standard action sheet rather than a hidden modal.
37
47
  */
38
48
 
39
- interface LastVideo {
40
- path: string;
41
- duration: number;
42
- }
43
-
44
49
  type ActionState =
45
50
  | 'idle'
46
51
  | 'hot-reloading'
47
52
  | 'capturing'
48
- | 'vibing'
49
- | 'uploading-video';
53
+ | 'vibing';
54
+
55
+ type MachineCardState = {
56
+ device: RemoteDevice | null;
57
+ reachable: boolean | null;
58
+ loading: boolean;
59
+ status: 'none' | 'live' | 'attention' | 'offline';
60
+ title: string;
61
+ detail: string;
62
+ };
50
63
 
51
64
  export const FeedbackModal: React.FC = () => {
52
65
  const [visible, setVisible] = useState(false);
@@ -54,8 +67,6 @@ export const FeedbackModal: React.FC = () => {
54
67
  const [error, setError] = useState<string | null>(null);
55
68
  const [toast, setToast] = useState<string | null>(null);
56
69
  const [progress, setProgress] = useState<number | null>(null);
57
- const [isRecordingVideo, setIsRecordingVideo] = useState(false);
58
- const [lastVideo, setLastVideo] = useState<LastVideo | null>(null);
59
70
  // Tracks whether the user has hidden the QuickActionIcon via its
60
71
  // long-press menu. Shake is always available, so the feedback modal
61
72
  // is our guaranteed UI for bringing the icon back — we surface a
@@ -68,10 +79,120 @@ export const FeedbackModal: React.FC = () => {
68
79
  // the wrong project because the matcher grepped the prompt itself).
69
80
  const [showVibeInput, setShowVibeInput] = useState(false);
70
81
  const [vibePrompt, setVibePrompt] = useState('');
71
- const [showCaptureChoices, setShowCaptureChoices] = useState(false);
72
82
  const [lastVibeTaskId, setLastVibeTaskId] = useState<string | null>(null);
83
+ const [quickIconColorPreset, setQuickIconColorPreset] =
84
+ useState<QuickIconColorPreset | null>(null);
85
+ const [machineCard, setMachineCard] = useState<MachineCardState>({
86
+ device: null,
87
+ reachable: null,
88
+ loading: false,
89
+ status: 'none',
90
+ title: 'No machine selected',
91
+ detail: 'Pick a remote dev machine before using the feedback actions.',
92
+ });
73
93
  const mountedRef = useRef(true);
74
94
 
95
+ const loadSelectedMachine = useCallback(async () => {
96
+ const cfg = YaverFeedback.getConfig();
97
+ if (!cfg?.authToken) {
98
+ if (mountedRef.current) {
99
+ setMachineCard({
100
+ device: null,
101
+ reachable: null,
102
+ loading: false,
103
+ status: 'none',
104
+ title: 'Not signed in',
105
+ detail: 'Sign in to pick and monitor a remote dev machine.',
106
+ });
107
+ }
108
+ return;
109
+ }
110
+ if (!cfg.preferredDeviceId) {
111
+ if (mountedRef.current) {
112
+ setMachineCard({
113
+ device: null,
114
+ reachable: null,
115
+ loading: false,
116
+ status: 'none',
117
+ title: 'No machine selected',
118
+ detail: 'Choose which machine this SDK should talk to.',
119
+ });
120
+ }
121
+ return;
122
+ }
123
+
124
+ if (mountedRef.current) {
125
+ setMachineCard((prev) => ({ ...prev, loading: true }));
126
+ }
127
+
128
+ try {
129
+ const devices = await listReachableDevices(cfg.authToken);
130
+ const all = [...devices.owned, ...devices.shared];
131
+ const device =
132
+ all.find((candidate) => candidate.deviceId === cfg.preferredDeviceId) ?? null;
133
+
134
+ if (!device) {
135
+ if (mountedRef.current) {
136
+ setMachineCard({
137
+ device: null,
138
+ reachable: null,
139
+ loading: false,
140
+ status: 'offline',
141
+ title: 'Selected machine missing',
142
+ detail: 'The saved machine was not returned by the device list. Re-select it.',
143
+ });
144
+ }
145
+ return;
146
+ }
147
+
148
+ let reachable: boolean | null = null;
149
+ const client = YaverFeedback.getP2PClient();
150
+ if (device.isOnline && !device.needsAuth && client) {
151
+ reachable = await client.health();
152
+ }
153
+
154
+ const hostHint = device.hostEmail ? ` via ${device.hostEmail}` : '';
155
+ let status: MachineCardState['status'] = 'live';
156
+ let detail = `${device.platform}${hostHint}`;
157
+
158
+ if (!device.isOnline) {
159
+ status = 'offline';
160
+ detail = 'Machine offline. Start `yaver serve` on the selected machine.';
161
+ } else if (device.needsAuth) {
162
+ status = 'attention';
163
+ detail = 'Machine needs pairing again before feedback actions can run.';
164
+ } else if (device.runnerDown) {
165
+ status = 'attention';
166
+ detail = 'Machine is online but the coding agent is down.';
167
+ } else if (reachable === false) {
168
+ status = 'offline';
169
+ detail = 'Machine selected, but the agent is not responding.';
170
+ }
171
+
172
+ if (mountedRef.current) {
173
+ setMachineCard({
174
+ device,
175
+ reachable,
176
+ loading: false,
177
+ status,
178
+ title: device.name || device.deviceId,
179
+ detail,
180
+ });
181
+ }
182
+ } catch (err) {
183
+ if (mountedRef.current) {
184
+ setMachineCard({
185
+ device: null,
186
+ reachable: null,
187
+ loading: false,
188
+ status: 'offline',
189
+ title: 'Machine status unavailable',
190
+ detail: err instanceof Error ? err.message : String(err),
191
+ });
192
+ }
193
+ }
194
+ }, []);
195
+
75
196
  useEffect(() => {
76
197
  mountedRef.current = true;
77
198
  const sub = DeviceEventEmitter.addListener('yaverFeedback:startReport', () => {
@@ -79,8 +200,10 @@ export const FeedbackModal: React.FC = () => {
79
200
  setVisible(true);
80
201
  setError(null);
81
202
  setToast(null);
203
+ setProgress(null);
82
204
  setAction('idle');
83
- setShowCaptureChoices(false);
205
+ setShowVibeInput(false);
206
+ setVibePrompt('');
84
207
  // Re-read the "user hid the quick icon" flag on every open so
85
208
  // the re-enable row reflects the latest preference (the user
86
209
  // might have hidden or shown it between opens).
@@ -89,6 +212,12 @@ export const FeedbackModal: React.FC = () => {
89
212
  if (mountedRef.current) setQuickIconHidden(v);
90
213
  })
91
214
  .catch(() => {});
215
+ YaverFeedback.getQuickIconColorPreset()
216
+ .then((preset) => {
217
+ if (mountedRef.current) setQuickIconColorPreset(preset);
218
+ })
219
+ .catch(() => {});
220
+ void loadSelectedMachine();
92
221
  }
93
222
  });
94
223
  // Agent streams build / compile progress through the BlackBox
@@ -117,7 +246,15 @@ export const FeedbackModal: React.FC = () => {
117
246
  sub.remove();
118
247
  statusSub.remove();
119
248
  };
120
- }, []);
249
+ }, [loadSelectedMachine]);
250
+
251
+ useEffect(() => {
252
+ if (!visible) return;
253
+ const interval = setInterval(() => {
254
+ void loadSelectedMachine();
255
+ }, 5000);
256
+ return () => clearInterval(interval);
257
+ }, [loadSelectedMachine, visible]);
121
258
 
122
259
  const closeSoon = useCallback((delayMs = 1200) => {
123
260
  setTimeout(() => {
@@ -129,8 +266,10 @@ export const FeedbackModal: React.FC = () => {
129
266
  setVisible(false);
130
267
  setError(null);
131
268
  setToast(null);
269
+ setProgress(null);
132
270
  setAction('idle');
133
- setShowCaptureChoices(false);
271
+ setShowVibeInput(false);
272
+ setVibePrompt('');
134
273
  }, []);
135
274
 
136
275
  // Helper: run a P2P call; on network failure, ask YaverFeedback to
@@ -188,28 +327,54 @@ export const FeedbackModal: React.FC = () => {
188
327
  setAction('hot-reloading');
189
328
  setError(null);
190
329
  setProgress(0);
191
- setToast('Sending…');
330
+ setToast('Contacting selected machine…');
192
331
  try {
332
+ await loadSelectedMachine();
333
+ const selected = await YaverFeedback.getSelectedRemoteDevice();
334
+ if (!selected) {
335
+ YaverFeedback.showMachinePicker();
336
+ throw new Error('No machine selected. Pick a machine and try again.');
337
+ }
338
+ if (selected.needsAuth) {
339
+ YaverFeedback.showMachinePicker();
340
+ throw new Error('Selected machine needs pairing again.');
341
+ }
342
+ if (!selected.isOnline) {
343
+ throw new Error('Selected machine is offline. Start `yaver serve` on it first.');
344
+ }
345
+
193
346
  // Default mode: bundle. Always rebuilds via the agent regardless
194
347
  // of Metro state. P2PClient.reloadApp auto-resolves projectName +
195
348
  // bundleId from expo-constants / NativeModules so the agent can
196
349
  // map this app to its MobileProject scan entry without needing
197
350
  // `yaver dev start` to have been run.
351
+ let ackMessage = 'Reload request acknowledged.';
198
352
  await runWithReconnect(async (client) => {
199
- await client.reloadApp('bundle');
353
+ const ack = await client.reloadApp('bundle');
354
+ ackMessage = ack.message;
355
+ setToast(ack.message);
356
+ setProgress(0.2);
200
357
  });
201
358
  // We don't auto-close here — the agent's BlackBox status pings
202
359
  // will keep the modal updated, and the on-device YaverBundleLoader
203
360
  // will reload the JS once the fresh bundle arrives. Modal stays
204
361
  // up for a beat so the user sees the final progress state.
362
+ setToast(ackMessage);
205
363
  closeSoon(2500);
206
364
  } catch (err: unknown) {
207
- setError(err instanceof Error ? err.message : String(err));
365
+ const message = err instanceof Error ? err.message : String(err);
366
+ setError(message);
367
+ setToast(
368
+ message.toLowerCase().indexOf('session expired') >= 0
369
+ ? 'Session expired. Sign in again.'
370
+ : 'Hot reload did not start.',
371
+ );
372
+ await loadSelectedMachine();
208
373
  setProgress(null);
209
374
  } finally {
210
375
  if (mountedRef.current) setAction('idle');
211
376
  }
212
- }, [closeSoon, runWithReconnect]);
377
+ }, [closeSoon, loadSelectedMachine, runWithReconnect]);
213
378
 
214
379
  const uploadBundleWithOptionalFix = useCallback(async (
215
380
  bundle: FeedbackBundle,
@@ -252,15 +417,9 @@ export const FeedbackModal: React.FC = () => {
252
417
  }
253
418
  }, [closeSoon]);
254
419
 
255
- // ─── 3. Screenshot / Upload ───────────────────────────────────────
256
- const handleCaptureChoiceToggle = useCallback(() => {
257
- setShowCaptureChoices((v) => !v);
258
- }, []);
259
-
260
420
  const handleScreenshotAndFix = useCallback(async () => {
261
421
  setAction('capturing');
262
422
  setError(null);
263
- setShowCaptureChoices(false);
264
423
 
265
424
  setVisible(false);
266
425
  await new Promise((resolve) => setTimeout(resolve, 350));
@@ -309,51 +468,11 @@ export const FeedbackModal: React.FC = () => {
309
468
  }
310
469
  }, [uploadBundleWithOptionalFix]);
311
470
 
471
+ /*
312
472
  const handleFileUpload = useCallback(async () => {
313
- setAction('capturing');
314
- setError(null);
315
- setShowCaptureChoices(false);
316
- try {
317
- const picked = await pickFeedbackFile();
318
- const { Dimensions } = require('react-native');
319
- const { width, height } = Dimensions.get('window');
320
- const deviceInfo: DeviceInfo = {
321
- platform: Platform.OS,
322
- osVersion: String(Platform.Version),
323
- model: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
324
- screenWidth: width,
325
- screenHeight: height,
326
- };
327
- const capturedErrors = YaverFeedback.getCapturedErrors();
328
- const bundle: FeedbackBundle = {
329
- metadata: {
330
- timestamp: new Date().toISOString(),
331
- device: deviceInfo,
332
- app: {},
333
- userNote: `[Uploaded file] ${picked.name}`,
334
- },
335
- screenshots: picked.kind === 'image' ? [picked.path] : [],
336
- video: picked.kind === 'video' ? picked.path : undefined,
337
- audio: picked.kind === 'audio' ? picked.path : undefined,
338
- errors: capturedErrors.length > 0 ? capturedErrors : undefined,
339
- };
340
- if (picked.kind === 'unknown') {
341
- throw new Error('Pick an image, video, or audio file.');
342
- }
343
- await uploadBundleWithOptionalFix(
344
- bundle,
345
- picked.kind === 'image',
346
- picked.kind === 'image' ? 'Fix task started' : 'File uploaded',
347
- );
348
- } catch (err: unknown) {
349
- const message = err instanceof Error ? err.message : String(err);
350
- if (message !== 'File selection canceled.') {
351
- setError(message);
352
- }
353
- } finally {
354
- if (mountedRef.current) setAction('idle');
355
- }
473
+ ...
356
474
  }, [uploadBundleWithOptionalFix]);
475
+ */
357
476
 
358
477
  // ─── 3. Vibing ─────────────────────────────────────────────────────
359
478
  // First tap expands the input; second submit fires the actual
@@ -361,8 +480,29 @@ export const FeedbackModal: React.FC = () => {
361
480
  // user types what they want, hits Send, sees the task id back. If
362
481
  // left blank, we default to "pick the next small improvement"
363
482
  // so a one-tap workflow still works for lazy days.
364
- const handleVibingButton = useCallback(() => {
483
+ const handleVibingButton = useCallback(async () => {
365
484
  if (!showVibeInput) {
485
+ const client = YaverFeedback.getP2PClient();
486
+ if (!client) {
487
+ setError('Not connected to the agent yet.');
488
+ return;
489
+ }
490
+ setError(null);
491
+ try {
492
+ const eligibility = await client.getVibingEligibility();
493
+ if (!eligibility.canVibe) {
494
+ const message =
495
+ eligibility.guidance && eligibility.guidance.trim()
496
+ ? `${eligibility.reason ?? 'Vibe coding is unavailable.'} ${eligibility.guidance}`
497
+ : eligibility.reason ?? 'Vibe coding is unavailable.';
498
+ setError(message);
499
+ setToast('Vibe coding unavailable for this project.');
500
+ return;
501
+ }
502
+ } catch (err: unknown) {
503
+ setError(err instanceof Error ? err.message : String(err));
504
+ return;
505
+ }
366
506
  setShowVibeInput(true);
367
507
  return;
368
508
  }
@@ -407,113 +547,11 @@ export const FeedbackModal: React.FC = () => {
407
547
  }
408
548
  }, [vibePrompt]);
409
549
 
410
- // ─── 4. Screen recording ───────────────────────────────────────────
550
+ /*
411
551
  const handleScreenRecording = useCallback(async () => {
412
- setError(null);
413
- if (!isRecordingVideo && lastVideo) {
414
- const config = YaverFeedback.getConfig();
415
- if (!config?.agentUrl) {
416
- setError('Not connected to the agent yet.');
417
- return;
418
- }
419
- setAction('uploading-video');
420
- try {
421
- const { Dimensions } = require('react-native');
422
- const { width, height } = Dimensions.get('window');
423
- const deviceInfo: DeviceInfo = {
424
- platform: Platform.OS,
425
- osVersion: String(Platform.Version),
426
- model: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
427
- screenWidth: width,
428
- screenHeight: height,
429
- };
430
- const bundle: FeedbackBundle = {
431
- metadata: {
432
- timestamp: new Date().toISOString(),
433
- device: deviceInfo,
434
- app: {},
435
- userNote: '[Screen recording]',
436
- },
437
- screenshots: [],
438
- video: lastVideo.path,
439
- errors: YaverFeedback.getCapturedErrors().length
440
- ? YaverFeedback.getCapturedErrors()
441
- : undefined,
442
- };
443
- await uploadFeedback(config.agentUrl, config.authToken ?? '', bundle);
444
- if (mountedRef.current) {
445
- setToast(`Recording uploaded — ${Math.round(lastVideo.duration)}s`);
446
- setLastVideo(null);
447
- }
448
- closeSoon(1200);
449
- } catch (err: unknown) {
450
- setError(err instanceof Error ? err.message : String(err));
451
- } finally {
452
- if (mountedRef.current) setAction('idle');
453
- }
454
- return;
455
- }
456
-
457
- if (isRecordingVideo) {
458
- try {
459
- const result = await stopVideoRecording();
460
- if (mountedRef.current) {
461
- setIsRecordingVideo(false);
462
- setLastVideo(result);
463
- setAction('uploading-video');
464
- setToast('Uploading recording…');
465
- }
466
- const config = YaverFeedback.getConfig();
467
- if (!config?.agentUrl) {
468
- throw new Error('Not connected to the agent yet.');
469
- }
470
- const { Dimensions } = require('react-native');
471
- const { width, height } = Dimensions.get('window');
472
- const deviceInfo: DeviceInfo = {
473
- platform: Platform.OS,
474
- osVersion: String(Platform.Version),
475
- model: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
476
- screenWidth: width,
477
- screenHeight: height,
478
- };
479
- const bundle: FeedbackBundle = {
480
- metadata: {
481
- timestamp: new Date().toISOString(),
482
- device: deviceInfo,
483
- app: {},
484
- userNote: '[Screen recording]',
485
- },
486
- screenshots: [],
487
- video: result.path,
488
- errors: YaverFeedback.getCapturedErrors().length
489
- ? YaverFeedback.getCapturedErrors()
490
- : undefined,
491
- };
492
- await uploadFeedback(config.agentUrl, config.authToken ?? '', bundle);
493
- if (mountedRef.current) {
494
- setToast(`Recording uploaded — ${Math.round(result.duration)}s`);
495
- setLastVideo(null);
496
- }
497
- closeSoon(1200);
498
- } catch (err: unknown) {
499
- setIsRecordingVideo(false);
500
- setError(err instanceof Error ? err.message : String(err));
501
- } finally {
502
- if (mountedRef.current) setAction('idle');
503
- }
504
- } else {
505
- try {
506
- await startVideoRecording();
507
- if (mountedRef.current) {
508
- setIsRecordingVideo(true);
509
- setToast('Recording… tap again to stop and upload');
510
- setLastVideo(null);
511
- }
512
- } catch (err: unknown) {
513
- setError(err instanceof Error ? err.message : String(err));
514
- }
515
- }
552
+ ...
516
553
  }, [closeSoon, isRecordingVideo, lastVideo]);
554
+ */
517
555
 
518
556
  const busy = action !== 'idle';
519
557
 
@@ -529,7 +567,23 @@ export const FeedbackModal: React.FC = () => {
529
567
  onRequestClose={handleClose}
530
568
  >
531
569
  <Pressable style={styles.overlay} onPress={handleClose}>
532
- <Pressable style={styles.modal} onPress={(e) => e.stopPropagation()}>
570
+ <KeyboardAvoidingView
571
+ behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
572
+ style={styles.kbAvoider}
573
+ pointerEvents="box-none"
574
+ >
575
+ <Pressable
576
+ style={styles.modal}
577
+ onPress={(e) => {
578
+ e.stopPropagation();
579
+ Keyboard.dismiss();
580
+ }}
581
+ >
582
+ <ScrollView
583
+ style={styles.scroll}
584
+ contentContainerStyle={styles.scrollContent}
585
+ keyboardShouldPersistTaps="handled"
586
+ >
533
587
  <View style={styles.header}>
534
588
  <Text style={styles.title}>Send Feedback</Text>
535
589
  <Pressable
@@ -543,6 +597,111 @@ export const FeedbackModal: React.FC = () => {
543
597
  </Pressable>
544
598
  </View>
545
599
 
600
+ <Pressable
601
+ onPress={() => {
602
+ if (!YaverFeedback.isAuthed()) {
603
+ YaverFeedback.showLogin();
604
+ return;
605
+ }
606
+ YaverFeedback.showMachinePicker();
607
+ }}
608
+ style={[
609
+ styles.machineCard,
610
+ machineCard.status === 'live' && styles.machineCardLive,
611
+ machineCard.status === 'attention' && styles.machineCardAttention,
612
+ machineCard.status === 'offline' && styles.machineCardOffline,
613
+ ]}
614
+ >
615
+ <View style={styles.machineHeader}>
616
+ <View style={styles.machineTitleWrap}>
617
+ <View
618
+ style={[
619
+ styles.machineDot,
620
+ machineCard.status === 'live' && styles.machineDotLive,
621
+ machineCard.status === 'attention' && styles.machineDotAttention,
622
+ machineCard.status === 'offline' && styles.machineDotOffline,
623
+ ]}
624
+ />
625
+ <Text style={styles.machineLabel}>Selected Machine</Text>
626
+ </View>
627
+ <Text style={styles.machineAction}>
628
+ {machineCard.loading ? 'Refreshing…' : 'Change'}
629
+ </Text>
630
+ </View>
631
+ <Text style={styles.machineName}>
632
+ {machineCard.loading ? 'Checking machine…' : machineCard.title}
633
+ </Text>
634
+ <Text style={styles.machineMeta}>{machineCard.detail}</Text>
635
+ </Pressable>
636
+
637
+ {quickIconHidden && (
638
+ <View style={styles.quickIconNote}>
639
+ <Text style={styles.quickIconNoteText}>
640
+ Quick access icon is hidden. Shake the phone if you want feedback back fast.
641
+ </Text>
642
+ <Pressable
643
+ onPress={() => {
644
+ void YaverFeedback.setQuickIconVisible(true);
645
+ setQuickIconHidden(false);
646
+ }}
647
+ style={({ pressed }) => [
648
+ styles.quickIconToggle,
649
+ pressed && styles.buttonPressed,
650
+ ]}
651
+ >
652
+ <Text style={styles.quickIconToggleText}>Show quick icon again</Text>
653
+ </Pressable>
654
+ </View>
655
+ )}
656
+
657
+ <View style={styles.iconSelector}>
658
+ <Text style={styles.iconSelectorTitle}>Quick Icon Color</Text>
659
+ <Text style={styles.iconSelectorText}>
660
+ Pick a runtime color so the floating y icon does not overlap with your app UI.
661
+ </Text>
662
+ <View style={styles.iconSelectorGrid}>
663
+ {(Object.entries(QUICK_ICON_COLOR_PRESETS) as Array<
664
+ [QuickIconColorPreset, (typeof QUICK_ICON_COLOR_PRESETS)[QuickIconColorPreset]]
665
+ >).map(([preset, colors]) => {
666
+ const selected = quickIconColorPreset === preset;
667
+ return (
668
+ <Pressable
669
+ key={preset}
670
+ onPress={() => {
671
+ setQuickIconColorPreset(preset);
672
+ void YaverFeedback.setQuickIconColorPreset(preset);
673
+ }}
674
+ style={[
675
+ styles.iconOption,
676
+ selected && styles.iconOptionSelected,
677
+ ]}
678
+ >
679
+ <View
680
+ style={[
681
+ styles.iconOptionCircle,
682
+ {
683
+ backgroundColor: colors.backgroundColor,
684
+ borderColor: colors.borderColor,
685
+ shadowColor: colors.shadowColor,
686
+ },
687
+ ]}
688
+ >
689
+ <Text
690
+ style={[
691
+ styles.iconOptionLabel,
692
+ { color: colors.foregroundColor },
693
+ ]}
694
+ >
695
+ y
696
+ </Text>
697
+ </View>
698
+ <Text style={styles.iconOptionText}>{colors.label}</Text>
699
+ </Pressable>
700
+ );
701
+ })}
702
+ </View>
703
+ </View>
704
+
546
705
  {/* 1. Hot Reload — the common path */}
547
706
  <ActionRow
548
707
  label={
@@ -613,51 +772,13 @@ export const FeedbackModal: React.FC = () => {
613
772
  </Text>
614
773
  )}
615
774
 
616
- {/* Screenshot / Upload */}
617
- {!showCaptureChoices ? (
618
- <ActionRow
619
- label={
620
- action === 'capturing'
621
- ? 'Working…'
622
- : 'Screenshot / Upload'
623
- }
624
- tint="#22c55e"
625
- onPress={handleCaptureChoiceToggle}
626
- disabled={busy}
627
- busy={action === 'capturing'}
628
- />
629
- ) : (
630
- <View style={styles.captureChoices}>
631
- <ActionRow
632
- label="Take Screenshot"
633
- tint="#22c55e"
634
- onPress={handleScreenshotAndFix}
635
- disabled={busy}
636
- />
637
- <ActionRow
638
- label="Upload File"
639
- tint="#34d399"
640
- onPress={handleFileUpload}
641
- disabled={busy}
642
- />
643
- </View>
644
- )}
645
-
646
- {/* 4. Screen recording */}
775
+ {/* Screenshot & Fix */}
647
776
  <ActionRow
648
- label={
649
- action === 'uploading-video'
650
- ? 'Uploading…'
651
- : isRecordingVideo
652
- ? 'Stop & Upload Recording'
653
- : lastVideo
654
- ? `Retry Upload Recording · ${Math.round(lastVideo.duration)}s`
655
- : 'Screen Recording'
656
- }
657
- tint={isRecordingVideo ? '#ef4444' : '#60a5fa'}
658
- onPress={handleScreenRecording}
659
- disabled={busy && action !== 'uploading-video' && !isRecordingVideo}
660
- busy={action === 'uploading-video'}
777
+ label={action === 'capturing' ? 'Working…' : 'Screenshot & Fix'}
778
+ tint="#22c55e"
779
+ onPress={handleScreenshotAndFix}
780
+ disabled={busy}
781
+ busy={action === 'capturing'}
661
782
  />
662
783
 
663
784
  {progress !== null && (
@@ -673,34 +794,6 @@ export const FeedbackModal: React.FC = () => {
673
794
  {toast && <Text style={styles.toast}>{toast}</Text>}
674
795
  {error && <Text style={styles.error}>{error}</Text>}
675
796
 
676
- {/* Quick-icon toggle. The user's three ways to control
677
- the floating icon are: (1) long-press the icon →
678
- Hide, (2) tap this row to toggle it on/off, (3) shake
679
- → this modal → tap this row. Shake is the unkillable
680
- back-door when the icon is hidden and the dev hasn't
681
- exposed their own settings UI. */}
682
- <Pressable
683
- onPress={async () => {
684
- const next = !quickIconHidden;
685
- setQuickIconHidden(next);
686
- await YaverFeedback.setQuickIconVisible(!next);
687
- }}
688
- style={({ pressed }) => [
689
- styles.quickIconToggle,
690
- pressed && { opacity: 0.7 },
691
- ]}
692
- accessibilityRole="button"
693
- accessibilityLabel={
694
- quickIconHidden ? 'Show quick icon' : 'Hide quick icon'
695
- }
696
- >
697
- <Text style={styles.quickIconToggleText}>
698
- {quickIconHidden
699
- ? '◯ Show quick-access icon'
700
- : '● Hide quick-access icon'}
701
- </Text>
702
- </Pressable>
703
-
704
797
  <Pressable
705
798
  onPress={handleClose}
706
799
  style={({ pressed }) => [
@@ -712,7 +805,9 @@ export const FeedbackModal: React.FC = () => {
712
805
  >
713
806
  <Text style={styles.cancelBtnText}>Cancel</Text>
714
807
  </Pressable>
808
+ </ScrollView>
715
809
  </Pressable>
810
+ </KeyboardAvoidingView>
716
811
  </Pressable>
717
812
  </Modal>
718
813
  )}
@@ -814,6 +909,10 @@ const styles = StyleSheet.create({
814
909
  backgroundColor: 'rgba(0,0,0,0.55)',
815
910
  justifyContent: 'flex-end',
816
911
  },
912
+ kbAvoider: {
913
+ width: '100%',
914
+ justifyContent: 'flex-end',
915
+ },
817
916
  modal: {
818
917
  backgroundColor: '#141422',
819
918
  borderTopLeftRadius: 22,
@@ -821,6 +920,14 @@ const styles = StyleSheet.create({
821
920
  padding: 22,
822
921
  paddingBottom: 36,
823
922
  gap: 12,
923
+ maxHeight: '92%',
924
+ },
925
+ scroll: {
926
+ maxHeight: '100%',
927
+ },
928
+ scrollContent: {
929
+ gap: 12,
930
+ paddingBottom: 8,
824
931
  },
825
932
  header: {
826
933
  flexDirection: 'row',
@@ -861,6 +968,74 @@ const styles = StyleSheet.create({
861
968
  fontSize: 15,
862
969
  fontWeight: '700',
863
970
  },
971
+ machineCard: {
972
+ borderRadius: 14,
973
+ borderWidth: 1,
974
+ padding: 14,
975
+ backgroundColor: 'rgba(255,255,255,0.04)',
976
+ borderColor: 'rgba(255,255,255,0.12)',
977
+ },
978
+ machineCardLive: {
979
+ backgroundColor: 'rgba(34,197,94,0.10)',
980
+ borderColor: 'rgba(34,197,94,0.35)',
981
+ },
982
+ machineCardAttention: {
983
+ backgroundColor: 'rgba(245,158,11,0.10)',
984
+ borderColor: 'rgba(245,158,11,0.35)',
985
+ },
986
+ machineCardOffline: {
987
+ backgroundColor: 'rgba(239,68,68,0.10)',
988
+ borderColor: 'rgba(239,68,68,0.35)',
989
+ },
990
+ machineHeader: {
991
+ flexDirection: 'row',
992
+ alignItems: 'center',
993
+ justifyContent: 'space-between',
994
+ marginBottom: 6,
995
+ },
996
+ machineTitleWrap: {
997
+ flexDirection: 'row',
998
+ alignItems: 'center',
999
+ gap: 8,
1000
+ },
1001
+ machineDot: {
1002
+ width: 10,
1003
+ height: 10,
1004
+ borderRadius: 5,
1005
+ backgroundColor: '#6b7280',
1006
+ },
1007
+ machineDotLive: {
1008
+ backgroundColor: '#22c55e',
1009
+ },
1010
+ machineDotAttention: {
1011
+ backgroundColor: '#f59e0b',
1012
+ },
1013
+ machineDotOffline: {
1014
+ backgroundColor: '#ef4444',
1015
+ },
1016
+ machineLabel: {
1017
+ color: '#cbd5e1',
1018
+ fontSize: 12,
1019
+ fontWeight: '700',
1020
+ textTransform: 'uppercase',
1021
+ letterSpacing: 0.8,
1022
+ },
1023
+ machineAction: {
1024
+ color: '#a5b4fc',
1025
+ fontSize: 12,
1026
+ fontWeight: '700',
1027
+ },
1028
+ machineName: {
1029
+ color: '#fff',
1030
+ fontSize: 16,
1031
+ fontWeight: '700',
1032
+ },
1033
+ machineMeta: {
1034
+ color: '#cbd5e1',
1035
+ fontSize: 12,
1036
+ marginTop: 4,
1037
+ lineHeight: 17,
1038
+ },
864
1039
  captureChoices: {
865
1040
  gap: 10,
866
1041
  },
@@ -889,15 +1064,89 @@ const styles = StyleSheet.create({
889
1064
  marginTop: 4,
890
1065
  },
891
1066
  quickIconToggle: {
892
- marginTop: 4,
893
- alignSelf: 'center',
1067
+ marginTop: 6,
1068
+ alignSelf: 'flex-start',
894
1069
  paddingVertical: 6,
895
- paddingHorizontal: 12,
1070
+ paddingHorizontal: 10,
1071
+ borderRadius: 10,
1072
+ backgroundColor: 'rgba(255,255,255,0.05)',
896
1073
  },
897
1074
  quickIconToggleText: {
898
- color: '#9ca3af',
1075
+ color: '#cbd5e1',
1076
+ fontSize: 12,
1077
+ fontWeight: '700',
1078
+ },
1079
+ quickIconNote: {
1080
+ borderRadius: 12,
1081
+ borderWidth: 1,
1082
+ borderColor: 'rgba(251,191,36,0.28)',
1083
+ backgroundColor: 'rgba(251,191,36,0.08)',
1084
+ padding: 12,
1085
+ },
1086
+ quickIconNoteText: {
1087
+ color: '#fde68a',
1088
+ fontSize: 12,
1089
+ lineHeight: 17,
1090
+ },
1091
+ iconSelector: {
1092
+ borderRadius: 12,
1093
+ borderWidth: 1,
1094
+ borderColor: 'rgba(255,255,255,0.09)',
1095
+ backgroundColor: 'rgba(255,255,255,0.03)',
1096
+ padding: 12,
1097
+ gap: 10,
1098
+ },
1099
+ iconSelectorTitle: {
1100
+ color: '#f8fafc',
1101
+ fontSize: 13,
1102
+ fontWeight: '700',
1103
+ },
1104
+ iconSelectorText: {
1105
+ color: '#cbd5e1',
899
1106
  fontSize: 12,
900
- fontWeight: '500',
1107
+ lineHeight: 17,
1108
+ },
1109
+ iconSelectorGrid: {
1110
+ flexDirection: 'row',
1111
+ flexWrap: 'wrap',
1112
+ gap: 10,
1113
+ },
1114
+ iconOption: {
1115
+ width: '31%',
1116
+ minWidth: 84,
1117
+ borderRadius: 12,
1118
+ borderWidth: 1,
1119
+ borderColor: 'rgba(255,255,255,0.08)',
1120
+ backgroundColor: 'rgba(255,255,255,0.02)',
1121
+ paddingVertical: 10,
1122
+ paddingHorizontal: 8,
1123
+ alignItems: 'center',
1124
+ gap: 8,
1125
+ },
1126
+ iconOptionSelected: {
1127
+ borderColor: 'rgba(129,140,248,0.72)',
1128
+ backgroundColor: 'rgba(129,140,248,0.12)',
1129
+ },
1130
+ iconOptionCircle: {
1131
+ width: 38,
1132
+ height: 38,
1133
+ borderRadius: 19,
1134
+ alignItems: 'center',
1135
+ justifyContent: 'center',
1136
+ borderWidth: 2,
1137
+ shadowOffset: { width: 0, height: 2 },
1138
+ shadowOpacity: 0.28,
1139
+ shadowRadius: 5,
1140
+ elevation: 5,
1141
+ },
1142
+ iconOptionLabel: {
1143
+ fontSize: 18,
1144
+ fontWeight: '700',
1145
+ },
1146
+ iconOptionText: {
1147
+ color: '#e2e8f0',
1148
+ fontSize: 11,
1149
+ fontWeight: '600',
901
1150
  },
902
1151
  cancelBtn: {
903
1152
  marginTop: 4,