Files
paseo/packages/app/src/components/create-agent-modal.tsx
T
Mohamed BoudraandClaude Opus 4.5 e8890a2fc1 wip: uncommitted changes
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 16:06:40 +07:00

2492 lines
75 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
useState,
useRef,
useEffect,
useMemo,
useCallback,
} from "react";
import type { ReactElement, ReactNode } from "react";
import {
View,
Text,
Pressable,
ScrollView,
FlatList,
ActivityIndicator,
InteractionManager,
TextInput,
Modal,
useWindowDimensions,
type LayoutChangeEvent,
type ListRenderItem,
Platform,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
Easing,
runOnJS,
} from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Monitor, X } from "lucide-react-native";
import { theme as defaultTheme } from "@/styles/theme";
import { useRecentPaths } from "@/hooks/use-recent-paths";
import { useRouter } from "expo-router";
import { generateMessageId } from "@/types/stream";
import { MessageInput } from "./message-input";
import { useDaemonConnections, type ConnectionStatus } from "@/contexts/daemon-connections-context";
import type {
AgentProvider,
AgentSessionConfig,
AgentPersistenceHandle,
AgentTimelineItem,
} from "@server/server/agent/agent-sdk-types";
import { useDaemonRequest } from "@/hooks/use-daemon-request";
import type { WSInboundMessage, SessionOutboundMessage } from "@server/server/messages";
import { formatConnectionStatus } from "@/utils/daemons";
import { trackAnalyticsEvent } from "@/utils/analytics";
import type { SessionContextValue } from "@/contexts/session-context";
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
AssistantDropdown,
DropdownField,
DropdownSheet,
ModelDropdown,
PermissionsDropdown,
WorkingDirectoryDropdown,
} from "@/components/agent-form/agent-form-dropdowns";
import {
useAgentFormState,
type CreateAgentInitialValues,
} from "@/hooks/use-agent-form-state";
interface AgentFlowModalProps {
isVisible: boolean;
onClose: () => void;
flow: "create" | "import";
initialValues?: CreateAgentInitialValues;
serverId?: string | null;
onAfterClose?: () => void;
}
interface ModalWrapperProps {
isVisible: boolean;
onClose: () => void;
initialValues?: CreateAgentInitialValues;
serverId?: string | null;
}
type CreateAgentSessionSlice = {
serverId: string;
ws: UseWebSocketReturn | null;
createAgent: (options: {
config: any;
initialPrompt: string;
git?: any;
worktreeName?: string;
requestId?: string;
}) => void;
resumeAgent: (options: { handle: any; overrides?: any; requestId?: string }) => void;
sendAgentAudio: (
agentId: string | undefined,
audioBlob: Blob,
requestId?: string,
options?: { mode?: "transcribe_only" | "auto_run" }
) => Promise<void>;
agents: Map<string, Agent>;
};
const BACKDROP_OPACITY = 0.55;
const IMPORT_PAGE_SIZE = 20;
const IS_WEB = Platform.OS === "web";
type ImportCandidate = {
provider: AgentProvider;
sessionId: string;
cwd: string;
title: string;
lastActivityAt: Date;
persistence: AgentPersistenceHandle;
timeline: AgentTimelineItem[];
};
type ProviderFilter = "all" | AgentProvider;
type DropdownKey =
| "assistant"
| "permissions"
| "model"
| "workingDir"
| "baseBranch"
| "host";
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" }
>;
function formatRelativeTime(date: Date): string {
const now = Date.now();
const diffMs = now - date.getTime();
if (!Number.isFinite(diffMs)) {
return "unknown";
}
const minutes = Math.max(0, Math.floor(diffMs / 60000));
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return `${hours}h ago`;
}
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function getImportPreview(candidate: ImportCandidate): string {
for (const item of candidate.timeline) {
if (item.type === "user_message") {
const text = item.text.trim();
if (text.length > 0) {
return text;
}
}
}
return candidate.title || candidate.cwd;
}
function AgentFlowModal({
isVisible,
onClose,
flow,
initialValues,
serverId,
onAfterClose,
}: AgentFlowModalProps) {
const insets = useSafeAreaInsets();
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
const slideOffset = useSharedValue(screenHeight);
const backdropOpacity = useSharedValue(0);
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const isCompactLayout = screenWidth < 720;
const shouldAutoFocusPrompt = IS_WEB;
const isImportFlow = flow === "import";
const isCreateFlow = !isImportFlow;
const { addRecentPath } = useRecentPaths();
const { connectionStates } = useDaemonConnections();
const daemonEntries = useMemo(() => Array.from(connectionStates.values()), [connectionStates]);
const initialServerId = useMemo(() => {
if (!serverId) {
return null;
}
const exists = daemonEntries.some((entry) => entry.daemon.id === serverId);
return exists ? serverId : null;
}, [serverId, daemonEntries]);
const {
selectedServerId,
setSelectedServerId,
setSelectedServerIdFromUser,
selectedProvider,
setProviderFromUser,
selectedMode,
setModeFromUser,
selectedModel,
setModelFromUser,
workingDir,
setWorkingDirFromUser,
providerDefinitions,
providerDefinitionMap,
modeOptions,
availableModels,
isModelLoading,
modelError,
refreshProviderModels,
queueProviderModelFetch,
clearQueuedProviderModelRequest,
workingDirIsEmpty,
} = useAgentFormState({
initialServerId,
initialValues,
isVisible,
isCreateFlow,
});
const sessionState = useSessionStore((state) =>
selectedServerId ? state.sessions[selectedServerId] : undefined
);
// Extract only what we need for CreateAgentSessionSlice
const session = useMemo<CreateAgentSessionSlice | null>(() => {
if (!selectedServerId || !sessionState || !sessionState.methods) {
return null;
}
const slice: CreateAgentSessionSlice = {
serverId: selectedServerId,
ws: sessionState.ws,
createAgent: sessionState.methods.createAgent,
resumeAgent: sessionState.methods.resumeAgent,
sendAgentAudio: sessionState.methods.sendAgentAudio,
agents: sessionState.agents,
};
return slice;
}, [selectedServerId, sessionState]);
useEffect(() => {
if (selectedServerId) {
const exists = daemonEntries.some((entry) => entry.daemon.id === selectedServerId);
if (!exists) {
setSelectedServerId(initialServerId);
}
return;
}
if (initialServerId && selectedServerId !== initialServerId) {
setSelectedServerId(initialServerId);
}
}, [daemonEntries, selectedServerId, initialServerId]);
useEffect(() => {
if (!isVisible) {
return;
}
if (initialServerId && selectedServerId !== initialServerId) {
setSelectedServerId(initialServerId);
}
}, [isVisible, initialServerId, selectedServerId]);
useEffect(() => {
if (!isVisible || initialServerId || selectedServerId) {
return;
}
const firstReady = daemonEntries.find(
({ status, sessionReady }) => status === "online" && sessionReady
);
if (firstReady) {
setSelectedServerId(firstReady.daemon.id);
}
}, [daemonEntries, initialServerId, isVisible, selectedServerId]);
const inertWebSocket = useMemo<UseWebSocketReturn>(
() => ({
isConnected: false,
isConnecting: false,
conversationId: null,
lastError: null,
send: () => {},
on: () => () => {},
sendPing: () => {},
sendUserMessage: () => {},
clearAgentAttention: () => {},
subscribeConnectionStatus: () => () => {},
getConnectionState: () => ({ isConnected: false, isConnecting: false }),
}),
[]
);
const ws = session?.ws ?? null;
const effectiveWs: UseWebSocketReturn = ws ?? inertWebSocket;
const createAgent = session?.createAgent;
const resumeAgent = session?.resumeAgent;
const sessionSendAgentAudio = session?.sendAgentAudio;
const noopSendAgentAudio = useCallback<SessionContextValue["sendAgentAudio"]>(async () => {}, []);
const sendAgentAudio = sessionSendAgentAudio ?? noopSendAgentAudio;
const hasSendAgentAudio = Boolean(sessionSendAgentAudio);
const agents = session?.agents;
const agentWorkingDirSuggestions = useMemo(() => {
if (!selectedServerId || !agents) {
return [];
}
const uniquePaths = new Set<string>();
agents.forEach((agent) => {
if (agent.cwd) {
uniquePaths.add(agent.cwd);
}
});
return Array.from(uniquePaths);
}, [agents, selectedServerId]);
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 isWsConnected = effectiveWs.getConnectionState
? effectiveWs.getConnectionState().isConnected
: effectiveWs.isConnected;
const router = useRouter();
const sessionServerId = session?.serverId ?? null;
const selectedDaemonId = selectedServerId ?? sessionServerId;
const selectedDaemonConnection = selectedDaemonId
? connectionStates.get(selectedDaemonId)
: null;
const selectedDaemonStatus: ConnectionStatus =
selectedDaemonConnection?.status ??
(ws?.isConnected
? "online"
: ws?.isConnecting
? "connecting"
: ws?.lastError
? "error"
: "offline");
const selectedDaemonLabel =
selectedDaemonConnection?.daemon.label ??
selectedDaemonConnection?.daemon.wsUrl ??
selectedDaemonId ??
"Selected host";
const selectedDaemonStatusLabel = formatConnectionStatus(selectedDaemonStatus);
const hasSelectedDaemon = Boolean(selectedServerId);
const hostBadgeLabel = hasSelectedDaemon ? selectedDaemonLabel : "Select host";
const selectedDaemonIsOffline = selectedDaemonStatus !== "online";
const selectedDaemonLastError = selectedDaemonConnection?.lastError?.trim();
const daemonAvailabilityError = !hasSelectedDaemon
? "Select a host before creating or importing agents."
: selectedDaemonIsOffline
? `${selectedDaemonLabel} is ${selectedDaemonStatusLabel}. We'll reconnect automatically and enable actions once it's online.${
selectedDaemonLastError ? ` ${selectedDaemonLastError}` : ""
}`
: null;
const selectedDaemonSessionReady =
selectedDaemonConnection?.status === "online" &&
selectedDaemonConnection.sessionReady;
const isTargetDaemonReady = Boolean(
hasSelectedDaemon && selectedDaemonSessionReady && !selectedDaemonIsOffline
);
const [isMounted, setIsMounted] = useState(isVisible);
const [initialPrompt, setInitialPrompt] = useState("");
const [baseBranch, setBaseBranch] = useState("");
const [createNewBranch, setCreateNewBranch] = useState(false);
const [branchName, setBranchName] = useState("");
const [createWorktree, setCreateWorktree] = useState(false);
const [worktreeSlug, setWorktreeSlug] = useState("");
const [branchNameEdited, setBranchNameEdited] = useState(false);
const [worktreeSlugEdited, setWorktreeSlugEdited] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [importProviderFilter, setImportProviderFilter] =
useState<ProviderFilter>("all");
const [importSearchQuery, setImportSearchQuery] = useState("");
const [importCandidates, setImportCandidates] = useState<ImportCandidate[]>(
[]
);
const [isImportLoading, setIsImportLoading] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [openDropdown, setOpenDropdown] = useState<DropdownKey | null>(null);
const pendingRequestIdRef = useRef<string | null>(null);
const shouldSyncBaseBranchRef = useRef(true);
const hasPendingCreateOrResume = pendingRequestIdRef.current !== null;
const shouldListenForStatus = isVisible || hasPendingCreateOrResume;
const idleProviderPrefetchHandleRef = useRef<ReturnType<
typeof InteractionManager.runAfterInteractions
> | null>(null);
const pendingNavigationAgentIdRef = useRef<string | null>(null);
const pendingNavigationServerIdRef = useRef<string | null>(null);
const openDropdownSheet = useCallback((key: DropdownKey) => {
setOpenDropdown(key);
}, []);
const closeDropdown = useCallback(() => {
setOpenDropdown(null);
}, []);
const handleUserWorkingDirChange = useCallback(
(value: string) => {
setWorkingDirFromUser(value);
setErrorMessage("");
},
[setWorkingDirFromUser]
);
const handleBaseBranchChange = useCallback(
(value: string) => {
shouldSyncBaseBranchRef.current = false;
setBaseBranch(value);
setErrorMessage("");
},
[setErrorMessage]
);
const providerFilterOptions = useMemo(
() => [
{ id: "all" as ProviderFilter, label: "All" },
...providerDefinitions.map((definition) => ({
id: definition.id as ProviderFilter,
label: definition.label,
})),
],
[]
);
const getProviderLabel = useCallback(
(provider: AgentProvider) =>
providerDefinitionMap.get(provider)?.label ?? provider,
[]
);
const activeSessionIds = useMemo(() => {
const ids = new Set<string>();
if (!agents) {
return ids;
}
// Use persistence.sessionId for filtering - this is the canonical reference
// to the provider's session file (Claude resume token, Codex thread ID, etc.)
agents.forEach((agent) => {
const persistedSessionId = agent.persistence?.sessionId;
if (persistedSessionId) {
ids.add(persistedSessionId);
}
});
return ids;
}, [agents]);
const filteredImportCandidates = useMemo(() => {
const providerFilter = importProviderFilter;
const query = importSearchQuery.trim().toLowerCase();
return importCandidates
.filter((candidate) => !activeSessionIds.has(candidate.sessionId))
.filter(
(candidate) =>
providerFilter === "all" || candidate.provider === providerFilter
)
.filter((candidate) => {
if (query.length === 0) {
return true;
}
const titleText = candidate.title.toLowerCase();
const cwdText = candidate.cwd.toLowerCase();
return titleText.includes(query) || cwdText.includes(query);
})
.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
}, [
activeSessionIds,
importCandidates,
importProviderFilter,
importSearchQuery,
]);
const logOfflineDaemonAction = useCallback(
(action: "create" | "resume" | "dictation" | "import_list", reason?: string | null) => {
trackAnalyticsEvent({
type: "offline_daemon_action_attempt",
action,
daemonId: selectedDaemonId,
status: selectedDaemonStatus ?? null,
reason: reason ?? daemonAvailabilityError,
});
},
[
daemonAvailabilityError,
selectedDaemonId,
selectedDaemonStatus,
]
);
const resetFormState = useCallback(() => {
setInitialPrompt("");
setBaseBranch("");
setCreateNewBranch(false);
setBranchName("");
setCreateWorktree(false);
setWorktreeSlug("");
setBranchNameEdited(false);
setWorktreeSlugEdited(false);
setErrorMessage("");
setIsLoading(false);
resetRepoInfo();
setOpenDropdown(null);
pendingRequestIdRef.current = null;
pendingNavigationServerIdRef.current = null;
shouldSyncBaseBranchRef.current = true;
cancelRepoInfo();
}, [cancelRepoInfo, resetRepoInfo]);
const navigateToAgentIfNeeded = useCallback(() => {
const agentId = pendingNavigationAgentIdRef.current;
const targetServerId = pendingNavigationServerIdRef.current ?? selectedDaemonId;
if (!agentId || !targetServerId) {
return;
}
pendingNavigationAgentIdRef.current = null;
pendingNavigationServerIdRef.current = null;
InteractionManager.runAfterInteractions(() => {
router.push({
pathname: "/agent/[serverId]/[agentId]",
params: {
serverId: targetServerId,
agentId,
},
});
});
}, [router, selectedDaemonId]);
const handleSelectServer = useCallback(
(serverId: string) => {
setSelectedServerIdFromUser(serverId);
},
[setSelectedServerIdFromUser]
);
const handleCloseAnimationComplete = useCallback(() => {
console.log("[CreateAgentModal] close animation complete – resetting form");
resetFormState();
setIsMounted(false);
navigateToAgentIfNeeded();
onAfterClose?.();
}, [navigateToAgentIfNeeded, onAfterClose, resetFormState]);
const requestImportCandidates = useCallback(
(provider?: AgentProvider) => {
if (!isTargetDaemonReady || !ws || !ws.isConnected) {
setIsImportLoading(false);
logOfflineDaemonAction("import_list");
setImportError(
daemonAvailabilityError ??
"Import candidates load automatically once the selected host is back online."
);
return;
}
setIsImportLoading(true);
setImportError(null);
const msg: WSInboundMessage = {
type: "session",
message: {
type: "list_persisted_agents_request",
...(provider ? { provider } : {}),
limit: IMPORT_PAGE_SIZE,
},
};
try {
ws.send(msg);
} catch (error) {
console.error(
"[CreateAgentModal] Failed to request persisted agents:",
error
);
setIsImportLoading(false);
setImportError("Unable to load agents to import. Please try again.");
}
},
[daemonAvailabilityError, isTargetDaemonReady, logOfflineDaemonAction, ws]
);
useEffect(() => {
if (!isVisible) {
console.log(
"[CreateAgentModal] visibility effect skipped (isVisible is false)",
{
isMounted,
}
);
return;
}
console.log("[CreateAgentModal] visibility effect triggered", {
wasMounted: isMounted,
screenHeight,
});
setIsMounted(true);
slideOffset.value = screenHeight;
backdropOpacity.value = 0;
backdropOpacity.value = withTiming(BACKDROP_OPACITY, {
duration: 200,
easing: Easing.out(Easing.cubic),
});
slideOffset.value = withTiming(0, {
duration: 240,
easing: Easing.out(Easing.cubic),
});
}, [isVisible, slideOffset, backdropOpacity, screenHeight]);
useEffect(() => {
if (!isMounted || isVisible) {
console.log("[CreateAgentModal] close animation skipped", {
isMounted,
isVisible,
});
return;
}
console.log("[CreateAgentModal] close animation starting", {
screenHeight,
});
backdropOpacity.value = withTiming(0, {
duration: 160,
easing: Easing.out(Easing.cubic),
});
slideOffset.value = withTiming(
screenHeight,
{
duration: 220,
easing: Easing.in(Easing.cubic),
},
(finished) => {
if (finished) {
console.log("[CreateAgentModal] slide animation finished");
runOnJS(handleCloseAnimationComplete)();
}
}
);
}, [
isMounted,
isVisible,
slideOffset,
backdropOpacity,
screenHeight,
handleCloseAnimationComplete,
]);
useEffect(() => {
return () => {
idleProviderPrefetchHandleRef.current?.cancel?.();
};
}, []);
const footerAnimatedStyle = useAnimatedStyle(() => {
"worklet";
const absoluteHeight = Math.abs(keyboardHeight.value);
const shift = Math.max(0, absoluteHeight - insets.bottom);
return {
transform: [{ translateY: -shift }],
};
}, [insets.bottom, keyboardHeight]);
const containerAnimatedStyle = useAnimatedStyle(() => {
"worklet";
return {
transform: [{ translateY: slideOffset.value }],
};
}, [slideOffset]);
const backdropAnimatedStyle = useAnimatedStyle(() => {
"worklet";
return {
opacity: backdropOpacity.value,
};
}, [backdropOpacity]);
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 handleClose = useCallback(() => {
onClose();
}, [onClose]);
useEffect(() => {
const slug = slugifyWorktreeName(initialPrompt);
if (!branchNameEdited) {
setBranchName(slug);
}
if (!worktreeSlugEdited) {
setWorktreeSlug(slug);
}
}, [
initialPrompt,
branchNameEdited,
worktreeSlugEdited,
slugifyWorktreeName,
]);
useEffect(() => {
if (!isCreateFlow || !isVisible) {
return;
}
shouldSyncBaseBranchRef.current = true;
}, [isCreateFlow, isVisible, workingDir]);
useEffect(() => {
idleProviderPrefetchHandleRef.current?.cancel?.();
idleProviderPrefetchHandleRef.current = InteractionManager.runAfterInteractions(() => {
daemonEntries.forEach(({ daemon, status }) => {
const serverId = daemon.id;
const isSelected = serverId === selectedServerId;
const isReady = status === "online" || status === "idle";
if (isSelected || !isReady) {
clearQueuedProviderModelRequest(serverId);
return;
}
queueProviderModelFetch(serverId, { delayMs: 320 });
});
});
return () => {
idleProviderPrefetchHandleRef.current?.cancel?.();
};
}, [
clearQueuedProviderModelRequest,
daemonEntries,
queueProviderModelFetch,
selectedProvider,
selectedServerId,
]);
const trimmedWorkingDir = workingDir.trim();
const shouldInspectRepo = isCreateFlow && isVisible && trimmedWorkingDir.length > 0;
const repoAvailabilityError = shouldInspectRepo && (!isTargetDaemonReady || !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 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;
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 (
createNewBranch ||
createWorktree ||
baseBranch.trim().length > 0 ||
branchName.trim().length > 0 ||
worktreeSlug.trim().length > 0
) {
setCreateNewBranch(false);
setCreateWorktree(false);
setBaseBranch("");
setBranchName("");
setWorktreeSlug("");
setBranchNameEdited(false);
setWorktreeSlugEdited(false);
shouldSyncBaseBranchRef.current = true;
}
}, [
baseBranch,
branchName,
createNewBranch,
createWorktree,
isNonGitDirectory,
worktreeSlug,
]);
const handleCreate = useCallback(async () => {
const trimmedPath = workingDir.trim();
if (!trimmedPath) {
setErrorMessage("Working directory is required");
return;
}
const trimmedPrompt = initialPrompt.trim();
if (!trimmedPrompt) {
setErrorMessage("Initial prompt is required");
return;
}
if (isLoading) {
return;
}
if (gitBlockingError) {
setErrorMessage(gitBlockingError);
return;
}
if (!createAgent || !isTargetDaemonReady) {
logOfflineDaemonAction("create");
setErrorMessage(
daemonAvailabilityError ??
"Creating agents is temporarily unavailable while the selected host is offline. Paseo reconnects automatically—try again once it's online."
);
return;
}
const trimmedBaseBranch = baseBranch.trim();
try {
await addRecentPath(trimmedPath);
} catch (error) {
console.error("[CreateAgentModal] Failed to save recent path:", error);
}
const requestId = generateMessageId();
pendingRequestIdRef.current = requestId;
pendingNavigationServerIdRef.current = selectedDaemonId ?? null;
setIsLoading(true);
setErrorMessage("");
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 currentBranch = repoInfo?.currentBranch ?? "";
const shouldIncludeBase =
createNewBranch ||
createWorktree ||
(trimmedBaseBranch.length > 0 && trimmedBaseBranch !== currentBranch);
const gitOptions = shouldIncludeBase && !isNonGitDirectory
? {
...(trimmedBaseBranch ? { baseBranch: trimmedBaseBranch } : {}),
...(createNewBranch
? { createNewBranch: true, newBranchName: branchName.trim() }
: {}),
...(createWorktree
? {
createWorktree: true,
worktreeSlug: (worktreeSlug || branchName).trim(),
}
: {}),
}
: undefined;
try {
createAgent({
config,
initialPrompt: trimmedPrompt,
git: gitOptions,
requestId,
});
} catch (error) {
console.error("[CreateAgentModal] Failed to create agent:", error);
setErrorMessage("Failed to create agent. Please try again.");
setIsLoading(false);
pendingRequestIdRef.current = null;
pendingNavigationServerIdRef.current = null;
}
}, [
workingDir,
initialPrompt,
baseBranch,
createNewBranch,
branchName,
createWorktree,
worktreeSlug,
repoInfo,
selectedMode,
modeOptions,
logOfflineDaemonAction,
selectedProvider,
isLoading,
validateWorktreeName,
addRecentPath,
createAgent,
daemonAvailabilityError,
isTargetDaemonReady,
selectedDaemonId,
]);
const handleImportCandidatePress = useCallback(
(candidate: ImportCandidate) => {
if (isLoading) {
return;
}
if (!resumeAgent || !isTargetDaemonReady) {
logOfflineDaemonAction("resume");
setImportError(
daemonAvailabilityError ??
"Importing agents will resume automatically once the selected host is online."
);
return;
}
setErrorMessage("");
const requestId = generateMessageId();
pendingRequestIdRef.current = requestId;
pendingNavigationServerIdRef.current = selectedDaemonId ?? null;
setIsLoading(true);
resumeAgent({
handle: candidate.persistence,
requestId,
});
},
[
daemonAvailabilityError,
isLoading,
logOfflineDaemonAction,
isTargetDaemonReady,
resumeAgent,
selectedDaemonId,
setImportError,
]
);
const renderImportItem = useCallback<ListRenderItem<ImportCandidate>>(
({ item }) => (
<Pressable
onPress={() => handleImportCandidatePress(item)}
disabled={isLoading || !isTargetDaemonReady}
style={styles.resumeItem}
>
<View style={styles.resumeItemHeader}>
<Text style={styles.resumeItemTitle} numberOfLines={1}>
{getImportPreview(item)}
</Text>
<Text style={styles.resumeItemTimestamp}>
{formatRelativeTime(item.lastActivityAt)}
</Text>
</View>
<Text style={styles.resumeItemPath} numberOfLines={1}>
{item.cwd}
</Text>
<View style={styles.resumeItemMetaRow}>
<View style={styles.resumeProviderBadge}>
<Text style={styles.resumeProviderBadgeText}>
{getProviderLabel(item.provider)}
</Text>
</View>
<Text style={styles.resumeItemHint}>Tap to import</Text>
</View>
</Pressable>
),
[getProviderLabel, handleImportCandidatePress, isLoading, isTargetDaemonReady]
);
useEffect(() => {
if (!isImportFlow || !isVisible || !ws) {
return;
}
const unsubscribe = ws.on("list_persisted_agents_response", (message) => {
if (message.type !== "list_persisted_agents_response") {
return;
}
const mapped = message.payload.items.map((item) => ({
provider: item.provider,
sessionId: item.sessionId,
cwd: item.cwd,
title: item.title ?? `Session ${item.sessionId.slice(0, 8)}`,
lastActivityAt: new Date(item.lastActivityAt),
persistence: item.persistence,
timeline: item.timeline ?? [],
})) as ImportCandidate[];
setImportCandidates(mapped);
setIsImportLoading(false);
});
return () => {
unsubscribe();
};
}, [isImportFlow, isVisible, ws]);
useEffect(() => {
if (!shouldListenForStatus || !ws) {
return;
}
const unsubscribe = ws.on("status", (message) => {
if (message.type !== "status") {
return;
}
const payload = message.payload as {
status: string;
agentId?: string;
requestId?: string;
error?: string;
};
if (payload.status === "agent_create_failed") {
const expectedRequestId = pendingRequestIdRef.current;
if (!expectedRequestId || payload.requestId !== expectedRequestId) {
return;
}
pendingRequestIdRef.current = null;
pendingNavigationServerIdRef.current = null;
setIsLoading(false);
setErrorMessage(
payload.error ??
"Failed to create agent. Resolve git issues or try again."
);
return;
}
if (
(payload.status !== "agent_created" &&
payload.status !== "agent_resumed") ||
!payload.agentId
) {
return;
}
const expectedRequestId = pendingRequestIdRef.current;
if (!expectedRequestId || payload.requestId !== expectedRequestId) {
return;
}
console.log("[CreateAgentModal] Agent created:", payload.agentId);
pendingRequestIdRef.current = null;
setIsLoading(false);
pendingNavigationAgentIdRef.current = payload.agentId;
handleClose();
});
return () => {
unsubscribe();
};
}, [handleClose, shouldListenForStatus, ws]);
useEffect(() => {
if (!isVisible || !isImportFlow) {
return;
}
const provider =
importProviderFilter === "all" ? undefined : importProviderFilter;
requestImportCandidates(provider);
}, [importProviderFilter, isImportFlow, isVisible, requestImportCandidates]);
const refreshImportList = useCallback(() => {
const provider =
importProviderFilter === "all" ? undefined : importProviderFilter;
requestImportCandidates(provider);
}, [requestImportCandidates, importProviderFilter]);
const shouldRender = isVisible || isMounted;
const modalTitle = isImportFlow ? "Import Agent" : "Create New Agent";
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,
]);
const promptIsEmpty = !initialPrompt.trim();
const createDisabled =
workingDirIsEmpty ||
promptIsEmpty ||
Boolean(gitBlockingError) ||
isLoading ||
!isTargetDaemonReady;
const headerPaddingTop = useMemo(
() => insets.top + defaultTheme.spacing[4],
[insets.top]
);
const horizontalPaddingLeft = useMemo(
() => defaultTheme.spacing[6] + insets.left,
[insets.left]
);
const horizontalPaddingRight = useMemo(
() => defaultTheme.spacing[6] + insets.right,
[insets.right]
);
const handleSheetLayout = useCallback((event: LayoutChangeEvent) => {
const { height, y } = event.nativeEvent.layout;
console.log("[CreateAgentModal] sheet layout", { height, y });
}, []);
if (!shouldRender) {
// console.log("[CreateAgentModal] render skipped", {
// isVisible,
// isMounted,
// });
return null;
}
// console.log("[CreateAgentModal] rendering modal", {
// isVisible,
// isMounted,
// });
return (
<Modal
transparent
statusBarTranslucent
animationType="none"
visible={shouldRender}
onRequestClose={handleClose}
>
<View style={styles.overlay}>
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
<Pressable style={styles.backdropPressable} onPress={handleClose} />
</Animated.View>
<Animated.View
style={[styles.sheet, containerAnimatedStyle]}
onLayout={handleSheetLayout}
>
<View style={styles.content}>
<ModalHeader
paddingTop={headerPaddingTop}
paddingLeft={horizontalPaddingLeft}
paddingRight={horizontalPaddingRight}
onClose={handleClose}
title={modalTitle}
rightContent={
isImportFlow ? (
<Pressable
style={styles.hostBadge}
onPress={() => openDropdownSheet("host")}
>
<Monitor size={14} color={defaultTheme.colors.mutedForeground} />
<Text style={styles.hostBadgeLabel}>{hostBadgeLabel}</Text>
<View
style={[
styles.hostStatusDot,
selectedDaemonStatus === "online" && styles.hostStatusDotOnline,
]}
/>
</Pressable>
) : undefined
}
/>
{isCreateFlow ? (
<>
<ScrollView
style={styles.scroll}
contentContainerStyle={[
styles.scrollContent,
{
paddingBottom: insets.bottom + defaultTheme.spacing[16],
paddingLeft: horizontalPaddingLeft,
paddingRight: horizontalPaddingRight,
},
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View style={styles.daemonSelectorSection}>
<Text style={styles.daemonSelectorLabel}>Target Host</Text>
{daemonEntries.length === 0 ? (
<Text style={styles.daemonChipText}>No hosts available</Text>
) : (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.daemonSelectorChips}
>
{daemonEntries.map(({ daemon, status }) => {
const isSelected = daemon.id === selectedServerId;
const label = daemon.label || daemon.wsUrl;
return (
<Pressable
key={daemon.id}
onPress={() => handleSelectServer(daemon.id)}
style={[styles.daemonChip, isSelected && styles.daemonChipSelected]}
>
<Text
style={[
styles.daemonChipText,
isSelected && styles.daemonChipTextSelected,
]}
numberOfLines={1}
>
{label}
</Text>
<Text
style={[
styles.daemonChipStatus,
isSelected && styles.daemonChipTextSelected,
]}
>
{formatConnectionStatus(status)}
</Text>
</Pressable>
);
})}
</ScrollView>
)}
{daemonAvailabilityError ? (
<Text style={styles.daemonAvailabilityText}>
{daemonAvailabilityError}
</Text>
) : null}
</View>
<View style={styles.formSection}>
<Text style={styles.label}>Initial Prompt</Text>
<MessageInput
value={initialPrompt}
onChangeText={(text) => {
setInitialPrompt(text);
setErrorMessage("");
}}
onSubmit={() => {
void handleCreate();
}}
ws={effectiveWs}
sendAgentAudio={sendAgentAudio}
placeholder="Describe what you want the agent to do"
autoFocus={shouldAutoFocusPrompt}
disabled={isLoading || !isTargetDaemonReady}
isSubmitDisabled={createDisabled}
/>
</View>
<View
style={[
styles.selectorRow,
isCompactLayout && styles.selectorRowStacked,
]}
>
<AssistantDropdown
providerDefinitions={providerDefinitions}
disabled={isLoading}
selectedProvider={selectedProvider}
isOpen={openDropdown === "assistant"}
onOpen={() => openDropdownSheet("assistant")}
onClose={closeDropdown}
onSelect={setProviderFromUser}
/>
<PermissionsDropdown
disabled={isLoading}
modeOptions={modeOptions}
selectedMode={selectedMode}
isOpen={openDropdown === "permissions"}
onOpen={() => openDropdownSheet("permissions")}
onClose={closeDropdown}
onSelect={setModeFromUser}
/>
<ModelDropdown
models={availableModels}
selectedModel={selectedModel}
isLoading={isModelLoading}
error={modelError}
isOpen={openDropdown === "model"}
onOpen={() => {
refreshProviderModels();
openDropdownSheet("model");
}}
onClose={closeDropdown}
onSelect={(modelId) => {
setModelFromUser(modelId);
setErrorMessage("");
}}
onClear={() => {
setModelFromUser("");
setErrorMessage("");
}}
onRefresh={refreshProviderModels}
/>
</View>
<WorkingDirectoryDropdown
workingDir={workingDir}
errorMessage={errorMessage}
isOpen={openDropdown === "workingDir"}
onOpen={() => openDropdownSheet("workingDir")}
onClose={closeDropdown}
disabled={isLoading}
suggestedPaths={agentWorkingDirSuggestions}
onSelectPath={handleUserWorkingDirChange}
/>
<GitOptionsSection
baseBranch={baseBranch}
onBaseBranchChange={handleBaseBranchChange}
branches={repoInfo?.branches ?? []}
status={repoInfoStatus}
repoError={repoInfoError}
helperText={gitHelperText}
isGitDisabled={Boolean(gitHelperText)}
warning={
!createWorktree && repoInfo?.isDirty
? "Working directory has uncommitted changes"
: null
}
createNewBranch={createNewBranch}
onToggleCreateNewBranch={(next) => {
setCreateNewBranch(next);
if (next) {
if (!branchNameEdited) {
const slug = slugifyWorktreeName(
initialPrompt || baseBranch || ""
);
setBranchName(slug);
}
} else {
setBranchName("");
setBranchNameEdited(false);
}
}}
branchName={branchName}
onBranchNameChange={(value) => {
setBranchName(slugifyWorktreeName(value));
setBranchNameEdited(true);
}}
createWorktree={createWorktree}
onToggleCreateWorktree={(next) => {
setCreateWorktree(next);
if (next) {
if (!worktreeSlugEdited) {
const slug = slugifyWorktreeName(
initialPrompt || branchName || baseBranch || ""
);
setWorktreeSlug(slug);
}
} else {
setWorktreeSlug("");
setWorktreeSlugEdited(false);
}
}}
worktreeSlug={worktreeSlug}
onWorktreeSlugChange={(value) => {
setWorktreeSlug(slugifyWorktreeName(value));
setWorktreeSlugEdited(true);
}}
gitValidationError={gitBlockingError}
isBaseDropdownOpen={openDropdown === "baseBranch"}
onToggleBaseDropdown={() => openDropdownSheet("baseBranch")}
onCloseDropdown={closeDropdown}
/>
</ScrollView>
<Animated.View
style={[
styles.footer,
{
paddingBottom: insets.bottom + defaultTheme.spacing[4],
paddingLeft: horizontalPaddingLeft,
paddingRight: horizontalPaddingRight,
},
footerAnimatedStyle,
]}
>
<Pressable
style={[
styles.createButton,
createDisabled && styles.createButtonDisabled,
]}
onPress={handleCreate}
disabled={createDisabled}
>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator
color={defaultTheme.colors.palette.white}
/>
<Text style={styles.createButtonText}>Creating...</Text>
</View>
) : (
<Text style={styles.createButtonText}>Create Agent</Text>
)}
</Pressable>
</Animated.View>
</>
) : (
<View
style={[
styles.resumeContainer,
{
paddingLeft: horizontalPaddingLeft,
paddingRight: horizontalPaddingRight,
paddingBottom: insets.bottom + defaultTheme.spacing[4],
},
]}
>
{daemonAvailabilityError ? (
<Text style={styles.daemonAvailabilityText}>
{daemonAvailabilityError}
</Text>
) : null}
<View style={styles.resumeFilters}>
<View style={styles.providerFilterRow}>
{providerFilterOptions.map((option) => {
const isActive = importProviderFilter === option.id;
return (
<Pressable
key={option.id}
onPress={() => setImportProviderFilter(option.id)}
style={[
styles.providerFilterButton,
isActive && styles.providerFilterButtonActive,
]}
>
<Text
style={[
styles.providerFilterText,
isActive && styles.providerFilterTextActive,
]}
>
{option.label}
</Text>
</Pressable>
);
})}
</View>
<View style={styles.resumeSearchRow}>
<TextInput
style={styles.resumeSearchInput}
placeholder="Search by title or path"
placeholderTextColor={defaultTheme.colors.mutedForeground}
value={importSearchQuery}
onChangeText={setImportSearchQuery}
/>
<Pressable
style={styles.refreshButton}
onPress={refreshImportList}
disabled={isImportLoading || !isTargetDaemonReady}
>
<Text style={styles.refreshButtonText}>Refresh</Text>
</Pressable>
</View>
</View>
{importError ? (
<Text style={styles.importErrorText}>{importError}</Text>
) : null}
{isImportLoading ? (
<View style={styles.resumeLoading}>
<ActivityIndicator
color={defaultTheme.colors.mutedForeground}
/>
<Text style={styles.resumeLoadingText}>
Loading agents to import...
</Text>
</View>
) : filteredImportCandidates.length === 0 ? (
<View style={styles.resumeEmptyState}>
<Text style={styles.resumeEmptyTitle}>No agents to import</Text>
<Text style={styles.resumeEmptySubtitle}>
We will load the latest Claude and Codex sessions from your
local history so you can import them.
</Text>
<Pressable
style={styles.refreshButtonAlt}
onPress={refreshImportList}
disabled={isImportLoading || !isTargetDaemonReady}
>
<Text style={styles.refreshButtonAltText}>Try Again</Text>
</Pressable>
</View>
) : (
<FlatList
data={filteredImportCandidates}
renderItem={renderImportItem}
keyExtractor={(item) =>
`${item.provider}:${item.sessionId}`
}
ItemSeparatorComponent={() => (
<View style={styles.resumeItemSeparator} />
)}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.resumeListContent}
/>
)}
</View>
)}
<DropdownSheet
title="Host"
visible={openDropdown === "host"}
onClose={closeDropdown}
>
{daemonEntries.length === 0 ? (
<Text style={styles.helperText}>No hosts available yet.</Text>
) : (
<View style={styles.dropdownSheetList}>
{daemonEntries.map(({ daemon, status }) => {
const isSelected = daemon.id === selectedServerId;
const label = daemon.label ?? daemon.wsUrl ?? daemon.id;
return (
<Pressable
key={daemon.id}
style={[
styles.dropdownSheetOption,
isSelected && styles.dropdownSheetOptionSelected,
]}
onPress={() => {
setSelectedServerIdFromUser(daemon.id);
closeDropdown();
}}
>
<Text style={styles.dropdownSheetOptionLabel}>{label}</Text>
<Text style={styles.dropdownSheetOptionDescription}>
{formatConnectionStatus(status)}
</Text>
</Pressable>
);
})}
</View>
)}
</DropdownSheet>
</View>
</Animated.View>
</View>
</Modal>
);
}
function LazyAgentFlowModal(props: Omit<AgentFlowModalProps, "onAfterClose">) {
const { isVisible } = props;
const [shouldRender, setShouldRender] = useState(isVisible);
useEffect(() => {
if (isVisible) {
setShouldRender(true);
}
}, [isVisible]);
const handleAfterClose = useCallback(() => {
setShouldRender(false);
}, []);
if (!shouldRender) {
return null;
}
return <AgentFlowModal {...props} onAfterClose={handleAfterClose} />;
}
export function CreateAgentModal(props: ModalWrapperProps) {
return <LazyAgentFlowModal {...props} flow="create" />;
}
export function ImportAgentModal(props: ModalWrapperProps) {
return <LazyAgentFlowModal {...props} flow="import" />;
}
interface ModalHeaderProps {
paddingTop: number;
paddingLeft: number;
paddingRight: number;
onClose: () => void;
title: string;
rightContent?: ReactNode;
}
function ModalHeader({
paddingTop,
paddingLeft,
paddingRight,
onClose,
title,
rightContent,
}: ModalHeaderProps): ReactElement {
return (
<View style={[styles.header, { paddingTop, paddingLeft, paddingRight }]}>
<Text style={styles.headerTitle}>{title}</Text>
<View style={styles.headerActions}>
{rightContent}
<Pressable onPress={onClose} style={styles.closeButton} hitSlop={8}>
<X size={20} color={defaultTheme.colors.mutedForeground} />
</Pressable>
</View>
</View>
);
}
interface GitOptionsSectionProps {
baseBranch: string;
onBaseBranchChange: (value: string) => void;
branches: Array<{ name: string; isCurrent: boolean }>;
status: "idle" | "loading" | "ready" | "error";
repoError: string | null;
helperText?: string | null;
warning: string | null;
createNewBranch: boolean;
onToggleCreateNewBranch: (value: boolean) => void;
branchName: string;
onBranchNameChange: (value: string) => void;
createWorktree: boolean;
onToggleCreateWorktree: (value: boolean) => void;
worktreeSlug: string;
onWorktreeSlugChange: (value: string) => void;
gitValidationError: string | null;
isGitDisabled?: boolean;
isBaseDropdownOpen: boolean;
onToggleBaseDropdown: () => void;
onCloseDropdown: () => void;
}
function GitOptionsSection({
baseBranch,
onBaseBranchChange,
branches,
status,
repoError,
helperText,
warning,
createNewBranch,
onToggleCreateNewBranch,
branchName,
onBranchNameChange,
createWorktree,
onToggleCreateWorktree,
worktreeSlug,
onWorktreeSlugChange,
gitValidationError,
isGitDisabled,
isBaseDropdownOpen,
onToggleBaseDropdown,
onCloseDropdown,
}: GitOptionsSectionProps): ReactElement {
const [branchSearch, setBranchSearch] = useState("");
const branchFilter = branchSearch.trim().toLowerCase();
const filteredBranches =
branchFilter.length === 0
? branches
: branches.filter((branch) =>
branch.name.toLowerCase().includes(branchFilter)
);
const maxVisible = 30;
const currentBranchLabel =
branches.find((branch) => branch.isCurrent)?.name ?? "";
const baseInputRef = useRef<TextInput | null>(null);
const gitInputsDisabled = Boolean(isGitDisabled) || status === "loading";
useEffect(() => {
if (isBaseDropdownOpen) {
setBranchSearch("");
baseInputRef.current?.focus();
}
}, [isBaseDropdownOpen]);
return (
<View style={styles.formSection}>
<Text style={styles.label}>Git Setup</Text>
<Text style={styles.helperText}>
Choose a base branch, then optionally create a feature branch or
isolated worktree.
</Text>
<DropdownField
label="Base Branch"
value={baseBranch}
placeholder={currentBranchLabel || "main"}
onPress={onToggleBaseDropdown}
disabled={gitInputsDisabled}
errorMessage={repoError}
warningMessage={!gitValidationError && !isGitDisabled ? warning : null}
helperText={
helperText ??
(status === "loading"
? "Inspecting repository…"
: "Search existing branches, then tap to select.")
}
/>
<DropdownSheet
title="Base Branch"
visible={isBaseDropdownOpen}
onClose={onCloseDropdown}
>
<TextInput
ref={baseInputRef}
style={styles.dropdownSearchInput}
placeholder={currentBranchLabel || "main"}
placeholderTextColor={defaultTheme.colors.mutedForeground}
value={branchSearch}
onChangeText={setBranchSearch}
autoCapitalize="none"
autoCorrect={false}
/>
{status === "loading" ? (
<View style={styles.dropdownLoading}>
<ActivityIndicator color={defaultTheme.colors.mutedForeground} />
<Text style={styles.helperText}>Inspecting repository…</Text>
</View>
) : filteredBranches.length === 0 ? (
<Text style={styles.helperText}>
{branchFilter.length === 0
? "No branches detected yet."
: "No branches match your search."}
</Text>
) : (
<View style={styles.dropdownSheetList}>
{filteredBranches.slice(0, maxVisible).map((branch) => {
const isActive = branch.name === baseBranch;
return (
<Pressable
key={branch.name}
style={[
styles.dropdownSheetOption,
isActive && styles.dropdownSheetOptionSelected,
]}
onPress={() => {
onBaseBranchChange(branch.name);
onCloseDropdown();
}}
>
<Text style={styles.dropdownSheetOptionLabel}>
{branch.name}
{branch.isCurrent ? " (current)" : ""}
</Text>
</Pressable>
);
})}
</View>
)}
{filteredBranches.length > maxVisible ? (
<Text style={styles.helperText}>
Showing first {maxVisible} matches. Keep typing to narrow it down.
</Text>
) : null}
</DropdownSheet>
<ToggleRow
label="New Branch"
description="Create a feature branch before launching the agent"
value={createNewBranch}
onToggle={onToggleCreateNewBranch}
disabled={isGitDisabled}
/>
{createNewBranch ? (
<TextInput
style={styles.input}
placeholder="feature-branch-name"
placeholderTextColor={defaultTheme.colors.mutedForeground}
value={branchName}
onChangeText={onBranchNameChange}
autoCapitalize="none"
autoCorrect={false}
/>
) : null}
<ToggleRow
label="Create Worktree"
description="Use an isolated directory so your current branch stays untouched"
value={createWorktree}
onToggle={onToggleCreateWorktree}
disabled={isGitDisabled}
/>
{createWorktree ? (
<TextInput
style={styles.input}
placeholder={branchName || "feature-worktree"}
placeholderTextColor={defaultTheme.colors.mutedForeground}
value={worktreeSlug}
onChangeText={onWorktreeSlugChange}
autoCapitalize="none"
autoCorrect={false}
/>
) : null}
{gitValidationError ? (
<Text style={styles.errorText}>{gitValidationError}</Text>
) : null}
</View>
);
}
interface ToggleRowProps {
label: string;
description?: string;
value: boolean;
onToggle: (value: boolean) => void;
disabled?: boolean;
}
function ToggleRow({
label,
description,
value,
onToggle,
disabled,
}: ToggleRowProps): ReactElement {
return (
<Pressable
onPress={() => {
if (!disabled) {
onToggle(!value);
}
}}
style={[styles.toggleRow, disabled && styles.toggleRowDisabled]}
>
<View
style={[
styles.checkbox,
value && styles.checkboxChecked,
disabled && styles.checkboxDisabled,
]}
>
{value ? <View style={styles.checkboxDot} /> : null}
</View>
<View style={styles.toggleTextContainer}>
<Text style={styles.toggleLabel}>{label}</Text>
{description ? (
<Text style={styles.helperText}>{description}</Text>
) : null}
</View>
</Pressable>
);
}
const styles = StyleSheet.create(((theme: any) => ({
overlay: {
flex: 1,
justifyContent: "flex-end",
},
backdrop: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: theme.colors.palette.gray[900],
zIndex: 1,
},
backdropPressable: {
flex: 1,
},
sheet: {
position: "absolute",
left: 0,
right: 0,
top: 0,
bottom: 0,
zIndex: 2,
width: "100%",
backgroundColor: theme.colors.card,
overflow: "hidden",
},
content: {
flex: 1,
position: "relative",
},
header: {
paddingBottom: theme.spacing[4],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
headerTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.semibold,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
closeButton: {
padding: theme.spacing[2],
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
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],
},
scroll: {
flex: 1,
},
scrollContent: {
paddingTop: theme.spacing[6],
paddingBottom: theme.spacing[8],
gap: theme.spacing[6],
},
formSection: {
gap: theme.spacing[3],
},
label: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
input: {
backgroundColor: theme.colors.background,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
dropdownControl: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
backgroundColor: theme.colors.background,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
},
dropdownControlDisabled: {
opacity: theme.opacity[50],
},
dropdownValue: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
dropdownPlaceholder: {
flex: 1,
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.base,
},
dropdownSearchInput: {
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.background,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
color: theme.colors.foreground,
},
dropdownLoading: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
dropdownSheetOverlay: {
flex: 1,
justifyContent: "flex-end",
},
dropdownSheetBackdrop: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: theme.colors.palette.gray[900],
opacity: 0.45,
},
dropdownSheetContainer: {
backgroundColor: theme.colors.card,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
paddingTop: theme.spacing[4],
paddingHorizontal: theme.spacing[5],
paddingBottom: theme.spacing[6] + theme.spacing[2],
maxHeight: 560,
width: "100%",
},
dropdownSheetHandle: {
width: 56,
height: 4,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.border,
alignSelf: "center",
marginBottom: theme.spacing[3],
},
dropdownSheetTitle: {
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: "center",
marginBottom: theme.spacing[4],
},
dropdownSheetScrollContent: {
paddingBottom: theme.spacing[8],
paddingHorizontal: theme.spacing[1],
},
dropdownSheetList: {
marginTop: theme.spacing[3],
},
dropdownSheetOption: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
borderWidth: theme.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],
},
dropdownSheetLoading: {
alignItems: "center",
paddingVertical: theme.spacing[4],
},
inputDisabled: {
opacity: theme.opacity[50],
},
errorText: {
color: theme.colors.palette.red[500],
fontSize: theme.fontSize.sm,
},
warningText: {
color: theme.colors.palette.orange[400],
fontSize: theme.fontSize.sm,
},
helperText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
selectorRow: {
flexDirection: "row",
gap: theme.spacing[4],
},
selectorRowStacked: {
flexDirection: "column",
},
selectorColumn: {
flex: 1,
gap: theme.spacing[3],
},
selectorColumnFull: {
width: "100%",
},
toggleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
toggleRowDisabled: {
opacity: theme.opacity[50],
},
checkbox: {
width: 22,
height: 22,
borderRadius: theme.borderRadius.sm,
borderWidth: theme.borderWidth[2],
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
checkboxChecked: {
borderColor: theme.colors.palette.blue[500],
backgroundColor: theme.colors.palette.blue[500],
},
checkboxDisabled: {
borderColor: theme.colors.border,
},
checkboxDot: {
width: 10,
height: 10,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.white,
},
toggleTextContainer: {
flex: 1,
gap: theme.spacing[1],
},
toggleLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
daemonSelectorSection: {
marginBottom: theme.spacing[6],
},
daemonSelectorLabel: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
textTransform: "uppercase",
letterSpacing: 0.5,
marginBottom: theme.spacing[2],
},
daemonSelectorChips: {
flexDirection: "row",
gap: theme.spacing[2],
},
daemonChip: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
marginRight: theme.spacing[2],
},
daemonChipSelected: {
backgroundColor: theme.colors.palette.blue[900],
borderColor: theme.colors.palette.blue[500],
},
daemonChipText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
daemonChipTextSelected: {
color: theme.colors.palette.white,
},
daemonChipStatus: {
fontSize: theme.fontSize.xs,
color: theme.colors.mutedForeground,
},
daemonAvailabilityText: {
marginTop: theme.spacing[2],
marginBottom: theme.spacing[2],
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
},
footer: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
paddingTop: theme.spacing[4],
backgroundColor: theme.colors.card,
},
createButton: {
backgroundColor: theme.colors.palette.blue[500],
paddingVertical: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
alignItems: "center",
},
createButtonDisabled: {
backgroundColor: theme.colors.palette.blue[900],
opacity: theme.opacity[50],
},
createButtonText: {
color: theme.colors.palette.white,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
loadingContainer: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
resumeContainer: {
flex: 1,
gap: theme.spacing[4],
},
resumeFilters: {
gap: theme.spacing[3],
},
providerFilterRow: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[2],
},
providerFilterButton: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.full,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.background,
},
providerFilterButtonActive: {
borderColor: theme.colors.palette.blue[500],
backgroundColor: theme.colors.muted,
},
providerFilterText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
providerFilterTextActive: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.semibold,
},
resumeSearchRow: {
flexDirection: "row",
gap: theme.spacing[3],
alignItems: "center",
},
resumeSearchInput: {
flex: 1,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.background,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
color: theme.colors.foreground,
},
refreshButton: {
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
refreshButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
importErrorText: {
color: theme.colors.palette.red[500],
fontSize: theme.fontSize.sm,
},
resumeLoading: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
},
resumeLoadingText: {
color: theme.colors.mutedForeground,
},
resumeEmptyState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[6],
textAlign: "center",
},
resumeEmptyTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
textAlign: "center",
},
resumeEmptySubtitle: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
refreshButtonAlt: {
marginTop: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.palette.blue[500],
paddingHorizontal: theme.spacing[6],
paddingVertical: theme.spacing[3],
},
refreshButtonAltText: {
color: theme.colors.palette.white,
fontWeight: theme.fontWeight.semibold,
},
resumeListContent: {
paddingBottom: theme.spacing[8],
gap: theme.spacing[2],
},
resumeItem: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[4],
backgroundColor: theme.colors.background,
gap: theme.spacing[2],
},
resumeItemHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: theme.spacing[2],
},
resumeItemTitle: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.semibold,
flex: 1,
},
resumeItemTimestamp: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
resumeItemPath: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
resumeItemMetaRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
resumeProviderBadge: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.muted,
},
resumeProviderBadgeText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
resumeItemHint: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
},
resumeItemSeparator: {
height: theme.spacing[2],
},
})) as any) as Record<string, any>;