hermes-agent/web/src/pages/ProfilesPage.tsx
Austin Pickett 0bbf325a8f
fix(dashboard): scope chat sidebar model card to selected profile (#46665)
* fix(dashboard): scope chat sidebar model card to selected profile

The PTY already honors ?profile= on profile switch, but the JSON-RPC
sidecar created sessions against the dashboard launch profile. Pass the
management profile through session.create and reconnect on switch.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dashboard): sync active profile with management scope

Align the sidebar switcher with the sticky active profile on load and
when "Set as active" is clicked, so Chat and management pages match
what the Profiles page shows as active.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dashboard): auto-reconnect chat sidebar on profile switch

Bump the sidecar connection version when profile or PTY channel changes,
matching the manual Reconnect path so gateway and events sockets come
back without clicking the error banner.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dashboard): prevent model selector chevron overlapping label

Use inline flex layout instead of Button suffix, which is absolutely
positioned and overlapped truncated model names at px-0.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 12:50:19 -04:00

1425 lines
48 KiB
TypeScript

import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useNavigate } from "react-router-dom";
import { useProfileScope } from "@/contexts/useProfileScope";
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 (
<span
aria-hidden
className="inline-block select-none font-mono text-xl leading-none text-muted-foreground"
>
{frames[frameIndex]}
</span>
);
}
/**
* 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<HTMLDivElement>(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 (
<div className="relative" data-profile-actions ref={containerRef}>
<Button
ghost
size="icon"
title={labels.actions}
aria-label={labels.actions}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<MoreVertical className="h-4 w-4" />
</Button>
{open && (
<div
role="menu"
className="absolute right-0 top-full z-50 mt-1 min-w-[200px] border border-border bg-card shadow-lg"
>
{!isActive && (
<button
type="button"
role="menuitem"
className={itemClass}
disabled={settingActive}
onClick={run(onSetActive)}
>
<Check className="h-4 w-4" />
{labels.setActive}
</button>
)}
<button
type="button"
role="menuitem"
className={itemClass}
onClick={run(onEditModel)}
>
{isEditingModel ? (
<ChevronDown className="h-4 w-4" />
) : (
<Cpu className="h-4 w-4" />
)}
{labels.editModel}
</button>
<button
type="button"
role="menuitem"
className={itemClass}
onClick={run(onEditDescription)}
>
{isEditingDesc ? (
<ChevronDown className="h-4 w-4" />
) : (
<AlignLeft className="h-4 w-4" />
)}
{labels.editDescription}
</button>
<button
type="button"
role="menuitem"
className={itemClass}
onClick={run(onEditSoul)}
>
{isEditingSoul ? (
<ChevronDown className="h-4 w-4" />
) : (
<span aria-hidden className="w-4 text-center text-xs font-bold">
S
</span>
)}
{labels.editSoul}
</button>
<button
type="button"
role="menuitem"
className={itemClass}
onClick={run(onManageSkills)}
>
<Package className="h-4 w-4" />
{labels.manageSkills}
</button>
<button
type="button"
role="menuitem"
className={itemClass}
onClick={run(onCopyCommand)}
>
<Terminal className="h-4 w-4" />
{labels.openInTerminal}
</button>
{!isDefault && (
<button
type="button"
role="menuitem"
className={cn(itemClass, "border-t border-border/50")}
onClick={run(onRename)}
>
<Pencil className="h-4 w-4" />
{labels.rename}
</button>
)}
{!isDefault && (
<button
type="button"
role="menuitem"
className={cn(itemClass, "text-destructive hover:bg-destructive/10")}
onClick={run(onDelete)}
>
<Trash2 className="h-4 w-4" />
{labels.delete}
</button>
)}
</div>
)}
</div>
);
}
export default function ProfilesPage() {
const navigate = useNavigate();
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
const [activeInfo, setActiveInfo] = useState<ActiveProfileInfo | null>(null);
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setEnd } = usePageHeader();
const { setProfile } = useProfileScope();
// 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 ??
"Dashboard switched to manage {name}. New CLI/gateway runs will use this profile too.",
};
}, [t.profiles]);
// Create modal
const [createModalOpen, setCreateModalOpen] = useState(false);
const [newName, setNewName] = useState("");
const [cloneFrom, setCloneFrom] = useState<string | null>("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<string | null>(null);
const [renameTo, setRenameTo] = useState("");
// Inline SOUL editor state
const [editingSoulFor, setEditingSoulFor] = useState<string | null>(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<string | null>(null);
// Inline description editor state
const [editingDescFor, setEditingDescFor] = useState<string | null>(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<string | null>(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<string | null>(null);
const [modelEditChoice, setModelEditChoice] = useState("");
const [modelSaving, setModelSaving] = useState(false);
// Per-profile "set active" in-flight name
const [settingActive, setSettingActive] = useState<string | null>(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);
setProfile(active);
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<string>({
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(
<div className="flex items-center gap-2">
<Button
className="uppercase"
size="sm"
outlined
onClick={() => navigate("/profiles/new")}
>
Build
</Button>
<Button
className="uppercase"
size="sm"
onClick={() => setCreateModalOpen(true)}
>
{t.common.create}
</Button>
</div>,
);
return () => {
setEnd(null);
};
}, [setEnd, t.common.create, loading, navigate]);
const cloning = cloneFrom !== null;
if (loading) {
return (
<div
aria-busy="true"
aria-live="polite"
className="flex items-center justify-center py-24"
>
<span className="sr-only">{t.common.loading}</span>
<ProfilesLoadingSpinner />
</div>
);
}
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
<DeleteConfirmDialog
open={profileDelete.isOpen}
onCancel={profileDelete.cancel}
onConfirm={profileDelete.confirm}
title={t.profiles.confirmDeleteTitle}
description={deleteMessage}
loading={profileDelete.isDeleting}
/>
{/* Create profile modal */}
{createModalOpen && (
<div
ref={createModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={(e) =>
e.target === e.currentTarget && setCreateModalOpen(false)
}
role="dialog"
aria-modal="true"
aria-labelledby="create-profile-title"
>
<div
className={cn(
themedBody,
"relative w-full max-w-md border border-border bg-card shadow-2xl flex flex-col max-h-[90vh]",
)}
>
<Button
ghost
size="icon"
onClick={() => setCreateModalOpen(false)}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X />
</Button>
<header className="p-5 pb-3 border-b border-border">
<h2
id="create-profile-title"
className="font-mondwest text-display text-base tracking-wider"
>
{t.profiles.newProfile}
</h2>
</header>
<div className="min-h-0 overflow-y-auto p-5 grid gap-4">
<div className="grid gap-2">
<Label htmlFor="profile-name">{t.profiles.name}</Label>
<Input
id="profile-name"
autoFocus
placeholder={t.profiles.namePlaceholder}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
}}
aria-invalid={
newName.trim() !== "" &&
!PROFILE_NAME_RE.test(newName.trim())
}
/>
<p className="text-xs text-muted-foreground">
{t.profiles.nameRule}
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="clone-from">{t.profiles.cloneFrom}</Label>
<Select
id="clone-from"
value={cloneFrom ?? ""}
onValueChange={(v) => {
const next = v || null;
setCloneFrom(next);
if (next === null) setCloneAll(false);
}}
>
<SelectOption value="">{t.profiles.cloneFromNone}</SelectOption>
{profiles.map((profile) => (
<SelectOption key={profile.name} value={profile.name}>
{profile.name}
</SelectOption>
))}
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="profile-description">
{L.descriptionOptional}
</Label>
<textarea
id="profile-description"
className="flex min-h-[64px] w-full border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder={L.descriptionPlaceholder}
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="profile-model">{L.modelOptional}</Label>
<Select
id="profile-model"
value={modelChoice}
disabled={modelChoices === null}
onValueChange={setModelChoice}
>
<SelectOption value="">
{modelChoices === null ? L.modelLoading : L.modelInherit}
</SelectOption>
{(modelChoices ?? []).map((c) => (
<SelectOption
key={`${c.provider}\u0000${c.model}`}
value={`${c.provider}\u0000${c.model}`}
>
{c.label}
</SelectOption>
))}
</Select>
{modelChoices !== null && modelChoices.length === 0 && (
<p className="text-xs text-muted-foreground">{L.modelNone}</p>
)}
</div>
<fieldset className="grid gap-3 border-t border-border pt-4">
<legend className="font-mondwest text-display text-xs tracking-wider text-muted-foreground">
{L.advancedOptions}
</legend>
<div className="flex items-center gap-2.5">
<Checkbox
checked={cloneAll}
disabled={!cloning}
id="clone-all"
onCheckedChange={(checked) => setCloneAll(checked === true)}
/>
<Label
className={cn(
"font-mondwest normal-case tracking-normal text-sm cursor-pointer",
!cloning && "opacity-50",
)}
htmlFor="clone-all"
>
{L.cloneAll}
</Label>
</div>
<div className="flex items-center gap-2.5">
<Checkbox
checked={noSkills}
id="no-skills"
disabled={cloning}
onCheckedChange={(checked) => setNoSkills(checked === true)}
/>
<Label
className={cn(
"font-mondwest normal-case tracking-normal text-sm cursor-pointer",
cloning && "opacity-50",
)}
htmlFor="no-skills"
>
{L.noSkillsOption}
</Label>
</div>
</fieldset>
<div className="flex justify-end">
<Button
className="uppercase"
size="sm"
onClick={handleCreate}
disabled={creating}
>
{creating ? t.common.creating : t.common.create}
</Button>
</div>
</div>
</div>
</div>
)}
{/* Active profile banner */}
{activeInfo && (
<Card>
<CardContent className="flex flex-wrap items-center gap-x-4 gap-y-1 py-3 text-xs">
<span className="flex items-center gap-2 text-muted-foreground">
<Check className="h-3.5 w-3.5 text-success" />
<span>
{L.activeProfile}:{" "}
<span className="font-medium text-foreground">
{activeInfo.active}
</span>
</span>
</span>
{activeInfo.current !== activeInfo.active && (
<span className="font-mono text-muted-foreground/80">
({activeInfo.current})
</span>
)}
</CardContent>
</Card>
)}
{/* List */}
<div className="flex flex-col gap-3">
<H2
variant="sm"
className="flex items-center gap-2 text-muted-foreground"
>
<Users className="h-4 w-4" />
{t.profiles.allProfiles} ({profiles.length})
</H2>
{profiles.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
{t.profiles.noProfiles}
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{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 (
<Card key={p.name} className="h-full">
<CardContent className="flex h-full flex-col gap-2 py-4">
{isRenaming ? (
<div className="flex flex-col gap-2">
<Input
autoFocus
value={renameTo}
onChange={(e) => 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 (
<p
className={cn(
"text-xs",
invalid
? "text-destructive"
: "text-muted-foreground",
)}
>
{invalid
? `${t.profiles.invalidName}: ${t.profiles.nameRule}`
: t.profiles.nameRule}
</p>
);
})()}
<div className="flex gap-1.5">
<Button size="sm" onClick={handleRenameSubmit}>
{t.common.save}
</Button>
<Button
size="sm"
ghost
onClick={() => setRenamingFrom(null)}
>
{t.common.cancel}
</Button>
</div>
</div>
) : (
<>
<div className="flex items-start gap-2">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<span className="font-medium text-sm truncate">
{p.name}
</span>
{active && (
<Badge tone="success">{L.activeBadge}</Badge>
)}
{p.is_default && (
<Badge tone="secondary">
{t.profiles.defaultBadge}
</Badge>
)}
{p.has_alias && (
<Badge tone="outline">{L.aliasBadge}</Badge>
)}
{p.has_env && (
<Badge tone="outline">{t.profiles.hasEnv}</Badge>
)}
{p.distribution_name && (
<Badge tone="outline" className="gap-1">
<Package className="h-3 w-3" />
{p.distribution_name}
{p.distribution_version
? `@${p.distribution_version}`
: ""}
</Badge>
)}
</div>
<ProfileActionsMenu
isActive={active}
isDefault={p.is_default}
isEditingDesc={isEditingDesc}
isEditingModel={isEditingModel}
isEditingSoul={isEditingSoul}
settingActive={settingActive === p.name}
labels={{
actions: L.actions,
setActive: L.setActive,
editModel: L.editModel,
editDescription: L.editDescription,
editSoul: t.profiles.editSoul,
manageSkills: L.manageSkills,
openInTerminal: t.profiles.openInTerminal,
rename: t.profiles.rename,
delete: t.common.delete,
}}
onCopyCommand={() =>
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)}
/>
</div>
<div className="flex items-center gap-1.5 text-xs">
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
p.gateway_running
? "bg-success"
: "bg-muted-foreground/40",
)}
/>
<span
className={cn(
p.gateway_running
? "text-success"
: "text-muted-foreground",
)}
>
{p.gateway_running
? L.gatewayRunning
: L.gatewayStopped}
</span>
</div>
<div className="flex items-start gap-2 text-xs">
<span
className={cn(
"line-clamp-2",
p.description
? "text-muted-foreground"
: "text-muted-foreground/60 italic",
)}
>
{p.description || L.noDescription}
</span>
{p.description && p.description_auto && (
<Badge tone="warning" className="shrink-0">
{L.reviewBadge}
</Badge>
)}
</div>
<div className="mt-auto flex flex-col gap-0.5 pt-1 text-xs text-muted-foreground">
{p.model && (
<span className="truncate">
{t.profiles.model}: {p.model}
{p.provider ? ` (${p.provider})` : ""}
</span>
)}
<span>
{t.profiles.skills}: {p.skill_count}
</span>
<span className="font-mono truncate">{p.path}</span>
</div>
</>
)}
</CardContent>
</Card>
);
})}
</div>
</div>
{/* Editor dialog — model / description / SOUL for the selected profile */}
{editorName && (
<div
ref={editorModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={(e) => e.target === e.currentTarget && closeEditor()}
role="dialog"
aria-modal="true"
aria-labelledby="profile-editor-title"
>
<div
className={cn(
themedBody,
"relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col max-h-[90vh]",
)}
>
<Button
ghost
size="icon"
onClick={closeEditor}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X />
</Button>
<header className="p-5 pb-3 border-b border-border">
<h2
id="profile-editor-title"
className="font-mondwest text-display text-base tracking-wider"
>
{editorKind === "model"
? L.editModel
: editorKind === "desc"
? L.description
: t.profiles.soulSection}
<span className="text-muted-foreground"> · {editorName}</span>
</h2>
</header>
<div
className={cn(
"p-5 grid gap-4",
editorKind === "soul" && "min-h-0 overflow-y-auto",
)}
>
{editorKind === "model" &&
(modelChoices !== null && modelChoices.length === 0 ? (
<p className="text-xs text-muted-foreground">{L.modelNone}</p>
) : (
<>
<Select
value={modelEditChoice}
disabled={modelChoices === null}
placeholder={
modelChoices === null ? L.modelLoading : L.modelSelect
}
onValueChange={setModelEditChoice}
>
{(modelChoices ?? []).map((c) => (
<SelectOption
key={`${c.provider}\u0000${c.model}`}
value={`${c.provider}\u0000${c.model}`}
>
{c.label}
</SelectOption>
))}
</Select>
<div className="flex justify-end">
<Button
size="sm"
className="uppercase"
onClick={() => handleSaveModel(editorName)}
disabled={
modelSaving ||
!modelChoices?.some(
(c) =>
`${c.provider}\u0000${c.model}` ===
modelEditChoice,
)
}
>
{modelSaving ? t.common.saving : t.common.save}
</Button>
</div>
</>
))}
{editorKind === "desc" && (
<>
<div className="flex items-center justify-between gap-2">
<Label
htmlFor="profile-desc-editor"
className="font-mondwest text-display text-xs tracking-wider text-muted-foreground"
>
{L.description}
</Label>
<Button
size="sm"
ghost
className="gap-1.5"
disabled={describing}
onClick={() => handleAutoDescribe(editorName)}
>
<Sparkles className="h-3.5 w-3.5" />
{describing ? L.generating : L.autoGenerate}
</Button>
</div>
<textarea
id="profile-desc-editor"
className="flex min-h-[96px] w-full border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder={L.descriptionPlaceholder}
value={descText}
onChange={(e) => setDescText(e.target.value)}
/>
<div className="flex justify-end">
<Button
size="sm"
className="uppercase"
onClick={() => handleSaveDesc(editorName)}
disabled={descSaving}
>
{descSaving ? t.common.saving : t.common.save}
</Button>
</div>
</>
)}
{editorKind === "soul" && (
<>
<Label
htmlFor="profile-soul-editor"
className="font-mondwest text-display text-xs tracking-wider text-muted-foreground"
>
{t.profiles.soulSection}
</Label>
<textarea
id="profile-soul-editor"
className="flex min-h-[280px] w-full border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder={t.profiles.soulPlaceholder}
value={soulText}
onChange={(e) => setSoulText(e.target.value)}
/>
<div className="flex justify-end">
<Button
size="sm"
className="uppercase"
onClick={() => handleSaveSoul(editorName)}
disabled={soulSaving}
>
{soulSaving ? t.common.saving : t.common.save}
</Button>
</div>
</>
)}
</div>
</div>
</div>
)}
</div>
);
}
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;
}