import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react"; import { ActivityIndicator, Pressable, Text, View } from "react-native"; import { Bot, ChevronLeft, MessagesSquare, SquareTerminal } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; import { createNameId } from "mnemonic-id"; import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { Composer } from "@/components/composer"; import { getProviderIcon } from "@/components/provider-icons"; import { Button } from "@/components/ui/button"; import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useProviderRecency } from "@/stores/provider-recency-store"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { encodeImages } from "@/utils/encode-images"; import { toErrorMessage } from "@/utils/error-messages"; import { requireWorkspaceExecutionAuthority, requireWorkspaceRecordId, } from "@/utils/workspace-execution"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import type { MessagePayload } from "./message-input"; type SetupStep = "choose" | "chat" | "terminal-agent"; export function WorkspaceSetupDialog() { const { theme } = useUnistyles(); const toast = useToast(); const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup); const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup); const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); const setAgents = useSessionStore((state) => state.setAgents); const [step, setStep] = useState("choose"); const [terminalPrompt, setTerminalPrompt] = useState(""); const [errorMessage, setErrorMessage] = useState(null); const [createdWorkspace, setCreatedWorkspace] = useState | null>(null); const [pendingAction, setPendingAction] = useState<"chat" | "terminal-agent" | "terminal" | null>( null, ); const serverId = pendingWorkspaceSetup?.serverId ?? ""; const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? ""; const displayName = pendingWorkspaceSetup?.displayName?.trim() ?? ""; const workspace = createdWorkspace; const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const chatDraft = useAgentInputDraft({ draftKey: `workspace-setup:${serverId}:${sourceDirectory}`, composer: { initialServerId: serverId || null, initialValues: workspace?.workspaceDirectory ? { workingDir: workspace.workspaceDirectory } : undefined, isVisible: pendingWorkspaceSetup !== null, onlineServerIds: isConnected && serverId ? [serverId] : [], lockedWorkingDir: workspace?.workspaceDirectory || undefined, }, }); const composerState = chatDraft.composerState; if (!composerState && pendingWorkspaceSetup) { throw new Error("Workspace setup composer state is required"); } const { providers: sortedProviders, recordUsage } = useProviderRecency( composerState?.providerDefinitions ?? [], ); useEffect(() => { setStep("choose"); setTerminalPrompt(""); setErrorMessage(null); setCreatedWorkspace(null); setPendingAction(null); }, [pendingWorkspaceSetup?.creationMethod, serverId, sourceDirectory]); const handleClose = useCallback(() => { clearWorkspaceSetup(); }, [clearWorkspaceSetup]); const navigateAfterCreation = useCallback( ( workspaceId: string, target: { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string }, ) => { if (!pendingWorkspaceSetup) { return; } clearWorkspaceSetup(); navigateToPreparedWorkspaceTab({ serverId: pendingWorkspaceSetup.serverId, workspaceId, target, navigationMethod: pendingWorkspaceSetup.navigationMethod, }); }, [clearWorkspaceSetup, pendingWorkspaceSetup], ); const withConnectedClient = useCallback(() => { if (!client || !isConnected) { throw new Error("Host is not connected"); } return client; }, [client, isConnected]); const ensureWorkspace = useCallback(async () => { if (!pendingWorkspaceSetup) { throw new Error("No workspace setup is pending"); } if (createdWorkspace) { return createdWorkspace; } const connectedClient = withConnectedClient(); const payload = pendingWorkspaceSetup.creationMethod === "create_worktree" ? await connectedClient.createPaseoWorktree({ cwd: pendingWorkspaceSetup.sourceDirectory, worktreeSlug: createNameId(), }) : await connectedClient.openProject(pendingWorkspaceSetup.sourceDirectory); if (payload.error || !payload.workspace) { throw new Error( payload.error ?? (pendingWorkspaceSetup.creationMethod === "create_worktree" ? "Failed to create worktree" : "Failed to open project"), ); } const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace); mergeWorkspaces(pendingWorkspaceSetup.serverId, [normalizedWorkspace]); if (pendingWorkspaceSetup.creationMethod === "open_project") { setHasHydratedWorkspaces(pendingWorkspaceSetup.serverId, true); } setCreatedWorkspace(normalizedWorkspace); return normalizedWorkspace; }, [ createdWorkspace, mergeWorkspaces, pendingWorkspaceSetup, setHasHydratedWorkspaces, withConnectedClient, ]); const getIsStillActive = useCallback(() => { const current = useWorkspaceSetupStore.getState().pendingWorkspaceSetup; return ( current?.serverId === pendingWorkspaceSetup?.serverId && current?.sourceDirectory === pendingWorkspaceSetup?.sourceDirectory && current?.creationMethod === pendingWorkspaceSetup?.creationMethod ); }, [ pendingWorkspaceSetup?.creationMethod, pendingWorkspaceSetup?.serverId, pendingWorkspaceSetup?.sourceDirectory, ]); const handleCreateChatAgent = useCallback( async ({ text, images }: MessagePayload) => { try { setPendingAction("chat"); setErrorMessage(null); const workspace = await ensureWorkspace(); const connectedClient = withConnectedClient(); if (!composerState) { throw new Error("Workspace setup composer state is required"); } const encodedImages = await encodeImages(images); const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory; const agent = await connectedClient.createAgent({ provider: composerState.selectedProvider, cwd: workspaceDirectory, workspaceId: requireWorkspaceRecordId(workspace.id), ...(composerState.modeOptions.length > 0 && composerState.selectedMode !== "" ? { modeId: composerState.selectedMode } : {}), ...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}), ...(composerState.effectiveThinkingOptionId ? { thinkingOptionId: composerState.effectiveThinkingOptionId } : {}), ...(text.trim() ? { initialPrompt: text.trim() } : {}), ...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}), }); if (!getIsStillActive()) { return; } setAgents(serverId, (previous) => { const next = new Map(previous); next.set(agent.id, normalizeAgentSnapshot(agent, serverId)); return next; }); navigateAfterCreation(workspace.id, { kind: "agent", agentId: agent.id }); } catch (error) { const message = toErrorMessage(error); setErrorMessage(message); toast.error(message); } finally { if (getIsStillActive()) { setPendingAction(null); } } }, [ composerState, getIsStillActive, navigateAfterCreation, serverId, setAgents, ensureWorkspace, toast, withConnectedClient, ], ); const handleCreateTerminalAgent = useCallback(async () => { try { setPendingAction("terminal-agent"); setErrorMessage(null); const workspace = await ensureWorkspace(); const connectedClient = withConnectedClient(); if (!composerState) { throw new Error("Workspace setup composer state is required"); } const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory; const agent = await connectedClient.createAgent({ provider: composerState.selectedProvider, cwd: workspaceDirectory, workspaceId: requireWorkspaceRecordId(workspace.id), terminal: true, ...(terminalPrompt.trim() ? { initialPrompt: terminalPrompt.trim() } : {}), }); if (!getIsStillActive()) { return; } recordUsage(composerState.selectedProvider); setAgents(serverId, (previous) => { const next = new Map(previous); next.set(agent.id, normalizeAgentSnapshot(agent, serverId)); return next; }); navigateAfterCreation(workspace.id, { kind: "agent", agentId: agent.id }); } catch (error) { const message = toErrorMessage(error); setErrorMessage(message); toast.error(message); } finally { if (getIsStillActive()) { setPendingAction(null); } } }, [ composerState, getIsStillActive, navigateAfterCreation, recordUsage, serverId, setAgents, ensureWorkspace, terminalPrompt, toast, withConnectedClient, ]); const handleCreateTerminal = useCallback(async () => { try { setPendingAction("terminal"); setErrorMessage(null); const workspace = await ensureWorkspace(); const connectedClient = withConnectedClient(); const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory; const payload = await connectedClient.createTerminal(workspaceDirectory); if (payload.error || !payload.terminal) { throw new Error(payload.error ?? "Failed to open terminal"); } if (!getIsStillActive()) { return; } navigateAfterCreation(workspace.id, { kind: "terminal", terminalId: payload.terminal.id }); } catch (error) { const message = toErrorMessage(error); setErrorMessage(message); toast.error(message); } finally { if (getIsStillActive()) { setPendingAction(null); } } }, [ensureWorkspace, getIsStillActive, navigateAfterCreation, toast, withConnectedClient]); const workspaceTitle = workspace?.name || workspace?.projectDisplayName || displayName || sourceDirectory.split(/[\\/]/).filter(Boolean).pop() || sourceDirectory; const workspacePath = workspace?.workspaceDirectory || "Workspace will be created before launch."; if (!pendingWorkspaceSetup || !sourceDirectory) { return null; } return ( {workspaceTitle} {workspacePath} {step === "choose" ? ( What do you want to open? { setErrorMessage(null); setStep("chat"); }} /> { setErrorMessage(null); setStep("terminal-agent"); }} /> { void handleCreateTerminal(); }} /> ) : null} {step === "chat" ? ( { setErrorMessage(null); setStep("choose"); }} /> Start with a prompt and optional images. The workspace is created first, then the agent launches, then navigation happens. ) : null} {step === "terminal-agent" ? ( { setErrorMessage(null); setStep("choose"); }} /> Choose a provider and optionally send an initial prompt. The workspace is created before the terminal agent launches. {sortedProviders.map((provider) => ( composerState?.setProviderFromUser(provider.id)} /> ))} Initial prompt ) : null} {errorMessage ? {errorMessage} : null} ); } function StepHeader({ title, onBack }: { title: string; onBack: () => void }) { const { theme } = useUnistyles(); return ( {title} ); } function ChoiceCard({ title, description, Icon, disabled, pending = false, onPress, }: { title: string; description: string; Icon: ComponentType<{ size: number; color: string }>; disabled: boolean; pending?: boolean; onPress: () => void; }) { const { theme } = useUnistyles(); return ( [ styles.choiceCard, (hovered || pressed) && !disabled ? styles.choiceCardHovered : null, disabled ? styles.cardDisabled : null, ]} > {pending ? ( ) : ( )} {title} {description} ); } function ProviderOption({ provider, selected, disabled, onPress, }: { provider: { id: AgentProvider; label: string; description: string }; selected: boolean; disabled: boolean; onPress: () => void; }) { const { theme } = useUnistyles(); const Icon = getProviderIcon(provider.id); return ( [ styles.providerCard, selected ? styles.providerCardSelected : null, (hovered || pressed) && !disabled ? styles.choiceCardHovered : null, disabled ? styles.cardDisabled : null, ]} > {provider.label} ); } const styles = StyleSheet.create((theme) => ({ header: { gap: theme.spacing[1], }, workspaceTitle: { fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.semibold, color: theme.colors.foreground, }, workspacePath: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, section: { gap: theme.spacing[3], }, sectionTitle: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, color: theme.colors.foregroundMuted, }, helper: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, lineHeight: 20, }, choiceGrid: { gap: theme.spacing[2], }, choiceCard: { flexDirection: "row", alignItems: "center", gap: theme.spacing[3], borderWidth: 1, borderColor: theme.colors.border, borderRadius: theme.borderRadius.lg, backgroundColor: theme.colors.surface1, paddingVertical: theme.spacing[3], paddingHorizontal: theme.spacing[3], }, choiceCardHovered: { backgroundColor: theme.colors.surface2, }, cardDisabled: { opacity: theme.opacity[50], }, choiceIconWrap: { width: 32, height: 32, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", backgroundColor: theme.colors.surface2, }, choiceBody: { flex: 1, gap: 2, }, choiceTitle: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, color: theme.colors.foreground, }, choiceDescription: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, composerCard: { minHeight: 180, borderWidth: 1, borderColor: theme.colors.border, borderRadius: theme.borderRadius.lg, backgroundColor: theme.colors.surface0, overflow: "hidden", }, stepHeader: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, backButton: { width: 28, height: 28, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", backgroundColor: theme.colors.surface2, }, providerGrid: { flexDirection: "row", flexWrap: "wrap", gap: theme.spacing[2], }, providerCard: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], borderWidth: 1, borderColor: theme.colors.border, borderRadius: theme.borderRadius.lg, backgroundColor: theme.colors.surface1, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], }, providerCardSelected: { borderColor: theme.colors.accent, backgroundColor: theme.colors.surface2, }, providerIconWrap: { width: 28, height: 28, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", backgroundColor: theme.colors.surface2, }, providerBody: { flex: 1, }, providerTitle: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, color: theme.colors.foreground, }, field: { gap: theme.spacing[2], }, fieldLabel: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, color: theme.colors.foreground, }, input: { minHeight: 80, borderWidth: 1, borderColor: theme.colors.border, borderRadius: theme.borderRadius.lg, backgroundColor: theme.colors.surface1, color: theme.colors.foreground, paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[3], textAlignVertical: "top", fontSize: theme.fontSize.sm, }, actions: { flexDirection: "row", gap: theme.spacing[2], }, actionButton: { flex: 1, }, errorText: { fontSize: theme.fontSize.sm, color: theme.colors.destructive, lineHeight: 20, }, }));