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();
+}