@10play/expo-air 0.12.1 → 0.12.2

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.
@@ -1,48 +1,15 @@
1
- import React, { useState, useEffect, useCallback, useRef } from "react";
2
- import { View, Text, StyleSheet, NativeModules, TouchableOpacity, Animated, Easing, Linking, type TextProps, type ViewProps } from "react-native";
1
+ import React, { useState, useEffect, useRef, useCallback } from "react";
2
+ import { View, Text, StyleSheet } from "react-native";
3
3
  import { PromptInput, type PromptInputHandle } from "./components/PromptInput";
4
4
  import { ResponseArea } from "./components/ResponseArea";
5
5
  import { GitChangesTab } from "./components/GitChangesTab";
6
- import {
7
- createWebSocketClient,
8
- getWebSocketClient,
9
- type ServerMessage,
10
- type ConnectionStatus,
11
- type GitChange,
12
- type BranchInfo,
13
- type AnyConversationEntry,
14
- type AssistantPart,
15
- type AssistantPartsMessage,
16
- type ImageAttachment,
17
- } from "./services/websocket";
18
- import { requestPushToken, setupTapHandler } from "./services/notifications";
19
6
  import { BranchSwitcher } from "./components/BranchSwitcher";
20
- import { SPACING, LAYOUT, COLORS, TYPOGRAPHY, SIZES } from "./constants/design";
21
-
22
- // Typed animated components for React 19 compatibility
23
- const AnimatedText = Animated.Text as React.ComponentClass<Animated.AnimatedProps<TextProps>>;
24
- const AnimatedView = Animated.View as React.ComponentClass<Animated.AnimatedProps<ViewProps>>;
25
-
26
- // WidgetBridge is a simple native module available in the widget runtime
27
- // ExpoAir is the main app's module (fallback)
28
- const { WidgetBridge, ExpoAir } = NativeModules;
29
-
30
- function handleCollapse() {
31
- try {
32
- // Try WidgetBridge first (widget runtime), then ExpoAir (main app)
33
- if (WidgetBridge?.collapse) {
34
- WidgetBridge.collapse();
35
- } else if (ExpoAir?.collapse) {
36
- ExpoAir.collapse();
37
- } else {
38
- console.warn("[expo-air] No collapse method available");
39
- }
40
- } catch (e) {
41
- console.warn("[expo-air] Failed to collapse:", e);
42
- }
43
- }
44
-
45
- type TabType = "chat" | "changes";
7
+ import { Header, PulsingIndicator } from "./components/Header";
8
+ import { TabBar, type TabType } from "./components/TabBar";
9
+ import { useWebSocketMessages } from "./hooks/useWebSocketMessages";
10
+ import { useGitState } from "./hooks/useGitState";
11
+ import type { ServerMessage } from "./services/websocket";
12
+ import { LAYOUT, COLORS, SPACING, TYPOGRAPHY } from "./constants/design";
46
13
 
