import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { useNavigate } from "react-router-dom"; import { AlignLeft, Check, ChevronDown, Cpu, MoreVertical, Pencil, Package, Sparkles, Terminal, Trash2, Users, X, } from "lucide-react"; import spinners from "unicode-animations"; import { H2 } from "@nous-research/ui/ui/components/typography/h2"; import { api } from "@/lib/api"; import type { ActiveProfileInfo, ProfileInfo } from "@/lib/api"; import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; import { useToast } from "@nous-research/ui/hooks/use-toast"; import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete"; import { useModalBehavior } from "@/hooks/useModalBehavior"; import { Toast } from "@nous-research/ui/ui/components/toast"; import { Card, CardContent } from "@nous-research/ui/ui/components/card"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Button } from "@nous-research/ui/ui/components/button"; import { Input } from "@nous-research/ui/ui/components/input"; import { Label } from "@nous-research/ui/ui/components/label"; import { Select, SelectOption, } from "@nous-research/ui/ui/components/select"; import { Checkbox } from "@nous-research/ui/ui/components/checkbox"; import { useI18n } from "@/i18n"; import { usePageHeader } from "@/contexts/usePageHeader"; import { cn, themedBody } from "@/lib/utils"; // Mirrors hermes_cli/profiles.py::_PROFILE_ID_RE so we can reject obviously // invalid names (uppercase, spaces, …) before round-tripping a doomed POST. const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; /** Braille unicode spinner (`unicode-animations`); static first frame when reduced motion is preferred. */ function ProfilesLoadingSpinner() { const { frames, interval } = spinners.braille; const [frameIndex, setFrameIndex] = useState(0); useEffect(() => { if ( typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches ) { return; } const id = window.setInterval( () => setFrameIndex((i) => (i + 1) % frames.length), interval, ); return () => window.clearInterval(id); }, [frames.length, interval]); return ( {frames[frameIndex]} ); } /** * Per-card "⋯" actions menu. Holds every action for the profile (set active, * model, description, SOUL, copy command, rename, delete) so the card row stays * a single button. Mirrors the hand-rolled dropdown pattern used by ModelsPage's * "Use as" menu (button + absolute panel + outside-click close). */ function ProfileActionsMenu({ isActive, isDefault, isEditingDesc, isEditingModel, isEditingSoul, labels, settingActive, onCopyCommand, onDelete, onEditDescription, onEditModel, onEditSoul, onManageSkills, onRename, onSetActive, }: ProfileActionsMenuProps) { const [open, setOpen] = useState(false); const containerRef = useRef(null); useEffect(() => { if (!open) return; const onDown = (e: MouseEvent) => { const target = e.target as Node | null; // Close only when the click lands outside *this* menu. Matching any // `[data-profile-actions]` would treat another card's menu as "inside" // and leave several menus open at once. if (target && !containerRef.current?.contains(target)) setOpen(false); }; window.addEventListener("mousedown", onDown); return () => window.removeEventListener("mousedown", onDown); }, [open]); // Run the action, then collapse the menu. Toggle editors (model/description/ // SOUL) expand the inline section below the card once the menu closes. const run = (fn: () => void) => () => { fn(); setOpen(false); }; const itemClass = "flex w-full items-center gap-2.5 px-3 py-2 text-xs uppercase tracking-wider hover:bg-muted/50 disabled:opacity-40"; return ( setOpen((v) => !v)} > {open && ( {!isActive && ( {labels.setActive} )} {isEditingModel ? ( ) : ( )} {labels.editModel} {isEditingDesc ? ( ) : ( )} {labels.editDescription} {isEditingSoul ? ( ) : ( S )} {labels.editSoul} {labels.manageSkills} {labels.openInTerminal} {!isDefault && ( {labels.rename} )} {!isDefault && ( {labels.delete} )} )} ); } export default function ProfilesPage() { const navigate = useNavigate(); const [profiles, setProfiles] = useState([]); const [activeInfo, setActiveInfo] = useState(null); const [loading, setLoading] = useState(true); const { toast, showToast } = useToast(); const { t } = useI18n(); const { setEnd } = usePageHeader(); // Locale strings with English fallbacks. The enriched keys are optional in // the i18n type so untranslated locales don't break the build — they render // the English literal until translated. const L = useMemo(() => { const p = t.profiles; return { activeProfile: p.activeProfile ?? "Active profile", activeBadge: p.activeBadge ?? "active", setActive: p.setActive ?? "Set as active", activeSet: p.activeSet ?? "Active profile set", gatewayRunning: p.gatewayRunning ?? "Gateway running", gatewayStopped: p.gatewayStopped ?? "Gateway stopped", gatewayRunningWarning: p.gatewayRunningWarning ?? "This profile's gateway is running — it will be stopped.", aliasBadge: p.aliasBadge ?? "alias", description: p.description ?? "Description", descriptionPlaceholder: p.descriptionPlaceholder ?? "What is this profile good at? Used to route kanban tasks by role.", noDescription: p.noDescription ?? "No description", editDescription: p.editDescription ?? "Edit description", descriptionSaved: p.descriptionSaved ?? "Description saved", reviewBadge: p.reviewBadge ?? "review", autoGenerate: p.autoGenerate ?? "Auto-generate", generating: p.generating ?? "Generating…", describeFailed: p.describeFailed ?? "Could not generate description", distribution: p.distribution ?? "Distribution", advancedOptions: p.advancedOptions ?? "Advanced options", cloneAll: p.cloneAll ?? "Clone everything (memories, sessions, skills, state)", noSkillsOption: p.noSkillsOption ?? "Don't seed bundled skills", descriptionOptional: p.descriptionOptional ?? "Description (optional)", modelOptional: p.modelOptional ?? "Model (optional)", modelInherit: p.modelInherit ?? "Inherit from clone / default", modelLoading: p.modelLoading ?? "Loading models…", modelNone: p.modelNone ?? "No authenticated providers — set a key first", editModel: p.editModel ?? "Change model", modelSaved: p.modelSaved ?? "Model updated", modelSelect: p.modelSelect ?? "Select a model", actions: p.actions ?? "Actions", manageSkills: p.manageSkills ?? "Manage skills & tools", activeSetHint: p.activeSetHint ?? "Applies to new CLI/gateway runs. This dashboard still manages its own profile — use “Manage skills & tools” to edit {name}.", }; }, [t.profiles]); // Create modal const [createModalOpen, setCreateModalOpen] = useState(false); const [newName, setNewName] = useState(""); const [cloneFrom, setCloneFrom] = useState("default"); const [cloneAll, setCloneAll] = useState(false); const [noSkills, setNoSkills] = useState(false); const [newDescription, setNewDescription] = useState(""); const [creating, setCreating] = useState(false); // Model picker (lazy-loaded the first time a picker is opened). modelChoice // is a "slug\u0000model" key, or "" to inherit from clone/default. const [modelChoices, setModelChoices] = useState< { provider: string; model: string; label: string }[] | null >(null); const modelChoicesLoading = useRef(false); const [modelChoice, setModelChoice] = useState(""); const closeCreateModal = useCallback(() => setCreateModalOpen(false), []); const createModalRef = useModalBehavior({ open: createModalOpen, onClose: closeCreateModal, }); // Inline rename state const [renamingFrom, setRenamingFrom] = useState(null); const [renameTo, setRenameTo] = useState(""); // Inline SOUL editor state const [editingSoulFor, setEditingSoulFor] = useState(null); const [soulText, setSoulText] = useState(""); const [soulSaving, setSoulSaving] = useState(false); // Tracks the latest SOUL request so out-of-order responses don't overwrite // newer state when the user switches profiles or closes the editor. const activeSoulRequest = useRef(null); // Inline description editor state const [editingDescFor, setEditingDescFor] = useState(null); const [descText, setDescText] = useState(""); const [descSaving, setDescSaving] = useState(false); const [describing, setDescribing] = useState(false); // Tracks the latest description request (save / auto-describe) so a late // response can't overwrite state for a different, newly-opened editor. const activeDescRequest = useRef(null); // Counts in-flight save / auto-describe requests so the saving indicator // is only cleared when the last concurrent request settles. const descSavingCount = useRef(0); const describingCount = useRef(0); // Inline model editor state const [editingModelFor, setEditingModelFor] = useState(null); const [modelEditChoice, setModelEditChoice] = useState(""); const [modelSaving, setModelSaving] = useState(false); // Per-profile "set active" in-flight name const [settingActive, setSettingActive] = useState(null); const modelKey = (provider: string | null, model: string | null) => provider && model ? `${provider}\u0000${model}` : ""; const loadModelChoices = useCallback(() => { if (modelChoices !== null || modelChoicesLoading.current) return; modelChoicesLoading.current = true; api .getModelOptions() .then((res) => { const flat: { provider: string; model: string; label: string }[] = []; for (const prov of res.providers ?? []) { for (const m of prov.models ?? []) { flat.push({ provider: prov.slug, model: m, label: `${prov.name} · ${m}`, }); } } setModelChoices(flat); }) .catch(() => setModelChoices([])) .finally(() => { modelChoicesLoading.current = false; }); }, [modelChoices]); const load = useCallback(() => { Promise.all([api.getProfiles(), api.getActiveProfile().catch(() => null)]) .then(([res, active]) => { setProfiles(res.profiles); setActiveInfo(active); }) .catch((e) => showToast(`${t.status.error}: ${e}`, "error")) .finally(() => setLoading(false)); }, [showToast, t.status.error]); useEffect(() => { load(); }, [load]); // Lazily load the model picker the first time the create modal opens. useEffect(() => { if (createModalOpen) loadModelChoices(); }, [createModalOpen, loadModelChoices]); const isActive = useCallback( (p: ProfileInfo) => activeInfo != null && (activeInfo.active === p.name || (activeInfo.active === "default" && p.is_default)), [activeInfo], ); const handleCreate = async () => { const name = newName.trim(); if (!name) { showToast(t.profiles.nameRequired, "error"); return; } if (!PROFILE_NAME_RE.test(name)) { showToast(`${t.profiles.invalidName}: ${t.profiles.nameRule}`, "error"); return; } setCreating(true); try { const cloning = cloneFrom !== null; const picked = modelChoice ? modelChoices?.find( (c) => `${c.provider}\u0000${c.model}` === modelChoice, ) : undefined; const res = await api.createProfile({ name, clone_from: cloneFrom, clone_all: cloning && cloneAll, no_skills: cloning ? false : noSkills, description: newDescription.trim() || undefined, provider: picked?.provider, model: picked?.model, }); showToast(`${t.profiles.created}: ${name}`, "success"); if (picked && res.model_set === false) { showToast( `Profile created, but the model could not be saved — set it from the profile editor.`, "error", ); } setNewName(""); setNewDescription(""); setNoSkills(false); setCloneAll(false); setCloneFrom("default"); setModelChoice(""); setCreateModalOpen(false); load(); } catch (e) { showToast(`${t.status.error}: ${e}`, "error"); } finally { setCreating(false); } }; const handleRenameSubmit = async () => { if (!renamingFrom) return; const target = renameTo.trim(); if (!target || target === renamingFrom) { setRenamingFrom(null); setRenameTo(""); return; } if (!PROFILE_NAME_RE.test(target)) { showToast(`${t.profiles.invalidName}: ${t.profiles.nameRule}`, "error"); return; } try { await api.renameProfile(renamingFrom, target); showToast(`${t.profiles.renamed}: ${renamingFrom} → ${target}`, "success"); setRenamingFrom(null); setRenameTo(""); load(); } catch (e) { showToast(`${t.status.error}: ${e}`, "error"); } }; const handleSetActive = async (name: string) => { setSettingActive(name); try { // The backend normalizes/validates the name; trust the canonical // value it returns rather than the raw input. const { active } = await api.setActiveProfile(name); // "Set as active" only flips the sticky default for FUTURE CLI/gateway // invocations — it does NOT retarget this running dashboard. Say so, // or users assume skill/tool toggles now apply to the activated // profile (they don't — that's what "Manage skills & tools" is for). showToast( `${L.activeSet}: ${active} — ${L.activeSetHint.replace("{name}", active)}`, "success", ); setActiveInfo((prev) => prev ? { ...prev, active } : { active, current: active }, ); } catch (e) { showToast(`${t.status.error}: ${e}`, "error"); } finally { setSettingActive(null); } }; // Closes whichever editor dialog is open (model / description / SOUL). const closeEditor = useCallback(() => { activeSoulRequest.current = null; activeDescRequest.current = null; setEditingModelFor(null); setEditingDescFor(null); setEditingSoulFor(null); }, []); const openSoulEditor = useCallback( async (name: string) => { // Re-selecting the action for the already-open editor collapses it, // matching the chevron-down affordance in the actions menu. if (editingSoulFor === name) { closeEditor(); return; } setEditingDescFor(null); setEditingModelFor(null); setEditingSoulFor(name); setSoulText(""); activeSoulRequest.current = name; try { const soul = await api.getProfileSoul(name); if (activeSoulRequest.current === name) { setSoulText(soul.content); } } catch (e) { if (activeSoulRequest.current === name) { showToast(`${t.status.error}: ${e}`, "error"); } } }, [closeEditor, editingSoulFor, showToast, t.status.error], ); const handleSaveSoul = async (name: string) => { setSoulSaving(true); try { await api.updateProfileSoul(name, soulText); showToast(`${t.profiles.soulSaved}: ${name}`, "success"); activeSoulRequest.current = null; setEditingSoulFor(null); } catch (e) { showToast(`${t.status.error}: ${e}`, "error"); } finally { setSoulSaving(false); } }; const openDescEditor = useCallback( (p: ProfileInfo) => { if (editingDescFor === p.name) { closeEditor(); return; } activeDescRequest.current = p.name; setEditingSoulFor(null); setEditingModelFor(null); setEditingDescFor(p.name); setDescText(p.description ?? ""); }, [closeEditor, editingDescFor], ); const handleSaveDesc = async (name: string) => { descSavingCount.current += 1; setDescSaving(true); activeDescRequest.current = name; try { const res = await api.updateProfileDescription(name, descText); // Profile-list state always reflects the persisted result, but only // touch the open editor if it's still showing this profile. setProfiles((prev) => prev.map((p) => p.name === name ? { ...p, description: res.description, description_auto: res.description_auto, } : p, ), ); if (activeDescRequest.current === name) { showToast(`${L.descriptionSaved}: ${name}`, "success"); setEditingDescFor(null); } } catch (e) { if (activeDescRequest.current === name) { showToast(`${t.status.error}: ${e}`, "error"); } } finally { descSavingCount.current -= 1; if (descSavingCount.current === 0) setDescSaving(false); } }; const handleAutoDescribe = async (name: string) => { describingCount.current += 1; setDescribing(true); activeDescRequest.current = name; try { const res = await api.describeProfileAuto(name); const current = activeDescRequest.current === name; if (res.ok && res.description != null) { if (current) setDescText(res.description); setProfiles((prev) => prev.map((p) => p.name === name ? { ...p, description: res.description ?? "", description_auto: res.description_auto, } : p, ), ); if (current) showToast(`${L.descriptionSaved}: ${name}`, "success"); } else if (current) { showToast(`${L.describeFailed}: ${res.reason}`, "error"); } } catch (e) { if (activeDescRequest.current === name) { showToast(`${t.status.error}: ${e}`, "error"); } } finally { describingCount.current -= 1; if (describingCount.current === 0) setDescribing(false); } }; const openModelEditor = useCallback( (p: ProfileInfo) => { if (editingModelFor === p.name) { closeEditor(); return; } setEditingSoulFor(null); setEditingDescFor(null); setEditingModelFor(p.name); setModelEditChoice(modelKey(p.provider, p.model)); loadModelChoices(); }, [closeEditor, editingModelFor, loadModelChoices], ); const handleSaveModel = async (name: string) => { const picked = modelEditChoice ? modelChoices?.find( (c) => `${c.provider}\u0000${c.model}` === modelEditChoice, ) : undefined; if (!picked) return; setModelSaving(true); try { await api.setProfileModel(name, picked.provider, picked.model); showToast(`${L.modelSaved}: ${picked.model}`, "success"); setProfiles((prev) => prev.map((p) => p.name === name ? { ...p, model: picked.model, provider: picked.provider } : p, ), ); setEditingModelFor(null); } catch (e) { showToast(`${t.status.error}: ${e}`, "error"); } finally { setModelSaving(false); } }; // Exactly one editor is open at a time; derive which profile + kind so a // single dialog can render the right body. const editorName = editingModelFor ?? editingDescFor ?? editingSoulFor; const editorKind: "model" | "desc" | "soul" | null = editingModelFor ? "model" : editingDescFor ? "desc" : editingSoulFor ? "soul" : null; const editorModalRef = useModalBehavior({ open: editorName != null, onClose: closeEditor, }); const handleCopyTerminalCommand = async (name: string) => { let cmd: string; try { const res = await api.getProfileSetupCommand(name); cmd = res.command; } catch (e) { showToast(`${t.status.error}: ${e}`, "error"); return; } try { await navigator.clipboard.writeText(cmd); showToast(`${t.profiles.commandCopied}: ${cmd}`, "success"); } catch { showToast(`${t.profiles.copyFailed}: ${cmd}`, "error"); } }; const profileDelete = useConfirmDelete({ onDelete: useCallback( async (name: string) => { try { await api.deleteProfile(name); showToast(`${t.profiles.deleted}: ${name}`, "success"); load(); } catch (e) { showToast(`${t.status.error}: ${e}`, "error"); throw e; } }, [load, showToast, t.profiles.deleted, t.status.error], ), }); const pendingName = profileDelete.pendingId; const pendingProfile = pendingName ? profiles.find((p) => p.name === pendingName) : undefined; const deleteMessage = (() => { if (!pendingName) return t.profiles.confirmDeleteMessage; const base = t.profiles.confirmDeleteMessage.replace("{name}", pendingName); return pendingProfile?.gateway_running ? `${base}\n\n${L.gatewayRunningWarning}` : base; })(); // Put "Build" (full builder) + "Create" (quick modal) buttons in header useLayoutEffect(() => { setEnd( navigate("/profiles/new")} > Build setCreateModalOpen(true)} > {t.common.create} , ); return () => { setEnd(null); }; }, [setEnd, t.common.create, loading, navigate]); const cloning = cloneFrom !== null; if (loading) { return ( {t.common.loading} ); } return ( {/* Create profile modal */} {createModalOpen && ( e.target === e.currentTarget && setCreateModalOpen(false) } role="dialog" aria-modal="true" aria-labelledby="create-profile-title" > setCreateModalOpen(false)} className="absolute right-2 top-2 text-muted-foreground hover:text-foreground" aria-label="Close" > {t.profiles.newProfile} {t.profiles.name} setNewName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }} aria-invalid={ newName.trim() !== "" && !PROFILE_NAME_RE.test(newName.trim()) } /> {t.profiles.nameRule} {t.profiles.cloneFrom} { const next = v || null; setCloneFrom(next); if (next === null) setCloneAll(false); }} > {t.profiles.cloneFromNone} {profiles.map((profile) => ( {profile.name} ))} {L.descriptionOptional} setNewDescription(e.target.value)} /> {L.modelOptional} {modelChoices === null ? L.modelLoading : L.modelInherit} {(modelChoices ?? []).map((c) => ( {c.label} ))} {modelChoices !== null && modelChoices.length === 0 && ( {L.modelNone} )} {L.advancedOptions} setCloneAll(checked === true)} /> {L.cloneAll} setNoSkills(checked === true)} /> {L.noSkillsOption} {creating ? t.common.creating : t.common.create} )} {/* Active profile banner */} {activeInfo && ( {L.activeProfile}:{" "} {activeInfo.active} {activeInfo.current !== activeInfo.active && ( ({activeInfo.current}) )} )} {/* List */} {t.profiles.allProfiles} ({profiles.length}) {profiles.length === 0 && ( {t.profiles.noProfiles} )} {profiles.map((p) => { const isRenaming = renamingFrom === p.name; const isEditingSoul = editingSoulFor === p.name; const isEditingDesc = editingDescFor === p.name; const isEditingModel = editingModelFor === p.name; const active = isActive(p); return ( {isRenaming ? ( setRenameTo(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleRenameSubmit(); if (e.key === "Escape") setRenamingFrom(null); }} aria-invalid={ renameTo.trim() !== "" && renameTo.trim() !== p.name && !PROFILE_NAME_RE.test(renameTo.trim()) } /> {(() => { const trimmed = renameTo.trim(); const invalid = trimmed !== "" && trimmed !== p.name && !PROFILE_NAME_RE.test(trimmed); return ( {invalid ? `${t.profiles.invalidName}: ${t.profiles.nameRule}` : t.profiles.nameRule} ); })()} {t.common.save} setRenamingFrom(null)} > {t.common.cancel} ) : ( <> {p.name} {active && ( {L.activeBadge} )} {p.is_default && ( {t.profiles.defaultBadge} )} {p.has_alias && ( {L.aliasBadge} )} {p.has_env && ( {t.profiles.hasEnv} )} {p.distribution_name && ( {p.distribution_name} {p.distribution_version ? `@${p.distribution_version}` : ""} )} handleCopyTerminalCommand(p.name) } onDelete={() => profileDelete.requestDelete(p.name)} onEditDescription={() => openDescEditor(p)} onEditModel={() => openModelEditor(p)} onEditSoul={() => openSoulEditor(p.name)} onManageSkills={() => navigate( `/skills?profile=${encodeURIComponent(p.name)}`, ) } onRename={() => { setRenamingFrom(p.name); setRenameTo(p.name); }} onSetActive={() => handleSetActive(p.name)} /> {p.gateway_running ? L.gatewayRunning : L.gatewayStopped} {p.description || L.noDescription} {p.description && p.description_auto && ( {L.reviewBadge} )} {p.model && ( {t.profiles.model}: {p.model} {p.provider ? ` (${p.provider})` : ""} )} {t.profiles.skills}: {p.skill_count} {p.path} > )} ); })} {/* Editor dialog — model / description / SOUL for the selected profile */} {editorName && ( e.target === e.currentTarget && closeEditor()} role="dialog" aria-modal="true" aria-labelledby="profile-editor-title" > {editorKind === "model" ? L.editModel : editorKind === "desc" ? L.description : t.profiles.soulSection} · {editorName} {editorKind === "model" && (modelChoices !== null && modelChoices.length === 0 ? ( {L.modelNone} ) : ( <> {(modelChoices ?? []).map((c) => ( {c.label} ))} handleSaveModel(editorName)} disabled={ modelSaving || !modelChoices?.some( (c) => `${c.provider}\u0000${c.model}` === modelEditChoice, ) } > {modelSaving ? t.common.saving : t.common.save} > ))} {editorKind === "desc" && ( <> {L.description} handleAutoDescribe(editorName)} > {describing ? L.generating : L.autoGenerate} setDescText(e.target.value)} /> handleSaveDesc(editorName)} disabled={descSaving} > {descSaving ? t.common.saving : t.common.save} > )} {editorKind === "soul" && ( <> {t.profiles.soulSection} setSoulText(e.target.value)} /> handleSaveSoul(editorName)} disabled={soulSaving} > {soulSaving ? t.common.saving : t.common.save} > )} )} ); } interface ProfileActionsMenuProps { isActive: boolean; isDefault: boolean; isEditingDesc: boolean; isEditingModel: boolean; isEditingSoul: boolean; labels: { actions: string; delete: string; editDescription: string; editModel: string; editSoul: string; manageSkills: string; openInTerminal: string; rename: string; setActive: string; }; settingActive: boolean; onCopyCommand: () => void; onDelete: () => void; onEditDescription: () => void; onEditModel: () => void; onEditSoul: () => void; onManageSkills: () => void; onRename: () => void; onSetActive: () => void; }
{t.profiles.nameRule}
{L.modelNone}
{invalid ? `${t.profiles.invalidName}: ${t.profiles.nameRule}` : t.profiles.nameRule}