Files
kandev/apps/web/components/settings/agents/agent-profiles-section.tsx
T
2026-09-12 14:58:49 +02:00

393 lines
13 KiB
TypeScript

"use client";
import { useRef, useState, type RefObject } from "react";
import { useTranslation } from "react-i18next";
import { IconCopy, IconDotsVertical, IconTrash } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Card, CardContent } from "@kandev/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import Link from "@/components/routing/app-link";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { AgentProfileDeleteConfirmation } from "@/components/settings/agent-profile-delete-dialog";
import { deleteAgentProfileAction } from "@/app/actions/agents";
import { useProfileDuplicate } from "@/hooks/domains/settings/use-profile-duplicate";
import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useConfirmationBoundary } from "@/components/confirmation/mobile-action-confirmation";
import { useRouter } from "@/lib/routing/client-router";
import { toAgentProfileOption } from "@/lib/state/slices/settings/types";
import type { Agent, AgentProfile } from "@/lib/types/http";
import { RecordDot } from "@/components/settings/record-dot";
import { DisabledBadge } from "@/components/settings/record-badges";
import { settingsActionClassName } from "@/components/settings/settings-control";
function profileHref(agentName: string, profileId: string): string {
return `/settings/agents/${encodeURIComponent(agentName)}/profiles/${encodeURIComponent(profileId)}`;
}
/**
* An agent's profiles inside its group card on the Agents page: one clickable
* row per profile (no agent branding or duplicate creation controls because
* the group header already names the agent and owns its action).
*/
export function AgentProfilesSubList({
savedAgent,
agentName,
}: {
savedAgent: Agent | undefined;
agentName: string;
}) {
if (!savedAgent || savedAgent.profiles.length === 0) return null;
return (
<div
className="border-t border-border/70 bg-background p-3"
data-testid={`agent-profiles-${agentName}`}
>
<div className="grid gap-2">
{savedAgent.profiles.map((profile) => (
<ProfileRow key={profile.id} agent={savedAgent} profile={profile} />
))}
</div>
</div>
);
}
/**
* The per-profile actions dropdown (duplicate, delete). The trigger exposes a
* touch-sized hitbox (>= 44px) so the row actions are reachable on mobile.
* Duplication uses the shared useProfileDuplicate hook (per-profile in-flight
* guard, revision-aware store merge).
*/
function ProfileRowActions({
profile,
deleteAnchorRef,
onDuplicate,
onConfirmDelete,
}: {
profile: AgentProfile;
deleteAnchorRef: RefObject<HTMLButtonElement | null>;
onDuplicate: () => void;
onConfirmDelete: () => void;
}) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const pendingDelete = useRef(false);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
ref={deleteAnchorRef}
variant="ghost"
size="icon"
className={settingsActionClassName("cursor-pointer")}
aria-label={t("agents:profileActions")}
data-testid={`profile-actions-menu-${profile.id}`}
>
<IconDotsVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => {
if (!pendingDelete.current) return;
pendingDelete.current = false;
event.preventDefault();
onConfirmDelete();
}}
>
{profile.kind !== "dynamic" && (
<DropdownMenuItem
className="cursor-pointer"
data-testid={`duplicate-profile-${profile.id}`}
onSelect={onDuplicate}
>
<IconCopy className="h-4 w-4 mr-2" />
{t("agents:duplicate")}
</DropdownMenuItem>
)}
<DropdownMenuItem
className="cursor-pointer text-destructive focus:text-destructive"
data-testid={`delete-profile-${profile.id}`}
onSelect={() => {
if (isMobile) pendingDelete.current = true;
else onConfirmDelete();
}}
>
<IconTrash className="h-4 w-4 mr-2" />
{t("agents:delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function ProfileRowInlineActions({
profile,
deleteAnchorRef,
onDuplicate,
onConfirmDelete,
}: {
profile: AgentProfile;
deleteAnchorRef: RefObject<HTMLButtonElement | null>;
onDuplicate: () => void;
onConfirmDelete: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-1" data-testid={`profile-actions-inline-${profile.id}`}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="cursor-pointer"
data-testid={`duplicate-profile-inline-${profile.id}`}
onClick={onDuplicate}
aria-label={t("agents:duplicate")}
>
<IconCopy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">{t("agents:duplicate")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
ref={deleteAnchorRef}
type="button"
variant="destructive"
size="icon"
className="cursor-pointer"
data-testid={`delete-profile-inline-${profile.id}`}
onClick={onConfirmDelete}
aria-label={t("agents:delete")}
>
<IconTrash className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">{t("agents:delete")}</TooltipContent>
</Tooltip>
</div>
);
}
type ProfileRowDeleteConfirmationProps = {
profileId: string;
profileName: string;
open: boolean;
isFinePointer: boolean;
anchorRef: RefObject<HTMLButtonElement | null>;
onOpenChange: (open: boolean) => void;
onCancel: () => void;
onConfirm: () => void | Promise<void>;
placement: "inline" | "popover";
};
type ProfileRowDeleteConfirmationBaseProps = Omit<ProfileRowDeleteConfirmationProps, "placement">;
function ProfileRowDeleteConfirmation({
profileId,
profileName,
open,
isFinePointer,
anchorRef,
onOpenChange,
onCancel,
onConfirm,
placement,
}: ProfileRowDeleteConfirmationProps) {
const { isMobile } = useResponsiveBreakpoint();
if (placement === "inline" && (isMobile || isFinePointer || !open)) return null;
if (placement === "popover" && !isMobile && !isFinePointer) return null;
const confirmation = (
<AgentProfileDeleteConfirmation
profileId={profileId}
profileName={profileName}
open={open}
isFinePointer={isFinePointer}
anchorRef={anchorRef}
onOpenChange={onOpenChange}
onCancel={onCancel}
onConfirm={onConfirm}
/>
);
return placement === "inline" ? (
<div className="relative z-10 basis-full min-w-0">{confirmation}</div>
) : (
confirmation
);
}
type ProfileRowCardProps = {
profile: AgentProfile;
href: string;
canManage: boolean;
confirmOpen: boolean;
isFinePointer: boolean;
isFullDesktop: boolean;
deleteAnchorRef: RefObject<HTMLButtonElement | null>;
onDuplicate: () => void;
onConfirmDelete: () => void;
confirmationProps: ProfileRowDeleteConfirmationBaseProps;
};
function ProfileRowCard({
profile,
href,
canManage,
confirmOpen,
isFinePointer,
isFullDesktop,
deleteAnchorRef,
onDuplicate,
onConfirmDelete,
confirmationProps,
}: ProfileRowCardProps) {
const { isMobile } = useResponsiveBreakpoint();
return (
<Card
// Same surface treatment as the workspace section tiles.
className="relative gap-0 border-border/70 bg-background/50 py-1.5 transition-colors hover:border-foreground/30 hover:bg-muted/50"
data-testid="agent-profile-row"
>
{/* Whole-card link as an overlay — the action buttons sit above it (z-10). */}
<Link
href={href}
aria-label={profile.name}
className="absolute inset-0"
data-testid="agent-profile-row-link"
/>
<CardContent className="flex items-center justify-between gap-2 px-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<RecordDot />
<span className="truncate text-sm font-medium">{profile.name}</span>
{profile.enabled === false && <DisabledBadge />}
</div>
{(profile.model || profile.mode) && (
<div className="mt-0.5 flex flex-wrap items-center gap-1.5 pl-3.5">
{profile.model && <Badge variant="outline">{profile.model}</Badge>}
{profile.mode && <Badge variant="secondary">{profile.mode}</Badge>}
</div>
)}
</div>
<div className="relative z-10 flex shrink-0 items-center gap-1">
{canManage &&
(isMobile || !(confirmOpen && !isFinePointer)) &&
(isFullDesktop ? (
<ProfileRowInlineActions
profile={profile}
deleteAnchorRef={deleteAnchorRef}
onDuplicate={onDuplicate}
onConfirmDelete={onConfirmDelete}
/>
) : (
<ProfileRowActions
profile={profile}
deleteAnchorRef={deleteAnchorRef}
onDuplicate={onDuplicate}
onConfirmDelete={onConfirmDelete}
/>
))}
</div>
<ProfileRowDeleteConfirmation {...confirmationProps} placement="inline" />
</CardContent>
<ProfileRowDeleteConfirmation {...confirmationProps} placement="popover" />
</Card>
);
}
/** One saved profile as a fully clickable row — shared by the Agents index and the agent page. */
export function ProfileRow({ agent, profile }: { agent: Agent; profile: AgentProfile }) {
const canManage = useIsAdmin();
const { t } = useTranslation();
const { toast } = useToast();
const router = useRouter();
const { isFinePointer, isFullDesktop } = useResponsiveBreakpoint();
const handleDuplicate = useProfileDuplicate();
const [confirmOpen, setConfirmOpen] = useState(false);
useConfirmationBoundary(confirmOpen, profile.id, setConfirmOpen);
const deleteAnchorRef = useRef<HTMLButtonElement>(null);
const store = useAppStoreApi();
const setSettingsAgents = useAppStore((state) => state.setSettingsAgents);
const setAgentProfiles = useAppStore((state) => state.setAgentProfiles);
const href = profileHref(agent.name, profile.id);
const closeDeleteConfirmation = () => {
setConfirmOpen(false);
queueMicrotask(() => deleteAnchorRef.current?.focus());
};
const handleDelete = async () => {
setConfirmOpen(false);
const result = await deleteAgentProfileAction(profile.id);
if (result.status === "ok") {
// Read the store at write time, not at render: this closure was created
// before the await, so a snapshot taken then would be stale by now and
// two profiles deleted in quick succession would resurrect each other.
const nextAgents = store.getState().settingsAgents.items.map((item) => ({
...item,
profiles: item.profiles.filter((p) => p.id !== profile.id),
}));
setSettingsAgents(nextAgents);
// `agentProfiles` is the flattened picker list over the same data. Every
// other writer updates the pair together, and its only refetch is a
// one-shot guarded by `agentsLoaded`, so skipping it here left the
// deleted profile selectable until a reload.
setAgentProfiles(
nextAgents.flatMap((item) => item.profiles.map((p) => toAgentProfileOption(item, p))),
);
return;
}
// Conflicts (active sessions, watchers, routing tiers) carry a guided
// resolution flow that lives on the profile page — send the user there.
if (result.status === "conflict") {
toast({ title: t("agents:cannotDeleteAgentProfile"), variant: "error" });
router.push(href);
return;
}
if (result.handled) {
closeDeleteConfirmation();
return;
}
toast({
title: t("agents:cannotDeleteAgentProfile"),
description: result.message,
variant: "error",
});
closeDeleteConfirmation();
};
const confirmationProps = {
profileId: profile.id,
profileName: profile.name,
open: confirmOpen,
isFinePointer,
anchorRef: deleteAnchorRef,
onOpenChange: setConfirmOpen,
onCancel: closeDeleteConfirmation,
onConfirm: () => void handleDelete(),
};
return (
<ProfileRowCard
profile={profile}
href={href}
canManage={canManage}
confirmOpen={confirmOpen}
isFinePointer={isFinePointer}
isFullDesktop={isFullDesktop}
deleteAnchorRef={deleteAnchorRef}
onDuplicate={() => void handleDuplicate(agent, profile)}
onConfirmDelete={() => setConfirmOpen(true)}
confirmationProps={confirmationProps}
/>
);
}