yaver-feedback-react-native 0.8.3 → 0.8.4

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.
@@ -63,6 +63,7 @@ const FeedbackModal = () => {
63
63
  const [vibePrompt, setVibePrompt] = (0, react_1.useState)('');
64
64
  const [lastVibeTaskId, setLastVibeTaskId] = (0, react_1.useState)(null);
65
65
  const [quickIconColorPreset, setQuickIconColorPreset] = (0, react_1.useState)(null);
66
+ const [keyboardInset, setKeyboardInset] = (0, react_1.useState)(0);
66
67
  const [machineCard, setMachineCard] = (0, react_1.useState)({
67
68
  device: null,
68
69
  reachable: null,
@@ -231,6 +232,24 @@ const FeedbackModal = () => {
231
232
  }, 5000);
232
233
  return () => clearInterval(interval);
233
234
  }, [loadSelectedMachine, visible]);
235
+ (0, react_1.useEffect)(() => {
236
+ if (!visible) {
237
+ setKeyboardInset(0);
238
+ return;
239
+ }
240
+ const showEvent = react_native_1.Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
241
+ const hideEvent = react_native_1.Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
242
+ const showSub = react_native_1.Keyboard.addListener(showEvent, (event) => {
243
+ setKeyboardInset(event.endCoordinates?.height ?? 0);
244
+ });
245
+ const hideSub = react_native_1.Keyboard.addListener(hideEvent, () => {
246
+ setKeyboardInset(0);
247
+ });
248
+ return () => {
249
+ showSub.remove();
250
+ hideSub.remove();
251
+ };
252
+ }, [visible]);
234
253
  const closeSoon = (0, react_1.useCallback)((delayMs = 1200) => {
235
254
  setTimeout(() => {
236
255
  if (mountedRef.current)
@@ -515,12 +534,17 @@ const FeedbackModal = () => {
515
534
  <QuickActionIcon_1.QuickActionIcon />
516
535
  {visible && (<react_native_1.Modal visible={visible} animationType="slide" transparent onRequestClose={handleClose}>
517
536
  <react_native_1.Pressable style={styles.overlay} onPress={handleClose}>
518
- <react_native_1.KeyboardAvoidingView behavior={react_native_1.Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.kbAvoider} pointerEvents="box-none">
537
+ <react_native_1.KeyboardAvoidingView behavior={react_native_1.Platform.OS === 'ios' ? 'padding' : 'height'} keyboardVerticalOffset={react_native_1.Platform.OS === 'ios' ? 12 : 0} style={styles.kbAvoider} pointerEvents="box-none">
519
538
  <react_native_1.Pressable style={styles.modal} onPress={(e) => {
520
539
  e.stopPropagation();
521
540
  react_native_1.Keyboard.dismiss();
522
541
  }}>
523
- <react_native_1.ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
542
+ <react_native_1.ScrollView style={styles.scroll} contentContainerStyle={[
543
+ styles.scrollContent,
544
+ showVibeInput && keyboardInset > 0
545
+ ? { paddingBottom: 8 + keyboardInset }
546
+ : null,
547
+ ]} keyboardShouldPersistTaps="handled" keyboardDismissMode={react_native_1.Platform.OS === 'ios' ? 'interactive' : 'on-drag'}>
524
548
  <react_native_1.View style={styles.header}>
525
549
  <react_native_1.Text style={styles.title}>Send Feedback</react_native_1.Text>
526
550
  <react_native_1.Pressable onPress={handleClose} hitSlop={12} style={styles.closeBtn} accessibilityRole="button" accessibilityLabel="Close">
@@ -179,14 +179,18 @@ const YaverLoginScreen = ({ onLoggedIn, onCancel, initialInviteCode, }) => {
179
179
  return (<react_native_1.SafeAreaView style={styles.safeArea}>
180
180
  <react_native_1.KeyboardAvoidingView style={{ flex: 1 }} behavior={react_native_1.Platform.OS === 'ios' ? 'padding' : undefined}>
181
181
  <react_native_1.ScrollView contentContainerStyle={styles.scrollContainer} keyboardShouldPersistTaps="handled">
182
- <react_native_1.View style={styles.header}>
183
- <react_native_1.Text style={styles.logo}>Yaver</react_native_1.Text>
184
- <react_native_1.Text style={styles.subtitle}>Sign in to send feedback</react_native_1.Text>
182
+ <react_native_1.View style={styles.topBar}>
183
+ <react_native_1.View style={styles.topBarSpacer}/>
185
184
  {onCancel && (<react_native_1.Pressable onPress={onCancel} style={styles.cancel}>
186
185
  <react_native_1.Text style={styles.cancelText}>Cancel</react_native_1.Text>
187
186
  </react_native_1.Pressable>)}
188
187
  </react_native_1.View>
189
188
 
189
+ <react_native_1.View style={styles.header}>
190
+ <react_native_1.Text style={styles.logo}>Yaver</react_native_1.Text>
191
+ <react_native_1.Text style={styles.subtitle}>Sign in to send feedback</react_native_1.Text>
192
+ </react_native_1.View>
193
+
190
194
  <react_native_1.View style={styles.buttons}>
191
195
  {react_native_1.Platform.OS === 'ios'
192
196
  ? renderProvider('apple', 'Continue with Apple', handleApple)
@@ -252,11 +256,21 @@ const styles = react_native_1.StyleSheet.create({
252
256
  paddingHorizontal: 24,
253
257
  justifyContent: 'center',
254
258
  },
259
+ topBar: {
260
+ minHeight: 32,
261
+ marginBottom: 24,
262
+ flexDirection: 'row',
263
+ alignItems: 'center',
264
+ justifyContent: 'space-between',
265
+ },
266
+ topBarSpacer: {
267
+ width: 56,
268
+ },
255
269
  header: { alignItems: 'center', marginBottom: 40 },
256
270
  logo: { fontSize: 44, fontWeight: '800', color: '#e0e0e0', letterSpacing: -1 },
257
271
  subtitle: { fontSize: 15, color: '#9ca3af', marginTop: 6 },
258
- cancel: { position: 'absolute', right: 0, top: 0, padding: 8 },
259
- cancelText: { color: '#9ca3af', fontSize: 14 },
272
+ cancel: { minWidth: 56, alignItems: 'flex-end', paddingVertical: 8 },
273
+ cancelText: { color: '#9ca3af', fontSize: 14, fontWeight: '500' },
260
274
  buttons: { gap: 12 },
261
275
  button: {
262
276
  backgroundColor: 'rgba(255,255,255,0.06)',
@@ -53,6 +53,7 @@ const YaverMachinePickerScreen = ({ token, currentDeviceId, onPick, onCancel, })
53
53
  const [error, setError] = (0, react_1.useState)(null);
54
54
  const [list, setList] = (0, react_1.useState)({ owned: [], shared: [] });
55
55
  const [pairingDevice, setPairingDevice] = (0, react_1.useState)(null);
56
+ const [reachability, setReachability] = (0, react_1.useState)({});
56
57
  const load = (0, react_1.useCallback)(async (silent = false) => {
57
58
  if (!silent)
58
59
  setLoading(true);
@@ -60,6 +61,23 @@ const YaverMachinePickerScreen = ({ token, currentDeviceId, onPick, onCancel, })
60
61
  try {
61
62
  const result = await (0, auth_1.listReachableDevices)(token);
62
63
  setList(result);
64
+ setReachability({});
65
+ void (async () => {
66
+ const devices = [...result.owned, ...result.shared];
67
+ const settled = await Promise.allSettled(devices.map(async (device) => ({
68
+ deviceId: device.deviceId,
69
+ result: await (0, auth_1.probeDeviceReachability)(device),
70
+ })));
71
+ setReachability((prev) => {
72
+ const next = { ...prev };
73
+ for (const entry of settled) {
74
+ if (entry.status === 'fulfilled') {
75
+ next[entry.value.deviceId] = entry.value.result;
76
+ }
77
+ }
78
+ return next;
79
+ });
80
+ })();
63
81
  if (result.owned.length === 0 && result.shared.length === 0) {
64
82
  setError('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.');
65
83
  }
@@ -85,11 +103,18 @@ const YaverMachinePickerScreen = ({ token, currentDeviceId, onPick, onCancel, })
85
103
  setPairingDevice(device);
86
104
  return;
87
105
  }
106
+ const direct = await (0, auth_1.probeDeviceReachability)(device);
107
+ if (!direct.reachable && !device.needsAuth) {
108
+ setError('Selected machine is not responding. Start `yaver serve` on it and try again.');
109
+ setReachability((prev) => ({ ...prev, [device.deviceId]: direct }));
110
+ return;
111
+ }
88
112
  await (0, auth_1.saveSelectedDeviceId)(device.deviceId);
89
113
  onPick(device);
90
114
  };
91
115
  const renderDevice = (device) => {
92
116
  const selected = device.deviceId === currentDeviceId;
117
+ const probe = reachability[device.deviceId];
93
118
  // Trust Convex's `isOnline` — the backend already gates it on a
94
119
  // fresh 90 s heartbeat (see backend/convex/devices.ts
95
120
  // deriveIsOnline). Re-checking on the client produced false
@@ -100,20 +125,33 @@ const YaverMachinePickerScreen = ({ token, currentDeviceId, onPick, onCancel, })
100
125
  // healthy — a separate concern from "can I reach this machine?"
101
126
  // Mobile app surfaces runner issues via a separate badge, not
102
127
  // this dot. Picker's job is reachability, nothing more.
103
- const healthColor = !device.isOnline
104
- ? '#ef4444'
105
- : device.needsAuth
106
- ? '#f59e0b'
107
- : '#22c55e';
128
+ const effectivelyReachable = probe?.reachable === true;
129
+ const explicitlyOffline = probe?.reachable === false;
130
+ const healthColor = device.needsAuth
131
+ ? '#f59e0b'
132
+ : effectivelyReachable
133
+ ? '#22c55e'
134
+ : explicitlyOffline || !device.isOnline
135
+ ? '#ef4444'
136
+ : '#22c55e';
108
137
  // Derive a single short status phrase the user can act on.
109
138
  let statusLine = device.platform;
110
- if (!device.isOnline) {
139
+ if (probe === undefined) {
140
+ statusLine = 'Checking connection…';
141
+ }
142
+ else if (!device.isOnline && effectivelyReachable) {
143
+ statusLine = 'Reachable now — waiting for cloud status to refresh';
144
+ }
145
+ else if (!device.isOnline) {
111
146
  statusLine = 'Offline — start `yaver serve` on the Mac';
112
147
  }
113
148
  else if (device.needsAuth) {
114
149
  statusLine =
115
150
  'Needs pairing — open the Yaver app to adopt this machine';
116
151
  }
152
+ else if (explicitlyOffline) {
153
+ statusLine = 'Agent not responding on this machine';
154
+ }
117
155
  else if (device.runnerDown) {
118
156
  statusLine = 'Runner down — restart the coding agent on the Mac';
119
157
  }
package/dist/auth.d.ts CHANGED
@@ -123,6 +123,10 @@ export interface DeviceList {
123
123
  owned: RemoteDevice[];
124
124
  shared: RemoteDevice[];
125
125
  }
126
+ export interface DeviceReachability {
127
+ reachable: boolean;
128
+ url?: string;
129
+ }
126
130
  export interface GuestInvitation {
127
131
  hostUserId: string;
128
132
  hostName: string;
@@ -170,6 +174,8 @@ export interface InvitationPreview {
170
174
  * raw list used to render as "Kvancs-MacBook-Air.local ×3" in the picker.
171
175
  */
172
176
  export declare function listReachableDevices(token: string): Promise<DeviceList>;
177
+ export declare function buildDeviceCandidateUrls(device: RemoteDevice): string[];
178
+ export declare function probeDeviceReachability(device: RemoteDevice, timeoutMs?: number): Promise<DeviceReachability>;
173
179
  export declare function mintGuestSdkToken(token: string, hostUserId: string, targetDeviceId: string): Promise<{
174
180
  token: string;
175
181
  expiresAt: number;
package/dist/auth.js CHANGED
@@ -37,6 +37,8 @@ exports.signInWithOAuth = signInWithOAuth;
37
37
  exports.signupWithEmail = signupWithEmail;
38
38
  exports.loginWithEmail = loginWithEmail;
39
39
  exports.listReachableDevices = listReachableDevices;
40
+ exports.buildDeviceCandidateUrls = buildDeviceCandidateUrls;
41
+ exports.probeDeviceReachability = probeDeviceReachability;
40
42
  exports.mintGuestSdkToken = mintGuestSdkToken;
41
43
  exports.fetchGuestHosts = fetchGuestHosts;
42
44
  exports.findInviteByCode = findInviteByCode;
@@ -412,6 +414,45 @@ async function listReachableDevices(token) {
412
414
  return { owned: [], shared: [] };
413
415
  }
414
416
  }
417
+ function buildDeviceCandidateUrls(device) {
418
+ const port = device.httpPort ?? device.quicPort ?? 18080;
419
+ const hosts = new Set();
420
+ if (device.quicHost)
421
+ hosts.add(device.quicHost);
422
+ for (const ip of device.localIps ?? []) {
423
+ if (ip)
424
+ hosts.add(ip);
425
+ }
426
+ return Array.from(hosts).map((host) => `http://${host}:${port}`);
427
+ }
428
+ async function probeDeviceReachability(device, timeoutMs = 2500) {
429
+ const candidates = buildDeviceCandidateUrls(device);
430
+ if (candidates.length === 0)
431
+ return { reachable: false };
432
+ const probeOne = async (baseUrl) => {
433
+ const controller = new AbortController();
434
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
435
+ try {
436
+ const response = await fetch(`${baseUrl}/health`, {
437
+ method: 'GET',
438
+ signal: controller.signal,
439
+ });
440
+ if (!response.ok)
441
+ throw new Error(`health ${response.status}`);
442
+ return baseUrl;
443
+ }
444
+ finally {
445
+ clearTimeout(timer);
446
+ }
447
+ };
448
+ const settled = await Promise.allSettled(candidates.map((url) => probeOne(url)));
449
+ for (const result of settled) {
450
+ if (result.status === 'fulfilled') {
451
+ return { reachable: true, url: result.value };
452
+ }
453
+ }
454
+ return { reachable: false };
455
+ }
415
456
  async function mintGuestSdkToken(token, hostUserId, targetDeviceId) {
416
457
  const res = await fetch(`${convexSiteUrl}/guests/sdk-token`, {
417
458
  method: 'POST',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yaver-feedback-react-native",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "Visual feedback SDK for Yaver — bug reports, screen recording, voice annotations, and local-first developer workflows",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -82,6 +82,7 @@ export const FeedbackModal: React.FC = () => {
82
82
  const [lastVibeTaskId, setLastVibeTaskId] = useState<string | null>(null);
83
83
  const [quickIconColorPreset, setQuickIconColorPreset] =
84
84
  useState<QuickIconColorPreset | null>(null);
85
+ const [keyboardInset, setKeyboardInset] = useState(0);
85
86
  const [machineCard, setMachineCard] = useState<MachineCardState>({
86
87
  device: null,
87
88
  reachable: null,
@@ -256,6 +257,26 @@ export const FeedbackModal: React.FC = () => {
256
257
  return () => clearInterval(interval);
257
258
  }, [loadSelectedMachine, visible]);
258
259
 
260
+ useEffect(() => {
261
+ if (!visible) {
262
+ setKeyboardInset(0);
263
+ return;
264
+ }
265
+
266
+ const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
267
+ const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
268
+ const showSub = Keyboard.addListener(showEvent, (event) => {
269
+ setKeyboardInset(event.endCoordinates?.height ?? 0);
270
+ });
271
+ const hideSub = Keyboard.addListener(hideEvent, () => {
272
+ setKeyboardInset(0);
273
+ });
274
+ return () => {
275
+ showSub.remove();
276
+ hideSub.remove();
277
+ };
278
+ }, [visible]);
279
+
259
280
  const closeSoon = useCallback((delayMs = 1200) => {
260
281
  setTimeout(() => {
261
282
  if (mountedRef.current) setVisible(false);
@@ -569,6 +590,7 @@ export const FeedbackModal: React.FC = () => {
569
590
  <Pressable style={styles.overlay} onPress={handleClose}>
570
591
  <KeyboardAvoidingView
571
592
  behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
593
+ keyboardVerticalOffset={Platform.OS === 'ios' ? 12 : 0}
572
594
  style={styles.kbAvoider}
573
595
  pointerEvents="box-none"
574
596
  >
@@ -581,8 +603,14 @@ export const FeedbackModal: React.FC = () => {
581
603
  >
582
604
  <ScrollView
583
605
  style={styles.scroll}
584
- contentContainerStyle={styles.scrollContent}
606
+ contentContainerStyle={[
607
+ styles.scrollContent,
608
+ showVibeInput && keyboardInset > 0
609
+ ? { paddingBottom: 8 + keyboardInset }
610
+ : null,
611
+ ]}
585
612
  keyboardShouldPersistTaps="handled"
613
+ keyboardDismissMode={Platform.OS === 'ios' ? 'interactive' : 'on-drag'}
586
614
  >
587
615
  <View style={styles.header}>
588
616
  <Text style={styles.title}>Send Feedback</Text>
@@ -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/auth.ts CHANGED
@@ -435,6 +435,11 @@ export interface DeviceList {
435
435
  shared: RemoteDevice[];
436
436
  }
437
437
 
438
+ export interface DeviceReachability {
439
+ reachable: boolean;
440
+ url?: string;
441
+ }
442
+
438
443
  export interface GuestInvitation {
439
444
  hostUserId: string;
440
445
  hostName: string;
@@ -536,6 +541,47 @@ export async function listReachableDevices(
536
541
  }
537
542
  }
538
543
 
544
+ export function buildDeviceCandidateUrls(device: RemoteDevice): string[] {
545
+ const port = device.httpPort ?? device.quicPort ?? 18080;
546
+ const hosts = new Set<string>();
547
+ if (device.quicHost) hosts.add(device.quicHost);
548
+ for (const ip of device.localIps ?? []) {
549
+ if (ip) hosts.add(ip);
550
+ }
551
+ return Array.from(hosts).map((host) => `http://${host}:${port}`);
552
+ }
553
+
554
+ export async function probeDeviceReachability(
555
+ device: RemoteDevice,
556
+ timeoutMs = 2500,
557
+ ): Promise<DeviceReachability> {
558
+ const candidates = buildDeviceCandidateUrls(device);
559
+ if (candidates.length === 0) return { reachable: false };
560
+
561
+ const probeOne = async (baseUrl: string): Promise<string> => {
562
+ const controller = new AbortController();
563
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
564
+ try {
565
+ const response = await fetch(`${baseUrl}/health`, {
566
+ method: 'GET',
567
+ signal: controller.signal,
568
+ });
569
+ if (!response.ok) throw new Error(`health ${response.status}`);
570
+ return baseUrl;
571
+ } finally {
572
+ clearTimeout(timer);
573
+ }
574
+ };
575
+
576
+ const settled = await Promise.allSettled(candidates.map((url) => probeOne(url)));
577
+ for (const result of settled) {
578
+ if (result.status === 'fulfilled') {
579
+ return { reachable: true, url: result.value };
580
+ }
581
+ }
582
+ return { reachable: false };
583
+ }
584
+
539
585
  export async function mintGuestSdkToken(
540
586
  token: string,
541
587
  hostUserId: string,