"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]);
}