Files
kandev/apps/web/app/settings/agents/[agentId]/agent-setup-parts.tsx
T
2026-09-25 10:31:58 +01:00

409 lines
13 KiB
TypeScript

"use client";
import { useTranslation } from "react-i18next";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import Link from "@/components/routing/app-link";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@kandev/ui/alert-dialog";
import { Button } from "@kandev/ui/button";
import { CardContent } from "@kandev/ui/card";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/settings-card-header";
import { SettingsPageHeader, SETTINGS_TYPOGRAPHY } from "@/components/settings/settings-typography";
import { settingsActionClassName } from "@/components/settings/settings-control";
import { ProfileFormFields, type ProfileFormData } from "@/components/settings/profile-form-fields";
import { ProfileEnvVarsSection } from "@/components/settings/agent-profile-page";
import { ProviderSection } from "@/components/settings/profile-edit/provider-section";
import { CustomCLIFlagsCard } from "@/components/settings/cli-flags-field";
import type { Agent, ModelConfig, PermissionSetting, PassthroughConfig } from "@/lib/types/http";
import type { SecretListItem } from "@/lib/types/http-secrets";
import { ProfileMcpConfigCard } from "./profile-mcp-config-card";
import { profilePermissionValues } from "@/lib/agent-permissions";
import { DynamicAgentProfileEditor } from "@/components/settings/dynamic-agent-profile-editor";
import {
isProfileDirty,
toAgentProfilePatch,
type DraftProfile,
type DraftAgent,
} from "./agent-save-helpers";
function profileFormData(
profile: DraftProfile,
permissionSettings: Record<string, PermissionSetting>,
): ProfileFormData {
const permissions = profilePermissionValues(profile, permissionSettings);
return {
name: profile.name,
model: profile.model,
provider_kind: profile.providerKind ?? "",
mode: profile.mode ?? "",
config_options: profile.configOptions ?? {},
auto_approve: permissions.auto_approve,
allow_indexing: permissions.allow_indexing,
cli_passthrough: profile.cliPassthrough ?? false,
cursor_mcp_auth_enabled: profile.cursorMcpAuthEnabled ?? true,
cli_flags: profile.cliFlags ?? [],
command_prefix: profile.commandPrefix ?? "",
};
}
export type AgentHeaderProps = {
displayName: string;
matchedPath: string | null | undefined;
isCreateMode: boolean;
savedAgent: Agent | null;
showInstallationStatus?: boolean;
onDelete?: () => void;
};
export function AgentHeader({
displayName,
matchedPath,
isCreateMode,
savedAgent,
showInstallationStatus = true,
onDelete,
}: AgentHeaderProps) {
const { t } = useTranslation();
return (
<SettingsPageHeader
title={
<span className="flex min-w-0 flex-wrap items-center gap-2">
<span className="min-w-0 break-words">{displayName}</span>
{showInstallationStatus && (
<span
className={
SETTINGS_TYPOGRAPHY.meta +
" max-w-full truncate rounded-full border border-muted-foreground/30 px-2 py-1"
}
>
{matchedPath ?? t("agents:installationNotDetected")}
</span>
)}
</span>
}
description={
isCreateMode ? t("agents:createProfileIntro") : t("agents:configureProfilesIntro")
}
actions={
savedAgent?.tui_config && onDelete ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
className={settingsActionClassName("cursor-pointer")}
>
<IconTrash className="h-4 w-4 mr-2" />
{t("agents:deleteAgent")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("agents:deleteAgentTitle", { name: displayName })}
</AlertDialogTitle>
<AlertDialogDescription>
{t("agents:deleteAgentDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">
{t("common:cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={onDelete} className="cursor-pointer">
{t("agents:delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : undefined
}
/>
);
}
export type ProfileCardItemProps = {
profile: DraftProfile;
savedProfile?: DraftProfile;
isNew: boolean;
draftAgent: DraftAgent;
currentAgentModelConfig: ModelConfig;
permissionSettings: Record<string, PermissionSetting>;
passthroughConfig: PassthroughConfig | null;
secrets: SecretListItem[];
providerSupported: boolean;
onProfileChange: (profileId: string, patch: Partial<DraftProfile>) => void;
onProfileMcpChange: (
profileId: string,
patch: Partial<NonNullable<DraftProfile["mcp_config"]>>,
) => void;
onRemoveProfile: (profileId: string) => void;
onToastError: (error: unknown) => void;
};
export function ProfileCardItem({
profile,
savedProfile,
isNew,
draftAgent,
currentAgentModelConfig,
permissionSettings,
passthroughConfig,
secrets,
providerSupported,
onProfileChange,
onProfileMcpChange,
onRemoveProfile,
onToastError,
}: ProfileCardItemProps) {
const formProfile = profileFormData(profile, permissionSettings);
const providerDraft = { ...profile, providerSupported };
const baselineProfile = savedProfile
? profileFormData(savedProfile, permissionSettings)
: undefined;
const dirty =
isNew ||
!savedProfile ||
Boolean(profile.mcp_config?.dirty) ||
isProfileDirty(profile, savedProfile);
return (
<SettingsCard id={`profile-card-${profile.id}`} isDirty={dirty}>
<CardContent className="pt-6 space-y-4">
<ProfileFormFields
profile={formProfile}
baselineProfile={baselineProfile}
onChange={(patch) => onProfileChange(profile.id, toAgentProfilePatch(patch))}
modelConfig={currentAgentModelConfig}
permissionSettings={permissionSettings}
passthroughConfig={passthroughConfig}
agentName={draftAgent.name}
cursorMcpAuthSupported={
draftAgent.name === "cursor-acp" || draftAgent.tui_config?.mcp_strategy === "cursor"
}
onRemove={() => onRemoveProfile(profile.id)}
canRemove={draftAgent.profiles.length > 1}
lockPassthrough={Boolean(draftAgent.tui_config)}
hideCustomCLIFlags
/>
<CustomCLIFlagsCard
flags={profile.cliFlags ?? []}
baselineFlags={savedProfile?.cliFlags}
onChange={(next) => onProfileChange(profile.id, { cliFlags: next })}
permissionSettings={permissionSettings}
/>
<ProviderSection
draft={providerDraft}
savedProfile={savedProfile ?? providerDraft}
secrets={secrets}
onChange={(patch) => onProfileChange(profile.id, patch)}
/>
<ProfileEnvVarsSection
envVars={profile.envVars}
baselineEnvVars={savedProfile?.envVars}
onChange={(patch) => onProfileChange(profile.id, patch)}
/>
<ProfileMcpConfigCard
profileId={profile.id}
supportsMcp={draftAgent.supports_mcp}
cliPassthrough={profile.cliPassthrough ?? false}
mcpInjection={passthroughConfig?.mcp_injection}
draftState={profile.mcp_config}
onDraftStateChange={(patch) => onProfileMcpChange(profile.id, patch)}
onToastError={onToastError}
/>
</CardContent>
</SettingsCard>
);
}
export type ProfilesCardProps = {
displayName: string;
isCreateMode: boolean;
isAgentDirty: boolean;
draftAgent: DraftAgent;
savedAgent: Agent | null;
newProfileId: string | null;
currentAgentModelConfig: ModelConfig;
permissionSettings: Record<string, PermissionSetting>;
passthroughConfig: PassthroughConfig | null;
secrets: SecretListItem[];
onAddProfile: () => void;
onProfileChange: (profileId: string, patch: Partial<DraftProfile>) => void;
onProfileMcpChange: (
profileId: string,
patch: Partial<NonNullable<DraftProfile["mcp_config"]>>,
) => void;
onRemoveProfile: (profileId: string) => void;
onToastError: (error: unknown) => void;
};
function dynamicProfileRoute(agent: Agent, profile: DraftProfile): string {
return `/settings/agents/${encodeURIComponent(agent.name)}/profiles/${encodeURIComponent(profile.id)}`;
}
function DynamicProfileRouteRow({ agent, profile }: { agent: Agent; profile: DraftProfile }) {
const { t } = useTranslation();
const candidateCount = profile.dynamic?.candidates.length ?? 0;
return (
<Link
href={dynamicProfileRoute(agent, profile)}
className="flex min-h-11 min-w-0 items-center justify-between gap-3 rounded-md border p-3 transition-colors hover:border-foreground/30 hover:bg-muted/50"
data-testid={`dynamic-profile-route-${profile.id}`}
>
<span className="min-w-0 truncate text-sm font-medium">
{profile.name || t("agents:profileName")}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{t("agents:dynamicCandidates")}: {candidateCount}
</span>
</Link>
);
}
function DynamicProfilesCard({
isCreateMode,
isAgentDirty,
draftAgent,
dynamicProfiles,
onAddProfile,
onProfileChange,
}: {
isCreateMode: boolean;
isAgentDirty: boolean;
draftAgent: DraftAgent;
dynamicProfiles: DraftProfile[];
onAddProfile: () => void;
onProfileChange: (profileId: string, patch: Partial<DraftProfile>) => void;
}) {
const { t } = useTranslation();
return (
<SettingsCard isDirty={isAgentDirty}>
<SettingsCardHeader
title={t("agents:dynamicProfileSettings")}
actions={
!isCreateMode && (
<Button
size="sm"
variant="outline"
onClick={onAddProfile}
className={settingsActionClassName("cursor-pointer")}
>
<IconPlus className="mr-2 h-4 w-4" />
{t("agents:addProfile")}
</Button>
)
}
/>
<CardContent className="space-y-6">
{dynamicProfiles.map((profile) => (
<div key={profile.id}>
{profile.isNew ? (
<DynamicAgentProfileEditor
agent={draftAgent}
profile={profile}
isCreating={Boolean(profile.isNew)}
onDraftChange={(patch) => onProfileChange(profile.id, patch)}
/>
) : (
<DynamicProfileRouteRow agent={draftAgent} profile={profile} />
)}
</div>
))}
</CardContent>
</SettingsCard>
);
}
export function ProfilesCard({
displayName,
isCreateMode,
isAgentDirty,
draftAgent,
savedAgent,
newProfileId,
currentAgentModelConfig,
permissionSettings,
passthroughConfig,
secrets,
onAddProfile,
onProfileChange,
onProfileMcpChange,
onRemoveProfile,
onToastError,
}: ProfilesCardProps) {
const { t } = useTranslation();
const dynamicProfiles = draftAgent.profiles.filter(
(profile) => profile.kind === "dynamic" || draftAgent.name === "dynamic",
);
// The provider capability is computed server-side per agent and rides on
// saved profiles; a brand-new agent has none yet, so the section appears
// after the first save.
const providerSupported = (savedAgent?.profiles ?? []).some((p) => p.providerSupported);
if (dynamicProfiles.length > 0) {
return (
<DynamicProfilesCard
isCreateMode={isCreateMode}
isAgentDirty={isAgentDirty}
draftAgent={draftAgent}
dynamicProfiles={dynamicProfiles}
onAddProfile={onAddProfile}
onProfileChange={onProfileChange}
/>
);
}
return (
<SettingsCard isDirty={isAgentDirty}>
<SettingsCardHeader
title={
isCreateMode
? t("agents:createAgentProfileTitle", { name: displayName })
: t("agents:agentProfilesTitle", { name: displayName })
}
actions={
<Button
size="sm"
variant="outline"
onClick={onAddProfile}
className={settingsActionClassName("cursor-pointer")}
>
<IconPlus className="h-4 w-4 mr-2" />
{t("agents:addProfile")}
</Button>
}
/>
<CardContent className="space-y-4">
{draftAgent.profiles.map((profile) => (
<ProfileCardItem
key={profile.id}
profile={profile}
savedProfile={savedAgent?.profiles.find((saved) => saved.id === profile.id)}
isNew={profile.id === newProfileId}
draftAgent={draftAgent}
currentAgentModelConfig={currentAgentModelConfig}
permissionSettings={permissionSettings}
passthroughConfig={passthroughConfig}
secrets={secrets}
providerSupported={providerSupported}
onProfileChange={onProfileChange}
onProfileMcpChange={onProfileMcpChange}
onRemoveProfile={onRemoveProfile}
onToastError={onToastError}
/>
))}
</CardContent>
</SettingsCard>
);
}