yaver-feedback-react-native 0.8.2 → 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.
@@ -0,0 +1,307 @@
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ Pressable,
5
+ SafeAreaView,
6
+ ScrollView,
7
+ StyleSheet,
8
+ Text,
9
+ TextInput,
10
+ View,
11
+ } from 'react-native';
12
+ import {
13
+ acceptGuestByCode,
14
+ acceptGuestInvitation,
15
+ fetchGuestHosts,
16
+ findInviteByCode,
17
+ type GuestHostsResponse,
18
+ type InvitationPreview,
19
+ } from './auth';
20
+
21
+ export interface YaverGuestOnboardingScreenProps {
22
+ token: string;
23
+ initialInviteCode?: string;
24
+ onContinue: () => void;
25
+ onCancel?: () => void;
26
+ }
27
+
28
+ export const YaverGuestOnboardingScreen: React.FC<YaverGuestOnboardingScreenProps> = ({
29
+ token,
30
+ initialInviteCode,
31
+ onContinue,
32
+ onCancel,
33
+ }) => {
34
+ const [code, setCode] = useState((initialInviteCode ?? '').toUpperCase());
35
+ const [preview, setPreview] = useState<InvitationPreview | null>(null);
36
+ const [hosts, setHosts] = useState<GuestHostsResponse>({ pending: [], active: [] });
37
+ const [loading, setLoading] = useState(true);
38
+ const [busy, setBusy] = useState(false);
39
+ const [error, setError] = useState<string | null>(null);
40
+ const cleanedCode = useMemo(() => code.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 6), [code]);
41
+
42
+ const loadHosts = useCallback(async () => {
43
+ setLoading(true);
44
+ setError(null);
45
+ try {
46
+ const result = await fetchGuestHosts(token);
47
+ setHosts(result);
48
+ } catch (err) {
49
+ setError(err instanceof Error ? err.message : String(err));
50
+ } finally {
51
+ setLoading(false);
52
+ }
53
+ }, [token]);
54
+
55
+ useEffect(() => {
56
+ void loadHosts();
57
+ }, [loadHosts]);
58
+
59
+ useEffect(() => {
60
+ let cancelled = false;
61
+ if (cleanedCode.length !== 6) {
62
+ setPreview(null);
63
+ return;
64
+ }
65
+ (async () => {
66
+ try {
67
+ const result = await findInviteByCode(token, cleanedCode);
68
+ if (!cancelled) {
69
+ setPreview(result);
70
+ if (!result) {
71
+ setError('Invite code not found or expired.');
72
+ } else {
73
+ setError(null);
74
+ }
75
+ }
76
+ } catch (err) {
77
+ if (!cancelled) {
78
+ setPreview(null);
79
+ setError(err instanceof Error ? err.message : String(err));
80
+ }
81
+ }
82
+ })();
83
+ return () => {
84
+ cancelled = true;
85
+ };
86
+ }, [cleanedCode, token]);
87
+
88
+ const handleAcceptCode = useCallback(async () => {
89
+ if (cleanedCode.length !== 6) {
90
+ setError('Enter the 6-character invite code from the host.');
91
+ return;
92
+ }
93
+ setBusy(true);
94
+ setError(null);
95
+ try {
96
+ await acceptGuestByCode(token, cleanedCode, preview?.proposedDeviceIds);
97
+ await loadHosts();
98
+ onContinue();
99
+ } catch (err) {
100
+ setError(err instanceof Error ? err.message : String(err));
101
+ } finally {
102
+ setBusy(false);
103
+ }
104
+ }, [cleanedCode, loadHosts, onContinue, preview?.proposedDeviceIds, token]);
105
+
106
+ const handleAcceptPending = useCallback(async (hostUserId: string) => {
107
+ setBusy(true);
108
+ setError(null);
109
+ try {
110
+ await acceptGuestInvitation(token, hostUserId);
111
+ await loadHosts();
112
+ onContinue();
113
+ } catch (err) {
114
+ setError(err instanceof Error ? err.message : String(err));
115
+ } finally {
116
+ setBusy(false);
117
+ }
118
+ }, [loadHosts, onContinue, token]);
119
+
120
+ return (
121
+ <SafeAreaView style={styles.container}>
122
+ <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
123
+ <View style={styles.header}>
124
+ <View>
125
+ <Text style={styles.title}>Guest Access</Text>
126
+ <Text style={styles.subtitle}>
127
+ Join a host&apos;s repo-scoped Feedback SDK access without leaving the app.
128
+ </Text>
129
+ </View>
130
+ {onCancel && (
131
+ <Pressable onPress={onCancel} style={styles.skipBtn}>
132
+ <Text style={styles.skipText}>Skip</Text>
133
+ </Pressable>
134
+ )}
135
+ </View>
136
+
137
+ <View style={styles.card}>
138
+ <Text style={styles.cardTitle}>Have an invite code?</Text>
139
+ <Text style={styles.cardText}>
140
+ Enter the 6-character code from the host. After redemption you can pick the shared machine directly here.
141
+ </Text>
142
+ <TextInput
143
+ style={styles.input}
144
+ value={cleanedCode}
145
+ onChangeText={setCode}
146
+ placeholder="ABC123"
147
+ placeholderTextColor="#667085"
148
+ autoCapitalize="characters"
149
+ autoCorrect={false}
150
+ maxLength={6}
151
+ />
152
+ {preview && (
153
+ <View style={styles.previewBox}>
154
+ <Text style={styles.previewTitle}>{preview.hostName}</Text>
155
+ <Text style={styles.previewMeta}>{preview.hostEmail}</Text>
156
+ <Text style={styles.previewMeta}>
157
+ {preview.hostDevices.length > 0
158
+ ? `${preview.hostDevices.length} host machine${preview.hostDevices.length === 1 ? '' : 's'} shared`
159
+ : 'Host machine list will appear after acceptance'}
160
+ </Text>
161
+ </View>
162
+ )}
163
+ <Pressable
164
+ onPress={() => void handleAcceptCode()}
165
+ disabled={busy || cleanedCode.length !== 6}
166
+ style={({ pressed }) => [
167
+ styles.primaryBtn,
168
+ (pressed || busy || cleanedCode.length !== 6) && styles.primaryBtnPressed,
169
+ ]}
170
+ >
171
+ {busy ? <ActivityIndicator color="#fff" /> : <Text style={styles.primaryBtnText}>Redeem Code</Text>}
172
+ </Pressable>
173
+ </View>
174
+
175
+ <View style={styles.card}>
176
+ <Text style={styles.cardTitle}>Pending host invites</Text>
177
+ <Text style={styles.cardText}>
178
+ If a host invited your email directly, it should appear here after you sign up.
179
+ </Text>
180
+ {loading ? (
181
+ <ActivityIndicator color="#98a2b3" style={{ marginTop: 12 }} />
182
+ ) : hosts.pending.length > 0 ? (
183
+ hosts.pending.map((invite) => (
184
+ <View key={`${invite.hostUserId}:${invite.createdAt}`} style={styles.inviteRow}>
185
+ <View style={{ flex: 1 }}>
186
+ <Text style={styles.inviteName}>{invite.hostName}</Text>
187
+ <Text style={styles.inviteMeta}>{invite.hostEmail}</Text>
188
+ </View>
189
+ <Pressable
190
+ onPress={() => void handleAcceptPending(invite.hostUserId)}
191
+ disabled={busy}
192
+ style={({ pressed }) => [
193
+ styles.secondaryBtn,
194
+ (pressed || busy) && styles.secondaryBtnPressed,
195
+ ]}
196
+ >
197
+ <Text style={styles.secondaryBtnText}>Accept</Text>
198
+ </Pressable>
199
+ </View>
200
+ ))
201
+ ) : (
202
+ <Text style={styles.emptyText}>No pending host invites on this account yet.</Text>
203
+ )}
204
+ </View>
205
+
206
+ <View style={styles.cardMuted}>
207
+ <Text style={styles.cardTitle}>No machine yet?</Text>
208
+ <Text style={styles.cardText}>
209
+ That is fine. You can still sign up here and redeem the host&apos;s guest access. Shared machines will appear once the host has one running. Later you can install Yaver on your own machine too.
210
+ </Text>
211
+ </View>
212
+
213
+ {error ? <Text style={styles.error}>{error}</Text> : null}
214
+
215
+ <Pressable
216
+ onPress={onContinue}
217
+ style={({ pressed }) => [styles.linkBtn, pressed && { opacity: 0.7 }]}
218
+ >
219
+ <Text style={styles.linkBtnText}>Continue to machine picker</Text>
220
+ </Pressable>
221
+ </ScrollView>
222
+ </SafeAreaView>
223
+ );
224
+ };
225
+
226
+ const styles = StyleSheet.create({
227
+ container: { flex: 1, backgroundColor: '#0f172a' },
228
+ content: { padding: 20, gap: 16 },
229
+ header: {
230
+ flexDirection: 'row',
231
+ justifyContent: 'space-between',
232
+ alignItems: 'flex-start',
233
+ marginBottom: 8,
234
+ },
235
+ title: { color: '#f8fafc', fontSize: 24, fontWeight: '700' },
236
+ subtitle: { color: '#94a3b8', fontSize: 14, marginTop: 6, maxWidth: 280 },
237
+ skipBtn: { paddingVertical: 6, paddingHorizontal: 10 },
238
+ skipText: { color: '#cbd5e1', fontSize: 13, fontWeight: '600' },
239
+ card: {
240
+ backgroundColor: 'rgba(15,23,42,0.82)',
241
+ borderWidth: 1,
242
+ borderColor: 'rgba(148,163,184,0.18)',
243
+ borderRadius: 16,
244
+ padding: 16,
245
+ },
246
+ cardMuted: {
247
+ backgroundColor: 'rgba(30,41,59,0.9)',
248
+ borderRadius: 16,
249
+ padding: 16,
250
+ },
251
+ cardTitle: { color: '#e2e8f0', fontSize: 16, fontWeight: '700' },
252
+ cardText: { color: '#94a3b8', fontSize: 13, lineHeight: 20, marginTop: 8 },
253
+ input: {
254
+ marginTop: 14,
255
+ borderWidth: 1,
256
+ borderColor: 'rgba(148,163,184,0.28)',
257
+ borderRadius: 12,
258
+ paddingHorizontal: 14,
259
+ paddingVertical: 12,
260
+ backgroundColor: 'rgba(2,6,23,0.88)',
261
+ color: '#f8fafc',
262
+ fontSize: 18,
263
+ letterSpacing: 3,
264
+ textAlign: 'center',
265
+ },
266
+ previewBox: {
267
+ marginTop: 12,
268
+ padding: 12,
269
+ borderRadius: 12,
270
+ backgroundColor: 'rgba(30,41,59,0.75)',
271
+ },
272
+ previewTitle: { color: '#f8fafc', fontSize: 15, fontWeight: '600' },
273
+ previewMeta: { color: '#94a3b8', fontSize: 12, marginTop: 4 },
274
+ primaryBtn: {
275
+ marginTop: 14,
276
+ backgroundColor: '#2563eb',
277
+ borderRadius: 12,
278
+ minHeight: 46,
279
+ alignItems: 'center',
280
+ justifyContent: 'center',
281
+ },
282
+ primaryBtnPressed: { opacity: 0.6 },
283
+ primaryBtnText: { color: '#fff', fontSize: 15, fontWeight: '700' },
284
+ inviteRow: {
285
+ flexDirection: 'row',
286
+ alignItems: 'center',
287
+ gap: 12,
288
+ marginTop: 12,
289
+ paddingTop: 12,
290
+ borderTopWidth: 1,
291
+ borderTopColor: 'rgba(148,163,184,0.12)',
292
+ },
293
+ inviteName: { color: '#f8fafc', fontSize: 14, fontWeight: '600' },
294
+ inviteMeta: { color: '#94a3b8', fontSize: 12, marginTop: 4 },
295
+ secondaryBtn: {
296
+ paddingHorizontal: 12,
297
+ paddingVertical: 10,
298
+ borderRadius: 10,
299
+ backgroundColor: 'rgba(37,99,235,0.16)',
300
+ },
301
+ secondaryBtnPressed: { opacity: 0.6 },
302
+ secondaryBtnText: { color: '#bfdbfe', fontSize: 13, fontWeight: '700' },
303
+ emptyText: { color: '#64748b', fontSize: 13, marginTop: 12 },
304
+ error: { color: '#f87171', fontSize: 13, marginTop: 4 },
305
+ linkBtn: { alignItems: 'center', paddingVertical: 8 },
306
+ linkBtnText: { color: '#cbd5e1', fontSize: 14, fontWeight: '600' },
307
+ });
@@ -113,9 +113,11 @@ const iconStyles = StyleSheet.create({
113
113
 
114
114
  export interface YaverLoginScreenProps {
115
115
  /** Invoked once a session token is issued and the user is loaded. */
116
- onLoggedIn: (token: string) => void;
116
+ onLoggedIn: (token: string, opts?: { inviteCode?: string }) => void;
117
117
  /** Optional cancel button shown in header. */
118
118
  onCancel?: () => void;
119
+ /** Optional prefilled guest invite code from config / deep link. */
120
+ initialInviteCode?: string;
119
121
  }
120
122
 
121
123
  /**
@@ -126,6 +128,7 @@ export interface YaverLoginScreenProps {
126
128
  export const YaverLoginScreen: React.FC<YaverLoginScreenProps> = ({
127
129
  onLoggedIn,
128
130
  onCancel,
131
+ initialInviteCode,
129
132
  }) => {
130
133
  const [busyProvider, setBusyProvider] = useState<OAuthProvider | 'apple' | null>(null);
131
134
  const [showEmailForm, setShowEmailForm] = useState(false);
@@ -134,6 +137,7 @@ export const YaverLoginScreen: React.FC<YaverLoginScreenProps> = ({
134
137
  const [email, setEmail] = useState('');
135
138
  const [password, setPassword] = useState('');
136
139
  const [confirmPassword, setConfirmPassword] = useState('');
140
+ const [inviteCode, setInviteCode] = useState((initialInviteCode ?? '').toUpperCase());
137
141
  const [emailBusy, setEmailBusy] = useState(false);
138
142
  const [emailError, setEmailError] = useState('');
139
143
 
@@ -141,7 +145,8 @@ export const YaverLoginScreen: React.FC<YaverLoginScreenProps> = ({
141
145
  const user = await validateToken(token);
142
146
  await saveToken(token);
143
147
  if (user) await saveUser(user);
144
- onLoggedIn(token);
148
+ const cleanedInviteCode = inviteCode.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 6);
149
+ onLoggedIn(token, cleanedInviteCode ? { inviteCode: cleanedInviteCode } : undefined);
145
150
  };
146
151
 
147
152
  const handleApple = async () => {
@@ -231,9 +236,8 @@ export const YaverLoginScreen: React.FC<YaverLoginScreenProps> = ({
231
236
  contentContainerStyle={styles.scrollContainer}
232
237
  keyboardShouldPersistTaps="handled"
233
238
  >
234
- <View style={styles.header}>
235
- <Text style={styles.logo}>Yaver</Text>
236
- <Text style={styles.subtitle}>Sign in to send feedback</Text>
239
+ <View style={styles.topBar}>
240
+ <View style={styles.topBarSpacer} />
237
241
  {onCancel && (
238
242
  <Pressable onPress={onCancel} style={styles.cancel}>
239
243
  <Text style={styles.cancelText}>Cancel</Text>
@@ -241,6 +245,11 @@ export const YaverLoginScreen: React.FC<YaverLoginScreenProps> = ({
241
245
  )}
242
246
  </View>
243
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
+
244
253
  <View style={styles.buttons}>
245
254
  {Platform.OS === 'ios'
246
255
  ? renderProvider('apple', 'Continue with Apple', handleApple)
@@ -321,6 +330,20 @@ export const YaverLoginScreen: React.FC<YaverLoginScreenProps> = ({
321
330
  secureTextEntry
322
331
  />
323
332
  )}
333
+ {isSignUp && (
334
+ <TextInput
335
+ style={styles.input}
336
+ placeholder="Invite Code (optional)"
337
+ placeholderTextColor="#666"
338
+ value={inviteCode}
339
+ onChangeText={(value) =>
340
+ setInviteCode(value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 6))
341
+ }
342
+ autoCapitalize="characters"
343
+ autoCorrect={false}
344
+ maxLength={6}
345
+ />
346
+ )}
324
347
 
325
348
  {emailError ? (
326
349
  <Text style={styles.errorText}>{emailError}</Text>
@@ -372,11 +395,21 @@ const styles = StyleSheet.create({
372
395
  paddingHorizontal: 24,
373
396
  justifyContent: 'center',
374
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
+ },
375
408
  header: { alignItems: 'center', marginBottom: 40 },
376
409
  logo: { fontSize: 44, fontWeight: '800', color: '#e0e0e0', letterSpacing: -1 },
377
410
  subtitle: { fontSize: 15, color: '#9ca3af', marginTop: 6 },
378
- cancel: { position: 'absolute', right: 0, top: 0, padding: 8 },
379
- cancelText: { color: '#9ca3af', fontSize: 14 },
411
+ cancel: { minWidth: 56, alignItems: 'flex-end', paddingVertical: 8 },
412
+ cancelText: { color: '#9ca3af', fontSize: 14, fontWeight: '500' },
380
413
  buttons: { gap: 12 },
381
414
  button: {
382
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,8 +55,29 @@ 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
- setError('Hiç makine bulunamadı — önce bir makinede `yaver auth` + `yaver serve` çalıştır.');
78
+ setError(
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.',
80
+ );
57
81
  }
58
82
  } catch (err) {
59
83
  setError(err instanceof Error ? err.message : String(err));
@@ -77,12 +101,19 @@ export const YaverMachinePickerScreen: React.FC<YaverMachinePickerProps> = ({
77
101
  setPairingDevice(device);
78
102
  return;
79
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
+ }
80
110
  await saveSelectedDeviceId(device.deviceId);
81
111
  onPick(device);
82
112
  };
83
113
 
84
114
  const renderDevice = (device: RemoteDevice) => {
85
115
  const selected = device.deviceId === currentDeviceId;
116
+ const probe = reachability[device.deviceId];
86
117
  // Trust Convex's `isOnline` — the backend already gates it on a
87
118
  // fresh 90 s heartbeat (see backend/convex/devices.ts
88
119
  // deriveIsOnline). Re-checking on the client produced false
@@ -93,18 +124,28 @@ export const YaverMachinePickerScreen: React.FC<YaverMachinePickerProps> = ({
93
124
  // healthy — a separate concern from "can I reach this machine?"
94
125
  // Mobile app surfaces runner issues via a separate badge, not
95
126
  // this dot. Picker's job is reachability, nothing more.
96
- const healthColor = !device.isOnline
97
- ? '#ef4444'
98
- : device.needsAuth
99
- ? '#f59e0b'
100
- : '#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';
101
136
  // Derive a single short status phrase the user can act on.
102
137
  let statusLine = device.platform;
103
- 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) {
104
143
  statusLine = 'Offline — start `yaver serve` on the Mac';
105
144
  } else if (device.needsAuth) {
106
145
  statusLine =
107
146
  'Needs pairing — open the Yaver app to adopt this machine';
147
+ } else if (explicitlyOffline) {
148
+ statusLine = 'Agent not responding on this machine';
108
149
  } else if (device.runnerDown) {
109
150
  statusLine = 'Runner down — restart the coding agent on the Mac';
110
151
  } else {
package/src/P2PClient.ts CHANGED
@@ -449,6 +449,39 @@ export class P2PClient {
449
449
  return response.json();
450
450
  }
451
451
 
452
+ async getVibingEligibility(
453
+ opts?: { projectName?: string; bundleId?: string; projectPath?: string },
454
+ ): Promise<{
455
+ canVibe: boolean;
456
+ reason?: string;
457
+ guidance?: string;
458
+ projectName?: string;
459
+ projectPath?: string;
460
+ provider?: string;
461
+ repoFullName?: string;
462
+ }> {
463
+ const identity = resolveAppIdentity(opts);
464
+ const params = new URLSearchParams();
465
+ if (identity.projectName ?? opts?.projectName) {
466
+ params.set('projectName', identity.projectName ?? opts?.projectName ?? '');
467
+ }
468
+ if (identity.bundleId ?? opts?.bundleId) {
469
+ params.set('bundleId', identity.bundleId ?? opts?.bundleId ?? '');
470
+ }
471
+ if (identity.projectPath ?? opts?.projectPath) {
472
+ params.set('projectPath', identity.projectPath ?? opts?.projectPath ?? '');
473
+ }
474
+ const response = await fetch(`${this.baseUrl}/vibing/eligibility?${params.toString()}`, {
475
+ method: 'GET',
476
+ headers: { Authorization: `Bearer ${this.authToken}` },
477
+ });
478
+ if (!response.ok) {
479
+ const text = await response.text().catch(() => '');
480
+ throw new Error(`[P2PClient] Vibing eligibility failed (${response.status}): ${text}`);
481
+ }
482
+ return response.json();
483
+ }
484
+
452
485
  /**
453
486
  * After uploading a feedback bundle with `uploadFeedback`, call this
454
487
  * with the returned report id to create a fix task on the agent. The
@@ -10,6 +10,7 @@ import {
10
10
  getToken,
11
11
  getSelectedDeviceId,
12
12
  listReachableDevices,
13
+ mintGuestSdkToken,
13
14
  clearToken,
14
15
  clearSelectedDeviceId,
15
16
  DEFAULT_CONVEX_SITE_URL,
@@ -49,6 +50,7 @@ let config: FeedbackConfig | null = null;
49
50
  let enabled = false;
50
51
  let p2pClient: P2PClient | null = null;
51
52
  let shakeDetector: ShakeDetector | null = null;
53
+ let p2pAuthToken: string | null = null;
52
54
 
53
55
  /** Ring buffer of captured errors. */
54
56
  let errorBuffer: CapturedError[] = [];
@@ -77,6 +79,44 @@ const flagCache: Map<string, { value: unknown; at: number }> = new Map();
77
79
  * Call `YaverFeedback.init()` once at app startup.
78
80
  */
79
81
  export class YaverFeedback {
82
+ private static async resolveP2PAuthToken(): Promise<string | null> {
83
+ if (!config?.authToken) return null;
84
+ if (!config.preferredDeviceId) return config.authToken;
85
+ const devices = await listReachableDevices(config.authToken);
86
+ const all = [...devices.owned, ...devices.shared];
87
+ const selected = all.find((device) => device.deviceId === config?.preferredDeviceId);
88
+ if (!selected || !selected.isGuest || selected.accessScope !== 'shared-scoped') {
89
+ return config.authToken;
90
+ }
91
+ if (!selected.hostUserId) {
92
+ return config.authToken;
93
+ }
94
+ const delegated = await mintGuestSdkToken(
95
+ config.authToken,
96
+ selected.hostUserId,
97
+ selected.deviceId,
98
+ );
99
+ return delegated.token;
100
+ }
101
+
102
+ private static async rebuildP2PClient(agentUrl?: string): Promise<void> {
103
+ if (!config) return;
104
+ const effectiveUrl = agentUrl ?? config.agentUrl;
105
+ if (!effectiveUrl) {
106
+ p2pClient = null;
107
+ p2pAuthToken = null;
108
+ return;
109
+ }
110
+ const token = await YaverFeedback.resolveP2PAuthToken();
111
+ if (!token) {
112
+ p2pClient = null;
113
+ p2pAuthToken = null;
114
+ return;
115
+ }
116
+ p2pAuthToken = token;
117
+ p2pClient = new P2PClient(effectiveUrl, token);
118
+ }
119
+
80
120
  /**
81
121
  * Initialize the feedback SDK with the given configuration.
82
122
  * Typically called in your app's root component or entry file.
@@ -130,7 +170,11 @@ export class YaverFeedback {
130
170
 
131
171
  // Create P2P client if we have a URL
132
172
  if (config.agentUrl) {
173
+ p2pAuthToken = config.authToken ?? null;
133
174
  p2pClient = new P2PClient(config.agentUrl, config.authToken ?? '');
175
+ if (config.authToken) {
176
+ void YaverFeedback.rebuildP2PClient(config.agentUrl);
177
+ }
134
178
  } else {
135
179
  p2pClient = null;
136
180
  // Auto-discover agent in the background when convexUrl or LAN is available
@@ -251,7 +295,7 @@ export class YaverFeedback {
251
295
  });
252
296
  if (result && config) {
253
297
  config.agentUrl = result.url;
254
- p2pClient = new P2PClient(result.url, config.authToken ?? '');
298
+ await YaverFeedback.rebuildP2PClient(result.url);
255
299
  }
256
300
  } catch {
257
301
  // Discovery failed — FloatingButton will show disconnected, user can retry
@@ -278,7 +322,7 @@ export class YaverFeedback {
278
322
  });
279
323
  if (!result) return false;
280
324
  config.agentUrl = result.url;
281
- p2pClient = new P2PClient(result.url, config.authToken ?? '');
325
+ await YaverFeedback.rebuildP2PClient(result.url);
282
326
  return true;
283
327
  } catch {
284
328
  return false;
@@ -322,7 +366,7 @@ export class YaverFeedback {
322
366
  if (!config) return;
323
367
  config.authToken = token;
324
368
  if (config.agentUrl) {
325
- p2pClient = new P2PClient(config.agentUrl, token);
369
+ await YaverFeedback.rebuildP2PClient(config.agentUrl);
326
370
  } else {
327
371
  await YaverFeedback.discoverAgent();
328
372
  }
@@ -363,6 +407,7 @@ export class YaverFeedback {
363
407
  config.preferredDeviceId = deviceId;
364
408
  config.agentUrl = undefined;
365
409
  p2pClient = null;
410
+ p2pAuthToken = null;
366
411
  await YaverFeedback.discoverAgent();
367
412
  }
368
413
 
@@ -388,6 +433,7 @@ export class YaverFeedback {
388
433
  config.agentUrl = undefined;
389
434
  }
390
435
  p2pClient = null;
436
+ p2pAuthToken = null;
391
437
  }
392
438
 
393
439
  /**
@@ -427,7 +473,7 @@ export class YaverFeedback {
427
473
  });
428
474
  if (result) {
429
475
  config.agentUrl = result.url;
430
- p2pClient = new P2PClient(result.url, config.authToken ?? '');
476
+ await YaverFeedback.rebuildP2PClient(result.url);
431
477
  } else if (config.autoLogin !== false && !config.preferredDeviceId) {
432
478
  // No agent discovered and no device picked yet — prompt the user
433
479
  // to pick one of their machines (handles the non-LAN case where