mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-06 12:52:11 +00:00
* 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>
1425 lines
48 KiB
TypeScript
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;
|
|
}
|