yaver-feedback-react-native 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/app.plugin.js +126 -9
  2. package/dist/BlackBox.js +9 -1
  3. package/dist/DeployPanel.js +65 -0
  4. package/dist/Discovery.js +13 -2
  5. package/dist/FeedbackModal.js +160 -6
  6. package/dist/P2PClient.d.ts +64 -1
  7. package/dist/P2PClient.js +222 -2
  8. package/dist/ShakeDetector.js +2 -0
  9. package/dist/YaverFeedback.d.ts +98 -0
  10. package/dist/YaverFeedback.js +509 -46
  11. package/dist/__tests__/BlackBox.relayPassword.test.d.ts +1 -0
  12. package/dist/__tests__/BlackBox.relayPassword.test.js +105 -0
  13. package/dist/__tests__/BlackBoxAutoStart.test.d.ts +1 -0
  14. package/dist/__tests__/BlackBoxAutoStart.test.js +91 -0
  15. package/dist/__tests__/BlackBoxAutoStartColdStart.test.d.ts +1 -0
  16. package/dist/__tests__/BlackBoxAutoStartColdStart.test.js +156 -0
  17. package/dist/__tests__/BrowserLaneIcon.test.d.ts +3 -0
  18. package/dist/__tests__/BrowserLaneIcon.test.js +80 -0
  19. package/dist/__tests__/P2PClient.test.js +2 -2
  20. package/dist/__tests__/ReportIdentity.test.d.ts +1 -0
  21. package/dist/__tests__/ReportIdentity.test.js +168 -0
  22. package/dist/__tests__/SDKToken.test.js +1 -1
  23. package/dist/__tests__/ShakeToggle.test.d.ts +1 -0
  24. package/dist/__tests__/ShakeToggle.test.js +121 -0
  25. package/dist/__tests__/pickTargetDevice.test.d.ts +1 -0
  26. package/dist/__tests__/pickTargetDevice.test.js +89 -0
  27. package/dist/__tests__/reloadActions.test.d.ts +1 -0
  28. package/dist/__tests__/reloadActions.test.js +129 -0
  29. package/dist/__tests__/reloadActionsParity.test.d.ts +1 -0
  30. package/dist/__tests__/reloadActionsParity.test.js +42 -0
  31. package/dist/__tests__/types.test.js +5 -5
  32. package/dist/_core/device.d.ts +19 -9
  33. package/dist/_core/device.js +20 -14
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.js +11 -1
  36. package/dist/reloadActions.d.ts +88 -0
  37. package/dist/reloadActions.js +200 -0
  38. package/dist/storeShots.d.ts +67 -0
  39. package/dist/storeShots.js +137 -0
  40. package/dist/types.d.ts +158 -1
  41. package/package.json +2 -2
  42. package/src/BlackBox.ts +10 -1
  43. package/src/DeployPanel.tsx +74 -0
  44. package/src/Discovery.ts +13 -2
  45. package/src/FeedbackModal.tsx +176 -14
  46. package/src/P2PClient.ts +235 -2
  47. package/src/ShakeDetector.ts +1 -0
  48. package/src/YaverFeedback.ts +498 -46
  49. package/src/__tests__/BlackBox.relayPassword.test.ts +129 -0
  50. package/src/__tests__/BlackBoxAutoStart.test.ts +111 -0
  51. package/src/__tests__/BlackBoxAutoStartColdStart.test.ts +191 -0
  52. package/src/__tests__/BrowserLaneIcon.test.ts +85 -0
  53. package/src/__tests__/P2PClient.test.ts +2 -2
  54. package/src/__tests__/ReportIdentity.test.ts +203 -0
  55. package/src/__tests__/SDKToken.test.ts +1 -1
  56. package/src/__tests__/ShakeToggle.test.ts +153 -0
  57. package/src/__tests__/pickTargetDevice.test.ts +101 -0
  58. package/src/__tests__/reloadActions.test.ts +171 -0
  59. package/src/__tests__/reloadActionsParity.test.ts +49 -0
  60. package/src/__tests__/types.test.ts +5 -5
  61. package/src/_core/device.ts +20 -14
  62. package/src/index.ts +21 -0
  63. package/src/reloadActions.ts +273 -0
  64. package/src/storeShots.ts +189 -0
  65. package/src/types.ts +164 -1
