mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-20 05:01:30 +00:00
Dashboard UX polish pass — consolidates create forms into modals triggered from the page header, fixes layout inconsistencies, adds scroll-to navigation for the Keys page, and aligns the TokenBar with the design system. Changes: - App.tsx: add padding to sidebar header - resolve-page-title.ts: add missing routes, better fallback title - en.ts: fix nav labels (Profiles was 'profiles : multi agents') - ModelsPage: two-col layout, auxiliary tasks modal, TokenBar redesign - ProfilesPage: create button in header, form in modal, Checkbox component - CronPage: create button in header, form in modal - EnvPage: scroll-to sub-nav in header, fix text overflow Modal and dialog standardization: - Replace all native confirm()/window.confirm() with ConfirmDialog (OAuthProvidersCard, PluginsPage, ModelsPage, ConfigPage) - Add useModalBehavior hook (Escape-to-close, scroll lock, focus restore) - Apply hook to ProfilesPage, CronPage, AuxiliaryTasksModal Component fixes (from PR review): - Checkbox: fix controlled/uncontrolled mismatch, add focus-visible ring - TokenBar: add rounded-full to legend dots, remove dead code CI/test fixes: - Fix TS unused imports (noUnusedLocals), type-narrow PickerTarget union - Add windows-footgun suppression on platform-guarded os.killpg - Fix 19 stale unit tests + 9 e2e tests broken by recent main changes - Restore minimal example-dashboard plugin for plugin auth test
495 lines
17 KiB
TypeScript
495 lines
17 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
import { ChevronDown, Pencil, Plus, Terminal, Trash2, Users, X } from "lucide-react";
|
|
import { H2 } from "@/components/NouiTypography";
|
|
import { api } from "@/lib/api";
|
|
import type { ProfileInfo } from "@/lib/api";
|
|
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
|
import { useToast } from "@/hooks/useToast";
|
|
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
|
|
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
|
import { Toast } from "@/components/Toast";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Badge } from "@nous-research/ui/ui/components/badge";
|
|
import { Button } from "@nous-research/ui/ui/components/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { useI18n } from "@/i18n";
|
|
import { usePageHeader } from "@/contexts/usePageHeader";
|
|
|
|
// 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}$/;
|
|
|
|
export default function ProfilesPage() {
|
|
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
|
|
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<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);
|
|
|
|
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<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;
|
|
|
|
// Put "Create" button in page header
|
|
useLayoutEffect(() => {
|
|
setEnd(
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setCreateModalOpen(true)}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
{t.common.create}
|
|
</Button>,
|
|
);
|
|
return () => {
|
|
setEnd(null);
|
|
};
|
|
}, [setEnd, t.common.create, loading]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-24">
|
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
// Profile names, model slugs, and paths are case-sensitive; opt out of
|
|
// the app shell's global ``uppercase`` so they render as the user typed.
|
|
// Children that explicitly opt back in (Badges, etc.) keep their casing.
|
|
<div className="flex flex-col gap-6 normal-case">
|
|
<Toast toast={toast} />
|
|
|
|
<DeleteConfirmDialog
|
|
open={profileDelete.isOpen}
|
|
onCancel={profileDelete.cancel}
|
|
onConfirm={profileDelete.confirm}
|
|
title={t.profiles.confirmDeleteTitle}
|
|
description={
|
|
pendingName
|
|
? t.profiles.confirmDeleteMessage.replace("{name}", pendingName)
|
|
: t.profiles.confirmDeleteMessage
|
|
}
|
|
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="relative w-full max-w-md border border-border bg-card shadow-2xl flex flex-col">
|
|
<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-display text-base tracking-wider uppercase"
|
|
>
|
|
{t.profiles.newProfile}
|
|
</h2>
|
|
</header>
|
|
|
|
<div className="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>
|
|
|
|
<Checkbox
|
|
id="clone-from-default"
|
|
checked={cloneFromDefault}
|
|
onChange={(e) => setCloneFromDefault(e.target.checked)}
|
|
label={t.profiles.cloneFromDefault}
|
|
/>
|
|
|
|
<div className="flex justify-end">
|
|
<Button size="sm" onClick={handleCreate} disabled={creating}>
|
|
<Plus className="h-3 w-3" />
|
|
{creating ? t.common.creating : t.common.create}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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>
|
|
)}
|
|
|
|
{profiles.map((p) => {
|
|
const isRenaming = renamingFrom === p.name;
|
|
const isEditingSoul = editingSoulFor === p.name;
|
|
return (
|
|
<Card key={p.name}>
|
|
<CardContent className="flex items-center gap-4 py-4">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
{isRenaming ? (
|
|
<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())
|
|
}
|
|
className="max-w-xs"
|
|
/>
|
|
) : (
|
|
<span className="font-medium text-sm truncate">
|
|
{p.name}
|
|
</span>
|
|
)}
|
|
{p.is_default && (
|
|
<Badge tone="secondary">{t.profiles.defaultBadge}</Badge>
|
|
)}
|
|
{p.has_env && (
|
|
<Badge tone="outline">{t.profiles.hasEnv}</Badge>
|
|
)}
|
|
</div>
|
|
{isRenaming &&
|
|
(() => {
|
|
const trimmed = renameTo.trim();
|
|
const invalid =
|
|
trimmed !== "" &&
|
|
trimmed !== p.name &&
|
|
!PROFILE_NAME_RE.test(trimmed);
|
|
return (
|
|
<p
|
|
className={
|
|
"text-xs mb-1 " +
|
|
(invalid
|
|
? "text-destructive"
|
|
: "text-muted-foreground")
|
|
}
|
|
>
|
|
{invalid
|
|
? `${t.profiles.invalidName}: ${t.profiles.nameRule}`
|
|
: t.profiles.nameRule}
|
|
</p>
|
|
);
|
|
})()}
|
|
<div className="flex items-center gap-4 text-xs text-muted-foreground flex-wrap">
|
|
{p.model && (
|
|
<span>
|
|
{t.profiles.model}: {p.model}
|
|
{p.provider ? ` (${p.provider})` : ""}
|
|
</span>
|
|
)}
|
|
<span>
|
|
{t.profiles.skills}: {p.skill_count}
|
|
</span>
|
|
<span className="font-mono truncate max-w-[28rem]">
|
|
{p.path}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{isRenaming ? (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleRenameSubmit}
|
|
>
|
|
{t.common.save}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
ghost
|
|
onClick={() => setRenamingFrom(null)}
|
|
>
|
|
{t.common.cancel}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button
|
|
ghost
|
|
size="icon"
|
|
title={t.profiles.editSoul}
|
|
aria-label={t.profiles.editSoul}
|
|
onClick={() => openSoulEditor(p.name)}
|
|
>
|
|
{isEditingSoul ? (
|
|
<ChevronDown className="h-4 w-4" />
|
|
) : (
|
|
<span aria-hidden className="text-xs font-bold">
|
|
S
|
|
</span>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
ghost
|
|
size="icon"
|
|
title={t.profiles.openInTerminal}
|
|
aria-label={t.profiles.openInTerminal}
|
|
onClick={() => handleCopyTerminalCommand(p.name)}
|
|
>
|
|
<Terminal className="h-4 w-4" />
|
|
</Button>
|
|
{!p.is_default && (
|
|
<Button
|
|
ghost
|
|
size="icon"
|
|
title={t.profiles.rename}
|
|
aria-label={t.profiles.rename}
|
|
onClick={() => {
|
|
setRenamingFrom(p.name);
|
|
setRenameTo(p.name);
|
|
}}
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
{!p.is_default && (
|
|
<Button
|
|
ghost
|
|
size="icon"
|
|
title={t.common.delete}
|
|
aria-label={t.common.delete}
|
|
onClick={() => profileDelete.requestDelete(p.name)}
|
|
>
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
|
|
{isEditingSoul && (
|
|
<div className="border-t border-border px-4 pb-4 pt-3 flex flex-col gap-2">
|
|
<Label
|
|
htmlFor={`soul-editor-${p.name}`}
|
|
className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-foreground"
|
|
>
|
|
{t.profiles.soulSection}
|
|
</Label>
|
|
<textarea
|
|
id={`soul-editor-${p.name}`}
|
|
className="flex min-h-[180px] 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>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handleSaveSoul(p.name)}
|
|
disabled={soulSaving}
|
|
>
|
|
{soulSaving ? t.common.saving : t.profiles.saveSoul}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|