@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.
- package/cli/dist/server/promptServer.d.ts.map +1 -1
- package/cli/dist/server/promptServer.js +2 -0
- package/cli/dist/server/promptServer.js.map +1 -1
- package/ios/widget.jsbundle +11 -5
- package/package.json +1 -1
- package/widget/BubbleContent.tsx +50 -755
- package/widget/components/FormattedText.tsx +320 -0
- package/widget/components/Header.tsx +225 -0
- package/widget/components/MessageItems.tsx +308 -0
- package/widget/components/ResponseArea.tsx +3 -754
- package/widget/components/TabBar.tsx +161 -0
package/widget/BubbleContent.tsx
CHANGED
|
@@ -1,48 +1,15 @@
|
|
|
1
|
-
import React, { useState, useEffect,
|
|
2
|
-
import { View, Text, StyleSheet
|
|
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 {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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,
|