package/dist/types.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { BlackBoxConfig } from './BlackBox';
1
2
  /**
2
3
  * Remote browser-style sign-in session for a coding-agent CLI on the
3
4
  * connected yaver host. Mirrors runnerBrowserAuthSession on the agent
@@ -121,9 +122,32 @@ export interface CapabilitySnapshot {
121
122
  };
122
123
  targets: Record<string, CapabilityTargetReadiness>;
123
124
  }
125
+ /**
126
+ * Opt-in config for the overlay's "App Store screenshots" action and the
127
+ * `capture_store_shots` remote command. When set, the SDK can walk the
128
+ * app's routes on-device, screenshot each, and upload them to the agent
129
+ * (which runs the App Store Connect backend).
130
+ */
131
+ export interface StoreShotsConfig {
132
+ /** Ordered routes to visit + screenshot (e.g. ['/(tabs)/home', ...]). */
133
+ routes: string[];
134
+ /** Navigation handle: a react-navigation ref or expo-router `router`. */
135
+ navigationRef?: any;
136
+ /** Also set metadata + attempt submit-for-review after upload. */
137
+ submit?: boolean;
138
+ /** Optional per-route screenshot names (defaults to NN_<route>). */
139
+ screens?: string[];
140
+ }
124
141
  export interface FeedbackConfig {
125
142
  /** URL of the Yaver agent (e.g. "http://192.168.1.10:18080"). If omitted, auto-discovery is used. */
126
143
  agentUrl?: string;
144
+ /**
145
+ * Enables the overlay's "App Store screenshots" action + the
146
+ * `capture_store_shots` remote command. The host supplies the route
147
+ * list (and a navigation ref) once; the SDK captures the real running
148
+ * app — no simulator needed.
149
+ */
150
+ storeShots?: StoreShotsConfig;
127
151
  /**
128
152
  * Auth token for the Yaver agent. Optional in 0.5+: if omitted, the SDK
129
153
  * will hydrate one from AsyncStorage or show its in-app login screen
@@ -163,6 +187,39 @@ export interface FeedbackConfig {
163
187
  * If omitted with convexUrl, connects to the first online device.
164
188
  */
165
189
  preferredDeviceId?: string;
190
+ /**
191
+ * Identity of the app this SDK instance reports for. Both fields are
192
+ * resolved automatically from expo-constants / native modules when
193
+ * omitted, so most apps never set them.
194
+ *
195
+ * Set them when the ambient answer would be wrong — above all when the
196
+ * app's JS is pushed into Yaver's container as a Hermes guest. There the
197
+ * runtime IS Yaver: `expo-constants` and `SettingsManager` describe the
198
+ * host (`Yaver` / `io.yaver.mobile`), not the guest, so auto-resolution
199
+ * would file every report against Yaver's own repo and the fix task would
200
+ * edit the SDK instead of the app. Declaring identity here is the only
201
+ * answer that holds in both a standalone build and a guest bundle.
202
+ *
203
+ * `bundleId` is the key that actually routes: the agent matches it against
204
+ * each project's pbxproj / build.gradle / app.json. `projectName` is a
205
+ * fallback for platforms with no bundle id.
206
+ */
207
+ projectName?: string;
208
+ bundleId?: string;
209
+ /** Primary Yaver product surface for this app/package. */
210
+ surface?: YaverSurface;
211
+ /** All product surfaces this app/package owns. */
212
+ surfaces?: YaverSurface[];
213
+ /** Canonical development stack labels, e.g. react-native-expo, yaver-xml. */
214
+ stack?: string;
215
+ stacks?: string[];
216
+ /** Yaver preview/runtime targets that can exercise this app. */
217
+ testSurfaces?: string[];
218
+ feedbackSdk?: string;
219
+ feedbackTransport?: string;
220
+ voiceCapabilities?: string[];
221
+ sttProvider?: string;
222
+ ttsProvider?: string;
166
223
  /** How feedback collection is triggered */
167
224
  trigger?: 'shake' | 'floating-button' | 'manual';
168
225
  /**
@@ -203,6 +260,22 @@ export interface FeedbackConfig {
203
260
  * Calling `BlackBox.start()` manually is still safe — it's idempotent.
204
261
  */
205
262
  autoStartBlackBox?: boolean;
263
+ /**
264
+ * Config handed to BlackBox when the SDK auto-starts it.
265
+ *
266
+ * Without this there was no way to configure the flight recorder at all.
267
+ * The obvious move — and the one the README's own snippet makes — is:
268
+ *
269
+ * YaverFeedback.init({ trigger: 'shake' });
270
+ * BlackBox.start({ appName: 'my-app' });
271
+ *
272
+ * but that start() early-returns, because a zero-config init has no
273
+ * agentUrl yet (discovery resolves it asynchronously). The auto-start then
274
+ * fires ~500ms later once the agent and token exist and calls `start()`
275
+ * with NO arguments — so the host's config is silently dropped and appName
276
+ * is ''. Pass it here instead and the auto-start will honour it.
277
+ */
278
+ blackBox?: BlackBoxConfig;
206
279
  /**
207
280
  * Small tap-to-open icon that floats above the app so the user
208
281
  * doesn't have to shake every time they want to open feedback.
@@ -291,6 +364,26 @@ export interface FeedbackConfig {
291
364
  * Bugsnag, or any other error tracking tool.
292
365
  */
293
366
  maxCapturedErrors?: number;
367
+ /**
368
+ * Crash-aware reporting/fixing. Defaults to disabled.
369
+ *
370
+ * - enabled: records fatal errors into the SDK error buffer and enables
371
+ * explicit `YaverFeedback.reportCrash(error)`.
372
+ * - installGlobalHandler: opt-in only. Wraps React Native's global ErrorUtils
373
+ * handler without suppressing the existing handler. Leave false when Sentry,
374
+ * Crashlytics, Bugsnag, or another crash tool owns the global handler.
375
+ * - autoFix: after uploading a crash report, ask the connected Yaver agent to
376
+ * create a fix task. The normal BlackBox command stream can then deliver a
377
+ * reload/reload_bundle command after the agent rebuilds.
378
+ * - captureScreenshot: attach a best-effort screenshot to the crash report.
379
+ */
380
+ crashReporting?: {
381
+ enabled?: boolean;
382
+ installGlobalHandler?: boolean;
383
+ autoFix?: boolean;
384
+ captureScreenshot?: boolean;
385
+ metadata?: Record<string, unknown>;
386
+ };
294
387
  /**
295
388
  * Which platforms the Build button targets.
296
389
  * - 'ios' — build iOS only
@@ -403,9 +496,36 @@ export interface CapturedError {
403
496
  }
404
497
  export interface FeedbackMetadata {
405
498
  timestamp: string;
406
- device: DeviceInfo;
499
+ /**
500
+ * Wire key is `deviceInfo`, matching the Flutter + web SDKs and the
501
+ * agent's `FeedbackReport.DeviceInfo` (`json:"deviceInfo"`). This was
502
+ * `device` until 0.9.2 — a key the agent never read, so the whole block
503
+ * (platform, model, and the app name the fix router needs) was silently
504
+ * dropped on every React Native report. The agent still accepts the old
505
+ * key so builds already in the field keep working.
506
+ */
507
+ deviceInfo: DeviceInfo;
407
508
  app: AppInfo;
509
+ /**
510
+ * Which of the host's projects this report is about. The agent resolves
511
+ * a feedback→fix task's working directory from this; without it the task
512
+ * falls back to whatever directory the agent happens to be sitting in.
513
+ * Populated automatically by `resolveReportIdentity()`.
514
+ */
515
+ project?: FeedbackProjectRef;
408
516
  userNote?: string;
517
+ reportKind?: 'feedback' | 'auto-report' | 'crash';
518
+ crash?: {
519
+ message: string;
520
+ isFatal: boolean;
521
+ source: 'manual' | 'global-handler';
522
+ autoFixRequested?: boolean;
523
+ };
524
+ twin?: {
525
+ sessionId?: string;
526
+ surface?: string;
527
+ artifactDir?: string;
528
+ };
409
529
  }
