mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
16f9d02084
commit
e88aa8a58c
11 changed files with 547 additions and 489 deletions
112
web/src/App.tsx
112
web/src/App.tsx
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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]}
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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]">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue