refactor: implement async agent creation flow with loading states

- Add loading states and request tracking to agent creation modal
- Move agent creation logic from parent to modal component
- Add automatic navigation to agent page after creation
- Implement requestId-based response matching for agent_created events
- Add audio output handling in session context
- Improve modal UX with disabled states during creation
```
This commit is contained in:
Mohamed Boudra
2025-10-23 12:04:12 +02:00
parent 542707ecdd
commit 41be2d4096
5 changed files with 144 additions and 23 deletions
+1 -7
View File
@@ -14,7 +14,7 @@ import { useSession } from "@/contexts/session-context";
export default function HomeScreen() { export default function HomeScreen() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { agents, createAgent } = useSession(); const { agents } = useSession();
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
// Keyboard animation // Keyboard animation
@@ -34,11 +34,6 @@ export default function HomeScreen() {
setShowCreateModal(true); setShowCreateModal(true);
} }
function handleCreateAgentConfirm(workingDir: string, mode: string) {
createAgent({ cwd: workingDir, autoStart: true });
setShowCreateModal(false);
}
return ( return (
<View style={styles.container}> <View style={styles.container}>
{/* Header */} {/* Header */}
@@ -60,7 +55,6 @@ export default function HomeScreen() {
<CreateAgentModal <CreateAgentModal
isVisible={showCreateModal} isVisible={showCreateModal}
onClose={() => setShowCreateModal(false)} onClose={() => setShowCreateModal(false)}
onCreateAgent={handleCreateAgentConfirm}
/> />
</View> </View>
); );
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect, useMemo, useCallback } from "react"; import { useState, useRef, useEffect, useMemo, useCallback } from "react";
import { View, Text, Pressable, ScrollView } from "react-native"; import { View, Text, Pressable, ScrollView, ActivityIndicator } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import Animated, { useAnimatedStyle } from "react-native-reanimated"; import Animated, { useAnimatedStyle } from "react-native-reanimated";
@@ -18,11 +18,12 @@ import type {
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { theme as defaultTheme } from "@/styles/theme"; import { theme as defaultTheme } from "@/styles/theme";
import { useRecentPaths } from "@/hooks/use-recent-paths"; import { useRecentPaths } from "@/hooks/use-recent-paths";
import { useSession } from "@/contexts/session-context";
import { useRouter } from "expo-router";
interface CreateAgentModalProps { interface CreateAgentModalProps {
isVisible: boolean; isVisible: boolean;
onClose: () => void; onClose: () => void;
onCreateAgent: (workingDir: string, mode: string) => void;
} }
const MODES = [ const MODES = [
@@ -41,16 +42,19 @@ const MODES = [
export function CreateAgentModal({ export function CreateAgentModal({
isVisible, isVisible,
onClose, onClose,
onCreateAgent,
}: CreateAgentModalProps) { }: CreateAgentModalProps) {
const bottomSheetRef = useRef<BottomSheetModal>(null); const bottomSheetRef = useRef<BottomSheetModal>(null);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { recentPaths, addRecentPath } = useRecentPaths(); const { recentPaths, addRecentPath } = useRecentPaths();
const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const { ws, createAgent } = useSession();
const router = useRouter();
const [workingDir, setWorkingDir] = useState(""); const [workingDir, setWorkingDir] = useState("");
const [selectedMode, setSelectedMode] = useState("plan"); const [selectedMode, setSelectedMode] = useState("plan");
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
const snapPoints = useMemo(() => ["90%"], []); const snapPoints = useMemo(() => ["90%"], []);
@@ -86,17 +90,24 @@ export function CreateAgentModal({
<Pressable <Pressable
style={[ style={[
styles.createButton, styles.createButton,
!workingDir.trim() && styles.createButtonDisabled, (!workingDir.trim() || isLoading) && styles.createButtonDisabled,
]} ]}
onPress={handleCreate} onPress={handleCreate}
disabled={!workingDir.trim()} disabled={!workingDir.trim() || isLoading}
> >
<Text style={styles.createButtonText}>Create Agent</Text> {isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator color={defaultTheme.colors.palette.white} />
<Text style={styles.createButtonText}>Creating...</Text>
</View>
) : (
<Text style={styles.createButtonText}>Create Agent</Text>
)}
</Pressable> </Pressable>
</Animated.View> </Animated.View>
</BottomSheetFooter> </BottomSheetFooter>
), ),
[insets.bottom, workingDir, animatedFooterStyle] [insets.bottom, workingDir, animatedFooterStyle, isLoading]
); );
useEffect(() => { useEffect(() => {
@@ -105,12 +116,42 @@ export function CreateAgentModal({
} }
}, [isVisible]); }, [isVisible]);
// Listen for agent_created events
useEffect(() => {
if (!pendingRequestId) return;
const unsubscribe = ws.on("agent_created", (message) => {
if (message.type !== "agent_created") return;
const { agentId, requestId } = message.payload;
// Check if this is the response to our request
if (requestId === pendingRequestId) {
console.log("[CreateAgentModal] Agent created:", agentId);
setIsLoading(false);
setPendingRequestId(null);
handleClose();
// Navigate to the agent page
router.push(`/agent/${agentId}`);
}
});
return () => {
unsubscribe();
};
}, [pendingRequestId, ws, router]);
async function handleCreate() { async function handleCreate() {
if (!workingDir.trim()) { if (!workingDir.trim()) {
setErrorMessage("Working directory is required"); setErrorMessage("Working directory is required");
return; return;
} }
if (isLoading) {
return;
}
const path = workingDir.trim(); const path = workingDir.trim();
// Save to recent paths // Save to recent paths
@@ -121,8 +162,26 @@ export function CreateAgentModal({
// Continue anyway - don't block agent creation // Continue anyway - don't block agent creation
} }
onCreateAgent(path, selectedMode); // Generate request ID
handleClose(); const requestId = crypto.randomUUID();
setIsLoading(true);
setPendingRequestId(requestId);
setErrorMessage("");
// Create the agent
try {
createAgent({
cwd: path,
initialMode: selectedMode,
requestId,
});
} catch (error) {
console.error("[CreateAgentModal] Failed to create agent:", error);
setErrorMessage("Failed to create agent. Please try again.");
setIsLoading(false);
setPendingRequestId(null);
}
} }
function handleClose() { function handleClose() {
@@ -130,9 +189,12 @@ export function CreateAgentModal({
} }
function handleDismiss() { function handleDismiss() {
// Reset all state
setWorkingDir(""); setWorkingDir("");
setSelectedMode("plan"); setSelectedMode("plan");
setErrorMessage(""); setErrorMessage("");
setIsLoading(false);
setPendingRequestId(null);
onClose(); onClose();
} }
@@ -168,7 +230,7 @@ export function CreateAgentModal({
<View style={styles.formSection}> <View style={styles.formSection}>
<Text style={styles.label}>Working Directory</Text> <Text style={styles.label}>Working Directory</Text>
<BottomSheetTextInput <BottomSheetTextInput
style={styles.input} style={[styles.input, isLoading && styles.inputDisabled]}
placeholder="/path/to/project" placeholder="/path/to/project"
placeholderTextColor={defaultTheme.colors.mutedForeground} placeholderTextColor={defaultTheme.colors.mutedForeground}
value={workingDir} value={workingDir}
@@ -178,6 +240,7 @@ export function CreateAgentModal({
}} }}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
editable={!isLoading}
/> />
{errorMessage && ( {errorMessage && (
<Text style={styles.errorText}>{errorMessage}</Text> <Text style={styles.errorText}>{errorMessage}</Text>
@@ -214,9 +277,11 @@ export function CreateAgentModal({
<Pressable <Pressable
key={mode.value} key={mode.value}
onPress={() => setSelectedMode(mode.value)} onPress={() => setSelectedMode(mode.value)}
disabled={isLoading}
style={[ style={[
styles.modeOption, styles.modeOption,
selectedMode === mode.value && styles.modeOptionSelected, selectedMode === mode.value && styles.modeOptionSelected,
isLoading && styles.modeOptionDisabled,
]} ]}
> >
<View style={styles.modeOptionContent}> <View style={styles.modeOptionContent}>
@@ -296,6 +361,9 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border, borderColor: theme.colors.border,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
}, },
inputDisabled: {
opacity: theme.opacity[50],
},
helperText: { helperText: {
color: theme.colors.mutedForeground, color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
@@ -320,6 +388,9 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.palette.blue[500], borderColor: theme.colors.palette.blue[500],
backgroundColor: theme.colors.muted, backgroundColor: theme.colors.muted,
}, },
modeOptionDisabled: {
opacity: theme.opacity[50],
},
modeOptionContent: { modeOptionContent: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -381,6 +452,11 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
}, },
loadingContainer: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
recentPathsContainer: { recentPathsContainer: {
flexDirection: "row", flexDirection: "row",
gap: theme.spacing[2], gap: theme.spacing[2],
@@ -398,6 +474,6 @@ const styles = StyleSheet.create((theme) => ({
recentPathChipText: { recentPathChipText: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.semibold,
}, },
})); }));
+50 -3
View File
@@ -120,7 +120,7 @@ interface SessionContextValue {
// Helpers // Helpers
sendAgentMessage: (agentId: string, message: string) => void; sendAgentMessage: (agentId: string, message: string) => void;
createAgent: (options: { cwd: string; autoStart?: boolean }) => void; createAgent: (options: { cwd: string; initialMode?: string; requestId?: string }) => void;
setAgentMode: (agentId: string, modeId: string) => void; setAgentMode: (agentId: string, modeId: string) => void;
respondToPermission: (requestId: string, agentId: string, sessionId: string, selectedOptionIds: string[]) => void; respondToPermission: (requestId: string, agentId: string, sessionId: string, selectedOptionIds: string[]) => void;
} }
@@ -259,14 +259,61 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
})); }));
}); });
// Audio output
const unsubAudioOutput = ws.on("audio_output", (message) => {
if (message.type !== "audio_output") return;
const { audio, format, id } = message.payload;
console.log("[Session] Received audio output:", id, "format:", format);
// Create Blob-like object for React Native
// React Native doesn't support creating Blobs from binary data
const audioBlob = {
type: format,
size: audio.length,
arrayBuffer: async () => {
// Convert base64 to ArrayBuffer
const binaryString = atob(audio);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
},
} as Blob;
// Play audio
setIsPlayingAudio(true);
audioPlayer.play(audioBlob)
.then(() => {
console.log("[Session] Audio playback complete:", id);
setIsPlayingAudio(false);
// Send confirmation to server
const msg: WSInboundMessage = {
type: "session",
message: {
type: "audio_played",
id,
},
};
ws.send(msg);
})
.catch((error) => {
console.error("[Session] Audio playback failed:", error);
setIsPlayingAudio(false);
});
});
return () => { return () => {
unsubSessionState(); unsubSessionState();
unsubAgentCreated(); unsubAgentCreated();
unsubAgentStatus(); unsubAgentStatus();
unsubAgentUpdate(); unsubAgentUpdate();
unsubPermissionRequest(); unsubPermissionRequest();
unsubAudioOutput();
}; };
}, [ws]); }, [ws, audioPlayer, setIsPlayingAudio]);
const sendAgentMessage = useCallback((agentId: string, message: string) => { const sendAgentMessage = useCallback((agentId: string, message: string) => {
// Generate unique message ID for deduplication // Generate unique message ID for deduplication
@@ -310,7 +357,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
ws.send(msg); ws.send(msg);
}, [ws]); }, [ws]);
const createAgent = useCallback((options: { cwd: string; autoStart?: boolean }) => { const createAgent = useCallback((options: { cwd: string; initialMode?: string; requestId?: string }) => {
const msg: WSInboundMessage = { const msg: WSInboundMessage = {
type: "session", type: "session",
message: { message: {
+2
View File
@@ -63,6 +63,7 @@ export const CreateAgentRequestMessageSchema = z.object({
type: z.literal("create_agent_request"), type: z.literal("create_agent_request"),
cwd: z.string(), cwd: z.string(),
initialMode: z.string().optional(), initialMode: z.string().optional(),
requestId: z.string().optional(),
}); });
export const SetAgentModeMessageSchema = z.object({ export const SetAgentModeMessageSchema = z.object({
@@ -188,6 +189,7 @@ export const AgentCreatedMessageSchema = z.object({
})).optional(), })).optional(),
title: z.string().optional(), title: z.string().optional(),
cwd: z.string(), cwd: z.string(),
requestId: z.string().optional(),
}), }),
}); });
+4 -2
View File
@@ -466,7 +466,7 @@ export class Session {
break; break;
case "create_agent_request": case "create_agent_request":
await this.handleCreateAgentRequest(msg.cwd, msg.initialMode); await this.handleCreateAgentRequest(msg.cwd, msg.initialMode, msg.requestId);
break; break;
case "set_agent_mode": case "set_agent_mode":
@@ -697,7 +697,8 @@ export class Session {
*/ */
private async handleCreateAgentRequest( private async handleCreateAgentRequest(
cwd: string, cwd: string,
initialMode?: string initialMode?: string,
requestId?: string
): Promise<void> { ): Promise<void> {
console.log( console.log(
`[Session ${this.clientId}] Creating agent in ${cwd} with mode ${ `[Session ${this.clientId}] Creating agent in ${cwd} with mode ${
@@ -736,6 +737,7 @@ export class Session {
availableModes: agentInfo?.availableModes ?? undefined, availableModes: agentInfo?.availableModes ?? undefined,
title: agentInfo?.title ?? undefined, title: agentInfo?.title ?? undefined,
cwd: agentInfo?.cwd || cwd, cwd: agentInfo?.cwd || cwd,
requestId,
}, },
}); });
console.log( console.log(