diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index d53840b3e..88efbfeeb 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -1,4 +1,4 @@ -import { Stack } from "expo-router"; +import { Stack, useLocalSearchParams, usePathname } from "expo-router"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { GestureHandlerRootView } from "react-native-gesture-handler"; @@ -6,12 +6,13 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { RealtimeProvider } from "@/contexts/realtime-context"; import { useAppSettings } from "@/hooks/use-settings"; import { View, ActivityIndicator, Text } from "react-native"; -import { useUnistyles } from "react-native-unistyles"; +import { UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context"; import { DaemonConnectionsProvider } from "@/contexts/daemon-connections-context"; import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useState, type ReactNode } from "react"; +import { useState, type ReactNode, useMemo } from "react"; +import { SlidingSidebar } from "@/components/sliding-sidebar"; function QueryProvider({ children }: { children: ReactNode }) { const [queryClient] = useState( @@ -32,12 +33,23 @@ function QueryProvider({ children }: { children: ReactNode }) { return {children}; } -function AppContainer({ children }: { children: ReactNode }) { +interface AppContainerProps { + children: ReactNode; + selectedAgentId?: string; +} + +function AppContainer({ children, selectedAgentId }: AppContainerProps) { const { theme } = useUnistyles(); + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; return ( - {children} + + {!isMobile && } + {children} + + {isMobile && } ); } @@ -58,6 +70,22 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { return {children}; } +function AppWithSidebar({ children }: { children: ReactNode }) { + const pathname = usePathname(); + const params = useLocalSearchParams<{ agentId?: string }>(); + + const selectedAgentId = useMemo(() => { + if (pathname.startsWith("/agent/") && params.agentId) { + return params.agentId; + } + return undefined; + }, [pathname, params.agentId]); + + return ( + {children} + ); +} + function LoadingView() { return ( - + - + diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index 1e48c2cc3..63bf8aac3 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -24,17 +24,15 @@ import { GitBranch, Folder, RotateCcw, - Plus, Download, Users, ChevronRight, PlusIcon, } from "lucide-react-native"; +import { MenuHeader } from "@/components/headers/menu-header"; import { BackHeader } from "@/components/headers/back-header"; import { AgentStreamView } from "@/components/agent-stream-view"; import { AgentInputArea } from "@/components/agent-input-area"; -import { AgentList } from "@/components/agent-list"; -import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; import { ImportAgentModal } from "@/components/create-agent-modal"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import type { ConnectionStatus } from "@/contexts/daemon-connections-context"; @@ -148,7 +146,6 @@ export default function AgentScreen() { ); } @@ -156,28 +153,17 @@ export default function AgentScreen() { type AgentScreenContentProps = { serverId: string; agentId?: string; - onBack: () => void; }; -const SIDEBAR_WIDTH = 280; -const LARGE_SCREEN_BREAKPOINT = 768; - function AgentScreenContent({ serverId, agentId, - onBack, }: AgentScreenContentProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const router = useRouter(); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); - const isLargeScreen = windowWidth >= LARGE_SCREEN_BREAKPOINT; - const { - agents: aggregatedAgents, - isRevalidating, - refreshAll, - } = useAggregatedAgents(); const [menuVisible, setMenuVisible] = useState(false); const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); const [menuContentHeight, setMenuContentHeight] = useState(0); @@ -193,27 +179,6 @@ function AgentScreenContent({ : undefined ); - - // Get parent agent ID for back navigation - const parentAgentId = agent?.parentAgentId; - - // Navigate to parent agent if this is a child, otherwise go to homepage - const handleBack = useCallback(() => { - if (parentAgentId) { - // Child agent: navigate back to parent - router.push({ - pathname: "/agent/[serverId]/[agentId]", - params: { - serverId: serverId, - agentId: parentAgentId, - }, - }); - } else { - // Root agent: navigate to homepage - onBack(); - } - }, [parentAgentId, router, serverId, onBack]); - // Select the agents Map directly - this is a stable reference that only changes when agents are added/removed const allAgents = useSessionStore( (state) => state.sessions[serverId]?.agents @@ -560,7 +525,7 @@ function AgentScreenContent({ if (agentModel) { params.model = agentModel; } - router.push({ pathname: "/agent/new", params }); + router.push({ pathname: "/", params }); }, [agent, agentModel, handleCloseMenu, router, serverId]); const handleImportAgent = useCallback(() => { @@ -598,7 +563,7 @@ function AgentScreenContent({ return ( <> - + Agent not found @@ -611,87 +576,48 @@ function AgentScreenContent({ return ( <> - - {/* Sidebar - only on large screens */} - {isLargeScreen && ( - - - - - - New Agent - - + {/* Header */} + + + + + + } + /> + + {/* Content Area with Keyboard Animation */} + + + {isInitializing ? ( + + + Loading agent... - - - )} - - {/* Main agent panel */} - - {/* Header */} - - - - - - } - /> - - {/* Content Area with Keyboard Animation */} - - - {isInitializing ? ( - - - Loading agent... - - ) : ( - - )} - - - - {/* Agent Input Area */} - {!isInitializing && agent && resolvedAgentId && ( - )} - + + {/* Agent Input Area */} + {!isInitializing && agent && resolvedAgentId && ( + + )} + {/* Dropdown Menu */} ({ flex: 1, backgroundColor: theme.colors.background, }, - mainLayout: { - flex: 1, - }, - mainLayoutRow: { - flexDirection: "row", - }, - sidebar: { - borderRightWidth: 1, - borderRightColor: theme.colors.border, - }, - sidebarHeader: { - paddingHorizontal: theme.spacing[4], - paddingTop: theme.spacing[4], - paddingBottom: theme.spacing[2], - }, - newAgentButton: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - gap: theme.spacing[2], - paddingVertical: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - }, - newAgentButtonText: { - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - }, - agentPanel: { - flex: 1, - }, contentContainer: { flex: 1, overflow: "hidden", diff --git a/packages/app/src/app/agent/new.tsx b/packages/app/src/app/agent/new.tsx deleted file mode 100644 index 9c206a316..000000000 --- a/packages/app/src/app/agent/new.tsx +++ /dev/null @@ -1,1159 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - View, - Text, - Pressable, - ScrollView, - useWindowDimensions, -} from "react-native"; -import { useLocalSearchParams, useRouter } from "expo-router"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { ChevronRight, Plus, Monitor } from "lucide-react-native"; -import { BackHeader } from "@/components/headers/back-header"; -import { AgentList } from "@/components/agent-list"; -import { AgentInputArea } from "@/components/agent-input-area"; -import { - DropdownSheet, - GitOptionsSection, - WorkingDirectoryDropdown, -} from "@/components/agent-form/agent-form-dropdowns"; -import { useDaemonRequest } from "@/hooks/use-daemon-request"; -import type { SessionOutboundMessage } from "@server/server/messages"; -import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; -import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; -import { useDaemonConnections } from "@/contexts/daemon-connections-context"; -import { formatConnectionStatus } from "@/utils/daemons"; -import { useSessionStore } from "@/stores/session-store"; -import { generateMessageId } from "@/types/stream"; -import type { - AgentProvider, - AgentSessionConfig, -} from "@server/server/agent/agent-sdk-types"; -import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; - -const SIDEBAR_WIDTH = 280; -const LARGE_SCREEN_BREAKPOINT = 768; -const DRAFT_AGENT_ID = "__new_agent__"; -const PROVIDER_DEFINITION_MAP = new Map( - AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]) -); - -function getParamValue(value: string | string[] | undefined) { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; - } - return undefined; -} - -function getValidProvider(value: string | undefined) { - if (!value) { - return undefined; - } - return PROVIDER_DEFINITION_MAP.has(value as AgentProvider) - ? (value as AgentProvider) - : undefined; -} - -function getValidMode( - provider: AgentProvider | undefined, - value: string | undefined -) { - if (!provider || !value) { - return undefined; - } - const definition = PROVIDER_DEFINITION_MAP.get(provider); - const modes = definition?.modes ?? []; - return modes.some((mode) => mode.id === value) ? value : undefined; -} - -type DraftAgentParams = { - serverId?: string; - provider?: string; - modeId?: string; - model?: string; - workingDir?: string; -}; - -type ConfigRowProps = { - label: string; - value: string; - meta?: string; - onPress: () => void; - disabled?: boolean; -}; - -export default function DraftAgentScreen() { - const { theme } = useUnistyles(); - const router = useRouter(); - const { width: windowWidth } = useWindowDimensions(); - const isLargeScreen = windowWidth >= LARGE_SCREEN_BREAKPOINT; - const { agents: aggregatedAgents, isRevalidating, refreshAll } = - useAggregatedAgents(); - const { connectionStates } = useDaemonConnections(); - const params = useLocalSearchParams(); - - const resolvedServerId = getParamValue(params.serverId); - const resolvedProvider = getValidProvider(getParamValue(params.provider)); - const resolvedMode = getValidMode(resolvedProvider, getParamValue(params.modeId)); - const resolvedModel = getParamValue(params.model); - const resolvedWorkingDir = getParamValue(params.workingDir); - - const initialValues = useMemo(() => { - const values: CreateAgentInitialValues = {}; - if (resolvedWorkingDir) { - values.workingDir = resolvedWorkingDir; - } - if (resolvedProvider) { - values.provider = resolvedProvider; - } - if (resolvedMode) { - values.modeId = resolvedMode; - } - return values; - }, [resolvedMode, resolvedProvider, resolvedWorkingDir]); - const { - selectedServerId, - setSelectedServerIdFromUser, - selectedProvider, - setProviderFromUser, - selectedMode, - setModeFromUser, - selectedModel, - setModelFromUser, - workingDir, - setWorkingDirFromUser, - providerDefinitions, - modeOptions, - availableModels, - isModelLoading, - modelError, - refreshProviderModels, - persistFormPreferences, - userEditedPreferencesRef, - } = useAgentFormState({ - initialServerId: resolvedServerId ?? null, - initialValues, - isVisible: true, - isCreateFlow: true, - }); - const hasAppliedModelParamRef = useRef(false); - useEffect(() => { - if (!resolvedModel || hasAppliedModelParamRef.current) { - return; - } - if (availableModels.length === 0) { - return; - } - const isValidModel = availableModels.some((model) => model.id === resolvedModel); - hasAppliedModelParamRef.current = true; - if (!isValidModel) { - return; - } - if (userEditedPreferencesRef.current.model) { - return; - } - setModelFromUser(resolvedModel); - }, [availableModels, resolvedModel, setModelFromUser, userEditedPreferencesRef]); - const hostEntry = selectedServerId - ? connectionStates.get(selectedServerId) - : undefined; - const hostLabel = - hostEntry?.daemon.label ?? selectedServerId ?? "Select host"; - const hostStatus = hostEntry?.status - ? formatConnectionStatus(hostEntry.status) - : undefined; - - const [openDropdown, setOpenDropdown] = useState< - "host" | "provider" | "mode" | "model" | "workingDir" | "baseBranch" | "agent" | null - >(null); - const [errorMessage, setErrorMessage] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const [promptText, setPromptText] = useState(""); - const [baseBranch, setBaseBranch] = useState(""); - // Isolation mode: "none" | "branch" | "worktree" - const [isolationMode, setIsolationMode] = useState<"none" | "branch" | "worktree">("none"); - const [branchName, setBranchName] = useState(""); - const [worktreeSlug, setWorktreeSlug] = useState(""); - const [branchNameEdited, setBranchNameEdited] = useState(false); - const [worktreeSlugEdited, setWorktreeSlugEdited] = useState(false); - const shouldSyncBaseBranchRef = useRef(true); - // Derive old flags from isolationMode for backwards compatibility - const createNewBranch = isolationMode === "branch" || isolationMode === "worktree"; - const createWorktree = isolationMode === "worktree"; - const openDropdownSheet = useCallback( - (key: "host" | "provider" | "mode" | "model" | "workingDir" | "baseBranch" | "agent") => { - setOpenDropdown(key); - }, - [] - ); - const closeDropdown = useCallback(() => { - setOpenDropdown(null); - }, []); - const sessionAgents = useSessionStore((state) => - selectedServerId ? state.sessions[selectedServerId]?.agents : undefined - ); - const agentWorkingDirSuggestions = useMemo(() => { - if (!selectedServerId || !sessionAgents) { - return []; - } - const uniquePaths = new Set(); - sessionAgents.forEach((agent) => { - if (agent.cwd) { - uniquePaths.add(agent.cwd); - } - }); - return Array.from(uniquePaths).sort(); - }, [selectedServerId, sessionAgents]); - - const sessionWs = useSessionStore((state) => - selectedServerId ? state.sessions[selectedServerId]?.ws : undefined - ); - const inertWebSocket = useMemo( - () => ({ - isConnected: false, - isConnecting: false, - conversationId: null, - lastError: null, - send: () => {}, - on: () => () => {}, - sendPing: () => {}, - sendUserMessage: () => {}, - clearAgentAttention: () => {}, - subscribeConnectionStatus: () => () => {}, - getConnectionState: () => ({ isConnected: false, isConnecting: false }), - }), - [] - ); - const effectiveWs = sessionWs ?? inertWebSocket; - const isWsConnected = effectiveWs.getConnectionState - ? effectiveWs.getConnectionState().isConnected - : effectiveWs.isConnected; - - type RepoInfoState = { - cwd: string; - repoRoot: string; - branches: Array<{ name: string; isCurrent: boolean }>; - currentBranch: string | null; - isDirty: boolean; - }; - type GitRepoInfoResponseMessage = Extract< - SessionOutboundMessage, - { type: "git_repo_info_response" } - >; - const gitRepoInfoRequest = useDaemonRequest< - { cwd: string }, - RepoInfoState, - GitRepoInfoResponseMessage - >({ - ws: effectiveWs, - responseType: "git_repo_info_response", - buildRequest: ({ params, requestId }) => ({ - type: "session", - message: { - type: "git_repo_info_request", - cwd: params?.cwd ?? ".", - requestId, - }, - }), - getRequestKey: (params) => params?.cwd ?? "default", - selectData: (message) => ({ - cwd: message.payload.cwd, - repoRoot: message.payload.repoRoot, - branches: message.payload.branches ?? [], - currentBranch: message.payload.currentBranch ?? null, - isDirty: Boolean(message.payload.isDirty), - }), - extractError: (message) => - message.payload.error ? new Error(message.payload.error) : null, - keepPreviousData: false, - }); - const { - status: repoRequestStatus, - data: repoInfo, - error: repoRequestError, - execute: inspectRepoInfo, - reset: resetRepoInfo, - cancel: cancelRepoInfo, - } = gitRepoInfoRequest; - - const trimmedWorkingDir = workingDir.trim(); - const shouldInspectRepo = trimmedWorkingDir.length > 0; - const daemonAvailabilityError = - !selectedServerId || hostEntry?.status !== "online" - ? "Host is offline" - : null; - const repoAvailabilityError = - shouldInspectRepo && (!hostEntry || hostEntry.status !== "online" || !isWsConnected) - ? daemonAvailabilityError ?? - "Repository details will load automatically once the selected host is back online." - : null; - const isNonGitDirectory = - repoRequestStatus === "error" && - /not in a git repository/i.test(repoRequestError?.message ?? ""); - const isDirectoryNotExists = - repoRequestStatus === "error" && - /does not exist|no such file or directory|ENOENT/i.test(repoRequestError?.message ?? ""); - const repoInfoStatus: "idle" | "loading" | "ready" | "error" = !shouldInspectRepo - ? "idle" - : repoAvailabilityError - ? "error" - : repoRequestStatus === "loading" - ? "loading" - : repoRequestStatus === "error" - ? isNonGitDirectory - ? "idle" - : "error" - : repoRequestStatus === "success" - ? "ready" - : "idle"; - const repoInfoError = - repoAvailabilityError ?? (isNonGitDirectory ? null : repoRequestError?.message ?? null); - const gitHelperText = isNonGitDirectory - ? "No git repository detected. Git options are disabled for this directory." - : null; - - const slugifyWorktreeName = useCallback((input: string): string => { - return input - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - }, []); - - const validateWorktreeName = useCallback( - (name: string): { valid: boolean; error?: string } => { - if (!name) { - return { valid: true }; - } - if (name.length > 100) { - return { - valid: false, - error: "Worktree name too long (max 100 characters)", - }; - } - if (!/^[a-z0-9-/]+$/.test(name)) { - return { - valid: false, - error: "Must contain only lowercase letters, numbers, hyphens, and forward slashes", - }; - } - if (name.startsWith("-") || name.endsWith("-")) { - return { valid: false, error: "Cannot start or end with a hyphen" }; - } - if (name.includes("--")) { - return { valid: false, error: "Cannot have consecutive hyphens" }; - } - return { valid: true }; - }, - [] - ); - - const gitBlockingError = useMemo(() => { - if (isNonGitDirectory) { - return null; - } - const trimmedBase = baseBranch.trim(); - const currentBranch = repoInfo?.currentBranch ?? ""; - const isCustomBase = - trimmedBase.length > 0 && - (currentBranch.length === 0 || trimmedBase !== currentBranch); - const requiresBase = createNewBranch || createWorktree || isCustomBase; - - if (requiresBase && !trimmedBase) { - return "Select a base branch before launching the agent"; - } - - if (createNewBranch) { - const slug = branchName.trim(); - const validation = validateWorktreeName(slug); - if (!slug || !validation.valid) { - return `Invalid branch name: ${ - validation.error ?? "Must use lowercase letters, numbers, hyphens, or forward slashes" - }`; - } - } - - if (createWorktree) { - const slug = (worktreeSlug || branchName).trim(); - const validation = validateWorktreeName(slug); - if (!slug || !validation.valid) { - return `Invalid worktree name: ${ - validation.error ?? "Must use lowercase letters, numbers, or hyphens" - }`; - } - } - - if (!createWorktree && repoInfo?.isDirty) { - const intendsCheckout = - createNewBranch || - (trimmedBase.length > 0 && trimmedBase !== repoInfo.currentBranch); - if (intendsCheckout) { - return "Working directory has uncommitted changes. Clean up or create a worktree first."; - } - } - - return null; - }, [ - baseBranch, - branchName, - createNewBranch, - createWorktree, - isNonGitDirectory, - repoInfo, - validateWorktreeName, - worktreeSlug, - ]); - - useEffect(() => { - if (!shouldInspectRepo) { - cancelRepoInfo(); - resetRepoInfo(); - return; - } - if (repoAvailabilityError) { - cancelRepoInfo(); - return; - } - inspectRepoInfo({ cwd: trimmedWorkingDir }).catch(() => {}); - return () => { - cancelRepoInfo(); - }; - }, [ - cancelRepoInfo, - inspectRepoInfo, - repoAvailabilityError, - resetRepoInfo, - shouldInspectRepo, - trimmedWorkingDir, - ]); - - useEffect(() => { - if (!repoInfo) { - return; - } - setBaseBranch((prev) => { - if (shouldSyncBaseBranchRef.current || prev.trim().length === 0) { - shouldSyncBaseBranchRef.current = false; - return repoInfo.currentBranch ?? ""; - } - return prev; - }); - }, [repoInfo]); - - useEffect(() => { - if (!isNonGitDirectory) { - return; - } - if ( - isolationMode !== "none" || - baseBranch.trim().length > 0 || - branchName.trim().length > 0 || - worktreeSlug.trim().length > 0 - ) { - setIsolationMode("none"); - setBaseBranch(""); - setBranchName(""); - setWorktreeSlug(""); - setBranchNameEdited(false); - setWorktreeSlugEdited(false); - shouldSyncBaseBranchRef.current = true; - } - }, [ - baseBranch, - branchName, - isolationMode, - isNonGitDirectory, - worktreeSlug, - ]); - - const handleBaseBranchChange = useCallback((value: string) => { - setBaseBranch(value); - shouldSyncBaseBranchRef.current = false; - }, []); - - const renderConfigRow = useCallback( - ({ label, value, meta, onPress, disabled }: ConfigRowProps) => ( - - - {label} - - {value} - - {meta ? {meta} : null} - - - - ), - [theme.colors.mutedForeground] - ); - const renderDropdownTrigger = useCallback( - ({ - label, - value, - placeholder, - onPress, - disabled, - }: { - label: string; - value: string; - placeholder: string; - onPress: () => void; - disabled?: boolean; - }) => - renderConfigRow({ - label, - value: value || placeholder, - onPress, - disabled, - }), - [renderConfigRow] - ); - - const handleBackToHome = useCallback(() => { - router.replace("/"); - }, [router]); - - const handleCreateNewAgent = useCallback(() => { - router.push("/agent/new"); - }, [router]); - const pendingRequestIdRef = useRef(null); - const sessionMethods = useSessionStore((state) => - selectedServerId ? state.sessions[selectedServerId]?.methods : undefined - ); - - const handleCreateFromInput = useCallback( - async ({ - text, - images, - }: { - text: string; - images?: Array<{ uri: string; mimeType: string }>; - }) => { - setErrorMessage(""); - const trimmedPath = workingDir.trim(); - const trimmedPrompt = text.trim(); - if (!trimmedPath) { - setErrorMessage("Working directory is required"); - throw new Error("Working directory is required"); - } - if (isDirectoryNotExists) { - setErrorMessage("Working directory does not exist on the selected host"); - throw new Error("Working directory does not exist on the selected host"); - } - if (!trimmedPrompt) { - setErrorMessage("Initial prompt is required"); - throw new Error("Initial prompt is required"); - } - if (!selectedServerId) { - setErrorMessage("No host selected"); - throw new Error("No host selected"); - } - if (gitBlockingError) { - setErrorMessage(gitBlockingError); - throw new Error(gitBlockingError); - } - if (isLoading) { - throw new Error("Already loading"); - } - const createAgent = sessionMethods?.createAgent; - if (!createAgent) { - setErrorMessage("Host is not connected"); - throw new Error("Host is not connected"); - } - const modeId = - modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; - const trimmedModel = selectedModel.trim(); - const config: AgentSessionConfig = { - provider: selectedProvider, - cwd: trimmedPath, - ...(modeId ? { modeId } : {}), - ...(trimmedModel ? { model: trimmedModel } : {}), - }; - const trimmedBaseBranch = baseBranch.trim(); - const shouldIncludeBase = - trimmedBaseBranch.length > 0 || - createNewBranch || - createWorktree; - const gitOptions = - shouldIncludeBase && !isNonGitDirectory - ? { - ...(trimmedBaseBranch ? { baseBranch: trimmedBaseBranch } : {}), - ...(createNewBranch - ? { createNewBranch: true, newBranchName: branchName.trim() } - : {}), - ...(createWorktree - ? { - createWorktree: true, - worktreeSlug: (worktreeSlug || branchName).trim(), - } - : {}), - } - : undefined; - - void persistFormPreferences(); - - const requestId = generateMessageId(); - pendingRequestIdRef.current = requestId; - setIsLoading(true); - createAgent({ - config, - initialPrompt: trimmedPrompt, - images, - git: gitOptions, - requestId, - }); - }, - [ - baseBranch, - branchName, - createNewBranch, - createWorktree, - gitBlockingError, - isDirectoryNotExists, - isLoading, - isNonGitDirectory, - modeOptions, - persistFormPreferences, - selectedMode, - selectedModel, - selectedProvider, - selectedServerId, - sessionMethods, - workingDir, - worktreeSlug, - ] - ); - - useEffect(() => { - if (!sessionWs) { - return; - } - const unsubscribe = sessionWs.on("status", (message) => { - if (message.type !== "status") { - return; - } - const payload = message.payload as { - status: string; - agentId?: string; - requestId?: string; - error?: string; - }; - const expectedRequestId = pendingRequestIdRef.current; - if (!expectedRequestId || payload.requestId !== expectedRequestId) { - return; - } - if (payload.status === "agent_create_failed") { - pendingRequestIdRef.current = null; - setIsLoading(false); - setErrorMessage(payload.error ?? "Failed to create agent"); - return; - } - if (payload.status !== "agent_created" || !payload.agentId) { - return; - } - if (!selectedServerId) { - pendingRequestIdRef.current = null; - setIsLoading(false); - return; - } - pendingRequestIdRef.current = null; - setIsLoading(false); - setPromptText(""); - router.replace({ - pathname: "/agent/[serverId]/[agentId]", - params: { - serverId: selectedServerId, - agentId: payload.agentId, - }, - }); - }); - - return () => { - unsubscribe(); - }; - }, [router, selectedServerId, sessionWs]); - - const selectedProviderLabel = - providerDefinitions.find((provider) => provider.id === selectedProvider)?.label ?? - selectedProvider; - const selectedModeLabel = - modeOptions.length > 0 - ? modeOptions.find((mode) => mode.id === selectedMode)?.label ?? - modeOptions[0]?.label ?? - "Default" - : "Automatic"; - - return ( - - - {isLargeScreen && ( - - - - - - New Agent - - - - - - )} - - - openDropdownSheet("host")} - > - - {hostLabel} - - - } - /> - - - - openDropdownSheet("workingDir")} - onClose={closeDropdown} - disabled={false} - suggestedPaths={agentWorkingDirSuggestions} - onSelectPath={setWorkingDirFromUser} - label="Working Directory" - wrapInContainer={false} - renderTrigger={renderDropdownTrigger} - /> - {isDirectoryNotExists && ( - - - Directory does not exist on the selected host - - - )} - {renderConfigRow({ - label: "Agent", - value: `${selectedProviderLabel} · ${selectedModel || "auto"} · ${selectedModeLabel}`, - onPress: () => openDropdownSheet("agent"), - })} - {/* Git section - only show for git repos */} - {trimmedWorkingDir.length > 0 && !isNonGitDirectory ? ( - { - setIsolationMode(mode); - if (mode === "none") { - setBranchName(""); - setWorktreeSlug(""); - setBranchNameEdited(false); - setWorktreeSlugEdited(false); - } else if (mode === "branch") { - if (!branchNameEdited) { - const slug = slugifyWorktreeName(baseBranch || ""); - setBranchName(slug); - } - } else if (mode === "worktree") { - if (!worktreeSlugEdited) { - const slug = slugifyWorktreeName( - branchName || baseBranch || "" - ); - setWorktreeSlug(slug); - } - } - }} - branchName={branchName} - onBranchNameChange={(value) => { - setBranchName(slugifyWorktreeName(value)); - setBranchNameEdited(true); - }} - worktreeSlug={worktreeSlug} - onWorktreeSlugChange={(value) => { - setWorktreeSlug(slugifyWorktreeName(value)); - setWorktreeSlugEdited(true); - }} - gitValidationError={gitBlockingError} - isBaseDropdownOpen={openDropdown === "baseBranch"} - onToggleBaseDropdown={() => openDropdownSheet("baseBranch")} - onCloseDropdown={closeDropdown} - /> - ) : null} - - - {connectionStates.size === 0 ? ( - - No hosts available yet. - - ) : ( - - {Array.from(connectionStates.values()).map(({ daemon, status }) => { - const isSelected = daemon.id === selectedServerId; - const label = daemon.label ?? daemon.wsUrl ?? daemon.id; - return ( - { - setSelectedServerIdFromUser(daemon.id); - closeDropdown(); - }} - > - - {label} - - - {formatConnectionStatus(status)} - - - ); - })} - - )} - - - {/* Agent dropdown sheet - provider + model + mode */} - - - Provider - - {providerDefinitions.map((definition) => { - const isSelected = definition.id === selectedProvider; - return ( - { - setProviderFromUser(definition.id); - }} - > - - {definition.label} - - {definition.description ? ( - - {definition.description} - - ) : null} - - ); - })} - - - - - Model - - setModelFromUser("")} - > - - Automatic (provider default) - - - {availableModels.map((model) => { - const isSelected = model.id === selectedModel; - return ( - setModelFromUser(model.id)} - > - - {model.label} - - {model.description ? ( - - {model.description} - - ) : null} - - ); - })} - - - - {modeOptions.length > 0 ? ( - - Mode - - {modeOptions.map((mode) => { - const isSelected = mode.id === selectedMode; - return ( - setModeFromUser(mode.id)} - > - - {mode.label} - - {mode.description ? ( - - {mode.description} - - ) : null} - - ); - })} - - - ) : null} - - - {errorMessage ? ( - - {errorMessage} - - ) : null} - - - - - - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.background, - }, - mainLayout: { - flex: 1, - }, - mainLayoutRow: { - flexDirection: "row", - }, - sidebar: { - borderRightWidth: 1, - borderRightColor: theme.colors.border, - }, - sidebarHeader: { - paddingHorizontal: theme.spacing[4], - paddingTop: theme.spacing[4], - paddingBottom: theme.spacing[2], - }, - newAgentButton: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - gap: theme.spacing[2], - paddingVertical: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - }, - newAgentButtonText: { - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - }, - agentPanel: { - flex: 1, - }, - contentContainer: { - flex: 1, - }, - inputAreaWrapper: { - backgroundColor: theme.colors.background, - }, - configScrollContent: { - flexGrow: 1, - }, - configSection: { - paddingHorizontal: theme.spacing[4], - paddingTop: theme.spacing[3], - paddingBottom: theme.spacing[4], - gap: theme.spacing[2], - }, - configRow: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.border, - backgroundColor: theme.colors.card, - }, - configRowDisabled: { - opacity: theme.opacity[50], - }, - configTextGroup: { - flex: 1, - gap: theme.spacing[1], - marginRight: theme.spacing[2], - }, - configLabel: { - fontSize: theme.fontSize.xs, - textTransform: "uppercase", - letterSpacing: 0.6, - color: theme.colors.mutedForeground, - }, - configValue: { - fontSize: theme.fontSize.base, - color: theme.colors.foreground, - }, - configMeta: { - fontSize: theme.fontSize.xs, - color: theme.colors.mutedForeground, - }, - dropdownHelper: { - fontSize: theme.fontSize.sm, - color: theme.colors.mutedForeground, - }, - dropdownSheetList: { - marginTop: theme.spacing[3], - }, - dropdownSheetOption: { - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[4], - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.border, - backgroundColor: theme.colors.background, - marginBottom: theme.spacing[2], - }, - dropdownSheetOptionSelected: { - borderColor: theme.colors.palette.blue[400], - backgroundColor: "rgba(59, 130, 246, 0.18)", - }, - dropdownSheetOptionLabel: { - color: theme.colors.foreground, - fontWeight: theme.fontWeight.semibold, - }, - dropdownSheetOptionDescription: { - color: theme.colors.mutedForeground, - fontSize: theme.fontSize.sm, - marginTop: theme.spacing[1], - }, - errorContainer: { - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], - marginHorizontal: theme.spacing[4], - marginBottom: theme.spacing[2], - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.destructive, - }, - errorText: { - color: theme.colors.destructiveForeground, - fontSize: theme.fontSize.sm, - }, - warningContainer: { - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.palette.yellow[400], - }, - warningText: { - color: "#000000", - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, - }, - hostBadge: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - backgroundColor: theme.colors.muted, - borderRadius: theme.borderRadius.full, - }, - hostBadgeLabel: { - color: theme.colors.mutedForeground, - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.semibold, - }, - hostStatusDot: { - width: 6, - height: 6, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.mutedForeground, - }, - hostStatusDotOnline: { - backgroundColor: theme.colors.palette.green[500], - }, - agentSheetSection: { - marginBottom: theme.spacing[4], - }, - agentSheetSectionLabel: { - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.foreground, - marginBottom: theme.spacing[2], - }, -})); diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index d072c8a71..e0491c74a 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -1,144 +1,938 @@ -import { View, ActivityIndicator } from "react-native"; -import { useState, useCallback, useMemo, useRef, useEffect } from "react"; -import { useFocusEffect } from "@react-navigation/native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; -import ReanimatedAnimated, { useAnimatedStyle } from "react-native-reanimated"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { HomeHeader } from "@/components/headers/home-header"; -import { HomeFooter } from "@/components/home-footer"; -import { EmptyState } from "@/components/empty-state"; -import { AgentList } from "@/components/agent-list"; -import { ImportAgentModal } from "@/components/create-agent-modal"; -import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + View, + Text, + Pressable, + ScrollView, +} from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; -import { endNavigationTiming, HOME_NAVIGATION_KEY } from "@/utils/navigation-timing"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { ChevronRight, Monitor } from "lucide-react-native"; +import { MenuHeader } from "@/components/headers/menu-header"; +import { AgentInputArea } from "@/components/agent-input-area"; +import { + DropdownSheet, + GitOptionsSection, + WorkingDirectoryDropdown, +} from "@/components/agent-form/agent-form-dropdowns"; +import { useDaemonRequest } from "@/hooks/use-daemon-request"; +import type { SessionOutboundMessage } from "@server/server/messages"; +import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; +import { useDaemonConnections } from "@/contexts/daemon-connections-context"; +import { formatConnectionStatus } from "@/utils/daemons"; +import { useSessionStore } from "@/stores/session-store"; +import { generateMessageId } from "@/types/stream"; +import type { + AgentProvider, + AgentSessionConfig, +} from "@server/server/agent/agent-sdk-types"; +import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; + +const DRAFT_AGENT_ID = "__new_agent__"; +const PROVIDER_DEFINITION_MAP = new Map( + AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]) +); + +function getParamValue(value: string | string[] | undefined) { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + return undefined; +} + +function getValidProvider(value: string | undefined) { + if (!value) { + return undefined; + } + return PROVIDER_DEFINITION_MAP.has(value as AgentProvider) + ? (value as AgentProvider) + : undefined; +} + +function getValidMode( + provider: AgentProvider | undefined, + value: string | undefined +) { + if (!provider || !value) { + return undefined; + } + const definition = PROVIDER_DEFINITION_MAP.get(provider); + const modes = definition?.modes ?? []; + return modes.some((mode) => mode.id === value) ? value : undefined; +} + +type DraftAgentParams = { + serverId?: string; + provider?: string; + modeId?: string; + model?: string; + workingDir?: string; +}; + +type ConfigRowProps = { + label: string; + value: string; + meta?: string; + onPress: () => void; + disabled?: boolean; +}; export default function HomeScreen() { - const insets = useSafeAreaInsets(); const { theme } = useUnistyles(); - const { - agents: aggregatedAgents, - isInitialLoad, - isRevalidating, - refreshAll, - } = useAggregatedAgents(); const router = useRouter(); - const [showImportModal, setShowImportModal] = useState(false); - const [pendingImportServerId, setPendingImportServerId] = useState(null); - const { modal, flow, action, serverId: serverIdParam } = useLocalSearchParams<{ - modal?: string; - flow?: string; - action?: string; - serverId?: string; - }>(); - const deepLinkHandledRef = useRef(null); + const { connectionStates } = useDaemonConnections(); + const params = useLocalSearchParams(); - // Keyboard animation - const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); - const animatedKeyboardStyle = useAnimatedStyle(() => { - "worklet"; - const absoluteHeight = Math.abs(keyboardHeight.value); - const padding = Math.max(0, absoluteHeight - insets.bottom); - return { - paddingBottom: padding, - }; + const resolvedServerId = getParamValue(params.serverId); + const resolvedProvider = getValidProvider(getParamValue(params.provider)); + const resolvedMode = getValidMode(resolvedProvider, getParamValue(params.modeId)); + const resolvedModel = getParamValue(params.model); + const resolvedWorkingDir = getParamValue(params.workingDir); + + const initialValues = useMemo(() => { + const values: CreateAgentInitialValues = {}; + if (resolvedWorkingDir) { + values.workingDir = resolvedWorkingDir; + } + if (resolvedProvider) { + values.provider = resolvedProvider; + } + if (resolvedMode) { + values.modeId = resolvedMode; + } + return values; + }, [resolvedMode, resolvedProvider, resolvedWorkingDir]); + const { + selectedServerId, + setSelectedServerIdFromUser, + selectedProvider, + setProviderFromUser, + selectedMode, + setModeFromUser, + selectedModel, + setModelFromUser, + workingDir, + setWorkingDirFromUser, + providerDefinitions, + modeOptions, + availableModels, + isModelLoading, + modelError, + refreshProviderModels, + persistFormPreferences, + userEditedPreferencesRef, + } = useAgentFormState({ + initialServerId: resolvedServerId ?? null, + initialValues, + isVisible: true, + isCreateFlow: true, }); + const hasAppliedModelParamRef = useRef(false); + useEffect(() => { + if (!resolvedModel || hasAppliedModelParamRef.current) { + return; + } + if (availableModels.length === 0) { + return; + } + const isValidModel = availableModels.some((model) => model.id === resolvedModel); + hasAppliedModelParamRef.current = true; + if (!isValidModel) { + return; + } + if (userEditedPreferencesRef.current.model) { + return; + } + setModelFromUser(resolvedModel); + }, [availableModels, resolvedModel, setModelFromUser, userEditedPreferencesRef]); + const hostEntry = selectedServerId + ? connectionStates.get(selectedServerId) + : undefined; + const hostLabel = + hostEntry?.daemon.label ?? selectedServerId ?? "Select host"; + const hostStatus = hostEntry?.status + ? formatConnectionStatus(hostEntry.status) + : undefined; - const hasAgents = aggregatedAgents.length > 0; + const [openDropdown, setOpenDropdown] = useState< + "host" | "provider" | "mode" | "model" | "workingDir" | "baseBranch" | "agent" | null + >(null); + const [errorMessage, setErrorMessage] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [promptText, setPromptText] = useState(""); + const [baseBranch, setBaseBranch] = useState(""); + const [isolationMode, setIsolationMode] = useState<"none" | "branch" | "worktree">("none"); + const [branchName, setBranchName] = useState(""); + const [worktreeSlug, setWorktreeSlug] = useState(""); + const [branchNameEdited, setBranchNameEdited] = useState(false); + const [worktreeSlugEdited, setWorktreeSlugEdited] = useState(false); + const shouldSyncBaseBranchRef = useRef(true); + const createNewBranch = isolationMode === "branch" || isolationMode === "worktree"; + const createWorktree = isolationMode === "worktree"; + const openDropdownSheet = useCallback( + (key: "host" | "provider" | "mode" | "model" | "workingDir" | "baseBranch" | "agent") => { + setOpenDropdown(key); + }, + [] + ); + const closeDropdown = useCallback(() => { + setOpenDropdown(null); + }, []); + const sessionAgents = useSessionStore((state) => + selectedServerId ? state.sessions[selectedServerId]?.agents : undefined + ); + const agentWorkingDirSuggestions = useMemo(() => { + if (!selectedServerId || !sessionAgents) { + return []; + } + const uniquePaths = new Set(); + sessionAgents.forEach((agent) => { + if (agent.cwd) { + uniquePaths.add(agent.cwd); + } + }); + return Array.from(uniquePaths).sort(); + }, [selectedServerId, sessionAgents]); - const handleCreateAgent = useCallback(() => { - router.push("/agent/new"); - }, [router]); + const sessionWs = useSessionStore((state) => + selectedServerId ? state.sessions[selectedServerId]?.ws : undefined + ); + const inertWebSocket = useMemo( + () => ({ + isConnected: false, + isConnecting: false, + conversationId: null, + lastError: null, + send: () => {}, + on: () => () => {}, + sendPing: () => {}, + sendUserMessage: () => {}, + clearAgentAttention: () => {}, + subscribeConnectionStatus: () => () => {}, + getConnectionState: () => ({ isConnected: false, isConnecting: false }), + }), + [] + ); + const effectiveWs = sessionWs ?? inertWebSocket; + const isWsConnected = effectiveWs.getConnectionState + ? effectiveWs.getConnectionState().isConnected + : effectiveWs.isConnected; - const openImportModal = useCallback((serverIdOverride?: string | null) => { - setPendingImportServerId(serverIdOverride ?? null); - setShowImportModal(true); + type RepoInfoState = { + cwd: string; + repoRoot: string; + branches: Array<{ name: string; isCurrent: boolean }>; + currentBranch: string | null; + isDirty: boolean; + }; + type GitRepoInfoResponseMessage = Extract< + SessionOutboundMessage, + { type: "git_repo_info_response" } + >; + const gitRepoInfoRequest = useDaemonRequest< + { cwd: string }, + RepoInfoState, + GitRepoInfoResponseMessage + >({ + ws: effectiveWs, + responseType: "git_repo_info_response", + buildRequest: ({ params, requestId }) => ({ + type: "session", + message: { + type: "git_repo_info_request", + cwd: params?.cwd ?? ".", + requestId, + }, + }), + getRequestKey: (params) => params?.cwd ?? "default", + selectData: (message) => ({ + cwd: message.payload.cwd, + repoRoot: message.payload.repoRoot, + branches: message.payload.branches ?? [], + currentBranch: message.payload.currentBranch ?? null, + isDirty: Boolean(message.payload.isDirty), + }), + extractError: (message) => + message.payload.error ? new Error(message.payload.error) : null, + keepPreviousData: false, + }); + const { + status: repoRequestStatus, + data: repoInfo, + error: repoRequestError, + execute: inspectRepoInfo, + reset: resetRepoInfo, + cancel: cancelRepoInfo, + } = gitRepoInfoRequest; + + const trimmedWorkingDir = workingDir.trim(); + const shouldInspectRepo = trimmedWorkingDir.length > 0; + const daemonAvailabilityError = + !selectedServerId || hostEntry?.status !== "online" + ? "Host is offline" + : null; + const repoAvailabilityError = + shouldInspectRepo && (!hostEntry || hostEntry.status !== "online" || !isWsConnected) + ? daemonAvailabilityError ?? + "Repository details will load automatically once the selected host is back online." + : null; + const isNonGitDirectory = + repoRequestStatus === "error" && + /not in a git repository/i.test(repoRequestError?.message ?? ""); + const isDirectoryNotExists = + repoRequestStatus === "error" && + /does not exist|no such file or directory|ENOENT/i.test(repoRequestError?.message ?? ""); + const repoInfoStatus: "idle" | "loading" | "ready" | "error" = !shouldInspectRepo + ? "idle" + : repoAvailabilityError + ? "error" + : repoRequestStatus === "loading" + ? "loading" + : repoRequestStatus === "error" + ? isNonGitDirectory + ? "idle" + : "error" + : repoRequestStatus === "success" + ? "ready" + : "idle"; + const repoInfoError = + repoAvailabilityError ?? (isNonGitDirectory ? null : repoRequestError?.message ?? null); + const gitHelperText = isNonGitDirectory + ? "No git repository detected. Git options are disabled for this directory." + : null; + + const slugifyWorktreeName = useCallback((input: string): string => { + return input + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); }, []); - const handleImportAgent = useCallback(() => { - openImportModal(); - }, [openImportModal]); + const validateWorktreeName = useCallback( + (name: string): { valid: boolean; error?: string } => { + if (!name) { + return { valid: true }; + } + if (name.length > 100) { + return { + valid: false, + error: "Worktree name too long (max 100 characters)", + }; + } + if (!/^[a-z0-9-/]+$/.test(name)) { + return { + valid: false, + error: "Must contain only lowercase letters, numbers, hyphens, and forward slashes", + }; + } + if (name.startsWith("-") || name.endsWith("-")) { + return { valid: false, error: "Cannot start or end with a hyphen" }; + } + if (name.includes("--")) { + return { valid: false, error: "Cannot have consecutive hyphens" }; + } + return { valid: true }; + }, + [] + ); - const handleCloseImportModal = useCallback(() => { - setShowImportModal(false); - setPendingImportServerId(null); - }, []); - - const wantsImportDeepLink = useMemo(() => { - const values = [modal, flow, action]; - return values.some( - (value) => typeof value === "string" && value.trim().toLowerCase() === "import" - ); - }, [action, flow, modal]); - const deepLinkServerId = typeof serverIdParam === "string" ? serverIdParam : null; - const deepLinkKey = useMemo(() => { - if (!wantsImportDeepLink) { + const gitBlockingError = useMemo(() => { + if (isNonGitDirectory) { return null; } - return JSON.stringify({ - action: action ?? null, - flow: flow ?? null, - modal: modal ?? null, - serverId: deepLinkServerId, - }); - }, [action, flow, modal, deepLinkServerId, wantsImportDeepLink]); + const trimmedBase = baseBranch.trim(); + const currentBranch = repoInfo?.currentBranch ?? ""; + const isCustomBase = + trimmedBase.length > 0 && + (currentBranch.length === 0 || trimmedBase !== currentBranch); + const requiresBase = createNewBranch || createWorktree || isCustomBase; + + if (requiresBase && !trimmedBase) { + return "Select a base branch before launching the agent"; + } + + if (createNewBranch) { + const slug = branchName.trim(); + const validation = validateWorktreeName(slug); + if (!slug || !validation.valid) { + return `Invalid branch name: ${ + validation.error ?? "Must use lowercase letters, numbers, hyphens, or forward slashes" + }`; + } + } + + if (createWorktree) { + const slug = (worktreeSlug || branchName).trim(); + const validation = validateWorktreeName(slug); + if (!slug || !validation.valid) { + return `Invalid worktree name: ${ + validation.error ?? "Must use lowercase letters, numbers, or hyphens" + }`; + } + } + + if (!createWorktree && repoInfo?.isDirty) { + const intendsCheckout = + createNewBranch || + (trimmedBase.length > 0 && trimmedBase !== repoInfo.currentBranch); + if (intendsCheckout) { + return "Working directory has uncommitted changes. Clean up or create a worktree first."; + } + } + + return null; + }, [ + baseBranch, + branchName, + createNewBranch, + createWorktree, + isNonGitDirectory, + repoInfo, + validateWorktreeName, + worktreeSlug, + ]); useEffect(() => { - if (!wantsImportDeepLink || !deepLinkKey) { - deepLinkHandledRef.current = null; + if (!shouldInspectRepo) { + cancelRepoInfo(); + resetRepoInfo(); return; } - if (deepLinkHandledRef.current === deepLinkKey) { + if (repoAvailabilityError) { + cancelRepoInfo(); return; } - deepLinkHandledRef.current = deepLinkKey; - openImportModal(deepLinkServerId); - }, [deepLinkKey, deepLinkServerId, openImportModal, wantsImportDeepLink]); + inspectRepoInfo({ cwd: trimmedWorkingDir }).catch(() => {}); + return () => { + cancelRepoInfo(); + }; + }, [ + cancelRepoInfo, + inspectRepoInfo, + repoAvailabilityError, + resetRepoInfo, + shouldInspectRepo, + trimmedWorkingDir, + ]); - useFocusEffect( - useCallback(() => { - endNavigationTiming(HOME_NAVIGATION_KEY, { screen: "home" }); - }, []) + useEffect(() => { + if (!repoInfo) { + return; + } + setBaseBranch((prev) => { + if (shouldSyncBaseBranchRef.current || prev.trim().length === 0) { + shouldSyncBaseBranchRef.current = false; + return repoInfo.currentBranch ?? ""; + } + return prev; + }); + }, [repoInfo]); + + useEffect(() => { + if (!isNonGitDirectory) { + return; + } + if ( + isolationMode !== "none" || + baseBranch.trim().length > 0 || + branchName.trim().length > 0 || + worktreeSlug.trim().length > 0 + ) { + setIsolationMode("none"); + setBaseBranch(""); + setBranchName(""); + setWorktreeSlug(""); + setBranchNameEdited(false); + setWorktreeSlugEdited(false); + shouldSyncBaseBranchRef.current = true; + } + }, [ + baseBranch, + branchName, + isolationMode, + isNonGitDirectory, + worktreeSlug, + ]); + + const handleBaseBranchChange = useCallback((value: string) => { + setBaseBranch(value); + shouldSyncBaseBranchRef.current = false; + }, []); + + const renderConfigRow = useCallback( + ({ label, value, meta, onPress, disabled }: ConfigRowProps) => ( + + + {label} + + {value} + + {meta ? {meta} : null} + + + + ), + [theme.colors.mutedForeground] ); + const renderDropdownTrigger = useCallback( + ({ + label, + value, + placeholder, + onPress, + disabled, + }: { + label: string; + value: string; + placeholder: string; + onPress: () => void; + disabled?: boolean; + }) => + renderConfigRow({ + label, + value: value || placeholder, + onPress, + disabled, + }), + [renderConfigRow] + ); + + const pendingRequestIdRef = useRef(null); + const sessionMethods = useSessionStore((state) => + selectedServerId ? state.sessions[selectedServerId]?.methods : undefined + ); + + const handleCreateFromInput = useCallback( + async ({ + text, + images, + }: { + text: string; + images?: Array<{ uri: string; mimeType: string }>; + }) => { + setErrorMessage(""); + const trimmedPath = workingDir.trim(); + const trimmedPrompt = text.trim(); + if (!trimmedPath) { + setErrorMessage("Working directory is required"); + throw new Error("Working directory is required"); + } + if (isDirectoryNotExists) { + setErrorMessage("Working directory does not exist on the selected host"); + throw new Error("Working directory does not exist on the selected host"); + } + if (!trimmedPrompt) { + setErrorMessage("Initial prompt is required"); + throw new Error("Initial prompt is required"); + } + if (!selectedServerId) { + setErrorMessage("No host selected"); + throw new Error("No host selected"); + } + if (gitBlockingError) { + setErrorMessage(gitBlockingError); + throw new Error(gitBlockingError); + } + if (isLoading) { + throw new Error("Already loading"); + } + const createAgent = sessionMethods?.createAgent; + if (!createAgent) { + setErrorMessage("Host is not connected"); + throw new Error("Host is not connected"); + } + const modeId = + modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; + const trimmedModel = selectedModel.trim(); + const config: AgentSessionConfig = { + provider: selectedProvider, + cwd: trimmedPath, + ...(modeId ? { modeId } : {}), + ...(trimmedModel ? { model: trimmedModel } : {}), + }; + const trimmedBaseBranch = baseBranch.trim(); + const shouldIncludeBase = + trimmedBaseBranch.length > 0 || + createNewBranch || + createWorktree; + const gitOptions = + shouldIncludeBase && !isNonGitDirectory + ? { + ...(trimmedBaseBranch ? { baseBranch: trimmedBaseBranch } : {}), + ...(createNewBranch + ? { createNewBranch: true, newBranchName: branchName.trim() } + : {}), + ...(createWorktree + ? { + createWorktree: true, + worktreeSlug: (worktreeSlug || branchName).trim(), + } + : {}), + } + : undefined; + + void persistFormPreferences(); + + const requestId = generateMessageId(); + pendingRequestIdRef.current = requestId; + setIsLoading(true); + createAgent({ + config, + initialPrompt: trimmedPrompt, + images, + git: gitOptions, + requestId, + }); + }, + [ + baseBranch, + branchName, + createNewBranch, + createWorktree, + gitBlockingError, + isDirectoryNotExists, + isLoading, + isNonGitDirectory, + modeOptions, + persistFormPreferences, + selectedMode, + selectedModel, + selectedProvider, + selectedServerId, + sessionMethods, + workingDir, + worktreeSlug, + ] + ); + + useEffect(() => { + if (!sessionWs) { + return; + } + const unsubscribe = sessionWs.on("status", (message) => { + if (message.type !== "status") { + return; + } + const payload = message.payload as { + status: string; + agentId?: string; + requestId?: string; + error?: string; + }; + const expectedRequestId = pendingRequestIdRef.current; + if (!expectedRequestId || payload.requestId !== expectedRequestId) { + return; + } + if (payload.status === "agent_create_failed") { + pendingRequestIdRef.current = null; + setIsLoading(false); + setErrorMessage(payload.error ?? "Failed to create agent"); + return; + } + if (payload.status !== "agent_created" || !payload.agentId) { + return; + } + if (!selectedServerId) { + pendingRequestIdRef.current = null; + setIsLoading(false); + return; + } + pendingRequestIdRef.current = null; + setIsLoading(false); + setPromptText(""); + router.replace({ + pathname: "/agent/[serverId]/[agentId]", + params: { + serverId: selectedServerId, + agentId: payload.agentId, + }, + }); + }); + + return () => { + unsubscribe(); + }; + }, [router, selectedServerId, sessionWs]); + + const selectedProviderLabel = + providerDefinitions.find((provider) => provider.id === selectedProvider)?.label ?? + selectedProvider; + const selectedModeLabel = + modeOptions.length > 0 + ? modeOptions.find((mode) => mode.id === selectedMode)?.label ?? + modeOptions[0]?.label ?? + "Default" + : "Automatic"; return ( - {/* Header */} - + + openDropdownSheet("host")} + > + + {hostLabel} + + + } + /> - {/* Content Area with Keyboard Animation */} - - {isInitialLoad ? ( - - + + + openDropdownSheet("workingDir")} + onClose={closeDropdown} + disabled={false} + suggestedPaths={agentWorkingDirSuggestions} + onSelectPath={setWorkingDirFromUser} + label="Working Directory" + wrapInContainer={false} + renderTrigger={renderDropdownTrigger} + /> + {isDirectoryNotExists && ( + + + Directory does not exist on the selected host + + + )} + {renderConfigRow({ + label: "Agent", + value: `${selectedProviderLabel} · ${selectedModel || "auto"} · ${selectedModeLabel}`, + onPress: () => openDropdownSheet("agent"), + })} + {trimmedWorkingDir.length > 0 && !isNonGitDirectory ? ( + { + setIsolationMode(mode); + if (mode === "none") { + setBranchName(""); + setWorktreeSlug(""); + setBranchNameEdited(false); + setWorktreeSlugEdited(false); + } else if (mode === "branch") { + if (!branchNameEdited) { + const slug = slugifyWorktreeName(baseBranch || ""); + setBranchName(slug); + } + } else if (mode === "worktree") { + if (!worktreeSlugEdited) { + const slug = slugifyWorktreeName( + branchName || baseBranch || "" + ); + setWorktreeSlug(slug); + } + } + }} + branchName={branchName} + onBranchNameChange={(value) => { + setBranchName(slugifyWorktreeName(value)); + setBranchNameEdited(true); + }} + worktreeSlug={worktreeSlug} + onWorktreeSlugChange={(value) => { + setWorktreeSlug(slugifyWorktreeName(value)); + setWorktreeSlugEdited(true); + }} + gitValidationError={gitBlockingError} + isBaseDropdownOpen={openDropdown === "baseBranch"} + onToggleBaseDropdown={() => openDropdownSheet("baseBranch")} + onCloseDropdown={closeDropdown} + /> + ) : null} - ) : hasAgents ? ( - - ) : ( - - )} - + + {connectionStates.size === 0 ? ( + + No hosts available yet. + + ) : ( + + {Array.from(connectionStates.values()).map(({ daemon, status }) => { + const isSelected = daemon.id === selectedServerId; + const label = daemon.label ?? daemon.wsUrl ?? daemon.id; + return ( + { + setSelectedServerIdFromUser(daemon.id); + closeDropdown(); + }} + > + + {label} + + + {formatConnectionStatus(status)} + + + ); + })} + + )} + - {/* Home Footer */} - + + + Provider + + {providerDefinitions.map((definition) => { + const isSelected = definition.id === selectedProvider; + return ( + { + setProviderFromUser(definition.id); + }} + > + + {definition.label} + + {definition.description ? ( + + {definition.description} + + ) : null} + + ); + })} + + - {/* Import Agent Modal */} - + + Model + + setModelFromUser("")} + > + + Automatic (provider default) + + + {availableModels.map((model) => { + const isSelected = model.id === selectedModel; + return ( + setModelFromUser(model.id)} + > + + {model.label} + + {model.description ? ( + + {model.description} + + ) : null} + + ); + })} + + + + {modeOptions.length > 0 ? ( + + Mode + + {modeOptions.map((mode) => { + const isSelected = mode.id === selectedMode; + return ( + setModeFromUser(mode.id)} + > + + {mode.label} + + {mode.description ? ( + + {mode.description} + + ) : null} + + ); + })} + + + ) : null} + + + {errorMessage ? ( + + {errorMessage} + + ) : null} + + + + + ); } @@ -148,12 +942,139 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, backgroundColor: theme.colors.background, }, - content: { + agentPanel: { flex: 1, }, - loadingContainer: { + contentContainer: { flex: 1, + }, + inputAreaWrapper: { + backgroundColor: theme.colors.background, + }, + configScrollContent: { + flexGrow: 1, + }, + configSection: { + paddingHorizontal: theme.spacing[4], + paddingTop: theme.spacing[3], + paddingBottom: theme.spacing[4], + gap: theme.spacing[2], + }, + configRow: { + flexDirection: "row", alignItems: "center", - justifyContent: "center", + justifyContent: "space-between", + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.card, + }, + configRowDisabled: { + opacity: theme.opacity[50], + }, + configTextGroup: { + flex: 1, + gap: theme.spacing[1], + marginRight: theme.spacing[2], + }, + configLabel: { + fontSize: theme.fontSize.xs, + textTransform: "uppercase", + letterSpacing: 0.6, + color: theme.colors.mutedForeground, + }, + configValue: { + fontSize: theme.fontSize.base, + color: theme.colors.foreground, + }, + configMeta: { + fontSize: theme.fontSize.xs, + color: theme.colors.mutedForeground, + }, + dropdownHelper: { + fontSize: theme.fontSize.sm, + color: theme.colors.mutedForeground, + }, + dropdownSheetList: { + marginTop: theme.spacing[3], + }, + dropdownSheetOption: { + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.background, + marginBottom: theme.spacing[2], + }, + dropdownSheetOptionSelected: { + borderColor: theme.colors.palette.blue[400], + backgroundColor: "rgba(59, 130, 246, 0.18)", + }, + dropdownSheetOptionLabel: { + color: theme.colors.foreground, + fontWeight: theme.fontWeight.semibold, + }, + dropdownSheetOptionDescription: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.sm, + marginTop: theme.spacing[1], + }, + errorContainer: { + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + marginHorizontal: theme.spacing[4], + marginBottom: theme.spacing[2], + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.destructive, + }, + errorText: { + color: theme.colors.destructiveForeground, + fontSize: theme.fontSize.sm, + }, + warningContainer: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.palette.yellow[400], + }, + warningText: { + color: "#000000", + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, + hostBadge: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + backgroundColor: theme.colors.muted, + borderRadius: theme.borderRadius.full, + }, + hostBadgeLabel: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.semibold, + }, + hostStatusDot: { + width: 6, + height: 6, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.mutedForeground, + }, + hostStatusDotOnline: { + backgroundColor: theme.colors.palette.green[500], + }, + agentSheetSection: { + marginBottom: theme.spacing[4], + }, + agentSheetSectionLabel: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.foreground, + marginBottom: theme.spacing[2], }, })); diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 2154b86e0..83abab60a 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -12,9 +12,10 @@ interface AgentListProps { isRefreshing?: boolean; onRefresh?: () => void; selectedAgentId?: string; + onAgentSelect?: () => void; } -export function AgentList({ agents, isRefreshing = false, onRefresh, selectedAgentId }: AgentListProps) { +export function AgentList({ agents, isRefreshing = false, onRefresh, selectedAgentId, onAgentSelect }: AgentListProps) { const { theme } = useUnistyles(); const pathname = usePathname(); const [actionAgent, setActionAgent] = useState(null); @@ -65,8 +66,9 @@ export function AgentList({ agents, isRefreshing = false, onRefresh, selectedAge agentId, }, }); + onAgentSelect?.(); }, - [isActionSheetVisible, pathname] + [isActionSheetVisible, pathname, onAgentSelect] ); const handleAgentLongPress = useCallback((agent: AggregatedAgent) => { diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx new file mode 100644 index 000000000..724153dc0 --- /dev/null +++ b/packages/app/src/components/headers/menu-header.tsx @@ -0,0 +1,57 @@ +import type { ReactNode } from "react"; +import { Pressable, Text } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Menu } from "lucide-react-native"; +import { ScreenHeader } from "./screen-header"; +import { useSidebarStore } from "@/stores/sidebar-store"; + +interface MenuHeaderProps { + title?: string; + rightContent?: ReactNode; +} + +export function MenuHeader({ title, rightContent }: MenuHeaderProps) { + const { theme } = useUnistyles(); + const { toggle } = useSidebarStore(); + + return ( + + + + + {title && ( + + {title} + + )} + + } + right={rightContent} + leftStyle={styles.left} + /> + ); +} + +const styles = StyleSheet.create((theme) => ({ + left: { + gap: theme.spacing[2], + }, + menuButton: { + padding: { + xs: theme.spacing[3], + md: theme.spacing[2], + }, + borderRadius: theme.borderRadius.lg, + }, + title: { + flex: 1, + fontSize: theme.fontSize.lg, + fontWeight: { + xs: theme.fontWeight.semibold, + md: "400", + }, + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/components/home-footer.tsx b/packages/app/src/components/home-footer.tsx index b72b80c9c..bddc6b5a7 100644 --- a/packages/app/src/components/home-footer.tsx +++ b/packages/app/src/components/home-footer.tsx @@ -163,7 +163,7 @@ export function HomeFooter() { { console.log("[HomeFooter] New Agent button pressed"); - router.push("/agent/new"); + router.push("/"); }} style={({ pressed }) => [ styles.footerButton, diff --git a/packages/app/src/components/sliding-sidebar.tsx b/packages/app/src/components/sliding-sidebar.tsx new file mode 100644 index 000000000..52e1812d7 --- /dev/null +++ b/packages/app/src/components/sliding-sidebar.tsx @@ -0,0 +1,354 @@ +import { useCallback, useEffect } from "react"; +import { View, Pressable, useWindowDimensions, Text } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, + interpolate, + Extrapolation, + runOnJS, + Easing, +} from "react-native-reanimated"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { Plus, Settings } from "lucide-react-native"; +import { router } from "expo-router"; +import { useSidebarStore } from "@/stores/sidebar-store"; +import { AgentList } from "./agent-list"; +import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; + +const DESKTOP_SIDEBAR_WIDTH = 320; +const ANIMATION_DURATION = 220; +const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1); + +interface SlidingSidebarProps { + selectedAgentId?: string; +} + +export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { + const { theme } = useUnistyles(); + const insets = useSafeAreaInsets(); + const { width: windowWidth } = useWindowDimensions(); + const { isOpen, open, close } = useSidebarStore(); + const { agents, isRevalidating, refreshAll } = useAggregatedAgents(); + + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + + // Mobile sidebar is full width + const sidebarWidth = isMobile ? windowWidth : DESKTOP_SIDEBAR_WIDTH; + + const translateX = useSharedValue(isOpen ? 0 : -sidebarWidth); + const backdropOpacity = useSharedValue(isOpen ? 1 : 0); + + // Track if we're currently in a gesture (to prevent useEffect from interfering) + const isGesturing = useSharedValue(false); + + useEffect(() => { + // Don't animate if we're in the middle of a gesture + if (isGesturing.value) return; + + const width = isMobile ? windowWidth : DESKTOP_SIDEBAR_WIDTH; + translateX.value = withTiming(isOpen ? 0 : -width, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + backdropOpacity.value = withTiming(isOpen ? 1 : 0, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + }, [isOpen, translateX, backdropOpacity, isMobile, windowWidth, isGesturing]); + + const handleClose = useCallback(() => { + close(); + }, [close]); + + const handleOpen = useCallback(() => { + open(); + }, [open]); + + // Mobile: close sidebar and navigate + const handleCreateAgentMobile = useCallback(() => { + close(); + router.push("/"); + }, [close]); + + // Desktop: just navigate, don't close + const handleCreateAgentDesktop = useCallback(() => { + router.push("/"); + }, []); + + // Mobile: close sidebar and navigate + const handleSettingsMobile = useCallback(() => { + close(); + router.push("/settings"); + }, [close]); + + // Desktop: just navigate, don't close + const handleSettingsDesktop = useCallback(() => { + router.push("/settings"); + }, []); + + // Mobile: close sidebar when agent is selected + const handleAgentSelectMobile = useCallback(() => { + close(); + }, [close]); + + // Close gesture (swipe left to close when sidebar is open) + const closeGesture = Gesture.Pan() + // Only activate after 15px horizontal movement + .activeOffsetX([-15, 15]) + // Fail if 10px vertical movement happens first (allow vertical scroll) + .failOffsetY([-10, 10]) + .onStart(() => { + isGesturing.value = true; + }) + .onUpdate((event) => { + if (!isMobile) return; + // Only allow swiping left (closing) + const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX)); + translateX.value = newTranslateX; + backdropOpacity.value = interpolate( + newTranslateX, + [-windowWidth, 0], + [0, 1], + Extrapolation.CLAMP + ); + }) + .onEnd((event) => { + isGesturing.value = false; + if (!isMobile) return; + const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500; + if (shouldClose) { + translateX.value = withTiming(-windowWidth, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + backdropOpacity.value = withTiming(0, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + runOnJS(handleClose)(); + } else { + translateX.value = withTiming(0, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + backdropOpacity.value = withTiming(1, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + } + }) + .onFinalize(() => { + isGesturing.value = false; + }); + + // Open gesture (swipe right from left edge to open when sidebar is closed) + const openGesture = Gesture.Pan() + .hitSlop({ right: windowWidth * 0.5 }) + // Only activate after 15px horizontal movement to the right + .activeOffsetX(15) + // Fail if 10px vertical movement happens first (allow vertical scroll) + .failOffsetY([-10, 10]) + .onStart(() => { + isGesturing.value = true; + }) + .onUpdate((event) => { + if (!isMobile) return; + // Start from closed position (-windowWidth) and move towards 0 + const newTranslateX = Math.min(0, -windowWidth + event.translationX); + translateX.value = newTranslateX; + backdropOpacity.value = interpolate( + newTranslateX, + [-windowWidth, 0], + [0, 1], + Extrapolation.CLAMP + ); + }) + .onEnd((event) => { + isGesturing.value = false; + if (!isMobile) return; + // Open if dragged more than 1/3 of sidebar or fast swipe + const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500; + if (shouldOpen) { + translateX.value = withTiming(0, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + backdropOpacity.value = withTiming(1, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + runOnJS(handleOpen)(); + } else { + translateX.value = withTiming(-windowWidth, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + backdropOpacity.value = withTiming(0, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + } + }) + .onFinalize(() => { + isGesturing.value = false; + }); + + const swipeGesture = Gesture.Simultaneous( + isOpen ? closeGesture : openGesture, + Gesture.Native() + ); + + const sidebarAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }], + })); + + const backdropAnimatedStyle = useAnimatedStyle(() => ({ + opacity: backdropOpacity.value, + pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", + })); + + // Render mobile sidebar with edge swipe + if (isMobile) { + return ( + + {/* Backdrop */} + + + + + {/* Sidebar */} + + + + + + + + + + New Agent + + + + + + + + ); + } + + // Desktop: no edge swipe, just show/hide based on isOpen + if (!isOpen) { + return null; + } + + return ( + + + + + + + + + New Agent + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0, 0, 0, 0.5)", + }, + backdropPressable: { + flex: 1, + }, + mobileSidebar: { + position: "absolute", + top: 0, + left: 0, + bottom: 0, + backgroundColor: theme.colors.background, + }, + desktopSidebar: { + borderRightWidth: 1, + borderRightColor: theme.colors.border, + backgroundColor: theme.colors.background, + }, + sidebarHeader: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: theme.spacing[4], + paddingTop: theme.spacing[4], + paddingBottom: theme.spacing[2], + gap: theme.spacing[2], + }, + headerIconButton: { + padding: theme.spacing[2], + borderRadius: theme.borderRadius.lg, + }, + newAgentButton: { + flex: 1, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[2], + paddingVertical: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + }, + newAgentButtonText: { + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.normal, + }, +})); diff --git a/packages/app/src/stores/sidebar-store.ts b/packages/app/src/stores/sidebar-store.ts new file mode 100644 index 000000000..f693f04c2 --- /dev/null +++ b/packages/app/src/stores/sidebar-store.ts @@ -0,0 +1,30 @@ +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; +import AsyncStorage from "@react-native-async-storage/async-storage"; + +interface SidebarState { + isOpen: boolean; + toggle: () => void; + open: () => void; + close: () => void; +} + +export const useSidebarStore = create()( + persist( + (set) => ({ + isOpen: false, + toggle: () => set((state) => ({ isOpen: !state.isOpen })), + open: () => set({ isOpen: true }), + close: () => set({ isOpen: false }), + }), + { + name: "sidebar-state", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ isOpen: state.isOpen }), + } + ) +); + +export function useSidebar() { + return useSidebarStore(); +}