import { useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { ChevronDown, Pencil, 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 { 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 { 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]} ); } export default function ProfilesPage() { const [profiles, setProfiles] = useState([]); const [loading, setLoading] = useState(true); const { toast, showToast } = useToast(); const { t } = useI18n(); const { setEnd } = usePageHeader(); // Create modal const [createModalOpen, setCreateModalOpen] = useState(false); const [newName, setNewName] = useState(""); const [cloneFromDefault, setCloneFromDefault] = useState(true); const [creating, setCreating] = useState(false); 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); const load = useCallback(() => { api .getProfiles() .then((res) => setProfiles(res.profiles)) .catch((e) => showToast(`${t.status.error}: ${e}`, "error")) .finally(() => setLoading(false)); }, [showToast, t.status.error]); useEffect(() => { load(); }, [load]); 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 { await api.createProfile({ name, clone_from_default: cloneFromDefault }); showToast(`${t.profiles.created}: ${name}`, "success"); setNewName(""); 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 openSoulEditor = useCallback( async (name: string) => { if (editingSoulFor === name) { activeSoulRequest.current = null; setEditingSoulFor(null); return; } 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"); } } }, [editingSoulFor, showToast, t.status.error], ); const handleSaveSoul = async (name: string) => { setSoulSaving(true); try { await api.updateProfileSoul(name, soulText); showToast(`${t.profiles.soulSaved}: ${name}`, "success"); } catch (e) { showToast(`${t.status.error}: ${e}`, "error"); } finally { setSoulSaving(false); } }; 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; // Put "Create" button in page header useLayoutEffect(() => { setEnd( setCreateModalOpen(true)} > {t.common.create} , ); return () => { setEnd(null); }; }, [setEnd, t.common.create, loading]); 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} setCloneFromDefault(checked === true) } /> {t.profiles.cloneFromDefault} {creating ? t.common.creating : t.common.create} )} {/* 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; 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()) } className="max-w-xs" /> ) : ( {p.name} )} {p.is_default && ( {t.profiles.defaultBadge} )} {p.has_env && ( {t.profiles.hasEnv} )} {isRenaming && (() => { 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} ); })()} {p.model && ( {t.profiles.model}: {p.model} {p.provider ? ` (${p.provider})` : ""} )} {t.profiles.skills}: {p.skill_count} {p.path} {isRenaming ? ( <> {t.common.save} setRenamingFrom(null)} > {t.common.cancel} > ) : ( <> openSoulEditor(p.name)} > {isEditingSoul ? ( ) : ( S )} handleCopyTerminalCommand(p.name)} > {!p.is_default && ( { setRenamingFrom(p.name); setRenameTo(p.name); }} > )} {!p.is_default && ( profileDelete.requestDelete(p.name)} > )} > )} {isEditingSoul && ( {t.profiles.soulSection} setSoulText(e.target.value)} /> handleSaveSoul(p.name)} disabled={soulSaving} > {soulSaving ? t.common.saving : t.common.save} )} ); })} ); }
{t.profiles.nameRule}
{invalid ? `${t.profiles.invalidName}: ${t.profiles.nameRule}` : t.profiles.nameRule}