mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 01:41:43 +00:00
* fix(terminal): three-layer defense against watch_patterns notification spam Background processes that stack notify_on_complete=True with watch_patterns can flood the user with duplicate, delayed notifications — matches deliver asynchronously via the completion queue and continue arriving minutes after the process has exited. The docstring warning against this (PR #12113) has proven insufficient; agents still misuse the combination. Three layered defenses, each sufficient on its own: 1. Mutual exclusion (terminal_tool.py): When both flags are set on a background process, drop watch_patterns with a warning. notify_on_complete wins because 'let me know when it's done' is the more useful signal and fires exactly once. Extracted as _resolve_notification_flag_conflict() so the rule is testable in isolation. 2. Suppress-after-exit (process_registry.py): _check_watch_patterns() now bails the moment session.exited is True. Post-exit chunks (buffered reads draining after the process is gone) no longer produce notifications. This is the fix flagged as future work in session 20260418_020302_79881c. 3. Global circuit breaker (process_registry.py): Per-session rate limits don't catch the sibling-flood case — N concurrent processes can each stay under 8/10s and still collectively spam. New WATCH_GLOBAL_MAX_PER_WINDOW=15 cap trips a 30-second cooldown across ALL sessions, emits a single watch_overflow_tripped event, silently counts dropped events, and emits a watch_overflow_released summary when the cooldown ends. Also updates the tool schema + docstring to document the new behavior. Tests: 8 new tests covering all three fixes (suppress-after-exit x2, mutual-exclusion resolver x4, global breaker trip/cooldown/release x2). All 60 tests across test_watch_patterns.py, test_notify_on_complete.py, test_terminal_tool.py pass. Real-world trigger: self-inflicted in session 20260425_051924 — three concurrent hermes-sweeper review subprocesses each set watch_patterns= ['failed validation', 'errored'] AND notify_on_complete=True, then iterated over multiple items, producing enough matches per process to defeat the per-session cap while staying under the global cap that didn't yet exist. * fix(terminal): aggressive 1-per-15s watch_patterns rate limit + strike-3 promotion Per Teknium's direction, the watch_patterns rate limit is now much more aggressive and self-healing. ## New rule — per session - HARD cap: 1 watch-match notification per 15 seconds per process. - Any match arriving inside the cooldown window is dropped and counts as ONE strike for that window (many drops in the same window still = 1 strike). - After 3 consecutive strike windows, watch_patterns is permanently disabled for the session and the session is auto-promoted to notify_on_complete semantics — exactly one notification when the process actually exits. - A cooldown window that expires with zero drops resets the consecutive strike counter — healthy cadence is forgiven. ## Schema + docstring rewritten The tool schema description now gives the model explicit guidance: - notify_on_complete is 'the right choice for almost every long-running task' - watch_patterns is for RARE one-shot signals on LONG-LIVED processes - Do NOT use watch_patterns with loops/batch jobs — error patterns fire every iteration and will hit the strike limit fast - Mutual exclusion is stated on both parameter descriptions - 1/15s cooldown and 3-strike promotion are stated in the watch_patterns description so the model sees the contract every turn ## Removed - WATCH_MAX_PER_WINDOW (8/10s) and WATCH_OVERLOAD_KILL_SECONDS (45) — the new 1/15s limit subsumes both; keeping them would double-count. - _watch_window_hits / _watch_window_start / _watch_overload_since fields on ProcessSession. Replaced by _watch_last_emit_at / _watch_cooldown_until / _watch_strike_candidate / _watch_consecutive_strikes. ## Kept - Global circuit breaker across all sessions (15/10s → 30s cooldown) as a secondary safety net for concurrent siblings. Still valuable when 20 short-lived processes each fire once — none individually violates the per-session limit. - Suppress-after-exit guard. - Mutual exclusion resolver at the tool entry point. ## Tests - 6 new tests in TestPerSessionRateLimit covering: first match delivers, second in cooldown suppressed, multi-drop = single strike, 3 strikes disables + promotes, clean window resets counter, suppressed count carried to next emit. - Global circuit breaker tests rewritten to use fresh sessions instead of hacking removed per-window fields. - 50/50 watch_patterns + notify_on_complete tests pass. - 60/60 including test_terminal_tool.py pass. * feat(dashboard): page-scoped plugin slots for built-in pages Dashboard plugins can now inject components into specific built-in pages (Sessions, Analytics, Logs, Cron, Skills, Config, Env, Docs, Chat) without overriding the whole route. Previously, plugins could only: 1. Add new tabs (tab.path) 2. Replace whole built-in pages (tab.override) 3. Inject into global shell slots (header-*, footer-*, pre-main, ...) None of those let a plugin add a banner, card, or widget to an existing page. The new <page>:top / <page>:bottom slots close that gap, reusing the existing registerSlot() API. Changes - web/src/plugins/slots.ts: 18 new KNOWN_SLOT_NAMES entries (sessions:top, sessions:bottom, analytics:top, ..., chat:bottom), grouped under "Shell-wide" vs "Page-scoped" in the docblock - web/src/pages/*: each built-in page now renders <PluginSlot name="<page>:top" /> as the first child of its outer wrapper and <PluginSlot name="<page>:bottom" /> as the last child -- zero visual cost when no plugin registers - plugins/example-dashboard: registers a demo banner into sessions:top via registerSlot(), with matching slots entry in the manifest -- so freshly-setup users can see what page-scoped slots look like without writing any plugin code - website/docs: new "Page-scoped slots" table in the plugin authoring guide, with a worked example - tests/hermes_cli/test_web_server.py: round-trip test for colon-bearing slot names (sessions:top, analytics:bottom, ...) Validation - npm run build: clean (tsc -b + vite build, 2761 modules) - scripts/run_tests.sh tests/hermes_cli/test_web_server.py::TestDashboardPluginManifestExtensions: 5/5 pass
587 lines
20 KiB
TypeScript
587 lines
20 KiB
TypeScript
import { useEffect, useLayoutEffect, useState, useMemo } from "react";
|
|
import {
|
|
Package,
|
|
Search,
|
|
Wrench,
|
|
X,
|
|
Cpu,
|
|
Globe,
|
|
Shield,
|
|
Eye,
|
|
Paintbrush,
|
|
Brain,
|
|
Blocks,
|
|
Code,
|
|
Zap,
|
|
Filter,
|
|
} from "lucide-react";
|
|
import { api } from "@/lib/api";
|
|
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
|
|
import { useToast } from "@/hooks/useToast";
|
|
import { Toast } from "@/components/Toast";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { useI18n } from "@/i18n";
|
|
import { usePageHeader } from "@/contexts/usePageHeader";
|
|
import { PluginSlot } from "@/plugins";
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Types & helpers */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
const CATEGORY_LABELS: Record<string, string> = {
|
|
mlops: "MLOps",
|
|
"mlops/cloud": "MLOps / Cloud",
|
|
"mlops/evaluation": "MLOps / Evaluation",
|
|
"mlops/inference": "MLOps / Inference",
|
|
"mlops/models": "MLOps / Models",
|
|
"mlops/training": "MLOps / Training",
|
|
"mlops/vector-databases": "MLOps / Vector DBs",
|
|
mcp: "MCP",
|
|
"red-teaming": "Red Teaming",
|
|
ocr: "OCR",
|
|
p5js: "p5.js",
|
|
ai: "AI",
|
|
ux: "UX",
|
|
ui: "UI",
|
|
};
|
|
|
|
function prettyCategory(
|
|
raw: string | null | undefined,
|
|
generalLabel: string,
|
|
): string {
|
|
if (!raw) return generalLabel;
|
|
if (CATEGORY_LABELS[raw]) return CATEGORY_LABELS[raw];
|
|
return raw
|
|
.split(/[-_/]/)
|
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
.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 */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export default function SkillsPage() {
|
|
const [skills, setSkills] = useState<SkillInfo[]>([]);
|
|
const [toolsets, setToolsets] = useState<ToolsetInfo[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState("");
|
|
const [view, setView] = useState<"skills" | "toolsets">("skills");
|
|
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
|
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
|
|
const { toast, showToast } = useToast();
|
|
const { t } = useI18n();
|
|
const { setAfterTitle, setEnd } = usePageHeader();
|
|
|
|
useEffect(() => {
|
|
Promise.all([api.getSkills(), api.getToolsets()])
|
|
.then(([s, tsets]) => {
|
|
setSkills(s);
|
|
setToolsets(tsets);
|
|
})
|
|
.catch(() => showToast(t.common.loading, "error"))
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
/* ---- Toggle skill ---- */
|
|
const handleToggleSkill = async (skill: SkillInfo) => {
|
|
setTogglingSkills((prev) => new Set(prev).add(skill.name));
|
|
try {
|
|
await api.toggleSkill(skill.name, !skill.enabled);
|
|
setSkills((prev) =>
|
|
prev.map((s) =>
|
|
s.name === skill.name ? { ...s, enabled: !s.enabled } : s,
|
|
),
|
|
);
|
|
showToast(
|
|
`${skill.name} ${skill.enabled ? t.common.disabled : t.common.enabled}`,
|
|
"success",
|
|
);
|
|
} catch {
|
|
showToast(`${t.common.failedToToggle} ${skill.name}`, "error");
|
|
} finally {
|
|
setTogglingSkills((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(skill.name);
|
|
return next;
|
|
});
|
|
}
|
|
};
|
|
|
|
/* ---- Derived data ---- */
|
|
const lowerSearch = search.toLowerCase();
|
|
const isSearching = search.trim().length > 0;
|
|
|
|
const searchMatchedSkills = useMemo(() => {
|
|
if (!isSearching) return [];
|
|
return skills.filter(
|
|
(s) =>
|
|
s.name.toLowerCase().includes(lowerSearch) ||
|
|
s.description.toLowerCase().includes(lowerSearch) ||
|
|
(s.category ?? "").toLowerCase().includes(lowerSearch),
|
|
);
|
|
}, [skills, isSearching, lowerSearch]);
|
|
|
|
const activeSkills = useMemo(() => {
|
|
if (isSearching) return [];
|
|
if (!activeCategory)
|
|
return [...skills].sort((a, b) => a.name.localeCompare(b.name));
|
|
return skills
|
|
.filter((s) =>
|
|
activeCategory === "__none__"
|
|
? !s.category
|
|
: s.category === activeCategory,
|
|
)
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
}, [skills, activeCategory, isSearching]);
|
|
|
|
const allCategories = useMemo(() => {
|
|
const cats = new Map<string, number>();
|
|
for (const s of skills) {
|
|
const key = s.category || "__none__";
|
|
cats.set(key, (cats.get(key) || 0) + 1);
|
|
}
|
|
return [...cats.entries()]
|
|
.sort((a, b) => {
|
|
if (a[0] === "__none__") return -1;
|
|
if (b[0] === "__none__") return 1;
|
|
return a[0].localeCompare(b[0]);
|
|
})
|
|
.map(([key, count]) => ({
|
|
key,
|
|
name: prettyCategory(key === "__none__" ? null : key, t.common.general),
|
|
count,
|
|
}));
|
|
}, [skills, t]);
|
|
|
|
const enabledCount = skills.filter((s) => s.enabled).length;
|
|
|
|
useLayoutEffect(() => {
|
|
if (loading) {
|
|
setAfterTitle(null);
|
|
setEnd(null);
|
|
return;
|
|
}
|
|
setAfterTitle(
|
|
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
|
{t.skills.enabledOf
|
|
.replace("{enabled}", String(enabledCount))
|
|
.replace("{total}", String(skills.length))}
|
|
</span>,
|
|
);
|
|
setEnd(
|
|
<div className="relative w-full min-w-0 sm:max-w-xs">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
<Input
|
|
className="h-8 pl-8 pr-7 text-xs"
|
|
placeholder={t.common.search}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
{search && (
|
|
<button
|
|
type="button"
|
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
onClick={() => setSearch("")}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</div>,
|
|
);
|
|
return () => {
|
|
setAfterTitle(null);
|
|
setEnd(null);
|
|
};
|
|
}, [
|
|
enabledCount,
|
|
loading,
|
|
search,
|
|
setAfterTitle,
|
|
setEnd,
|
|
skills.length,
|
|
t,
|
|
]);
|
|
|
|
const filteredToolsets = useMemo(() => {
|
|
return toolsets.filter(
|
|
(ts) =>
|
|
!search ||
|
|
ts.name.toLowerCase().includes(lowerSearch) ||
|
|
ts.label.toLowerCase().includes(lowerSearch) ||
|
|
ts.description.toLowerCase().includes(lowerSearch),
|
|
);
|
|
}, [toolsets, search, lowerSearch]);
|
|
|
|
/* ---- Loading ---- */
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-24">
|
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<PluginSlot name="skills:top" />
|
|
<Toast toast={toast} />
|
|
|
|
{/* ═══════════════ Filter panel + Content ═══════════════ */}
|
|
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
|
|
{/* ---- Filter panel ---- */}
|
|
<aside
|
|
aria-label={t.skills.title}
|
|
className="sm:w-56 sm:shrink-0"
|
|
>
|
|
<div className="sm:sticky sm:top-0">
|
|
<div
|
|
className={`
|
|
flex flex-col
|
|
border border-border bg-muted/20
|
|
`}
|
|
>
|
|
{/* Filter heading */}
|
|
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
|
|
<Filter className="h-3 w-3 text-muted-foreground" />
|
|
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
|
|
{t.skills.filters}
|
|
</span>
|
|
</div>
|
|
|
|
{/* View switch (Skills / Toolsets) */}
|
|
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none p-2">
|
|
<PanelItem
|
|
icon={Package}
|
|
label={`${t.skills.all} (${skills.length})`}
|
|
active={view === "skills" && !isSearching}
|
|
onClick={() => {
|
|
setView("skills");
|
|
setActiveCategory(null);
|
|
setSearch("");
|
|
}}
|
|
/>
|
|
<PanelItem
|
|
icon={Wrench}
|
|
label={`${t.skills.toolsets} (${toolsets.length})`}
|
|
active={view === "toolsets"}
|
|
onClick={() => {
|
|
setView("toolsets");
|
|
setSearch("");
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Category sub-filters (only for Skills view) */}
|
|
{view === "skills" && !isSearching && allCategories.length > 0 && (
|
|
<div className="hidden sm:flex flex-col border-t border-border">
|
|
<div className="px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70">
|
|
{t.skills.categories}
|
|
</div>
|
|
<div className="flex flex-col p-2 pt-1 gap-px max-h-[calc(100vh-340px)] overflow-y-auto">
|
|
{allCategories.map(({ key, name, count }) => {
|
|
const isActive = activeCategory === key;
|
|
|
|
return (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
onClick={() =>
|
|
setActiveCategory(isActive ? null : key)
|
|
}
|
|
className={`
|
|
group flex items-center gap-2 px-2 py-1
|
|
rounded-sm text-left text-[11px] cursor-pointer
|
|
transition-colors
|
|
${
|
|
isActive
|
|
? "bg-foreground/10 text-foreground"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-foreground/5"
|
|
}
|
|
`}
|
|
>
|
|
<span className="flex-1 truncate">{name}</span>
|
|
<span
|
|
className={`text-[10px] tabular-nums ${
|
|
isActive
|
|
? "text-foreground/60"
|
|
: "text-muted-foreground/50"
|
|
}`}
|
|
>
|
|
{count}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* ---- 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]">
|
|
{t.skills.resultCount
|
|
.replace("{count}", String(searchMatchedSkills.length))
|
|
.replace(
|
|
"{s}",
|
|
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]">
|
|
{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
|
|
? t.skills.toolsetLabel.replace(
|
|
"{name}",
|
|
ts.name,
|
|
)
|
|
: t.skills.disabledForCli}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<PluginSlot name="skills:bottom" />
|
|
</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>
|
|
);
|
|
}
|
|
|
|
function PanelItem({ active, icon: Icon, label, onClick }: PanelItemProps) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={`
|
|
group flex items-center gap-2 px-2.5 py-1.5
|
|
font-mondwest text-[0.7rem] tracking-[0.08em] uppercase
|
|
rounded-sm text-left cursor-pointer whitespace-nowrap
|
|
transition-colors
|
|
${
|
|
active
|
|
? "bg-foreground/90 text-background"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-foreground/10"
|
|
}
|
|
`}
|
|
>
|
|
<Icon className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="flex-1 truncate">{label}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
interface PanelItemProps {
|
|
active: boolean;
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
label: string;
|
|
onClick: () => void;
|
|
}
|
|
|
|
interface SkillRowProps {
|
|
noDescriptionLabel: string;
|
|
onToggle: () => void;
|
|
skill: SkillInfo;
|
|
toggling: boolean;
|
|
}
|