@10play/expo-air 0.12.0 → 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.
@@ -0,0 +1,225 @@
1
+ import React, { useRef, useEffect } from "react";
2
+ import { View, Text, StyleSheet, NativeModules, TouchableOpacity, Animated, Easing, type TextProps, type ViewProps } from "react-native";
3
+ import type { ConnectionStatus } from "../services/websocket";
4
+ import { SPACING, LAYOUT, COLORS, TYPOGRAPHY, SIZES } from "../constants/design";
5
+
6
+ // Typed animated components for React 19 compatibility
7
+ const AnimatedView = Animated.View as React.ComponentClass<Animated.AnimatedProps<ViewProps>>;
8
+
9
+ // WidgetBridge is a simple native module available in the widget runtime
10
+ // ExpoAir is the main app's module (fallback)
11
+ const { WidgetBridge, ExpoAir } = NativeModules;
12
+
13
+ function handleCollapse() {
14
+ try {
15
+ // Try WidgetBridge first (widget runtime), then ExpoAir (main app)
16
+ if (WidgetBridge?.collapse) {
17
+ WidgetBridge.collapse();
18
+ } else if (ExpoAir?.collapse) {
19
+ ExpoAir.collapse();
20
+ } else {
21
+ console.warn("[expo-air] No collapse method available");
22
+ }
23
+ } catch (e) {
24
+ console.warn("[expo-air] Failed to collapse:", e);
25
+ }
26
+ }
27
+
28
+ interface HeaderProps {
29
+ status: ConnectionStatus;
30
+ branchName: string;
31
+ onBranchPress: () => void;
32
+ }
33
+
34
+ export function Header({ status, branchName, onBranchPress }: HeaderProps) {
35
+ const statusColors = {
36
+ disconnected: COLORS.STATUS_ERROR,
37
+ connecting: COLORS.STATUS_INFO,
38
+ connected: COLORS.STATUS_SUCCESS,
39
+ processing: COLORS.STATUS_INFO,
40
+ };
41
+
42
+ return (
43
+ <View style={styles.header}>
44
+ <TouchableOpacity onPress={handleCollapse} style={styles.closeButton}>
45
+ <Text style={styles.closeButtonText}>✕</Text>
46
+ </TouchableOpacity>
47
+
48
+ <TouchableOpacity style={styles.branchButton} onPress={onBranchPress} disabled={!branchName}>
49
+ {branchName ? (
50
+ <>
51
+ <Text style={styles.branchName} numberOfLines={1}>
52
+ {branchName}
53
+ </Text>
54
+ <Text style={styles.branchChevron}>▾</Text>
55
+ </>
56
+ ) : (
57
+ <View style={styles.branchLoadingBar} />
58
+ )}
59
+ </TouchableOpacity>
60
+
61
+ <View style={[styles.statusDot, { backgroundColor: statusColors[status] }]} />
62
+ </View>
63
+ );
64
+ }
65
+
66
+ export function PulsingIndicator({ status }: { status: ConnectionStatus }) {
67
+ const colors = {
68
+ disconnected: COLORS.STATUS_ERROR,
69
+ connecting: COLORS.STATUS_INFO,
70
+ connected: COLORS.STATUS_SUCCESS,
71
+ processing: COLORS.STATUS_INFO,
72
+ };
73
+
74
+ const isAnimating = status === "processing" || status === "connecting";
75
+
76
+ // Animated values for the pulsing ring
77
+ const scaleAnim = useRef(new Animated.Value(1)).current;
78
+ const opacityAnim = useRef(new Animated.Value(0.4)).current;
79
+
80
+ useEffect(() => {
81
+ if (isAnimating) {
82
+ // Create a soft pulsing animation
83
+ const pulseAnimation = Animated.loop(
84
+ Animated.sequence([
85
+ Animated.parallel([
86
+ Animated.timing(scaleAnim, {
87
+ toValue: 1.3,
88
+ duration: 1200,
89
+ easing: Easing.inOut(Easing.ease),
90
+ useNativeDriver: true,
91
+ }),
92
+ Animated.timing(opacityAnim, {
93
+ toValue: 0,
94
+ duration: 1200,
95
+ easing: Easing.inOut(Easing.ease),
96
+ useNativeDriver: true,
97
+ }),
98
+ ]),
99
+ Animated.parallel([
100
+ Animated.timing(scaleAnim, {
101
+ toValue: 1,
102
+ duration: 0,
103
+ useNativeDriver: true,
104
+ }),
105
+ Animated.timing(opacityAnim, {
106
+ toValue: 0.4,
107
+ duration: 0,
108
+ useNativeDriver: true,
109
+ }),
110
+ ]),
111
+ ])
112
+ );
113
+ pulseAnimation.start();
114
+ return () => pulseAnimation.stop();
115
+ } else {
116
+ // Reset when not animating
117
+ scaleAnim.setValue(1);
118
+ opacityAnim.setValue(0.4);
119
+ }
120
+ }, [isAnimating, scaleAnim, opacityAnim]);
121
+
122
+ return (
123
+ <View style={styles.indicatorContainer}>
124
+ <View
125
+ style={[
126
+ styles.indicator,
127
+ { backgroundColor: colors[status] },
128
+ ]}
129
+ />
130
+ {isAnimating && (
131
+ <AnimatedView
132
+ style={[
133
+ styles.indicatorRing,
134
+ {
135
+ borderColor: colors[status],
136
+ transform: [{ scale: scaleAnim }],
137
+ opacity: opacityAnim,
138
+ },
139
+ ]}
140
+ />
141
+ )}
142
+ </View>
143
+ );
144
+ }
145
+
146
+ const styles = StyleSheet.create({
147
+ header: {
148
+ flexDirection: "row",
149
+ alignItems: "center",
150
+ paddingHorizontal: LAYOUT.CONTENT_PADDING_H,
151
+ paddingVertical: SPACING.MD + 2, // 14px for comfortable header height
152
+ borderBottomWidth: 1,
153
+ borderBottomColor: COLORS.BORDER,
154
+ },
155
+ closeButton: {
156
+ width: SIZES.CLOSE_BUTTON,
157
+ height: SIZES.CLOSE_BUTTON,
158
+ borderRadius: SIZES.CLOSE_BUTTON / 2,
159
+ // Make invisible - native close button handles the tap
160
+ backgroundColor: "transparent",
161
+ alignItems: "center",
162
+ justifyContent: "center",
163
+ marginRight: SPACING.MD,
164
+ },
165
+ closeButtonText: {
166
+ // Hide the text - native button shows the X
167
+ color: "transparent",
168
+ fontSize: TYPOGRAPHY.SIZE_MD,
169
+ fontWeight: TYPOGRAPHY.WEIGHT_SEMIBOLD,
170
+ },
171
+ branchButton: {
172
+ flex: 1,
173
+ flexDirection: "row",
174
+ alignItems: "center",
175
+ },
176
+ branchName: {
177
+ flexShrink: 1,
178
+ color: COLORS.TEXT_SECONDARY,
179
+ fontSize: TYPOGRAPHY.SIZE_MD,
180
+ fontWeight: TYPOGRAPHY.WEIGHT_MEDIUM,
181
+ },
182
+ branchChevron: {
183
+ color: COLORS.TEXT_MUTED,
184
+ fontSize: TYPOGRAPHY.SIZE_SM,
185
+ marginLeft: SPACING.XS,
186
+ },
187
+ branchLoadingBar: {
188
+ width: 80,
189
+ height: 12,
190
+ borderRadius: 6,
191
+ backgroundColor: "rgba(255,255,255,0.08)",
192
+ },
193
+ statusDot: {
194
+ width: SIZES.STATUS_DOT,
195
+ height: SIZES.STATUS_DOT,
196
+ borderRadius: SIZES.STATUS_DOT / 2,
197
+ marginLeft: SPACING.MD, // Match the closeButton marginRight for visual balance
198
+ },
199
+ collapsedPill: {
200
+ width: 100,
201
+ height: 32,
202
+ backgroundColor: "transparent",
203
+ alignItems: "center",
204
+ justifyContent: "center",
205
+ },
206
+ indicatorContainer: {
207
+ width: 20,
208
+ height: 20,
209
+ alignItems: "center",
210
+ justifyContent: "center",
211
+ },
212
+ indicator: {
213
+ width: SIZES.STATUS_DOT,
214
+ height: SIZES.STATUS_DOT,
215
+ borderRadius: SIZES.STATUS_DOT / 2,
216
+ },
217
+ indicatorRing: {
218
+ position: "absolute",
219
+ width: 16,
220
+ height: 16,
221
+ borderRadius: 8,
222
+ borderWidth: 1.5,
223
+ opacity: 0.4,
224
+ },
225
+ });
@@ -0,0 +1,308 @@
1
+ import React from "react";
2
+ import { View, Text, StyleSheet, Platform, Image } from "react-native";
3
+ import type {
4
+ ServerMessage,
5
+ ToolMessage,
6
+ ResultMessage,
7
+ UserPromptMessage,
8
+ HistoryResultMessage,
9
+ SystemDisplayMessage,
10
+ AssistantPart,
11
+ AssistantPartsMessage,
12
+ } from "../services/websocket";
13
+ import { FormattedText } from "./FormattedText";
14
+ import { SPACING, LAYOUT, COLORS, TYPOGRAPHY } from "../constants/design";
15
+
16
+ export function MessageItem({ message }: { key?: React.Key; message: ServerMessage }) {
17
+ switch (message.type) {
18
+ case "stream":
19
+ return null; // Handled by currentParts
20
+
21
+ case "tool":
22
+ // Legacy: individual tool messages from history
23
+ return <ToolItem tool={message} />;
24
+
25
+ case "result":
26
+ return <ResultItem result={message} />;
27
+
28
+ case "error":
29
+ return (
30
+ <View style={styles.errorContainer}>
31
+ <Text style={styles.errorText}>{message.message}</Text>
32
+ </View>
33
+ );
34
+
35
+ case "status":
36
+ return null; // Handled by header
37
+
38
+ case "user_prompt":
39
+ return <UserPromptItem message={message} />;
40
+
41
+ case "history_result":
42
+ return <HistoryResultItem message={message} />;
43
+
44
+ case "assistant_parts":
45
+ return <AssistantPartsItem message={message} />;
46
+
47
+ case "system_message":
48
+ return <SystemMessageItem message={message} />;
49
+
50
+ default:
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function UserPromptItem({ message }: { message: UserPromptMessage }) {
56
+ return (
57
+ <View style={styles.userPromptContainer}>
58
+ {message.images && message.images.length > 0 && (
59
+ <View style={styles.userImages}>
60
+ {message.images.map((img, i) => (
61
+ <Image key={i} source={{ uri: img.uri }} style={styles.userImageThumb} />
62
+ ))}
63
+ </View>
64
+ )}
65
+ {message.content ? (
66
+ <Text style={styles.userPromptText} selectable>{message.content}</Text>
67
+ ) : null}
68
+ </View>
69
+ );
70
+ }
71
+
72
+ function HistoryResultItem({ message }: { message: HistoryResultMessage }) {
73
+ return (
74
+ <View style={styles.resultContainer}>
75
+ <FormattedText content={message.content} />
76
+ </View>
77
+ );
78
+ }
79
+
80
+ function SystemMessageItem({ message }: { message: SystemDisplayMessage }) {
81
+ // Use error styling for errors, muted styling for other system messages
82
+ if (message.messageType === "error") {
83
+ return (
84
+ <View style={styles.errorContainer}>
85
+ <Text style={styles.errorText}>{message.content}</Text>
86
+ </View>
87
+ );
88
+ }
89
+ // Stopped and info messages use muted styling
90
+ return (
91
+ <View style={styles.systemContainer}>
92
+ <Text style={styles.systemText}>{message.content}</Text>
93
+ </View>
94
+ );
95
+ }
96
+
97
+ // Renders interleaved text and tool parts in order
98
+ export function PartsRenderer({ parts, isStreaming }: { parts: AssistantPart[], isStreaming: boolean }) {
99
+ return (
100
+ <View style={styles.partsContainer}>
101
+ {parts.map((part, index) => {
102
+ if (part.type === "text") {
103
+ const isLastPart = index === parts.length - 1;
104
+ return (
105
+ <View key={part.id} style={styles.messageContainer}>
106
+ <FormattedText content={part.content} isStreaming={isStreaming && isLastPart} />
107
+ </View>
108
+ );
109
+ } else if (part.type === "tool") {
110
+ return <ToolPartItem key={part.id} part={part} />;
111
+ }
112
+ return null;
113
+ })}
114
+ </View>
115
+ );
116
+ }
117
+
118
+ // Renders a completed assistant response with parts
119
+ function AssistantPartsItem({ message }: { message: AssistantPartsMessage }) {
120
+ return (
121
+ <View style={styles.resultContainer}>
122
+ <PartsRenderer parts={message.parts} isStreaming={false} />
123
+ {!message.isComplete && (
124
+ <Text style={styles.interruptedText}>(interrupted)</Text>
125
+ )}
126
+ </View>
127
+ );
128
+ }
129
+
130
+ // Shared helper for tool display info
131
+ function getToolDisplayInfo(toolName: string, input: Record<string, unknown> | undefined): { label: string; value: string } {
132
+ const getFileName = (path: string): string => path.split('/').pop() || path;
133
+
134
+ switch (toolName) {
135
+ case "Read":
136
+ return { label: "read", value: getFileName(input?.file_path as string || "file") };
137
+ case "Edit":
138
+ return { label: "edit", value: getFileName(input?.file_path as string || "file") };
139
+ case "Write":
140
+ return { label: "write", value: getFileName(input?.file_path as string || "file") };
141
+ case "Bash": {
142
+ const cmd = input?.command as string || "";
143
+ return { label: "$", value: cmd.length > 45 ? cmd.slice(0, 45) + "…" : cmd };
144
+ }
145
+ case "Glob":
146
+ return { label: "glob", value: input?.pattern as string || "*" };
147
+ case "Grep":
148
+ return { label: "grep", value: input?.pattern as string || "search" };
149
+ case "Task":
150
+ return { label: "agent", value: input?.description as string || "task" };
151
+ default:
152
+ return { label: toolName.toLowerCase(), value: "" };
153
+ }
154
+ }
155
+
156
+ // Renders a tool display line (shared between ToolPartItem and ToolItem)
157
+ function ToolDisplay({ toolName, input, isFailed }: { toolName: string; input?: unknown; isFailed: boolean }) {
158
+ const { label, value } = getToolDisplayInfo(toolName, input as Record<string, unknown> | undefined);
159
+
160
+ return (
161
+ <View style={styles.toolLine}>
162
+ <Text style={isFailed ? styles.toolLabelFailed : styles.toolLabel}>{label}</Text>
163
+ <Text style={isFailed ? styles.toolValueFailed : styles.toolValue} numberOfLines={1}>{value}</Text>
164
+ {isFailed && <Text style={styles.toolLabelFailed}> ✕</Text>}
165
+ </View>
166
+ );
167
+ }
168
+
169
+ // Tool part renderer (for parts in AssistantPartsMessage)
170
+ function ToolPartItem({ part }: { key?: React.Key; part: AssistantPart & { type: "tool" } }) {
171
+ if (part.status === "started") return null;
172
+ return <ToolDisplay toolName={part.toolName} input={part.input} isFailed={part.status === "failed"} />;
173
+ }
174
+
175
+ // Tool item renderer (for legacy ToolMessage from history)
176
+ function ToolItem({ tool }: { tool: ToolMessage }) {
177
+ if (tool.status === "started") return null;
178
+ return <ToolDisplay toolName={tool.toolName} input={tool.input} isFailed={tool.status === "failed"} />;
179
+ }
180
+
181
+ function ResultItem({ result }: { result: ResultMessage }) {
182
+ if (!result.success && result.error) {
183
+ return (
184
+ <View style={styles.errorContainer}>
185
+ <Text style={styles.errorText}>{result.error}</Text>
186
+ </View>
187
+ );
188
+ }
189
+
190
+ return (
191
+ <View style={styles.resultContainer}>
192
+ {result.result && (
193
+ <Text style={styles.responseText} selectable>{result.result}</Text>
194
+ )}
195
+ {result.durationMs !== undefined && (
196
+ <Text style={styles.metaText}>
197
+ {`${result.durationMs}ms`}
198
+ </Text>
199
+ )}
200
+ </View>
201
+ );
202
+ }
203
+
204
+ const styles = StyleSheet.create({
205
+ messageContainer: {
206
+ flexDirection: "row",
207
+ flexWrap: "wrap",
208
+ },
209
+ responseText: {
210
+ color: "rgba(255,255,255,0.95)",
211
+ fontSize: TYPOGRAPHY.SIZE_LG,
212
+ lineHeight: 22,
213
+ },
214
+ resultContainer: {
215
+ marginTop: SPACING.SM,
216
+ },
217
+ partsContainer: {
218
+ // Container for interleaved parts
219
+ },
220
+ interruptedText: {
221
+ color: COLORS.TEXT_MUTED,
222
+ fontSize: TYPOGRAPHY.SIZE_XS + 1, // 12px
223
+ fontStyle: "italic",
224
+ marginTop: SPACING.XS,
225
+ },
226
+ metaText: {
227
+ color: COLORS.TEXT_MUTED,
228
+ fontSize: TYPOGRAPHY.SIZE_XS + 1, // 12px
229
+ marginTop: SPACING.SM + 2, // 10px
230
+ },
231
+ errorContainer: {
232
+ backgroundColor: "rgba(255,59,48,0.15)",
233
+ borderRadius: SPACING.MD,
234
+ padding: SPACING.MD,
235
+ marginVertical: SPACING.SM - 2, // 6px
236
+ },
237
+ errorText: {
238
+ color: "#FF6B6B",
239
+ fontSize: TYPOGRAPHY.SIZE_MD,
240
+ },
241
+ systemContainer: {
242
+ backgroundColor: "rgba(255,255,255,0.05)",
243
+ borderRadius: SPACING.MD,
244
+ padding: SPACING.MD,
245
+ marginVertical: SPACING.SM - 2, // 6px
246
+ },
247
+ systemText: {
248
+ color: COLORS.TEXT_TERTIARY,
249
+ fontSize: TYPOGRAPHY.SIZE_MD,
250
+ fontStyle: "italic",
251
+ },
252
+ toolLine: {
253
+ flexDirection: "row",
254
+ alignItems: "center",
255
+ marginVertical: SPACING.XS,
256
+ },
257
+ toolLabel: {
258
+ color: COLORS.TEXT_MUTED,
259
+ fontSize: TYPOGRAPHY.SIZE_XS + 1, // 12px
260
+ fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
261
+ marginRight: SPACING.SM,
262
+ minWidth: 36,
263
+ },
264
+ toolLabelFailed: {
265
+ color: "rgba(255,100,100,0.6)",
266
+ fontSize: TYPOGRAPHY.SIZE_XS + 1, // 12px
267
+ fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
268
+ marginRight: SPACING.SM,
269
+ minWidth: 36,
270
+ },
271
+ toolValue: {
272
+ color: "rgba(255,255,255,0.7)",
273
+ fontSize: TYPOGRAPHY.SIZE_SM,
274
+ fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
275
+ flexShrink: 1,
276
+ },
277
+ toolValueFailed: {
278
+ color: "rgba(255,100,100,0.7)",
279
+ fontSize: TYPOGRAPHY.SIZE_SM,
280
+ fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
281
+ flexShrink: 1,
282
+ },
283
+ userPromptContainer: {
284
+ backgroundColor: "rgba(0,122,255,0.15)",
285
+ borderRadius: LAYOUT.BORDER_RADIUS_SM + 2, // 16px
286
+ padding: SPACING.MD,
287
+ marginVertical: SPACING.SM,
288
+ alignSelf: "flex-end",
289
+ maxWidth: "85%",
290
+ },
291
+ userPromptText: {
292
+ color: COLORS.TEXT_PRIMARY,
293
+ fontSize: TYPOGRAPHY.SIZE_LG,
294
+ lineHeight: 20,
295
+ },
296
+ userImages: {
297
+ flexDirection: "row",
298
+ flexWrap: "wrap",
299
+ gap: SPACING.SM,
300
+ marginBottom: SPACING.SM,
301
+ },
302
+ userImageThumb: {
303
+ width: 80,
304
+ height: 80,
305
+ borderRadius: SPACING.SM,
306
+ backgroundColor: "rgba(255,255,255,0.1)",
307
+ },
308
+ });