Files
kandev/apps/web/components/settings/utility-agent-dialog.tsx
T
2026-08-21 16:40:58 +01:00

181 lines
6.6 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@kandev/ui/dialog";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import {
type UtilityAgent,
createUtilityAgent,
updateUtilityAgent,
getTemplateVariables,
} from "@/lib/api/domains/utility-api";
import { useAppStore } from "@/components/state-provider";
import { SettingsPromptEditor } from "./settings-prompt-editor";
import { UtilityAgentProfilePicker } from "./utility-agent-profile-picker";
import type { ScriptPlaceholder } from "./profile-edit/script-editor-completions";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
agent: UtilityAgent | null;
onSuccess: () => void;
};
type FormState = { name: string; description: string; prompt: string; profile_id: string };
const PROMPT_VARIABLE_SIGIL = "{{";
const defaultFormState: FormState = { name: "", description: "", prompt: "", profile_id: "" };
function toScriptPlaceholders(
variables: { name: string; description: string; example: string }[],
): ScriptPlaceholder[] {
return variables.map((variable) => ({
key: variable.name,
description: variable.description,
example: variable.example,
executor_types: [],
}));
}
// eslint-disable-next-line max-lines-per-function
export function UtilityAgentDialog({ open, onOpenChange, agent, onSuccess }: Props) {
const { t } = useTranslation();
const profiles = useAppStore((state) => state.agentProfiles.items);
const [form, setForm] = useState<FormState>(defaultFormState);
const [saving, setSaving] = useState(false);
const [placeholders, setPlaceholders] = useState<ScriptPlaceholder[]>([]);
const isEdit = Boolean(agent);
useEffect(() => {
getTemplateVariables()
.then(({ variables }) => setPlaceholders(toScriptPlaceholders(variables)))
.catch(() => setPlaceholders([]));
}, [open]);
useEffect(() => {
setForm(
agent
? {
name: agent.name,
description: agent.description,
prompt: agent.prompt,
profile_id: agent.agent_profile_id || "",
}
: defaultFormState,
);
}, [agent, open]);
const eligible = profiles.filter(
(profile) => profile.enabled !== false && !profile.cli_passthrough && !profile.workspace_id,
);
let submitLabel = t("settings:utilityAgentDialogCreate");
if (saving) submitLabel = t("settings:saving");
else if (isEdit) submitLabel = t("settings:utilityAgentDialogSave");
const submit = async () => {
if (!form.name.trim() || !form.prompt.trim() || !form.profile_id) return;
setSaving(true);
try {
const data = {
name: form.name,
description: form.description,
prompt: form.prompt,
agent_profile_id: form.profile_id,
profile_binding_state: "explicit",
};
if (agent) await updateUtilityAgent(agent.id, data);
else await createUtilityAgent(data);
onSuccess();
} catch (error) {
console.error("Failed to save utility agent", error);
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{isEdit
? t("settings:utilityAgentDialogEditTitle")
: t("settings:utilityAgentDialogCreateTitle")}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="name">{t("settings:name")}</Label>
<Input
id="name"
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
placeholder={t("settings:utilityAgentNamePlaceholder")}
disabled={agent?.builtin ?? false}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">{t("settings:utilityAgentDescriptionLabel")}</Label>
<Input
id="description"
value={form.description}
onChange={(event) =>
setForm((current) => ({ ...current, description: event.target.value }))
}
placeholder={t("settings:utilityAgentDescriptionPlaceholder")}
/>
</div>
<div className="space-y-2">
<Label>{t("settings:utilityAgentProfile")}</Label>
<UtilityAgentProfilePicker
profiles={profiles}
value={form.profile_id}
onValueChange={(value) => setForm((current) => ({ ...current, profile_id: value }))}
unavailableValue={
form.profile_id && !eligible.some((profile) => profile.id === form.profile_id)
? form.profile_id
: undefined
}
testId="utility-profile-picker-custom"
triggerClassName="w-full"
/>
{form.profile_id && !eligible.some((profile) => profile.id === form.profile_id) && (
<p className="text-xs text-destructive">{t("settings:utilityProfileNeedsRepair")}</p>
)}
</div>
<div className="space-y-2">
<Label>{t("settings:utilityAgentPromptTemplate")}</Label>
<SettingsPromptEditor
value={form.prompt}
onChange={(value) => setForm((current) => ({ ...current, prompt: value }))}
language="plaintext"
height="200px"
placeholders={placeholders}
testId="utility-agent-prompt-editor"
help={
<p className="text-xs text-muted-foreground">
<Trans
i18nKey="settings:utilityAgentPromptHint"
values={{ sigil: PROMPT_VARIABLE_SIGIL }}
>
Type <code>{PROMPT_VARIABLE_SIGIL}</code> to see available variables with
autocomplete
</Trans>
</p>
}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} className="cursor-pointer">
{t("settings:cancel")}
</Button>
<Button
onClick={submit}
disabled={saving || !form.name.trim() || !form.prompt.trim() || !form.profile_id}
className="cursor-pointer"
>
{submitLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}