Files
paseo/packages/app/src/screens/agent/draft-agent-screen.tsx
T
Mohamed Boudra cda3d1d46c chore: apply oxlint fix-suggestions (unused imports + useless fragments)
Scoped to only `no-unused-vars` and `react/jsx-no-useless-fragment` —
the safe subset of --fix-suggestions. Primary changes:

- Remove unused named imports from statements
- Remove unused default/type imports entirely
- Collapse `<><Single/></>` fragments to `<Single/>`

Verified clean: typecheck + format + lint all green.
2026-04-23 21:55:25 +07:00

1407 lines
48 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { createNameId } from "mnemonic-id";
import type { ImageAttachment } from "@/components/message-input";
import { View, Text, ScrollView, Keyboard } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useIsFocused } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { GestureDetector } from "react-native-gesture-handler";
import Animated from "react-native-reanimated";
import { Folder, GitBranch, PanelRight } from "lucide-react-native";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { Composer } from "@/components/composer";
import { AgentStreamView } from "@/components/agent-stream-view";
import { FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns";
import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { Combobox } from "@/components/ui/combobox";
import { FileDropZone } from "@/components/file-drop-zone";
import { useQuery } from "@tanstack/react-query";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { checkoutStatusQueryKey } from "@/hooks/use-checkout-status-query";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import { useHosts } from "@/runtime/host-runtime";
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { shortenPath } from "@/utils/shorten-path";
import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import { useSessionStore } from "@/stores/session-store";
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
import {
getHostRuntimeStore,
useHostRuntimeClient,
useHostRuntimeIsConnected,
} from "@/runtime/host-runtime";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { WelcomeScreen } from "@/components/welcome-screen";
import type { Agent } from "@/contexts/session-context";
import { encodeImages } from "@/utils/encode-images";
import type {
AgentProvider,
AgentCapabilityFlags,
AgentSessionConfig,
} from "@server/server/agent/agent-sdk-types";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
import { isWeb } from "@/constants/platform";
const EMPTY_PENDING_PERMISSIONS = new Map();
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
};
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 value as AgentProvider;
}
function getValidMode(provider: AgentProvider | undefined, value: string | undefined) {
if (!provider || !value) {
return undefined;
}
return value;
}
type DraftAgentParams = {
serverId?: string;
provider?: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
workingDir?: string;
worktreeMode?: string;
};
type DraftAgentScreenProps = {
isVisible?: boolean;
onCreateFlowActiveChange?: (active: boolean) => void;
forcedServerId?: string;
};
export function DraftAgentScreen({
isVisible = true,
onCreateFlowActiveChange,
forcedServerId,
}: DraftAgentScreenProps = {}) {
return (
<ExplorerSidebarAnimationProvider>
<DraftAgentScreenContent
isVisible={isVisible}
onCreateFlowActiveChange={onCreateFlowActiveChange}
forcedServerId={forcedServerId}
/>
</ExplorerSidebarAnimationProvider>
);
}
function DraftAgentScreenContent({
isVisible = true,
onCreateFlowActiveChange,
forcedServerId,
}: DraftAgentScreenProps = {}) {
const isFocused = useIsFocused();
const { theme } = useUnistyles();
const router = useRouter();
const insets = useSafeAreaInsets();
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
const params = useLocalSearchParams<DraftAgentParams>();
const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
mode: "translate",
});
const forcedServerIdParam = forcedServerId?.trim();
const resolvedServerId =
forcedServerIdParam && forcedServerIdParam.length > 0
? forcedServerIdParam
: getParamValue(params.serverId);
const resolvedProvider = getValidProvider(getParamValue(params.provider));
const resolvedMode = getValidMode(resolvedProvider, getParamValue(params.modeId));
const resolvedModel = getParamValue(params.model);
const resolvedThinkingOptionId = getParamValue(params.thinkingOptionId);
const resolvedWorkingDir = getParamValue(params.workingDir);
const resolvedWorktreeMode = getParamValue(params.worktreeMode);
const initialWorktreeMode =
resolvedWorktreeMode === "create" || resolvedWorktreeMode === "attach"
? resolvedWorktreeMode
: "none";
const onlineServerIds = useMemo(() => {
if (daemons.length === 0) return [];
const out: string[] = [];
for (const daemon of daemons) {
const status = runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting";
if (status === "online") out.push(daemon.serverId);
}
return out;
}, [daemons, runtime, runtimeVersion]);
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;
}
if (resolvedThinkingOptionId) {
values.thinkingOptionId = resolvedThinkingOptionId;
}
return values;
}, [resolvedMode, resolvedModel, resolvedProvider, resolvedThinkingOptionId, resolvedWorkingDir]);
const draftIdRef = useRef(generateDraftId());
const draftAgentIdRef = useRef(generateDraftId());
const draftInput = useAgentInputDraft({
draftKey: ({ selectedServerId }) =>
buildDraftStoreKey({
serverId: selectedServerId ?? "",
agentId: draftAgentIdRef.current,
draftId: draftIdRef.current,
}),
initialCwd: resolvedWorkingDir ?? "",
composer: {
initialServerId: resolvedServerId ?? null,
initialValues,
isVisible,
onlineServerIds,
},
});
const composerState = draftInput.composerState;
if (!composerState) {
throw new Error("Draft agent composer state is required");
}
const {
selectedServerId,
setSelectedServerIdFromUser,
providerDefinitions,
workingDir,
setWorkingDirFromUser,
modeOptions,
isModelLoading,
modelError,
refreshProviderModels,
persistFormPreferences,
effectiveModelId,
effectiveThinkingOptionId,
commandDraftConfig,
statusControls,
} = composerState;
const isMobile = useIsCompactFormFactor();
const isExplorerOpen = usePanelStore((state) =>
selectIsFileExplorerOpen(state, { isCompact: isMobile }),
);
const canOpenExplorerFromAgentView = usePanelStore(
(state) =>
state.mobileView === "agent" && !selectIsFileExplorerOpen(state, { isCompact: true }),
);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const toggleFileExplorerForCheckout = usePanelStore(
(state) => state.toggleFileExplorerForCheckout,
);
const activateExplorerTabForCheckout = usePanelStore(
(state) => state.activateExplorerTabForCheckout,
);
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">(
initialWorktreeMode,
);
const [baseBranch, setBaseBranch] = useState("");
const [worktreeSlug, setWorktreeSlug] = useState("");
const [selectedWorktreePath, setSelectedWorktreePath] = useState("");
const [isWorkingDirOpen, setIsWorkingDirOpen] = useState(false);
const [isWorktreePickerOpen, setIsWorktreePickerOpen] = useState(false);
const [isBranchOpen, setIsBranchOpen] = useState(false);
const [branchSearchQuery, setBranchSearchQuery] = useState("");
const [debouncedBranchSearchQuery, setDebouncedBranchSearchQuery] = useState("");
const [workingDirSearchQuery, setWorkingDirSearchQuery] = useState("");
const [debouncedWorkingDirSearchQuery, setDebouncedWorkingDirSearchQuery] = useState("");
const workingDirAnchorRef = useRef<View>(null);
const worktreeAnchorRef = useRef<View>(null);
const branchAnchorRef = useRef<View>(null);
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
useEffect(() => {
const trimmed = branchSearchQuery.trim();
const timer = setTimeout(() => setDebouncedBranchSearchQuery(trimmed), 180);
return () => clearTimeout(timer);
}, [branchSearchQuery]);
useEffect(() => {
const trimmed = workingDirSearchQuery.trim();
const timer = setTimeout(() => setDebouncedWorkingDirSearchQuery(trimmed), 180);
return () => clearTimeout(timer);
}, [workingDirSearchQuery]);
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files);
}, []);
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages;
}, []);
const sessionAgents = useSessionStore((state) =>
selectedServerId ? state.sessions[selectedServerId]?.agents : undefined,
);
const { agents: allAgents } = useAllAgentsList({ serverId: selectedServerId });
const worktreePathLastCreatedAt = useMemo(() => {
const map = new Map<string, number>();
if (!sessionAgents) {
return map;
}
sessionAgents.forEach((agent) => {
if (!agent.cwd) {
return;
}
const ts = agent.createdAt.getTime();
const prev = map.get(agent.cwd);
if (!prev || ts > prev) {
map.set(agent.cwd, ts);
}
});
return map;
}, [sessionAgents]);
const agentWorkingDirSuggestions = useMemo(() => {
const liveSources = sessionAgents
? Array.from(sessionAgents.values()).map((agent) => ({
cwd: agent.cwd,
createdAt: agent.createdAt,
lastActivityAt: agent.lastActivityAt,
}))
: [];
const fetchedSources = allAgents.map((agent) => ({
cwd: agent.cwd,
lastActivityAt: agent.lastActivityAt,
}));
return collectAgentWorkingDirectorySuggestions([...liveSources, ...fetchedSources]);
}, [allAgents, sessionAgents]);
const runtimeClient = useHostRuntimeClient(selectedServerId ?? "");
const isHostOnline = useHostRuntimeIsConnected(selectedServerId ?? "");
const sessionClient = runtimeClient;
const trimmedWorkingDir = workingDir.trim();
const shouldInspectRepo = trimmedWorkingDir.length > 0;
const canQuerySelectedHost = Boolean(selectedServerId) && Boolean(sessionClient) && isHostOnline;
const checkoutStatusQuery = useQuery({
queryKey: checkoutStatusQueryKey(selectedServerId ?? "", trimmedWorkingDir),
queryFn: async () => {
const client = sessionClient;
if (!client) {
throw new Error("Daemon client unavailable");
}
return await client.getCheckoutStatus(trimmedWorkingDir);
},
enabled: Boolean(trimmedWorkingDir) && canQuerySelectedHost,
retry: false,
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
const checkout = checkoutStatusQuery.data ?? null;
const checkoutQueryError =
checkoutStatusQuery.error instanceof Error ? checkoutStatusQuery.error.message : null;
const checkoutPayloadError = checkout?.error ? checkout.error.message : null;
const isGitDirectory = checkoutStatusQuery.isSuccess && checkout?.isGit === true;
const isNonGitDirectory =
Boolean(trimmedWorkingDir) &&
checkoutStatusQuery.isSuccess &&
checkout?.isGit === false &&
checkout?.error == null;
const isDirectoryNotExists =
checkoutStatusQuery.isError &&
/does not exist|no such file or directory|ENOENT/i.test(checkoutQueryError ?? "");
const repoInfoStatus: "idle" | "loading" | "ready" | "error" = !shouldInspectRepo
? "idle"
: !canQuerySelectedHost
? "idle"
: checkoutStatusQuery.isPending || checkoutStatusQuery.isFetching
? "loading"
: checkoutStatusQuery.isError || Boolean(checkoutPayloadError)
? "error"
: checkout?.isGit
? "ready"
: "idle";
const repoInfoError =
(checkoutStatusQuery.isError ? checkoutQueryError : null) ?? checkoutPayloadError;
const isCreateWorktree = worktreeMode === "create";
const isAttachWorktree = worktreeMode === "attach";
const worktreeListRoot = checkout?.isGit ? checkout.repoRoot : "";
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:
isGitDirectory && Boolean(worktreeListRoot) && canQuerySelectedHost && !isNonGitDirectory,
retry: false,
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
const worktreeOptions = useMemo(() => {
const options = (worktreeListQuery.data ?? []).map((worktree) => ({
path: worktree.worktreePath,
label: worktree.branchName ?? worktree.head ?? "Unknown branch",
}));
return options.sort((a, b) => {
const aTs = worktreePathLastCreatedAt.get(a.path) ?? 0;
const bTs = worktreePathLastCreatedAt.get(b.path) ?? 0;
if (aTs !== bTs) {
return bTs - aTs;
}
return a.label.localeCompare(b.label);
});
}, [worktreeListQuery.data, worktreePathLastCreatedAt]);
const worktreeOptionsError =
worktreeListQuery.error instanceof Error ? worktreeListQuery.error.message : null;
const worktreeOptionsStatus: "idle" | "loading" | "ready" | "error" =
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 branchSuggestionsQuery = useQuery({
queryKey: [
"branchSuggestions",
selectedServerId,
trimmedWorkingDir,
debouncedBranchSearchQuery,
],
queryFn: async () => {
const client = sessionClient;
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.getBranchSuggestions({
cwd: trimmedWorkingDir || ".",
query: debouncedBranchSearchQuery || undefined,
limit: 50,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload.branches ?? [];
},
enabled:
isCreateWorktree &&
isBranchOpen &&
isGitDirectory &&
!isNonGitDirectory &&
Boolean(trimmedWorkingDir) &&
canQuerySelectedHost,
retry: false,
staleTime: 15_000,
});
const directorySuggestionsQuery = useQuery({
queryKey: ["directorySuggestions", selectedServerId, debouncedWorkingDirSearchQuery],
queryFn: async () => {
const client = sessionClient;
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.getDirectorySuggestions({
query: debouncedWorkingDirSearchQuery,
limit: 50,
includeDirectories: true,
includeFiles: false,
});
if (payload.error) {
throw new Error(payload.error);
}
if (payload.entries.length > 0) {
return payload.entries
.filter((entry) => entry.kind === "directory")
.map((entry) => entry.path);
}
return payload.directories ?? [];
},
enabled: Boolean(debouncedWorkingDirSearchQuery) && canQuerySelectedHost,
retry: false,
staleTime: 15_000,
});
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]);
// Validate branch exists (checks local first, then remote)
const branchValidationQuery = useQuery({
queryKey: ["validateBranch", selectedServerId, trimmedWorkingDir, baseBranch],
queryFn: async () => {
const client = sessionClient;
if (!client) {
throw new Error("Daemon client unavailable");
}
return client.validateBranch({
cwd: trimmedWorkingDir || ".",
branchName: baseBranch,
});
},
enabled:
isCreateWorktree &&
isGitDirectory &&
!isNonGitDirectory &&
Boolean(baseBranch) &&
Boolean(trimmedWorkingDir) &&
Boolean(sessionClient) &&
isHostOnline,
retry: false,
staleTime: 30_000,
});
const baseBranchError = useMemo(() => {
if (!isCreateWorktree || isNonGitDirectory) {
return null;
}
if (!baseBranch) {
return "Base branch is required";
}
// While validating, don't show error
if (branchValidationQuery.isPending || branchValidationQuery.isFetching) {
return null;
}
// If validation query errored, show generic error
if (branchValidationQuery.isError) {
return "Failed to validate branch";
}
// If validation completed and branch doesn't exist
const validationResult = branchValidationQuery.data;
if (validationResult && !validationResult.exists) {
return `Branch "${baseBranch}" not found in repository`;
}
return null;
}, [isCreateWorktree, isNonGitDirectory, baseBranch, branchValidationQuery]);
const handleBaseBranchChange = useCallback((value: string) => {
setBaseBranch(value);
}, []);
const handleSelectWorktreePath = useCallback((path: string) => {
setSelectedWorktreePath(path);
}, []);
useEffect(() => {
if (!isCreateWorktree || isNonGitDirectory) {
return;
}
if (baseBranch) {
return;
}
const current = checkout?.isGit ? checkout.currentBranch?.trim() : null;
if (!current || current === "HEAD") {
return;
}
if (current) {
setBaseBranch(current);
}
}, [isCreateWorktree, isNonGitDirectory, baseBranch, checkout]);
useEffect(() => {
if (isNonGitDirectory && worktreeMode !== "none") {
setWorktreeMode("none");
setSelectedWorktreePath("");
}
}, [isNonGitDirectory, worktreeMode]);
const selectedWorktreeLabel =
worktreeOptions.find((option) => option.path === selectedWorktreePath)?.label ?? "";
const explorerCwd = useMemo(
() => (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim(),
[isAttachWorktree, selectedWorktreePath, workingDir],
);
const draftExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
if (!selectedServerId || !explorerCwd) {
return null;
}
return {
serverId: selectedServerId,
cwd: explorerCwd,
isGit: isAttachWorktree && selectedWorktreePath ? true : checkout?.isGit === true,
};
}, [selectedServerId, explorerCwd, isAttachWorktree, selectedWorktreePath, checkout?.isGit]);
const draftExplorerWorkspaceId = useSessionStore(
useCallback(
(state) =>
resolveWorkspaceIdByExecutionDirectory({
workspaces: selectedServerId
? state.sessions[selectedServerId]?.workspaces?.values()
: null,
workspaceDirectory: explorerCwd,
}),
[explorerCwd, selectedServerId],
),
);
const canOpenExplorer = draftExplorerCheckout !== null;
const openExplorerForDraftCheckout = useCallback(() => {
if (!draftExplorerCheckout) {
return;
}
openFileExplorerForCheckout({
isCompact: isMobile,
checkout: draftExplorerCheckout,
});
}, [draftExplorerCheckout, isMobile, openFileExplorerForCheckout]);
const handleToggleExplorer = useCallback(() => {
if (!canOpenExplorer) {
return;
}
toggleFileExplorerForCheckout({
isCompact: isMobile,
checkout: draftExplorerCheckout,
});
}, [canOpenExplorer, draftExplorerCheckout, isMobile, toggleFileExplorerForCheckout]);
const explorerOpenGesture = useExplorerOpenGesture({
enabled: isMobile && canOpenExplorerFromAgentView && canOpenExplorer,
onOpen: openExplorerForDraftCheckout,
});
const handleDraftSidebarAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (action.id !== "sidebar.toggle.right") {
return false;
}
handleToggleExplorer();
return true;
},
[handleToggleExplorer],
);
useKeyboardActionHandler({
handlerId: `draft-agent-sidebar-actions:${draftIdRef.current}`,
actions: ["sidebar.toggle.right"] as const,
enabled: Boolean(isFocused && canOpenExplorer),
priority: 100,
isActive: () => true,
handle: handleDraftSidebarAction,
});
const hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0;
const workingDirSearchError =
directorySuggestionsQuery.error instanceof Error
? directorySuggestionsQuery.error.message
: null;
const workingDirSuggestionPaths = useMemo(
() =>
buildWorkingDirectorySuggestions({
recommendedPaths: agentWorkingDirSuggestions,
serverPaths: hasWorkingDirectorySearch ? (directorySuggestionsQuery.data ?? []) : [],
query: workingDirSearchQuery,
}),
[
agentWorkingDirSuggestions,
directorySuggestionsQuery.data,
hasWorkingDirectorySearch,
workingDirSearchQuery,
],
);
const workingDirComboOptions = useMemo(
() =>
workingDirSuggestionPaths.map((path) => ({
id: path,
label: shortenPath(path),
kind: "directory" as const,
})),
[workingDirSuggestionPaths],
);
const workingDirEmptyText = useMemo(() => {
if (hasWorkingDirectorySearch) {
if (workingDirSearchError) {
return "Failed to search directories on this host.";
}
return "No directories match your search.";
}
return agentWorkingDirSuggestions.length > 0
? "No agent directories match your search."
: "No agent directories match your search.";
}, [agentWorkingDirSuggestions.length, hasWorkingDirectorySearch, workingDirSearchError]);
const displayWorkingDir = shortenPath(workingDir);
const worktreeTriggerValue =
worktreeMode === "create" ? "Create new worktree" : selectedWorktreeLabel || "Select worktree";
const worktreeComboOptions = useMemo(
() => [
{
id: "__none__",
label: "None",
},
{
id: "__create_new__",
label: "Create new worktree",
},
...worktreeOptions.map((option) => ({
id: option.path,
label: option.label,
description: shortenPath(option.path),
})),
],
[worktreeOptions],
);
const branchComboOptions = useMemo(() => {
const options = buildBranchComboOptions({
suggestedBranches: branchSuggestionsQuery.data ?? [],
currentBranch: checkout?.isGit ? checkout.currentBranch : null,
baseRef: checkout?.isGit ? checkout.baseRef : null,
typedBaseBranch: baseBranch,
worktreeBranchLabels: worktreeOptions.map((option) => option.label),
});
const normalizedQuery = normalizeBranchOptionName(branchSearchQuery)?.toLowerCase() ?? "";
if (!normalizedQuery) {
return options;
}
return options.sort((a, b) => {
const aLower = a.label.toLowerCase();
const bLower = b.label.toLowerCase();
const aPrefix = aLower.startsWith(normalizedQuery);
const bPrefix = bLower.startsWith(normalizedQuery);
if (aPrefix !== bPrefix) {
return aPrefix ? -1 : 1;
}
return aLower.localeCompare(bLower);
});
}, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]);
const createAgentClient = sessionClient;
const {
formErrorMessage,
isSubmitting,
optimisticStreamItems,
draftAgent,
handleCreateFromInput,
} = useDraftAgentCreateFlow<Agent, { id: string; workspaceId: string | null }>({
draftId: draftIdRef.current,
getPendingServerId: () => selectedServerId,
validateBeforeSubmit: ({ text }) => {
const trimmedPath = workingDir.trim();
if (!trimmedPath) {
return "Working directory is required";
}
if (isDirectoryNotExists) {
return "Working directory does not exist on the selected host";
}
if (!text.trim()) {
return "Initial prompt is required";
}
if (!selectedServerId) {
return "No host selected";
}
if (providerDefinitions.length === 0) {
return "No available providers on the selected host";
}
if (!composerState.selectedProvider) {
return "Select a model";
}
if (gitBlockingError) {
return gitBlockingError;
}
if (isModelLoading) {
return "Model defaults are still loading";
}
if (!effectiveModelId) {
return "No model is available for the selected provider";
}
if (isAttachWorktree && !selectedWorktreePath) {
return "Select a worktree to attach";
}
if (baseBranchError) {
return baseBranchError;
}
if (!createAgentClient) {
return "Host is not connected";
}
return null;
},
onBeforeSubmit: () => {
void persistFormPreferences();
if (isWeb) {
(document.activeElement as HTMLElement | null)?.blur?.();
}
Keyboard.dismiss();
},
onCreateStart: () => {
onCreateFlowActiveChange?.(true);
},
onCreateError: () => {
onCreateFlowActiveChange?.(false);
},
buildDraftAgent: (attempt) => {
const serverId = selectedServerId ?? "";
const now = attempt.timestamp;
const cwd =
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
".";
const provider = composerState.selectedProvider;
if (!provider) {
throw new Error("Select a model");
}
const model = effectiveModelId || null;
const thinkingOptionId = effectiveThinkingOptionId || null;
const modeId =
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? composerState.selectedMode
: null;
return {
serverId,
id: draftAgentIdRef.current,
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,
thinkingOptionId,
labels: {},
};
},
createRequest: async ({ attempt, text, images, attachments }) => {
const trimmedPath = workingDir.trim();
const resolvedWorkingDir =
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath;
const modeId =
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? composerState.selectedMode
: undefined;
const provider = composerState.selectedProvider;
if (!provider) {
throw new Error("Select a model");
}
const config: AgentSessionConfig = {
provider,
cwd: resolvedWorkingDir,
...(modeId ? { modeId } : {}),
...(effectiveModelId ? { model: effectiveModelId } : {}),
...(effectiveThinkingOptionId ? { thinkingOptionId: effectiveThinkingOptionId } : {}),
};
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;
const client = createAgentClient;
if (!client) {
throw new Error("Host is not connected");
}
const imagesData = await encodeImages(images);
const result = await client.createAgent({
config,
initialPrompt: text,
clientMessageId: attempt.clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachments && attachments.length > 0 ? { attachments } : {}),
git: gitOptions,
});
if (!result.id || !selectedServerId) {
throw new Error("Failed to create agent");
}
useSessionStore.getState().setAgents(selectedServerId, (prev) => {
const next = new Map(prev);
next.set(result.id, normalizeAgentSnapshot(result, selectedServerId));
return next;
});
const createdWorkingDir = typeof result.cwd === "string" ? result.cwd.trim() : "";
const configuredWorkingDir = config.cwd.trim();
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: useSessionStore.getState().sessions[selectedServerId]?.workspaces?.values(),
workspaceDirectory: createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir,
});
return {
agentId: result.id,
result: {
id: result.id,
workspaceId,
},
};
},
onCreateSuccess: ({ result }) => {
if (!result.workspaceId) {
router.replace(buildHostAgentDetailRoute(selectedServerId as string, result.id) as any);
return;
}
const route = prepareWorkspaceTab({
serverId: selectedServerId as string,
workspaceId: result.workspaceId,
target: { kind: "agent", agentId: result.id },
});
router.replace(route as any);
},
});
useEffect(() => {
if (!isFocused || !draftExplorerCheckout) {
return;
}
activateExplorerTabForCheckout(draftExplorerCheckout);
}, [activateExplorerTabForCheckout, draftExplorerCheckout, isFocused]);
useEffect(() => {
if (!isFocused || canOpenExplorer || !isExplorerOpen) {
return;
}
if (isMobile) {
showMobileAgent();
return;
}
closeDesktopFileExplorer();
}, [
canOpenExplorer,
closeDesktopFileExplorer,
isExplorerOpen,
isFocused,
isMobile,
showMobileAgent,
]);
if (daemons.length === 0) {
return (
<WelcomeScreen
onHostAdded={(profile) => {
setSelectedServerIdFromUser(profile.serverId);
}}
/>
);
}
const explorerServerId = draftExplorerCheckout?.serverId ?? null;
const explorerIsGit = draftExplorerCheckout?.isGit ?? false;
const mainContent = (
<View style={styles.container}>
<TitlebarDragRegion />
<View style={styles.outerContainer}>
<View style={styles.agentPanel}>
<View
style={[
styles.menuToggleContainer,
isMobile ? { paddingTop: insets.top + theme.spacing[2] } : null,
]}
>
<View style={styles.menuToggleRow}>
<SidebarMenuToggle />
{!isMobile && canOpenExplorer ? (
<HeaderToggleButton
onPress={handleToggleExplorer}
tooltipLabel="Toggle explorer"
tooltipKeys={["mod", "E"]}
tooltipSide="left"
style={styles.menuButton}
accessible
accessibilityRole="button"
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
accessibilityState={{ expanded: isExplorerOpen }}
>
<PanelRight
size={theme.iconSize.md}
color={isExplorerOpen ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
</HeaderToggleButton>
) : null}
</View>
</View>
<Animated.View style={[styles.contentContainer, animatedKeyboardStyle]}>
{isSubmitting && draftAgent && selectedServerId ? (
<View style={styles.streamContainer}>
<AgentStreamView
agentId={draftAgentIdRef.current}
serverId={selectedServerId}
agent={draftAgent}
streamItems={optimisticStreamItems}
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
/>
</View>
) : (
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.configScrollContent}
>
<View style={styles.configSection}>
<View style={isMobile ? styles.stackedSelectorGroup : styles.topSelectorRow}>
<FormSelectTrigger
controlRef={workingDirAnchorRef}
containerStyle={styles.fullSelector}
label="Working directory"
value={displayWorkingDir}
placeholder="Choose a working directory"
onPress={() => setIsWorkingDirOpen(true)}
icon={
<Folder size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
}
showLabel={false}
valueEllipsizeMode="middle"
testID="working-directory-select"
/>
</View>
{isDirectoryNotExists && (
<View style={styles.warningContainer}>
<Text style={styles.warningText}>
Directory does not exist on the selected host
</Text>
</View>
)}
{isMobile && trimmedWorkingDir.length > 0 && !isNonGitDirectory ? (
<View style={styles.formSeparator} />
) : null}
{trimmedWorkingDir.length > 0 && !isNonGitDirectory ? (
<View style={isMobile ? styles.stackedSelectorGroup : styles.topSelectorRow}>
<FormSelectTrigger
controlRef={worktreeAnchorRef}
containerStyle={
isMobile
? styles.fullSelector
: worktreeMode === "create"
? styles.halfSelector
: styles.topSelectorPrimary
}
label="Worktree"
value={worktreeTriggerValue}
placeholder="Select worktree"
onPress={() => setIsWorktreePickerOpen(true)}
icon={
<GitBranch
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
}
showLabel={false}
valueEllipsizeMode="middle"
testID="worktree-select-trigger"
/>
{worktreeMode === "create" ? (
<FormSelectTrigger
controlRef={branchAnchorRef}
containerStyle={isMobile ? styles.fullSelector : styles.halfSelector}
label="Base branch"
value={baseBranch}
placeholder="From branch"
onPress={() => setIsBranchOpen(true)}
disabled={repoInfoStatus === "loading"}
icon={
<GitBranch
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
}
showLabel={false}
testID="worktree-base-branch-trigger"
/>
) : null}
</View>
) : null}
{baseBranchError ? (
<Text style={styles.errorInlineText}>{baseBranchError}</Text>
) : null}
{repoInfoError ? (
<Text style={styles.errorInlineText}>{repoInfoError}</Text>
) : null}
{gitBlockingError ? (
<Text style={styles.errorInlineText}>{gitBlockingError}</Text>
) : null}
{attachWorktreeError ? (
<Text style={styles.errorInlineText}>{attachWorktreeError}</Text>
) : null}
{worktreeOptionsError ? (
<Text style={styles.errorInlineText}>{worktreeOptionsError}</Text>
) : null}
</View>
<Combobox
options={worktreeComboOptions}
value={
worktreeMode === "create"
? "__create_new__"
: worktreeMode === "attach"
? selectedWorktreePath
: "__none__"
}
onSelect={(id) => {
if (id === "__create_new__") {
setWorktreeMode("create");
if (!worktreeSlug) {
setWorktreeSlug(createNameId());
}
setSelectedWorktreePath("");
return;
}
if (id === "__none__") {
setWorktreeMode("none");
setSelectedWorktreePath("");
return;
}
handleSelectWorktreePath(id);
setWorktreeMode("attach");
}}
title="Select worktree"
searchPlaceholder="Search worktrees..."
open={isWorktreePickerOpen}
onOpenChange={setIsWorktreePickerOpen}
emptyText="No worktrees found"
anchorRef={worktreeAnchorRef}
/>
<Combobox
options={workingDirComboOptions}
value={workingDir}
onSelect={setWorkingDirFromUser}
onSearchQueryChange={setWorkingDirSearchQuery}
searchPlaceholder="Search directories..."
emptyText={workingDirEmptyText}
allowCustomValue
customValuePrefix=""
customValueKind="directory"
optionsPosition="above-search"
title="Working directory"
open={isWorkingDirOpen}
onOpenChange={setIsWorkingDirOpen}
anchorRef={workingDirAnchorRef}
/>
<Combobox
options={branchComboOptions}
value={baseBranch}
onSelect={handleBaseBranchChange}
onSearchQueryChange={setBranchSearchQuery}
searchPlaceholder="Choose a base branch..."
allowCustomValue
customValuePrefix="Use"
customValueDescription="Use this branch name"
title="Select base branch"
open={isBranchOpen}
onOpenChange={(nextOpen) => {
setIsBranchOpen(nextOpen);
if (!nextOpen) {
setBranchSearchQuery("");
}
}}
anchorRef={branchAnchorRef}
/>
{formErrorMessage ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{formErrorMessage}</Text>
</View>
) : null}
</ScrollView>
)}
</Animated.View>
<View style={[styles.inputAreaWrapper, { paddingBottom: insets.bottom }]}>
<Composer
agentId={draftAgentIdRef.current}
serverId={selectedServerId ?? ""}
isPaneFocused={isFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
value={draftInput.text}
onChangeText={draftInput.setText}
attachments={draftInput.attachments}
onChangeAttachments={draftInput.setAttachments}
cwd={draftInput.cwd}
clearDraft={draftInput.clear}
autoFocus={!isSubmitting}
onAddImages={handleAddImagesCallback}
commandDraftConfig={commandDraftConfig}
statusControls={{
...statusControls,
disabled: isSubmitting,
}}
/>
</View>
</View>
{!isMobile && isExplorerOpen && explorerServerId && draftExplorerCheckout ? (
<ExplorerSidebar
serverId={explorerServerId}
workspaceId={draftExplorerWorkspaceId}
workspaceRoot={draftExplorerCheckout.cwd}
isGit={explorerIsGit}
/>
) : null}
</View>
</View>
);
return (
<FileDropZone onFilesDropped={handleFilesDropped}>
<>
{isMobile ? (
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
{mainContent}
</GestureDetector>
) : (
mainContent
)}
{isMobile && explorerServerId && draftExplorerCheckout ? (
<ExplorerSidebar
serverId={explorerServerId}
workspaceId={draftExplorerWorkspaceId}
workspaceRoot={draftExplorerCheckout.cwd}
isGit={explorerIsGit}
/>
) : null}
</>
</FileDropZone>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
position: "relative",
flex: 1,
backgroundColor: theme.colors.surface0,
},
outerContainer: {
flex: 1,
flexDirection: "row",
},
agentPanel: {
flex: 1,
},
menuToggleContainer: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
},
menuToggleRow: {
width: "100%",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
menuButton: {
marginLeft: theme.spacing[2],
},
contentContainer: {
flex: 1,
overflow: "hidden",
},
scrollView: {
flex: 1,
},
inputAreaWrapper: {
backgroundColor: theme.colors.surface0,
},
streamContainer: {
flex: 1,
},
configScrollContent: {
flexGrow: 1,
justifyContent: "flex-end",
},
configSection: {
paddingHorizontal: {
xs: theme.spacing[4],
md: theme.spacing[0],
},
paddingTop: theme.spacing[3],
paddingBottom: theme.spacing[4],
gap: theme.spacing[2],
maxWidth: MAX_CONTENT_WIDTH,
alignSelf: "center",
width: "100%",
},
topSelectorRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
stackedSelectorGroup: {
gap: theme.spacing[2],
},
fullSelector: {
width: "100%",
},
formSeparator: {
height: theme.borderWidth[1],
backgroundColor: {
xs: theme.colors.surface1,
sm: theme.colors.surface1,
md: theme.colors.border,
},
marginHorizontal: theme.spacing[1],
marginVertical: theme.spacing[2],
},
topSelectorPrimary: {
flex: 7,
},
topSelectorSecondary: {
flex: 3,
},
halfSelector: {
flex: 1,
},
errorContainer: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
marginHorizontal: theme.spacing[0],
marginBottom: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.destructive,
},
errorText: {
color: theme.colors.destructiveForeground,
fontSize: theme.fontSize.base,
},
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.base,
fontWeight: theme.fontWeight.semibold,
},
errorInlineText: {
color: theme.colors.palette.red[500],
fontSize: theme.fontSize.base,
},
}));