feat: react-router, sidebar layout, sticky header, dropdown component, remove emojis, rounded corners

This commit is contained in:
Austin Pickett 2026-04-14 00:01:18 -04:00
parent 0cc7f79016
commit bc3844c907
16 changed files with 914 additions and 509 deletions

71
web/package-lock.json generated
View file

@ -14,6 +14,7 @@
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1"
},
@ -63,6 +64,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@ -1637,6 +1639,7 @@
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@ -1647,6 +1650,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -1706,6 +1710,7 @@
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.57.0",
"@typescript-eslint/types": "8.57.0",
@ -1983,6 +1988,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -2091,6 +2097,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -2208,6 +2215,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -2354,6 +2374,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -3317,6 +3338,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -3377,6 +3399,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -3386,6 +3409,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -3403,6 +3427,44 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz",
"integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz",
"integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==",
"license": "MIT",
"dependencies": {
"react-router": "7.14.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@ -3473,6 +3535,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -3608,6 +3676,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -3693,6 +3762,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -3814,6 +3884,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View file

@ -16,6 +16,7 @@
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1"
},

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { Routes, Route, NavLink, Navigate } from "react-router-dom";
import { Activity, BarChart3, Clock, FileText, KeyRound, MessageSquare, Package, Settings } from "lucide-react";
import StatusPage from "@/pages/StatusPage";
import ConfigPage from "@/pages/ConfigPage";
@ -10,88 +10,58 @@ import CronPage from "@/pages/CronPage";
import SkillsPage from "@/pages/SkillsPage";
const NAV_ITEMS = [
{ id: "status", label: "Status", icon: Activity },
{ id: "sessions", label: "Sessions", icon: MessageSquare },
{ id: "analytics", label: "Analytics", icon: BarChart3 },
{ id: "logs", label: "Logs", icon: FileText },
{ id: "cron", label: "Cron", icon: Clock },
{ id: "skills", label: "Skills", icon: Package },
{ id: "config", label: "Config", icon: Settings },
{ id: "env", label: "Keys", icon: KeyRound },
{ path: "/", label: "Status", icon: Activity },
{ path: "/sessions", label: "Sessions", icon: MessageSquare },
{ path: "/analytics", label: "Analytics", icon: BarChart3 },
{ path: "/logs", label: "Logs", icon: FileText },
{ path: "/cron", label: "Cron", icon: Clock },
{ path: "/skills", label: "Skills", icon: Package },
{ path: "/config", label: "Config", icon: Settings },
{ path: "/env", label: "Keys", icon: KeyRound },
] as const;
type PageId = (typeof NAV_ITEMS)[number]["id"];
const PAGE_COMPONENTS: Record<PageId, React.FC> = {
status: StatusPage,
sessions: SessionsPage,
analytics: AnalyticsPage,
logs: LogsPage,
cron: CronPage,
skills: SkillsPage,
config: ConfigPage,
env: EnvPage,
};
export default function App() {
const [page, setPage] = useState<PageId>("status");
const [animKey, setAnimKey] = useState(0);
const initialRef = useRef(true);
useEffect(() => {
// Skip the animation key bump on initial mount to avoid re-mounting
// the default page component (which causes duplicate API requests).
if (initialRef.current) {
initialRef.current = false;
return;
}
setAnimKey((k) => k + 1);
}, [page]);
const PageComponent = PAGE_COMPONENTS[page];
return (
<div className="flex min-h-screen flex-col bg-background text-foreground overflow-x-hidden">
{/* Global grain + warm glow (matches landing page) */}
<div className="noise-overlay" />
<div className="warm-glow" />
{/* ---- Header with grid-border nav ---- */}
<header className="sticky top-0 z-40 border-b border-border bg-background/90 backdrop-blur-sm">
<header className="fixed top-0 left-0 right-0 z-40 border-b border-border bg-background/90 backdrop-blur-sm">
<div className="mx-auto flex h-12 max-w-[1400px] items-stretch">
{/* Brand — abbreviated on mobile */}
<div className="flex items-center border-r border-border px-3 sm:px-5 shrink-0">
<span className="font-collapse text-lg sm:text-xl font-bold tracking-wider uppercase blend-lighter">
H<span className="hidden sm:inline">ermes </span>A<span className="hidden sm:inline">gent</span>
</span>
</div>
{/* Nav — icons only on mobile, icon+label on sm+ */}
<nav className="flex items-stretch overflow-x-auto scrollbar-none">
{NAV_ITEMS.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => setPage(id)}
className={`group relative inline-flex items-center gap-1 sm:gap-1.5 border-r border-border px-2.5 sm:px-4 py-2 font-display text-[0.65rem] sm:text-[0.8rem] tracking-[0.12em] uppercase whitespace-nowrap transition-colors cursor-pointer shrink-0 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
page === id
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
{NAV_ITEMS.map(({ path, label, icon: Icon }) => (
<NavLink
key={path}
to={path}
end={path === "/"}
className={({ isActive }) =>
`group relative inline-flex items-center gap-1 sm:gap-1.5 border-r border-border px-2.5 sm:px-4 py-2 font-display text-[0.65rem] sm:text-[0.8rem] tracking-[0.12em] uppercase whitespace-nowrap transition-colors cursor-pointer shrink-0 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
isActive
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`
}
>
<Icon className="h-4 w-4 sm:h-3.5 sm:w-3.5 shrink-0" />
<span className="hidden sm:inline">{label}</span>
{/* Hover highlight */}
<span className="absolute inset-0 bg-foreground pointer-events-none transition-opacity duration-150 group-hover:opacity-5 opacity-0" />
{/* Active indicator */}
{page === id && (
<span className="absolute bottom-0 left-0 right-0 h-px bg-foreground" />
{({ isActive }) => (
<>
<Icon className="h-4 w-4 sm:h-3.5 sm:w-3.5 shrink-0" />
<span className="hidden sm:inline">{label}</span>
<span className="absolute inset-0 bg-foreground pointer-events-none transition-opacity duration-150 group-hover:opacity-5 opacity-0" />
{isActive && (
<span className="absolute bottom-0 left-0 right-0 h-px bg-foreground" />
)}
</>
)}
</button>
</NavLink>
))}
</nav>
{/* Version badge — hidden on mobile */}
<div className="ml-auto hidden sm:flex items-center px-4 text-muted-foreground">
<span className="font-display text-[0.7rem] tracking-[0.15em] uppercase opacity-50">
Web UI
@ -100,15 +70,20 @@ export default function App() {
</div>
</header>
<main
key={animKey}
className="relative z-2 mx-auto w-full max-w-[1400px] flex-1 px-3 sm:px-6 py-4 sm:py-8"
style={{ animation: "fade-in 150ms ease-out" }}
>
<PageComponent />
<main className="relative z-2 mx-auto w-full max-w-[1400px] flex-1 px-3 sm:px-6 pt-16 sm:pt-20 pb-4 sm:pb-8">
<Routes>
<Route path="/" element={<StatusPage />} />
<Route path="/sessions" element={<SessionsPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/logs" element={<LogsPage />} />
<Route path="/cron" element={<CronPage />} />
<Route path="/skills" element={<SkillsPage />} />
<Route path="/config" element={<ConfigPage />} />
<Route path="/env" element={<EnvPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
{/* ---- Footer ---- */}
<footer className="relative z-2 border-t border-border">
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-3 sm:px-6 py-3">
<span className="font-display text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] uppercase opacity-50">

View file

@ -1,6 +1,6 @@
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select } from "@/components/ui/select";
import { Select, SelectOption } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
function FieldHint({ schema, schemaKey }: { schema: Record<string, unknown>; schemaKey: string }) {
@ -44,11 +44,11 @@ export function AutoField({
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Select value={String(value ?? "")} onChange={(e) => onChange(e.target.value)}>
<Select value={String(value ?? "")} onValueChange={(v) => onChange(v)}>
{options.map((opt) => (
<option key={opt} value={opt}>
<SelectOption key={opt} value={opt}>
{opt || "(none)"}
</option>
</SelectOption>
))}
</Select>
</div>
@ -85,7 +85,7 @@ export function AutoField({
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<textarea
className="flex min-h-[80px] w-full rounded-md 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"
className="flex min-h-[80px] 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"
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
/>
@ -117,7 +117,7 @@ export function AutoField({
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
return (
<div className="grid gap-3 rounded-lg border border-border p-3">
<div className="grid gap-3 border border-border p-3">
<Label className="text-xs font-medium">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
{Object.entries(obj).map(([subKey, subVal]) => (

View file

@ -128,7 +128,7 @@ function Block({ block, highlightTerms }: { block: BlockNode; highlightTerms?: s
switch (block.type) {
case "code":
return (
<pre className="rounded-md bg-secondary/60 border border-border px-3 py-2.5 text-xs font-mono leading-relaxed overflow-x-auto">
<pre className="bg-secondary/60 border border-border px-3 py-2.5 text-xs font-mono leading-relaxed overflow-x-auto">
<code>{block.content}</code>
</pre>
);
@ -228,7 +228,7 @@ function InlineContent({ text, highlightTerms }: { text: string; highlightTerms?
return <HighlightedText key={i} text={node.content} terms={highlightTerms} />;
case "code":
return (
<code key={i} className="rounded bg-secondary/60 px-1.5 py-0.5 text-xs font-mono text-primary/90">
<code key={i} className="bg-secondary/60 px-1.5 py-0.5 text-xs font-mono text-primary/90">
{node.content}
</code>
);
@ -269,7 +269,7 @@ function HighlightedText({ text, terms }: { text: string; terms?: string[] }) {
<>
{parts.map((part, i) =>
regex.test(part) ? (
<mark key={i} className="bg-warning/30 text-warning rounded-sm px-0.5">{part}</mark>
<mark key={i} className="bg-warning/30 text-warning px-0.5">{part}</mark>
) : (
<span key={i}>{part}</span>
)

View file

@ -188,7 +188,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
{!p.status.logged_in && (
<span className="text-xs text-muted-foreground/80">
Not connected. Run{" "}
<code className="text-foreground bg-secondary/40 px-1 rounded">
<code className="text-foreground bg-secondary/40 px-1">
{p.cli_command}
</code>{" "}
in a terminal.

View file

@ -4,7 +4,7 @@ export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElemen
return (
<div
className={cn(
"border border-border bg-card/80 text-card-foreground overflow-hidden w-full",
"border border-border bg-card/80 text-card-foreground w-full",
className,
)}
{...props}

View file

@ -1,15 +1,194 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { ChevronDown, Check } from "lucide-react";
import { cn } from "@/lib/utils";
export function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
export function Select({
value,
onValueChange,
children,
className,
id,
disabled,
}: SelectProps) {
const [open, setOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const options: SelectOptionData[] = [];
flattenChildren(children, options);
const selectedOption = options.find((o) => o.value === value);
const displayLabel = selectedOption?.label ?? value ?? "";
const close = useCallback(() => {
setOpen(false);
setHighlightedIndex(-1);
}, []);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
close();
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open, close]);
useEffect(() => {
if (open && listRef.current && highlightedIndex >= 0) {
const el = listRef.current.children[highlightedIndex] as HTMLElement | undefined;
el?.scrollIntoView({ block: "nearest" });
}
}, [open, highlightedIndex]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return;
switch (e.key) {
case "Enter":
case " ":
e.preventDefault();
if (!open) {
setOpen(true);
setHighlightedIndex(options.findIndex((o) => o.value === value));
} else if (highlightedIndex >= 0 && options[highlightedIndex]) {
onValueChange?.(options[highlightedIndex].value);
close();
}
break;
case "ArrowDown":
e.preventDefault();
if (!open) {
setOpen(true);
setHighlightedIndex(options.findIndex((o) => o.value === value));
} else {
setHighlightedIndex((i) => Math.min(i + 1, options.length - 1));
}
break;
case "ArrowUp":
e.preventDefault();
if (open) {
setHighlightedIndex((i) => Math.max(i - 1, 0));
}
break;
case "Escape":
e.preventDefault();
close();
break;
}
};
return (
<select
className={cn(
"flex h-9 w-full border border-border bg-background/40 px-3 py-1 font-courier text-sm",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
<div ref={containerRef} className={cn("relative", className)} id={id}>
<button
type="button"
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
disabled={disabled}
onClick={() => !disabled && setOpen((o) => !o)}
onKeyDown={handleKeyDown}
className={cn(
"flex h-9 w-full items-center justify-between border border-border bg-background/40 px-3 py-1 font-courier text-sm text-left transition-colors",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25",
"disabled:cursor-not-allowed disabled:opacity-50",
"cursor-pointer",
)}
>
<span className={cn("truncate", !selectedOption && "text-muted-foreground")}>
{displayLabel}
</span>
<ChevronDown
className={cn(
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform",
open && "rotate-180",
)}
/>
</button>
{open && (
<div
ref={listRef}
role="listbox"
className={cn(
"absolute z-50 mt-1 w-full border border-border bg-popover text-popover-foreground shadow-lg",
"max-h-60 overflow-auto",
"animate-[fade-in_100ms_ease-out]",
)}
>
{options.map((opt, i) => {
const isSelected = opt.value === value;
const isHighlighted = i === highlightedIndex;
return (
<div
key={opt.value}
role="option"
aria-selected={isSelected}
onMouseEnter={() => setHighlightedIndex(i)}
onClick={() => {
onValueChange?.(opt.value);
close();
}}
className={cn(
"flex items-center gap-2 px-3 py-2 text-sm font-courier cursor-pointer transition-colors",
isHighlighted && "bg-foreground/10",
isSelected && "text-foreground",
!isSelected && "text-muted-foreground",
)}
>
<Check
className={cn(
"h-3.5 w-3.5 shrink-0",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">{opt.label}</span>
</div>
);
})}
</div>
)}
{...props}
/>
</div>
);
}
export function SelectOption(_props: SelectOptionProps) {
return null;
}
function flattenChildren(children: React.ReactNode, out: SelectOptionData[]) {
const arr = Array.isArray(children) ? children : [children];
for (const child of arr) {
if (!child || typeof child !== "object" || !("props" in child)) continue;
const props = child.props as Record<string, unknown>;
if (props.value !== undefined) {
out.push({
value: String(props.value),
label: typeof props.children === "string" ? props.children : String(props.value),
});
} else if (props.children) {
flattenChildren(props.children as React.ReactNode, out);
}
}
}
interface SelectProps {
value?: string;
onValueChange?: (value: string) => void;
children?: React.ReactNode;
className?: string;
id?: string;
disabled?: boolean;
}
interface SelectOptionProps {
value: string;
children: React.ReactNode;
}
interface SelectOptionData {
value: string;
label: string;
}

View file

@ -81,7 +81,6 @@ html, body {
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--color-foreground) 20%, transparent);
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--color-foreground) 35%, transparent);

View file

@ -1,7 +1,10 @@
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import "./index.css";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<App />,
<BrowserRouter>
<App />
</BrowserRouter>,
);

View file

@ -72,11 +72,11 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 rounded-sm bg-[#ffe6cb]" />
<div className="h-2.5 w-2.5 bg-[#ffe6cb]" />
Input
</div>
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 rounded-sm bg-emerald-500" />
<div className="h-2.5 w-2.5 bg-emerald-500" />
Output
</div>
</div>
@ -95,7 +95,7 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10 pointer-events-none">
<div className="rounded-md bg-card border border-border px-2.5 py-1.5 text-[10px] text-foreground shadow-lg whitespace-nowrap">
<div className="bg-card border border-border px-2.5 py-1.5 text-[10px] text-foreground shadow-lg whitespace-nowrap">
<div className="font-medium">{formatDate(d.day)}</div>
<div>Input: {formatTokens(d.input_tokens)}</div>
<div>Output: {formatTokens(d.output_tokens)}</div>

View file

@ -1,17 +1,32 @@
import { useEffect, useRef, useState, useMemo } from "react";
import {
Bot,
ChevronRight,
Code,
Ear,
Download,
FileText,
FormInput,
Globe,
Lock,
MessageSquare,
Mic,
Monitor,
Package,
Palette,
RotateCcw,
Save,
ScrollText,
Search,
Upload,
X,
ChevronRight,
Settings,
Settings2,
FileText,
Upload,
Users,
Volume2,
Wrench,
X,
} from "lucide-react";
import type { ComponentType } from "react";
import { api } from "@/lib/api";
import { getNestedValue, setNestedValue } from "@/lib/nested";
import { useToast } from "@/hooks/useToast";
@ -26,23 +41,24 @@ import { Badge } from "@/components/ui/badge";
/* Helpers */
/* ------------------------------------------------------------------ */
const CATEGORY_ICONS: Record<string, string> = {
general: "⚙️",
agent: "🤖",
terminal: "💻",
display: "🎨",
delegation: "👥",
memory: "🧠",
compression: "📦",
security: "🔒",
browser: "🌐",
voice: "🎙️",
tts: "🔊",
stt: "👂",
logging: "📋",
discord: "💬",
auxiliary: "🔧",
const CATEGORY_ICONS: Record<string, ComponentType<{ className?: string }>> = {
general: Settings,
agent: Bot,
terminal: Monitor,
display: Palette,
delegation: Users,
memory: Package,
compression: Package,
security: Lock,
browser: Globe,
voice: Mic,
tts: Volume2,
stt: Ear,
logging: ScrollText,
discord: MessageSquare,
auxiliary: Wrench,
};
const FallbackIcon = FileText;
function prettyCategoryName(cat: string): string {
if (cat === "tts") return "Text-to-Speech";
@ -50,6 +66,11 @@ function prettyCategoryName(cat: string): string {
return cat.charAt(0).toUpperCase() + cat.slice(1);
}
function CategoryIcon({ cat, className }: { cat: string; className?: string }) {
const Icon = CATEGORY_ICONS[cat] ?? FallbackIcon;
return <Icon className={className} />;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
@ -230,7 +251,7 @@ export default function ConfigPage() {
<div key={key}>
{showCatBadge && (
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
<span className="text-base">{CATEGORY_ICONS[cat] || "📄"}</span>
<CategoryIcon cat={cat} className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{prettyCategoryName(cat)}
</span>
@ -266,7 +287,7 @@ export default function ConfigPage() {
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<code className="text-xs text-muted-foreground bg-muted/50 px-2 py-0.5 rounded">
<code className="text-xs text-muted-foreground bg-muted/50 px-2 py-0.5">
~/.hermes/config.yaml
</code>
</div>
@ -379,13 +400,13 @@ export default function ConfigPage() {
setSearchQuery("");
setActiveCategory(cat);
}}
className={`group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span className="text-sm leading-none">{CATEGORY_ICONS[cat] || "📄"}</span>
<CategoryIcon cat={cat} className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate">{prettyCategoryName(cat)}</span>
<span className={`text-[10px] tabular-nums ${isActive ? "text-primary/60" : "text-muted-foreground/50"}`}>
{categoryCounts[cat] || 0}
@ -432,7 +453,7 @@ export default function ConfigPage() {
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<span className="text-base">{CATEGORY_ICONS[activeCategory] || "📄"}</span>
<CategoryIcon cat={activeCategory} className="h-4 w-4" />
{prettyCategoryName(activeCategory)}
</CardTitle>
<Badge variant="secondary" className="text-[10px]">

View file

@ -9,7 +9,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select } from "@/components/ui/select";
import { Select, SelectOption } from "@/components/ui/select";
function formatTime(iso?: string | null): string {
if (!iso) return "—";
@ -147,7 +147,7 @@ export default function CronPage() {
<Label htmlFor="cron-prompt">Prompt</Label>
<textarea
id="cron-prompt"
className="flex min-h-[80px] w-full rounded-md 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"
className="flex min-h-[80px] 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="What should the agent do on each run?"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
@ -170,13 +170,13 @@ export default function CronPage() {
<Select
id="cron-deliver"
value={deliver}
onChange={(e) => setDeliver(e.target.value)}
onValueChange={setDeliver}
>
<option value="local">Local</option>
<option value="telegram">Telegram</option>
<option value="discord">Discord</option>
<option value="slack">Slack</option>
<option value="email">Email</option>
<SelectOption value="local">Local</SelectOption>
<SelectOption value="telegram">Telegram</SelectOption>
<SelectOption value="discord">Discord</SelectOption>
<SelectOption value="slack">Slack</SelectOption>
<SelectOption value="email">Email</SelectOption>
</Select>
</div>

View file

@ -1,5 +1,13 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { FileText, RefreshCw } from "lucide-react";
import {
AlertTriangle,
Bug,
ChevronRight,
FileText,
Hash,
Layers,
RefreshCw,
} from "lucide-react";
import { api } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@ -27,37 +35,6 @@ const LINE_COLORS: Record<string, string> = {
debug: "text-muted-foreground/60",
};
function FilterBar<T extends string>({
label,
options,
value,
onChange,
}: {
label: string;
options: readonly T[];
value: T;
onChange: (v: T) => void;
}) {
return (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground font-medium w-20 shrink-0">{label}</span>
<div className="flex gap-1 flex-wrap">
{options.map((opt) => (
<Button
key={opt}
variant={value === opt ? "default" : "outline"}
size="sm"
className="text-xs h-7 px-2.5"
onClick={() => onChange(opt)}
>
{opt}
</Button>
))}
</div>
</div>
);
}
export default function LogsPage() {
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL");
@ -76,7 +53,6 @@ export default function LogsPage() {
.getLogs({ file, lines: lineCount, level, component })
.then((resp) => {
setLines(resp.lines);
// Auto-scroll to bottom
setTimeout(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
@ -87,12 +63,10 @@ export default function LogsPage() {
.finally(() => setLoading(false));
}, [file, lineCount, level, component]);
// Initial load + refetch on filter change
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
// Auto-refresh polling
useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(fetchLogs, 5000);
@ -100,76 +74,176 @@ export default function LogsPage() {
}, [autoRefresh, fetchLogs]);
return (
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Logs</CardTitle>
{loading && (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
)}
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Switch
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
<Label className="text-xs">Auto-refresh</Label>
{autoRefresh && (
<Badge variant="success" className="text-[10px]">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
Live
</Badge>
)}
</div>
<Button variant="outline" size="sm" onClick={fetchLogs} className="text-xs h-7">
<RefreshCw className="h-3 w-3 mr-1" />
Refresh
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 mb-4">
<FilterBar label="File" options={FILES} value={file} onChange={setFile} />
<FilterBar label="Level" options={LEVELS} value={level} onChange={setLevel} />
<FilterBar label="Component" options={COMPONENTS} value={component} onChange={setComponent} />
<FilterBar
label="Lines"
options={LINE_COUNTS.map(String) as unknown as readonly string[]}
value={String(lineCount)}
onChange={(v) => setLineCount(Number(v) as (typeof LINE_COUNTS)[number])}
/>
</div>
{error && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3 mb-4">
<p className="text-sm text-destructive">{error}</p>
</div>
<div className="flex flex-col gap-4">
{/* ═══════════════ Header ═══════════════ */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{file} / {level.toLowerCase()} / {component}
</span>
{loading && (
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
)}
</div>
<div
ref={scrollRef}
className="border border-border bg-background p-4 font-mono-ui text-xs leading-5 overflow-auto max-h-[600px] min-h-[200px]"
>
{lines.length === 0 && !loading && (
<p className="text-muted-foreground text-center py-8">No log lines found</p>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
<Label className="text-xs">Auto-refresh</Label>
{autoRefresh && (
<Badge variant="success" className="text-[10px]">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
Live
</Badge>
)}
{lines.map((line, i) => {
const cls = classifyLine(line);
return (
<div key={i} className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1 rounded`}>
{line}
</div>
);
})}
</div>
</CardContent>
</Card>
<Button variant="outline" size="sm" onClick={fetchLogs} className="text-xs h-7">
<RefreshCw className="h-3 w-3 mr-1" />
Refresh
</Button>
</div>
</div>
{/* ═══════════════ Sidebar + Content ═══════════════ */}
<div className="flex flex-col sm:flex-row gap-4" style={{ minHeight: "calc(100vh - 180px)" }}>
{/* ---- Sidebar ---- */}
<div className="sm:w-52 sm:shrink-0">
<div className="sm:sticky sm:top-[72px] flex flex-col gap-1">
{/* File section */}
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none pb-1 sm:pb-0">
<SidebarHeading icon={FileText} label="File" />
{FILES.map((f) => (
<SidebarItem
key={f}
label={f}
active={file === f}
indented
onClick={() => setFile(f)}
/>
))}
<div className="hidden sm:block border-t border-border my-1" />
<SidebarHeading icon={AlertTriangle} label="Level" />
{LEVELS.map((l) => (
<SidebarItem
key={l}
label={l.toLowerCase()}
active={level === l}
indented
onClick={() => setLevel(l)}
/>
))}
<div className="hidden sm:block border-t border-border my-1" />
<SidebarHeading icon={Layers} label="Component" />
{COMPONENTS.map((c) => (
<SidebarItem
key={c}
label={c}
active={component === c}
indented
onClick={() => setComponent(c)}
/>
))}
<div className="hidden sm:block border-t border-border my-1" />
<SidebarHeading icon={Hash} label="Lines" />
{LINE_COUNTS.map((n) => (
<SidebarItem
key={n}
label={String(n)}
active={lineCount === n}
indented
onClick={() => setLineCount(n)}
/>
))}
</div>
</div>
</div>
{/* ---- Content ---- */}
<div className="flex-1 min-w-0">
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Bug className="h-4 w-4" />
{file} logs
</CardTitle>
<Badge variant="secondary" className="text-[10px]">
{lines.length} line{lines.length !== 1 ? "s" : ""}
</Badge>
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
{error && (
<div className="bg-destructive/10 border border-destructive/20 p-3 mb-4">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<div
ref={scrollRef}
className="border border-border bg-background p-4 font-mono-ui text-xs leading-5 overflow-auto max-h-[600px] min-h-[200px]"
>
{lines.length === 0 && !loading && (
<p className="text-muted-foreground text-center py-8">No log lines found</p>
)}
{lines.map((line, i) => {
const cls = classifyLine(line);
return (
<div key={i} className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1`}>
{line}
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
function SidebarHeading({ icon: Icon, label }: SidebarHeadingProps) {
return (
<div className="flex items-center gap-2 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
<Icon className="h-3.5 w-3.5" />
{label}
</div>
);
}
function SidebarItem({ label, active, indented, onClick }: SidebarItemProps) {
return (
<button
type="button"
onClick={onClick}
className={`group flex items-center gap-2 ${indented ? "sm:pl-6" : ""} px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
active
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span className="flex-1 truncate">{label}</span>
{active && <ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />}
</button>
);
}
interface SidebarHeadingProps {
icon: React.ComponentType<{ className?: string }>;
label: string;
}
interface SidebarItemProps {
label: string;
active: boolean;
indented?: boolean;
onClick: () => void;
}

View file

@ -50,7 +50,7 @@ function SnippetHighlight({ snippet }: { snippet: string }) {
parts.push(snippet.slice(last, match.index));
}
parts.push(
<mark key={i++} className="bg-warning/30 text-warning rounded-sm px-0.5">
<mark key={i++} className="bg-warning/30 text-warning px-0.5">
{match[1]}
</mark>
);
@ -77,7 +77,7 @@ function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name:
}
return (
<div className="mt-2 rounded-md border border-warning/20 bg-warning/5">
<div className="mt-2 border border-warning/20 bg-warning/5">
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-warning cursor-pointer hover:bg-warning/10 transition-colors"

View file

@ -1,13 +1,28 @@
import { useEffect, useState, useMemo } from "react";
import {
Package,
Search,
Wrench,
ChevronDown,
Blocks,
Bot,
BrainCircuit,
ChevronRight,
Filter,
Code,
Database,
FileCode,
FileSearch,
Globe,
Image,
LayoutDashboard,
Monitor,
Package,
Paintbrush,
Search,
Server,
Shield,
Sparkles,
Terminal,
Wrench,
X,
} from "lucide-react";
import type { ComponentType } from "react";
import { api } from "@/lib/api";
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
import { useToast } from "@/hooks/useToast";
@ -21,13 +36,6 @@ import { Switch } from "@/components/ui/switch";
/* Types & helpers */
/* ------------------------------------------------------------------ */
interface CategoryGroup {
name: string; // display name
key: string; // raw key (or "__none__")
skills: SkillInfo[];
enabledCount: number;
}
const CATEGORY_LABELS: Record<string, string> = {
mlops: "MLOps",
"mlops/cloud": "MLOps / Cloud",
@ -54,21 +62,54 @@ function prettyCategory(raw: string | null | undefined): string {
.join(" ");
}
const TOOLSET_ICONS: Record<string, ComponentType<{ className?: string }>> = {
terminal: Terminal,
shell: Terminal,
browser: Globe,
web: Globe,
code: Code,
coding: Code,
python: FileCode,
files: FileSearch,
file: FileSearch,
search: Search,
image: Image,
vision: Image,
memory: BrainCircuit,
database: Database,
db: Database,
mcp: Blocks,
ai: Sparkles,
agent: Bot,
security: Shield,
server: Server,
deploy: Server,
ui: Paintbrush,
ux: LayoutDashboard,
display: Monitor,
};
function toolsetIcon(name: string, label: string): ComponentType<{ className?: string }> {
const lower = name.toLowerCase();
if (TOOLSET_ICONS[lower]) return TOOLSET_ICONS[lower];
for (const [key, icon] of Object.entries(TOOLSET_ICONS)) {
if (lower.includes(key) || label.toLowerCase().includes(key)) return icon;
}
return Wrench;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function SkillsPage() {
const [view, setView] = useState<"skills" | "toolsets">("skills");
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [toolsets, setToolsets] = useState<ToolsetInfo[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
// Start collapsed by default
const [collapsedCategories, setCollapsedCategories] = useState<Set<string> | "all">("all");
const { toast, showToast } = useToast();
useEffect(() => {
@ -123,27 +164,6 @@ export default function SkillsPage() {
});
}, [skills, search, lowerSearch, activeCategory]);
const categoryGroups: CategoryGroup[] = useMemo(() => {
const map = new Map<string, SkillInfo[]>();
for (const s of filteredSkills) {
const key = s.category || "__none__";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(s);
}
// Sort: General first, then alphabetical
const entries = [...map.entries()].sort((a, b) => {
if (a[0] === "__none__") return -1;
if (b[0] === "__none__") return 1;
return a[0].localeCompare(b[0]);
});
return entries.map(([key, list]) => ({
key,
name: prettyCategory(key === "__none__" ? null : key),
skills: list.sort((a, b) => a.name.localeCompare(b.name)),
enabledCount: list.filter((s) => s.enabled).length,
}));
}, [filteredSkills]);
const allCategories = useMemo(() => {
const cats = new Map<string, number>();
for (const s of skills) {
@ -171,25 +191,24 @@ export default function SkillsPage() {
);
}, [toolsets, search, lowerSearch]);
const isCollapsed = (key: string): boolean => {
if (collapsedCategories === "all") return true;
return collapsedCategories.has(key);
};
const isSearching = search.trim().length > 0;
const toggleCollapse = (key: string) => {
setCollapsedCategories((prev) => {
if (prev === "all") {
// Switching from "all collapsed" → expand just this one
const allKeys = new Set(categoryGroups.map((g) => g.key));
allKeys.delete(key);
return allKeys;
}
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const activeToolsetCount = toolsets.filter((t) => t.enabled).length;
const searchMatchedSkills = useMemo(() => {
if (!isSearching) return [];
return skills.filter(
(s) =>
s.name.toLowerCase().includes(lowerSearch) ||
s.description.toLowerCase().includes(lowerSearch) ||
(s.category ?? "").toLowerCase().includes(lowerSearch),
);
}, [isSearching, skills, lowerSearch]);
const activeSkills = useMemo(() => {
if (isSearching) return [];
return [...filteredSkills].sort((a, b) => a.name.localeCompare(b.name));
}, [isSearching, filteredSkills]);
/* ---- Loading ---- */
if (loading) {
@ -200,240 +219,303 @@ export default function SkillsPage() {
);
}
const activeCategoryName = activeCategory
? prettyCategory(activeCategory === "__none__" ? null : activeCategory)
: "All Skills";
const renderSkillList = (list: SkillInfo[]) => (
<div className="grid gap-1">
{list.map((skill) => (
<div
key={skill.name}
className="group flex items-start gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40"
>
<div className="pt-0.5 shrink-0">
<Switch
checked={skill.enabled}
onCheckedChange={() => handleToggleSkill(skill)}
disabled={togglingSkills.has(skill.name)}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span
className={`font-mono-ui text-sm ${
skill.enabled ? "text-foreground" : "text-muted-foreground"
}`}
>
{skill.name}
</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{skill.description || "No description available."}
</p>
</div>
</div>
))}
</div>
);
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<Toast toast={toast} />
{/* ═══════════════ Header + Search ═══════════════ */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-muted-foreground" />
<h1 className="text-base font-semibold">Skills</h1>
<span className="text-xs text-muted-foreground">
{enabledCount}/{skills.length} enabled
</span>
</div>
{/* ═══════════════ Header ═══════════════ */}
<div className="flex items-center gap-3">
{view === "skills" ? (
<Package className="h-4 w-4 text-muted-foreground" />
) : (
<Wrench className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-xs text-muted-foreground">
{view === "skills"
? `${enabledCount}/${skills.length} skills enabled`
: `${activeToolsetCount}/${toolsets.length} toolsets active`}
</span>
</div>
{/* ═══════════════ Search + Category Filter ═══════════════ */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
className="pl-9"
placeholder="Search skills and toolsets..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
>
<X className="h-4 w-4" />
</button>
{/* ═══════════════ Sidebar + Content ═══════════════ */}
<div className="flex flex-col sm:flex-row gap-4" style={{ minHeight: "calc(100vh - 180px)" }}>
{/* ---- Sidebar ---- */}
<div className="sm:w-52 sm:shrink-0">
<div className="sm:sticky sm:top-[72px] flex flex-col gap-1">
{/* Search */}
<div className="relative mb-2 hidden sm:block">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="pl-8 h-8 text-xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
>
<X className="h-3 w-3" />
</button>
)}
</div>
{/* Nav items */}
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none pb-1 sm:pb-0">
{/* Skills top-level */}
<button
type="button"
onClick={() => {
setView("skills");
setActiveCategory(null);
setSearch("");
}}
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
view === "skills" && !activeCategory && !isSearching
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<Package className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate">All Skills</span>
<span className={`text-[10px] tabular-nums ${
view === "skills" && !activeCategory && !isSearching
? "text-primary/60"
: "text-muted-foreground/50"
}`}>
{skills.length}
</span>
{view === "skills" && !activeCategory && !isSearching && (
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
)}
</button>
{/* Skill category sub-items */}
{allCategories.map(({ key, name, count }) => {
const isActive = view === "skills" && activeCategory === key && !isSearching;
return (
<button
key={key}
type="button"
onClick={() => {
setView("skills");
setActiveCategory(key);
setSearch("");
}}
className={`group flex items-center gap-2 sm:pl-6 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span className="flex-1 truncate">{name}</span>
<span className={`text-[10px] tabular-nums ${
isActive ? "text-primary/60" : "text-muted-foreground/50"
}`}>
{count}
</span>
{isActive && (
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
)}
</button>
);
})}
{/* Divider */}
<div className="hidden sm:block border-t border-border my-1" />
{/* Toolsets top-level */}
<button
type="button"
onClick={() => {
setView("toolsets");
setSearch("");
}}
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
view === "toolsets" && !isSearching
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<Wrench className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate">Toolsets</span>
<span className={`text-[10px] tabular-nums ${
view === "toolsets" && !isSearching
? "text-primary/60"
: "text-muted-foreground/50"
}`}>
{toolsets.length}
</span>
{view === "toolsets" && !isSearching && (
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
)}
</button>
</div>
</div>
</div>
{/* ---- Content ---- */}
<div className="flex-1 min-w-0">
{/* Search results (across both skills and toolsets) */}
{isSearching ? (
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Search className="h-4 w-4" />
Search Results
</CardTitle>
<Badge variant="secondary" className="text-[10px]">
{searchMatchedSkills.length} skill{searchMatchedSkills.length !== 1 ? "s" : ""}
</Badge>
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
{searchMatchedSkills.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No skills match &ldquo;<span className="text-foreground">{search}</span>&rdquo;
</p>
) : (
renderSkillList(searchMatchedSkills)
)}
</CardContent>
</Card>
) : view === "skills" ? (
/* ---- Skills view ---- */
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Package className="h-4 w-4" />
{activeCategoryName}
</CardTitle>
<Badge variant="secondary" className="text-[10px]">
{activeSkills.length} skill{activeSkills.length !== 1 ? "s" : ""}
</Badge>
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
{activeSkills.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
{skills.length === 0
? "No skills found. Skills are loaded from ~/.hermes/skills/"
: "No skills in this category."}
</p>
) : (
renderSkillList(activeSkills)
)}
</CardContent>
</Card>
) : (
/* ---- Toolsets view ---- */
<>
{filteredToolsets.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No toolsets found.
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filteredToolsets.map((ts) => {
const labelText = ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() || ts.name;
const TsIcon = toolsetIcon(ts.name, ts.label);
return (
<Card key={ts.name}>
<CardContent className="py-4">
<div className="flex items-start gap-3">
<TsIcon className="h-5 w-5 shrink-0 mt-0.5 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm">{labelText}</span>
<Badge
variant={ts.enabled ? "success" : "outline"}
className="text-[10px]"
>
{ts.enabled ? "active" : "inactive"}
</Badge>
</div>
<p className="text-xs text-muted-foreground mb-2">
{ts.description}
</p>
{ts.enabled && !ts.configured && (
<p className="text-[10px] text-amber-300/80 mb-2">
Setup needed
</p>
)}
{ts.tools.length > 0 && (
<div className="flex flex-wrap gap-1">
{ts.tools.map((tool) => (
<Badge
key={tool}
variant="secondary"
className="text-[10px] font-mono"
>
{tool}
</Badge>
))}
</div>
)}
{ts.tools.length === 0 && (
<span className="text-[10px] text-muted-foreground/60">
{ts.enabled ? `${ts.name} toolset` : "Disabled for CLI"}
</span>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</>
)}
</div>
</div>
{/* Category pills */}
{allCategories.length > 1 && (
<div className="flex items-center gap-2 flex-wrap">
<Filter className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<button
type="button"
className={`inline-flex items-center px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
!activeCategory
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
}`}
onClick={() => setActiveCategory(null)}
>
All ({skills.length})
</button>
{allCategories.map(({ key, name, count }) => (
<button
key={key}
type="button"
className={`inline-flex items-center px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
activeCategory === key
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
}`}
onClick={() =>
setActiveCategory(activeCategory === key ? null : key)
}
>
{name}
<span className="ml-1 opacity-60">{count}</span>
</button>
))}
</div>
)}
{/* ═══════════════ Skills by Category ═══════════════ */}
<section className="flex flex-col gap-3">
{filteredSkills.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-sm text-muted-foreground">
{skills.length === 0
? "No skills found. Skills are loaded from ~/.hermes/skills/"
: "No skills match your search or filter."}
</CardContent>
</Card>
) : (
categoryGroups.map(({ key, name, skills: catSkills, enabledCount: catEnabled }) => {
const collapsed = isCollapsed(key);
return (
<Card key={key}>
<CardHeader
className="cursor-pointer select-none py-3 px-4"
onClick={() => toggleCollapse(key)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{collapsed ? (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<CardTitle className="text-sm font-medium">{name}</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{catSkills.length} skill{catSkills.length !== 1 ? "s" : ""}
</Badge>
</div>
<Badge
variant={catEnabled === catSkills.length ? "success" : "outline"}
className="text-[10px]"
>
{catEnabled}/{catSkills.length} enabled
</Badge>
</div>
</CardHeader>
{collapsed ? (
/* Peek: show first few skill names so collapsed isn't blank */
<div className="px-4 pb-3 flex items-center min-h-[28px]">
<p className="text-xs text-muted-foreground/60 truncate leading-normal">
{catSkills.slice(0, 4).map((s) => s.name).join(", ")}
{catSkills.length > 4 && `, +${catSkills.length - 4} more`}
</p>
</div>
) : (
<CardContent className="pt-0 px-4 pb-3">
<div className="grid gap-1">
{catSkills.map((skill) => (
<div
key={skill.name}
className="group flex items-start gap-3 rounded-md px-3 py-2.5 transition-colors hover:bg-muted/40"
>
<div className="pt-0.5 shrink-0">
<Switch
checked={skill.enabled}
onCheckedChange={() => handleToggleSkill(skill)}
disabled={togglingSkills.has(skill.name)}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span
className={`font-mono-ui text-sm ${
skill.enabled
? "text-foreground"
: "text-muted-foreground"
}`}
>
{skill.name}
</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{skill.description || "No description available."}
</p>
</div>
</div>
))}
</div>
</CardContent>
)}
</Card>
);
})
)}
</section>
{/* ═══════════════ Toolsets ═══════════════ */}
<section className="flex flex-col gap-4">
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Wrench className="h-4 w-4" />
Toolsets ({filteredToolsets.length})
</h2>
{filteredToolsets.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No toolsets match the search.
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filteredToolsets.map((ts) => {
// Strip emoji prefix from label for cleaner display
const labelText = ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() || ts.name;
const emoji = ts.label.match(/^[\p{Emoji}]+/u)?.[0] || "🔧";
return (
<Card key={ts.name} className="relative overflow-hidden">
<CardContent className="py-4">
<div className="flex items-start gap-3">
<div className="text-2xl shrink-0 leading-none mt-0.5">{emoji}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm">{labelText}</span>
<Badge
variant={ts.enabled ? "success" : "outline"}
className="text-[10px]"
>
{ts.enabled ? "active" : "inactive"}
</Badge>
</div>
<p className="text-xs text-muted-foreground mb-2">
{ts.description}
</p>
{ts.enabled && !ts.configured && (
<p className="text-[10px] text-amber-300/80 mb-2">
Setup needed
</p>
)}
{ts.tools.length > 0 && (
<div className="flex flex-wrap gap-1">
{ts.tools.map((tool) => (
<Badge
key={tool}
variant="secondary"
className="text-[10px] font-mono"
>
{tool}
</Badge>
))}
</div>
)}
{ts.tools.length === 0 && (
<span className="text-[10px] text-muted-foreground/60">
{ts.enabled ? `${ts.name} toolset` : "Disabled for CLI"}
</span>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</section>
</div>
);
}