feat(web): re-apply dashboard UI improvements on top of i18n

Re-applies changes from #9471 that were overwritten by the i18n PR:

- URL-based routing via react-router-dom (NavLink, Routes, BrowserRouter)
- Replace emoji icons with lucide-react in ConfigPage and SkillsPage
- Sidebar layout for ConfigPage, SkillsPage, and LogsPage
- Custom dropdown Select component (SelectOption) in CronPage
- Remove all non-functional rounded borders across the UI
- Fixed header with proper content offset

Made-with: Cursor
This commit is contained in:
Austin Pickett 2026-04-14 10:23:43 -04:00
parent 16f9d02084
commit e88aa8a58c
11 changed files with 547 additions and 489 deletions

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 { Activity, BarChart3, Clock, FileText, KeyRound, MessageSquare, Package, Settings } from "lucide-react";
import StatusPage from "@/pages/StatusPage"; import StatusPage from "@/pages/StatusPage";
import ConfigPage from "@/pages/ConfigPage"; import ConfigPage from "@/pages/ConfigPage";
@ -12,89 +12,60 @@ import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
const NAV_ITEMS = [ const NAV_ITEMS = [
{ id: "status", labelKey: "status" as const, icon: Activity }, { path: "/", labelKey: "status" as const, icon: Activity },
{ id: "sessions", labelKey: "sessions" as const, icon: MessageSquare }, { path: "/sessions", labelKey: "sessions" as const, icon: MessageSquare },
{ id: "analytics", labelKey: "analytics" as const, icon: BarChart3 }, { path: "/analytics", labelKey: "analytics" as const, icon: BarChart3 },
{ id: "logs", labelKey: "logs" as const, icon: FileText }, { path: "/logs", labelKey: "logs" as const, icon: FileText },
{ id: "cron", labelKey: "cron" as const, icon: Clock }, { path: "/cron", labelKey: "cron" as const, icon: Clock },
{ id: "skills", labelKey: "skills" as const, icon: Package }, { path: "/skills", labelKey: "skills" as const, icon: Package },
{ id: "config", labelKey: "config" as const, icon: Settings }, { path: "/config", labelKey: "config" as const, icon: Settings },
{ id: "env", labelKey: "keys" as const, icon: KeyRound }, { path: "/env", labelKey: "keys" as const, icon: KeyRound },
] as const; ] 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() { export default function App() {
const [page, setPage] = useState<PageId>("status");
const [animKey, setAnimKey] = useState(0);
const initialRef = useRef(true);
const { t } = useI18n(); const { t } = useI18n();
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 ( return (
<div className="flex min-h-screen flex-col bg-background text-foreground overflow-x-hidden"> <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="noise-overlay" />
<div className="warm-glow" /> <div className="warm-glow" />
{/* ---- Header with grid-border nav ---- */} <header className="fixed top-0 left-0 right-0 z-40 border-b border-border bg-background/90 backdrop-blur-sm">
<header className="sticky top-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"> <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"> <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"> <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> H<span className="hidden sm:inline">ermes </span>A<span className="hidden sm:inline">gent</span>
</span> </span>
</div> </div>
{/* Nav — icons only on mobile, icon+label on sm+ */}
<nav className="flex items-stretch overflow-x-auto scrollbar-none"> <nav className="flex items-stretch overflow-x-auto scrollbar-none">
{NAV_ITEMS.map(({ id, labelKey, icon: Icon }) => ( {NAV_ITEMS.map(({ path, labelKey, icon: Icon }) => (
<button <NavLink
key={id} key={path}
type="button" to={path}
onClick={() => setPage(id)} end={path === "/"}
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 ${ className={({ isActive }) =>
page === id `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 ${
? "text-foreground" isActive
: "text-muted-foreground hover:text-foreground" ? "text-foreground"
}`} : "text-muted-foreground hover:text-foreground"
}`
}
> >
<Icon className="h-4 w-4 sm:h-3.5 sm:w-3.5 shrink-0" /> {({ isActive }) => (
<span className="hidden sm:inline">{t.app.nav[labelKey]}</span> <>
{/* Hover highlight */} <Icon className="h-4 w-4 sm:h-3.5 sm:w-3.5 shrink-0" />
<span className="absolute inset-0 bg-foreground pointer-events-none transition-opacity duration-150 group-hover:opacity-5 opacity-0" /> <span className="hidden sm:inline">{t.app.nav[labelKey]}</span>
{/* Active indicator */} <span className="absolute inset-0 bg-foreground pointer-events-none transition-opacity duration-150 group-hover:opacity-5 opacity-0" />
{page === id && ( {isActive && (
<span className="absolute bottom-0 left-0 right-0 h-px bg-foreground" /> <span className="absolute bottom-0 left-0 right-0 h-px bg-foreground" />
)}
</>
)} )}
</button> </NavLink>
))} ))}
</nav> </nav>
{/* Right side: language switcher + version badge */}
<div className="ml-auto flex items-center gap-2 px-2 sm:px-4"> <div className="ml-auto flex items-center gap-2 px-2 sm:px-4">
<LanguageSwitcher /> <LanguageSwitcher />
<span className="hidden sm:inline font-display text-[0.7rem] tracking-[0.15em] uppercase opacity-50"> <span className="hidden sm:inline font-display text-[0.7rem] tracking-[0.15em] uppercase opacity-50">
@ -104,15 +75,20 @@ export default function App() {
</div> </div>
</header> </header>
<main <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">
key={animKey} <Routes>
className="relative z-2 mx-auto w-full max-w-[1400px] flex-1 px-3 sm:px-6 py-4 sm:py-8" <Route path="/" element={<StatusPage />} />
style={{ animation: "fade-in 150ms ease-out" }} <Route path="/sessions" element={<SessionsPage />} />
> <Route path="/analytics" element={<AnalyticsPage />} />
<PageComponent /> <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> </main>
{/* ---- Footer ---- */}
<footer className="relative z-2 border-t border-border"> <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"> <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"> <span className="font-display text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] uppercase opacity-50">

View file

@ -13,7 +13,7 @@ export function LanguageSwitcher() {
<button <button
type="button" type="button"
onClick={toggle} onClick={toggle}
className="group relative inline-flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring rounded" className="group relative inline-flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
title={t.language.switchTo} title={t.language.switchTo}
aria-label={t.language.switchTo} aria-label={t.language.switchTo}
> >

View file

@ -52,7 +52,7 @@ export function ModelInfoCard({ currentModel, refreshKey = 0 }: ModelInfoCardPro
const hasCaps = caps && Object.keys(caps).length > 0; const hasCaps = caps && Object.keys(caps).length > 0;
return ( return (
<div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5 space-y-2"> <div className="border border-border/60 bg-muted/30 px-3 py-2.5 space-y-2">
{/* Context window */} {/* Context window */}
<div className="flex items-center gap-4 text-xs"> <div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground"> <div className="flex items-center gap-1.5 text-muted-foreground">
@ -90,22 +90,22 @@ export function ModelInfoCard({ currentModel, refreshKey = 0 }: ModelInfoCardPro
{hasCaps && ( {hasCaps && (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5"> <div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{caps.supports_tools && ( {caps.supports_tools && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400"> <span className="inline-flex items-center gap-1 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
<Wrench className="h-2.5 w-2.5" /> Tools <Wrench className="h-2.5 w-2.5" /> Tools
</span> </span>
)} )}
{caps.supports_vision && ( {caps.supports_vision && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-2 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400"> <span className="inline-flex items-center gap-1 bg-blue-500/10 px-2 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
<Eye className="h-2.5 w-2.5" /> Vision <Eye className="h-2.5 w-2.5" /> Vision
</span> </span>
)} )}
{caps.supports_reasoning && ( {caps.supports_reasoning && (
<span className="inline-flex items-center gap-1 rounded-full bg-purple-500/10 px-2 py-0.5 text-[10px] font-medium text-purple-600 dark:text-purple-400"> <span className="inline-flex items-center gap-1 bg-purple-500/10 px-2 py-0.5 text-[10px] font-medium text-purple-600 dark:text-purple-400">
<Brain className="h-2.5 w-2.5" /> Reasoning <Brain className="h-2.5 w-2.5" /> Reasoning
</span> </span>
)} )}
{caps.model_family && ( {caps.model_family && (
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground"> <span className="inline-flex items-center gap-1 bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{caps.model_family} {caps.model_family}
</span> </span>
)} )}

View file

@ -171,7 +171,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
{!p.status.logged_in && ( {!p.status.logged_in && (
<span className="text-xs text-muted-foreground/80"> <span className="text-xs text-muted-foreground/80">
{t.oauth.notConnected.split("{command}")[0]} {t.oauth.notConnected.split("{command}")[0]}
<code className="text-foreground bg-secondary/40 px-1 rounded"> <code className="text-foreground bg-secondary/40 px-1">
{p.cli_command} {p.cli_command}
</code> </code>
{t.oauth.notConnected.split("{command}")[1]} {t.oauth.notConnected.split("{command}")[1]}

View file

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

View file

@ -74,11 +74,11 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
</div> </div>
<div className="flex items-center gap-4 text-xs text-muted-foreground"> <div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5"> <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]" />
{t.analytics.input} {t.analytics.input}
</div> </div>
<div className="flex items-center gap-1.5"> <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" />
{t.analytics.output} {t.analytics.output}
</div> </div>
</div> </div>
@ -97,7 +97,7 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
> >
{/* Tooltip */} {/* 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="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 className="font-medium">{formatDate(d.day)}</div>
<div>{t.analytics.input}: {formatTokens(d.input_tokens)}</div> <div>{t.analytics.input}: {formatTokens(d.input_tokens)}</div>
<div>{t.analytics.output}: {formatTokens(d.output_tokens)}</div> <div>{t.analytics.output}: {formatTokens(d.output_tokens)}</div>

View file

@ -11,6 +11,22 @@ import {
ChevronRight, ChevronRight,
Settings2, Settings2,
FileText, FileText,
Settings,
Bot,
Monitor,
Palette,
Users,
Brain,
Package,
Lock,
Globe,
Mic,
Volume2,
Ear,
ClipboardList,
MessageCircle,
Wrench,
FileQuestion,
} from "lucide-react"; } from "lucide-react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { getNestedValue, setNestedValue } from "@/lib/nested"; import { getNestedValue, setNestedValue } from "@/lib/nested";
@ -27,24 +43,29 @@ import { useI18n } from "@/i18n";
/* Helpers */ /* Helpers */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
const CATEGORY_ICONS: Record<string, string> = { const CATEGORY_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
general: "⚙️", general: Settings,
agent: "🤖", agent: Bot,
terminal: "💻", terminal: Monitor,
display: "🎨", display: Palette,
delegation: "👥", delegation: Users,
memory: "🧠", memory: Brain,
compression: "📦", compression: Package,
security: "🔒", security: Lock,
browser: "🌐", browser: Globe,
voice: "🎙️", voice: Mic,
tts: "🔊", tts: Volume2,
stt: "👂", stt: Ear,
logging: "📋", logging: ClipboardList,
discord: "💬", discord: MessageCircle,
auxiliary: "🔧", auxiliary: Wrench,
}; };
function CategoryIcon({ category, className }: { category: string; className?: string }) {
const Icon = CATEGORY_ICONS[category] ?? FileQuestion;
return <Icon className={className ?? "h-4 w-4"} />;
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Component */ /* Component */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@ -232,7 +253,7 @@ export default function ConfigPage() {
<div key={key}> <div key={key}>
{showCatBadge && ( {showCatBadge && (
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0"> <div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
<span className="text-base">{CATEGORY_ICONS[cat] || "📄"}</span> <CategoryIcon category={cat} className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{prettyCategoryName(cat)} {prettyCategoryName(cat)}
</span> </span>
@ -268,7 +289,7 @@ export default function ConfigPage() {
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" /> <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">
{t.config.configPath} {t.config.configPath}
</code> </code>
</div> </div>
@ -381,13 +402,13 @@ export default function ConfigPage() {
setSearchQuery(""); setSearchQuery("");
setActiveCategory(cat); 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 isActive
? "bg-primary/10 text-primary font-medium" ? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50" : "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`} }`}
> >
<span className="text-sm leading-none">{CATEGORY_ICONS[cat] || "📄"}</span> <CategoryIcon category={cat} className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">{prettyCategoryName(cat)}</span> <span className="flex-1 truncate">{prettyCategoryName(cat)}</span>
<span className={`text-[10px] tabular-nums ${isActive ? "text-primary/60" : "text-muted-foreground/50"}`}> <span className={`text-[10px] tabular-nums ${isActive ? "text-primary/60" : "text-muted-foreground/50"}`}>
{categoryCounts[cat] || 0} {categoryCounts[cat] || 0}
@ -434,7 +455,7 @@ export default function ConfigPage() {
<CardHeader className="py-3 px-4"> <CardHeader className="py-3 px-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2"> <CardTitle className="text-sm flex items-center gap-2">
<span className="text-base">{CATEGORY_ICONS[activeCategory] || "📄"}</span> <CategoryIcon category={activeCategory} className="h-4 w-4" />
{prettyCategoryName(activeCategory)} {prettyCategoryName(activeCategory)}
</CardTitle> </CardTitle>
<Badge variant="secondary" className="text-[10px]"> <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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select } from "@/components/ui/select"; import { Select, SelectOption } from "@/components/ui/select";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
function formatTime(iso?: string | null): string { function formatTime(iso?: string | null): string {
@ -149,7 +149,7 @@ export default function CronPage() {
<Label htmlFor="cron-prompt">{t.cron.prompt}</Label> <Label htmlFor="cron-prompt">{t.cron.prompt}</Label>
<textarea <textarea
id="cron-prompt" 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={t.cron.promptPlaceholder} placeholder={t.cron.promptPlaceholder}
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
@ -174,11 +174,11 @@ export default function CronPage() {
value={deliver} value={deliver}
onValueChange={(v) => setDeliver(v)} onValueChange={(v) => setDeliver(v)}
> >
<option value="local">{t.cron.delivery.local}</option> <SelectOption value="local">{t.cron.delivery.local}</SelectOption>
<option value="telegram">{t.cron.delivery.telegram}</option> <SelectOption value="telegram">{t.cron.delivery.telegram}</SelectOption>
<option value="discord">{t.cron.delivery.discord}</option> <SelectOption value="discord">{t.cron.delivery.discord}</SelectOption>
<option value="slack">{t.cron.delivery.slack}</option> <SelectOption value="slack">{t.cron.delivery.slack}</SelectOption>
<option value="email">{t.cron.delivery.email}</option> <SelectOption value="email">{t.cron.delivery.email}</SelectOption>
</Select> </Select>
</div> </div>

View file

@ -1,5 +1,5 @@
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { FileText, RefreshCw } from "lucide-react"; import { FileText, RefreshCw, ChevronRight } from "lucide-react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -28,34 +28,34 @@ const LINE_COLORS: Record<string, string> = {
debug: "text-muted-foreground/60", debug: "text-muted-foreground/60",
}; };
function FilterBar<T extends string>({ function SidebarHeading({ children }: { children: React.ReactNode }) {
label,
options,
value,
onChange,
}: {
label: string;
options: readonly T[];
value: T;
onChange: (v: T) => void;
}) {
return ( return (
<div className="flex items-center gap-2 flex-wrap"> <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60 px-2.5 pt-3 pb-1">
<span className="text-xs text-muted-foreground font-medium w-20 shrink-0">{label}</span> {children}
<div className="flex gap-1 flex-wrap"> </span>
{options.map((opt) => ( );
<Button }
key={opt}
variant={value === opt ? "default" : "outline"} function SidebarItem<T extends string>({
size="sm" label,
className="text-xs h-7 px-2.5" value,
onClick={() => onChange(opt)} current,
> onChange,
{opt} }: SidebarItemProps<T>) {
</Button> const isActive = current === value;
))} return (
</div> <button
</div> type="button"
onClick={() => onChange(value)}
className={`group flex items-center gap-2 px-2.5 py-1 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">{label}</span>
{isActive && <ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />}
</button>
); );
} }
@ -78,7 +78,6 @@ export default function LogsPage() {
.getLogs({ file, lines: lineCount, level, component }) .getLogs({ file, lines: lineCount, level, component })
.then((resp) => { .then((resp) => {
setLines(resp.lines); setLines(resp.lines);
// Auto-scroll to bottom
setTimeout(() => { setTimeout(() => {
if (scrollRef.current) { if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
@ -89,12 +88,10 @@ export default function LogsPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [file, lineCount, level, component]); }, [file, lineCount, level, component]);
// Initial load + refetch on filter change
useEffect(() => { useEffect(() => {
fetchLogs(); fetchLogs();
}, [fetchLogs]); }, [fetchLogs]);
// Auto-refresh polling
useEffect(() => { useEffect(() => {
if (!autoRefresh) return; if (!autoRefresh) return;
const interval = setInterval(fetchLogs, 5000); const interval = setInterval(fetchLogs, 5000);
@ -102,76 +99,113 @@ export default function LogsPage() {
}, [autoRefresh, fetchLogs]); }, [autoRefresh, fetchLogs]);
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-4">
<Card> {/* ═══════════════ Header ═══════════════ */}
<CardHeader> <div className="flex items-center justify-between gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <FileText className="h-5 w-5 text-muted-foreground" />
<FileText className="h-5 w-5 text-muted-foreground" /> <h1 className="text-base font-semibold">{t.logs.title}</h1>
<CardTitle className="text-base">{t.logs.title}</CardTitle> {loading && (
{loading && ( <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<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">{t.logs.autoRefresh}</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" />
{t.common.live}
</Badge>
)}
</div>
<Button variant="outline" size="sm" onClick={fetchLogs} className="text-xs h-7">
<RefreshCw className="h-3 w-3 mr-1" />
{t.common.refresh}
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 mb-4">
<FilterBar label={t.logs.file} options={FILES} value={file} onChange={setFile} />
<FilterBar label={t.logs.level} options={LEVELS} value={level} onChange={setLevel} />
<FilterBar label={t.logs.component} options={COMPONENTS} value={component} onChange={setComponent} />
<FilterBar
label={t.logs.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>
)} )}
<Badge variant="secondary" className="text-[10px]">
<div {file} · {level} · {component}
ref={scrollRef} </Badge>
className="border border-border bg-background p-4 font-mono-ui text-xs leading-5 overflow-auto max-h-[600px] min-h-[200px]" </div>
> <div className="flex items-center gap-3">
{lines.length === 0 && !loading && ( <div className="flex items-center gap-2">
<p className="text-muted-foreground text-center py-8">{t.logs.noLogLines}</p> <Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
<Label className="text-xs">{t.logs.autoRefresh}</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" />
{t.common.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> </div>
</CardContent> <Button variant="outline" size="sm" onClick={fetchLogs} className="text-xs h-7">
</Card> <RefreshCw className="h-3 w-3 mr-1" />
{t.common.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-44 sm:shrink-0">
<div className="sm:sticky sm:top-[72px] flex flex-col gap-0.5">
<SidebarHeading>{t.logs.file}</SidebarHeading>
{FILES.map((f) => (
<SidebarItem key={f} label={f} value={f} current={file} onChange={setFile} />
))}
<SidebarHeading>{t.logs.level}</SidebarHeading>
{LEVELS.map((l) => (
<SidebarItem key={l} label={l} value={l} current={level} onChange={setLevel} />
))}
<SidebarHeading>{t.logs.component}</SidebarHeading>
{COMPONENTS.map((c) => (
<SidebarItem key={c} label={c} value={c} current={component} onChange={setComponent} />
))}
<SidebarHeading>{t.logs.lines}</SidebarHeading>
{LINE_COUNTS.map((n) => (
<SidebarItem
key={n}
label={String(n)}
value={String(n)}
current={String(lineCount)}
onChange={(v) => setLineCount(Number(v) as (typeof LINE_COUNTS)[number])}
/>
))}
</div>
</div>
{/* ---- Content ---- */}
<div className="flex-1 min-w-0">
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm flex items-center gap-2">
<FileText className="h-4 w-4" />
{file}.log
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{error && (
<div className="bg-destructive/10 border-b border-destructive/20 p-3">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<div
ref={scrollRef}
className="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">{t.logs.noLogLines}</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> </div>
); );
} }
interface SidebarItemProps<T extends string> {
label: string;
value: T;
current: T;
onChange: (v: T) => void;
}

View file

@ -44,7 +44,7 @@ function SnippetHighlight({ snippet }: { snippet: string }) {
parts.push(snippet.slice(last, match.index)); parts.push(snippet.slice(last, match.index));
} }
parts.push( 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]} {match[1]}
</mark> </mark>
); );
@ -72,7 +72,7 @@ function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name:
} }
return ( 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 <button
type="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" 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

@ -3,10 +3,17 @@ import {
Package, Package,
Search, Search,
Wrench, Wrench,
ChevronDown,
ChevronRight, ChevronRight,
Filter,
X, X,
Cpu,
Globe,
Shield,
Eye,
Paintbrush,
Brain,
Blocks,
Code,
Zap,
} from "lucide-react"; } from "lucide-react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { SkillInfo, ToolsetInfo } from "@/lib/api"; import type { SkillInfo, ToolsetInfo } from "@/lib/api";
@ -22,13 +29,6 @@ import { useI18n } from "@/i18n";
/* Types & helpers */ /* Types & helpers */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
interface CategoryGroup {
name: string; // display name
key: string; // raw key (or "__none__")
skills: SkillInfo[];
enabledCount: number;
}
const CATEGORY_LABELS: Record<string, string> = { const CATEGORY_LABELS: Record<string, string> = {
mlops: "MLOps", mlops: "MLOps",
"mlops/cloud": "MLOps / Cloud", "mlops/cloud": "MLOps / Cloud",
@ -55,7 +55,25 @@ function prettyCategory(raw: string | null | undefined, generalLabel: string): s
.join(" "); .join(" ");
} }
const TOOLSET_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
computer: Cpu,
web: Globe,
security: Shield,
vision: Eye,
design: Paintbrush,
ai: Brain,
integration: Blocks,
code: Code,
automation: Zap,
};
function toolsetIcon(name: string): React.ComponentType<{ className?: string }> {
const lower = name.toLowerCase();
for (const [key, icon] of Object.entries(TOOLSET_ICONS)) {
if (lower.includes(key)) return icon;
}
return Wrench;
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Component */ /* Component */
@ -66,10 +84,9 @@ export default function SkillsPage() {
const [toolsets, setToolsets] = useState<ToolsetInfo[]>([]); const [toolsets, setToolsets] = useState<ToolsetInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [view, setView] = useState<"skills" | "toolsets">("skills");
const [activeCategory, setActiveCategory] = useState<string | null>(null); const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set()); const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
// Start collapsed by default
const [collapsedCategories, setCollapsedCategories] = useState<Set<string> | "all">("all");
const { toast, showToast } = useToast(); const { toast, showToast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
@ -110,41 +127,27 @@ export default function SkillsPage() {
/* ---- Derived data ---- */ /* ---- Derived data ---- */
const lowerSearch = search.toLowerCase(); const lowerSearch = search.toLowerCase();
const isSearching = search.trim().length > 0;
const filteredSkills = useMemo(() => { const searchMatchedSkills = useMemo(() => {
return skills.filter((s) => { if (!isSearching) return [];
const matchesSearch = return skills.filter(
!search || (s) =>
s.name.toLowerCase().includes(lowerSearch) || s.name.toLowerCase().includes(lowerSearch) ||
s.description.toLowerCase().includes(lowerSearch) || s.description.toLowerCase().includes(lowerSearch) ||
(s.category ?? "").toLowerCase().includes(lowerSearch); (s.category ?? "").toLowerCase().includes(lowerSearch)
const matchesCategory = );
!activeCategory || }, [skills, isSearching, lowerSearch]);
(activeCategory === "__none__" ? !s.category : s.category === activeCategory);
return matchesSearch && matchesCategory;
});
}, [skills, search, lowerSearch, activeCategory]);
const categoryGroups: CategoryGroup[] = useMemo(() => { const activeSkills = useMemo(() => {
const map = new Map<string, SkillInfo[]>(); if (isSearching) return [];
for (const s of filteredSkills) { if (!activeCategory) return [...skills].sort((a, b) => a.name.localeCompare(b.name));
const key = s.category || "__none__"; return skills
if (!map.has(key)) map.set(key, []); .filter((s) =>
map.get(key)!.push(s); activeCategory === "__none__" ? !s.category : s.category === activeCategory
} )
// Sort: General first, then alphabetical .sort((a, b) => a.name.localeCompare(b.name));
const entries = [...map.entries()].sort((a, b) => { }, [skills, activeCategory, isSearching]);
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, t.common.general),
skills: list.sort((a, b) => a.name.localeCompare(b.name)),
enabledCount: list.filter((s) => s.enabled).length,
}));
}, [filteredSkills]);
const allCategories = useMemo(() => { const allCategories = useMemo(() => {
const cats = new Map<string, number>(); const cats = new Map<string, number>();
@ -173,26 +176,6 @@ export default function SkillsPage() {
); );
}, [toolsets, search, lowerSearch]); }, [toolsets, search, lowerSearch]);
const isCollapsed = (key: string): boolean => {
if (collapsedCategories === "all") return true;
return collapsedCategories.has(key);
};
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;
});
};
/* ---- Loading ---- */ /* ---- Loading ---- */
if (loading) { if (loading) {
return ( return (
@ -203,10 +186,10 @@ export default function SkillsPage() {
} }
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-4">
<Toast toast={toast} /> <Toast toast={toast} />
{/* ═══════════════ Header + Search ═══════════════ */} {/* ═══════════════ Header ═══════════════ */}
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Package className="h-5 w-5 text-muted-foreground" /> <Package className="h-5 w-5 text-muted-foreground" />
@ -217,225 +200,266 @@ export default function SkillsPage() {
</div> </div>
</div> </div>
{/* ═══════════════ Search + Category Filter ═══════════════ */} {/* ═══════════════ Sidebar + Content ═══════════════ */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="flex flex-col sm:flex-row gap-4" style={{ minHeight: "calc(100vh - 180px)" }}>
<div className="relative flex-1"> {/* ---- Sidebar ---- */}
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <div className="sm:w-52 sm:shrink-0">
<Input <div className="sm:sticky sm:top-[72px] flex flex-col gap-1">
className="pl-9" {/* Search */}
placeholder={t.skills.searchPlaceholder} <div className="relative mb-2 hidden sm:block">
value={search} <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
onChange={(e) => setSearch(e.target.value)} <Input
/> className="pl-8 h-8 text-xs"
{search && ( placeholder={t.common.search}
<button value={search}
type="button" onChange={(e) => setSearch(e.target.value)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" />
onClick={() => setSearch("")} {search && (
> <button
<X className="h-4 w-4" /> type="button"
</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>
{/* Top-level nav */}
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none pb-1 sm:pb-0">
<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" && !isSearching
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<Package className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">{t.skills.all} ({skills.length})</span>
{view === "skills" && !isSearching && <ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />}
</button>
{/* Skill categories (nested under All Skills) */}
{view === "skills" && !isSearching && allCategories.map(({ key, name, count }) => {
const isActive = activeCategory === key;
return (
<button
key={key}
type="button"
onClick={() => setActiveCategory(activeCategory === key ? null : key)}
className={`group flex items-center gap-2 px-2.5 py-1 pl-7 text-left text-[11px] transition-colors cursor-pointer ${
isActive
? "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>
</button>
);
})}
<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"
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<Wrench className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">{t.skills.toolsets} ({toolsets.length})</span>
{view === "toolsets" && <ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />}
</button>
</div>
</div>
</div>
{/* ---- Content ---- */}
<div className="flex-1 min-w-0">
{isSearching ? (
/* Search results */
<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" />
{t.skills.title}
</CardTitle>
<Badge variant="secondary" className="text-[10px]">
{searchMatchedSkills.length} result{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">
{t.skills.noSkillsMatch}
</p>
) : (
<div className="grid gap-1">
{searchMatchedSkills.map((skill) => (
<SkillRow
key={skill.name}
skill={skill}
toggling={togglingSkills.has(skill.name)}
onToggle={() => handleToggleSkill(skill)}
noDescriptionLabel={t.skills.noDescription}
/>
))}
</div>
)}
</CardContent>
</Card>
) : view === "skills" ? (
/* Skills list */
<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" />
{activeCategory
? prettyCategory(activeCategory === "__none__" ? null : activeCategory, t.common.general)
: t.skills.all}
</CardTitle>
<Badge variant="secondary" className="text-[10px]">
{activeSkills.length} {t.skills.skillCount.replace("{count}", String(activeSkills.length)).replace("{s}", 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 ? t.skills.noSkills : t.skills.noSkillsMatch}
</p>
) : (
<div className="grid gap-1">
{activeSkills.map((skill) => (
<SkillRow
key={skill.name}
skill={skill}
toggling={togglingSkills.has(skill.name)}
onToggle={() => handleToggleSkill(skill)}
noDescriptionLabel={t.skills.noDescription}
/>
))}
</div>
)}
</CardContent>
</Card>
) : (
/* Toolsets grid */
<>
{filteredToolsets.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
{t.skills.noToolsetsMatch}
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filteredToolsets.map((ts) => {
const TsIcon = toolsetIcon(ts.name);
const labelText = ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() || ts.name;
return (
<Card key={ts.name} className="relative">
<CardContent className="py-4">
<div className="flex items-start gap-3">
<TsIcon className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
<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 ? t.common.active : t.common.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">
{t.skills.setupNeeded}
</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` : t.skills.disabledForCli}
</span>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</>
)} )}
</div> </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)}
>
{t.skills.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
? t.skills.noSkills
: t.skills.noSkillsMatch}
</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">
{t.skills.skillCount.replace("{count}", String(catSkills.length)).replace("{s}", catSkills.length !== 1 ? "s" : "")}
</Badge>
</div>
<Badge
variant={catEnabled === catSkills.length ? "success" : "outline"}
className="text-[10px]"
>
{t.skills.enabledOf.replace("{enabled}", String(catEnabled)).replace("{total}", String(catSkills.length))}
</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 && `, ${t.skills.more.replace("{count}", String(catSkills.length - 4))}`}
</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 || t.skills.noDescription}
</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" />
{t.skills.toolsets} ({filteredToolsets.length})
</h2>
{filteredToolsets.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
{t.skills.noToolsetsMatch}
</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 ? t.common.active : t.common.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">
{t.skills.setupNeeded}
</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` : t.skills.disabledForCli}
</span>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</section>
</div> </div>
); );
} }
function SkillRow({
skill,
toggling,
onToggle,
noDescriptionLabel,
}: SkillRowProps) {
return (
<div 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={onToggle}
disabled={toggling}
/>
</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 || noDescriptionLabel}
</p>
</div>
</div>
);
}
interface SkillRowProps {
skill: SkillInfo;
toggling: boolean;
onToggle: () => void;
noDescriptionLabel: string;
}