Files
kandev/apps/web/components/settings/workflow-session-target-surfaces.tsx
T
2026-09-10 16:32:41 +01:00

501 lines
17 KiB
TypeScript

"use client";
import { useTranslation } from "react-i18next";
import { IconChevronLeft, IconChevronRight, IconRobot } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@kandev/ui/command";
import { AgentLogo } from "@/components/agent-logo";
import { MobilePickerSheet } from "@/components/task/mobile/mobile-picker-sheet";
import type {
WorkflowProfileSessionEndPolicy,
WorkflowProfileSessionStartPolicy,
WorkflowSessionTarget,
WorkflowStep,
} from "@/lib/types/http";
import {
normalizeWorkflowProfileSessionEndPolicy,
normalizeWorkflowProfileSessionStartPolicy,
} from "@/lib/types/http";
import { useHealthyAgentProfiles } from "@/hooks/domains/settings/use-healthy-agent-profiles";
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";
const NO_PROFILE_OVERRIDE_KEY = "workflows:noProfileOverride";
export type SelectorView = "profiles" | "session";
export type WorkflowSessionSelectorSurfaceProps = {
step: WorkflowStep;
steps: WorkflowStep[];
profiles: ReturnType<typeof useHealthyAgentProfiles>;
readOnly: boolean;
profileSelectionDisabled: boolean;
view: SelectorView;
setView: (view: SelectorView) => void;
onUpdate: (updates: Partial<WorkflowStep>) => void;
onSelectProfile: (profileId: string) => void;
onSelectTarget: (target: WorkflowSessionTarget) => void;
onClose: () => void;
};
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];
}
export 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 WorkflowSessionTargetGroup({
step,
steps,
profiles,
targetsDisabled,
onSelectTarget,
}: Pick<WorkflowSessionSelectorSurfaceProps, "step" | "steps" | "profiles" | "onSelectTarget"> & {
targetsDisabled: boolean;
}) {
const { t } = useTranslation();
const earlierProfileSteps = steps.filter(
(candidate) =>
candidate.id !== step.id &&
candidate.position < step.position &&
!!candidate.agent_profile_id &&
!candidate.session_target,
);
const selectedTarget = step.session_target;
return (
<CommandGroup heading={t("workflows:workflowSessions")}>
<CommandItem
value={"initial " + t("workflows:initialAgentSession")}
disabled={targetsDisabled}
aria-selected={selectedTarget?.kind === "initial"}
data-checked={selectedTarget?.kind === "initial"}
data-testid={step.id + "-session-target-initial"}
onSelect={() => onSelectTarget({ kind: "initial" })}
className="min-h-11 cursor-pointer"
>
<IconRobot className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="min-w-0 truncate">
<span className="block truncate">{t("workflows:initialAgentSession")}</span>
<span className="block truncate text-xs text-muted-foreground">
{t("workflows:initialAgentSessionDescription")}
</span>
</span>
</CommandItem>
{earlierProfileSteps.map((source) => {
const profile = profiles.find((candidate) => candidate.id === source.agent_profile_id);
const selected = selectedTarget?.kind === "step" && selectedTarget.step_id === source.id;
return (
<CommandItem
key={source.id}
value={source.name + " " + (profile?.label ?? source.agent_profile_id)}
disabled={targetsDisabled}
aria-selected={selected}
data-checked={selected}
data-testid={step.id + "-session-target-step-" + source.id}
onSelect={() => onSelectTarget({ kind: "step", step_id: source.id })}
className="min-h-11 cursor-pointer"
>
{profile ? (
<AgentLogo agentName={profile.agent_name} className="shrink-0" />
) : (
<IconRobot className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
)}
<span className="truncate">
{t("workflows:sessionTargetStepLabel", {
stepName: source.name,
profileName: profile?.label ?? t("workflows:profileUnavailable"),
})}
</span>
</CommandItem>
);
})}
</CommandGroup>
);
}
function AgentProfileGroup({
step,
profiles,
readOnly,
onSelect,
}: Pick<WorkflowSessionSelectorSurfaceProps, "step" | "profiles" | "readOnly"> & {
onSelect: (profileId: string) => void;
}) {
const { t } = useTranslation();
const selectedTarget = step.session_target;
return (
<CommandGroup heading={t("workflows:agentProfiles")}>
<CommandItem
value={"none " + t(NO_PROFILE_OVERRIDE_KEY)}
disabled={readOnly}
aria-selected={!step.agent_profile_id && !selectedTarget}
data-checked={!step.agent_profile_id && !selectedTarget}
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(NO_PROFILE_OVERRIDE_KEY)}</span>
</CommandItem>
{profiles.map((profile) => (
<CommandItem
key={profile.id}
value={profile.id + " " + profile.label}
disabled={readOnly}
aria-selected={!selectedTarget && step.agent_profile_id === profile.id}
data-checked={!selectedTarget && 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>
);
}
function ProfileOptionList({
step,
steps,
profiles,
readOnly,
profileSelectionDisabled,
onSelectTarget,
onSelectProfile,
}: Pick<
WorkflowSessionSelectorSurfaceProps,
| "step"
| "steps"
| "profiles"
| "readOnly"
| "profileSelectionDisabled"
| "onSelectTarget"
| "onSelectProfile"
>) {
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>
<WorkflowSessionTargetGroup
step={step}
steps={steps}
profiles={profiles}
targetsDisabled={profileSelectionDisabled}
onSelectTarget={onSelectTarget}
/>
<AgentProfileGroup
step={step}
profiles={profiles}
readOnly={readOnly || profileSelectionDisabled}
onSelect={onSelectProfile}
/>
</CommandList>
</Command>
);
}
function LifecycleOptionList({
step,
readOnly,
onStartSelect,
onEndSelect,
}: Pick<WorkflowSessionSelectorSurfaceProps, "step" | "readOnly"> & {
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,
onOpen,
}: Pick<WorkflowSessionSelectorSurfaceProps, "step"> & { 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"
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>
);
}
export function SelectorSurface({
step,
steps,
profiles,
readOnly,
profileSelectionDisabled,
view,
setView,
onUpdate,
onSelectProfile,
onSelectTarget,
}: WorkflowSessionSelectorSurfaceProps) {
const { t } = useTranslation();
if (view === "session") {
return (
<div className="min-w-0">
<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">
<p className="font-medium">{t(PROFILE_SESSION_LIFECYCLE_KEY)}</p>
<p className="truncate text-xs text-muted-foreground">
{t("workflows:profileSessionLifecycleIntro")}
</p>
</div>
</div>
<div className="max-h-[min(70vh,32rem,calc(var(--radix-popover-content-available-height)-4rem))] overflow-y-auto overscroll-contain">
<LifecycleOptionList
step={step}
readOnly={readOnly}
onStartSelect={(policy) => onUpdate({ profile_session_start_policy: policy })}
onEndSelect={(policy) => onUpdate({ profile_session_end_policy: policy })}
/>
</div>
</div>
);
}
return (
<div className="min-w-0">
<div className="border-t border-border p-2">
<LifecycleNavigation step={step} onOpen={() => setView("session")} />
</div>
<div className="max-h-[min(70vh,32rem,calc(var(--radix-popover-content-available-height)-4rem))] overflow-y-auto overscroll-contain">
<ProfileOptionList
step={step}
steps={steps}
profiles={profiles}
readOnly={readOnly}
profileSelectionDisabled={profileSelectionDisabled}
onSelectTarget={onSelectTarget}
onSelectProfile={onSelectProfile}
/>
</div>
</div>
);
}
export function MobileSelectorSurface({
step,
steps,
profiles,
readOnly,
profileSelectionDisabled,
view,
setView,
onUpdate,
onSelectProfile,
onSelectTarget,
onClose,
}: WorkflowSessionSelectorSurfaceProps) {
const { t } = useTranslation();
if (view === "session") {
return (
<MobilePickerSheet
open
onOpenChange={(open) => !open && onClose()}
title={t(PROFILE_SESSION_LIFECYCLE_KEY)}
description={t("workflows:profileSessionLifecycleIntro")}
contentTestId={step.id + "-profile-session-lifecycle-content"}
fixedContent={
<div className="border-b border-border px-3 py-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 cursor-pointer"
aria-label={t("common:back")}
onClick={() => setView("profiles")}
>
<IconChevronLeft className="mr-1 h-4 w-4" aria-hidden="true" />
{t("common:back")}
</Button>
</div>
}
>
<LifecycleOptionList
step={step}
readOnly={readOnly}
onStartSelect={(policy) => onUpdate({ profile_session_start_policy: policy })}
onEndSelect={(policy) => onUpdate({ profile_session_end_policy: policy })}
/>
</MobilePickerSheet>
);
}
return (
<MobilePickerSheet
open
onOpenChange={(open) => !open && onClose()}
title={t("workflows:agentProfile")}
description={t("workflows:sessionTargetAndProfileDescription")}
contentTestId={step.id + "-profile-picker-content"}
fixedContent={
<div className="border-b border-border p-2">
<LifecycleNavigation step={step} onOpen={() => setView("session")} />
</div>
}
>
<ProfileOptionList
step={step}
steps={steps}
profiles={profiles}
readOnly={readOnly}
profileSelectionDisabled={profileSelectionDisabled}
onSelectTarget={onSelectTarget}
onSelectProfile={onSelectProfile}
/>
</MobilePickerSheet>
);
}