yaver-feedback-react-native 0.8.12 → 0.8.13

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,403 @@
1
+ import React, { useCallback, useEffect, useState } from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ Pressable,
5
+ ScrollView,
6
+ StyleSheet,
7
+ Text,
8
+ View,
9
+ } from 'react-native';
10
+ import { YaverFeedback } from './YaverFeedback';
11
+
12
+ /**
13
+ * Inline Deploy panel — third-party RN apps using yaver-feedback can deploy
14
+ * to TestFlight / Play Store from a phone shake without leaving their app.
15
+ *
16
+ * Flow:
17
+ * 1. GET /fleet/deploy-options?app=<slug> on the SDK's selected machine.
18
+ * The agent fans out doctor probes to the user's other reachable
19
+ * devices (LAN > Tailscale > relay) and returns merged capabilities.
20
+ * 2. User picks a target (TestFlight / Play / Both) — disables machines
21
+ * whose doctor reports a blocker for any picked target. Linux boxes
22
+ * grey out for TestFlight ("xcodebuild: only on darwin"). macOS
23
+ * machines without Xcode grey out for the same reason.
24
+ * 3. Tap a machine row → POST /deploy/ship {app, target/targets, machine}.
25
+ * Toast + auto-collapse the panel. Live SSE log viewing is the
26
+ * desktop / web Deploy tab's job — this surface stays minimal.
27
+ *
28
+ * App slug resolution: prefer config.deployAppSlug → bundleId tail →
29
+ * literal "main". Documented on FeedbackConfig.deployAppSlug.
30
+ */
31
+
32
+ interface FleetDeployTargetCap {
33
+ target: string;
34
+ ok: boolean;
35
+ reason?: string;
36
+ }
37
+
38
+ interface FleetDeployDevice {
39
+ deviceId: string;
40
+ name: string;
41
+ alias?: string;
42
+ platform: string;
43
+ isLocal: boolean;
44
+ isOnline: boolean;
45
+ probed: boolean;
46
+ probeError?: string;
47
+ capabilities: FleetDeployTargetCap[];
48
+ }
49
+
50
+ interface FleetDeployOptions {
51
+ app: string;
52
+ stack?: string;
53
+ targets: string[];
54
+ devices: FleetDeployDevice[];
55
+ warnings?: string[];
56
+ }
57
+
58
+ const TARGET_LABELS: Record<string, string> = {
59
+ testflight: 'TestFlight',
60
+ playstore: 'Play Store',
61
+ };
62
+
63
+ interface DeployPanelProps {
64
+ /** Called when the user taps Cancel or after a successful deploy starts. */
65
+ onClose: () => void;
66
+ }
67
+
68
+ type SelectedTarget = 'testflight' | 'playstore' | 'both';
69
+
70
+ export const DeployPanel: React.FC<DeployPanelProps> = ({ onClose }) => {
71
+ const [options, setOptions] = useState<FleetDeployOptions | null>(null);
72
+ const [loading, setLoading] = useState(true);
73
+ const [error, setError] = useState<string | null>(null);
74
+ const [status, setStatus] = useState<string | null>(null);
75
+ const [statusTone, setStatusTone] = useState<'progress' | 'success' | 'error'>('progress');
76
+ const [selected, setSelected] = useState<SelectedTarget>('both');
77
+ const [shipping, setShipping] = useState(false);
78
+
79
+ const resolveAppSlug = useCallback((): string => {
80
+ const cfg = YaverFeedback.getConfig();
81
+ const explicit = (cfg as { deployAppSlug?: string } | null | undefined)?.deployAppSlug;
82
+ if (explicit && explicit.trim().length > 0) return explicit.trim();
83
+ // Best-effort fallback: bundleId's last dot-segment. iOS gives us
84
+ // `io.yaver.sfmg`; Android gives the same shape. The agent's
85
+ // workspace manifest typically names apps after the project basename
86
+ // which is usually the same word, but the user can override via
87
+ // config.deployAppSlug if it isn't.
88
+ const bundleId = (cfg as { bundleId?: string } | null | undefined)?.bundleId;
89
+ if (bundleId) {
90
+ const tail = bundleId.split('.').pop();
91
+ if (tail) return tail;
92
+ }
93
+ return 'main';
94
+ }, []);
95
+
96
+ const baseAuthHeaders = useCallback((): Record<string, string> => {
97
+ const cfg = YaverFeedback.getConfig();
98
+ const headers: Record<string, string> = {};
99
+ if (cfg?.authToken) headers.Authorization = `Bearer ${cfg.authToken}`;
100
+ const relay = YaverFeedback.getRelayPassword();
101
+ if (relay) headers['X-Relay-Password'] = relay;
102
+ return headers;
103
+ }, []);
104
+
105
+ const fetchOptions = useCallback(async () => {
106
+ setLoading(true);
107
+ setError(null);
108
+ const cfg = YaverFeedback.getConfig();
109
+ if (!cfg?.agentUrl) {
110
+ setError('Not connected to a Yaver agent yet.');
111
+ setLoading(false);
112
+ return;
113
+ }
114
+ const app = resolveAppSlug();
115
+ const url = `${cfg.agentUrl.replace(/\/$/, '')}/fleet/deploy-options?app=${encodeURIComponent(app)}`;
116
+ try {
117
+ const resp = await fetch(url, { headers: baseAuthHeaders() });
118
+ if (!resp.ok) {
119
+ const text = await resp.text().catch(() => '');
120
+ throw new Error(`fetch failed (${resp.status}): ${text || resp.statusText}`);
121
+ }
122
+ const json = (await resp.json()) as FleetDeployOptions;
123
+ setOptions(json);
124
+ } catch (err: unknown) {
125
+ setError(err instanceof Error ? err.message : String(err));
126
+ } finally {
127
+ setLoading(false);
128
+ }
129
+ }, [baseAuthHeaders, resolveAppSlug]);
130
+
131
+ useEffect(() => {
132
+ void fetchOptions();
133
+ }, [fetchOptions]);
134
+
135
+ const pickedTargets = (): string[] => {
136
+ switch (selected) {
137
+ case 'testflight':
138
+ return ['testflight'];
139
+ case 'playstore':
140
+ return ['playstore'];
141
+ default:
142
+ return ['testflight', 'playstore'];
143
+ }
144
+ };
145
+
146
+ const machineRow = (d: FleetDeployDevice) => {
147
+ const targets = pickedTargets();
148
+ const blockers: string[] = [];
149
+ let allOK = true;
150
+ for (const t of targets) {
151
+ const cap = d.capabilities.find((c) => c.target === t);
152
+ if (!cap) {
153
+ allOK = false;
154
+ blockers.push(`${TARGET_LABELS[t] ?? t}: no capability data`);
155
+ continue;
156
+ }
157
+ if (!cap.ok) {
158
+ allOK = false;
159
+ if (cap.reason) blockers.push(`${TARGET_LABELS[t] ?? t}: ${cap.reason}`);
160
+ }
161
+ }
162
+ if (!d.probed && allOK) {
163
+ allOK = false;
164
+ blockers.push(d.probeError || "couldn't reach this machine");
165
+ }
166
+ const label = (d.alias && d.alias.length > 0 ? d.alias : d.name) +
167
+ (d.isLocal ? ' (this phone’s primary)' : '');
168
+ return (
169
+ <Pressable
170
+ key={d.deviceId}
171
+ disabled={!allOK || shipping}
172
+ onPress={() => triggerDeploy(d.deviceId)}
173
+ style={({ pressed }) => [
174
+ styles.row,
175
+ !allOK && styles.rowDisabled,
176
+ pressed && allOK && styles.rowPressed,
177
+ ]}
178
+ >
179
+ <Text style={styles.rowName}>{label}</Text>
180
+ <Text style={[styles.rowMeta, !allOK && styles.rowMetaWarning]}>
181
+ {d.platform} {'·'} {allOK ? 'ready' : blockers.join(' · ')}
182
+ </Text>
183
+ </Pressable>
184
+ );
185
+ };
186
+
187
+ const triggerDeploy = async (machine: string) => {
188
+ if (!options) return;
189
+ setShipping(true);
190
+ setStatus(`starting deploy on ${prettyMachineName(machine)}…`);
191
+ setStatusTone('progress');
192
+ const cfg = YaverFeedback.getConfig();
193
+ if (!cfg?.agentUrl) {
194
+ setStatus('Not connected to a Yaver agent yet.');
195
+ setStatusTone('error');
196
+ setShipping(false);
197
+ return;
198
+ }
199
+ const targets = pickedTargets();
200
+ const body: Record<string, unknown> = {
201
+ app: options.app,
202
+ machine,
203
+ };
204
+ if (targets.length === 1) {
205
+ body.target = targets[0];
206
+ } else {
207
+ body.targets = targets;
208
+ }
209
+ try {
210
+ const resp = await fetch(`${cfg.agentUrl.replace(/\/$/, '')}/deploy/ship`, {
211
+ method: 'POST',
212
+ headers: { ...baseAuthHeaders(), 'Content-Type': 'application/json' },
213
+ body: JSON.stringify(body),
214
+ });
215
+ if (!resp.ok) {
216
+ const text = await resp.text().catch(() => '');
217
+ throw new Error(`ship failed (${resp.status}): ${text || resp.statusText}`);
218
+ }
219
+ setStatus('deploy started — track progress in the desktop / web tab');
220
+ setStatusTone('success');
221
+ // Auto-close shortly so the user can keep using their app. Keep
222
+ // this in sync with the iOS / Android pane delays.
223
+ setTimeout(() => onClose(), 1600);
224
+ } catch (err: unknown) {
225
+ setStatus(err instanceof Error ? err.message : String(err));
226
+ setStatusTone('error');
227
+ } finally {
228
+ setShipping(false);
229
+ }
230
+ };
231
+
232
+ const prettyMachineName = (id: string): string => {
233
+ const d = options?.devices.find((x) => x.deviceId === id);
234
+ if (!d) return id;
235
+ return d.alias && d.alias.length > 0 ? d.alias : d.name;
236
+ };
237
+
238
+ const targetButton = (value: SelectedTarget, label: string) => (
239
+ <Pressable
240
+ key={value}
241
+ onPress={() => setSelected(value)}
242
+ style={[styles.segBtn, selected === value && styles.segBtnSelected]}
243
+ >
244
+ <Text style={[styles.segText, selected === value && styles.segTextSelected]}>{label}</Text>
245
+ </Pressable>
246
+ );
247
+
248
+ return (
249
+ <View style={styles.container}>
250
+ <View style={styles.headerRow}>
251
+ <Text style={styles.title}>Deploy</Text>
252
+ <Pressable onPress={onClose} hitSlop={10}>
253
+ <Text style={styles.closeIcon}>✕</Text>
254
+ </Pressable>
255
+ </View>
256
+ <Text style={styles.subtitle}>
257
+ {loading
258
+ ? 'loading machines…'
259
+ : options
260
+ ? `${options.devices.length} machine${options.devices.length === 1 ? '' : 's'} — pick a target, then tap to deploy`
261
+ : 'no data yet'}
262
+ </Text>
263
+
264
+ <View style={styles.segment}>
265
+ {targetButton('testflight', 'TestFlight')}
266
+ {targetButton('playstore', 'Play Store')}
267
+ {targetButton('both', 'Both')}
268
+ </View>
269
+
270
+ {loading ? (
271
+ <View style={styles.loading}>
272
+ <ActivityIndicator color="rgba(255,255,255,0.6)" />
273
+ </View>
274
+ ) : error ? (
275
+ <Text style={styles.error}>{error}</Text>
276
+ ) : options ? (
277
+ <ScrollView style={styles.list}>{options.devices.map(machineRow)}</ScrollView>
278
+ ) : null}
279
+
280
+ {status && (
281
+ <Text
282
+ style={[
283
+ styles.status,
284
+ statusTone === 'success' && styles.statusSuccess,
285
+ statusTone === 'error' && styles.statusError,
286
+ ]}
287
+ >
288
+ {status}
289
+ </Text>
290
+ )}
291
+ </View>
292
+ );
293
+ };
294
+
295
+ const styles = StyleSheet.create({
296
+ container: {
297
+ backgroundColor: 'rgba(14,12,28,0.92)',
298
+ borderRadius: 16,
299
+ paddingHorizontal: 16,
300
+ paddingTop: 14,
301
+ paddingBottom: 18,
302
+ marginVertical: 8,
303
+ borderWidth: 1,
304
+ borderColor: 'rgba(255,255,255,0.08)',
305
+ },
306
+ headerRow: {
307
+ flexDirection: 'row',
308
+ alignItems: 'center',
309
+ justifyContent: 'space-between',
310
+ },
311
+ title: {
312
+ color: '#fff',
313
+ fontSize: 16,
314
+ fontWeight: '600',
315
+ },
316
+ closeIcon: {
317
+ color: 'rgba(255,255,255,0.55)',
318
+ fontSize: 18,
319
+ paddingHorizontal: 4,
320
+ },
321
+ subtitle: {
322
+ color: 'rgba(255,255,255,0.55)',
323
+ fontSize: 12,
324
+ marginTop: 2,
325
+ },
326
+ segment: {
327
+ flexDirection: 'row',
328
+ backgroundColor: 'rgba(255,255,255,0.08)',
329
+ borderRadius: 10,
330
+ padding: 3,
331
+ marginTop: 14,
332
+ },
333
+ segBtn: {
334
+ flex: 1,
335
+ alignItems: 'center',
336
+ paddingVertical: 7,
337
+ borderRadius: 8,
338
+ },
339
+ segBtnSelected: {
340
+ backgroundColor: 'rgba(127,140,247,0.65)',
341
+ },
342
+ segText: {
343
+ color: 'rgba(255,255,255,0.65)',
344
+ fontSize: 13,
345
+ fontWeight: '500',
346
+ },
347
+ segTextSelected: {
348
+ color: '#fff',
349
+ fontWeight: '600',
350
+ },
351
+ loading: {
352
+ paddingVertical: 32,
353
+ alignItems: 'center',
354
+ },
355
+ error: {
356
+ color: 'rgb(255,115,115)',
357
+ fontSize: 12,
358
+ marginTop: 14,
359
+ },
360
+ list: {
361
+ marginTop: 14,
362
+ maxHeight: 260,
363
+ },
364
+ row: {
365
+ backgroundColor: 'rgba(255,255,255,0.06)',
366
+ borderRadius: 12,
367
+ paddingHorizontal: 14,
368
+ paddingVertical: 12,
369
+ marginBottom: 8,
370
+ },
371
+ rowPressed: {
372
+ backgroundColor: 'rgba(255,255,255,0.10)',
373
+ },
374
+ rowDisabled: {
375
+ opacity: 0.55,
376
+ backgroundColor: 'rgba(255,255,255,0.03)',
377
+ },
378
+ rowName: {
379
+ color: '#fff',
380
+ fontSize: 15,
381
+ fontWeight: '600',
382
+ },
383
+ rowMeta: {
384
+ color: 'rgba(255,255,255,0.55)',
385
+ fontSize: 12,
386
+ marginTop: 2,
387
+ },
388
+ rowMetaWarning: {
389
+ color: 'rgb(255,178,115)',
390
+ },
391
+ status: {
392
+ color: 'rgba(255,255,255,0.55)',
393
+ fontSize: 12,
394
+ marginTop: 12,
395
+ textAlign: 'center',
396
+ },
397
+ statusSuccess: {
398
+ color: 'rgb(34,197,94)',
399
+ },
400
+ statusError: {
401
+ color: 'rgb(255,115,115)',
402
+ },
403
+ });
@@ -12,6 +12,7 @@ import {
12
12
  Text,
13
13
  TextInput,
14
14
  View,
15
+ useWindowDimensions,
15
16
  } from 'react-native';
