Files
paseo/packages/app/src/screens/agent/draft-agent-screen.tsx
T
2026-01-27 13:48:04 +07:00

1055 lines
33 KiB
TypeScript

import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import { createNameId } from "mnemonic-id";
import type { ImageAttachment } from "@/components/message-input";
import {
View,
Text,
Pressable,
ScrollView,
Keyboard,
Platform,
} from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { Monitor } from "lucide-react-native";
import { MenuHeader } from "@/components/headers/menu-header";
import { AgentInputArea } from "@/components/agent-input-area";
import { AgentStreamView } from "@/components/agent-stream-view";
import {
DropdownSheet,
GitOptionsSection,
WorkingDirectoryDropdown,
AgentConfigRow,
} from "@/components/agent-form/agent-form-dropdowns";
import { FileDropZone } from "@/components/file-drop-zone";
import { useQuery } from "@tanstack/react-query";
import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { formatConnectionStatus } from "@/utils/daemons";
import { useSessionStore } from "@/stores/session-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { WelcomeScreen } from "@/components/welcome-screen";
import type { Agent } from "@/contexts/session-context";
import type { StreamItem } from "@/types/stream";
import { generateMessageId } from "@/types/stream";
import type {
AgentProvider,
AgentCapabilityFlags,
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 EMPTY_PENDING_PERMISSIONS = new Map();
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
};
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;
}
if (Array.isArray(value)) {
for (const entry of value) {
const trimmed = entry.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
}
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 DraftAgentScreenProps = {
isVisible?: boolean;
onCreateFlowActiveChange?: (active: boolean) => void;
};
export function DraftAgentScreen({
isVisible = true,
onCreateFlowActiveChange,
}: DraftAgentScreenProps = {}) {
const { theme } = useUnistyles();
const router = useRouter();
const insets = useSafeAreaInsets();
const { connectionStates } = useDaemonConnections();
const { daemons } = useDaemonRegistry();
const params = useLocalSearchParams<DraftAgentParams>();
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInset = useSharedValue(insets.bottom);
useEffect(() => {
bottomInset.value = insets.bottom;
}, [insets.bottom, bottomInset]);
const animatedKeyboardStyle = useAnimatedStyle(() => {
"worklet";
const absoluteHeight = Math.abs(keyboardHeight.value);
const shift = Math.max(0, absoluteHeight - bottomInset.value);
return {
transform: [{ translateY: -shift }],
};
});
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((): CreateAgentInitialValues => {
const values: CreateAgentInitialValues = {};
if (resolvedWorkingDir) {
values.workingDir = resolvedWorkingDir;
}
if (resolvedProvider) {
values.provider = resolvedProvider;
}
if (resolvedMode) {
values.modeId = resolvedMode;
}
if (resolvedModel) {
values.model = resolvedModel;
}
return values;
}, [resolvedMode, resolvedModel, resolvedProvider, resolvedWorkingDir]);
const {
selectedServerId,
setSelectedServerIdFromUser,
selectedProvider,
setProviderFromUser,
selectedMode,
setModeFromUser,
selectedModel,
setModelFromUser,
workingDir,
setWorkingDirFromUser,
providerDefinitions,
modeOptions,
availableModels,
isModelLoading,
modelError,
refreshProviderModels,
persistFormPreferences,
} = useAgentFormState({
initialServerId: resolvedServerId ?? null,
initialValues,
isVisible,
isCreateFlow: true,
});
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" | null>(null);
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none");
const [baseBranch, setBaseBranch] = useState("");
const [worktreeSlug, setWorktreeSlug] = useState("");
const [selectedWorktreePath, setSelectedWorktreePath] = useState("");
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending);
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId);
const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear);
type CreateAttempt = {
messageId: string;
text: string;
timestamp: Date;
};
type DraftAgentMachineState =
| { tag: "draft"; promptText: string; errorMessage: string }
| { tag: "creating"; attempt: CreateAttempt };
type DraftAgentMachineEvent =
| { type: "DRAFT_SET_PROMPT"; text: string }
| { type: "DRAFT_SET_ERROR"; message: string }
| { type: "SUBMIT"; attempt: CreateAttempt }
| { type: "CREATE_FAILED"; message: string };
function assertNever(value: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}
const [machine, dispatch] = useReducer(
(state: DraftAgentMachineState, event: DraftAgentMachineEvent): DraftAgentMachineState => {
switch (event.type) {
case "DRAFT_SET_PROMPT": {
if (state.tag !== "draft") {
return state;
}
return { ...state, promptText: event.text };
}
case "DRAFT_SET_ERROR": {
if (state.tag !== "draft") {
return state;
}
return { ...state, errorMessage: event.message };
}
case "SUBMIT": {
return { tag: "creating", attempt: event.attempt };
}
case "CREATE_FAILED": {
if (state.tag !== "creating") {
return state;
}
return { tag: "draft", promptText: state.attempt.text, errorMessage: event.message };
}
default:
return assertNever(event);
}
},
{ tag: "draft", promptText: "", errorMessage: "" }
);
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files);
}, []);
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages;
}, []);
const openDropdownSheet = useCallback((key: "host") => {
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<string>();
sessionAgents.forEach((agent) => {
if (agent.cwd) {
uniquePaths.add(agent.cwd);
}
});
return Array.from(uniquePaths).sort();
}, [selectedServerId, sessionAgents]);
const sessionClient = useSessionStore((state) =>
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
);
const isConnected = useSessionStore((state) =>
selectedServerId
? state.sessions[selectedServerId]?.connection.isConnected ?? false
: false
);
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" || !isConnected)
? daemonAvailabilityError ??
"Repository details will load automatically once the selected host is back online."
: null;
type RepoInfoState = {
cwd: string;
repoRoot: string;
branches: Array<{ name: string; isCurrent: boolean }>;
currentBranch: string | null;
isDirty: boolean;
};
const repoInfoQuery = useQuery({
queryKey: ["gitRepoInfo", selectedServerId, trimmedWorkingDir],
queryFn: async () => {
const client = sessionClient;
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.getGitRepoInfo({
cwd: trimmedWorkingDir || ".",
});
if (payload.error) {
throw new Error(payload.error);
}
return {
cwd: payload.cwd,
repoRoot: payload.repoRoot,
branches: payload.branches ?? [],
currentBranch: payload.currentBranch ?? null,
isDirty: Boolean(payload.isDirty),
};
},
enabled:
Boolean(trimmedWorkingDir) &&
!repoAvailabilityError &&
Boolean(sessionClient) &&
isConnected,
retry: false,
});
const repoInfo = repoInfoQuery.data ?? null;
const refetchRepoInfo = repoInfoQuery.refetch;
const repoRequestError = repoInfoQuery.error as Error | null;
const repoRequestStatus: "idle" | "loading" | "success" | "error" =
!shouldInspectRepo || repoAvailabilityError
? "idle"
: repoInfoQuery.isPending || repoInfoQuery.isFetching
? "loading"
: repoInfoQuery.isError
? "error"
: repoInfoQuery.isSuccess
? "success"
: "idle";
const isNonGitDirectory =
repoRequestStatus === "error" &&
/not in a git repository|not 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 isCreateWorktree = worktreeMode === "create";
const isAttachWorktree = worktreeMode === "attach";
const worktreeListRoot = repoInfo?.repoRoot ?? trimmedWorkingDir;
const worktreeListQuery = useQuery({
queryKey: ["paseoWorktreeList", selectedServerId, worktreeListRoot],
queryFn: async () => {
const client = sessionClient;
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.getPaseoWorktreeList({
repoRoot: worktreeListRoot || undefined,
cwd: worktreeListRoot ? undefined : trimmedWorkingDir || undefined,
});
if (payload.error) {
throw new Error(payload.error.message);
}
return payload.worktrees ?? [];
},
enabled:
isAttachWorktree &&
Boolean(worktreeListRoot || trimmedWorkingDir) &&
!repoAvailabilityError &&
Boolean(sessionClient) &&
isConnected &&
!isNonGitDirectory,
retry: false,
staleTime: 0,
refetchOnMount: "always",
});
const worktreeOptions = useMemo(() => {
return (worktreeListQuery.data ?? []).map((worktree) => ({
path: worktree.worktreePath,
label: worktree.branchName ?? worktree.head ?? "Unknown branch",
}));
}, [worktreeListQuery.data]);
const worktreeOptionsError =
worktreeListQuery.error instanceof Error ? worktreeListQuery.error.message : null;
const worktreeOptionsStatus: "idle" | "loading" | "ready" | "error" =
!isAttachWorktree
? "idle"
: worktreeListQuery.isPending || worktreeListQuery.isFetching
? "loading"
: worktreeListQuery.isError
? "error"
: "ready";
const attachWorktreeError =
isAttachWorktree &&
worktreeOptionsStatus === "ready" &&
worktreeOptions.length > 0 &&
!selectedWorktreePath
? "Select a worktree to attach"
: null;
const handleWorktreeModeChange = useCallback(
(mode: "none" | "create" | "attach") => {
setWorktreeMode(mode);
if (mode === "create" && !worktreeSlug) {
setWorktreeSlug(createNameId());
}
if (mode !== "attach") {
setSelectedWorktreePath("");
}
if (mode !== "none") {
refetchRepoInfo();
}
},
[worktreeSlug, refetchRepoInfo]
);
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 (!isCreateWorktree || isNonGitDirectory) {
return null;
}
if (!worktreeSlug) {
return null;
}
const validation = validateWorktreeName(worktreeSlug);
if (!validation.valid) {
return `Invalid worktree name: ${
validation.error ?? "Must use lowercase letters, numbers, or hyphens"
}`;
}
return null;
}, [
isCreateWorktree,
isNonGitDirectory,
worktreeSlug,
validateWorktreeName,
]);
const baseBranchError = useMemo(() => {
if (!isCreateWorktree || isNonGitDirectory) {
return null;
}
if (!baseBranch) {
return "Base branch is required";
}
const branches = repoInfo?.branches ?? [];
if (branches.length === 0) {
return null;
}
const branchExists = branches.some((b) => b.name === baseBranch);
if (!branchExists) {
return `Branch "${baseBranch}" not found in repository`;
}
return null;
}, [isCreateWorktree, isNonGitDirectory, baseBranch, repoInfo?.branches]);
const handleBaseBranchChange = useCallback((value: string) => {
setBaseBranch(value);
}, []);
const handleSelectWorktreePath = useCallback(
(path: string) => {
setSelectedWorktreePath(path);
setWorkingDirFromUser(path);
},
[setWorkingDirFromUser]
);
useEffect(() => {
if (!isCreateWorktree || isNonGitDirectory) {
return;
}
if (baseBranch) {
return;
}
const current = repoInfo?.currentBranch?.trim();
if (current) {
setBaseBranch(current);
}
}, [isCreateWorktree, isNonGitDirectory, baseBranch, repoInfo?.currentBranch]);
useEffect(() => {
if (isNonGitDirectory && worktreeMode !== "none") {
setWorktreeMode("none");
setSelectedWorktreePath("");
}
}, [isNonGitDirectory, worktreeMode]);
const sessionMethods = useSessionStore((state) =>
selectedServerId ? state.sessions[selectedServerId]?.methods : undefined
);
const promptValue = machine.tag === "draft" ? machine.promptText : "";
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
const isSubmitting = machine.tag === "creating";
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
if (machine.tag !== "creating") {
return EMPTY_STREAM_ITEMS;
}
return [
{
kind: "user_message",
id: machine.attempt.messageId,
text: machine.attempt.text,
timestamp: machine.attempt.timestamp,
},
];
}, [machine]);
const draftAgent = useMemo<Agent | null>(() => {
if (machine.tag !== "creating") {
return null;
}
const serverId = selectedServerId ?? "";
const now = machine.attempt.timestamp;
const cwd = (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() || ".";
const provider = selectedProvider;
const model = selectedModel.trim() || null;
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
return {
serverId,
id: DRAFT_AGENT_ID,
provider,
status: "running",
createdAt: now,
updatedAt: now,
lastUserMessageAt: now,
lastActivityAt: now,
capabilities: DRAFT_CAPABILITIES,
currentModeId: modeId,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: {
provider,
sessionId: null,
model,
modeId,
},
title: "New agent",
cwd,
model,
};
}, [
machine.tag,
machine.tag === "creating" ? machine.attempt.timestamp : null,
isAttachWorktree,
modeOptions.length,
selectedMode,
selectedModel,
selectedProvider,
selectedServerId,
selectedWorktreePath,
workingDir,
]);
const handleCreateFromInput = useCallback(
async ({
text,
images,
}: {
text: string;
images?: Array<{ uri: string; mimeType: string }>;
}) => {
if (isSubmitting) {
throw new Error("Already loading");
}
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
const trimmedPath = workingDir.trim();
const trimmedPrompt = text.trim();
const resolvedWorkingDir =
isAttachWorktree && selectedWorktreePath
? selectedWorktreePath
: trimmedPath;
if (!trimmedPath) {
dispatch({ type: "DRAFT_SET_ERROR", message: "Working directory is required" });
throw new Error("Working directory is required");
}
if (isDirectoryNotExists) {
dispatch({
type: "DRAFT_SET_ERROR",
message: "Working directory does not exist on the selected host",
});
throw new Error("Working directory does not exist on the selected host");
}
if (!trimmedPrompt) {
dispatch({ type: "DRAFT_SET_ERROR", message: "Initial prompt is required" });
throw new Error("Initial prompt is required");
}
if (!selectedServerId) {
dispatch({ type: "DRAFT_SET_ERROR", message: "No host selected" });
throw new Error("No host selected");
}
if (gitBlockingError) {
dispatch({ type: "DRAFT_SET_ERROR", message: gitBlockingError });
throw new Error(gitBlockingError);
}
if (isAttachWorktree && !selectedWorktreePath) {
const message = "Select a worktree to attach";
dispatch({ type: "DRAFT_SET_ERROR", message });
throw new Error(message);
}
if (baseBranchError) {
dispatch({ type: "DRAFT_SET_ERROR", message: baseBranchError });
throw new Error(baseBranchError);
}
const createAgent = sessionMethods?.createAgent;
if (!createAgent) {
dispatch({ type: "DRAFT_SET_ERROR", message: "Host is not connected" });
throw new Error("Host is not connected");
}
const attempt = {
messageId: generateMessageId(),
text: trimmedPrompt,
timestamp: new Date(),
};
setPendingCreateAttempt({
serverId: selectedServerId,
agentId: null,
messageId: attempt.messageId,
text: attempt.text,
timestamp: attempt.timestamp.getTime(),
});
const modeId =
modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
const trimmedModel = selectedModel.trim();
const config: AgentSessionConfig = {
provider: selectedProvider,
cwd: resolvedWorkingDir,
...(modeId ? { modeId } : {}),
...(trimmedModel ? { model: trimmedModel } : {}),
};
const effectiveBaseBranch = baseBranch.trim();
const effectiveWorktreeSlug =
isCreateWorktree && !worktreeSlug ? createNameId() : worktreeSlug;
if (isCreateWorktree && !worktreeSlug && effectiveWorktreeSlug) {
setWorktreeSlug(effectiveWorktreeSlug);
}
const gitOptions =
isCreateWorktree && !isNonGitDirectory && effectiveWorktreeSlug
? {
createWorktree: true,
createNewBranch: true,
newBranchName: effectiveWorktreeSlug,
worktreeSlug: effectiveWorktreeSlug,
baseBranch: effectiveBaseBranch,
}
: undefined;
void persistFormPreferences();
if (Platform.OS === "web") {
(document.activeElement as HTMLElement | null)?.blur?.();
}
Keyboard.dismiss();
dispatch({ type: "SUBMIT", attempt });
onCreateFlowActiveChange?.(true);
try {
const result = await createAgent({
config,
initialPrompt: trimmedPrompt,
images,
git: gitOptions,
});
const agentId = (result as { id?: string })?.id;
if (agentId && selectedServerId) {
updatePendingAgentId(agentId);
router.replace(`/agent/${selectedServerId}/${agentId}` as any);
return;
}
dispatch({
type: "CREATE_FAILED",
message: "Failed to create agent",
});
onCreateFlowActiveChange?.(false);
clearPendingCreateAttempt();
throw new Error("Failed to create agent");
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create agent";
dispatch({ type: "CREATE_FAILED", message });
onCreateFlowActiveChange?.(false);
clearPendingCreateAttempt();
throw error; // Re-throw so AgentInputArea knows it failed
}
},
[
worktreeMode,
baseBranch,
worktreeSlug,
selectedWorktreePath,
repoInfo?.currentBranch,
gitBlockingError,
baseBranchError,
isDirectoryNotExists,
isNonGitDirectory,
modeOptions,
persistFormPreferences,
router,
selectedMode,
selectedModel,
selectedProvider,
selectedServerId,
sessionMethods,
setPendingCreateAttempt,
updatePendingAgentId,
clearPendingCreateAttempt,
workingDir,
isAttachWorktree,
isSubmitting,
onCreateFlowActiveChange,
]
);
if (daemons.length === 0) {
return (
<WelcomeScreen
onHostAdded={(profile) => {
setSelectedServerIdFromUser(profile.id);
}}
/>
);
}
return (
<FileDropZone onFilesDropped={handleFilesDropped}>
<View style={styles.container}>
<View style={styles.agentPanel}>
<MenuHeader
title="New agent"
rightContent={
<Pressable
style={styles.hostBadge}
onPress={() => openDropdownSheet("host")}
>
<Monitor size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.hostBadgeLabel}>{hostLabel}</Text>
<View
style={[
styles.hostStatusDot,
hostEntry?.status === "online" && styles.hostStatusDotOnline,
]}
/>
{hostStatus ? (
<Text style={styles.hostBadgeStatus}>{hostStatus}</Text>
) : null}
</Pressable>
}
/>
<Animated.View style={[styles.contentContainer, animatedKeyboardStyle]}>
{machine.tag === "creating" && draftAgent && selectedServerId ? (
<View style={styles.streamContainer}>
<AgentStreamView
agentId={DRAFT_AGENT_ID}
serverId={selectedServerId}
agent={draftAgent}
streamItems={optimisticStreamItems}
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
/>
</View>
) : (
<ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}>
<View style={styles.configSection}>
<WorkingDirectoryDropdown
workingDir={workingDir}
errorMessage=""
disabled={false}
suggestedPaths={agentWorkingDirSuggestions}
onSelectPath={setWorkingDirFromUser}
/>
{isDirectoryNotExists && (
<View style={styles.warningContainer}>
<Text style={styles.warningText}>
Directory does not exist on the selected host
</Text>
</View>
)}
<AgentConfigRow
providerDefinitions={providerDefinitions}
selectedProvider={selectedProvider}
onSelectProvider={setProviderFromUser}
modeOptions={modeOptions}
selectedMode={selectedMode}
onSelectMode={setModeFromUser}
models={availableModels}
selectedModel={selectedModel}
isModelLoading={isModelLoading}
onSelectModel={setModelFromUser}
/>
{trimmedWorkingDir.length > 0 && !isNonGitDirectory ? (
<GitOptionsSection
worktreeMode={worktreeMode}
onWorktreeModeChange={handleWorktreeModeChange}
worktreeSlug={worktreeSlug}
currentBranch={repoInfo?.currentBranch ?? null}
baseBranch={baseBranch}
onBaseBranchChange={handleBaseBranchChange}
branches={repoInfo?.branches ?? []}
status={repoInfoStatus}
repoError={repoInfoError}
gitValidationError={gitBlockingError}
baseBranchError={baseBranchError}
worktreeOptions={worktreeOptions}
selectedWorktreePath={selectedWorktreePath}
worktreeOptionsStatus={worktreeOptionsStatus}
worktreeOptionsError={worktreeOptionsError}
attachWorktreeError={attachWorktreeError}
onSelectWorktreePath={handleSelectWorktreePath}
/>
) : null}
</View>
<DropdownSheet
title="Host"
visible={openDropdown === "host"}
onClose={closeDropdown}
>
{connectionStates.size === 0 ? (
<Text style={styles.dropdownHelper}>
No hosts available yet.
</Text>
) : (
<View style={styles.dropdownSheetList}>
{Array.from(connectionStates.values()).map(({ daemon, status }) => {
const isSelected = daemon.id === selectedServerId;
const label = daemon.label ?? daemon.endpoints?.[0] ?? 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>
{formErrorMessage ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{formErrorMessage}</Text>
</View>
) : null}
</ScrollView>
)}
</Animated.View>
<View style={styles.inputAreaWrapper}>
<AgentInputArea
agentId={DRAFT_AGENT_ID}
serverId={selectedServerId ?? ""}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
value={promptValue}
onChangeText={(next) => dispatch({ type: "DRAFT_SET_PROMPT", text: next })}
autoFocus={machine.tag === "draft"}
onAddImages={handleAddImagesCallback}
/>
</View>
</View>
</View>
</FileDropZone>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
agentPanel: {
flex: 1,
},
contentContainer: {
flex: 1,
overflow: "hidden",
},
scrollView: {
flex: 1,
},
inputAreaWrapper: {
backgroundColor: theme.colors.surface0,
},
streamContainer: {
flex: 1,
},
configScrollContent: {
flexGrow: 1,
justifyContent: "flex-end",
},
configSection: {
paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[3],
paddingBottom: theme.spacing[4],
gap: theme.spacing[2],
maxWidth: MAX_CONTENT_WIDTH,
alignSelf: "center",
width: "100%",
},
dropdownHelper: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
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.surface0,
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.foregroundMuted,
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.surface2,
borderRadius: theme.borderRadius.full,
},
hostBadgeLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
hostBadgeStatus: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
hostStatusDot: {
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.foregroundMuted,
},
hostStatusDotOnline: {
backgroundColor: theme.colors.palette.green[500],
},
}));