"use client"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { t } from "@/lib/i18n"; import { IconAlertTriangle, IconGitBranch, IconTerminal2 } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, DrawerTrigger, } from "@kandev/ui/drawer"; import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import type { LocalRepository, Repository, Branch, Executor, ExecutorProfile, } from "@/lib/types/http"; import type { AvailableAgent } from "@/lib/types/http-agents"; import type { AgentProfileOption } from "@/lib/state/slices"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; import { useFeature } from "@/hooks/domains/features/use-feature"; import { isSelectableAgentProfile } from "@/lib/state/slices/settings/types"; import { formatUserHomePath, truncateRepoPath } from "@/lib/utils"; import { getExecutorIcon } from "@/lib/executor-icons"; import { AgentLogo } from "@/components/agent-logo"; import { getCapabilityWarning } from "@/lib/capability-warning"; import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; import { branchOptionValue, buildBranchKeywords } from "./branch-picker-options"; import { ensureAgentProfileRecentUseLoaded, orderAgentProfilesByRecentUse, } from "@/lib/agent-profile-recent-use"; import type { AgentProfileRecentUseContext } from "@/lib/types/http-agent-profile-recent-use"; type OptionItem = { value: string; label: string; renderLabel: () => React.ReactNode; renderTriggerLabel?: () => React.ReactNode; disabled?: boolean; disabledReason?: string; }; function ModelProbeWarning({ note }: { note: string }) { const usesTouchDrawer = useTouchDrawer(); const [drawerOpen, setDrawerOpen] = useState(false); const trigger = ( ); if (usesTouchDrawer) { return ( {trigger} {note} {note} ); } return ( {trigger} {note} ); } function ModelProbeWarningIndicator({ note }: { note: string }) { return ; } export function useRepositoryOptions( repositories: Repository[], discoveredRepositories: LocalRepository[], ) { const repositoryOptions = useMemo(() => { const normalizeRepoPath = (path: string) => path.replace(/\\/g, "/").replace(/\/+$/g, ""); const workspaceRepoPaths = new Set( repositories .map((repo: Repository) => repo.local_path) .filter(Boolean) .map((path: string) => normalizeRepoPath(path)), ); const localRepoOptions = discoveredRepositories.filter( (repo: LocalRepository) => !workspaceRepoPaths.has(normalizeRepoPath(repo.path)), ); return [ ...repositories.map((repo: Repository) => ({ value: repo.id, label: repo.name, renderLabel: () => ( {repo.name} {repo.local_path ? ( {truncateRepoPath(repo.local_path, 24)} ) : null} ), })), ...localRepoOptions.map((repo: LocalRepository) => ({ value: repo.path, label: truncateRepoPath(repo.path, 24), renderLabel: () => ( {truncateRepoPath(repo.path, 28)} ), })), ]; }, [repositories, discoveredRepositories]); const headerRepositoryOptions = useMemo(() => { return repositoryOptions.map((opt) => ({ ...opt, renderLabel: () => {opt.label}, })); }, [repositoryOptions]); return { repositoryOptions, headerRepositoryOptions }; } export function useBranchOptions(branchOptionsRaw: Branch[]) { return useMemo(() => { return branchOptionsRaw.map((branchObj: Branch) => { const displayName = branchOptionValue(branchObj); // Keywords give the scorer extra surfaces to match against: the leaf // branch name, every path segment, and (for remotes) the remote name. const keywords = buildBranchKeywords(branchObj.name, branchObj.remote); return { value: displayName, label: displayName, keywords, renderLabel: () => ( {displayName} {branchObj.type === "local" ? "local" : branchObj.remote || "remote"} ), }; }); }, [branchOptionsRaw]); } // advertisedModelIDs returns the currently advertised model IDs for an agent // from the host-utility probe cache (empty when the probe has not landed). function advertisedModelIDs(availableAgents: AvailableAgent[], agentName: string): string[] { const agent = availableAgents.find((a) => a.name === agentName); return agent?.model_config?.available_models?.map((m) => m.id) ?? []; } export function useAgentProfileOptions( agentProfiles: AgentProfileOption[], context?: AgentProfileRecentUseContext, ): OptionItem[] { const { t } = useTranslation(); const { items: availableAgents } = useAvailableAgents(); const dynamicRoutingEnabled = useFeature("dynamicAgentRouting"); const storeApi = useAppStoreApi(); const recentUseLoaded = useAppStore((state) => !context || state.agentProfileRecentUse.loaded); const recentProfileIds = useAppStore((state) => context ? state.agentProfileRecentUse?.records[context]?.profileIds : undefined, ); useEffect(() => { if (!context || recentUseLoaded) return; void ensureAgentProfileRecentUseLoaded(storeApi); }, [context, recentUseLoaded, storeApi]); return useMemo(() => { // Disabled profiles stay in the store (existing sessions keep their // labels) but are never offered as a choice for new work. const selectable = agentProfiles.filter((profile) => isSelectableAgentProfile(profile, dynamicRoutingEnabled), ); const orderedProfiles = context ? orderAgentProfilesByRecentUse(selectable, recentProfileIds) : selectable; return orderedProfiles.map((profile: AgentProfileOption) => { const parts = profile.label.split(" \u2022 "); const agentLabel = parts[0] ?? profile.label; const profileLabel = parts[1] ?? ""; const isPassthrough = profile.cli_passthrough === true; const warning = getCapabilityWarning(profile.capability_status, profile.capability_error); // The host-utility probe is an editing hint only. The selected // executor owns the launch-time model catalog, so a host-only mismatch // must never remove a profile from the task selector. const advertised = advertisedModelIDs(availableAgents, profile.agent_name); const startModelGone = Boolean( profile.model && advertised.length > 0 && !advertised.includes(profile.model), ); let modelProbeNote: string | undefined; if (startModelGone) { modelProbeNote = t("settings:profileStartModelNotAdvertisedOnHost", { model: profile.model, }); } const renderProfileLabel = (modelProbeWarning: React.ReactNode) => ( {agentLabel} {warning && ( )} {modelProbeWarning} {isPassthrough && ( )} {profileLabel ? ( {profileLabel} ) : null} ); return { value: profile.id, label: profile.label, disabled: undefined, disabledReason: undefined, renderLabel: () => renderProfileLabel(modelProbeNote ? : null), renderTriggerLabel: () => renderProfileLabel( modelProbeNote ? : null, ), }; }); }, [agentProfiles, availableAgents, context, dynamicRoutingEnabled, recentProfileIds, t]); } export function useExecutorOptions(executors: Executor[]): OptionItem[] { return useMemo(() => { return executors.map((executor: Executor) => { const Icon = getExecutorIcon(executor.type); return { value: executor.id, label: executor.name, renderLabel: () => ( {executor.name} ), }; }); }, [executors]); } export function useIsLocalExecutor(executors: Executor[], executorId: string): boolean { return useMemo(() => { const selected = executors.find((e: Executor) => e.id === executorId); return selected?.type === "local" || selected?.type === "local_pc"; }, [executors, executorId]); } export function computeExecutorHint( executors: Executor[], executorId: string, repoCount: number, ): string | null { const selectedExecutor = executors.find((e: Executor) => e.id === executorId); if (selectedExecutor?.type === "worktree") { if (repoCount > 1) { return t("task:executorHintWorktreeMulti"); } return t("task:executorHintWorktreeSingle"); } if (selectedExecutor?.type === "local_docker" || selectedExecutor?.type === "remote_docker") { return t("task:executorHintDocker"); } if (selectedExecutor?.type === "local" || selectedExecutor?.type === "local_pc") return t("task:executorHintLocal"); return null; } export function useExecutorHint( executors: Executor[], executorId: string, repoCount: number, ): string | null { return useMemo( () => computeExecutorHint(executors, executorId, repoCount), [executors, executorId, repoCount], ); } export type ExecutorProfileOptionItem = OptionItem & { executorType?: string; executorName?: string; }; export type ExecutorProfileOptionsConfig = { /** * Returns a tooltip string when the given profile should render disabled. * Used by the kanban task-create dialog for repository-mode executor * capability gates. */ disabledReasonFor?: (profile: ExecutorProfile) => string | null; }; export function useExecutorProfileOptions( allProfiles: ExecutorProfile[], config?: ExecutorProfileOptionsConfig, ): ExecutorProfileOptionItem[] { const disabledReasonFor = config?.disabledReasonFor; return useMemo(() => { return allProfiles.map((profile) => { const Icon = getExecutorIcon(profile.executor_type ?? "local"); const disabledReason = disabledReasonFor?.(profile) ?? null; return { value: profile.id, label: profile.name, executorType: profile.executor_type, executorName: profile.executor_name, disabled: !!disabledReason, disabledReason: disabledReason ?? undefined, renderLabel: () => ( {profile.name} {profile.executor_name && ( {profile.executor_name} )} ), }; }); }, [allProfiles, disabledReasonFor]); }