16
17
  import { YaverFeedback } from './YaverFeedback';
17
18
  import {
@@ -27,6 +28,8 @@ import { uploadFeedback } from './upload';
27
28
  import { DeviceInfo, FeedbackBundle } from './types';
28
29
  import { AuthOverlay } from './AuthOverlay';
29
30
  import { QuickActionIcon } from './QuickActionIcon';
31
+ import { VibeChatScreen } from './VibeChatScreen';
32
+ import { DeployPanel } from './DeployPanel';
30
33
  import { listReachableDevices, RemoteDevice } from './auth';
31
34
  import {
32
35
  QUICK_ICON_COLOR_PRESETS,
@@ -62,6 +65,11 @@ type MachineCardState = {
62
65
  };
63
66
 
64
67
  export const FeedbackModal: React.FC = () => {
68
+ const { width: winW, height: winH } = useWindowDimensions();
69
+ const isTablet = Math.min(winW, winH) >= 600;
70
+ // Tablet color/icon picker fans out to 5/6 cols — 31% (3-col)
71
+ // looks empty on a 1024pt iPad. Mobile keeps 3-col.
72
+ const iconOptionWidthOverride = isTablet ? '18%' : undefined;
65
73
  const [visible, setVisible] = useState(false);
66
74
  const [action, setAction] = useState<ActionState>('idle');
67
75
  const [error, setError] = useState<string | null>(null);
@@ -79,6 +87,7 @@ export const FeedbackModal: React.FC = () => {
79
87
  // "pick something for me" prompt (which in 0.7.13 pointed Claude at
80
88
  // the wrong project because the matcher grepped the prompt itself).
81
89
  const [showVibeInput, setShowVibeInput] = useState(false);
90
+ const [showDeploy, setShowDeploy] = useState(false);
82
91
  const [vibePrompt, setVibePrompt] = useState('');
83
92
  const [lastVibeTaskId, setLastVibeTaskId] = useState<string | null>(null);
84
93
  const [quickIconColorPreset, setQuickIconColorPreset] =
@@ -535,6 +544,17 @@ export const FeedbackModal: React.FC = () => {
535
544
  }
536
545
  }, [showVibeInput, vibePrompt]);
537
546
 
547
+ // Hold the active vibe-chat session — set when handleVibingSubmit
548
+ // returns a fresh taskId. Renders <VibeChatScreen> which streams the
549
+ // SSE transcript, supports multi-turn follow-ups via /tasks/{id}/
550
+ // resume, and exposes a Reload button. Mirrors the in-Yaver native
551
+ // pane's transcript-mode behaviour, just rendered in RN here.
552
+ const [activeVibe, setActiveVibe] = useState<{
553
+ taskId: string;
554
+ initialPrompt: string;
555
+ } | null>(null);
556
+ const [includeScreenshot, setIncludeScreenshot] = useState<boolean>(true);
557
+
538
558
  const handleVibingSubmit = useCallback(async () => {
539
559
  const client = YaverFeedback.getP2PClient();
540
560
  if (!client) {
@@ -554,13 +574,47 @@ export const FeedbackModal: React.FC = () => {
554
574
  .join('\n')
555
575
  : '';
556
576
  const userPrompt = vibePrompt.trim();
557
- const prompt = userPrompt
577
+ const promptText = userPrompt
558
578
  ? userPrompt + errNote
559
579
  : 'Pick the next small improvement or fix for this app based on recent activity and the current screen.' +
560
580
  errNote;
561
- const result = await client.vibing(prompt);
581
+
582
+ // Optional screenshot — captured from the host app's window.
583
+ // captureScreenshotBase64 returns null when react-native-view-
584
+ // shot isn't installed; we skip the screenshot rather than
585
+ // abort the whole feedback in that case.
586
+ let screenshotBase64: string | undefined;
587
+ if (includeScreenshot) {
588
+ const cap = await import('./capture');
589
+ const captured = await cap.captureScreenshotBase64();
590
+ if (captured?.base64) {
591
+ screenshotBase64 = captured.base64;
592
+ }
593
+ }
594
+
595
+ // Resolve project context the same way reloadApp / vibing did.
596
+ const { resolveAppIdentity } = await import('./P2PClient');
597
+ const identity = resolveAppIdentity();
598
+
599
+ // Pull the user's preferred runner / model from local prefs.
600
+ // Both are optional — the agent falls back to whatever runner
601
+ // is signed in if neither is provided.
602
+ const prefs = await import('./preferences');
603
+ const preferredRunner = (await prefs.getPreferredRunner?.()) ?? null;
604
+ const preferredModel = (await prefs.getPreferredModel?.()) ?? null;
605
+
606
+ const result = await client.createFeedbackTask({
607
+ userPrompt: promptText,
608
+ projectName: identity.projectName,
609
+ projectPath: identity.projectPath,
610
+ runner: preferredRunner ?? undefined,
611
+ model: preferredModel ?? undefined,
612
+ screenshotBase64,
613
+ });
562
614
  setLastVibeTaskId(result.taskId);
563
- setToast(`Vibing task ${result.taskId.slice(0, 8)} created`);
615
+ // Hand off to VibeChatScreen — it streams the SSE transcript,
616
+ // accepts follow-ups, and surfaces a Reload button.
617
+ setActiveVibe({ taskId: result.taskId, initialPrompt: promptText });
564
618
  setVibePrompt('');
565
619
  setShowVibeInput(false);
566
620
  } catch (err: unknown) {
@@ -568,7 +622,7 @@ export const FeedbackModal: React.FC = () => {
568
622
  } finally {
569
623
  if (mountedRef.current) setAction('idle');
570
624
  }
571
- }, [vibePrompt]);
625
+ }, [vibePrompt, includeScreenshot]);
572
626
 
573
627
  /*
574
628
  const handleScreenRecording = useCallback(async () => {
@@ -578,6 +632,40 @@ export const FeedbackModal: React.FC = () => {
578
632
 
579
633
  const busy = action !== 'idle';
580
634
 
635
+ // Once the user fires off a vibe task, swap the entire modal body
636
+ // for the live chat screen. The chat manages its own SSE
637
+ // subscription, multi-turn follow-ups, and Reload button. Closing
638
+ // the chat returns to idle and clears the active vibe.
639
+ if (visible && activeVibe) {
640
+ const client = YaverFeedback.getP2PClient();
641
+ return (
642
+ <>
643
+ <AuthOverlay />
644
+ <QuickActionIcon />
645
+ <Modal
646
+ visible={visible}
647
+ animationType="slide"
648
+ transparent
649
+ onRequestClose={() => setActiveVibe(null)}
650
+ >
651
+ {client ? (
652
+ <VibeChatScreen
653
+ client={client}
654
+ initialTaskId={activeVibe.taskId}
655
+ initialUserPrompt={activeVibe.initialPrompt}
656
+ onClose={() => setActiveVibe(null)}
657
+ onReload={async () => {
658
+ const c = YaverFeedback.getP2PClient();
659
+ if (!c) throw new Error('Not connected');
660
+ await c.reloadApp();
661
+ }}
662
+ />
663
+ ) : null}
664
+ </Modal>
665
+ </>
666
+ );
667
+ }
668
+
581
669
  return (
582
670
  <>
583
671
  <AuthOverlay />
@@ -597,7 +685,21 @@ export const FeedbackModal: React.FC = () => {
597
685
  pointerEvents="box-none"
598
686
  >
599
687
  <Pressable
600
- style={styles.modal}
688
+ // Tablet: cap modal width and center as a card-style
689
+ // sheet rather than a phone bottom sheet that stretches
690
+ // across a 12.9" iPad. Phone behaviour unchanged.
691
+ style={[
692
+ styles.modal,
693
+ isTablet
694
+ ? {
695
+ width: '100%',
696
+ maxWidth: 640,
697
+ alignSelf: 'center',
698
+ borderTopLeftRadius: 22,
699
+ borderTopRightRadius: 22,
700
+ }
701
+ : null,
702
+ ]}
601
703
  onPress={(e) => {
602
704
  e.stopPropagation();
603
705
  Keyboard.dismiss();
@@ -703,6 +805,7 @@ export const FeedbackModal: React.FC = () => {
703
805
  }}
704
806
  style={[
705
807
  styles.iconOption,
808
+ iconOptionWidthOverride ? { width: iconOptionWidthOverride } : null,
706
809
  selected && styles.iconOptionSelected,
707
810
  ]}
708
811
  >
@@ -811,6 +914,23 @@ export const FeedbackModal: React.FC = () => {
811
914
  busy={action === 'capturing'}
812
915
  />
813
916
 
917
+ {/* Deploy — opens an inline panel that talks to
918
+ /fleet/deploy-options on the agent and lets the user
919
+ pick TestFlight / Play / Both, then a machine to run
920
+ it on. Capabilities (e.g. "Linux can't TestFlight")
921
+ come from the agent's doctor probes — no client-side
922
+ platform smarts here. */}
923
+ {!showDeploy ? (
924
+ <ActionRow
925
+ label="Deploy"
926
+ tint="#7f8cf7"
927
+ onPress={() => setShowDeploy(true)}
928
+ disabled={busy}
929
+ />
930
+ ) : (
931
+ <DeployPanel onClose={() => setShowDeploy(false)} />
932
+ )}
933
+
814
934
  {/* Remote sign-in buttons — trigger codex/claude device-auth
815
935
  on the selected agent without leaving the app. Opens a
816
936
  small native modal showing the verification URL + 8-char
@@ -11,6 +11,7 @@ import {
11
11
  TextInput,
12
12
  TouchableOpacity,
13
13
  View,
14
+ useWindowDimensions,
14
15
  } from 'react-native';
15
16
  import { YaverFeedback } from './YaverFeedback';
16
17
  import { FixReport } from './FixReport';
@@ -58,6 +59,15 @@ export interface FloatingButtonProps {
58
59
 
59
60
  const DEFAULT_SIZE = 40;
60
61
  const DEFAULT_COLOR = '#6366f1';
62
+
63
+ // Tablet detection — short-edge dp >= 600 means iPad / 7"+ Android
64
+ // tablet / Z Fold open. The SDK has no app-side responsive context
65
+ // to lean on (it's a guest in third-party apps), so we infer
66
+ // locally and bump the button + panel to tablet sizes.
67
+ const TABLET_SHORT_EDGE = 600;
68
+ function isTabletWindow(width: number, height: number): boolean {
69
+ return Math.min(width, height) >= TABLET_SHORT_EDGE;
70
+ }
61
71
  const DEFAULT_PANEL_BG = '#2d2d2d';
62
72
 
63
73
  /**
@@ -101,7 +111,16 @@ export const FloatingButton: React.FC<FloatingButtonProps> = ({
101
111
  healthCheckInterval = 5000,
102
112
  panelBackgroundColor,
103
113
  }) => {
104
- const { width: screenWidth } = Dimensions.get('window');
114
+ // Read window size live so the SDK overlay re-pins itself when
115
+ // the host app rotates or splits. The legacy snapshot via
116
+ // Dimensions.get only ran once and parked the button off-screen
117
+ // after orientation changes on iPad.
118
+ const { width: screenWidth, height: screenHeight } = useWindowDimensions();
119
+ const isTablet = isTabletWindow(screenWidth, screenHeight);
120
+ // Tablets get a larger touch target and a wider panel — phones
121
+ // keep the existing 40 / 280 defaults so guest apps aren't
122
+ // disrupted on small screens.
123
+ const effectiveSize = isTablet ? Math.max(size, 52) : size;
105
124
  const defaultX = initialPosition?.x ?? 10;
106
125
  const defaultY = initialPosition?.y ?? 90;
107
126
 
@@ -535,7 +554,12 @@ export const FloatingButton: React.FC<FloatingButtonProps> = ({
535
554
  const buttonIcon = icon ?? 'y';
536
555
  const btnBg = isConnected ? color : `${color}88`;
537
556
 
538
- const panelWidth = fullSize ? screenWidth - 24 : 280;
557
+ // Panel sizing tablets get a wider compact panel (420) and a
558
+ // capped full-size panel (max 720 instead of full window) so the
559
+ // overlay doesn't dwarf the host app on a 12.9" iPad.
560
+ const compactPanelWidth = isTablet ? 420 : 280;
561
+ const fullPanelWidth = isTablet ? Math.min(screenWidth - 24, 720) : screenWidth - 24;
562
+ const panelWidth = fullSize ? fullPanelWidth : compactPanelWidth;
539
563
 
540
564
  return (
541
565
  <Animated.View
@@ -730,7 +754,7 @@ export const FloatingButton: React.FC<FloatingButtonProps> = ({
730
754
  style={[
731
755
  s.button,
732
756
  isTerminal ? s.buttonTerminal : s.buttonMinimal,
733
- { backgroundColor: btnBg, width: size, height: size },
757
+ { backgroundColor: btnBg, width: effectiveSize, height: effectiveSize },
734
758
  !isTerminal && { borderRadius: size / 2 },
735
759
  ]}
736
760
  activeOpacity={0.7}