diff --git a/packages/app/src/app/agent/new.tsx b/packages/app/src/app/agent/new.tsx index a210cfb02..dc26827b2 100644 --- a/packages/app/src/app/agent/new.tsx +++ b/packages/app/src/app/agent/new.tsx @@ -14,10 +14,13 @@ import { AgentInputArea } from "@/components/agent-input-area"; import { AssistantDropdown, DropdownSheet, + GitOptionsSection, ModelDropdown, PermissionsDropdown, WorkingDirectoryDropdown, } from "@/components/agent-form/agent-form-dropdowns"; +import { useDaemonRequest } from "@/hooks/use-daemon-request"; +import type { SessionOutboundMessage } from "@server/server/messages"; import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; import { useAgentFormState } from "@/hooks/use-agent-form-state"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; @@ -117,12 +120,20 @@ export default function DraftAgentScreen() { : undefined; const [openDropdown, setOpenDropdown] = useState< - "host" | "provider" | "mode" | "model" | "workingDir" | null + "host" | "provider" | "mode" | "model" | "workingDir" | "baseBranch" | null >(null); const [errorMessage, setErrorMessage] = useState(""); const [isLoading, setIsLoading] = useState(false); + 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 shouldSyncBaseBranchRef = useRef(true); const openDropdownSheet = useCallback( - (key: "host" | "provider" | "mode" | "model" | "workingDir") => { + (key: "host" | "provider" | "mode" | "model" | "workingDir" | "baseBranch") => { setOpenDropdown(key); }, [] @@ -145,6 +156,272 @@ export default function DraftAgentScreen() { }); return Array.from(uniquePaths).sort(); }, [selectedServerId, sessionAgents]); + + const sessionWs = useSessionStore((state) => + selectedServerId ? state.sessions[selectedServerId]?.ws : undefined + ); + const inertWebSocket = useMemo( + () => ({ + isConnected: false, + isConnecting: false, + conversationId: null, + lastError: null, + send: () => {}, + on: () => () => {}, + sendPing: () => {}, + sendUserMessage: () => {}, + clearAgentAttention: () => {}, + subscribeConnectionStatus: () => () => {}, + getConnectionState: () => ({ isConnected: false, isConnecting: false }), + }), + [] + ); + const effectiveWs = sessionWs ?? inertWebSocket; + const isWsConnected = effectiveWs.getConnectionState + ? effectiveWs.getConnectionState().isConnected + : effectiveWs.isConnected; + + type RepoInfoState = { + cwd: string; + repoRoot: string; + branches: Array<{ name: string; isCurrent: boolean }>; + currentBranch: string | null; + isDirty: boolean; + }; + type GitRepoInfoResponseMessage = Extract< + SessionOutboundMessage, + { type: "git_repo_info_response" } + >; + const gitRepoInfoRequest = useDaemonRequest< + { cwd: string }, + RepoInfoState, + GitRepoInfoResponseMessage + >({ + ws: effectiveWs, + responseType: "git_repo_info_response", + buildRequest: ({ params, requestId }) => ({ + type: "session", + message: { + type: "git_repo_info_request", + cwd: params?.cwd ?? ".", + requestId, + }, + }), + getRequestKey: (params) => params?.cwd ?? "default", + selectData: (message) => ({ + cwd: message.payload.cwd, + repoRoot: message.payload.repoRoot, + branches: message.payload.branches ?? [], + currentBranch: message.payload.currentBranch ?? null, + isDirty: Boolean(message.payload.isDirty), + }), + extractError: (message) => + message.payload.error ? new Error(message.payload.error) : null, + keepPreviousData: false, + }); + const { + status: repoRequestStatus, + data: repoInfo, + error: repoRequestError, + execute: inspectRepoInfo, + reset: resetRepoInfo, + cancel: cancelRepoInfo, + } = gitRepoInfoRequest; + + const trimmedWorkingDir = workingDir.trim(); + const shouldInspectRepo = trimmedWorkingDir.length > 0; + const daemonAvailabilityError = + !selectedServerId || hostEntry?.status !== "online" + ? "Host is offline" + : null; + const repoAvailabilityError = + shouldInspectRepo && (!hostEntry || hostEntry.status !== "online" || !isWsConnected) + ? daemonAvailabilityError ?? + "Repository details will load automatically once the selected host is back online." + : null; + const isNonGitDirectory = + repoRequestStatus === "error" && + /not in a git repository/i.test(repoRequestError?.message ?? ""); + const repoInfoStatus: "idle" | "loading" | "ready" | "error" = !shouldInspectRepo + ? "idle" + : repoAvailabilityError + ? "error" + : repoRequestStatus === "loading" + ? "loading" + : repoRequestStatus === "error" + ? isNonGitDirectory + ? "idle" + : "error" + : repoRequestStatus === "success" + ? "ready" + : "idle"; + const repoInfoError = + repoAvailabilityError ?? (isNonGitDirectory ? null : repoRequestError?.message ?? null); + const gitHelperText = isNonGitDirectory + ? "No git repository detected. Git options are disabled for this directory." + : null; + + const slugifyWorktreeName = useCallback((input: string): string => { + return input + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + }, []); + + const validateWorktreeName = useCallback( + (name: string): { valid: boolean; error?: string } => { + if (!name) { + return { valid: true }; + } + if (name.length > 100) { + return { + valid: false, + error: "Worktree name too long (max 100 characters)", + }; + } + if (!/^[a-z0-9-/]+$/.test(name)) { + return { + valid: false, + error: "Must contain only lowercase letters, numbers, hyphens, and forward slashes", + }; + } + if (name.startsWith("-") || name.endsWith("-")) { + return { valid: false, error: "Cannot start or end with a hyphen" }; + } + if (name.includes("--")) { + return { valid: false, error: "Cannot have consecutive hyphens" }; + } + return { valid: true }; + }, + [] + ); + + const gitBlockingError = useMemo(() => { + if (isNonGitDirectory) { + return null; + } + const trimmedBase = baseBranch.trim(); + const currentBranch = repoInfo?.currentBranch ?? ""; + const isCustomBase = + trimmedBase.length > 0 && + (currentBranch.length === 0 || trimmedBase !== currentBranch); + const requiresBase = createNewBranch || createWorktree || isCustomBase; + + if (requiresBase && !trimmedBase) { + return "Select a base branch before launching the agent"; + } + + if (createNewBranch) { + const slug = branchName.trim(); + const validation = validateWorktreeName(slug); + if (!slug || !validation.valid) { + return `Invalid branch name: ${ + validation.error ?? "Must use lowercase letters, numbers, hyphens, or forward slashes" + }`; + } + } + + if (createWorktree) { + const slug = (worktreeSlug || branchName).trim(); + const validation = validateWorktreeName(slug); + if (!slug || !validation.valid) { + return `Invalid worktree name: ${ + validation.error ?? "Must use lowercase letters, numbers, or hyphens" + }`; + } + } + + if (!createWorktree && repoInfo?.isDirty) { + const intendsCheckout = + createNewBranch || + (trimmedBase.length > 0 && trimmedBase !== repoInfo.currentBranch); + if (intendsCheckout) { + return "Working directory has uncommitted changes. Clean up or create a worktree first."; + } + } + + return null; + }, [ + baseBranch, + branchName, + createNewBranch, + createWorktree, + isNonGitDirectory, + repoInfo, + validateWorktreeName, + worktreeSlug, + ]); + + useEffect(() => { + if (!shouldInspectRepo) { + cancelRepoInfo(); + resetRepoInfo(); + return; + } + if (repoAvailabilityError) { + cancelRepoInfo(); + return; + } + inspectRepoInfo({ cwd: trimmedWorkingDir }).catch(() => {}); + return () => { + cancelRepoInfo(); + }; + }, [ + cancelRepoInfo, + inspectRepoInfo, + repoAvailabilityError, + resetRepoInfo, + shouldInspectRepo, + trimmedWorkingDir, + ]); + + useEffect(() => { + if (!repoInfo) { + return; + } + setBaseBranch((prev) => { + if (shouldSyncBaseBranchRef.current || prev.trim().length === 0) { + shouldSyncBaseBranchRef.current = false; + return repoInfo.currentBranch ?? ""; + } + return prev; + }); + }, [repoInfo]); + + useEffect(() => { + if (!isNonGitDirectory) { + return; + } + if ( + 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 handleBaseBranchChange = useCallback((value: string) => { + setBaseBranch(value); + shouldSyncBaseBranchRef.current = false; + }, []); + const renderConfigRow = useCallback( ({ label, value, meta, onPress, disabled }: ConfigRowProps) => ( selectedServerId ? state.sessions[selectedServerId]?.methods : undefined ); - const sessionWs = useSessionStore((state) => - selectedServerId ? state.sessions[selectedServerId]?.ws : undefined - ); const handleCreateFromInput = useCallback( async ({ @@ -225,6 +499,10 @@ export default function DraftAgentScreen() { setErrorMessage("No host selected"); return; } + if (gitBlockingError) { + setErrorMessage(gitBlockingError); + return; + } if (isLoading) { return; } @@ -247,17 +525,46 @@ export default function DraftAgentScreen() { if (images && images.length > 0) { console.warn("[DraftAgentScreen] Image attachments on agent creation not yet supported"); } + + const trimmedBaseBranch = baseBranch.trim(); + const shouldIncludeBase = + trimmedBaseBranch.length > 0 || + createNewBranch || + createWorktree; + const gitOptions = + shouldIncludeBase && !isNonGitDirectory + ? { + ...(trimmedBaseBranch ? { baseBranch: trimmedBaseBranch } : {}), + ...(createNewBranch + ? { createNewBranch: true, newBranchName: branchName.trim() } + : {}), + ...(createWorktree + ? { + createWorktree: true, + worktreeSlug: (worktreeSlug || branchName).trim(), + } + : {}), + } + : undefined; + const requestId = generateMessageId(); pendingRequestIdRef.current = requestId; setIsLoading(true); createAgent({ config, initialPrompt: trimmedPrompt, + git: gitOptions, requestId, }); }, [ + baseBranch, + branchName, + createNewBranch, + createWorktree, + gitBlockingError, isLoading, + isNonGitDirectory, modeOptions, selectedMode, selectedModel, @@ -265,6 +572,7 @@ export default function DraftAgentScreen() { selectedServerId, sessionMethods, workingDir, + worktreeSlug, ] ); @@ -418,6 +726,64 @@ export default function DraftAgentScreen() { wrapInContainer={false} renderTrigger={renderDropdownTrigger} /> + {trimmedWorkingDir.length > 0 ? ( + { + setCreateNewBranch(next); + if (next) { + if (!branchNameEdited) { + const slug = slugifyWorktreeName(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( + 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} + /> + ) : null} void; + disabled?: boolean; + errorMessage?: string | null; + warningMessage?: string | null; + helperText?: string | null; +}; + +type DropdownTriggerRenderer = (props: DropdownTriggerRenderProps) => ReactNode; + +interface DropdownFieldProps { + label: string; + value: string; + placeholder: string; + onPress: () => void; + disabled?: boolean; + errorMessage?: string | null; + warningMessage?: string | null; + helperText?: string | null; + renderTrigger?: DropdownTriggerRenderer; +} + +export function DropdownField({ + label, + value, + placeholder, + onPress, + disabled, + errorMessage, + warningMessage, + helperText, + renderTrigger, +}: DropdownFieldProps): ReactElement { + if (renderTrigger) { + return ( + <> + {renderTrigger({ + label, + value, + placeholder, + onPress, + disabled, + errorMessage, + warningMessage, + helperText, + })} + + ); + } + + return ( + + {label} + + + {value || placeholder} + + + + {errorMessage ? {errorMessage} : null} + {warningMessage ? {warningMessage} : null} + {!errorMessage && helperText ? ( + {helperText} + ) : null} + + ); +} + +interface DropdownSheetProps { + title: string; + visible: boolean; + onClose: () => void; + children: ReactNode; +} + +export function DropdownSheet({ + title, + visible, + onClose, + children, +}: DropdownSheetProps): ReactElement { + return ( + + + + + + {title} + + {children} + + + + + ); +} + +interface AssistantDropdownProps { + providerDefinitions: AgentProviderDefinition[]; + selectedProvider: AgentProvider; + disabled: boolean; + isOpen: boolean; + onOpen: () => void; + onClose: () => void; + onSelect: (provider: AgentProvider) => void; + label?: string; + placeholder?: string; + sheetTitle?: string; + renderTrigger?: DropdownTriggerRenderer; + wrapInContainer?: boolean; +} + +export function AssistantDropdown({ + providerDefinitions, + selectedProvider, + disabled, + isOpen, + onOpen, + onClose, + onSelect, + label = "Assistant", + placeholder = "Select assistant", + sheetTitle = "Choose Assistant", + renderTrigger, + wrapInContainer = true, +}: AssistantDropdownProps): ReactElement { + const selectedDefinition = providerDefinitions.find( + (definition) => definition.id === selectedProvider + ); + const field = ( + <> + + + {providerDefinitions.map((definition) => { + const isSelected = definition.id === selectedProvider; + return ( + { + onSelect(definition.id); + onClose(); + }} + > + {definition.label} + {definition.description ? ( + + {definition.description} + + ) : null} + + ); + })} + + + ); + + if (!wrapInContainer) { + return <>{field}; + } + + return {field}; +} + +interface PermissionsDropdownProps { + modeOptions: AgentMode[]; + selectedMode: string; + disabled: boolean; + isOpen: boolean; + onOpen: () => void; + onClose: () => void; + onSelect: (modeId: string) => void; + label?: string; + placeholder?: string; + sheetTitle?: string; + renderTrigger?: DropdownTriggerRenderer; + wrapInContainer?: boolean; +} + +export function PermissionsDropdown({ + modeOptions, + selectedMode, + disabled, + isOpen, + onOpen, + onClose, + onSelect, + label = "Permissions", + placeholder, + sheetTitle = "Permissions", + renderTrigger, + wrapInContainer = true, +}: PermissionsDropdownProps): ReactElement { + const hasOptions = modeOptions.length > 0; + const selectedModeLabel = hasOptions + ? modeOptions.find((mode) => mode.id === selectedMode)?.label ?? + modeOptions[0]?.label ?? + "Default" + : "Automatic"; + const placeholderLabel = hasOptions + ? placeholder ?? "Select permissions" + : "Automatic"; + const field = ( + <> + {}} + disabled={disabled || !hasOptions} + helperText={ + hasOptions + ? undefined + : "This assistant does not expose selectable permissions." + } + renderTrigger={renderTrigger} + /> + {hasOptions ? ( + + {modeOptions.map((mode) => { + const isSelected = mode.id === selectedMode; + return ( + { + onSelect(mode.id); + onClose(); + }} + > + {mode.label} + {mode.description ? ( + + {mode.description} + + ) : null} + + ); + })} + + ) : null} + + ); + + if (!wrapInContainer) { + return <>{field}; + } + + return {field}; +} + +interface ModelDropdownProps { + models: AgentModelDefinition[]; + selectedModel: string; + isLoading: boolean; + error: string | null; + isOpen: boolean; + onOpen: () => void; + onClose: () => void; + onSelect: (modelId: string) => void; + onClear: () => void; + onRefresh: () => void; + label?: string; + renderTrigger?: DropdownTriggerRenderer; + wrapInContainer?: boolean; +} + +export function ModelDropdown({ + models, + selectedModel, + isLoading, + error, + isOpen, + onOpen, + onClose, + onSelect, + onClear, + onRefresh, + label = "Model", + renderTrigger, + wrapInContainer = true, +}: ModelDropdownProps): ReactElement { + const selectedLabel = selectedModel + ? models.find((model) => model.id === selectedModel)?.label ?? selectedModel + : "Automatic"; + const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic"; + const helperText = error + ? undefined + : isLoading + ? "Fetching available models..." + : models.length === 0 + ? "This assistant did not expose selectable models." + : undefined; + + const field = ( + <> + + + { + onClear(); + onClose(); + }} + > + + Automatic (provider default) + + + Let the assistant pick the recommended model. + + + {models.map((model) => { + const isSelected = model.id === selectedModel; + return ( + { + onSelect(model.id); + onClose(); + }} + > + {model.label} + {model.description ? ( + + {model.description} + + ) : null} + + ); + })} + { + onRefresh(); + }} + > + Refresh models + + Request the latest catalog from the provider. + + + {isLoading ? ( + + + + ) : null} + + + ); + + if (!wrapInContainer) { + return <>{field}; + } + + return {field}; +} + +interface WorkingDirectoryDropdownProps { + workingDir: string; + errorMessage: string; + isOpen: boolean; + onOpen: () => void; + onClose: () => void; + disabled: boolean; + suggestedPaths: string[]; + onSelectPath: (value: string) => void; + label?: string; + renderTrigger?: DropdownTriggerRenderer; + wrapInContainer?: boolean; +} + +export function WorkingDirectoryDropdown({ + workingDir, + errorMessage, + isOpen, + onOpen, + onClose, + disabled, + suggestedPaths, + onSelectPath, + label = "Working Directory", + renderTrigger, + wrapInContainer = true, +}: WorkingDirectoryDropdownProps): ReactElement { + const inputRef = useRef(null); + const [searchQuery, setSearchQuery] = useState(""); + + useEffect(() => { + if (isOpen) { + setSearchQuery(""); + inputRef.current?.focus(); + } + }, [isOpen]); + + const normalizedSearch = searchQuery.trim().toLowerCase(); + const filteredPaths = useMemo(() => { + if (!normalizedSearch) { + return suggestedPaths; + } + return suggestedPaths.filter((path) => + path.toLowerCase().includes(normalizedSearch) + ); + }, [suggestedPaths, normalizedSearch]); + + const hasSuggestedPaths = suggestedPaths.length > 0; + const hasMatches = filteredPaths.length > 0; + const sanitizedSearchValue = searchQuery.trim(); + const showCustomOption = sanitizedSearchValue.length > 0; + + const handleSelect = useCallback( + (path: string) => { + onSelectPath(path); + onClose(); + }, + [onClose, onSelectPath] + ); + + const field = ( + <> + + + + {!hasSuggestedPaths && !showCustomOption ? ( + + We'll suggest directories from agents on this host once they exist. + + ) : null} + {showCustomOption ? ( + + handleSelect(sanitizedSearchValue)} + > + + {`Use "${sanitizedSearchValue}"`} + + + Launch the agent in this directory + + + + ) : null} + {hasMatches ? ( + + {filteredPaths.map((path) => { + const isActive = path === workingDir; + return ( + handleSelect(path)} + > + + {path} + + + ); + })} + + ) : hasSuggestedPaths ? ( + + No agent directories match your search. + + ) : null} + + + ); + + if (!wrapInContainer) { + return <>{field}; + } + + return {field}; +} + +interface ToggleRowProps { + label: string; + description?: string; + value: boolean; + onToggle: (value: boolean) => void; + disabled?: boolean; +} + +export function ToggleRow({ + label, + description, + value, + onToggle, + disabled, +}: ToggleRowProps): ReactElement { + return ( + { + if (!disabled) { + onToggle(!value); + } + }} + style={[styles.toggleRow, disabled && styles.toggleRowDisabled]} + > + + {value ? : null} + + + {label} + {description ? ( + {description} + ) : null} + + + ); +} + +export 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; +} + +export 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(null); + const gitInputsDisabled = Boolean(isGitDisabled) || status === "loading"; + + useEffect(() => { + if (isBaseDropdownOpen) { + setBranchSearch(""); + baseInputRef.current?.focus(); + } + }, [isBaseDropdownOpen]); + + return ( + + Git Setup + + Choose a base branch, then optionally create a feature branch or + isolated worktree. + + + + + + {status === "loading" ? ( + + + Inspecting repository… + + ) : filteredBranches.length === 0 ? ( + + {branchFilter.length === 0 + ? "No branches detected yet." + : "No branches match your search."} + + ) : ( + + {filteredBranches.slice(0, maxVisible).map((branch) => { + const isActive = branch.name === baseBranch; + return ( + { + onBaseBranchChange(branch.name); + onCloseDropdown(); + }} + > + + {branch.name} + {branch.isCurrent ? " (current)" : ""} + + + ); + })} + + )} + {filteredBranches.length > maxVisible ? ( + + Showing first {maxVisible} matches. Keep typing to narrow it down. + + ) : null} + + + + {createNewBranch ? ( + + ) : null} + + + {createWorktree ? ( + + ) : null} + + {gitValidationError ? ( + {gitValidationError} + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + formSection: { + gap: theme.spacing[3], + }, + label: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, + 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, + }, + 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[6], + 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], + }, + errorText: { + color: theme.colors.palette.red[500], + fontSize: theme.fontSize.sm, + }, + warningText: { + color: theme.colors.palette.orange[500], + fontSize: theme.fontSize.sm, + }, + helperText: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.sm, + }, + 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, + }, + 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, + }, + dropdownLoading: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, +}));