47
14
  interface BubbleContentProps {
48
15
  size?: number;
@@ -57,26 +24,35 @@ export function BubbleContent({
57
24
  expanded = false,
58
25
  serverUrl = "ws://localhost:3847",
59
26
  }: BubbleContentProps) {
60
- const [status, setStatus] = useState<ConnectionStatus>("disconnected");
61
- const [messages, setMessages] = useState<ServerMessage[]>([]);
62
- const [currentParts, setCurrentParts] = useState<AssistantPart[]>([]);
63
- const [branchName, setBranchName] = useState<string>("main");
64
- const [gitChanges, setGitChanges] = useState<GitChange[]>([]);
65
- const [hasPR, setHasPR] = useState(false);
66
- const [prUrl, setPrUrl] = useState<string | undefined>();
67
27
  const [activeTab, setActiveTab] = useState<TabType>("chat");
68
- const [showBranchSwitcher, setShowBranchSwitcher] = useState(false);
69
- const [branches, setBranches] = useState<BranchInfo[]>([]);
70
- const [branchesLoading, setBranchesLoading] = useState(false);
71
- const [branchError, setBranchError] = useState<string | null>(null);
72
- const previousBranchRef = useRef<string>("main");
73
- const pushTokenSentRef = useRef(false);
74
- const partIdCounter = useRef(0);
75
- // Use refs to avoid stale closure issues in handleMessage callback
76
- const currentPartsRef = useRef<AssistantPart[]>([]);
77
- const currentPromptIdRef = useRef<string | null>(null);
78
28
  const promptInputRef = useRef<PromptInputHandle>(null);
79
29
 
30
+ // Use a ref to break the circular dependency between the two hooks:
31
+ // useWebSocketMessages needs onGitMessage from useGitState
32
+ // useGitState needs handleSubmit from useWebSocketMessages
33
+ const gitMessageHandlerRef = useRef<(msg: ServerMessage) => void>(() => {});
34
+ const onGitMessage = useCallback((msg: ServerMessage) => {
35
+ gitMessageHandlerRef.current(msg);
36
+ }, []);
37
+
38
+ const {
39
+ status,
40
+ messages,
41
+ currentParts,
42
+ handleSubmit,
43
+ handleNewSession,
44
+ handleStop,
45
+ } = useWebSocketMessages({
46
+ serverUrl,
47
+ onGitMessage,
48
+ });
49
+
50
+ const git = useGitState({ handleSubmit, setActiveTab });
51
+
52
+ // Keep the ref in sync — WebSocket connects asynchronously so this
53
+ // will be set before any git messages arrive
54
+ gitMessageHandlerRef.current = git.handleGitMessage;
55
+
80
56
  // Auto-focus input when widget expands
81
57
  useEffect(() => {
82
58
  if (expanded && activeTab === "chat") {
@@ -85,357 +61,6 @@ export function BubbleContent({
85
61
  }
86
62
  }, [expanded]);
87
63
 
88
- // Extract PR number from URL (e.g., "https://github.com/org/repo/pull/12" → "12")
89
- const prNumber = prUrl?.match(/\/pull\/(\d+)/)?.[1];
90
-
91
- // Initialize WebSocket connection immediately (even when collapsed)
92
- // so it's already connected when user expands the widget
93
- useEffect(() => {
94
- console.log("[expo-air] Connecting to:", serverUrl?.replace(/([?&])secret=[^&]+/, "$1secret=***"));
95
- const client = createWebSocketClient({
96
- url: serverUrl,
97
- onStatusChange: setStatus,
98
- onMessage: handleMessage,
99
- onError: (error) => {
100
- console.warn("[expo-air] WebSocket error:", error);
101
- },
102
- });
103
- client.connect();
104
-
105
- return () => {
106
- client.disconnect();
107
- };
108
- }, [serverUrl]);
109
-
110
- // Setup notification tap handler (dev-only, expands widget on tap)
111
- useEffect(() => {
112
- const cleanup = setupTapHandler((promptId, success) => {
113
- // When user taps notification, ensure WebSocket is connected
114
- const client = getWebSocketClient();
115
- if (client && !client.isConnected()) {
116
- client.connect();
117
- }
118
- // The native side handles expanding the widget when app opens from notification
119
- });
120
- return cleanup;
121
- }, []);
122
-
123
- // Helper to finalize current parts into a message
124
- const finalizeCurrentParts = useCallback((promptId: string, isComplete: boolean) => {
125
- const parts = currentPartsRef.current;
126
- if (parts.length > 0) {
127
- const partsMsg: AssistantPartsMessage = {
128
- type: "assistant_parts",
129
- promptId,
130
- parts,
131
- isComplete,
132
- timestamp: Date.now(),
133
- };
134
- setMessages((prev) => [...prev, partsMsg]);
135
- }
136
- currentPartsRef.current = [];
137
- currentPromptIdRef.current = null;
138
- setCurrentParts([]);
139
- }, []);
140
-
141
- const handleMessage = useCallback((message: ServerMessage) => {
142
- switch (message.type) {
143
- case "stream":
144
- // Handle new prompt starting
145
- if (message.promptId !== currentPromptIdRef.current) {
146
- // New response - finalize any previous parts first
147
- if (currentPartsRef.current.length > 0 && currentPromptIdRef.current) {
148
- finalizeCurrentParts(currentPromptIdRef.current, false);
149
- }
150
- currentPromptIdRef.current = message.promptId;
151
- }
152
- // Add text chunk to current parts
153
- if (!message.done && message.chunk) {
154
- const parts = currentPartsRef.current;
155
- const lastPart = parts[parts.length - 1];
156
- if (lastPart?.type === "text") {
157
- // Append to existing text part
158
- lastPart.content += message.chunk;
159
- currentPartsRef.current = [...parts];
160
- } else {
161
- // Create new text part
162
- currentPartsRef.current = [...parts, {
163
- type: "text",
164
- id: `text-${partIdCounter.current++}`,
165
- content: message.chunk
166
- }];
167
- }
168
- setCurrentParts([...currentPartsRef.current]);
169
- }
170
- break;
171
- case "tool":
172
- // Only add completed/failed tools to parts (skip "started")
173
- if (message.status !== "started") {
174
- const toolPart: AssistantPart = {
175
- type: "tool",
176
- id: `tool-${partIdCounter.current++}`,
177
- toolName: message.toolName,
178
- status: message.status,
179
- input: message.input,
180
- output: message.output,
181
- timestamp: message.timestamp,
182
- };
183
- currentPartsRef.current = [...currentPartsRef.current, toolPart];
184
- setCurrentParts([...currentPartsRef.current]);
185
- }
186
- break;
187
- case "result":
188
- // Finalize parts into a message
189
- if (currentPartsRef.current.length > 0) {
190
- const partsMsg: AssistantPartsMessage = {
191
- type: "assistant_parts",
192
- promptId: message.promptId,
193
- parts: currentPartsRef.current,
194
- isComplete: true,
195
- timestamp: message.timestamp,
196
- };
197
- // Add result message for metadata (cost, duration) only — strip result.result
198
- // to avoid duplicating the content that's already in partsMsg with proper formatting
199
- const hasMetadata = message.costUsd !== undefined || message.durationMs !== undefined || (!message.success && message.error);
200
- if (hasMetadata) {
201
- const metadataOnly = { ...message, result: undefined };
202
- setMessages((prev) => [...prev, partsMsg, metadataOnly]);
203
- } else {
204
- setMessages((prev) => [...prev, partsMsg]);
205
- }
206
- } else if (message.costUsd !== undefined || message.durationMs !== undefined || (!message.success && message.error)) {
207
- setMessages((prev) => [...prev, message]);
208
- }
209
- currentPartsRef.current = [];
210
- currentPromptIdRef.current = null;
211
- setCurrentParts([]);
212
- break;
213
- case "error":
214
- // Finalize any partial parts and add error
215
- if (currentPartsRef.current.length > 0 && currentPromptIdRef.current) {
216
- const partsMsg: AssistantPartsMessage = {
217
- type: "assistant_parts",
218
- promptId: currentPromptIdRef.current,
219
- parts: currentPartsRef.current,
220
- isComplete: false,
221
- timestamp: Date.now(),
222
- };
223
- setMessages((prev) => [...prev, partsMsg, message]);
224
- } else {
225
- setMessages((prev) => [...prev, message]);
226
- }
227
- currentPartsRef.current = [];
228
- currentPromptIdRef.current = null;
229
- setCurrentParts([]);
230
- break;
231
- case "status":
232
- // Status is handled by the status indicator
233
- break;
234
- case "session_cleared":
235
- // Clear all messages for new session
236
- setMessages([]);
237
- currentPartsRef.current = [];
238
- currentPromptIdRef.current = null;
239
- partIdCounter.current = 0;
240
- setCurrentParts([]);
241
- break;
242
- case "stopped":
243
- // Preserve partial work when stopped
244
- if (currentPartsRef.current.length > 0 && currentPromptIdRef.current) {
245
- finalizeCurrentParts(currentPromptIdRef.current, false);
246
- } else {
247
- currentPartsRef.current = [];
248
- currentPromptIdRef.current = null;
249
- setCurrentParts([]);
250
- }
251
- break;
252
- case "history":
253
- // Convert history entries to displayable messages
254
- const historyMessages: ServerMessage[] = message.entries.flatMap((entry: AnyConversationEntry): ServerMessage[] => {
255
- if (entry.role === "user") {
256
- return [{
257
- type: "user_prompt" as const,
258
- content: entry.content,
259
- timestamp: entry.timestamp,
260
- }];
261
- } else if (entry.role === "assistant") {
262
- return [{
263
- type: "history_result" as const,
264
- content: entry.content,
265
- timestamp: entry.timestamp,
266
- }];
267
- } else if (entry.role === "tool") {
268
- // Reconstruct tool message from persisted entry
269
- return [{
270
- type: "tool" as const,
271
- promptId: "",
272
- toolName: entry.toolName,
273
- status: entry.status,
274
- input: entry.input,
275
- output: entry.output,
276
- timestamp: entry.timestamp,
277
- }];
278
- } else if (entry.role === "system") {
279
- // Reconstruct system message (errors, stopped, etc.) from persisted entry
280
- return [{
281
- type: "system_message" as const,
282
- messageType: entry.type,
283
- content: entry.content,
284
- timestamp: entry.timestamp,
285
- }];
286
- }
287
- return [];
288
- });
289
- setMessages(historyMessages);
290
- break;
291
- case "git_status":
292
- // Update branch name, git changes, and PR status
293
- setBranchName(message.branchName);
294
- setGitChanges(message.changes);
295
- setHasPR(message.hasPR);
296
- setPrUrl(message.prUrl);
297
- break;
298
- case "branches_list":
299
- setBranches(message.branches);
300
- setBranchesLoading(false);
301
- break;
302
- case "branch_switched":
303
- if (message.success) {
304
- setBranchError(null);
305
- } else if (message.error) {
306
- // Revert optimistic update on failure
307
- const prev = previousBranchRef.current;
308
- setBranchName(prev);
309
- setBranches((b) =>
310
- b.map((br) => ({ ...br, isCurrent: br.name === prev }))
311
- );
312
- setShowBranchSwitcher(true);
313
- setBranchError(message.error);
314
- }
315
- break;
316
- case "branch_created":
317
- if (message.success) {
318
- setShowBranchSwitcher(false);
319
- setBranchError(null);
320
- } else if (message.error) {
321
- setBranchError(message.error);
322
- }
323
- break;
324
- }
325
- }, [finalizeCurrentParts]);
326
-
327
- const handleSubmit = useCallback(async (prompt: string, images?: ImageAttachment[]) => {
328
- // Request push token on first submit (dev-only, lazy permission)
329
- if (!pushTokenSentRef.current) {
330
- const token = await requestPushToken();
331
- if (token) {
332
- const client = getWebSocketClient();
333
- if (client?.isConnected()) {
334
- client.sendPushToken(token);
335
- pushTokenSentRef.current = true;
336
- }
337
- }
338
- }
339
-
340
- // Add user prompt to messages for display (with local image URIs)
341
- setMessages((prev) => [
342
- ...prev,
343
- {
344
- type: "user_prompt" as const,
345
- content: prompt,
346
- images,
347
- timestamp: Date.now(),
348
- },
349
- ]);
350
- // Reset current response state
351
- currentPartsRef.current = [];
352
- currentPromptIdRef.current = null;
353
- setCurrentParts([]);
354
-
355
- // Send prompt immediately with local file paths
356
- // The server runs on the same machine and can read simulator temp files directly
357
- const imagePaths = images && images.length > 0
358
- ? images.map((img) => img.uri)
359
- : undefined;
360
-
361
- const client = getWebSocketClient();
362
- if (client) {
363
- client.sendPrompt(prompt, imagePaths);
364
- }
365
- }, []);
366
-
367
- const handleNewSession = useCallback(() => {
368
- const client = getWebSocketClient();
369
- if (client) {
370
- client.requestNewSession();
371
- }
372
- }, []);
373
-
374
- const handleStop = useCallback(() => {
375
- const client = getWebSocketClient();
376
- if (client) {
377
- client.requestStop();
378
- }
379
- }, []);
380
-
381
- const handleCommit = useCallback(() => {
382
- setActiveTab("chat");
383
- handleSubmit("Look at my current git changes and create a commit with a good conventional commit message. Stage all changes, commit them, and push to the remote.");
384
- }, [handleSubmit]);
385
-
386
- const handleCreatePR = useCallback(() => {
387
- setActiveTab("chat");
388
- handleSubmit("Create a pull request for my current branch. First commit any uncommitted changes with a good message. Then generate a title and description based on the commits, and use `gh pr create --title \"...\" --body \"...\"` (non-interactive mode) to create it. Push to remote first if needed.");
389
- }, [handleSubmit]);
390
-
391
- const handleViewPR = useCallback(() => {
392
- if (prUrl) {
393
- Linking.openURL(prUrl);
394
- }
395
- }, [prUrl]);
396
-
397
- const handleDiscard = useCallback(() => {
398
- const client = getWebSocketClient();
399
- if (client) {
400
- client.requestDiscardChanges();
401
- }
402
- }, []);
403
-
404
- const handleBranchPress = useCallback(() => {
405
- setShowBranchSwitcher((prev) => !prev);
406
- // Fetch branches when opening (side-effect outside state updater)
407
- if (!showBranchSwitcher) {
408
- setBranchesLoading(true);
409
- const client = getWebSocketClient();
410
- if (client) {
411
- client.requestBranches();
412
- }
413
- }
414
- }, [showBranchSwitcher]);
415
-
416
- const handleBranchSelect = useCallback((name: string) => {
417
- setBranchError(null);
418
- // Optimistically update UI before server confirms
419
- previousBranchRef.current = branchName;
420
- setBranchName(name);
421
- setBranches((prev) =>
422
- prev.map((b) => ({ ...b, isCurrent: b.name === name }))
423
- );
424
- setShowBranchSwitcher(false);
425
- const client = getWebSocketClient();
426
- if (client) {
427
- client.requestSwitchBranch(name);
428
- }
429
- }, [branchName]);
430
-
431
- const handleBranchCreate = useCallback((name: string) => {
432
- setBranchError(null);
433
- const client = getWebSocketClient();
434
- if (client) {
435
- client.requestCreateBranch(name);
436
- }
437
- }, []);
438
-
439
64
  // Collapsed: Just a pulsing indicator, no text
440
65
  if (!expanded) {
441
66
  return (
@@ -450,20 +75,20 @@ export function BubbleContent({
450
75
  <View style={styles.expanded}>
451
76
  <Header
452
77
  status={status}
453
- branchName={branchName}
454
- onBranchPress={handleBranchPress}
78
+ branchName={git.branchName}
79
+ onBranchPress={git.handleBranchPress}
455
80
  />
456
81
  <TabBar
457
82
  activeTab={activeTab}
458
83
  onTabChange={setActiveTab}
459
84
  onNewSession={handleNewSession}
460
85
  canStartNew={status === "connected"}
461
- hasPR={hasPR}
462
- hasChanges={gitChanges.length > 0}
463
- prNumber={prNumber}
464
- onCreatePR={handleCreatePR}
465
- onCommit={handleCommit}
466
- onViewPR={handleViewPR}
86
+ hasPR={git.hasPR}
87
+ hasChanges={git.gitChanges.length > 0}
88
+ prNumber={git.prNumber}
89
+ onCreatePR={git.handleCreatePR}
90
+ onCommit={git.handleCommit}
91
+ onViewPR={git.handleViewPR}
467
92
  />
468
93
  <View style={styles.body}>
469
94
  {status === "disconnected" && messages.length === 0 ? (
@@ -471,7 +96,7 @@ export function BubbleContent({
471
96
  ) : activeTab === "chat" ? (
472
97
  <ResponseArea messages={messages} currentParts={currentParts} />
473
98
  ) : (
474
- <GitChangesTab changes={gitChanges} onDiscard={handleDiscard} />
99
+ <GitChangesTab changes={git.gitChanges} onDiscard={git.handleDiscard} />
475
100
  )}
476
101
  </View>
477
102
  {activeTab === "chat" && status !== "disconnected" && (
@@ -483,15 +108,14 @@ export function BubbleContent({
483
108
  isProcessing={status === "processing"}
484
109
  />
485
110
  )}
486
- {showBranchSwitcher && (
111
+ {git.showBranchSwitcher && (
487
112
  <BranchSwitcher
488
- branches={branches}
489
- currentBranch={branchName}
490
- loading={branchesLoading}
491
- onSelect={handleBranchSelect}
492
- onCreate={handleBranchCreate}
493
- onClose={() => { setShowBranchSwitcher(false); setBranchError(null); }}
494
- error={branchError}
113
+ branches={git.branches}
114
+ currentBranch={git.branchName}
115
+ onSelect={git.handleBranchSelect}
116
+ onCreate={git.handleBranchCreate}
117
+ onClose={() => { git.setShowBranchSwitcher(false); git.setBranchError(null); }}
118
+ error={git.branchError}
495
119
  />
496
120
  )}
497
121
  </View>
@@ -512,230 +136,7 @@ function DisconnectedView() {
512
136
  );
513
137
  }
514
138
 
515
- interface HeaderProps {
516
- status: ConnectionStatus;
517
- branchName: string;
518
- onBranchPress: () => void;
519
- }
520
-
521
- function Header({ status, branchName, onBranchPress }: HeaderProps) {
522
- const statusColors = {
523
- disconnected: COLORS.STATUS_ERROR,
524
- connecting: COLORS.STATUS_INFO,
525
- connected: COLORS.STATUS_SUCCESS,
526
- processing: COLORS.STATUS_INFO,
527
- };
528
-
529
- return (
530
- <View style={styles.header}>
531
- <TouchableOpacity onPress={handleCollapse} style={styles.closeButton}>
532
- <Text style={styles.closeButtonText}>✕</Text>
533
- </TouchableOpacity>
534
-
535
- <TouchableOpacity style={styles.branchButton} onPress={onBranchPress}>
536
- <Text style={styles.branchName} numberOfLines={1}>
537
- {branchName}
538
- </Text>
539
- <Text style={styles.branchChevron}>▾</Text>
540
- </TouchableOpacity>
541
-
542
- <View style={[styles.statusDot, { backgroundColor: statusColors[status] }]} />
543
- </View>
544
- );
545
- }
546
-
547
- interface TabBarProps {
548
- activeTab: TabType;
549
- onTabChange: (tab: TabType) => void;
550
- onNewSession: () => void;
551
- canStartNew: boolean;
552
- hasPR: boolean;
553
- hasChanges: boolean;
554
- prNumber?: string;
555
- onCreatePR: () => void;
556
- onCommit: () => void;
557
- onViewPR: () => void;
558
- }
559
-
560
- function TabBar({
561
- activeTab,
562
- onTabChange,
563
- onNewSession,
564
- canStartNew,
565
- hasPR,
566
- hasChanges,
567
- prNumber,
568
- onCreatePR,
569
- onCommit,
570
- onViewPR,
571
- }: TabBarProps) {
572
- // Determine which CTA to show for Changes tab
573
- const renderCTA = () => {
574
- if (activeTab === "chat") {
575
- return (
576
- <TouchableOpacity
577
- onPress={onNewSession}
578
- style={[styles.ctaButton, !canStartNew && styles.ctaButtonDisabled]}
579
- disabled={!canStartNew}
580
- >
581
- <Text style={[styles.ctaText, !canStartNew && styles.ctaTextDisabled]}>New</Text>
582
- </TouchableOpacity>
583
- );
584
- }
585
-
586
- // Changes tab - show smart CTA with breathing animation
587
- if (!hasPR && hasChanges) {
588
- return <BreathingButton onPress={onCreatePR}>Create PR</BreathingButton>;
589
- }
590
- if (hasPR && hasChanges) {
591
- return <BreathingButton onPress={onCommit}>Commit</BreathingButton>;
592
- }
593
- if (hasPR && !hasChanges && prNumber) {
594
- return <BreathingButton onPress={onViewPR}>#{prNumber}</BreathingButton>;
595
- }
596
- return null; // no PR + no changes = nothing
597
- };
598
-
599
- return (
600
- <View style={styles.tabBar}>
601
- <View style={styles.tabButtons}>
602
- <TouchableOpacity onPress={() => onTabChange("chat")}>
603
- <Text style={[
604
- styles.tabText,
605
- activeTab === "chat" ? styles.tabTextActive : styles.tabTextInactive
606
- ]}>
607
- Chat
608
- </Text>
609
- </TouchableOpacity>
610
- <TouchableOpacity onPress={() => onTabChange("changes")}>
611
- <Text style={[
612
- styles.tabText,
613
- activeTab === "changes" ? styles.tabTextActive : styles.tabTextInactive
614
- ]}>
615
- Changes
616
- </Text>
617
- </TouchableOpacity>
618
- </View>
619
- {renderCTA()}
620
- </View>
621
- );
622
- }
623
-
624
- function BreathingButton({ children, onPress }: React.PropsWithChildren<{ onPress: () => void }>) {
625
- const opacityAnim = useRef(new Animated.Value(0.6)).current;
626
-
627
- useEffect(() => {
628
- const animation = Animated.loop(
629
- Animated.sequence([
630
- Animated.timing(opacityAnim, {
631
- toValue: 0.9,
632
- duration: 1500,
633
- easing: Easing.inOut(Easing.ease),
634
- useNativeDriver: true,
635
- }),
636
- Animated.timing(opacityAnim, {
637
- toValue: 0.6,
638
- duration: 1500,
639
- easing: Easing.inOut(Easing.ease),
640
- useNativeDriver: true,
641
- }),
642
- ])
643
- );
644
- animation.start();
645
- return () => animation.stop();
646
- }, [opacityAnim]);
647
-
648
- return (
649
- <TouchableOpacity onPress={onPress} style={styles.ctaButton} activeOpacity={0.7}>
650
- <AnimatedText style={[styles.ctaText, { opacity: opacityAnim }]}>
651
- {children}
652
- </AnimatedText>
653
- </TouchableOpacity>
654
- );
655
- }
656
-
657
- function PulsingIndicator({ status }: { status: ConnectionStatus }) {
658
- const colors = {
659
- disconnected: COLORS.STATUS_ERROR,
660
- connecting: COLORS.STATUS_INFO,
661
- connected: COLORS.STATUS_SUCCESS,
662
- processing: COLORS.STATUS_INFO,
663
- };
664
-
665
- const isAnimating = status === "processing" || status === "connecting";
666
-
667
- // Animated values for the pulsing ring
668
- const scaleAnim = useRef(new Animated.Value(1)).current;
669
- const opacityAnim = useRef(new Animated.Value(0.4)).current;
670
-
671
- useEffect(() => {
672
- if (isAnimating) {
673
- // Create a soft pulsing animation
674
- const pulseAnimation = Animated.loop(
675
- Animated.sequence([
676
- Animated.parallel([
677
- Animated.timing(scaleAnim, {
678
- toValue: 1.3,
679
- duration: 1200,
680
- easing: Easing.inOut(Easing.ease),
681
- useNativeDriver: true,
682
- }),
683
- Animated.timing(opacityAnim, {
684
- toValue: 0,
685
- duration: 1200,
686
- easing: Easing.inOut(Easing.ease),
687
- useNativeDriver: true,
688
- }),
689
- ]),
690
- Animated.parallel([
691
- Animated.timing(scaleAnim, {
692
- toValue: 1,
693
- duration: 0,
694
- useNativeDriver: true,
695
- }),
696
- Animated.timing(opacityAnim, {
697
- toValue: 0.4,
698
- duration: 0,
699
- useNativeDriver: true,
700
- }),
701
- ]),
702
- ])
703
- );
704
- pulseAnimation.start();
705
- return () => pulseAnimation.stop();
706
- } else {
707
- // Reset when not animating
708
- scaleAnim.setValue(1);
709
- opacityAnim.setValue(0.4);
710
- }
711
- }, [isAnimating, scaleAnim, opacityAnim]);
712
-
713
- return (
714
- <View style={styles.indicatorContainer}>
715
- <View
716
- style={[
717
- styles.indicator,
718
- { backgroundColor: colors[status] },
719
- ]}
720
- />
721
- {isAnimating && (
722
- <AnimatedView
723
- style={[
724
- styles.indicatorRing,
725
- {
726
- borderColor: colors[status],
727
- transform: [{ scale: scaleAnim }],
728
- opacity: opacityAnim,
729
- },
730
- ]}
731
- />
732
- )}
733
- </View>
734
- );
735
- }
736
-
737
139
  const styles = StyleSheet.create({
738
- // Collapsed: just show centered indicator
739
140
  collapsedPill: {
740
141
  width: 100,
741
142
  height: 32,
@@ -743,118 +144,12 @@ const styles = StyleSheet.create({
743
144
  alignItems: "center",
744
145
  justifyContent: "center",
745
146
  },
746
- indicatorContainer: {
747
- width: 20,
748
- height: 20,
749
- alignItems: "center",
750
- justifyContent: "center",
751
- },
752
- indicator: {
753
- width: SIZES.STATUS_DOT,
754
- height: SIZES.STATUS_DOT,
755
- borderRadius: SIZES.STATUS_DOT / 2,
756
- },
757
- indicatorRing: {
758
- position: "absolute",
759
- width: 16,
760
- height: 16,
761
- borderRadius: 8,
762
- borderWidth: 1.5,
763
- opacity: 0.4,
764
- },
765
- // Expanded panel - fills native container (which handles width/centering)
766
147
  expanded: {
767
148
  flex: 1,
768
149
  backgroundColor: COLORS.BACKGROUND,
769
150
  borderRadius: LAYOUT.BORDER_RADIUS_LG,
770
151
  overflow: "hidden",
771
152
  },
772
- header: {
773
- flexDirection: "row",
774
- alignItems: "center",
775
- paddingHorizontal: LAYOUT.CONTENT_PADDING_H,
776
- paddingVertical: SPACING.MD + 2, // 14px for comfortable header height
777
- borderBottomWidth: 1,
778
- borderBottomColor: COLORS.BORDER,
779
- },
780
- closeButton: {
781
- width: SIZES.CLOSE_BUTTON,
782
- height: SIZES.CLOSE_BUTTON,
783
- borderRadius: SIZES.CLOSE_BUTTON / 2,
784
- // Make invisible - native close button handles the tap
785
- backgroundColor: "transparent",
786
- alignItems: "center",
787
- justifyContent: "center",
788
- marginRight: SPACING.MD,
789
- },
790
- closeButtonText: {
791
- // Hide the text - native button shows the X
792
- color: "transparent",
793
- fontSize: TYPOGRAPHY.SIZE_MD,
794
- fontWeight: TYPOGRAPHY.WEIGHT_SEMIBOLD,
795
- },
796
- branchButton: {
797
- flex: 1,
798
- flexDirection: "row",
799
- alignItems: "center",
800
- },
801
- branchName: {
802
- flexShrink: 1,
803
- color: COLORS.TEXT_SECONDARY,
804
- fontSize: TYPOGRAPHY.SIZE_MD,
805
- fontWeight: TYPOGRAPHY.WEIGHT_MEDIUM,
806
- },
807
- branchChevron: {
808
- color: COLORS.TEXT_MUTED,
809
- fontSize: TYPOGRAPHY.SIZE_SM,
810
- marginLeft: SPACING.XS,
811
- },
812
- statusDot: {
813
- width: SIZES.STATUS_DOT,
814
- height: SIZES.STATUS_DOT,
815
- borderRadius: SIZES.STATUS_DOT / 2,
816
- marginLeft: SPACING.MD, // Match the closeButton marginRight for visual balance
817
- },
818
- ctaButton: {
819
- paddingHorizontal: SIZES.CTA_PADDING_H,
820
- paddingVertical: SIZES.CTA_PADDING_V,
821
- borderRadius: LAYOUT.BORDER_RADIUS_SM,
822
- backgroundColor: COLORS.BACKGROUND_INTERACTIVE,
823
- },
824
- ctaButtonDisabled: {
825
- opacity: 0.4,
826
- },
827
- ctaText: {
828
- color: COLORS.TEXT_PRIMARY,
829
- fontSize: TYPOGRAPHY.SIZE_SM,
830
- fontWeight: TYPOGRAPHY.WEIGHT_SEMIBOLD,
831
- },
832
- ctaTextDisabled: {
833
- opacity: 0.6,
834
- },
835
- tabBar: {
836
- flexDirection: "row",
837
- alignItems: "center",
838
- justifyContent: "space-between",
839
- paddingHorizontal: LAYOUT.CONTENT_PADDING_H,
840
- paddingVertical: SPACING.SM + 2, // 10px
841
- borderBottomWidth: 1,
842
- borderBottomColor: COLORS.BORDER,
843
- },
844
- tabButtons: {
845
- flexDirection: "row",
846
- gap: SPACING.XL,
847
- },
848
- tabText: {
849
- fontSize: TYPOGRAPHY.SIZE_LG,
850
- fontWeight: TYPOGRAPHY.WEIGHT_MEDIUM,
851
- },
852
- tabTextActive: {
853
- color: COLORS.TEXT_PRIMARY,
854
- },
855
- tabTextInactive: {
856
- color: COLORS.TEXT_MUTED,
857
- },
858
153
  body: {
859
154
  flex: 1,
860
155
  backgroundColor: COLORS.BACKGROUND_ELEVATED,