Files
kandev/apps/web/components/settings/utility-sections.tsx
T
2026-09-22 21:24:43 +01:00

283 lines
9.0 KiB
TypeScript

"use client";
import { useTranslation } from "react-i18next";
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import type { UtilityAgent } from "@/lib/api/domains/utility-api";
import type { AgentProfileOption } from "@/lib/state/slices/settings/types";
import { useFeature } from "@/hooks/domains/features/use-feature";
import { SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/standalone";
import { isUtilityAgentDirty } from "@/components/settings/utility-dirty";
import {
UtilityAgentProfilePicker,
utilityProfileEligibility,
} from "@/components/settings/utility-agent-profile-picker";
import { SettingsGroup } from "@/components/settings/settings-group";
import { SettingsFieldLabel } from "@/components/settings/settings-typography";
import { settingsActionClassName } from "@/components/settings/settings-control";
export const USE_DEFAULT = "__USE_DEFAULT__";
const UNCONFIGURED = "__UNCONFIGURED__";
export type BuiltinActionProfileSelection = {
value: string;
unavailableValue?: string;
};
export function getBuiltinActionProfileSelection(
agent: UtilityAgent,
): BuiltinActionProfileSelection {
const profileId = agent.agent_profile_id || "";
if (agent.profile_binding_state === "unconfigured") {
const unavailableValue = profileId || UNCONFIGURED;
return { value: unavailableValue, unavailableValue };
}
if (agent.profile_binding_state !== "inherit" && profileId) {
return { value: profileId };
}
return { value: USE_DEFAULT };
}
export function DefaultModelSection({
profiles,
profileId,
onProfileChange,
isDirty,
}: {
profiles: AgentProfileOption[];
profileId: string;
onProfileChange: (profileId: string) => void;
isDirty: boolean;
}) {
const { t } = useTranslation();
const dynamicRoutingEnabled = useFeature("dynamicAgentRouting");
const eligibleProfiles = profiles.filter((profile) =>
utilityProfileEligibility(profile, dynamicRoutingEnabled),
);
const selected = eligibleProfiles.find((profile) => profile.id === profileId);
return (
<SettingsGroup
isDirty={isDirty}
title={t("settings:utilityDefaultModelTitle")}
description={t("settings:utilityDefaultModelDescription")}
discoveryTargetId={SETTINGS_TARGETS.utilityDefaultModel}
data-testid="utility-default-model-card"
>
<div className="space-y-3">
<div className="space-y-2">
<SettingsFieldLabel>{t("settings:utilityAgentProfile")}</SettingsFieldLabel>
<UtilityAgentProfilePicker
profiles={profiles}
value={profileId || USE_DEFAULT}
onValueChange={(value) => onProfileChange(value === USE_DEFAULT ? "" : value)}
fallback={{ value: USE_DEFAULT, label: t("settings:utilityNoDefaultProfile") }}
unavailableValue={profileId && !selected ? profileId : undefined}
testId="utility-profile-picker-default"
triggerClassName="w-full max-w-sm font-normal"
/>
{profileId && !selected && (
<p className="text-xs text-destructive">{t("settings:utilityProfileNeedsRepair")}</p>
)}
</div>
</div>
</SettingsGroup>
);
}
export function BuiltinActionRow({
agent,
profiles,
defaultLabel,
onProfileChange,
onEdit,
isDirty,
}: {
agent: UtilityAgent;
profiles: AgentProfileOption[];
defaultLabel: string;
onProfileChange: (agent: UtilityAgent, value: string) => void;
onEdit: (agent: UtilityAgent) => void;
isDirty: boolean;
}) {
const { t } = useTranslation();
const selection = getBuiltinActionProfileSelection(agent);
return (
<div
className="flex flex-col gap-2 py-2 px-2 rounded hover:bg-muted/50 md:flex-row md:items-center md:gap-4"
data-testid={`utility-action-row-${agent.id}`}
data-settings-dirty={isDirty}
>
<div className="min-w-0 md:flex-1">
<div className="text-sm font-medium leading-5 truncate">{agent.name}</div>
<p className="text-xs text-muted-foreground truncate">{agent.description}</p>
</div>
<div className="flex items-center gap-2">
<UtilityAgentProfilePicker
profiles={profiles}
value={selection.value}
onValueChange={(value) => onProfileChange(agent, value)}
fallback={{ value: USE_DEFAULT, label: defaultLabel }}
unavailableValue={selection.unavailableValue}
unavailableLabel={
selection.unavailableValue ? t("settings:utilityProfileNeedsRepair") : undefined
}
testId={`utility-profile-picker-action-${agent.id}`}
triggerClassName="min-w-0 flex-1 md:w-[280px] md:flex-none"
/>
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(agent)}
className="h-7 w-7 p-0 shrink-0 cursor-pointer text-muted-foreground hover:text-foreground"
>
<IconPencil className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}
export function PerActionOverridesSection({
builtins,
profiles,
defaultLabel,
onProfileChange,
onEdit,
savedBuiltins,
}: {
builtins: UtilityAgent[];
profiles: AgentProfileOption[];
defaultLabel: string;
onProfileChange: (agent: UtilityAgent, value: string) => void;
onEdit: (agent: UtilityAgent) => void;
savedBuiltins: UtilityAgent[];
}) {
const { t } = useTranslation();
if (builtins.length === 0) return null;
return (
<SettingsGroup
isDirty={builtins.some((agent) =>
isUtilityAgentDirty(
agent,
savedBuiltins.find((saved) => saved.id === agent.id),
),
)}
title={t("settings:utilityActionsTitle")}
description={t("settings:utilityActionsDescription")}
discoveryTargetId={SETTINGS_TARGETS.utilityActions}
data-testid="utility-actions-card"
>
<div className="space-y-0">
{builtins.map((agent) => (
<BuiltinActionRow
key={agent.id}
agent={agent}
profiles={profiles}
defaultLabel={defaultLabel}
onProfileChange={onProfileChange}
onEdit={onEdit}
isDirty={isUtilityAgentDirty(
agent,
savedBuiltins.find((saved) => saved.id === agent.id),
)}
/>
))}
</div>
</SettingsGroup>
);
}
export function CustomAgentRow({
agent,
profiles,
onEdit,
onDelete,
}: {
agent: UtilityAgent;
profiles: AgentProfileOption[];
onEdit: (agent: UtilityAgent) => void;
onDelete: (agent: UtilityAgent) => void;
}) {
const { t } = useTranslation();
const profile = profiles.find((item) => item.id === agent.agent_profile_id);
return (
<div className="flex flex-col gap-2 py-3 px-3 rounded hover:bg-muted/50 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{agent.name}</div>
<p className="text-xs text-muted-foreground truncate">{agent.description}</p>
<p className="text-xs text-muted-foreground truncate">
{profile ? profile.label || profile.id : t("settings:utilityProfileNeedsRepair")}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(agent)}
className="h-7 w-7 p-0 cursor-pointer text-muted-foreground hover:text-foreground"
>
<IconPencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(agent)}
className="h-7 w-7 p-0 cursor-pointer text-muted-foreground hover:text-destructive"
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}
export function CustomAgentsSection({
agents,
profiles,
onAdd,
onEdit,
onDelete,
}: {
agents: UtilityAgent[];
profiles: AgentProfileOption[];
onAdd: () => void;
onEdit: (agent: UtilityAgent) => void;
onDelete: (agent: UtilityAgent) => void;
}) {
const { t } = useTranslation();
return (
<SettingsGroup
title={t("settings:utilityCustomAgentsTitle")}
description={t("settings:utilityCustomAgentsDescription")}
discoveryTargetId={SETTINGS_TARGETS.utilityCustomAgents}
data-testid="utility-custom-agents-card"
action={
<Button onClick={onAdd} size="sm" className={settingsActionClassName("cursor-pointer")}>
<IconPlus className="h-4 w-4 mr-1" />
{t("settings:utilityAddCustomAgent")}
</Button>
}
>
<div className="space-y-4">
{agents.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">
{t("settings:utilityCustomAgentsEmpty")}
</p>
) : (
<div className="space-y-2">
{agents.map((agent) => (
<CustomAgentRow
key={agent.id}
agent={agent}
profiles={profiles}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
)}
</div>
</SettingsGroup>
);
}