yaver-feedback-react-native 0.8.3 → 0.8.6

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.
@@ -72,6 +72,7 @@ export const FeedbackModal: React.FC = () => {
72
72
  // is our guaranteed UI for bringing the icon back — we surface a
73
73
  // small "Show quick icon" row when this is true.
74
74
  const [quickIconHidden, setQuickIconHidden] = useState(false);
75
+ const [runnerAuthModal, setRunnerAuthModal] = useState<string | null>(null);
75
76
  // Vibing-input mode: same expand-on-tap pattern as email login.
76
77
  // Tap "Vibing" once → the button reveals an input + Send; that lets
77
78
  // the user say WHAT they want to vibe on instead of firing a canned
@@ -82,6 +83,7 @@ export const FeedbackModal: React.FC = () => {
82
83
  const [lastVibeTaskId, setLastVibeTaskId] = useState<string | null>(null);
83
84
  const [quickIconColorPreset, setQuickIconColorPreset] =
84
85
  useState<QuickIconColorPreset | null>(null);
86
+ const [keyboardInset, setKeyboardInset] = useState(0);
85
87
  const [machineCard, setMachineCard] = useState<MachineCardState>({
86
88
  device: null,
87
89
  reachable: null,
@@ -256,6 +258,26 @@ export const FeedbackModal: React.FC = () => {
256
258
  return () => clearInterval(interval);
257
259
  }, [loadSelectedMachine, visible]);
258
260
 
261
+ useEffect(() => {
262
+ if (!visible) {
263
+ setKeyboardInset(0);
264
+ return;
265
+ }
266
+
267
+ const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
268
+ const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
269
+ const showSub = Keyboard.addListener(showEvent, (event) => {
270
+ setKeyboardInset(event.endCoordinates?.height ?? 0);
271
+ });
272
+ const hideSub = Keyboard.addListener(hideEvent, () => {
273
+ setKeyboardInset(0);
274
+ });
275
+ return () => {
276
+ showSub.remove();
277
+ hideSub.remove();
278
+ };
279
+ }, [visible]);
280
+
259
281
  const closeSoon = useCallback((delayMs = 1200) => {
260
282
  setTimeout(() => {
261
283
  if (mountedRef.current) setVisible(false);
@@ -569,6 +591,7 @@ export const FeedbackModal: React.FC = () => {
569
591
  <Pressable style={styles.overlay} onPress={handleClose}>
570
592
  <KeyboardAvoidingView
571
593
  behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
594
+ keyboardVerticalOffset={Platform.OS === 'ios' ? 12 : 0}
572
595
  style={styles.kbAvoider}
573
596
  pointerEvents="box-none"
574
597
  >
@@ -581,8 +604,14 @@ export const FeedbackModal: React.FC = () => {
581
604
  >
582
605
  <ScrollView
583
606
  style={styles.scroll}
584
- contentContainerStyle={styles.scrollContent}
607
+ contentContainerStyle={[
608
+ styles.scrollContent,
609
+ showVibeInput && keyboardInset > 0
610
+ ? { paddingBottom: 8 + keyboardInset }
611
+ : null,
612
+ ]}
585
613
  keyboardShouldPersistTaps="handled"
614
+ keyboardDismissMode={Platform.OS === 'ios' ? 'interactive' : 'on-drag'}
586
615
  >
587
616
  <View style={styles.header}>
588
617
  <Text style={styles.title}>Send Feedback</Text>
@@ -781,6 +810,41 @@ export const FeedbackModal: React.FC = () => {
781
810
  busy={action === 'capturing'}
782
811
  />
783
812
 
813
+ {/* Remote sign-in buttons — trigger codex/claude device-auth
814
+ on the selected agent without leaving the app. Opens a
815
+ small native modal showing the verification URL + 8-char
816
+ code the user enters in any browser. No API keys. */}
817
+ <View style={runnerAuthRowStyles.container}>
818
+ <Pressable
819
+ onPress={() => setRunnerAuthModal('codex')}
820
+ disabled={busy}
821
+ style={({ pressed }) => [
822
+ runnerAuthRowStyles.button,
823
+ pressed && runnerAuthRowStyles.buttonPressed,
824
+ busy && runnerAuthRowStyles.buttonDisabled,
825
+ ]}
826
+ accessibilityRole="button"
827
+ accessibilityLabel="Remote sign-in Codex"
828
+ >
829
+ <Text style={runnerAuthRowStyles.buttonLabel}>Remote sign-in</Text>
830
+ <Text style={runnerAuthRowStyles.buttonName}>Codex</Text>
831
+ </Pressable>
832
+ <Pressable
833
+ onPress={() => setRunnerAuthModal('claude')}
834
+ disabled={busy}
835
+ style={({ pressed }) => [
836
+ runnerAuthRowStyles.button,
837
+ pressed && runnerAuthRowStyles.buttonPressed,
838
+ busy && runnerAuthRowStyles.buttonDisabled,
839
+ ]}
840
+ accessibilityRole="button"
841
+ accessibilityLabel="Remote sign-in Claude"
842
+ >
843
+ <Text style={runnerAuthRowStyles.buttonLabel}>Remote sign-in</Text>
844
+ <Text style={runnerAuthRowStyles.buttonName}>Claude</Text>
845
+ </Pressable>
846
+ </View>
847
+
784
848
  {progress !== null && (
785
849
  <View style={styles.progressTrack}>
786
850
  <View
@@ -811,6 +875,12 @@ export const FeedbackModal: React.FC = () => {
811
875
  </Pressable>
812
876
  </Modal>
813
877
  )}
878
+ {runnerAuthModal ? (
879
+ <RunnerAuthNativeModal
880
+ runner={runnerAuthModal}
881
+ onClose={() => setRunnerAuthModal(null)}
882
+ />
883
+ ) : null}
814
884
  </>
815
885
  );
816
886
  };
@@ -1167,3 +1237,281 @@ const styles = StyleSheet.create({
1167
1237
  opacity: 0.7,
1168
1238
  },
1169
1239
  });
1240
+
1241
+ /**
1242
+ * Minimal native modal for the codex/claude remote sign-in flow. Opens
1243
+ * the device-auth session on the connected agent, surfaces the
1244
+ * verification URL + one-time code, polls every 1.5 s, and turns green
1245
+ * the moment the CLI writes its auth.json. No API keys, no SSH.
1246
+ */
1247
+ const RunnerAuthNativeModal: React.FC<{
1248
+ runner: string;
1249
+ onClose: () => void;
1250
+ }> = ({ runner, onClose }) => {
1251
+ const [session, setSession] = useState<import('./types').RunnerBrowserAuthSession | null>(null);
1252
+ const [startError, setStartError] = useState<string | null>(null);
1253
+ const [copied, setCopied] = useState(false);
1254
+ const startedRef = useRef(false);
1255
+
1256
+ useEffect(() => {
1257
+ if (startedRef.current) return;
1258
+ startedRef.current = true;
1259
+ (async () => {
1260
+ try {
1261
+ const s = await YaverFeedback.startRunnerBrowserAuth(runner);
1262
+ setSession(s);
1263
+ } catch (err) {
1264
+ setStartError(err instanceof Error ? err.message : String(err));
1265
+ }
1266
+ })();
1267
+ }, [runner]);
1268
+
1269
+ useEffect(() => {
1270
+ if (!session) return;
1271
+ if (['completed', 'failed', 'cancelled'].includes(session.status)) return;
1272
+ const iv = setInterval(async () => {
1273
+ try {
1274
+ const s = await YaverFeedback.getRunnerBrowserAuthStatus(session.id);
1275
+ setSession(s);
1276
+ } catch {
1277
+ // keep polling
1278
+ }
1279
+ }, 1500);
1280
+ return () => clearInterval(iv);
1281
+ }, [session?.id, session?.status]);
1282
+
1283
+ const terminal = session && ['completed', 'failed', 'cancelled'].includes(session.status);
1284
+ const runnerLabel = runner === 'codex' ? 'OpenAI Codex' : runner === 'claude' ? 'Claude Code' : runner;
1285
+
1286
+ const handleClose = () => {
1287
+ if (session && !terminal) {
1288
+ YaverFeedback.cancelRunnerBrowserAuth(session.id).catch(() => {});
1289
+ }
1290
+ onClose();
1291
+ };
1292
+
1293
+ const copyCode = () => {
1294
+ if (!session?.code) return;
1295
+ try {
1296
+ // Avoid a hard Clipboard dep — host app can polyfill.
1297
+ const Clipboard = require('react-native').Clipboard;
1298
+ if (Clipboard?.setString) {
1299
+ Clipboard.setString(session.code);
1300
+ setCopied(true);
1301
+ setTimeout(() => setCopied(false), 1500);
1302
+ }
1303
+ } catch {
1304
+ // best-effort — code is visible on screen regardless
1305
+ }
1306
+ };
1307
+
1308
+ const openUrl = () => {
1309
+ if (!session?.openUrl) return;
1310
+ try {
1311
+ const { Linking } = require('react-native');
1312
+ Linking.openURL(session.openUrl).catch(() => {});
1313
+ } catch {
1314
+ /* ignore */
1315
+ }
1316
+ };
1317
+
1318
+ return (
1319
+ <Modal visible={true} transparent animationType="fade" onRequestClose={handleClose}>
1320
+ <View style={runnerAuthModalStyles.overlay}>
1321
+ <View style={runnerAuthModalStyles.card}>
1322
+ <View style={runnerAuthModalStyles.header}>
1323
+ <View style={{ flex: 1 }}>
1324
+ <Text style={runnerAuthModalStyles.title}>Sign in to {runnerLabel}</Text>
1325
+ <Text style={runnerAuthModalStyles.subtitle}>
1326
+ Opens a one-time URL + code. Enter it in any browser.
1327
+ </Text>
1328
+ </View>
1329
+ <Pressable onPress={handleClose} hitSlop={10}>
1330
+ <Text style={runnerAuthModalStyles.close}>×</Text>
1331
+ </Pressable>
1332
+ </View>
1333
+
1334
+ {startError ? (
1335
+ <View style={runnerAuthModalStyles.errorBox}>
1336
+ <Text style={runnerAuthModalStyles.errorTitle}>Couldn't start</Text>
1337
+ <Text style={runnerAuthModalStyles.errorBody}>{startError}</Text>
1338
+ </View>
1339
+ ) : !session ? (
1340
+ <Text style={runnerAuthModalStyles.dim}>
1341
+ Starting the sign-in flow on the remote machine…
1342
+ </Text>
1343
+ ) : session.status === 'completed' ? (
1344
+ <View style={runnerAuthModalStyles.successBox}>
1345
+ <Text style={runnerAuthModalStyles.successTitle}>✓ Signed in</Text>
1346
+ <Text style={runnerAuthModalStyles.successBody}>
1347
+ {session.detail || 'Auth stored on the remote machine.'}
1348
+ </Text>
1349
+ </View>
1350
+ ) : session.status === 'failed' || session.status === 'cancelled' ? (
1351
+ <View style={runnerAuthModalStyles.errorBox}>
1352
+ <Text style={runnerAuthModalStyles.errorTitle}>
1353
+ {session.status === 'cancelled' ? 'Cancelled' : 'Failed'}
1354
+ </Text>
1355
+ <Text style={runnerAuthModalStyles.errorBody}>
1356
+ {session.error || session.detail || 'The CLI exited before sign-in completed.'}
1357
+ </Text>
1358
+ </View>
1359
+ ) : (
1360
+ <View>
1361
+ {session.openUrl ? (
1362
+ <Pressable onPress={openUrl} style={runnerAuthModalStyles.urlBox}>
1363
+ <Text style={runnerAuthModalStyles.urlText} numberOfLines={2}>
1364
+ ↗ {session.openUrl}
1365
+ </Text>
1366
+ </Pressable>
1367
+ ) : (
1368
+ <Text style={runnerAuthModalStyles.dim}>
1369
+ Waiting for verification URL from the remote CLI…
1370
+ </Text>
1371
+ )}
1372
+ {session.code ? (
1373
+ <View style={{ marginTop: 12 }}>
1374
+ <Text style={runnerAuthModalStyles.codeLabel}>ENTER THIS CODE</Text>
1375
+ <Pressable onPress={copyCode} style={runnerAuthModalStyles.codeBox}>
1376
+ <Text style={runnerAuthModalStyles.codeText}>{session.code}</Text>
1377
+ <Text style={runnerAuthModalStyles.codeHint}>
1378
+ {copied ? 'copied' : 'tap to copy'}
1379
+ </Text>
1380
+ </Pressable>
1381
+ </View>
1382
+ ) : null}
1383
+ <Text style={runnerAuthModalStyles.phishingHint}>
1384
+ Device codes are a common phishing target. Never share this code. This dialog
1385
+ turns green automatically once sign-in completes.
1386
+ </Text>
1387
+ </View>
1388
+ )}
1389
+ </View>
1390
+ </View>
1391
+ </Modal>
1392
+ );
1393
+ };
1394
+
1395
+ const runnerAuthRowStyles = StyleSheet.create({
1396
+ container: {
1397
+ flexDirection: 'row',
1398
+ gap: 8,
1399
+ marginTop: 8,
1400
+ flexWrap: 'wrap',
1401
+ },
1402
+ button: {
1403
+ flexGrow: 1,
1404
+ flexBasis: 0,
1405
+ minWidth: 120,
1406
+ paddingHorizontal: 12,
1407
+ paddingVertical: 10,
1408
+ borderRadius: 10,
1409
+ borderWidth: 1,
1410
+ borderColor: 'rgba(148,163,184,0.22)',
1411
+ backgroundColor: 'rgba(15,23,42,0.6)',
1412
+ },
1413
+ buttonPressed: { opacity: 0.7 },
1414
+ buttonDisabled: { opacity: 0.4 },
1415
+ buttonLabel: {
1416
+ fontSize: 10,
1417
+ color: '#94a3b8',
1418
+ textTransform: 'uppercase',
1419
+ letterSpacing: 0.8,
1420
+ },
1421
+ buttonName: {
1422
+ marginTop: 2,
1423
+ fontSize: 14,
1424
+ fontWeight: '600',
1425
+ color: '#f1f5f9',
1426
+ },
1427
+ });
1428
+
1429
+ const runnerAuthModalStyles = StyleSheet.create({
1430
+ overlay: {
1431
+ flex: 1,
1432
+ justifyContent: 'center',
1433
+ alignItems: 'center',
1434
+ backgroundColor: 'rgba(2,6,23,0.75)',
1435
+ padding: 16,
1436
+ },
1437
+ card: {
1438
+ width: '100%',
1439
+ maxWidth: 420,
1440
+ backgroundColor: '#0f172a',
1441
+ borderRadius: 14,
1442
+ borderWidth: 1,
1443
+ borderColor: 'rgba(148,163,184,0.18)',
1444
+ padding: 18,
1445
+ },
1446
+ header: {
1447
+ flexDirection: 'row',
1448
+ alignItems: 'flex-start',
1449
+ marginBottom: 12,
1450
+ },
1451
+ title: { color: '#f1f5f9', fontSize: 16, fontWeight: '600' },
1452
+ subtitle: { color: '#94a3b8', fontSize: 11, marginTop: 2 },
1453
+ close: { color: '#94a3b8', fontSize: 22, lineHeight: 22, paddingHorizontal: 4 },
1454
+ dim: {
1455
+ color: '#94a3b8',
1456
+ fontSize: 12,
1457
+ padding: 12,
1458
+ borderRadius: 10,
1459
+ borderWidth: 1,
1460
+ borderColor: 'rgba(148,163,184,0.2)',
1461
+ backgroundColor: 'rgba(15,23,42,0.6)',
1462
+ },
1463
+ errorBox: {
1464
+ padding: 12,
1465
+ borderRadius: 10,
1466
+ borderWidth: 1,
1467
+ borderColor: 'rgba(248,113,113,0.35)',
1468
+ backgroundColor: 'rgba(248,113,113,0.1)',
1469
+ },
1470
+ errorTitle: { color: '#fca5a5', fontWeight: '600', marginBottom: 4, fontSize: 13 },
1471
+ errorBody: { color: '#fca5a5', fontSize: 12 },
1472
+ successBox: {
1473
+ padding: 14,
1474
+ borderRadius: 10,
1475
+ borderWidth: 1,
1476
+ borderColor: 'rgba(34,197,94,0.35)',
1477
+ backgroundColor: 'rgba(34,197,94,0.1)',
1478
+ },
1479
+ successTitle: { color: '#4ade80', fontSize: 14, fontWeight: '600', marginBottom: 4 },
1480
+ successBody: { color: '#86efac', fontSize: 12 },
1481
+ urlBox: {
1482
+ padding: 12,
1483
+ borderRadius: 10,
1484
+ borderWidth: 1,
1485
+ borderColor: 'rgba(99,102,241,0.35)',
1486
+ backgroundColor: 'rgba(99,102,241,0.1)',
1487
+ },
1488
+ urlText: { color: '#c7d2fe', fontSize: 13 },
1489
+ codeLabel: {
1490
+ fontSize: 10,
1491
+ fontWeight: '600',
1492
+ color: '#94a3b8',
1493
+ letterSpacing: 0.8,
1494
+ marginBottom: 4,
1495
+ },
1496
+ codeBox: {
1497
+ padding: 14,
1498
+ borderRadius: 10,
1499
+ borderWidth: 1,
1500
+ borderColor: 'rgba(148,163,184,0.22)',
1501
+ backgroundColor: 'rgba(15,23,42,0.8)',
1502
+ alignItems: 'center',
1503
+ },
1504
+ codeText: {
1505
+ color: '#f1f5f9',
1506
+ fontSize: 22,
1507
+ letterSpacing: 6,
1508
+ fontFamily: 'Menlo',
1509
+ },
1510
+ codeHint: { color: '#64748b', fontSize: 10, marginTop: 4, textTransform: 'uppercase' },
1511
+ phishingHint: {
1512
+ color: '#475569',
1513
+ fontSize: 10,
1514
+ marginTop: 12,
1515
+ lineHeight: 14,
1516
+ },
1517
+ });
@@ -236,9 +236,8 @@ export const YaverLoginScreen: React.FC<YaverLoginScreenProps> = ({
236
236
  contentContainerStyle={styles.scrollContainer}
237
237
  keyboardShouldPersistTaps="handled"
238
238
  >
239
- <View style={styles.header}>
240
- <Text style={styles.logo}>Yaver</Text>
241
- <Text style={styles.subtitle}>Sign in to send feedback</Text>
239
+ <View style={styles.topBar}>
240
+ <View style={styles.topBarSpacer} />
242
241
  {onCancel && (
243
242
  <Pressable onPress={onCancel} style={styles.cancel}>
244
243
  <Text style={styles.cancelText}>Cancel</Text>
@@ -246,6 +245,11 @@ export const YaverLoginScreen: React.FC<YaverLoginScreenProps> = ({
246
245
  )}
247
246
  </View>
248
247
 
248
+ <View style={styles.header}>
249
+ <Text style={styles.logo}>Yaver</Text>
250
+ <Text style={styles.subtitle}>Sign in to send feedback</Text>
251
+ </View>
252
+
249
253
  <View style={styles.buttons}>
250
254
  {Platform.OS === 'ios'
251
255
  ? renderProvider('apple', 'Continue with Apple', handleApple)
@@ -391,11 +395,21 @@ const styles = StyleSheet.create({
391
395
  paddingHorizontal: 24,
392
396
  justifyContent: 'center',
393
397
  },
398
+ topBar: {
399
+ minHeight: 32,
400
+ marginBottom: 24,
401
+ flexDirection: 'row',
402
+ alignItems: 'center',
403
+ justifyContent: 'space-between',
404
+ },
405
+ topBarSpacer: {
406
+ width: 56,
407
+ },
394
408
  header: { alignItems: 'center', marginBottom: 40 },
395
409
  logo: { fontSize: 44, fontWeight: '800', color: '#e0e0e0', letterSpacing: -1 },
396
410
  subtitle: { fontSize: 15, color: '#9ca3af', marginTop: 6 },
397
- cancel: { position: 'absolute', right: 0, top: 0, padding: 8 },
398
- cancelText: { color: '#9ca3af', fontSize: 14 },
411
+ cancel: { minWidth: 56, alignItems: 'flex-end', paddingVertical: 8 },
412
+ cancelText: { color: '#9ca3af', fontSize: 14, fontWeight: '500' },
399
413
  buttons: { gap: 12 },
400
414
  button: {
401
415
  backgroundColor: 'rgba(255,255,255,0.06)',
@@ -11,8 +11,10 @@ import {
11
11
  } from 'react-native';
12
12
  import {
13
13
  DeviceList,
14
+ DeviceReachability,
14
15
  RemoteDevice,
15
16
  listReachableDevices,
17
+ probeDeviceReachability,
16
18
  saveSelectedDeviceId,
17
19
  } from './auth';
18
20
  import { PairDeviceModal } from './PairDeviceModal';
@@ -45,6 +47,7 @@ export const YaverMachinePickerScreen: React.FC<YaverMachinePickerProps> = ({
45
47
  const [error, setError] = useState<string | null>(null);
46
48
  const [list, setList] = useState<DeviceList>({ owned: [], shared: [] });
47
49
  const [pairingDevice, setPairingDevice] = useState<RemoteDevice | null>(null);
50
+ const [reachability, setReachability] = useState<Record<string, DeviceReachability | undefined>>({});
48
51
 
49
52
  const load = useCallback(async (silent = false) => {
50
53
  if (!silent) setLoading(true);
@@ -52,6 +55,25 @@ export const YaverMachinePickerScreen: React.FC<YaverMachinePickerProps> = ({
52
55
  try {
53
56
  const result = await listReachableDevices(token);
54
57
  setList(result);
58
+ setReachability({});
59
+ void (async () => {
60
+ const devices = [...result.owned, ...result.shared];
61
+ const settled = await Promise.allSettled(
62
+ devices.map(async (device) => ({
63
+ deviceId: device.deviceId,
64
+ result: await probeDeviceReachability(device),
65
+ })),
66
+ );
67
+ setReachability((prev) => {
68
+ const next = { ...prev };
69
+ for (const entry of settled) {
70
+ if (entry.status === 'fulfilled') {
71
+ next[entry.value.deviceId] = entry.value.result;
72
+ }
73
+ }
74
+ return next;
75
+ });
76
+ })();
55
77
  if (result.owned.length === 0 && result.shared.length === 0) {
56
78
  setError(
57
79
  'No machines found yet. If you do not have your own computer, redeem a host invite code first. Otherwise run `yaver auth` + `yaver serve` on your machine.',
@@ -79,12 +101,19 @@ export const YaverMachinePickerScreen: React.FC<YaverMachinePickerProps> = ({
79
101
  setPairingDevice(device);
80
102
  return;
81
103
  }
104
+ const direct = await probeDeviceReachability(device);
105
+ if (!direct.reachable && !device.needsAuth) {
106
+ setError('Selected machine is not responding. Start `yaver serve` on it and try again.');
107
+ setReachability((prev) => ({ ...prev, [device.deviceId]: direct }));
108
+ return;
109
+ }
82
110
  await saveSelectedDeviceId(device.deviceId);
83
111
  onPick(device);
84
112
  };
85
113
 
86
114
  const renderDevice = (device: RemoteDevice) => {
87
115
  const selected = device.deviceId === currentDeviceId;
116
+ const probe = reachability[device.deviceId];
88
117
  // Trust Convex's `isOnline` — the backend already gates it on a
89
118
  // fresh 90 s heartbeat (see backend/convex/devices.ts
90
119
  // deriveIsOnline). Re-checking on the client produced false
@@ -95,18 +124,28 @@ export const YaverMachinePickerScreen: React.FC<YaverMachinePickerProps> = ({
95
124
  // healthy — a separate concern from "can I reach this machine?"
96
125
  // Mobile app surfaces runner issues via a separate badge, not
97
126
  // this dot. Picker's job is reachability, nothing more.
98
- const healthColor = !device.isOnline
99
- ? '#ef4444'
100
- : device.needsAuth
101
- ? '#f59e0b'
102
- : '#22c55e';
127
+ const effectivelyReachable = probe?.reachable === true;
128
+ const explicitlyOffline = probe?.reachable === false;
129
+ const healthColor = device.needsAuth
130
+ ? '#f59e0b'
131
+ : effectivelyReachable
132
+ ? '#22c55e'
133
+ : explicitlyOffline || !device.isOnline
134
+ ? '#ef4444'
135
+ : '#22c55e';
103
136
  // Derive a single short status phrase the user can act on.
104
137
  let statusLine = device.platform;
105
- if (!device.isOnline) {
138
+ if (probe === undefined) {
139
+ statusLine = 'Checking connection…';
140
+ } else if (!device.isOnline && effectivelyReachable) {
141
+ statusLine = 'Reachable now — waiting for cloud status to refresh';
142
+ } else if (!device.isOnline) {
106
143
  statusLine = 'Offline — start `yaver serve` on the Mac';
107
144
  } else if (device.needsAuth) {
108
145
  statusLine =
109
146
  'Needs pairing — open the Yaver app to adopt this machine';
147
+ } else if (explicitlyOffline) {
148
+ statusLine = 'Agent not responding on this machine';
110
149
  } else if (device.runnerDown) {
111
150
  statusLine = 'Runner down — restart the coding agent on the Mac';
112
151
  } else {
package/src/P2PClient.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Platform } from 'react-native';
2
- import { FeedbackBundle, TestSession, VoiceCapability } from './types';
2
+ import { FeedbackBundle, RunnerBrowserAuthSession, TestSession, VoiceCapability } from './types';
3
3
 
4
4
  export interface FeedbackEvent {
5
5
  type: string;
@@ -117,10 +117,18 @@ function friendlyReloadError(status: number, body: string): string {
117
117
  export class P2PClient {
118
118
  private baseUrl: string;
119
119
  private authToken: string;
120
+ /**
121
+ * Shared relay password. Required when baseUrl points through the
122
+ * Yaver managed relay (e.g. https://public.yaver.io/d/<deviceId>) —
123
+ * the relay rejects unauthenticated requests with 401. Attached as
124
+ * X-Relay-Password on every agent request.
125
+ */
126
+ private relayPassword: string;
120
127
 
121
- constructor(baseUrl: string, authToken: string) {
128
+ constructor(baseUrl: string, authToken: string, relayPassword: string = '') {
122
129
  this.baseUrl = baseUrl.replace(/\/$/, '');
123
130
  this.authToken = authToken;
131
+ this.relayPassword = relayPassword;
124
132
  }
125
133
 
126
134
  /** Update the base URL (e.g. after re-discovery). */
@@ -133,6 +141,56 @@ export class P2PClient {
133
141
  this.authToken = token;
134
142
  }
135
143
 
144
+ /** Update the relay password (used for managed-relay baseUrls). */
145
+ setRelayPassword(password: string): void {
146
+ this.relayPassword = password;
147
+ }
148
+
149
+ /** Merge in Authorization + (optional) X-Relay-Password on top of a header block. */
150
+ private authHeaders(extra: Record<string, string> = {}): Record<string, string> {
151
+ const h: Record<string, string> = { ...extra };
152
+ if (this.authToken) h.Authorization = `Bearer ${this.authToken}`;
153
+ if (this.relayPassword) h['X-Relay-Password'] = this.relayPassword;
154
+ return h;
155
+ }
156
+
157
+ /**
158
+ * Start a remote browser-style sign-in for a runner (codex --device-auth
159
+ * / claude auth login --console). Returns a session id; callers poll
160
+ * getRunnerBrowserAuthStatus to surface the verification URL + one-time
161
+ * code. No API keys involved — the CLI writes its own auth.json once
162
+ * the user completes the flow in any browser.
163
+ */
164
+ async startRunnerBrowserAuth(runner: string): Promise<RunnerBrowserAuthSession> {
165
+ const resp = await fetch(`${this.baseUrl}/runner-auth/browser/start`, {
166
+ method: 'POST',
167
+ headers: this.authHeaders({ 'Content-Type': 'application/json' }),
168
+ body: JSON.stringify({ runner }),
169
+ });
170
+ if (!resp.ok) {
171
+ const text = await resp.text().catch(() => '');
172
+ throw new Error(`startRunnerBrowserAuth(${runner}) HTTP ${resp.status}: ${text}`);
173
+ }
174
+ const data = await resp.json();
175
+ return data.session as RunnerBrowserAuthSession;
176
+ }
177
+
178
+ async getRunnerBrowserAuthStatus(sessionId: string): Promise<RunnerBrowserAuthSession> {
179
+ const url = `${this.baseUrl}/runner-auth/browser/status?id=${encodeURIComponent(sessionId)}`;
180
+ const resp = await fetch(url, { headers: this.authHeaders() });
181
+ if (!resp.ok) {
182
+ const text = await resp.text().catch(() => '');
183
+ throw new Error(`getRunnerBrowserAuthStatus HTTP ${resp.status}: ${text}`);
184
+ }
185
+ const data = await resp.json();
186
+ return data.session as RunnerBrowserAuthSession;
187
+ }
188
+
189
+ async cancelRunnerBrowserAuth(sessionId: string): Promise<void> {
190
+ const url = `${this.baseUrl}/runner-auth/browser/cancel?id=${encodeURIComponent(sessionId)}`;
191
+ try { await fetch(url, { method: 'POST', headers: this.authHeaders() }); } catch { /* best-effort */ }
192
+ }
193
+
136
194
  /** Health check — returns true if the agent is reachable. */
137
195
  async health(): Promise<boolean> {
138
196
  try {
@@ -682,9 +740,7 @@ export class P2PClient {
682
740
  private async request(method: string, path: string): Promise<Response> {
683
741
  const response = await fetch(`${this.baseUrl}${path}`, {
684
742
  method,
685
- headers: {
686
- Authorization: `Bearer ${this.authToken}`,
687
- },
743
+ headers: this.authHeaders(),
688
744
  });
689
745
 
690
746
  if (!response.ok) {