Add Git Options Section to new agent page
- Added GitOptionsSection and ToggleRow components to agent-form-dropdowns.tsx - Added useDaemonRequest hook for git_repo_info_request in new.tsx - Added git-related state: baseBranch, createNewBranch, branchName, createWorktree, worktreeSlug - Added git validation logic: isNonGitDirectory, gitBlockingError, validateWorktreeName - Updated handleCreateFromInput to include git options in createAgent call - Git section only shows when working directory is set - Auto-populates base branch from current branch - Shows dirty directory warning - Validates branch/worktree names 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
bd7752b4f4
commit
54f0583334
@@ -14,10 +14,13 @@ import { AgentInputArea } from "@/components/agent-input-area";
|
|||||||
import {
|
import {
|
||||||
AssistantDropdown,
|
AssistantDropdown,
|
||||||
DropdownSheet,
|
DropdownSheet,
|
||||||
|
GitOptionsSection,
|
||||||
ModelDropdown,
|
ModelDropdown,
|
||||||
PermissionsDropdown,
|
PermissionsDropdown,
|
||||||
WorkingDirectoryDropdown,
|
WorkingDirectoryDropdown,
|
||||||
} from "@/components/agent-form/agent-form-dropdowns";
|
} 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 { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||||
import { useAgentFormState } from "@/hooks/use-agent-form-state";
|
import { useAgentFormState } from "@/hooks/use-agent-form-state";
|
||||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||||
@@ -117,12 +120,20 @@ export default function DraftAgentScreen() {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const [openDropdown, setOpenDropdown] = useState<
|
const [openDropdown, setOpenDropdown] = useState<
|
||||||
"host" | "provider" | "mode" | "model" | "workingDir" | null
|
"host" | "provider" | "mode" | "model" | "workingDir" | "baseBranch" | null
|
||||||
>(null);
|
>(null);
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
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(
|
const openDropdownSheet = useCallback(
|
||||||
(key: "host" | "provider" | "mode" | "model" | "workingDir") => {
|
(key: "host" | "provider" | "mode" | "model" | "workingDir" | "baseBranch") => {
|
||||||
setOpenDropdown(key);
|
setOpenDropdown(key);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
@@ -145,6 +156,272 @@ export default function DraftAgentScreen() {
|
|||||||
});
|
});
|
||||||
return Array.from(uniquePaths).sort();
|
return Array.from(uniquePaths).sort();
|
||||||
}, [selectedServerId, sessionAgents]);
|
}, [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(
|
const renderConfigRow = useCallback(
|
||||||
({ label, value, meta, onPress, disabled }: ConfigRowProps) => (
|
({ label, value, meta, onPress, disabled }: ConfigRowProps) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -198,9 +475,6 @@ export default function DraftAgentScreen() {
|
|||||||
const sessionMethods = useSessionStore((state) =>
|
const sessionMethods = useSessionStore((state) =>
|
||||||
selectedServerId ? state.sessions[selectedServerId]?.methods : undefined
|
selectedServerId ? state.sessions[selectedServerId]?.methods : undefined
|
||||||
);
|
);
|
||||||
const sessionWs = useSessionStore((state) =>
|
|
||||||
selectedServerId ? state.sessions[selectedServerId]?.ws : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCreateFromInput = useCallback(
|
const handleCreateFromInput = useCallback(
|
||||||
async ({
|
async ({
|
||||||
@@ -225,6 +499,10 @@ export default function DraftAgentScreen() {
|
|||||||
setErrorMessage("No host selected");
|
setErrorMessage("No host selected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (gitBlockingError) {
|
||||||
|
setErrorMessage(gitBlockingError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -247,17 +525,46 @@ export default function DraftAgentScreen() {
|
|||||||
if (images && images.length > 0) {
|
if (images && images.length > 0) {
|
||||||
console.warn("[DraftAgentScreen] Image attachments on agent creation not yet supported");
|
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();
|
const requestId = generateMessageId();
|
||||||
pendingRequestIdRef.current = requestId;
|
pendingRequestIdRef.current = requestId;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
createAgent({
|
createAgent({
|
||||||
config,
|
config,
|
||||||
initialPrompt: trimmedPrompt,
|
initialPrompt: trimmedPrompt,
|
||||||
|
git: gitOptions,
|
||||||
requestId,
|
requestId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
baseBranch,
|
||||||
|
branchName,
|
||||||
|
createNewBranch,
|
||||||
|
createWorktree,
|
||||||
|
gitBlockingError,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isNonGitDirectory,
|
||||||
modeOptions,
|
modeOptions,
|
||||||
selectedMode,
|
selectedMode,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
@@ -265,6 +572,7 @@ export default function DraftAgentScreen() {
|
|||||||
selectedServerId,
|
selectedServerId,
|
||||||
sessionMethods,
|
sessionMethods,
|
||||||
workingDir,
|
workingDir,
|
||||||
|
worktreeSlug,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -418,6 +726,64 @@ export default function DraftAgentScreen() {
|
|||||||
wrapInContainer={false}
|
wrapInContainer={false}
|
||||||
renderTrigger={renderDropdownTrigger}
|
renderTrigger={renderDropdownTrigger}
|
||||||
/>
|
/>
|
||||||
|
{trimmedWorkingDir.length > 0 ? (
|
||||||
|
<GitOptionsSection
|
||||||
|
baseBranch={baseBranch}
|
||||||
|
onBaseBranchChange={handleBaseBranchChange}
|
||||||
|
branches={repoInfo?.branches ?? []}
|
||||||
|
status={repoInfoStatus}
|
||||||
|
repoError={repoInfoError}
|
||||||
|
helperText={gitHelperText}
|
||||||
|
isGitDisabled={Boolean(gitHelperText)}
|
||||||
|
warning={
|
||||||
|
!createWorktree && repoInfo?.isDirty
|
||||||
|
? "Working directory has uncommitted changes"
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
createNewBranch={createNewBranch}
|
||||||
|
onToggleCreateNewBranch={(next) => {
|
||||||
|
setCreateNewBranch(next);
|
||||||
|
if (next) {
|
||||||
|
if (!branchNameEdited) {
|
||||||
|
const slug = slugifyWorktreeName(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}
|
||||||
</View>
|
</View>
|
||||||
<DropdownSheet
|
<DropdownSheet
|
||||||
title="Host"
|
title="Host"
|
||||||
|
|||||||
@@ -0,0 +1,991 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { ReactElement, ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Modal,
|
||||||
|
TextInput,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { ChevronDown } from "lucide-react-native";
|
||||||
|
import { theme as defaultTheme } from "@/styles/theme";
|
||||||
|
import type {
|
||||||
|
AgentMode,
|
||||||
|
AgentModelDefinition,
|
||||||
|
AgentProvider,
|
||||||
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
|
|
||||||
|
type DropdownTriggerRenderProps = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
placeholder: string;
|
||||||
|
onPress: () => 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 (
|
||||||
|
<View style={styles.formSection}>
|
||||||
|
<Text style={styles.label}>{label}</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={disabled}
|
||||||
|
style={[styles.dropdownControl, disabled && styles.dropdownControlDisabled]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={value ? styles.dropdownValue : styles.dropdownPlaceholder}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{value || placeholder}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={16} color={defaultTheme.colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||||
|
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
||||||
|
{!errorMessage && helperText ? (
|
||||||
|
<Text style={styles.helperText}>{helperText}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DropdownSheetProps {
|
||||||
|
title: string;
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownSheet({
|
||||||
|
title,
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
}: DropdownSheetProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
visible={visible}
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<View style={styles.dropdownSheetOverlay}>
|
||||||
|
<Pressable style={styles.dropdownSheetBackdrop} onPress={onClose} />
|
||||||
|
<View style={styles.dropdownSheetContainer}>
|
||||||
|
<View style={styles.dropdownSheetHandle} />
|
||||||
|
<Text style={styles.dropdownSheetTitle}>{title}</Text>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.dropdownSheetScrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = (
|
||||||
|
<>
|
||||||
|
<DropdownField
|
||||||
|
label={label}
|
||||||
|
value={selectedDefinition?.label ?? ""}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onPress={onOpen}
|
||||||
|
disabled={disabled}
|
||||||
|
renderTrigger={renderTrigger}
|
||||||
|
/>
|
||||||
|
<DropdownSheet title={sheetTitle} visible={isOpen} onClose={onClose}>
|
||||||
|
{providerDefinitions.map((definition) => {
|
||||||
|
const isSelected = definition.id === selectedProvider;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={definition.id}
|
||||||
|
style={[
|
||||||
|
styles.dropdownSheetOption,
|
||||||
|
isSelected && styles.dropdownSheetOptionSelected,
|
||||||
|
]}
|
||||||
|
onPress={() => {
|
||||||
|
onSelect(definition.id);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.dropdownSheetOptionLabel}>{definition.label}</Text>
|
||||||
|
{definition.description ? (
|
||||||
|
<Text style={styles.dropdownSheetOptionDescription}>
|
||||||
|
{definition.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownSheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!wrapInContainer) {
|
||||||
|
return <>{field}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <View style={styles.selectorColumn}>{field}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = (
|
||||||
|
<>
|
||||||
|
<DropdownField
|
||||||
|
label={label}
|
||||||
|
value={selectedModeLabel}
|
||||||
|
placeholder={placeholderLabel}
|
||||||
|
onPress={hasOptions ? onOpen : () => {}}
|
||||||
|
disabled={disabled || !hasOptions}
|
||||||
|
helperText={
|
||||||
|
hasOptions
|
||||||
|
? undefined
|
||||||
|
: "This assistant does not expose selectable permissions."
|
||||||
|
}
|
||||||
|
renderTrigger={renderTrigger}
|
||||||
|
/>
|
||||||
|
{hasOptions ? (
|
||||||
|
<DropdownSheet title={sheetTitle} visible={isOpen} onClose={onClose}>
|
||||||
|
{modeOptions.map((mode) => {
|
||||||
|
const isSelected = mode.id === selectedMode;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={mode.id}
|
||||||
|
style={[
|
||||||
|
styles.dropdownSheetOption,
|
||||||
|
isSelected && styles.dropdownSheetOptionSelected,
|
||||||
|
]}
|
||||||
|
onPress={() => {
|
||||||
|
onSelect(mode.id);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.dropdownSheetOptionLabel}>{mode.label}</Text>
|
||||||
|
{mode.description ? (
|
||||||
|
<Text style={styles.dropdownSheetOptionDescription}>
|
||||||
|
{mode.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownSheet>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!wrapInContainer) {
|
||||||
|
return <>{field}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <View style={[styles.selectorColumn, styles.selectorColumnFull]}>{field}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = (
|
||||||
|
<>
|
||||||
|
<DropdownField
|
||||||
|
label={label}
|
||||||
|
value={selectedLabel}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onPress={onOpen}
|
||||||
|
disabled={false}
|
||||||
|
errorMessage={error ?? undefined}
|
||||||
|
helperText={helperText}
|
||||||
|
renderTrigger={renderTrigger}
|
||||||
|
/>
|
||||||
|
<DropdownSheet title="Model" visible={isOpen} onClose={onClose}>
|
||||||
|
<Pressable
|
||||||
|
style={styles.dropdownSheetOption}
|
||||||
|
onPress={() => {
|
||||||
|
onClear();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.dropdownSheetOptionLabel}>
|
||||||
|
Automatic (provider default)
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.dropdownSheetOptionDescription}>
|
||||||
|
Let the assistant pick the recommended model.
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
{models.map((model) => {
|
||||||
|
const isSelected = model.id === selectedModel;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={model.id}
|
||||||
|
style={[
|
||||||
|
styles.dropdownSheetOption,
|
||||||
|
isSelected && styles.dropdownSheetOptionSelected,
|
||||||
|
]}
|
||||||
|
onPress={() => {
|
||||||
|
onSelect(model.id);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.dropdownSheetOptionLabel}>{model.label}</Text>
|
||||||
|
{model.description ? (
|
||||||
|
<Text style={styles.dropdownSheetOptionDescription}>
|
||||||
|
{model.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Pressable
|
||||||
|
style={styles.dropdownSheetOption}
|
||||||
|
onPress={() => {
|
||||||
|
onRefresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.dropdownSheetOptionLabel}>Refresh models</Text>
|
||||||
|
<Text style={styles.dropdownSheetOptionDescription}>
|
||||||
|
Request the latest catalog from the provider.
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={styles.dropdownSheetLoading}>
|
||||||
|
<ActivityIndicator size="small" color={defaultTheme.colors.foreground} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</DropdownSheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!wrapInContainer) {
|
||||||
|
return <>{field}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <View style={styles.selectorColumn}>{field}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<TextInput | null>(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 = (
|
||||||
|
<>
|
||||||
|
<DropdownField
|
||||||
|
label={label}
|
||||||
|
value={workingDir}
|
||||||
|
placeholder="/path/to/project"
|
||||||
|
onPress={onOpen}
|
||||||
|
disabled={disabled}
|
||||||
|
errorMessage={errorMessage || undefined}
|
||||||
|
helperText={
|
||||||
|
hasSuggestedPaths
|
||||||
|
? "Search directories from existing agents or paste a new path."
|
||||||
|
: "No agent directories yet - search to add one."
|
||||||
|
}
|
||||||
|
renderTrigger={renderTrigger}
|
||||||
|
/>
|
||||||
|
<DropdownSheet title="Working Directory" visible={isOpen} onClose={onClose}>
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
style={styles.dropdownSearchInput}
|
||||||
|
placeholder="/path/to/project"
|
||||||
|
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
{!hasSuggestedPaths && !showCustomOption ? (
|
||||||
|
<Text style={styles.helperText}>
|
||||||
|
We'll suggest directories from agents on this host once they exist.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{showCustomOption ? (
|
||||||
|
<View style={styles.dropdownSheetList}>
|
||||||
|
<Pressable
|
||||||
|
key="working-dir-custom-option"
|
||||||
|
style={styles.dropdownSheetOption}
|
||||||
|
onPress={() => handleSelect(sanitizedSearchValue)}
|
||||||
|
>
|
||||||
|
<Text style={styles.dropdownSheetOptionLabel} numberOfLines={1}>
|
||||||
|
{`Use "${sanitizedSearchValue}"`}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.dropdownSheetOptionDescription}>
|
||||||
|
Launch the agent in this directory
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{hasMatches ? (
|
||||||
|
<View style={styles.dropdownSheetList}>
|
||||||
|
{filteredPaths.map((path) => {
|
||||||
|
const isActive = path === workingDir;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={path}
|
||||||
|
style={[
|
||||||
|
styles.dropdownSheetOption,
|
||||||
|
isActive && styles.dropdownSheetOptionSelected,
|
||||||
|
]}
|
||||||
|
onPress={() => handleSelect(path)}
|
||||||
|
>
|
||||||
|
<Text style={styles.dropdownSheetOptionLabel} numberOfLines={1}>
|
||||||
|
{path}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
) : hasSuggestedPaths ? (
|
||||||
|
<Text style={styles.helperText}>
|
||||||
|
No agent directories match your search.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</DropdownSheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!wrapInContainer) {
|
||||||
|
return <>{field}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <View style={styles.formSection}>{field}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
if (!disabled) {
|
||||||
|
onToggle(!value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={[styles.toggleRow, disabled && styles.toggleRowDisabled]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.checkbox,
|
||||||
|
value && styles.checkboxChecked,
|
||||||
|
disabled && styles.checkboxDisabled,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{value ? <View style={styles.checkboxDot} /> : null}
|
||||||
|
</View>
|
||||||
|
<View style={styles.toggleTextContainer}>
|
||||||
|
<Text style={styles.toggleLabel}>{label}</Text>
|
||||||
|
{description ? (
|
||||||
|
<Text style={styles.helperText}>{description}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<TextInput | null>(null);
|
||||||
|
const gitInputsDisabled = Boolean(isGitDisabled) || status === "loading";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isBaseDropdownOpen) {
|
||||||
|
setBranchSearch("");
|
||||||
|
baseInputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [isBaseDropdownOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.formSection}>
|
||||||
|
<Text style={styles.label}>Git Setup</Text>
|
||||||
|
<Text style={styles.helperText}>
|
||||||
|
Choose a base branch, then optionally create a feature branch or
|
||||||
|
isolated worktree.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<DropdownField
|
||||||
|
label="Base Branch"
|
||||||
|
value={baseBranch}
|
||||||
|
placeholder={currentBranchLabel || "main"}
|
||||||
|
onPress={onToggleBaseDropdown}
|
||||||
|
disabled={gitInputsDisabled}
|
||||||
|
errorMessage={repoError}
|
||||||
|
warningMessage={!gitValidationError && !isGitDisabled ? warning : null}
|
||||||
|
helperText={
|
||||||
|
helperText ??
|
||||||
|
(status === "loading"
|
||||||
|
? "Inspecting repository…"
|
||||||
|
: "Search existing branches, then tap to select.")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownSheet
|
||||||
|
title="Base Branch"
|
||||||
|
visible={isBaseDropdownOpen}
|
||||||
|
onClose={onCloseDropdown}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
ref={baseInputRef}
|
||||||
|
style={styles.dropdownSearchInput}
|
||||||
|
placeholder={currentBranchLabel || "main"}
|
||||||
|
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||||
|
value={branchSearch}
|
||||||
|
onChangeText={setBranchSearch}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
{status === "loading" ? (
|
||||||
|
<View style={styles.dropdownLoading}>
|
||||||
|
<ActivityIndicator color={defaultTheme.colors.mutedForeground} />
|
||||||
|
<Text style={styles.helperText}>Inspecting repository…</Text>
|
||||||
|
</View>
|
||||||
|
) : filteredBranches.length === 0 ? (
|
||||||
|
<Text style={styles.helperText}>
|
||||||
|
{branchFilter.length === 0
|
||||||
|
? "No branches detected yet."
|
||||||
|
: "No branches match your search."}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<View style={styles.dropdownSheetList}>
|
||||||
|
{filteredBranches.slice(0, maxVisible).map((branch) => {
|
||||||
|
const isActive = branch.name === baseBranch;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={branch.name}
|
||||||
|
style={[
|
||||||
|
styles.dropdownSheetOption,
|
||||||
|
isActive && styles.dropdownSheetOptionSelected,
|
||||||
|
]}
|
||||||
|
onPress={() => {
|
||||||
|
onBaseBranchChange(branch.name);
|
||||||
|
onCloseDropdown();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.dropdownSheetOptionLabel}>
|
||||||
|
{branch.name}
|
||||||
|
{branch.isCurrent ? " (current)" : ""}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{filteredBranches.length > maxVisible ? (
|
||||||
|
<Text style={styles.helperText}>
|
||||||
|
Showing first {maxVisible} matches. Keep typing to narrow it down.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</DropdownSheet>
|
||||||
|
|
||||||
|
<ToggleRow
|
||||||
|
label="New Branch"
|
||||||
|
description="Create a feature branch before launching the agent"
|
||||||
|
value={createNewBranch}
|
||||||
|
onToggle={onToggleCreateNewBranch}
|
||||||
|
disabled={isGitDisabled}
|
||||||
|
/>
|
||||||
|
{createNewBranch ? (
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="feature-branch-name"
|
||||||
|
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||||
|
value={branchName}
|
||||||
|
onChangeText={onBranchNameChange}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ToggleRow
|
||||||
|
label="Create Worktree"
|
||||||
|
description="Use an isolated directory so your current branch stays untouched"
|
||||||
|
value={createWorktree}
|
||||||
|
onToggle={onToggleCreateWorktree}
|
||||||
|
disabled={isGitDisabled}
|
||||||
|
/>
|
||||||
|
{createWorktree ? (
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder={branchName || "feature-worktree"}
|
||||||
|
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||||
|
value={worktreeSlug}
|
||||||
|
onChangeText={onWorktreeSlugChange}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{gitValidationError ? (
|
||||||
|
<Text style={styles.errorText}>{gitValidationError}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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],
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user