Files
kandev/apps/web/components/settings/workflow-step-agent-profile-selector.tsx
T
2026-09-02 19:50:14 +00:00

507 lines
17 KiB
TypeScript

"use client";
import { forwardRef, useEffect, useRef, useState, type ComponentPropsWithoutRef } from "react";
import { useTranslation } from "react-i18next";
import { IconChevronLeft, IconChevronRight, IconRobot, IconSelector } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@kandev/ui/command";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from "@kandev/ui/drawer";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { AgentLogo } from "@/components/agent-logo";
import type {
WorkflowProfileSessionEndPolicy,
WorkflowProfileSessionStartPolicy,
WorkflowStep,
} from "@/lib/types/http";
import {
normalizeWorkflowProfileSessionEndPolicy,
normalizeWorkflowProfileSessionStartPolicy,
} from "@/lib/types/http";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useHealthyAgentProfiles } from "@/hooks/domains/settings/use-healthy-agent-profiles";
import { settingsControlClassName } from "./settings-control";
import { HelpTip, hasOnEnterAction } from "./workflow-pipeline-editor-helpers";
import { isWorkflowStepValueDirty } from "./workflow-dirty-state";
type StartOption = {
value: WorkflowProfileSessionStartPolicy;
labelKey: string;
descriptionKey: string;
shortLabelKey: string;
};
type EndOption = {
value: WorkflowProfileSessionEndPolicy;
labelKey: string;
descriptionKey: string;
shortLabelKey: string;
};
const START_OPTIONS: StartOption[] = [
{
value: "reuse",
labelKey: "workflows:profileSessionStartReuse",
descriptionKey: "workflows:profileSessionStartReuseDescription",
shortLabelKey: "workflows:profileSessionStartReuseShort",
},
{
value: "new",
labelKey: "workflows:profileSessionStartNew",
descriptionKey: "workflows:profileSessionStartNewDescription",
shortLabelKey: "workflows:profileSessionStartNewShort",
},
];
const END_OPTIONS: EndOption[] = [
{
value: "complete",
labelKey: "workflows:profileSessionEndComplete",
descriptionKey: "workflows:profileSessionEndCompleteDescription",
shortLabelKey: "workflows:profileSessionEndCompleteShort",
},
{
value: "park",
labelKey: "workflows:profileSessionEndPark",
descriptionKey: "workflows:profileSessionEndParkDescription",
shortLabelKey: "workflows:profileSessionEndParkShort",
},
];
const PROFILE_SESSION_LIFECYCLE_KEY = "workflows:profileSessionLifecycle";
type SelectorView = "profiles" | "session";
type WorkflowStepAgentProfileSelectorProps = {
step: WorkflowStep;
savedStep?: WorkflowStep;
onUpdate: (updates: Partial<WorkflowStep>) => void;
readOnly: boolean;
};
function startOption(value: unknown) {
const normalized = normalizeWorkflowProfileSessionStartPolicy(value);
return START_OPTIONS.find((option) => option.value === normalized) ?? START_OPTIONS[0];
}
function endOption(value: unknown) {
const normalized = normalizeWorkflowProfileSessionEndPolicy(value);
return END_OPTIONS.find((option) => option.value === normalized) ?? END_OPTIONS[0];
}
function lifecycleSummary(step: WorkflowStep, translate: (key: string) => string): string {
return `${translate(startOption(step.profile_session_start_policy).shortLabelKey)} · ${translate(endOption(step.profile_session_end_policy).shortLabelKey)}`;
}
function ProfileOptionList({
step,
profiles,
readOnly,
onSelect,
}: {
step: WorkflowStep;
profiles: ReturnType<typeof useHealthyAgentProfiles>;
readOnly: boolean;
onSelect: (profileId: string) => void;
}) {
const { t } = useTranslation();
return (
<Command shouldFilter>
<CommandInput
autoFocus
placeholder={t("agents:searchDynamicCandidates")}
aria-label={t("agents:searchDynamicCandidates")}
/>
<CommandList
className="max-h-none !overflow-visible overscroll-contain"
onWheel={(event) => event.stopPropagation()}
>
<CommandEmpty>{t("agents:profileNotFound")}</CommandEmpty>
<CommandGroup>
<CommandItem
value={`none ${t("workflows:noProfileOverride")}`}
disabled={readOnly}
aria-selected={!step.agent_profile_id}
data-checked={!step.agent_profile_id}
data-testid={`${step.id}-profile-option-none`}
onSelect={() => onSelect("")}
className="min-h-11 cursor-pointer"
>
<IconRobot className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="truncate">{t("workflows:noProfileOverride")}</span>
</CommandItem>
{profiles.map((profile) => (
<CommandItem
key={profile.id}
value={`${profile.id} ${profile.label}`}
disabled={readOnly}
aria-selected={step.agent_profile_id === profile.id}
data-checked={step.agent_profile_id === profile.id}
data-testid={`${step.id}-profile-option-${profile.id}`}
onSelect={() => onSelect(profile.id)}
className="min-h-11 cursor-pointer"
>
<AgentLogo agentName={profile.agent_name} className="shrink-0" />
<span className="truncate">{profile.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}
function LifecycleOptionList({
step,
readOnly,
onStartSelect,
onEndSelect,
}: {
step: WorkflowStep;
readOnly: boolean;
onStartSelect: (policy: WorkflowProfileSessionStartPolicy) => void;
onEndSelect: (policy: WorkflowProfileSessionEndPolicy) => void;
}) {
const { t } = useTranslation();
const selectedStart = normalizeWorkflowProfileSessionStartPolicy(
step.profile_session_start_policy,
);
const selectedEnd = normalizeWorkflowProfileSessionEndPolicy(step.profile_session_end_policy);
return (
<div className="space-y-4 p-3">
<p className="text-xs leading-relaxed text-muted-foreground">
{t("workflows:profileSessionLifecycleIntro")}
</p>
<fieldset className="space-y-1">
<legend className="px-1 pb-1 text-xs font-medium text-muted-foreground">
{t("workflows:profileSessionStartHeading")}
</legend>
{START_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
disabled={readOnly}
className="flex min-h-11 w-full cursor-pointer flex-col items-start justify-center rounded-md border border-transparent px-3 py-2 text-left hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[selected=true]:border-primary/40 data-[selected=true]:bg-primary/5"
data-selected={selectedStart === option.value}
aria-pressed={selectedStart === option.value}
data-testid={`${step.id}-profile-session-start-${option.value}`}
onClick={() => onStartSelect(option.value)}
>
<span className="font-medium">{t(option.labelKey)}</span>
<span className="text-xs leading-relaxed text-muted-foreground">
{t(option.descriptionKey)}
</span>
</button>
))}
</fieldset>
<fieldset className="space-y-1">
<legend className="px-1 pb-1 text-xs font-medium text-muted-foreground">
{t("workflows:profileSessionEndHeading")}
</legend>
{END_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
disabled={readOnly}
className="flex min-h-11 w-full cursor-pointer flex-col items-start justify-center rounded-md border border-transparent px-3 py-2 text-left hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[selected=true]:border-primary/40 data-[selected=true]:bg-primary/5"
data-selected={selectedEnd === option.value}
aria-pressed={selectedEnd === option.value}
data-testid={`${step.id}-profile-session-end-${option.value}`}
onClick={() => onEndSelect(option.value)}
>
<span className="font-medium">{t(option.labelKey)}</span>
<span className="text-xs leading-relaxed text-muted-foreground">
{t(option.descriptionKey)}
</span>
</button>
))}
</fieldset>
</div>
);
}
function LifecycleNavigation({
step,
readOnly,
onOpen,
}: {
step: WorkflowStep;
readOnly: boolean;
onOpen: () => void;
}) {
const { t } = useTranslation();
return (
<button
type="button"
className="flex min-h-11 w-full cursor-pointer items-center gap-3 rounded-md border border-border/70 px-3 py-2 text-left hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={readOnly}
data-testid={`${step.id}-profile-session-lifecycle-select`}
onClick={onOpen}
>
<span className="min-w-0 flex-1">
<span className="block text-xs font-medium text-muted-foreground">
{t(PROFILE_SESSION_LIFECYCLE_KEY)}
</span>
<span className="block truncate font-medium">{lifecycleSummary(step, t)}</span>
</span>
<IconChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
</button>
);
}
function SelectorSurface({
step,
profiles,
readOnly,
view,
setView,
onUpdate,
onClose,
mobile,
}: {
step: WorkflowStep;
profiles: ReturnType<typeof useHealthyAgentProfiles>;
readOnly: boolean;
view: SelectorView;
setView: (view: SelectorView) => void;
onUpdate: (updates: Partial<WorkflowStep>) => void;
onClose: () => void;
mobile: boolean;
}) {
const { t } = useTranslation();
const hasConditionalSessionConfig = hasOnEnterAction(step, "configure_session");
const selectProfile = (profileId: string) => {
if (readOnly || hasConditionalSessionConfig) return;
onUpdate({ agent_profile_id: profileId });
onClose();
};
if (view === "session") {
return (
<div className="max-h-[min(70vh,32rem)] min-w-0 overflow-y-auto overscroll-contain">
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="h-9 w-9 shrink-0 cursor-pointer"
aria-label={t("common:back")}
onClick={() => setView("profiles")}
>
<IconChevronLeft className="h-4 w-4" aria-hidden="true" />
</Button>
<div className="min-w-0">
{mobile ? (
<DrawerTitle>{t(PROFILE_SESSION_LIFECYCLE_KEY)}</DrawerTitle>
) : (
<p className="font-medium">{t(PROFILE_SESSION_LIFECYCLE_KEY)}</p>
)}
<p className="truncate text-xs text-muted-foreground">
{t("workflows:profileSessionLifecycleIntro")}
</p>
</div>
</div>
<LifecycleOptionList
step={step}
readOnly={readOnly}
onStartSelect={(profileSessionStartPolicy) =>
onUpdate({ profile_session_start_policy: profileSessionStartPolicy })
}
onEndSelect={(profileSessionEndPolicy) =>
onUpdate({ profile_session_end_policy: profileSessionEndPolicy })
}
/>
</div>
);
}
return (
<div className="max-h-[min(70vh,32rem)] min-w-0 overflow-y-auto overscroll-contain">
{mobile ? (
<DrawerHeader className="border-b border-border px-4 pb-3 pt-2 text-left">
<DrawerTitle>{t("workflows:agentProfile")}</DrawerTitle>
<DrawerDescription>{t(PROFILE_SESSION_LIFECYCLE_KEY)}</DrawerDescription>
</DrawerHeader>
) : null}
<ProfileOptionList
step={step}
profiles={profiles}
readOnly={readOnly || hasConditionalSessionConfig}
onSelect={selectProfile}
/>
<div className="border-t border-border p-2">
<LifecycleNavigation step={step} readOnly={readOnly} onOpen={() => setView("session")} />
</div>
</div>
);
}
type SelectorTriggerProps = {
selectedProfile: ReturnType<typeof useHealthyAgentProfiles>[number] | undefined;
summary: string;
open: boolean;
disabled: boolean;
dirty: boolean;
onOpen?: () => void;
} & ComponentPropsWithoutRef<"button">;
const SelectorTrigger = forwardRef<HTMLButtonElement, SelectorTriggerProps>(
function SelectorTrigger(
{ selectedProfile, summary, open, disabled, dirty, onOpen, onClick, ...triggerProps },
ref,
) {
const { t } = useTranslation();
return (
<Button
ref={ref}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
aria-label={t("workflows:agentProfile")}
disabled={disabled}
data-testid="step-agent-profile-select"
data-settings-dirty={dirty}
onClick={(event) => {
onClick?.(event);
onOpen?.();
}}
{...triggerProps}
className={settingsControlClassName(
"h-auto w-full min-w-0 cursor-pointer justify-between px-2 text-left sm:w-[280px]",
)}
>
<span className="flex min-w-0 items-center gap-2">
{selectedProfile ? (
<AgentLogo agentName={selectedProfile.agent_name} className="shrink-0" />
) : (
<IconRobot className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
)}
<span className="min-w-0 truncate">
<span className="block truncate">
{selectedProfile?.label ?? t("workflows:noProfileOverride")}
</span>
<span className="block truncate text-[0.65rem] text-muted-foreground">{summary}</span>
</span>
</span>
<IconSelector className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden="true" />
</Button>
);
},
);
export function WorkflowStepAgentProfileSelector({
step,
savedStep,
onUpdate,
readOnly,
}: WorkflowStepAgentProfileSelectorProps) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const profiles = useHealthyAgentProfiles(step.agent_profile_id);
const [open, setOpen] = useState(false);
const [view, setView] = useState<SelectorView>("profiles");
const triggerRef = useRef<HTMLButtonElement>(null);
const wasOpenRef = useRef(false);
const selectedProfile = profiles.find((profile) => profile.id === step.agent_profile_id);
const hasConditionalSessionConfig = hasOnEnterAction(step, "configure_session");
const disabled = readOnly;
const dirty = isWorkflowStepValueDirty(step, savedStep, (item) =>
JSON.stringify({
agent_profile_id: item.agent_profile_id ?? "",
profile_session_start_policy: normalizeWorkflowProfileSessionStartPolicy(
item.profile_session_start_policy,
),
profile_session_end_policy: normalizeWorkflowProfileSessionEndPolicy(
item.profile_session_end_policy,
),
}),
);
useEffect(() => {
if (wasOpenRef.current && !open) {
requestAnimationFrame(() => triggerRef.current?.focus({ preventScroll: true }));
}
wasOpenRef.current = open;
}, [open]);
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen);
if (nextOpen) setView("profiles");
};
const trigger = (
<SelectorTrigger
selectedProfile={selectedProfile}
summary={lifecycleSummary(step, t)}
open={open}
disabled={disabled}
dirty={dirty}
ref={triggerRef}
onOpen={isMobile ? () => handleOpenChange(true) : undefined}
/>
);
const surface = (
<SelectorSurface
step={step}
profiles={profiles}
readOnly={readOnly}
view={view}
setView={setView}
onUpdate={onUpdate}
onClose={() => setOpen(false)}
mobile={isMobile}
/>
);
return (
<div className="flex w-full min-w-0 flex-wrap items-center gap-2 sm:w-auto">
{isMobile ? (
<Drawer open={open} onOpenChange={handleOpenChange} shouldScaleBackground={false}>
{trigger}
<DrawerContent className="max-h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] overflow-hidden pb-[max(0.75rem,env(safe-area-inset-bottom))]">
{surface}
</DrawerContent>
</Drawer>
) : (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
className="w-[min(24rem,calc(100vw-2rem))] overflow-hidden p-0"
align="start"
onWheel={(event) => event.stopPropagation()}
onCloseAutoFocus={(event) => {
event.preventDefault();
triggerRef.current?.focus({ preventScroll: true });
}}
>
{surface}
</PopoverContent>
</Popover>
)}
<HelpTip
testId={`${step.id}-agent-profile-help`}
text={
hasConditionalSessionConfig
? t("workflows:removeConditionalSessionConfigBeforeProfile")
: t("workflows:overrideAgentProfileHelp")
}
/>
</div>
);
}