import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Check, ChevronDown, Loader2 } from "lucide-react"; import type { ToolProfileWithDetails } from "@paperclipai/shared"; import { useNavigate } from "@/lib/router"; import { toolsApi, type ToolProfileBindingInput } from "@/api/tools"; import { queryKeys } from "@/lib/queryKeys"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { AgentMultiSelect, type AgentMultiSelectOption } from "@/components/AgentMultiSelect"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { useToast } from "@/context/ToastContext"; import { LoadingState } from "../shared"; import { buildEntries, countAllowedTools, parseEntries, templateSelections, TEMPLATES, type AdvancedRule, type TemplateKey, type WizardSelections, } from "./profile-model"; import { useProfilesData } from "./useProfilesData"; import { WizardToolsStep } from "./WizardToolsStep"; import { readWizardMeta, resumeStep, withWizardMeta, type WizardStep } from "./wizard-draft"; function slugifyProfileKey(name: string): string { return name .trim() .toLowerCase() .replace(/[^a-z0-9._:-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 160); } const STEP_LABELS: Array<{ step: WizardStep; label: string }> = [ { step: 1, label: "Name" }, { step: 2, label: "Choose tools" }, { step: 3, label: "Assign" }, ]; export function ProfileWizard({ companyId, profileId, initialTemplate, initialStep, }: { companyId: string; profileId?: string; initialTemplate?: TemplateKey; initialStep?: WizardStep; }) { const navigate = useNavigate(); const queryClient = useQueryClient(); const { pushToast } = useToast(); const data = useProfilesData(companyId); const { appGroups, catalog, profiles, agents } = data; const allProfiles = profiles.data?.profiles ?? []; const existing = profileId ? allProfiles.find((p) => p.id === profileId) ?? null : null; const [step, setStep] = useState(1); const [draftId, setDraftId] = useState(profileId ?? null); const [template, setTemplate] = useState(initialTemplate ?? null); const [copyFromId, setCopyFromId] = useState(null); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [profileKey, setProfileKey] = useState(""); const [keyEdited, setKeyEdited] = useState(false); const [selections, setSelections] = useState({}); const [advancedRules, setAdvancedRules] = useState([]); const [newToolsAction, setNewToolsAction] = useState<"deny" | "allow">("deny"); const [selectedAgentIds, setSelectedAgentIds] = useState>(new Set()); const [selectedProjectIds, setSelectedProjectIds] = useState>(new Set()); const [selectedRoutineIds, setSelectedRoutineIds] = useState>(new Set()); const [companyDefault, setCompanyDefault] = useState(false); const toggleIn = (setter: typeof setSelectedAgentIds) => (id: string) => setter((prev) => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; }); // Hydrate once when resuming an existing draft and its catalog is loaded. const hydrated = useRef(false); useEffect(() => { if (hydrated.current || !existing || appGroups.length === 0) return; if (existing.id !== profileId) return; hydrated.current = true; setName(existing.name); setDescription(existing.description ?? ""); setProfileKey(existing.profileKey); setKeyEdited(true); setNewToolsAction(existing.defaultAction); const parsed = parseEntries(appGroups, existing.entries); setSelections(parsed.selections); setAdvancedRules(parsed.advancedRules); const meta = readWizardMeta(existing); setTemplate(meta?.template ?? null); // Drafts resume at the first unfinished step; a finished profile being // edited starts at step 1 so the admin can review the whole thing. setStep(initialStep ?? (existing.status === "draft" ? resumeStep(meta) : 1)); const targetIds = (type: string) => new Set(existing.bindings.filter((b) => b.targetType === type).map((b) => b.targetId)); setSelectedAgentIds(targetIds("agent")); setSelectedProjectIds(targetIds("project")); setSelectedRoutineIds(targetIds("routine")); setCompanyDefault(existing.bindings.some((b) => b.targetType === "company")); }, [existing, appGroups, profileId, initialStep]); // Key auto-derives from the name until the admin overrides it in Advanced. useEffect(() => { if (!keyEdited) setProfileKey(slugifyProfileKey(name)); }, [name, keyEdited]); const invalidate = () => { queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccesses() }); }; const live = useMemo( () => countAllowedTools(appGroups, selections, newToolsAction, catalog.length), [appGroups, selections, newToolsAction, catalog.length], ); // Map step-1 template choice onto concrete per-app selections (needs the catalog). const seedSelections = (): WizardSelections => { if (template === "copy" && copyFromId) { const source = allProfiles.find((p) => p.id === copyFromId); if (source) return parseEntries(appGroups, source.entries).selections; } if (template && template !== "copy") return templateSelections(template, appGroups); return selections; }; const saveDraft = useMutation({ mutationFn: async (input: { goToStep: WizardStep; completedStep: WizardStep; seed?: WizardSelections; }) => { const effSelections = input.seed ?? selections; const entries = buildEntries(appGroups, effSelections, advancedRules, newToolsAction); const metadata = withWizardMeta(existing?.metadata ?? null, { lastCompletedStep: input.completedStep, template, }); if (!draftId) { const created = await toolsApi.createProfile(companyId, { profileKey: profileKey || slugifyProfileKey(name) || "profile", name: name.trim() || "Untitled profile", description: description.trim() || null, status: "draft", defaultAction: newToolsAction, entries, metadata, }); return { created, goToStep: input.goToStep, seed: input.seed }; } const updated = await toolsApi.updateProfile(draftId, { profileKey: profileKey || undefined, name: name.trim() || "Untitled profile", description: description.trim() || null, defaultAction: newToolsAction, entries, metadata, }); return { created: updated, goToStep: input.goToStep, seed: input.seed }; }, onSuccess: ({ created, goToStep, seed }) => { setDraftId(created.id); if (seed) setSelections(seed); setStep(goToStep); invalidate(); }, onError: (error: unknown) => pushToast({ title: "Could not save", body: String((error as Error)?.message ?? error), tone: "error" }), }); const finish = useMutation({ mutationFn: async () => { if (!draftId) throw new Error("No draft to finish"); const entries = buildEntries(appGroups, selections, advancedRules, newToolsAction); const profile = await toolsApi.updateProfile(draftId, { defaultAction: newToolsAction, entries, metadata: withWizardMeta(existing?.metadata ?? null, { lastCompletedStep: 3, template }), }); await reconcileBindings(companyId, profile, { agentIds: [...selectedAgentIds], projectIds: [...selectedProjectIds], routineIds: [...selectedRoutineIds], companyDefault, }); return toolsApi.updateProfile(draftId, { status: "active" }); }, onSuccess: (profile) => { pushToast({ title: "Profile saved", tone: "success" }); invalidate(); navigate(`/apps/advanced/profiles/${profile.id}${selectedAgentIds.size === 0 && !companyDefault ? "?created=1" : ""}`); }, onError: (error: unknown) => pushToast({ title: "Could not save profile", body: String((error as Error)?.message ?? error), tone: "error" }), }); const saveAndExit = () => { const completed: WizardStep = step; saveDraft.mutate( { goToStep: step, completedStep: completed }, { onSuccess: () => { pushToast({ title: "Draft saved", body: "Pick it back up from the profiles list.", tone: "success" }); navigate("/apps/advanced/profiles"); }, }, ); }; const busy = saveDraft.isPending || finish.isPending; const step1Valid = name.trim().length > 0 && (template !== "copy" || Boolean(copyFromId)); if (profileId && profiles.isLoading) return ; return (
{step === 1 ? ( p.status !== "archived" && p.id !== profileId)} name={name} onName={setName} description={description} onDescription={setDescription} profileKey={profileKey} onProfileKey={(v) => { setKeyEdited(true); setProfileKey(v); }} /> ) : null} {step === 2 ? ( ) : null} {step === 3 ? ( ({ id: a.id, name: a.name, title: a.title, icon: a.icon }))} projects={(data.projects.data ?? []).map((p) => ({ id: p.id, name: p.name }))} routines={(data.routines.data ?? []).map((r) => ({ id: r.id, name: r.title }))} profiles={allProfiles} selectedAgentIds={selectedAgentIds} onToggleAgent={toggleIn(setSelectedAgentIds)} selectedProjectIds={selectedProjectIds} onToggleProject={toggleIn(setSelectedProjectIds)} selectedRoutineIds={selectedRoutineIds} onToggleRoutine={toggleIn(setSelectedRoutineIds)} companyDefault={companyDefault} onCompanyDefault={setCompanyDefault} /> ) : null}
{/* Sticky footer: live count + navigation. */}
{step >= 2 ? ( Allows {live.allowed} of {live.total}{" "} tools ) : null} {draftId ? ( ) : null}
{step > 1 ? ( ) : ( )} {step === 1 ? ( ) : null} {step === 2 ? ( ) : null} {step === 3 ? ( ) : null}
); } /** Reconcile the profile's assignments against the wizard's step-3 choices. */ async function reconcileBindings( companyId: string, profile: ToolProfileWithDetails, desired: { agentIds: string[]; projectIds: string[]; routineIds: string[]; companyDefault: boolean }, ) { const want = new Map(); if (desired.companyDefault) want.set(`company:${companyId}`, { targetType: "company", targetId: companyId }); for (const id of desired.agentIds) want.set(`agent:${id}`, { targetType: "agent", targetId: id }); for (const id of desired.projectIds) want.set(`project:${id}`, { targetType: "project", targetId: id }); for (const id of desired.routineIds) want.set(`routine:${id}`, { targetType: "routine", targetId: id }); // Only the target types the wizard manages are reconciled — leave any // issue-scoped or other bindings untouched. const managed = new Set(["company", "agent", "project", "routine"]); const have = new Set(profile.bindings.map((b) => `${b.targetType}:${b.targetId}`)); const operations = [ ...[...want.entries()] .filter(([key]) => !have.has(key)) .map(([key, input]) => ({ key, apply: () => toolsApi.bindProfile(companyId, profile.id, input), rollback: () => toolsApi.unbindProfile(companyId, profile.id, input), })), ...profile.bindings .filter((binding) => managed.has(binding.targetType) && !want.has(`${binding.targetType}:${binding.targetId}`)) .map((binding) => { const input = { targetType: binding.targetType, targetId: binding.targetId } as ToolProfileBindingInput; return { key: `${binding.targetType}:${binding.targetId}`, apply: () => toolsApi.unbindProfile(companyId, profile.id, input), rollback: () => toolsApi.bindProfile(companyId, profile.id, input), }; }), ]; const completed: typeof operations = []; for (const operation of operations) { try { await operation.apply(); completed.push(operation); } catch (error) { const rollbacks = await Promise.allSettled(completed.reverse().map((done) => done.rollback())); const rollbackFailures = rollbacks.filter((result) => result.status === "rejected").length; const suffix = rollbackFailures > 0 ? `; ${rollbackFailures} rollback operation(s) also failed` : ""; throw new Error(`Could not update assignment ${operation.key}${suffix}`, { cause: error }); } } } function Stepper({ current }: { current: WizardStep }) { return (
    {STEP_LABELS.map(({ step, label }, idx) => { const done = current > step; const active = current === step; return (
  1. {done ? : step} {label} {idx < STEP_LABELS.length - 1 ? → : null}
  2. ); })}
); } export function StepName({ template, onTemplate, copyFromId, onCopyFrom, copyOptions, name, onName, description, onDescription, profileKey, onProfileKey, }: { template: TemplateKey | null; onTemplate: (key: TemplateKey) => void; copyFromId: string | null; onCopyFrom: (id: string) => void; copyOptions: ToolProfileWithDetails[]; name: string; onName: (v: string) => void; description: string; onDescription: (v: string) => void; profileKey: string; onProfileKey: (v: string) => void; }) { const [advancedOpen, setAdvancedOpen] = useState(false); return (

Start from

{TEMPLATES.map((t) => ( ))}
{template === "copy" ? (

Which profile?

{copyOptions.length === 0 ? (

You don't have another profile to copy yet.

) : (
{copyOptions.map((p) => ( ))}
)}
) : null}
onName(e.target.value)} placeholder="e.g. Everyday work" />