410
530
  export interface DeviceInfo {
411
531
  platform: string;
@@ -413,12 +533,49 @@ export interface DeviceInfo {
413
533
  model: string;
414
534
  screenWidth: number;
415
535
  screenHeight: number;
536
+ /** Host app name. First key the agent's fix router checks. */
537
+ appName?: string;
416
538
  }
417
539
  export interface AppInfo {
418
540
  bundleId?: string;
419
541
  version?: string;
420
542
  buildNumber?: string;
421
543
  }
544
+ /**
545
+ * Identifies the host project a report belongs to. Mirrors the web SDK's
546
+ * `FeedbackProjectRef` and the agent's `FeedbackProject`.
547
+ */
548
+ export interface FeedbackProjectRef {
549
+ appName?: string;
550
+ projectName?: string;
551
+ /**
552
+ * Bundle identifier / applicationId. The only unambiguous key: an app's
553
+ * display name ("Talos") does not match the agent's registry name for it
554
+ * ("talos / mobile"), and one repo can hold several mobile projects that
555
+ * all share a name prefix. The agent matches this against the project's
556
+ * pbxproj, build.gradle, and app.json.
557
+ */
558
+ bundleId?: string;
559
+ /**
560
+ * Deliberately never set by this SDK. The agent ignores client-supplied
561
+ * paths for feedback reports (an untrusted guest could otherwise point the
562
+ * fix task's CWD at ~/.ssh) and resolves the path server-side. Present only
563
+ * so the type matches the wire format the agent parses.
564
+ */
565
+ projectPath?: string;
566
+ surface?: YaverSurface;
567
+ surfaces?: YaverSurface[];
568
+ stack?: string;
569
+ stacks?: string[];
570
+ testSurfaces?: string[];
571
+ feedbackSdk?: string;
572
+ feedbackTransport?: string;
573
+ voiceCapabilities?: string[];
574
+ sttProvider?: string;
575
+ ttsProvider?: string;
576
+ releaseChannel?: 'production' | 'candidate' | 'development';
577
+ }
578
+ export type YaverSurface = 'web' | 'mobile' | 'backend' | 'watch' | 'tv' | 'car' | 'vision' | 'desktop' | 'cli';
422
579
  export interface TimelineEvent {
423
580
  type: 'screenshot' | 'audio' | 'video';
424
581
  path: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "yaver-feedback-react-native",
3
- "version": "0.9.0",
4
- "description": "Visual feedback SDK for Yaver \u2014 bug reports, screen recording, voice vibe coding, and local-first developer workflows",
3
+ "version": "0.9.2",
4
+ "description": "Visual feedback SDK for Yaver bug reports, screen recording, voice vibe coding, and local-first developer workflows",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "files": [
package/src/BlackBox.ts CHANGED
@@ -66,6 +66,13 @@ export interface BlackBoxCommand {
66
66
  /** Callback type for handling agent commands. */
67
67
  export type CommandHandler = (cmd: BlackBoxCommand) => void;
68
68
 
69
+ function unrefTimer(timer: ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>): void {
70
+ const maybeNodeTimer = timer as unknown as { unref?: () => void };
71
+ if (typeof maybeNodeTimer.unref === 'function') {
72
+ maybeNodeTimer.unref();
73
+ }
74
+ }
75
+
69
76
  export class BlackBox {
70
77
  private static baseUrl: string | null = null;
71
78
  private static authToken: string | null = null;
@@ -109,7 +116,7 @@ export class BlackBox {
109
116
 
110
117
  BlackBox.baseUrl = feedbackConfig.agentUrl.replace(/\/$/, '');
111
118
  BlackBox.authToken = feedbackConfig.authToken ?? null;
112
- BlackBox.relayPassword = (feedbackConfig as { relayPassword?: string }).relayPassword ?? '';
119
+ BlackBox.relayPassword = YaverFeedback.getRelayPassword();
113
120
  BlackBox.deviceId = config?.deviceId ?? BlackBox.generateDeviceId();
114
121
  BlackBox.appName = config?.appName ?? '';
115
122
  BlackBox.flushInterval = config?.flushInterval ?? 2000;
@@ -120,6 +127,7 @@ export class BlackBox {
120
127
  // Start periodic flush
121
128
  if (BlackBox.flushTimer) clearInterval(BlackBox.flushTimer);
122
129
  BlackBox.flushTimer = setInterval(() => BlackBox.flush(), BlackBox.flushInterval);
130
+ unrefTimer(BlackBox.flushTimer);
123
131
 
124
132
  // Log the session start
125
133
  BlackBox.push({
@@ -471,6 +479,7 @@ export class BlackBox {
471
479
  BlackBox.sseReconnectTimer = null;
472
480
  if (BlackBox.started) BlackBox.connectSSE();
473
481
  }, 5000);
482
+ unrefTimer(BlackBox.sseReconnectTimer);
474
483
  }
475
484
 
476
485
  // ─── Internal ────────────────────────────────────────────────────
@@ -1,6 +1,7 @@
1
1
  import React, { useCallback, useEffect, useState } from 'react';
2
2
  import {
3
3
  ActivityIndicator,
4
+ Alert,
4
5
  Pressable,
5
6
  ScrollView,
6
7
  StyleSheet,
@@ -184,6 +185,47 @@ export const DeployPanel: React.FC<DeployPanelProps> = ({ onClose }) => {
184
185
  );
185
186
  };
186
187
 
188
+ // First-class App Store screenshots: walk the app's routes on THIS
189
+ // device, screenshot each, upload to the agent (which runs the ASC
190
+ // backend). Closes the overlay first so the captures are clean (no
191
+ // modal in frame), then reports via an alert.
192
+ const runStoreShots = () => {
193
+ const cfg = YaverFeedback.getConfig() as { storeShots?: any } | null;
194
+ const ss = cfg?.storeShots;
195
+ if (!ss?.routes?.length) {
196
+ setStatus('Set config.storeShots.routes (and a navigationRef) to enable.');
197
+ setStatusTone('error');
198
+ return;
199
+ }
200
+ const app = resolveAppSlug();
201
+ onClose();
202
+ // Defer so the overlay is fully dismissed before the first capture.
203
+ setTimeout(async () => {
204
+ try {
205
+ const res = await YaverFeedback.captureStoreScreenshots({
206
+ app,
207
+ routes: ss.routes,
208
+ navigationRef: ss.navigationRef,
209
+ screens: ss.screens,
210
+ submit: ss.submit,
211
+ });
212
+ const msg = res.ok
213
+ ? res.submitted
214
+ ? 'Submitted for App Store review 🎉'
215
+ : res.staged
216
+ ? `Uploaded ${res.uploaded} screenshots — staged. One tap left in App Store Connect.`
217
+ : `Uploaded ${res.uploaded} App Store screenshots.`
218
+ : res.message || 'Capture failed.';
219
+ Alert.alert('App Store screenshots', msg);
220
+ } catch (e: any) {
221
+ Alert.alert('App Store screenshots', e?.message ?? 'Capture failed.');
222
+ }
223
+ }, 500);
224
+ };
225
+
226
+ const storeShotsEnabled =
227
+ ((YaverFeedback.getConfig() as { storeShots?: any } | null)?.storeShots?.routes?.length ?? 0) > 0;
228
+
187
229
  const triggerDeploy = async (machine: string) => {
188
230
  if (!options) return;
189
231
  setShipping(true);
@@ -277,6 +319,19 @@ export const DeployPanel: React.FC<DeployPanelProps> = ({ onClose }) => {
277
319
  <ScrollView style={styles.list}>{options.devices.map(machineRow)}</ScrollView>
278
320
  ) : null}
279
321
 
322
+ {storeShotsEnabled && (
323
+ <Pressable
324
+ onPress={runStoreShots}
325
+ disabled={shipping}
326
+ style={({ pressed }) => [styles.shotsBtn, pressed && styles.rowPressed]}
327
+ >
328
+ <Text style={styles.shotsBtnText}>📸 App Store screenshots</Text>
329
+ <Text style={styles.shotsBtnSub}>
330
+ capture this app on-device + upload to App Store Connect
331
+ </Text>
332
+ </Pressable>
333
+ )}
334
+
280
335
  {status && (
281
336
  <Text
282
337
  style={[
@@ -388,6 +443,25 @@ const styles = StyleSheet.create({
388
443
  rowMetaWarning: {
389
444
  color: 'rgb(255,178,115)',
390
445
  },
446
+ shotsBtn: {
447
+ marginTop: 12,
448
+ paddingVertical: 12,
449
+ paddingHorizontal: 14,
450
+ borderRadius: 10,
451
+ backgroundColor: 'rgba(124,109,255,0.16)',
452
+ borderWidth: 1,
453
+ borderColor: 'rgba(124,109,255,0.4)',
454
+ },
455
+ shotsBtnText: {
456
+ color: '#fff',
457
+ fontSize: 14,
458
+ fontWeight: '600',
459
+ },
460
+ shotsBtnSub: {
461
+ color: 'rgba(255,255,255,0.55)',
462
+ fontSize: 12,
463
+ marginTop: 2,
464
+ },
391
465
  status: {
392
466
  color: 'rgba(255,255,255,0.55)',
393
467
  fontSize: 12,
package/src/Discovery.ts CHANGED
@@ -7,6 +7,14 @@ type AsyncStorageLike = {
7
7
  setItem: (key: string, value: string) => Promise<void>;
8
8
  removeItem: (key: string) => Promise<void>;
9
9
  };
10
+
11
+ function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
12
+ const maybeNodeTimer = timer as unknown as { unref?: () => void };
13
+ if (typeof maybeNodeTimer.unref === 'function') {
14
+ maybeNodeTimer.unref();
15
+ }
16
+ }
17
+
10
18
  function getAsyncStorage(): AsyncStorageLike | null {
11
19
  try {
12
20
  const mod = require('@react-native-async-storage/async-storage');
@@ -362,14 +370,15 @@ export class YaverDiscovery {
362
370
  static async probe(url: string): Promise<DiscoveryResult | null> {
363
371
  const base = url.replace(/\/$/, '');
364
372
  const start = Date.now();
373
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
365
374
  try {
366
375
  const controller = new AbortController();
367
- const timeoutId = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
376
+ timeoutId = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
377
+ unrefTimer(timeoutId);
368
378
  const response = await fetch(`${base}/health`, {
369
379
  method: 'GET',
370
380
  signal: controller.signal,
371
381
  });
372
- clearTimeout(timeoutId);
373
382
  if (!response.ok) return null;
374
383
  const latency = Date.now() - start;
375
384
  let hostname = 'Unknown';
@@ -384,6 +393,8 @@ export class YaverDiscovery {
384
393
  return { url: base, hostname, version, latency };
385
394
  } catch {
386
395
  return null;
396
+ } finally {
397
+ if (timeoutId) clearTimeout(timeoutId);
387
398
  }
388
399
  }
389
400
 
@@ -25,6 +25,7 @@ import {
25
25
  // stopVideoRecording,
26
26
  } from './capture';
27
27
  import { uploadFeedback } from './upload';
28
+ import { resolveReportIdentity } from './P2PClient';
28
29
  import {
29
30
  DeviceInfo,
30
31
  FeedbackBundle,
@@ -37,6 +38,8 @@ import { QuickActionIcon } from './QuickActionIcon';
37
38
  import { VibeChatScreen } from './VibeChatScreen';
38
39
  import { DeployPanel } from './DeployPanel';
39
40
  import { listReachableDevices, RemoteDevice } from './auth';
41
+ import { reloadActions } from './reloadActions';
42
+ import type { DevServerSnapshot, ReloadAction } from './reloadActions';
40
43
  import {
41
44
  QUICK_ICON_COLOR_PRESETS,
42
45
  QuickIconColorPreset,
@@ -195,6 +198,11 @@ export const FeedbackModal: React.FC = () => {
195
198
  // is our guaranteed UI for bringing the icon back — we surface a
196
199
  // small "Show quick icon" row when this is true.
197
200
  const [quickIconHidden, setQuickIconHidden] = useState(false);
201
+ // Dev-server snapshot behind the reload buttons. `null` means "we have not
202
+ // been able to ask" — rendered as "not connected", never as "no dev server".
203
+ const [devSnapshot, setDevSnapshot] = useState<DevServerSnapshot | null>(null);
204
+ // Which reload action is in flight, so only that button spins.
205
+ const [reloadingId, setReloadingId] = useState<string | null>(null);
198
206
  const [runnerAuthModal, setRunnerAuthModal] = useState<string | null>(null);
199
207
  // Vibing-input mode: same expand-on-tap pattern as email login.
200
208
  // Tap "Vibing" once → the button reveals an input + Send; that lets
@@ -226,6 +234,28 @@ export const FeedbackModal: React.FC = () => {
226
234
  const [showOpenCodeConfig, setShowOpenCodeConfig] = useState(false);
227
235
  const mountedRef = useRef(true);
228
236
 
237
+ /**
238
+ * Ask the machine what its dev server is doing, so the reload actions can
239
+ * be enabled/disabled against reality rather than against a guess.
240
+ *
241
+ * Best-effort and deliberately null-on-failure: null means "we could not
242
+ * ask", which the seam renders as "not connected to a machine yet" — a
243
+ * different sentence from "no dev server is running", because they have
244
+ * different fixes.
245
+ */
246
+ const refreshDevSnapshot = useCallback(async () => {
247
+ try {
248
+ const client = YaverFeedback.getP2PClient();
249
+ if (!client) {
250
+ setDevSnapshot(null);
251
+ return;
252
+ }
253
+ setDevSnapshot(await client.getDevServerStatus());
254
+ } catch {
255
+ setDevSnapshot(null);
256
+ }
257
+ }, []);
258
+
229
259
  const loadSelectedMachine = useCallback(async () => {
230
260
  const cfg = YaverFeedback.getConfig();
231
261
  if (!cfg?.authToken) {
@@ -448,12 +478,18 @@ export const FeedbackModal: React.FC = () => {
448
478
 
449
479
  useEffect(() => {
450
480
  if (!visible) return;
481
+ // Poll the dev server alongside the machine + runners. A reload button
482
+ // whose enabled state was decided once, when the sheet opened, goes
483
+ // stale the moment the user starts Metro from another surface — and a
484
+ // stale "no dev server is running" reads as the product being broken.
485
+ void refreshDevSnapshot();
451
486
  const interval = setInterval(() => {
452
487
  void loadSelectedMachine();
453
488
  void loadRunnerStatuses();
489
+ void refreshDevSnapshot();
454
490
  }, 5000);
455
491
  return () => clearInterval(interval);
456
- }, [loadRunnerStatuses, loadSelectedMachine, visible]);
492
+ }, [loadRunnerStatuses, loadSelectedMachine, refreshDevSnapshot, visible]);
457
493
 
458
494
  useEffect(() => {
459
495
  if (!visible) {
@@ -542,7 +578,87 @@ export const FeedbackModal: React.FC = () => {
542
578
  [],
543
579
  );
544
580
 
545
- // ─── 1. Hot reload ─────────────────────────────────────────────────
581
+ // ─── 1. Reload ─────────────────────────────────────────────────────
582
+ //
583
+ // Three actions now, not one: Hot Reload (mode=fast), Full Reload
584
+ // (mode=full — Flutter's hot RESTART), and Rebuild Bundle
585
+ // (/dev/reload-app, the only one that works with Metro down).
586
+ //
587
+ // WHICH of them render, and which are disabled with what reason, is
588
+ // decided by the pure `reloadActions()` seam — never inline here, so the
589
+ // same policy holds on web, Flutter, Unity, Swift and Kotlin. In
590
+ // particular: a production build (`__DEV__ === false`) gets NONE of them.
591
+
592
+ const availableReloadActions = reloadActions(devSnapshot, {
593
+ // __DEV__ is React Native's own build flag. A release bundle sets it
594
+ // false, so a shipped app renders no reload UI at all — which is the
595
+ // point, and is what reloadActions.test.ts pins.
596
+ isDevBuild: typeof __DEV__ !== 'undefined' && __DEV__ === true,
597
+ connected: devSnapshot !== null,
598
+ machineLabel: machineCard.device?.name || undefined,
599
+ includeRebuild: true,
600
+ });
601
+
602
+ const handleReloadAction = useCallback(
603
+ async (reloadAction: ReloadAction) => {
604
+ if (!reloadAction.enabled) {
605
+ // Pressing a disabled action must SAY why. A row that does nothing
606
+ // is the same defect as a spinner that never resolves.
607
+ setToast(reloadAction.disabledReason || 'Reload is unavailable right now.');
608
+ setError(reloadAction.disabledReason || null);
609
+ return;
610
+ }
611
+ setReloadingId(reloadAction.id);
612
+ setAction('hot-reloading');
613
+ setError(null);
614
+ setProgress(0);
615
+ setToast(`${reloadAction.label}…`);
616
+ try {
617
+ await loadSelectedMachine();
618
+ const selected = await YaverFeedback.getSelectedRemoteDevice();
619
+ if (!selected) {
620
+ YaverFeedback.showMachinePicker();
621
+ throw new Error('No machine selected. Pick a machine and try again.');
622
+ }
623
+ if (selected.needsAuth) {
624
+ YaverFeedback.showMachinePicker();
625
+ throw new Error('Selected machine needs pairing again.');
626
+ }
627
+ if (!selected.isOnline) {
628
+ throw new Error('Selected machine is offline. Start `yaver serve` on it first.');
629
+ }
630
+
631
+ let ackMessage = `${reloadAction.label} requested.`;
632
+ await runWithReconnect(async (client) => {
633
+ const ack = await client.reloadWithMode(reloadAction.mode, devSnapshot);
634
+ ackMessage = ack.message;
635
+ setToast(ack.message);
636
+ setProgress(0.2);
637
+ });
638
+ setToast(ackMessage);
639
+ if (reloadAction.mode === 'bundle') closeSoon(2500);
640
+ } catch (err: unknown) {
641
+ const message = err instanceof Error ? err.message : String(err);
642
+ setError(message);
643
+ setToast(
644
+ message.toLowerCase().indexOf('session expired') >= 0
645
+ ? 'Session expired. Sign in again.'
646
+ : message,
647
+ );
648
+ await loadSelectedMachine();
649
+ setProgress(null);
650
+ } finally {
651
+ if (mountedRef.current) {
652
+ setAction('idle');
653
+ setReloadingId(null);
654
+ void refreshDevSnapshot();
655
+ }
656
+ }
657
+ },
658
+ [closeSoon, devSnapshot, loadSelectedMachine, refreshDevSnapshot, runWithReconnect],
659
+ );
660
+
661
+ /** Kept for the legacy one-tap path (BlackBox command, chat Reload button). */
546
662
  const handleHotReload = useCallback(async () => {
547
663
  setAction('hot-reloading');
548
664
  setError(null);
@@ -661,19 +777,36 @@ export const FeedbackModal: React.FC = () => {
661
777
  try {
662
778
  const { Dimensions } = require('react-native');
663
779
  const { width, height } = Dimensions.get('window');
780
+ const cfg = YaverFeedback.getConfig();
781
+ const identity = resolveReportIdentity({
782
+ projectName: cfg?.projectName,
783
+ bundleId: cfg?.bundleId,
784
+ surface: cfg?.surface,
785
+ surfaces: cfg?.surfaces,
786
+ stack: cfg?.stack,
787
+ stacks: cfg?.stacks,
788
+ testSurfaces: cfg?.testSurfaces,
789
+ feedbackSdk: cfg?.feedbackSdk,
790
+ feedbackTransport: cfg?.feedbackTransport,
791
+ voiceCapabilities: cfg?.voiceCapabilities,
792
+ sttProvider: cfg?.sttProvider,
793
+ ttsProvider: cfg?.ttsProvider,
794
+ });
664
795
  const deviceInfo: DeviceInfo = {
665
796
  platform: Platform.OS,
666
797
  osVersion: String(Platform.Version),
667
798
  model: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
668
799
  screenWidth: width,
669
800
  screenHeight: height,
801
+ appName: identity.appName,
670
802
  };
671
803
  const capturedErrors = YaverFeedback.getCapturedErrors();
672
804
  const bundle: FeedbackBundle = {
673
805
  metadata: {
674
806
  timestamp: new Date().toISOString(),
675
- device: deviceInfo,
676
- app: {},
807
+ deviceInfo,
808
+ app: identity.app,
809
+ project: identity.project,
677
810
  userNote: '[Screenshot + Fix]',
678
811
  },
679
812
  screenshots: [path],
@@ -1115,16 +1248,36 @@ export const FeedbackModal: React.FC = () => {
1115
1248
  </View>
1116
1249
  </View>
1117
1250
 
1118
- {/* 1. Hot Reload — the common path */}
1119
- <ActionRow
1120
- label={
1121
- action === 'hot-reloading' ? 'Reloading…' : 'Hot Reload'
1122
- }
1123
- tint="#fbbf24"
1124
- onPress={handleHotReload}
1125
- disabled={busy}
1126
- busy={action === 'hot-reloading'}
1127
- />
1251
+ {/* 1. Reload — Hot / Full / Rebuild Bundle.
1252
+ Rendered from the shared decision seam, so a production
1253
+ build (__DEV__ false) renders nothing here at all, and a
1254
+ blocked action shows greyed WITH its reason underneath
1255
+ rather than vanishing. */}
1256
+ {availableReloadActions.map((reloadAction) => (
1257
+ <View key={reloadAction.id} style={styles.reloadRow}>
1258
+ <ActionRow
1259
+ label={
1260
+ reloadingId === reloadAction.id
1261
+ ? `${reloadAction.label}…`
1262
+ : reloadAction.label
1263
+ }
1264
+ tint={reloadAction.id === 'rebuild' ? '#38bdf8' : '#fbbf24'}
1265
+ onPress={() => {
1266
+ void handleReloadAction(reloadAction);
1267
+ }}
1268
+ // Never `disabled` at the Pressable level for a blocked
1269
+ // action: we WANT the tap so we can say why. Only a
1270
+ // genuinely busy modal blocks the press.
1271
+ disabled={busy && reloadingId !== reloadAction.id}
1272
+ busy={reloadingId === reloadAction.id}
1273
+ />
1274
+ <Text style={styles.reloadHint}>
1275
+ {reloadAction.enabled
1276
+ ? reloadAction.hint
1277
+ : reloadAction.disabledReason}
1278
+ </Text>
1279
+ </View>
1280
+ ))}
1128
1281
 
1129
1282
  {/* 3. Vibing — expands to an input box on first tap
1130
1283
  so the user says WHAT they want to vibe on, just
@@ -1293,6 +1446,15 @@ const ActionRow: React.FC<ActionRowProps> = ({
1293
1446
  );
1294
1447
 
1295
1448
  const styles = StyleSheet.create({
1449
+ reloadRow: {
1450
+ gap: 4,
1451
+ },
1452
+ reloadHint: {
1453
+ color: '#8b8b93',
1454
+ fontSize: 11,
1455
+ lineHeight: 15,
1456
+ paddingHorizontal: 4,
1457
+ },
1296
1458
  vibeInputRow: {
1297
1459
  backgroundColor: 'rgba(129,140,248,0.08)',
1298
1460
  borderColor: 'rgba(129,140,248,0.4)',