Files
kandev/apps/web/app/settings/workspace/workflow-import-profile-selection.tsx
T
2026-09-18 00:34:50 +01:00

610 lines
19 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconAlertTriangle, IconCheck, IconExternalLink, IconSelector } from "@tabler/icons-react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@kandev/ui/command";
import { Button } from "@kandev/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@kandev/ui/dialog";
import { Drawer, DrawerContent, DrawerFooter } from "@kandev/ui/drawer";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
import type {
WorkflowImportPreview,
WorkflowImportProfileCandidate,
WorkflowImportProfileConflict,
WorkflowImportProfileStep,
} from "@/lib/types/http";
import {
workflowImportProfileLabel,
workflowImportStepKey,
type WorkflowImportSelections,
} from "./use-workflow-import";
import { MobileSelectionHeader } from "./workflow-import-profile-selection-mobile-header";
type WorkflowImportProfileSelectionProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
preview: WorkflowImportPreview;
selections: WorkflowImportSelections;
missingSteps: WorkflowImportProfileStep[];
profileConflicts: WorkflowImportProfileConflict[];
activeStepKey: string | null;
onActiveStepKeyChange: (key: string | null) => void;
onSelectProfile: (stepKey: string, profileId: string) => void;
onImport: () => void | Promise<void>;
onRetryPreview: () => void | Promise<void>;
importLoading: boolean;
};
const DESKTOP_CONTROL_SIZE_CLASS = "min-h-11 md:min-h-7";
const TOUCH_CONTROL_SIZE_CLASS = "min-h-11";
function profileField(value: string, translate: (key: string) => string): string {
return value || translate("workflows:importProfileNotSet");
}
function ProfileDetails({
profile,
compact = false,
}: {
profile: WorkflowImportProfileCandidate;
compact?: boolean;
}) {
const { t } = useTranslation();
return (
<span className={compact ? "flex min-w-0 flex-col text-left" : "flex min-w-0 flex-col gap-1"}>
<span className="truncate font-medium">{profile.name || profile.id}</span>
<span className="truncate text-xs text-muted-foreground">
{profileField(profile.agent_name, t)} · {profileField(profile.model, t)} ·{" "}
{profileField(profile.mode, t)}
</span>
</span>
);
}
function RequestedProfileDetails({ step }: { step: WorkflowImportProfileStep }) {
const { t } = useTranslation();
const requested = step.requested_profile;
return (
<span className="min-w-0 text-xs text-muted-foreground">
<span className="mr-1 font-medium text-foreground">
{t("workflows:importRequestedProfile")}
</span>
{profileField(requested.agent_name, t)} · {profileField(requested.model ?? "", t)} ·{" "}
{profileField(requested.mode ?? "", t)}
</span>
);
}
function ProfilePickerList({
profiles,
selectedProfileId,
onSelect,
}: {
profiles: WorkflowImportProfileCandidate[];
selectedProfileId?: string;
onSelect: (profileId: string) => void;
}) {
const { t } = useTranslation();
return (
<Command shouldFilter>
<CommandInput
autoFocus
placeholder={t("workflows:importProfileSearchPlaceholder")}
aria-label={t("workflows:importProfileSearchPlaceholder")}
/>
<CommandList className="max-h-[min(50vh,24rem)] overscroll-contain">
<CommandEmpty>{t("workflows:importProfileNoMatches")}</CommandEmpty>
<CommandGroup>
{profiles.map((profile) => (
<CommandItem
key={profile.id}
value={workflowImportProfileLabel(profile)}
keywords={[profile.id, profile.name, profile.agent_name, profile.model, profile.mode]}
onSelect={() => onSelect(profile.id)}
className="min-h-11 cursor-pointer gap-2"
data-testid={`workflow-import-profile-option-${profile.id}`}
>
<IconCheck
className={
selectedProfileId === profile.id
? "h-4 w-4 shrink-0"
: "h-4 w-4 shrink-0 opacity-0"
}
aria-hidden="true"
/>
<ProfileDetails profile={profile} compact />
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}
function conflictForStep(
conflicts: WorkflowImportProfileConflict[],
step: WorkflowImportProfileStep,
): WorkflowImportProfileConflict | undefined {
const key = workflowImportStepKey(step);
return conflicts.find((conflict) => workflowImportStepKey(conflict) === key);
}
function ConflictMessage({ conflict }: { conflict?: WorkflowImportProfileConflict }) {
const { t } = useTranslation();
if (!conflict) return null;
const messageKey =
{
changed_profile: "workflows:importProfileChanged",
unavailable_profile: "workflows:importProfileUnavailable",
missing_selection: "workflows:importProfileSelectionRequired",
}[conflict.reason] ?? "workflows:importProfileSelectionRequired";
return (
<p className="flex items-start gap-1.5 text-xs text-destructive" role="alert">
<IconAlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span>{t(messageKey)}</span>
</p>
);
}
function SelectionRow({
step,
preview,
selections,
conflicts,
mobile,
open,
onOpenChange,
onSelectProfile,
}: {
step: WorkflowImportProfileStep;
preview: WorkflowImportPreview;
selections: WorkflowImportSelections;
conflicts: WorkflowImportProfileConflict[];
mobile: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
onSelectProfile: (profileId: string) => void;
}) {
const { t } = useTranslation();
const triggerRef = useRef<HTMLButtonElement>(null);
const key = workflowImportStepKey(step);
const selectedProfileId = selections[key];
const selectedProfile = preview.profiles.find((profile) => profile.id === selectedProfileId);
const isAutomatic = step.matched_profile?.id === selectedProfileId;
const conflict = conflictForStep(conflicts, step);
const profileButton = (
<Button
type="button"
variant="outline"
ref={triggerRef}
disabled={preview.profiles.length === 0}
onClick={mobile ? () => onOpenChange(true) : undefined}
className={`${mobile ? TOUCH_CONTROL_SIZE_CLASS : DESKTOP_CONTROL_SIZE_CLASS} w-full cursor-pointer justify-start text-left`}
data-testid={`workflow-import-profile-select-${key}`}
>
{selectedProfile ? (
<span
className="min-w-0 flex-1 truncate"
title={workflowImportProfileLabel(selectedProfile)}
>
{workflowImportProfileLabel(selectedProfile)}
</span>
) : (
<span className="min-w-0 flex-1 truncate">{t("workflows:importSelectProfile")}</span>
)}
<IconSelector className="ml-auto h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
</Button>
);
let profileContent = profileButton;
if (!mobile) {
profileContent = (
<Popover modal={false} open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>{profileButton}</PopoverTrigger>
<PopoverContent
align="start"
className="w-[min(24rem,calc(100vw-2rem))] p-0"
onCloseAutoFocus={(event) => {
event.preventDefault();
triggerRef.current?.focus({ preventScroll: true });
}}
>
<ProfilePickerList
profiles={preview.profiles}
selectedProfileId={selectedProfileId}
onSelect={onSelectProfile}
/>
</PopoverContent>
</Popover>
);
}
return (
<div
className="space-y-2 rounded-lg border border-border/70 p-3"
data-testid={`workflow-import-profile-step-${key}`}
>
<div className="min-w-0 space-y-1">
<p className="truncate font-medium">{step.step_name}</p>
<RequestedProfileDetails step={step} />
</div>
{isAutomatic && selectedProfile ? (
<div
className="flex min-w-0 items-center gap-2 rounded-md bg-muted/50 p-2 text-sm"
data-testid={`workflow-import-profile-match-${key}`}
>
<IconCheck className="h-4 w-4 shrink-0 text-green-600" aria-hidden="true" />
<ProfileDetails profile={selectedProfile} compact />
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
{t("workflows:importProfileMatched")}
</span>
</div>
) : (
profileContent
)}
<ConflictMessage conflict={conflict} />
</div>
);
}
function SelectionRows({
preview,
selections,
conflicts,
mobile,
openStepKey,
onOpenStep,
onSelectProfile,
}: {
preview: WorkflowImportPreview;
selections: WorkflowImportSelections;
conflicts: WorkflowImportProfileConflict[];
mobile: boolean;
openStepKey: string | null;
onOpenStep: (key: string | null) => void;
onSelectProfile: (stepKey: string, profileId: string) => void;
}) {
const groups = new Map<number, { name: string; steps: WorkflowImportProfileStep[] }>();
for (const step of preview.steps) {
const group = groups.get(step.workflow_index) ?? { name: step.workflow_name, steps: [] };
group.steps.push(step);
groups.set(step.workflow_index, group);
}
return (
<div className="space-y-4">
{Array.from(groups.entries()).map(([workflowIndex, group]) => (
<section
key={workflowIndex}
className="space-y-2"
data-testid={`workflow-import-group-${workflowIndex}`}
>
<h3 className="font-medium">{group.name}</h3>
<div className="space-y-2">
{group.steps.map((step) => {
const key = workflowImportStepKey(step);
return (
<SelectionRow
key={key}
step={step}
preview={preview}
selections={selections}
conflicts={conflicts}
mobile={mobile}
open={openStepKey === key}
onOpenChange={(open) => onOpenStep(open ? key : null)}
onSelectProfile={(profileId) => onSelectProfile(key, profileId)}
/>
);
})}
</div>
</section>
))}
</div>
);
}
function EmptyProfileCallout({
onRetry,
touch,
}: {
onRetry: () => void | Promise<void>;
touch: boolean;
}) {
const { t } = useTranslation();
return (
<div
className="space-y-3 rounded-lg border border-dashed border-border p-4"
data-testid="workflow-import-no-profiles"
>
<p className="text-sm text-muted-foreground">{t("workflows:importNoEligibleProfiles")}</p>
<div className="flex flex-wrap items-center gap-3">
<a
href="/settings/agents"
target="_blank"
rel="noreferrer"
className={`${touch ? TOUCH_CONTROL_SIZE_CLASS : DESKTOP_CONTROL_SIZE_CLASS} inline-flex cursor-pointer items-center gap-1 text-sm text-primary underline-offset-4 hover:underline`}
>
{t("workflows:openAgentSettings")}
<IconExternalLink className="h-4 w-4" aria-hidden="true" />
</a>
<Button
type="button"
variant="outline"
className={`${touch ? TOUCH_CONTROL_SIZE_CLASS : DESKTOP_CONTROL_SIZE_CLASS} cursor-pointer`}
onClick={onRetry}
>
{t("workflows:retryImportPreview")}
</Button>
</div>
</div>
);
}
function SelectionFooter({
canImport,
importLoading,
showRetry,
touch,
onOpenChange,
onImport,
onRetry,
}: {
canImport: boolean;
importLoading: boolean;
showRetry: boolean;
touch: boolean;
onOpenChange: (open: boolean) => void;
onImport: () => void | Promise<void>;
onRetry: () => void | Promise<void>;
}) {
const { t } = useTranslation();
return (
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={importLoading}
className={`${touch ? TOUCH_CONTROL_SIZE_CLASS : DESKTOP_CONTROL_SIZE_CLASS} cursor-pointer`}
>
{t("common:cancel")}
</Button>
{showRetry && (
<Button
type="button"
variant="outline"
onClick={onRetry}
disabled={importLoading}
className={`${touch ? TOUCH_CONTROL_SIZE_CLASS : DESKTOP_CONTROL_SIZE_CLASS} cursor-pointer`}
>
{t("workflows:retryImportPreview")}
</Button>
)}
<Button
type="button"
onClick={onImport}
disabled={!canImport || importLoading}
className={`${touch ? TOUCH_CONTROL_SIZE_CLASS : DESKTOP_CONTROL_SIZE_CLASS} cursor-pointer`}
data-testid="workflow-import-profile-submit"
>
{t("workflows:importContinue")}
</Button>
<span className="sr-only" role="status" aria-live="polite">
{importLoading ? t("workflows:importing") : ""}
</span>
</>
);
}
function MobileSelectionBody({
mobileView,
activeStep,
activeStepKey,
preview,
selections,
missingSteps,
profileConflicts,
onRetryPreview,
onOpenMobilePicker,
onSelectMobileProfile,
onSelectProfile,
}: {
mobileView: "list" | "picker";
activeStep?: WorkflowImportProfileStep;
activeStepKey: string | null;
preview: WorkflowImportPreview;
selections: WorkflowImportSelections;
missingSteps: WorkflowImportProfileStep[];
profileConflicts: WorkflowImportProfileConflict[];
onRetryPreview: () => void | Promise<void>;
onOpenMobilePicker: (key: string | null) => void;
onSelectMobileProfile: (profileId: string) => void;
onSelectProfile: (stepKey: string, profileId: string) => void;
}) {
if (mobileView === "picker" && activeStep) {
return (
<ProfilePickerList
profiles={preview.profiles}
selectedProfileId={selections[activeStepKey ?? ""]}
onSelect={onSelectMobileProfile}
/>
);
}
return (
<>
{preview.profiles.length === 0 && missingSteps.length > 0 && (
<EmptyProfileCallout onRetry={onRetryPreview} touch />
)}
<SelectionRows
preview={preview}
selections={selections}
conflicts={profileConflicts}
mobile
openStepKey={null}
onOpenStep={onOpenMobilePicker}
onSelectProfile={onSelectProfile}
/>
</>
);
}
function MobileProfileSelection({
open,
onOpenChange,
preview,
selections,
missingSteps,
profileConflicts,
activeStepKey,
onActiveStepKeyChange,
onSelectProfile,
onImport,
onRetryPreview,
importLoading,
}: WorkflowImportProfileSelectionProps) {
const [mobileView, setMobileView] = useState<"list" | "picker">("list");
useEffect(() => {
if (open) setMobileView("list");
}, [open]);
const activeStep = preview.steps.find((step) => workflowImportStepKey(step) === activeStepKey);
const openMobilePicker = (key: string | null) => {
onActiveStepKeyChange(key);
if (key) setMobileView("picker");
else setMobileView("list");
};
const selectMobileProfile = (profileId: string) => {
if (!activeStepKey) return;
onSelectProfile(activeStepKey, profileId);
setMobileView("list");
};
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent
className="h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] !max-h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] outline-none"
onEscapeKeyDown={(event) => {
if (mobileView === "picker") {
event.preventDefault();
openMobilePicker(null);
}
}}
>
<div
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl bg-background shadow-2xl shadow-black/20"
data-testid="workflow-import-profile-selection"
>
<MobileSelectionHeader
mobileView={mobileView}
activeStep={activeStep}
onBack={() => openMobilePicker(null)}
/>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pb-[env(safe-area-inset-bottom,0px)]">
<MobileSelectionBody
mobileView={mobileView}
activeStep={activeStep}
activeStepKey={activeStepKey}
preview={preview}
selections={selections}
missingSteps={missingSteps}
profileConflicts={profileConflicts}
onRetryPreview={onRetryPreview}
onOpenMobilePicker={openMobilePicker}
onSelectMobileProfile={selectMobileProfile}
onSelectProfile={onSelectProfile}
/>
</div>
<DrawerFooter className="shrink-0 border-t border-border/70 bg-background/95 pb-[calc(1rem+env(safe-area-inset-bottom,0px))]">
<SelectionFooter
canImport={missingSteps.length === 0}
importLoading={importLoading}
touch
showRetry={
profileConflicts.length > 0 ||
(preview.profiles.length === 0 && missingSteps.length > 0)
}
onOpenChange={onOpenChange}
onImport={onImport}
onRetry={onRetryPreview}
/>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
);
}
function DesktopProfileSelection({
open,
onOpenChange,
preview,
selections,
missingSteps,
profileConflicts,
activeStepKey,
onActiveStepKeyChange,
onSelectProfile,
onImport,
onRetryPreview,
importLoading,
}: WorkflowImportProfileSelectionProps) {
const { t } = useTranslation();
const canImport = missingSteps.length === 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex max-h-[min(90dvh,48rem)] flex-col overflow-hidden sm:max-w-3xl"
data-testid="workflow-import-profile-selection"
>
<DialogHeader>
<DialogTitle>{t("workflows:importResolveProfilesTitle")}</DialogTitle>
<p className="text-sm text-muted-foreground">
{t("workflows:importResolveProfilesDescription")}
</p>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
{preview.profiles.length === 0 && missingSteps.length > 0 && (
<EmptyProfileCallout onRetry={onRetryPreview} touch={false} />
)}
<SelectionRows
preview={preview}
selections={selections}
conflicts={profileConflicts}
mobile={false}
openStepKey={activeStepKey}
onOpenStep={onActiveStepKeyChange}
onSelectProfile={onSelectProfile}
/>
</div>
<DialogFooter>
<SelectionFooter
canImport={canImport}
importLoading={importLoading}
touch={false}
showRetry={profileConflicts.length > 0}
onOpenChange={onOpenChange}
onImport={onImport}
onRetry={onRetryPreview}
/>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function WorkflowImportProfileSelection(props: WorkflowImportProfileSelectionProps) {
const { isMobile } = useResponsiveBreakpoint();
const usesTouchDrawer = useTouchDrawer();
return isMobile || usesTouchDrawer ? (
<MobileProfileSelection {...props} />
) : (
<DesktopProfileSelection {...props} />
);
}