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.
- package/dist/FeedbackModal.js +26 -2
- package/dist/LoginScreen.js +19 -5
- package/dist/MachinePickerScreen.js +44 -6
- package/dist/auth.d.ts +6 -0
- package/dist/auth.js +41 -0
- package/package.json +1 -1
- package/src/FeedbackModal.tsx +29 -1
- package/src/LoginScreen.tsx +19 -5
- package/src/MachinePickerScreen.tsx +45 -6
- package/src/auth.ts +46 -0
package/dist/FeedbackModal.js
CHANGED
|
@@ -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={
|
|
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">
|
package/dist/LoginScreen.js
CHANGED
|
@@ -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.
|
|
183
|
-
<react_native_1.
|
|
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: {
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 (
|
|
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
|
+
"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",
|
package/src/FeedbackModal.tsx
CHANGED
|
@@ -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={
|
|
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>
|
package/src/LoginScreen.tsx
CHANGED
|
@@ -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.
|
|
240
|
-
<
|
|
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: {
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 (
|
|
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,
|