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

View File

@@ -14,7 +14,7 @@ import { useSession } from "@/contexts/session-context";
export default function HomeScreen() {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const { agents, createAgent } = useSession();
const { agents } = useSession();
const [showCreateModal, setShowCreateModal] = useState(false);
// Keyboard animation
@@ -34,11 +34,6 @@ export default function HomeScreen() {
setShowCreateModal(true);
}
function handleCreateAgentConfirm(workingDir: string, mode: string) {
createAgent({ cwd: workingDir, autoStart: true });
setShowCreateModal(false);
}
return (
<View style={styles.container}>
{/* Header */}
@@ -60,7 +55,6 @@ export default function HomeScreen() {
<CreateAgentModal
isVisible={showCreateModal}
onClose={() => setShowCreateModal(false)}
onCreateAgent={handleCreateAgentConfirm}
/>
</View>
);

View File

@@ -1,5 +1,5 @@
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 { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
@@ -18,11 +18,12 @@ import type {
import { StyleSheet } from "react-native-unistyles";
import { theme as defaultTheme } from "@/styles/theme";
import { useRecentPaths } from "@/hooks/use-recent-paths";
import { useSession } from "@/contexts/session-context";
import { useRouter } from "expo-router";
interface CreateAgentModalProps {
isVisible: boolean;
onClose: () => void;
onCreateAgent: (workingDir: string, mode: string) => void;
}
const MODES = [
@@ -41,16 +42,19 @@ const MODES = [
export function CreateAgentModal({
isVisible,
onClose,
onCreateAgent,
}: CreateAgentModalProps) {
const bottomSheetRef = useRef<BottomSheetModal>(null);
const insets = useSafeAreaInsets();
const { recentPaths, addRecentPath } = useRecentPaths();
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const { ws, createAgent } = useSession();
const router = useRouter();
const [workingDir, setWorkingDir] = useState("");
const [selectedMode, setSelectedMode] = useState("plan");
const [errorMessage, setErrorMessage] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
const snapPoints = useMemo(() => ["90%"], []);
@@ -86,17 +90,24 @@ export function CreateAgentModal({
<Pressable
style={[
styles.createButton,
!workingDir.trim() && styles.createButtonDisabled,
(!workingDir.trim() || isLoading) && styles.createButtonDisabled,
]}
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>
</Animated.View>
</BottomSheetFooter>
),
[insets.bottom, workingDir, animatedFooterStyle]
[insets.bottom, workingDir, animatedFooterStyle, isLoading]
);
useEffect(() => {
@@ -105,12 +116,42 @@ export function CreateAgentModal({
}
}, [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() {
if (!workingDir.trim()) {
setErrorMessage("Working directory is required");
return;
}
if (isLoading) {
return;
}
const path = workingDir.trim();
// Save to recent paths
@@ -121,8 +162,26 @@ export function CreateAgentModal({
// Continue anyway - don't block agent creation
}
onCreateAgent(path, selectedMode);
handleClose();
// Generate request ID
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() {
@@ -130,9 +189,12 @@ export function CreateAgentModal({
}
function handleDismiss() {
// Reset all state
setWorkingDir("");
setSelectedMode("plan");
setErrorMessage("");
setIsLoading(false);
setPendingRequestId(null);
onClose();
}
@@ -168,7 +230,7 @@ export function CreateAgentModal({
<View style={styles.formSection}>
<Text style={styles.label}>Working Directory</Text>
<BottomSheetTextInput
style={styles.input}
style={[styles.input, isLoading && styles.inputDisabled]}
placeholder="/path/to/project"
placeholderTextColor={defaultTheme.colors.mutedForeground}
value={workingDir}
@@ -178,6 +240,7 @@ export function CreateAgentModal({
}}
autoCapitalize="none"
autoCorrect={false}
editable={!isLoading}
/>
{errorMessage && (
<Text style={styles.errorText}>{errorMessage}</Text>
@@ -214,9 +277,11 @@ export function CreateAgentModal({
<Pressable
key={mode.value}
onPress={() => setSelectedMode(mode.value)}
disabled={isLoading}
style={[
styles.modeOption,
selectedMode === mode.value && styles.modeOptionSelected,
isLoading && styles.modeOptionDisabled,
]}
>
<View style={styles.modeOptionContent}>
@@ -296,6 +361,9 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
fontSize: theme.fontSize.base,
},
inputDisabled: {
opacity: theme.opacity[50],
},
helperText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
@@ -320,6 +388,9 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.palette.blue[500],
backgroundColor: theme.colors.muted,
},
modeOptionDisabled: {
opacity: theme.opacity[50],
},
modeOptionContent: {
flexDirection: "row",
alignItems: "center",
@@ -381,6 +452,11 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
loadingContainer: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
recentPathsContainer: {
flexDirection: "row",
gap: theme.spacing[2],
@@ -398,6 +474,6 @@ const styles = StyleSheet.create((theme) => ({
recentPathChipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
fontWeight: theme.fontWeight.semibold,
},
}));

View File

@@ -120,7 +120,7 @@ interface SessionContextValue {
// Helpers
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;
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 () => {
unsubSessionState();
unsubAgentCreated();
unsubAgentStatus();
unsubAgentUpdate();
unsubPermissionRequest();
unsubAudioOutput();
};
}, [ws]);
}, [ws, audioPlayer, setIsPlayingAudio]);
const sendAgentMessage = useCallback((agentId: string, message: string) => {
// Generate unique message ID for deduplication
@@ -310,7 +357,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
ws.send(msg);
}, [ws]);
const createAgent = useCallback((options: { cwd: string; autoStart?: boolean }) => {
const createAgent = useCallback((options: { cwd: string; initialMode?: string; requestId?: string }) => {
const msg: WSInboundMessage = {
type: "session",
message: {

View File

@@ -63,6 +63,7 @@ export const CreateAgentRequestMessageSchema = z.object({
type: z.literal("create_agent_request"),
cwd: z.string(),
initialMode: z.string().optional(),
requestId: z.string().optional(),
});
export const SetAgentModeMessageSchema = z.object({
@@ -188,6 +189,7 @@ export const AgentCreatedMessageSchema = z.object({
})).optional(),
title: z.string().optional(),
cwd: z.string(),
requestId: z.string().optional(),
}),
});

View File

@@ -466,7 +466,7 @@ export class Session {
break;
case "create_agent_request":
await this.handleCreateAgentRequest(msg.cwd, msg.initialMode);
await this.handleCreateAgentRequest(msg.cwd, msg.initialMode, msg.requestId);
break;
case "set_agent_mode":
@@ -697,7 +697,8 @@ export class Session {
*/
private async handleCreateAgentRequest(
cwd: string,
initialMode?: string
initialMode?: string,
requestId?: string
): Promise<void> {
console.log(
`[Session ${this.clientId}] Creating agent in ${cwd} with mode ${
@@ -736,6 +737,7 @@ export class Session {
availableModes: agentInfo?.availableModes ?? undefined,
title: agentInfo?.title ?? undefined,
cwd: agentInfo?.cwd || cwd,
requestId,
},
});
console.log(