yaver-feedback-react-native 0.2.0

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,860 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ Animated,
5
+ Dimensions,
6
+ Keyboard,
7
+ PanResponder,
8
+ ScrollView,
9
+ StyleSheet,
10
+ Text,
11
+ TextInput,
12
+ TouchableOpacity,
13
+ View,
14
+ } from 'react-native';
15
+ import { YaverFeedback } from './YaverFeedback';
16
+ import { FixReport } from './FixReport';
17
+ import type { TestSession } from './types';
18
+ import { BlackBox } from './BlackBox';
19
+
20
+ export interface FloatingButtonProps {
21
+ /** Called when user taps the button (opens inline console by default). */
22
+ onPress?: () => void;
23
+ /** Initial position. Default: top-left corner, below status bar. */
24
+ initialPosition?: { x: number; y: number };
25
+ /** Button size in pixels. Default: 40. */
26
+ size?: number;
27
+ /**
28
+ * Button background color. Default: "#6366f1" (indigo).
29
+ * Use a distinctive color so the debug button is never confused
30
+ * with your app's UI. Suggested: pink, purple, lime.
31
+ */
32
+ color?: string;
33
+ /** Show connection status dot on the button. Default: true. */
34
+ showStatusDot?: boolean;
35
+ /**
36
+ * Style preset:
37
+ * - "terminal" (default) — dark terminal look with >_ icon, monospace font, pink accents
38
+ * - "minimal" — small circle, single-letter icon, clean panel
39
+ */
40
+ style?: 'terminal' | 'minimal';
41
+ /** Custom icon text. Default: "y". */
42
+ icon?: string;
43
+ /** Agent base URL (auto-detected from YaverFeedback config if omitted). */
44
+ agentUrl?: string;
45
+ /** Auth token (auto-detected from YaverFeedback config if omitted). */
46
+ authToken?: string;
47
+ /**
48
+ * Health check interval in ms. The button polls the agent's /health
49
+ * endpoint to show connection status. Default: 5000. Set to 0 to disable.
50
+ */
51
+ healthCheckInterval?: number;
52
+ /**
53
+ * Background color of the debug console panel.
54
+ * Default: "#2d2d2d" (dark gray). Override to match your app's theme.
55
+ */
56
+ panelBackgroundColor?: string;
57
+ }
58
+
59
+ const DEFAULT_SIZE = 40;
60
+ const DEFAULT_COLOR = '#6366f1';
61
+ const DEFAULT_PANEL_BG = '#2d2d2d';
62
+
63
+ /**
64
+ * Draggable debug console button for the Yaver Feedback SDK.
65
+ *
66
+ * Drop this into any React Native app for an instant debug console
67
+ * with message back-and-forth, hot reload, and build+deploy:
68
+ *
69
+ * ```tsx
70
+ * import { FloatingButton } from '@yaver/feedback-react-native';
71
+ *
72
+ * function App() {
73
+ * return (
74
+ * <>
75
+ * <YourApp />
76
+ * <FloatingButton />
77
+ * </>
78
+ * );
79
+ * }
80
+ * ```
81
+ *
82
+ * Features:
83
+ * - **Tap** → expand terminal-style console panel
84
+ * - **Drag** → reposition anywhere
85
+ * - **Type** → send tasks to the AI agent, see responses
86
+ * - **Hot Reload** → trigger hot reload
87
+ * - **Build iOS** → build + auto-submit to TestFlight
88
+ * - **Build Android** → build + auto-submit to Play Store
89
+ * - **"quit"** → disable the SDK
90
+ */
91
+ export const FloatingButton: React.FC<FloatingButtonProps> = ({
92
+ onPress,
93
+ initialPosition,
94
+ size = DEFAULT_SIZE,
95
+ color = DEFAULT_COLOR,
96
+ showStatusDot = true,
97
+ style: stylePreset = 'terminal',
98
+ icon,
99
+ agentUrl: agentUrlProp,
100
+ authToken: authTokenProp,
101
+ healthCheckInterval = 5000,
102
+ panelBackgroundColor,
103
+ }) => {
104
+ const { width: screenWidth } = Dimensions.get('window');
105
+ const defaultX = initialPosition?.x ?? 10;
106
+ const defaultY = initialPosition?.y ?? 90;
107
+
108
+ const pan = useRef(new Animated.ValueXY({ x: defaultX, y: defaultY })).current;
109
+ const isDragging = useRef(false);
110
+ const [chatOpen, setChatOpen] = useState(false);
111
+ const [fullSize, setFullSize] = useState(false);
112
+ const [message, setMessage] = useState('');
113
+ const [sending, setSending] = useState(false);
114
+ const [output, setOutput] = useState<string[]>([]);
115
+ const [reloading, setReloading] = useState(false);
116
+ const [isConnected, setIsConnected] = useState(false);
117
+ const [testSession, setTestSession] = useState<TestSession | null>(null);
118
+ const [showFixReport, setShowFixReport] = useState(false);
119
+ const testPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
120
+ const outputScrollRef = useRef<ScrollView>(null);
121
+
122
+ // Resolve agent URL and token
123
+ const config = YaverFeedback.getConfig();
124
+ const agentUrl = agentUrlProp || config?.agentUrl;
125
+ const authToken = authTokenProp || config?.authToken;
126
+ const panelBg = panelBackgroundColor || config?.panelBackgroundColor || DEFAULT_PANEL_BG;
127
+
128
+ const addOutput = useCallback((line: string) => {
129
+ setOutput((prev) => [...prev.slice(-20), line]);
130
+ }, []);
131
+
132
+ // Connection health polling
133
+ useEffect(() => {
134
+ if (!healthCheckInterval || !agentUrl) return;
135
+
136
+ const check = async () => {
137
+ try {
138
+ const client = YaverFeedback.getP2PClient();
139
+ if (client) {
140
+ setIsConnected(await client.health());
141
+ } else if (agentUrl) {
142
+ const controller = new AbortController();
143
+ const timeout = setTimeout(() => controller.abort(), 3000);
144
+ const resp = await fetch(`${agentUrl.replace(/\/$/, '')}/health`, {
145
+ signal: controller.signal,
146
+ });
147
+ clearTimeout(timeout);
148
+ setIsConnected(resp.ok);
149
+ }
150
+ } catch {
151
+ setIsConnected(false);
152
+ }
153
+ };
154
+
155
+ check();
156
+ const interval = setInterval(check, healthCheckInterval);
157
+ return () => clearInterval(interval);
158
+ }, [agentUrl, healthCheckInterval]);
159
+
160
+ const panResponder = useRef(
161
+ PanResponder.create({
162
+ onStartShouldSetPanResponder: () => true,
163
+ onMoveShouldSetPanResponder: (_, gs) =>
164
+ Math.abs(gs.dx) > 4 || Math.abs(gs.dy) > 4,
165
+ onPanResponderGrant: () => {
166
+ pan.extractOffset();
167
+ isDragging.current = false;
168
+ },
169
+ onPanResponderMove: (_, gs) => {
170
+ if (Math.abs(gs.dx) > 4 || Math.abs(gs.dy) > 4) isDragging.current = true;
171
+ Animated.event([null, { dx: pan.x, dy: pan.y }], { useNativeDriver: false })(_, gs);
172
+ },
173
+ onPanResponderRelease: () => pan.flattenOffset(),
174
+ }),
175
+ ).current;
176
+
177
+ const handleTap = useCallback(() => {
178
+ if (isDragging.current) return;
179
+ if (onPress) {
180
+ onPress();
181
+ } else {
182
+ setChatOpen((prev) => !prev);
183
+ }
184
+ }, [onPress]);
185
+
186
+ // Send message → create task → poll for response
187
+ const handleSend = useCallback(async () => {
188
+ if (!message.trim() || !agentUrl || !authToken) return;
189
+ const msg = message.trim();
190
+ setSending(true);
191
+ setMessage('');
192
+ Keyboard.dismiss();
193
+ addOutput(`> ${msg}`);
194
+
195
+ try {
196
+ const url = agentUrl.replace(/\/$/, '');
197
+ const resp = await fetch(`${url}/tasks`, {
198
+ method: 'POST',
199
+ headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
200
+ body: JSON.stringify({ title: msg, source: 'feedback-console' }),
201
+ });
202
+ if (!resp.ok) {
203
+ addOutput(`err: ${resp.status}`);
204
+ setSending(false);
205
+ return;
206
+ }
207
+ const data = await resp.json();
208
+ const taskId = data.taskId ?? data.id ?? data.task?.id;
209
+ if (!taskId) {
210
+ addOutput('task created (no id)');
211
+ setSending(false);
212
+ return;
213
+ }
214
+ addOutput(`task ${taskId} started...`);
215
+ BlackBox.log(`Console task: ${msg}`, 'FloatingButton');
216
+
217
+ // Poll task output for up to 60s
218
+ let attempts = 0;
219
+ const poll = setInterval(async () => {
220
+ attempts++;
221
+ try {
222
+ const sr = await fetch(`${url}/tasks/${taskId}`, {
223
+ headers: { Authorization: `Bearer ${authToken}` },
224
+ });
225
+ if (!sr.ok) { clearInterval(poll); setSending(false); return; }
226
+ const task = await sr.json();
227
+ const t = task.task ?? task;
228
+
229
+ if (t.status === 'completed' || t.status === 'failed' || t.status === 'stopped') {
230
+ const out = t.output ?? t.rawOutput ?? '';
231
+ if (out) {
232
+ const lines = out.split('\n').filter((l: string) => l.trim());
233
+ for (const l of lines.slice(-5)) addOutput(l.slice(0, 80));
234
+ }
235
+ addOutput(t.status === 'completed' ? 'done.' : `${t.status}.`);
236
+ clearInterval(poll);
237
+ setSending(false);
238
+ } else if (attempts >= 30) {
239
+ addOutput('running in background...');
240
+ clearInterval(poll);
241
+ setSending(false);
242
+ }
243
+ } catch { clearInterval(poll); setSending(false); }
244
+ }, 2000);
245
+ } catch (e) {
246
+ addOutput(`fail: ${String(e).slice(0, 50)}`);
247
+ setSending(false);
248
+ }
249
+ }, [message, agentUrl, authToken, addOutput]);
250
+
251
+ // Generic action: send task to agent, poll for output
252
+ const runAgentAction = useCallback(async (label: string, prompt: string) => {
253
+ if (!agentUrl || !authToken) return;
254
+ addOutput(`> ${label}`);
255
+ setSending(true);
256
+ try {
257
+ const url = agentUrl.replace(/\/$/, '');
258
+ const resp = await fetch(`${url}/tasks`, {
259
+ method: 'POST',
260
+ headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
261
+ body: JSON.stringify({
262
+ title: prompt,
263
+ source: 'feedback-sdk',
264
+ description: `[Feedback SDK] User triggered "${label}" from the debug console.`,
265
+ }),
266
+ });
267
+ if (!resp.ok) { addOutput(`err: ${resp.status}`); setSending(false); return; }
268
+ const data = await resp.json();
269
+ const taskId = data.taskId ?? data.id ?? data.task?.id;
270
+ if (!taskId) { addOutput('started (no id)'); setSending(false); return; }
271
+ addOutput(`${label}: task ${taskId}...`);
272
+ BlackBox.log(`Action: ${label}`, 'FloatingButton');
273
+
274
+ // Poll output
275
+ let attempts = 0;
276
+ const poll = setInterval(async () => {
277
+ attempts++;
278
+ try {
279
+ const sr = await fetch(`${url}/tasks/${taskId}`, {
280
+ headers: { Authorization: `Bearer ${authToken}` },
281
+ });
282
+ if (!sr.ok) { clearInterval(poll); setSending(false); return; }
283
+ const task = await sr.json();
284
+ const t = task.task ?? task;
285
+ if (t.status === 'completed' || t.status === 'failed' || t.status === 'stopped') {
286
+ const out = t.output ?? t.rawOutput ?? '';
287
+ if (out) {
288
+ for (const l of out.split('\n').filter((l: string) => l.trim()).slice(-5)) {
289
+ addOutput(l.slice(0, 80));
290
+ }
291
+ }
292
+ addOutput(t.status === 'completed' ? 'done.' : `${t.status}.`);
293
+ clearInterval(poll); setSending(false);
294
+ } else if (attempts >= 60) {
295
+ addOutput('running in background...');
296
+ clearInterval(poll); setSending(false);
297
+ }
298
+ } catch { clearInterval(poll); setSending(false); }
299
+ }, 2000);
300
+ } catch (e) {
301
+ addOutput(`fail: ${String(e).slice(0, 50)}`);
302
+ setSending(false);
303
+ }
304
+ }, [agentUrl, authToken, addOutput]);
305
+
306
+ const handleReload = useCallback(() => {
307
+ // @ts-ignore — __DEV__ is defined by React Native bundler
308
+ const isDev = typeof __DEV__ !== 'undefined' ? __DEV__ : true;
309
+ if (isDev) {
310
+ runAgentAction('hot-reload', 'Hot reload the app. Send the reload signal to the dev server to trigger a fast refresh.');
311
+ } else {
312
+ runAgentAction(
313
+ 'rebuild',
314
+ 'This is a release build — hot reload is not available. ' +
315
+ 'Rebuild the app using native tools (xcodebuild for iOS, gradle for Android — no Expo) ' +
316
+ 'and upload to TestFlight/Play Store. Auto-increment build number. Report progress.',
317
+ );
318
+ }
319
+ }, [runAgentAction]);
320
+
321
+ // Build config from SDK settings
322
+ const buildPlatforms = config?.buildPlatforms ?? 'both';
323
+ const autoDeploy = config?.autoDeploy !== false; // default true
324
+
325
+ const handleBuild = useCallback(() => {
326
+ const platforms = buildPlatforms === 'both' ? ['ios', 'android'] : [buildPlatforms];
327
+ const parts: string[] = [];
328
+ for (const p of platforms) {
329
+ if (p === 'ios') {
330
+ parts.push(
331
+ autoDeploy
332
+ ? 'Build the iOS app, archive, and upload to TestFlight. Auto-increment the build number.'
333
+ : 'Build the iOS app and archive it locally. Auto-increment the build number. Do NOT upload to TestFlight.',
334
+ );
335
+ } else if (p === 'android') {
336
+ parts.push(
337
+ autoDeploy
338
+ ? 'Build the Android app (release AAB) and upload to Google Play internal testing. Auto-increment the versionCode.'
339
+ : 'Build the Android app (release AAB) locally. Auto-increment the versionCode. Do NOT upload to Play Store.',
340
+ );
341
+ } else if (p === 'web') {
342
+ parts.push('Build the web app for production.');
343
+ }
344
+ }
345
+ const deployLabel = autoDeploy ? ' + deploy' : '';
346
+ const platformLabel = buildPlatforms === 'both' ? 'iOS & Android' : buildPlatforms;
347
+ runAgentAction(
348
+ `build-${platformLabel}${deployLabel}`,
349
+ parts.join(' Then, ') + ' Report progress and result.',
350
+ );
351
+ }, [runAgentAction, buildPlatforms, autoDeploy]);
352
+
353
+ const handleBugReport = useCallback(async () => {
354
+ if (!agentUrl || !authToken) return;
355
+ addOutput('> bug report');
356
+ setSending(true);
357
+ try {
358
+ // Try to capture screenshot (without SDK overlay)
359
+ let screenshotUri: string | undefined;
360
+ try {
361
+ const { captureScreenshot } = require('./capture');
362
+ screenshotUri = await captureScreenshot();
363
+ } catch {
364
+ // Screenshot capture not available — send text-only report
365
+ }
366
+
367
+ const url = agentUrl.replace(/\/$/, '');
368
+ if (screenshotUri) {
369
+ // Upload screenshot as feedback with bug flag
370
+ const formData = new FormData();
371
+ formData.append('metadata', JSON.stringify({
372
+ timestamp: new Date().toISOString(),
373
+ type: 'bug-report',
374
+ source: 'feedback-sdk',
375
+ }));
376
+ formData.append('screenshot_0', {
377
+ uri: screenshotUri,
378
+ type: 'image/png',
379
+ name: 'bug_screenshot.png',
380
+ } as any);
381
+ const resp = await fetch(`${url}/feedback`, {
382
+ method: 'POST',
383
+ headers: { Authorization: `Bearer ${authToken}` },
384
+ body: formData,
385
+ });
386
+ if (resp.ok) {
387
+ addOutput('screenshot captured & sent');
388
+ } else {
389
+ addOutput(`screenshot upload err: ${resp.status}`);
390
+ }
391
+ }
392
+
393
+ // Also create a task so the agent investigates
394
+ const resp = await fetch(`${url}/tasks`, {
395
+ method: 'POST',
396
+ headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
397
+ body: JSON.stringify({
398
+ title: 'Bug report from device — investigate the attached screenshot and fix any visible issues.',
399
+ source: 'feedback-sdk',
400
+ description: '[Feedback SDK] User tapped the bug report button. A screenshot of the current screen was captured and sent. Investigate the UI state and fix any issues.',
401
+ hasScreenshot: !!screenshotUri,
402
+ }),
403
+ });
404
+ if (resp.ok) {
405
+ const data = await resp.json();
406
+ const taskId = data.taskId ?? data.id ?? data.task?.id;
407
+ addOutput(taskId ? `bug task ${taskId} created` : 'bug report sent');
408
+ BlackBox.log('Bug report submitted', 'FloatingButton');
409
+ } else {
410
+ addOutput(`err: ${resp.status}`);
411
+ }
412
+ } catch (e) {
413
+ addOutput(`fail: ${String(e).slice(0, 50)}`);
414
+ } finally {
415
+ setSending(false);
416
+ }
417
+ }, [agentUrl, authToken, addOutput]);
418
+
419
+ // Start autonomous test session
420
+ const handleTestApp = useCallback(async () => {
421
+ if (!agentUrl || !authToken) return;
422
+ const isRunning = testSession?.active;
423
+
424
+ if (isRunning) {
425
+ // Stop test session
426
+ addOutput('> stopping test...');
427
+ try {
428
+ await fetch(`${agentUrl.replace(/\/$/, '')}/test-app/stop`, {
429
+ method: 'POST',
430
+ headers: { Authorization: `Bearer ${authToken}` },
431
+ });
432
+ addOutput('test session stopped.');
433
+ if (testPollRef.current) clearInterval(testPollRef.current);
434
+ testPollRef.current = null;
435
+ // Fetch final report
436
+ try {
437
+ const resp = await fetch(`${agentUrl.replace(/\/$/, '')}/test-app/status`, {
438
+ headers: { Authorization: `Bearer ${authToken}` },
439
+ });
440
+ if (resp.ok) {
441
+ const session: TestSession = await resp.json();
442
+ setTestSession(session);
443
+ if (session.fixes.length > 0) {
444
+ addOutput(`${session.fixes.length} fixes applied (not committed).`);
445
+ addOutput('tap "fixes" to view report.');
446
+ setShowFixReport(true);
447
+ }
448
+ }
449
+ } catch {}
450
+ } catch (e) {
451
+ addOutput(`fail: ${String(e).slice(0, 50)}`);
452
+ }
453
+ return;
454
+ }
455
+
456
+ // Start test session
457
+ addOutput('> starting autonomous test...');
458
+ setSending(true);
459
+ try {
460
+ const url = agentUrl.replace(/\/$/, '');
461
+ const resp = await fetch(`${url}/test-app/start`, {
462
+ method: 'POST',
463
+ headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
464
+ body: JSON.stringify({ source: 'feedback-sdk' }),
465
+ });
466
+ if (!resp.ok) {
467
+ addOutput(`err: ${resp.status}`);
468
+ setSending(false);
469
+ return;
470
+ }
471
+ const data = await resp.json();
472
+ addOutput(`test session ${data.sessionId ?? 'started'}...`);
473
+ addOutput('agent reading codebase for context...');
474
+ BlackBox.log('Test session started', 'FloatingButton');
475
+
476
+ // Poll test status every 3s
477
+ testPollRef.current = setInterval(async () => {
478
+ try {
479
+ const sr = await fetch(`${url}/test-app/status`, {
480
+ headers: { Authorization: `Bearer ${authToken}` },
481
+ });
482
+ if (sr.ok) {
483
+ const session: TestSession = await sr.json();
484
+ setTestSession(session);
485
+ if (!session.active && testPollRef.current) {
486
+ clearInterval(testPollRef.current);
487
+ testPollRef.current = null;
488
+ addOutput(`test complete: ${session.errorsFound} errors, ${session.fixes.length} fixes.`);
489
+ if (session.fixes.length > 0) {
490
+ addOutput('tap "fixes" to view report.');
491
+ }
492
+ setSending(false);
493
+ }
494
+ }
495
+ } catch {}
496
+ }, 3000);
497
+ } catch (e) {
498
+ addOutput(`fail: ${String(e).slice(0, 50)}`);
499
+ setSending(false);
500
+ }
501
+ }, [agentUrl, authToken, addOutput, testSession]);
502
+
503
+ // Cleanup test poll on unmount
504
+ useEffect(() => {
505
+ return () => {
506
+ if (testPollRef.current) clearInterval(testPollRef.current);
507
+ };
508
+ }, []);
509
+
510
+ const handleDisable = useCallback(() => {
511
+ YaverFeedback.setEnabled(false);
512
+ setChatOpen(false);
513
+ }, []);
514
+
515
+ // Auto-scroll output
516
+ useEffect(() => {
517
+ if (outputScrollRef.current) {
518
+ setTimeout(() => outputScrollRef.current?.scrollToEnd({ animated: true }), 50);
519
+ }
520
+ }, [output]);
521
+
522
+ const isTerminal = stylePreset === 'terminal';
523
+ const buttonIcon = icon ?? 'y';
524
+ const btnBg = isConnected ? color : `${color}88`;
525
+
526
+ const panelWidth = fullSize ? screenWidth - 24 : 280;
527
+
528
+ return (
529
+ <Animated.View
530
+ style={[s.root, { transform: [{ translateX: pan.x }, { translateY: pan.y }] }]}
531
+ {...panResponder.panHandlers}
532
+ >
533
+ {/* Console panel */}
534
+ {chatOpen && (
535
+ <View style={[
536
+ s.panel,
537
+ isTerminal ? s.panelTerminal : s.panelMinimal,
538
+ { borderColor: `${color}44`, width: panelWidth, backgroundColor: panelBg },
539
+ fullSize && { position: 'absolute', left: 0, top: size + 8 },
540
+ ]}>
541
+ {/* Header */}
542
+ <View style={s.headerRow}>
543
+ <Text style={[s.headerTitle, isTerminal && s.mono, { color }]}>
544
+ {isTerminal ? 'YAVER DEBUG' : 'Yaver'}
545
+ </Text>
546
+ <View style={[s.dotSmall, isConnected ? s.green : s.red]} />
547
+ <Text style={[s.headerStatus, isTerminal && s.mono]}>
548
+ {isConnected ? 'live' : 'off'}
549
+ </Text>
550
+ <TouchableOpacity onPress={() => setFullSize(!fullSize)} style={s.xBtn}>
551
+ <Text style={s.xBtnText}>{fullSize ? '\u25A1' : '\u2197'}</Text>
552
+ </TouchableOpacity>
553
+ <TouchableOpacity onPress={() => { setChatOpen(false); setFullSize(false); }} style={s.xBtn}>
554
+ <Text style={s.xBtnText}>{'\u2715'}</Text>
555
+ </TouchableOpacity>
556
+ </View>
557
+
558
+ {/* Output area */}
559
+ <ScrollView
560
+ ref={outputScrollRef}
561
+ style={[s.outputArea, fullSize && s.outputAreaFull]}
562
+ contentContainerStyle={s.outputContent}
563
+ >
564
+ {output.length > 0 ? output.map((line, i) => (
565
+ <Text
566
+ key={i}
567
+ style={[
568
+ s.outputLine,
569
+ isTerminal && s.mono,
570
+ fullSize && s.outputLineFull,
571
+ line.startsWith('>')
572
+ ? s.outputLineUser
573
+ : line === 'done.' || line.startsWith('task ')
574
+ ? s.outputLineStatus
575
+ : s.outputLineAgent,
576
+ ]}
577
+ >
578
+ {line}
579
+ </Text>
580
+ )) : (
581
+ <Text style={[s.outputLine, isTerminal && s.mono, { color: '#666' }]}>
582
+ {isConnected ? 'connected. type a message or use actions below.' : 'not connected to agent.'}
583
+ </Text>
584
+ )}
585
+ {sending && <ActivityIndicator color={color} size="small" style={{ marginTop: 4 }} />}
586
+ </ScrollView>
587
+
588
+ {/* Input */}
589
+ <View style={s.inputRow}>
590
+ {isTerminal && <Text style={[s.prompt, { color }]}>&gt;</Text>}
591
+ <TextInput
592
+ style={[s.input, isTerminal && s.mono, fullSize && s.inputFull]}
593
+ placeholder={isTerminal ? 'tell the agent...' : 'Type a message...'}
594
+ placeholderTextColor="#444"
595
+ value={message}
596
+ onChangeText={setMessage}
597
+ onSubmitEditing={handleSend}
598
+ returnKeyType="send"
599
+ multiline={fullSize}
600
+ />
601
+ <TouchableOpacity
602
+ style={[s.goBtn, { backgroundColor: color }, (sending || !message.trim()) && s.dim]}
603
+ onPress={handleSend}
604
+ disabled={sending || !message.trim() || !isConnected}
605
+ >
606
+ {sending ? (
607
+ <ActivityIndicator color="#fff" size="small" />
608
+ ) : (
609
+ <Text style={[s.goBtnText, isTerminal && s.mono]}>
610
+ {isTerminal ? 'run' : 'Send'}
611
+ </Text>
612
+ )}
613
+ </TouchableOpacity>
614
+ </View>
615
+
616
+ {/* Action cards row 1 — Reload | Build | Bug */}
617
+ <View style={s.cardRow}>
618
+ <TouchableOpacity
619
+ style={[s.card, fullSize && s.cardFull, !isConnected && s.dim]}
620
+ onPress={handleReload}
621
+ disabled={sending || !isConnected}
622
+ >
623
+ <Text style={[s.cardIcon, { color: '#fbbf24' }]}>{'\u21BB'}</Text>
624
+ <Text style={[s.cardLabel, isTerminal && s.mono]}>Hot Reload</Text>
625
+ </TouchableOpacity>
626
+ <TouchableOpacity
627
+ style={[s.card, fullSize && s.cardFull, !isConnected && s.dim]}
628
+ onPress={handleBuild}
629
+ disabled={sending || !isConnected}
630
+ >
631
+ <Text style={[s.cardIcon, { color: '#60a5fa' }]}>{'\u2692'}</Text>
632
+ <Text style={[s.cardLabel, isTerminal && s.mono]}>
633
+ {buildPlatforms === 'both' ? 'Build' : `Build ${buildPlatforms}`}
634
+ </Text>
635
+ {autoDeploy && (
636
+ <Text style={[s.cardSub, isTerminal && s.mono]}>
637
+ {buildPlatforms === 'ios' ? '+ TestFlight'
638
+ : buildPlatforms === 'android' ? '+ Play Store'
639
+ : buildPlatforms === 'both' ? '+ Deploy'
640
+ : ''}
641
+ </Text>
642
+ )}
643
+ </TouchableOpacity>
644
+ <TouchableOpacity
645
+ style={[s.card, fullSize && s.cardFull, !isConnected && s.dim]}
646
+ onPress={handleBugReport}
647
+ disabled={sending || !isConnected}
648
+ >
649
+ <Text style={[s.cardIcon, { color: '#f87171' }]}>{'\u{1F41B}'}</Text>
650
+ <Text style={[s.cardLabel, isTerminal && s.mono]}>Report Bug</Text>
651
+ </TouchableOpacity>
652
+ </View>
653
+
654
+ {/* Action cards row 2 — Test | Fixes */}
655
+ <View style={[s.cardRow, { marginTop: -2 }]}>
656
+ <TouchableOpacity
657
+ style={[s.card, fullSize && s.cardFull, !isConnected && s.dim,
658
+ testSession?.active && { borderColor: '#a78bfa44', backgroundColor: '#a78bfa08' }]}
659
+ onPress={handleTestApp}
660
+ disabled={!isConnected}
661
+ >
662
+ <Text style={[s.cardIcon, { color: '#a78bfa' }]}>
663
+ {testSession?.active ? '\u23F8' : '\u25B6'}
664
+ </Text>
665
+ <Text style={[s.cardLabel, isTerminal && s.mono]}>
666
+ {testSession?.active ? 'Stop Test' : 'Test App'}
667
+ </Text>
668
+ {testSession?.active && (
669
+ <Text style={[s.cardSub, isTerminal && s.mono]}>
670
+ {testSession.screensTested}/{testSession.screensDiscovered}
671
+ </Text>
672
+ )}
673
+ </TouchableOpacity>
674
+ {testSession && testSession.fixes.length > 0 && (
675
+ <TouchableOpacity
676
+ style={[s.card, fullSize && s.cardFull]}
677
+ onPress={() => setShowFixReport(true)}
678
+ >
679
+ <Text style={[s.cardIcon, { color: '#22c55e' }]}>{'\u2713'}</Text>
680
+ <Text style={[s.cardLabel, isTerminal && s.mono]}>
681
+ {testSession.fixes.length} Fixes
682
+ </Text>
683
+ </TouchableOpacity>
684
+ )}
685
+ </View>
686
+
687
+ {/* Bottom row */}
688
+ <View style={s.actionsRow}>
689
+ <TouchableOpacity style={s.actionBtn} onPress={() => setOutput([])}>
690
+ <Text style={[s.actionText, isTerminal && s.mono]}>clear</Text>
691
+ </TouchableOpacity>
692
+ <TouchableOpacity
693
+ style={s.actionBtn}
694
+ onPress={() => {
695
+ setChatOpen(false);
696
+ YaverFeedback.startReport();
697
+ }}
698
+ >
699
+ <Text style={[s.actionText, isTerminal && s.mono]}>report</Text>
700
+ </TouchableOpacity>
701
+ <TouchableOpacity style={s.actionBtn} onPress={handleDisable}>
702
+ <Text style={[s.actionText, isTerminal && s.mono, { color: '#f87171' }]}>quit</Text>
703
+ </TouchableOpacity>
704
+ </View>
705
+ </View>
706
+ )}
707
+
708
+ {/* Fix Report Modal */}
709
+ <FixReport
710
+ session={testSession}
711
+ visible={showFixReport}
712
+ onClose={() => setShowFixReport(false)}
713
+ color={color}
714
+ />
715
+
716
+ {/* The button */}
717
+ <TouchableOpacity
718
+ style={[
719
+ s.button,
720
+ isTerminal ? s.buttonTerminal : s.buttonMinimal,
721
+ { backgroundColor: btnBg, width: size, height: size },
722
+ !isTerminal && { borderRadius: size / 2 },
723
+ ]}
724
+ activeOpacity={0.7}
725
+ onPress={handleTap}
726
+ >
727
+ <Text style={[s.buttonIcon, isTerminal && s.mono, { fontSize: 22 }]}>
728
+ {chatOpen ? '\u2715' : buttonIcon}
729
+ </Text>
730
+ {showStatusDot && (
731
+ <View style={[s.statusDot, isConnected ? s.green : s.red]} />
732
+ )}
733
+ </TouchableOpacity>
734
+ </Animated.View>
735
+ );
736
+ };
737
+
738
+ const s = StyleSheet.create({
739
+ root: { position: 'absolute', zIndex: 99999, alignItems: 'flex-start' },
740
+ mono: { fontFamily: 'Courier' },
741
+ // Button variants
742
+ button: {
743
+ alignItems: 'center',
744
+ justifyContent: 'center',
745
+ shadowColor: '#000',
746
+ shadowOffset: { width: 0, height: 3 },
747
+ shadowOpacity: 0.5,
748
+ shadowRadius: 5,
749
+ elevation: 10,
750
+ },
751
+ buttonTerminal: { borderRadius: 10 },
752
+ buttonMinimal: { /* borderRadius set inline */ },
753
+ buttonIcon: { color: '#fff', fontWeight: '800', fontStyle: 'italic' as const },
754
+ statusDot: {
755
+ position: 'absolute',
756
+ top: -2,
757
+ right: -2,
758
+ width: 9,
759
+ height: 9,
760
+ borderRadius: 5,
761
+ borderWidth: 1.5,
762
+ borderColor: '#000',
763
+ },
764
+ green: { backgroundColor: '#22c55e' },
765
+ red: { backgroundColor: '#ef4444' },
766
+ // Panel
767
+ panel: {
768
+ padding: 10,
769
+ marginBottom: 6,
770
+ borderWidth: 1,
771
+ shadowColor: '#000',
772
+ shadowOffset: { width: 0, height: 4 },
773
+ shadowOpacity: 0.5,
774
+ shadowRadius: 8,
775
+ elevation: 12,
776
+ },
777
+ panelTerminal: {
778
+ borderRadius: 12,
779
+ },
780
+ panelMinimal: {
781
+ borderRadius: 16,
782
+ },
783
+ // Header
784
+ headerRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 6, gap: 5 },
785
+ headerTitle: { flex: 1, fontSize: 11, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 1 },
786
+ dotSmall: { width: 6, height: 6, borderRadius: 3 },
787
+ headerStatus: { fontSize: 10, color: '#666' },
788
+ xBtn: { paddingHorizontal: 6, paddingVertical: 2 },
789
+ xBtnText: { color: '#666', fontSize: 12 },
790
+ // Output
791
+ outputArea: {
792
+ backgroundColor: '#1a1a1a',
793
+ borderRadius: 8,
794
+ padding: 8,
795
+ marginBottom: 6,
796
+ maxHeight: 140,
797
+ },
798
+ outputAreaFull: { maxHeight: 300, minHeight: 160 },
799
+ outputContent: { paddingBottom: 4 },
800
+ outputLine: {
801
+ fontSize: 11,
802
+ lineHeight: 16,
803
+ },
804
+ outputLineUser: {
805
+ color: '#9ca3af',
806
+ fontStyle: 'italic' as const,
807
+ },
808
+ outputLineAgent: {
809
+ color: '#e5e5e5',
810
+ },
811
+ outputLineStatus: {
812
+ color: '#22c55e',
813
+ },
814
+ outputLineFull: { fontSize: 13, lineHeight: 20 },
815
+ // Input
816
+ inputRow: { flexDirection: 'row', alignItems: 'center', gap: 4, marginBottom: 6 },
817
+ prompt: { fontSize: 14, fontWeight: '700' },
818
+ input: {
819
+ flex: 1,
820
+ backgroundColor: '#1a1a1a',
821
+ borderRadius: 6,
822
+ paddingHorizontal: 8,
823
+ paddingVertical: 6,
824
+ color: '#e5e5e5',
825
+ fontSize: 12,
826
+ borderWidth: 1,
827
+ borderColor: '#3a3a3a',
828
+ },
829
+ inputFull: { fontSize: 15, paddingVertical: 10 },
830
+ goBtn: { borderRadius: 6, paddingHorizontal: 10, paddingVertical: 6 },
831
+ goBtnText: { color: '#fff', fontSize: 11, fontWeight: '700' },
832
+ dim: { opacity: 0.3 },
833
+ // Action cards
834
+ cardRow: { flexDirection: 'row', gap: 6, marginBottom: 6 },
835
+ card: {
836
+ flex: 1,
837
+ backgroundColor: '#1a1a1a',
838
+ borderRadius: 8,
839
+ paddingVertical: 10,
840
+ alignItems: 'center',
841
+ borderWidth: 1,
842
+ borderColor: '#3a3a3a',
843
+ },
844
+ cardFull: { paddingVertical: 14 },
845
+ cardIcon: { fontSize: 18, marginBottom: 2 },
846
+ cardLabel: { fontSize: 10, color: '#999', fontWeight: '600' },
847
+ cardSub: { fontSize: 8, color: '#555', marginTop: 1 },
848
+ // Bottom actions
849
+ actionsRow: { flexDirection: 'row', gap: 4 },
850
+ actionBtn: {
851
+ flex: 1,
852
+ paddingVertical: 5,
853
+ borderRadius: 6,
854
+ alignItems: 'center',
855
+ backgroundColor: '#1a1a1a',
856
+ borderWidth: 1,
857
+ borderColor: '#3a3a3a',
858
+ },
859
+ actionText: { fontSize: 10, color: '#999', fontWeight: '600' },
860
+ });