Files
kandev/apps/web/components/settings/dynamic-agent-candidate-list.tsx
T
2026-08-21 22:04:17 +01:00

156 lines
6.1 KiB
TypeScript

"use client";
import { useTranslation } from "react-i18next";
import { IconArrowDown, IconArrowUp, IconTrash } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Switch } from "@kandev/ui/switch";
import { AgentProfilePicker } from "@/components/settings/agent-profile-picker";
import { DynamicPolicyEditor } from "@/components/settings/dynamic-agent-policy-editor";
import type { AgentProfileOption } from "@/lib/state/slices/settings/types";
import type { AgentProfile } from "@/lib/types/http";
import type {
DynamicAgentCandidate,
DynamicErrorClass,
DynamicErrorPolicy,
} from "@/lib/types/agent-profile";
type DynamicAgentCandidateListProps = {
candidates: DynamicAgentCandidate[];
concreteProfiles: AgentProfile[];
availableProfileOptions: AgentProfileOption[];
enabledLabel: string;
addCandidate: (executionProfileId: string) => void;
moveCandidate: (index: number, direction: -1 | 1) => void;
removeCandidate: (index: number) => void;
updateCandidate: (index: number, patch: Partial<DynamicAgentCandidate>) => void;
updateCandidatePolicy: (
index: number,
errorClass: DynamicErrorClass,
patch: Partial<DynamicErrorPolicy>,
) => void;
};
function candidateLabel(candidate: DynamicAgentCandidate, profiles: AgentProfile[]): string {
return (
profiles.find((profile) => profile.id === candidate.executionProfileId)?.name ??
candidate.executionProfileId
);
}
// eslint-disable-next-line max-lines-per-function -- keeps the candidate list row actions and policies together.
export function DynamicAgentCandidateList({
candidates,
concreteProfiles,
availableProfileOptions,
enabledLabel,
addCandidate,
moveCandidate,
removeCandidate,
updateCandidate,
updateCandidatePolicy,
}: DynamicAgentCandidateListProps) {
const { t } = useTranslation();
return (
<div className="space-y-3" data-testid="dynamic-profile-candidates">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-medium">{t("agents:dynamicCandidates")}</h3>
<p className="text-xs text-muted-foreground">
{t("agents:dynamicCandidatesDescription")}
</p>
</div>
<AgentProfilePicker
profiles={availableProfileOptions}
value=""
onValueChange={(value) => {
if (value) addCandidate(value);
}}
testId="add-dynamic-candidate"
placeholder={t("agents:addDynamicCandidate")}
searchPlaceholder={t("agents:searchDynamicCandidates")}
emptyMessage={t("agents:noDynamicCandidatesFound")}
ariaLabel={t("agents:addDynamicCandidate")}
triggerClassName="min-h-11 w-full sm:w-auto"
/>
</div>
{candidates.length === 0 ? (
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
{t("agents:noDynamicCandidates")}
</p>
) : (
<ol className="grid gap-4">
{candidates.map((candidate, index) => (
<li
key={candidate.executionProfileId}
className="min-w-0 space-y-4 rounded-md border p-3"
>
<div className="flex min-w-0 flex-wrap items-center gap-3">
<div className="flex min-w-0 basis-full items-center gap-3 sm:basis-auto sm:flex-1">
<Badge variant="outline">{index + 1}</Badge>
<span className="min-w-0 truncate text-sm font-medium">
{candidateLabel(candidate, concreteProfiles)}
</span>
</div>
<div className="flex min-h-11 items-center gap-2 rounded-md border px-2">
<span className="text-xs text-muted-foreground">{enabledLabel}</span>
<Switch
checked={candidate.enabled}
onCheckedChange={(checked) => updateCandidate(index, { enabled: checked })}
aria-label={`${enabledLabel}: ${candidateLabel(candidate, concreteProfiles)}`}
/>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="min-h-11 min-w-11 shrink-0 cursor-pointer"
onClick={() => moveCandidate(index, -1)}
disabled={index === 0}
aria-label={t("agents:moveDynamicCandidateUp")}
>
<IconArrowUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="min-h-11 min-w-11 shrink-0 cursor-pointer"
onClick={() => moveCandidate(index, 1)}
disabled={index === candidates.length - 1}
aria-label={t("agents:moveDynamicCandidateDown")}
>
<IconArrowDown className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="min-h-11 min-w-11 shrink-0 cursor-pointer text-destructive"
onClick={() => removeCandidate(index)}
aria-label={t("agents:removeDynamicCandidate")}
>
<IconTrash className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid min-w-0 gap-4 md:grid-cols-2">
<DynamicPolicyEditor
errorClass="transient"
policy={candidate.policies.transient}
onChange={(patch) => updateCandidatePolicy(index, "transient", patch)}
/>
<DynamicPolicyEditor
errorClass="hard"
policy={candidate.policies.hard}
onChange={(patch) => updateCandidatePolicy(index, "hard", patch)}
/>
</div>
</li>
))}
</ol>
)}
</div>
);
}