Files
kandev/apps/web/components/agent/cli-profile-editor.tsx
T

542 lines
16 KiB
TypeScript

"use client";
import { useCallback, useMemo, useState } from "react";
import { Label } from "@kandev/ui/label";
import { Input } from "@kandev/ui/input";
import { Switch } from "@kandev/ui/switch";
import { Button } from "@kandev/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { useAppStore } from "@/components/state-provider";
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
import { ModelCombobox } from "@/components/settings/model-combobox";
import { ModeCombobox } from "@/components/settings/mode-combobox";
import { CLIFlagsField } from "@/components/settings/cli-flags-field";
import {
createAgentAction,
createAgentProfileAction,
updateAgentProfileAction,
} from "@/app/actions/agents";
import type { Agent, AgentProfile, AvailableAgent, CLIFlag } from "@/lib/types/http";
import { seedDefaultCLIFlags } from "@/lib/cli-flags";
import {
buildDefaultPermissions,
PERMISSION_APPLY_AGENTCTL_AUTO_APPROVE,
} from "@/lib/agent-permissions";
import { useTranslation } from "react-i18next";
export type CliProfileEditorMode = "create" | "edit";
export type CliProfileEditorProps = {
/** "create" mounts the inline create flow; "edit" patches an existing profile. */
mode: CliProfileEditorMode;
/** When `mode === "edit"`, the existing profile to patch. */
profile?: AgentProfile;
/** Default profile name suggestion in create mode. */
defaultProfileName?: string;
/** Called once a profile is successfully saved. Receives the saved profile. */
onSaved: (profile: AgentProfile) => void;
/** Called on cancel; only used by callers that wrap us in a dialog. */
onCancel?: () => void;
/**
* Show the advanced (permissions / passthrough) toggles. Defaults to
* collapsed; the wizard hides them entirely, the configuration tab
* exposes them inline.
*/
showAdvanced?: boolean;
/** Whether the editor exposes CLI passthrough. Office agents should keep ACP enabled. */
allowCliPassthrough?: boolean;
};
type FormState = {
agentName: string;
profileName: string;
model: string;
mode: string;
cliFlags: CLIFlag[];
cliPassthrough: boolean;
allowIndexing: boolean;
autoApprove: boolean;
};
function pickDefaultAgent(available: AvailableAgent[]): AvailableAgent | undefined {
return available.find((a) => a.available) ?? available[0];
}
function fromExistingProfile(profile: AgentProfile): FormState {
return {
agentName: profile.agentId ?? "",
profileName: profile.name,
model: profile.model ?? "",
mode: profile.mode ?? "",
cliFlags: profile.cliFlags ?? [],
cliPassthrough: profile.cliPassthrough ?? false,
allowIndexing: profile.allowIndexing ?? false,
autoApprove: profile.autoApprove ?? false,
};
}
function fromDefaultAgent(
defaultName: string,
defaultAgent: AvailableAgent | undefined,
): FormState {
const cfg = defaultAgent?.model_config;
const permissionSettings = defaultAgent?.permission_settings ?? {};
const defaults = buildDefaultPermissions(permissionSettings);
return {
agentName: defaultAgent?.name ?? "",
profileName: defaultName,
model: cfg?.default_model ?? "",
mode: cfg?.current_mode_id ?? "",
cliFlags: seedDefaultCLIFlags(permissionSettings),
cliPassthrough: false,
...defaults,
};
}
function initialState(
mode: CliProfileEditorMode,
profile: AgentProfile | undefined,
defaultName: string,
defaultAgent: AvailableAgent | undefined,
): FormState {
if (mode === "edit" && profile) return fromExistingProfile(profile);
return fromDefaultAgent(defaultName, defaultAgent);
}
function ProfileNameField({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const { t } = useTranslation();
return (
<div>
<Label htmlFor="cli-profile-name">{t("common:profileName")}</Label>
<Input
id="cli-profile-name"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={t("common:default")}
className="mt-1"
/>
</div>
);
}
export function CliProfileEditor({
mode,
profile,
defaultProfileName = "default",
onSaved,
onCancel,
showAdvanced = false,
allowCliPassthrough = true,
}: CliProfileEditorProps) {
const availableAgents = useAvailableAgents();
const settingsAgents = useAppStore((s) => s.settingsAgents.items);
const installed = useMemo(
() => availableAgents.items.filter((a) => a.available),
[availableAgents.items],
);
const [form, setForm] = useState<FormState>(() =>
initialState(mode, profile, defaultProfileName, pickDefaultAgent(installed)),
);
const [saving, setSaving] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(showAdvanced);
const currentAgent = installed.find((a) => a.name === form.agentName);
const modelConfig = currentAgent?.model_config;
const permissionSettings = currentAgent?.permission_settings;
const patch = useCallback((p: Partial<FormState>) => {
setForm((prev) => ({ ...prev, ...p }));
}, []);
const handleSave = useCallback(async () => {
if (!form.agentName || !form.profileName.trim()) return;
setSaving(true);
try {
const saved =
mode === "edit" && profile
? await saveExistingProfile(profile.id, form)
: await saveNewProfile(form, settingsAgents);
onSaved(saved);
} finally {
setSaving(false);
}
}, [form, mode, profile, settingsAgents, onSaved]);
const canSave = form.agentName !== "" && form.profileName.trim() !== "";
return (
<div className="space-y-4" data-testid="cli-profile-editor">
{mode === "create" && (
<CliClientPicker
installed={installed}
value={form.agentName}
onChange={(name) =>
patch({
agentName: name,
model: installed.find((a) => a.name === name)?.model_config.default_model ?? "",
mode: installed.find((a) => a.name === name)?.model_config.current_mode_id ?? "",
cliFlags: seedDefaultCLIFlags(
installed.find((a) => a.name === name)?.permission_settings ?? {},
),
})
}
/>
)}
<ProfileNameField
value={form.profileName}
onChange={(profileName) => patch({ profileName })}
/>
<ModelModeFields
modelConfig={modelConfig ?? null}
model={form.model}
mode={form.mode}
onModelChange={(v) => patch({ model: v })}
onModeChange={(v) => patch({ mode: v })}
/>
<AdvancedToggles
open={advancedOpen}
onToggle={() => setAdvancedOpen((o) => !o)}
cliPassthrough={form.cliPassthrough}
allowIndexing={form.allowIndexing}
autoApprove={form.autoApprove}
cliFlags={form.cliFlags}
permissionSettings={permissionSettings ?? {}}
allowCliPassthrough={allowCliPassthrough}
showAllowIndexing={Boolean(permissionSettings?.allow_indexing?.supported)}
onCliPassthroughChange={(v) => patch({ cliPassthrough: v })}
onAllowIndexingChange={(v) => patch({ allowIndexing: v })}
onAutoApproveChange={(v) => patch({ autoApprove: v })}
onCliFlagsChange={(v) => patch({ cliFlags: v })}
/>
<EditorFooter
mode={mode}
saving={saving}
canSave={canSave}
onSave={handleSave}
onCancel={onCancel}
/>
</div>
);
}
function buttonLabel(mode: CliProfileEditorMode, saving: boolean): string {
if (saving) return mode === "edit" ? "Saving..." : "Creating...";
return mode === "edit" ? "Save profile" : "Create profile";
}
function EditorFooter({
mode,
saving,
canSave,
onSave,
onCancel,
}: {
mode: CliProfileEditorMode;
saving: boolean;
canSave: boolean;
onSave: () => void;
onCancel?: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-end gap-2 pt-2">
{onCancel && (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={saving}
className="cursor-pointer"
>
{t("common:cancel")}
</Button>
)}
<Button
type="button"
onClick={onSave}
disabled={!canSave || saving}
className="cursor-pointer"
>
{buttonLabel(mode, saving)}
</Button>
</div>
);
}
function CliClientPicker({
installed,
value,
onChange,
}: {
installed: AvailableAgent[];
value: string;
onChange: (name: string) => void;
}) {
const { t } = useTranslation();
return (
<div>
<Label>{t("common:cliClient")}</Label>
<Select value={value} onValueChange={onChange} disabled={installed.length === 0}>
<SelectTrigger className="mt-1 cursor-pointer">
<SelectValue placeholder={t("common:pickACliClient")} />
</SelectTrigger>
<SelectContent>
{installed.map((agent) => (
<SelectItem key={agent.name} value={agent.name} className="cursor-pointer">
{agent.display_name || agent.name}
</SelectItem>
))}
</SelectContent>
</Select>
{installed.length === 0 && (
<p className="text-xs text-muted-foreground mt-1">
{t("common:noCliClientsInstalledYetInstall")}
</p>
)}
</div>
);
}
type ModelModeFieldsProps = {
modelConfig: NonNullable<AvailableAgent["model_config"]> | null;
model: string;
mode: string;
onModelChange: (v: string) => void;
onModeChange: (v: string) => void;
};
function ModelModeFields({
modelConfig,
model,
mode,
onModelChange,
onModeChange,
}: ModelModeFieldsProps) {
const { t } = useTranslation();
if (!modelConfig) {
return <p className="text-xs text-muted-foreground">{t("common:pickACliClientToLoad")}</p>;
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label>{t("common:model")}</Label>
<ModelCombobox
value={model}
onChange={onModelChange}
models={modelConfig.available_models ?? []}
currentModelId={modelConfig.current_model_id}
/>
</div>
{(modelConfig.available_modes ?? []).length > 0 && (
<div>
<Label>{t("common:mode")}</Label>
<ModeCombobox
value={mode}
onChange={onModeChange}
modes={modelConfig.available_modes ?? []}
currentModeId={modelConfig.current_mode_id}
/>
</div>
)}
</div>
);
}
type AdvancedTogglesProps = {
open: boolean;
onToggle: () => void;
cliPassthrough: boolean;
allowIndexing: boolean;
autoApprove: boolean;
cliFlags: CLIFlag[];
permissionSettings: AvailableAgent["permission_settings"];
allowCliPassthrough: boolean;
showAllowIndexing: boolean;
onCliPassthroughChange: (v: boolean) => void;
onAllowIndexingChange: (v: boolean) => void;
onAutoApproveChange: (v: boolean) => void;
onCliFlagsChange: (v: CLIFlag[]) => void;
};
function AdvancedToggles({
open,
onToggle,
cliPassthrough,
allowIndexing,
autoApprove,
cliFlags,
permissionSettings,
allowCliPassthrough,
showAllowIndexing,
onCliPassthroughChange,
onAllowIndexingChange,
onAutoApproveChange,
onCliFlagsChange,
}: AdvancedTogglesProps) {
const { t } = useTranslation();
const autoSetting = permissionSettings?.auto_approve;
const showAgentctlAutoApprove = Boolean(
autoSetting?.supported && autoSetting.apply_method === PERMISSION_APPLY_AGENTCTL_AUTO_APPROVE,
);
return (
<div className="border-t pt-3">
<button
type="button"
onClick={onToggle}
className="text-xs text-muted-foreground hover:text-foreground cursor-pointer"
>
{open ? t("common:hideAdvancedOptions") : t("common:showAdvancedOptions")}
</button>
{open && (
<div className="mt-3 space-y-3">
{allowCliPassthrough && (
<ToggleRow
id="cli-passthrough"
label={t("common:cliPassthrough")}
description={t("common:forwardStdinStdoutStraightToThe")}
checked={cliPassthrough}
onChange={onCliPassthroughChange}
/>
)}
{showAgentctlAutoApprove && autoSetting && (
<AgentctlAutoApproveRow
id="cli-auto-approve"
setting={autoSetting}
checked={autoApprove}
onChange={onAutoApproveChange}
/>
)}
{showAllowIndexing && (
<ToggleRow
id="allow-indexing"
label={t("common:allowIndexing")}
description={t("common:permitTheCliToUploadCode")}
checked={allowIndexing}
onChange={onAllowIndexingChange}
/>
)}
<CLIFlagsField
flags={cliFlags}
permissionSettings={permissionSettings ?? {}}
onChange={onCliFlagsChange}
variant="compact"
/>
</div>
)}
</div>
);
}
function ToggleRow({
id,
label,
description,
checked,
onChange,
}: {
id: string;
label: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-start justify-between gap-3">
<div className="space-y-0.5">
<Label htmlFor={id} className="text-sm">
{label}
</Label>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<Switch id={id} checked={checked} onCheckedChange={onChange} className="cursor-pointer" />
</div>
);
}
function AgentctlAutoApproveRow({
id,
setting,
checked,
onChange,
}: {
id: string;
setting: NonNullable<AvailableAgent["permission_settings"]>["auto_approve"];
checked: boolean;
onChange: (v: boolean) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-start justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2">
<div className="space-y-0.5">
<Label htmlFor={id} className="text-sm text-destructive">
{setting?.label ?? t("common:autoApproveAllPermissions")}
</Label>
<p className="text-xs text-muted-foreground">
{setting?.description ?? t("common:kandevAllowsEveryAgentPermissionRequest")}
</p>
</div>
<Switch
id={id}
checked={checked}
onCheckedChange={onChange}
className="cursor-pointer data-[state=checked]:bg-destructive"
/>
</div>
);
}
async function saveExistingProfile(id: string, form: FormState): Promise<AgentProfile> {
return updateAgentProfileAction(id, {
name: form.profileName.trim(),
model: form.model,
mode: form.mode || undefined,
allow_indexing: form.allowIndexing,
auto_approve: form.autoApprove,
cli_flags: form.cliFlags,
cli_passthrough: form.cliPassthrough,
});
}
async function saveNewProfile(form: FormState, settingsAgents: Agent[]): Promise<AgentProfile> {
const existingAgent = settingsAgents.find((a) => a.name === form.agentName);
const profilePayload = {
name: form.profileName.trim(),
model: form.model,
mode: form.mode || undefined,
allow_indexing: form.allowIndexing,
auto_approve: form.autoApprove,
cli_passthrough: form.cliPassthrough,
cli_flags: form.cliFlags,
};
if (existingAgent) {
return createAgentProfileAction(existingAgent.id, profilePayload);
}
// Otherwise create the kanban Agent row + the first profile in one POST.
const created = await createAgentAction({
name: form.agentName,
profiles: [profilePayload],
});
const newProfile = created.profiles.find((p) => p.name === profilePayload.name);
if (!newProfile) {
throw new Error(
`Created agent ${form.agentName} but the response did not include the new profile.`,
);
}
return {
...newProfile,
agentId: newProfile.agentId || created.id,
agentDisplayName: newProfile.agentDisplayName || created.name,
};
}