feat(dashboard): add Plugins page with enable/disable, auth status, install/remove

- New PluginsPage.tsx: full plugin management UI (list, enable/disable,
  install from git, remove, git pull updates, provider picker)
- Backend: dashboard_set_agent_plugin_enabled now also toggles the
  plugin's toolset in platform_toolsets so enabling actually makes
  tools visible in agent sessions
- Backend: /api/dashboard/plugins/hub returns auth_required + auth_command
  per plugin (checks tool registry check_fn)
- Frontend: auth_required shown as Badge + CommandBlock with copy-able
  auth command
- Fix: Select overflow in providers card (min-w-0 grid cells, removed
  truncate/overflow-hidden that clipped dropdown)
- Refactor: _install_plugin_core extracted for non-interactive reuse,
  PluginOperationError for structured error handling
- i18n: en/zh/types updated with all new plugin page strings
This commit is contained in:
Austin Pickett 2026-04-30 17:41:10 -04:00
parent e5dad4ac57
commit e2a4905606
10 changed files with 1521 additions and 189 deletions

View file

@ -65,10 +65,12 @@ import ModelsPage from "@/pages/ModelsPage";
import CronPage from "@/pages/CronPage";
import ProfilesPage from "@/pages/ProfilesPage";
import SkillsPage from "@/pages/SkillsPage";
import PluginsPage from "@/pages/PluginsPage";
import ChatPage from "@/pages/ChatPage";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
import { useI18n } from "@/i18n";
import type { Translations } from "@/i18n/types";
import { PluginPage, PluginSlot, usePlugins } from "@/plugins";
import type { PluginManifest } from "@/plugins";
import { useTheme } from "@/themes";
@ -102,6 +104,7 @@ const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
"/logs": LogsPage,
"/cron": CronPage,
"/skills": SkillsPage,
"/plugins": PluginsPage,
"/profiles": ProfilesPage,
"/config": ConfigPage,
"/env": EnvPage,
@ -138,6 +141,7 @@ const BUILTIN_NAV_REST: NavItem[] = [
{ path: "/logs", labelKey: "logs", label: "Logs", icon: FileText },
{ path: "/cron", labelKey: "cron", label: "Cron", icon: Clock },
{ path: "/skills", labelKey: "skills", label: "Skills", icon: Package },
{ path: "/plugins", labelKey: "plugins", label: "Plugins", icon: Puzzle },
{ path: "/profiles", labelKey: "profiles", label: "Profiles", icon: Users },
{ path: "/config", labelKey: "config", label: "Config", icon: Settings },
{ path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound },
@ -213,6 +217,22 @@ function buildNavItems(
return items;
}
/** Split merged nav into built-in sidebar entries vs plugin tabs, preserving plugin order hints. */
function partitionSidebarNav(
builtIn: NavItem[],
manifests: PluginManifest[],
): { coreItems: NavItem[]; pluginItems: NavItem[] } {
const merged = buildNavItems(builtIn, manifests);
const builtinPaths = new Set(builtIn.map((i) => i.path));
const coreItems: NavItem[] = [];
const pluginItems: NavItem[] = [];
for (const item of merged) {
if (builtinPaths.has(item.path)) coreItems.push(item);
else pluginItems.push(item);
}
return { coreItems, pluginItems };
}
function buildRoutes(
builtinRoutes: Record<string, ComponentType>,
manifests: PluginManifest[],
@ -253,6 +273,7 @@ function buildRoutes(
for (const m of addons) {
if (m.tab.hidden) continue;
if (m.tab.path === "/plugins") continue;
if (builtinRoutes[m.tab.path]) continue;
routes.push({
key: `plugin:${m.name}`,
@ -263,6 +284,7 @@ function buildRoutes(
for (const m of manifests) {
if (!m.tab.hidden) continue;
if (m.tab.path === "/plugins") continue;
if (builtinRoutes[m.tab.path] || m.tab.override) continue;
routes.push({
key: `plugin:hidden:${m.name}`,
@ -322,8 +344,8 @@ export default function App() {
[embeddedChat],
);
const navItems = useMemo(
() => buildNavItems(builtinNav, manifests),
const sidebarNav = useMemo(
() => partitionSidebarNav(builtinNav, manifests),
[builtinNav, manifests],
);
const routes = useMemo(
@ -476,56 +498,44 @@ export default function App() {
aria-label={t.app.navigation}
>
<ul className="flex flex-col">
{navItems.map(({ path, label, labelKey, icon: Icon }) => {
const navLabel = labelKey
? ((t.app.nav as Record<string, string>)[labelKey] ?? label)
: label;
return (
<li key={path}>
<NavLink
to={path}
end={path === "/sessions"}
onClick={closeMobile}
className={({ isActive }) =>
cn(
"group relative flex items-center gap-3",
"px-5 py-2.5",
"font-mondwest text-[0.8rem] tracking-[0.12em]",
"whitespace-nowrap transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
isActive
? "text-midground"
: "opacity-60 hover:opacity-100",
)
}
style={{
clipPath: "var(--component-tab-clip-path)",
}}
>
{({ isActive }) => (
<>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{navLabel}</span>
<span
aria-hidden
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
/>
{isActive && (
<span
aria-hidden
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
style={{ mixBlendMode: "plus-lighter" }}
/>
)}
</>
)}
</NavLink>
</li>
);
})}
{sidebarNav.coreItems.map((item) => (
<SidebarNavLink
closeMobile={closeMobile}
item={item}
key={item.path}
t={t}
/>
))}
</ul>
{sidebarNav.pluginItems.length > 0 && (
<div
aria-labelledby="hermes-sidebar-plugin-nav-heading"
className="flex flex-col border-t border-current/10 pb-2"
role="group"
>
<span
className={cn(
"px-5 pt-2.5 pb-1",
"font-mondwest text-[0.6rem] tracking-[0.15em] uppercase opacity-30",
)}
id="hermes-sidebar-plugin-nav-heading"
>
{t.app.pluginNavSection}
</span>
<ul className="flex flex-col">
{sidebarNav.pluginItems.map((item) => (
<SidebarNavLink
closeMobile={closeMobile}
item={item}
key={item.path}
t={t}
/>
))}
</ul>
</div>
)}
</nav>
<SidebarSystemActions onNavigate={closeMobile} />
@ -615,6 +625,57 @@ export default function App() {
);
}
function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
const { path, label, labelKey, icon: Icon } = item;
const navLabel = labelKey
? ((t.app.nav as Record<string, string>)[labelKey] ?? label)
: label;
return (
<li>
<NavLink
to={path}
end={path === "/sessions"}
onClick={closeMobile}
className={({ isActive }) =>
cn(
"group relative flex items-center gap-3",
"px-5 py-2.5",
"font-mondwest text-[0.8rem] tracking-[0.12em]",
"whitespace-nowrap transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
isActive ? "text-midground" : "opacity-60 hover:opacity-100",
)
}
style={{
clipPath: "var(--component-tab-clip-path)",
}}
>
{({ isActive }) => (
<>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{navLabel}</span>
<span
aria-hidden
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
/>
{isActive && (
<span
aria-hidden
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
style={{ mixBlendMode: "plus-lighter" }}
/>
)}
</>
)}
</NavLink>
</li>
);
}
function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
const { t } = useI18n();
const navigate = useNavigate();
@ -733,6 +794,12 @@ interface NavItem {
path: string;
}
interface SidebarNavLinkProps {
closeMobile: () => void;
item: NavItem;
t: Translations;
}
interface SystemActionItem {
action: SystemAction;
icon: ComponentType<{ className?: string }>;

View file

@ -76,6 +76,7 @@ export const en: Translations = {
logs: "Logs",
models: "Models",
profiles: "profiles : multi agents",
plugins: "Plugins",
sessions: "Sessions",
skills: "Skills",
},
@ -84,6 +85,7 @@ export const en: Translations = {
navigation: "Navigation",
openDocumentation: "Open documentation in a new tab",
openNavigation: "Open navigation",
pluginNavSection: "Plugins",
sessionsActiveCount: "{count} active",
statusOverview: "Status overview",
system: "System",
@ -256,6 +258,45 @@ export const en: Translations = {
renamed: "Renamed",
},
pluginsPage: {
contextEngineLabel: "Context engine",
dashboardSlots: "Dashboard slots",
disableRuntime: "Disable",
enableAfterInstall: "Enable after install",
enableRuntime: "Enable",
forceReinstall: "Force reinstall (delete existing folder first)",
headline:
"Discover, install, enable, and update Hermes plugins (`hermes plugins` parity).",
identifierLabel: "Git URL or owner/repo",
inactive: "inactive",
installBtn: "Install from Git",
installHeading: "Install from GitHub / Git URL",
installHint: "Use owner/repo shorthand or a full https:// or git@ clone URL.",
memoryProviderLabel: "Memory provider",
missingEnvWarn: "Set these in Keys before the plugin can run:",
noDashboardTab: "No dashboard tab",
openTab: "Open",
orphanHeading: "Dashboard-only extensions (no agent plugin.yaml match)",
pluginListHeading: "Installed plugins",
providerDefaults: "built-in / default",
providersHeading: "Runtime provider plugins",
providersHint:
"Writes memory.provider (empty = built-in) and context.engine to config.yaml. Takes effect next session.",
refreshDashboard: "Rescan dashboard extensions",
removeConfirm: "Remove this plugin from ~/.hermes/plugins/?",
removeHint: "Only user-installed plugins under ~/.hermes/plugins can be removed.",
rescanHeading: "SPA plugin registry",
rescanHint: "Rescan after adding files on disk so the dashboard sidebar picks up new manifests.",
runtimeHeading: "Gateway runtime (YAML plugins)",
saveProviders: "Save provider settings",
savedProviders: "Provider settings saved.",
sourceBadge: "Source",
authRequired: "Auth required",
authRequiredHint: "Run this command to authenticate:",
updateGit: "Git pull",
versionBadge: "Version",
},
skills: {
title: "Skills",
searchPlaceholder: "Search skills and toolsets...",

View file

@ -76,6 +76,7 @@ export interface Translations {
logs: string;
models: string;
profiles: string;
plugins: string;
sessions: string;
skills: string;
};
@ -84,6 +85,7 @@ export interface Translations {
navigation: string;
openDocumentation: string;
openNavigation: string;
pluginNavSection: string;
sessionsActiveCount: string;
statusOverview: string;
system: string;
@ -228,6 +230,44 @@ export interface Translations {
};
};
// ── Plugins page ──
pluginsPage: {
contextEngineLabel: string;
dashboardSlots: string;
disableRuntime: string;
enableAfterInstall: string;
enableRuntime: string;
forceReinstall: string;
headline: string;
identifierLabel: string;
inactive: string;
installBtn: string;
installHeading: string;
installHint: string;
memoryProviderLabel: string;
missingEnvWarn: string;
noDashboardTab: string;
openTab: string;
orphanHeading: string;
pluginListHeading: string;
providerDefaults: string;
providersHeading: string;
providersHint: string;
refreshDashboard: string;
removeConfirm: string;
removeHint: string;
rescanHeading: string;
rescanHint: string;
runtimeHeading: string;
saveProviders: string;
savedProviders: string;
sourceBadge: string;
authRequired: string;
authRequiredHint: string;
updateGit: string;
versionBadge: string;
};
// ── Profiles page ──
profiles: {
newProfile: string;

View file

@ -75,6 +75,7 @@ export const zh: Translations = {
logs: "日志",
models: "模型",
profiles: "多Agent配置",
plugins: "插件管理",
sessions: "会话",
skills: "技能",
},
@ -83,6 +84,7 @@ export const zh: Translations = {
navigation: "导航",
openDocumentation: "在新标签页中打开文档",
openNavigation: "打开导航",
pluginNavSection: "插件",
sessionsActiveCount: "{count} 个活跃",
statusOverview: "状态概览",
system: "系统",
@ -253,6 +255,44 @@ export const zh: Translations = {
renamed: "已重命名",
},
pluginsPage: {
contextEngineLabel: "上下文引擎",
dashboardSlots: "面板插槽",
disableRuntime: "禁用",
enableAfterInstall: "安装后启用",
enableRuntime: "启用",
forceReinstall: "强制重装(先删除已有目录)",
headline: "发现、安装、启用和更新 Hermes 插件(对齐 `hermes plugins` CLI。",
identifierLabel: "Git 地址或 owner/repo",
inactive: "未启用",
installBtn: "从 Git 安装",
installHeading: "从 GitHub / Git 地址安装",
installHint: "使用 owner/repo 简写或完整的 https:// / git@ 克隆地址。",
memoryProviderLabel: "记忆提供方",
missingEnvWarn: "在「密钥」页面设置以下变量后再运行插件:",
noDashboardTab: "无仪表盘标签",
openTab: "打开",
orphanHeading: "仅仪表盘扩展(无匹配的 agent plugin.yaml",
pluginListHeading: "已安装插件",
providerDefaults: "内置 / 默认",
providersHeading: "运行时提供方插件",
providersHint:
"写入 config.yamlmemory.provider留空为内置、context.engine。下次会话生效。",
refreshDashboard: "重新扫描仪表盘扩展",
removeConfirm: "从 ~/.hermes/plugins/ 删除此插件?",
removeHint: "仅可移除用户安装在 ~/.hermes/plugins 下的插件。",
rescanHeading: "SPA 插件注册表",
rescanHint: "在磁盘新增文件后扫描,使侧边栏载入新 manifest。",
runtimeHeading: "网关运行时YAML 插件)",
saveProviders: "保存提供方设置",
savedProviders: "提供方设置已保存。",
sourceBadge: "来源",
authRequired: "需要认证",
authRequiredHint: "运行此命令以完成认证:",
updateGit: "git pull",
versionBadge: "版本",
},
skills: {
title: "技能",
searchPlaceholder: "搜索技能和工具集...",

View file

@ -259,6 +259,46 @@ export const api = {
rescanPlugins: () =>
fetchJSON<{ ok: boolean; count: number }>("/api/dashboard/plugins/rescan"),
getPluginsHub: () => fetchJSON<PluginsHubResponse>("/api/dashboard/plugins/hub"),
installAgentPlugin: (body: AgentPluginInstallRequest) =>
fetchJSON<AgentPluginInstallResponse>("/api/dashboard/agent-plugins/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...body }),
}),
enableAgentPlugin: (name: string) =>
fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/enable`,
{ method: "POST" },
),
disableAgentPlugin: (name: string) =>
fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/disable`,
{ method: "POST" },
),
updateAgentPlugin: (name: string) =>
fetchJSON<AgentPluginUpdateResponse>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/update`,
{ method: "POST" },
),
removeAgentPlugin: (name: string) =>
fetchJSON<{ ok: boolean; name: string }>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}`,
{ method: "DELETE" },
),
savePluginProviders: (body: PluginProvidersPutRequest) =>
fetchJSON<{ ok: boolean }>("/api/dashboard/plugin-providers", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
// Dashboard themes
getThemes: () =>
fetchJSON<DashboardThemesResponse>("/api/dashboard/themes"),
@ -668,8 +708,66 @@ export interface PluginManifestResponse {
override?: string;
hidden?: boolean;
};
slots?: string[];
entry: string;
css?: string | null;
has_api: boolean;
source: string;
}
export interface HubAgentPluginRow {
name: string;
version: string;
description: string;
source: string;
runtime_status: "disabled" | "enabled" | "inactive";
has_dashboard_manifest: boolean;
dashboard_manifest: PluginManifestResponse | null;
path: string;
can_remove: boolean;
can_update_git: boolean;
auth_required: boolean;
auth_command: string;
}
export interface PluginsHubProviders {
memory_provider: string;
memory_options: Array<{ name: string; description: string }>;
context_engine: string;
context_options: Array<{ name: string; description: string }>;
}
export interface PluginsHubResponse {
plugins: HubAgentPluginRow[];
orphan_dashboard_plugins: PluginManifestResponse[];
providers: PluginsHubProviders;
}
export interface AgentPluginInstallRequest {
identifier: string;
force?: boolean;
enable?: boolean;
}
export interface AgentPluginInstallResponse {
ok: boolean;
plugin_name?: string;
warnings?: string[];
missing_env?: string[];
after_install_path?: string | null;
enabled?: boolean;
error?: string;
}
export interface AgentPluginUpdateResponse {
ok: boolean;
name?: string;
output?: string;
unchanged?: boolean;
error?: string;
}
export interface PluginProvidersPutRequest {
memory_provider?: string;
context_engine?: string;
}

View file

@ -0,0 +1,569 @@
import { useCallback, useEffect, useState } from "react";
import { ExternalLink, RefreshCw, Puzzle, Trash2 } from "lucide-react";
import type { Translations } from "@/i18n/types";
import { Link } from "react-router-dom";
import { api } from "@/lib/api";
import type { HubAgentPluginRow, PluginsHubResponse } from "@/lib/api";
import { Button } from "@nous-research/ui/ui/components/button";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Switch } from "@nous-research/ui/ui/components/switch";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { CommandBlock } from "@nous-research/ui/ui/components/command-block";
import { H2 } from "@/components/NouiTypography";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/useToast";
import { Toast } from "@/components/Toast";
import { useI18n } from "@/i18n";
import { PluginSlot } from "@/plugins";
import { cn } from "@/lib/utils";
/** Select value for built-in memory (`config` uses empty string). Never use `""` — UI Select maps empty value to an empty label. */
const MEMORY_PROVIDER_BUILTIN = "__hermes_memory_builtin__";
export default function PluginsPage() {
const [hub, setHub] = useState<PluginsHubResponse | null>(null);
const [loading, setLoading] = useState(true);
const [installId, setInstallId] = useState("");
const [installForce, setInstallForce] = useState(false);
const [installEnable, setInstallEnable] = useState(true);
const [installBusy, setInstallBusy] = useState(false);
const [rescanBusy, setRescanBusy] = useState(false);
const [memorySel, setMemorySel] = useState(MEMORY_PROVIDER_BUILTIN);
const [contextSel, setContextSel] = useState("compressor");
const [providerBusy, setProviderBusy] = useState(false);
const [rowBusy, setRowBusy] = useState<string | null>(null);
const { toast, showToast } = useToast();
const { t } = useI18n();
const loadHub = useCallback(() => {
return api
.getPluginsHub()
.then((h) => {
setHub(h);
const p = h.providers;
setMemorySel(p.memory_provider ? p.memory_provider : MEMORY_PROVIDER_BUILTIN);
setContextSel(p.context_engine || "compressor");
})
.catch(() => showToast(t.common.loading, "error"));
}, [showToast, t.common.loading]);
useEffect(() => {
setLoading(true);
void loadHub().finally(() => setLoading(false));
}, [loadHub]);
const onInstall = async () => {
const id = installId.trim();
if (!id) {
showToast(t.pluginsPage.installHint, "error");
return;
}
setInstallBusy(true);
try {
const r = await api.installAgentPlugin({
identifier: id,
force: installForce,
enable: installEnable,
});
showToast(`${r.plugin_name ?? id} installed`, "success");
if ((r.warnings?.length ?? 0) > 0) showToast(r.warnings!.join(" "), "error");
if ((r.missing_env?.length ?? 0) > 0)
showToast(`${t.pluginsPage.missingEnvWarn} ${r.missing_env!.join(", ")}`, "error");
setInstallId("");
await loadHub();
} catch (e) {
showToast(e instanceof Error ? e.message : "Install failed", "error");
} finally {
setInstallBusy(false);
}
};
const onRescan = async () => {
setRescanBusy(true);
try {
const rc = await api.rescanPlugins();
showToast(
`${t.pluginsPage.refreshDashboard} (${rc.count})`,
"success",
);
await loadHub();
} catch (e) {
showToast(e instanceof Error ? e.message : "Rescan failed", "error");
} finally {
setRescanBusy(false);
}
};
const onSaveProviders = async () => {
setProviderBusy(true);
try {
await api.savePluginProviders({
memory_provider:
memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel,
context_engine: contextSel,
});
showToast(t.pluginsPage.savedProviders, "success");
await loadHub();
} catch (e) {
showToast(e instanceof Error ? e.message : "Save failed", "error");
} finally {
setProviderBusy(false);
}
};
const setRuntimeLoading = async (name: string, fn: () => Promise<unknown>) => {
setRowBusy(name);
try {
await fn();
await loadHub();
} catch (e) {
showToast(e instanceof Error ? e.message : "Failed", "error");
} finally {
setRowBusy(null);
}
};
const rows = hub?.plugins ?? [];
const providers = hub?.providers;
return (
<div className="flex flex-col gap-4">
<PluginSlot name="plugins:top" />
<div className={cn("mx-auto flex w-full max-w-5xl flex-col gap-8")}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<H2>{t.app.nav.plugins}</H2>
<p className="mt-1 max-w-xl text-[0.75rem] tracking-[0.06em] text-midground/55 normal-case">
{t.pluginsPage.headline}
</p>
</div>
<Button
ghost
size="sm"
className="shrink-0 gap-2"
disabled={loading || rescanBusy}
onClick={() => void onRescan()}
>
{rescanBusy ? <Spinner /> : <RefreshCw className="h-3.5 w-3.5" />}
{t.pluginsPage.refreshDashboard}
</Button>
</div>
{providers && (
<Card>
<CardHeader>
<CardTitle>{t.pluginsPage.providersHeading}</CardTitle>
<p className="text-[0.7rem] tracking-[0.08em] text-midground/55 normal-case">
{t.pluginsPage.providersHint}
</p>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<div className="grid gap-6 sm:grid-cols-2 max-w-full">
<div className="grid gap-2 min-w-0">
<Label htmlFor="mem-provider">{t.pluginsPage.memoryProviderLabel}</Label>
<Select
id="mem-provider"
className="w-full"
value={memorySel}
onValueChange={setMemorySel}
>
<SelectOption value={MEMORY_PROVIDER_BUILTIN}>
{`(${t.pluginsPage.providerDefaults})`}
</SelectOption>
{providers.memory_options.map((o) => (
<SelectOption key={o.name} value={o.name}>
{o.name}
</SelectOption>
))}
</Select>
</div>
<div className="grid gap-2 min-w-0">
<Label htmlFor="ctx-engine">{t.pluginsPage.contextEngineLabel}</Label>
<Select
id="ctx-engine"
className="w-full"
value={contextSel}
onValueChange={setContextSel}
>
<SelectOption value="compressor">compressor</SelectOption>
{providers.context_options
.filter((o) => o.name !== "compressor")
.map((o) => (
<SelectOption key={o.name} value={o.name}>
{o.name}
</SelectOption>
))}
</Select>
</div>
</div>
<Button
className="w-fit gap-2"
size="sm"
disabled={providerBusy}
onClick={() => void onSaveProviders()}
>
{providerBusy ? <Spinner /> : null}
{t.pluginsPage.saveProviders}
</Button>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>{t.pluginsPage.installHeading}</CardTitle>
<p className="text-[0.7rem] tracking-[0.08em] text-midground/55 normal-case">
{t.pluginsPage.installHint}
</p>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="install-url">{t.pluginsPage.identifierLabel}</Label>
<Input
className="normal-case font-sans lowercase"
id="install-url"
placeholder="owner/repo or https://..."
spellCheck={false}
value={installId}
onChange={(e) => setInstallId(e.target.value)}
/>
</div>
<div className="flex flex-wrap items-center gap-8">
<div className="flex items-center gap-3">
<Switch checked={installForce} onCheckedChange={setInstallForce} />
<span className="text-[0.7rem] tracking-[0.06em] text-midforeground/85 normal-case">
{t.pluginsPage.forceReinstall}
</span>
</div>
<div className="flex items-center gap-3">
<Switch checked={installEnable} onCheckedChange={setInstallEnable} />
<span className="text-[0.7rem] tracking-[0.06em] text-midforeground/85 normal-case">
{t.pluginsPage.enableAfterInstall}
</span>
</div>
</div>
<Button
className="w-fit gap-2"
size="sm"
disabled={installBusy}
onClick={() => void onInstall()}
>
{installBusy ? <Spinner /> : <Puzzle className="h-3.5 w-3.5" />}
{t.pluginsPage.installBtn}
</Button>
<p className="text-[0.65rem] tracking-[0.06em] text-midforeground/55 normal-case">
{t.pluginsPage.rescanHint}
</p>
<p className="text-[0.65rem] tracking-[0.06em] text-midforeground/55 normal-case">
{t.pluginsPage.removeHint}
</p>
</CardContent>
</Card>
<div className="flex flex-col gap-3">
<h3 className="font-mondwest text-[0.75rem] tracking-[0.12em] text-midground/85">
{t.pluginsPage.pluginListHeading}
</h3>
{loading ? (
<div className="flex items-center gap-2 py-8 text-[0.8rem] text-midforeground/65">
<Spinner />
<span>{t.common.loading}</span>
</div>
) : rows.length === 0 ? (
<p className="text-[0.75rem] text-midforeground/55 normal-case">{t.common.noResults}</p>
) : (
<ul className="flex flex-col gap-3">
{rows.map((row: HubAgentPluginRow) => (
<li key={row.name}>
<PluginRowCard
{...{ row, rowBusy, setRuntimeLoading, showToast, t }}
/>
</li>
))}
</ul>
)}
</div>
{(hub?.orphan_dashboard_plugins?.length ?? 0) > 0 ? (
<div className="flex flex-col gap-3 opacity-95">
<h3 className="font-mondwest text-[0.75rem] tracking-[0.12em] text-midforeground/85">
{t.pluginsPage.orphanHeading}
</h3>
<ul className="flex flex-col gap-2 rounded border border-current/15 p-4">
{hub!.orphan_dashboard_plugins.map((m) => (
<li className="text-[0.7rem] normal-case opacity-85" key={m.name}>
{m.label ?? m.name} {m.description || m.tab?.path}
{!m.tab?.hidden ? (
<Link className="ml-3 inline-flex items-center gap-1 underline" to={m.tab.path}>
<ExternalLink className="h-3 w-3 opacity-65" />
{t.pluginsPage.openTab}
</Link>
) : null}
</li>
))}
</ul>
</div>
) : null}
</div>
<Toast toast={toast} />
<PluginSlot name="plugins:bottom" />
</div>
);
}
interface PluginRowCardProps {
row: HubAgentPluginRow;
rowBusy: string | null;
setRuntimeLoading: (
name: string,
fn: () => Promise<unknown>,
) => Promise<void>;
showToast: (msg: string, variant: "success" | "error") => void;
t: Translations;
}
function PluginRowCard(props: PluginRowCardProps) {
const {
row,
rowBusy,
setRuntimeLoading,
showToast,
t,
} = props;
const dm = row.dashboard_manifest;
const tabPath = dm?.tab && !dm.tab.hidden ? dm.tab.override ?? dm.tab.path : null;
const busy = rowBusy === row.name;
const badgeTone =
row.runtime_status === "enabled"
? "success"
: row.runtime_status === "disabled"
? "destructive"
: "outline";
return (
<Card className={cn(busy ? "opacity-70" : undefined)}>
<CardContent className="flex flex-col gap-4 px-6 py-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-3">
<span className="truncate font-semibold">{row.name}</span>
<Badge tone="outline">
{t.pluginsPage.sourceBadge}: {row.source}
</Badge>
<Badge tone="outline">v{row.version || "—"}</Badge>
<Badge tone={badgeTone}>{row.runtime_status}</Badge>
{row.auth_required ? (
<Badge tone="destructive">{t.pluginsPage.authRequired}</Badge>
) : null}
</div>
{row.description ? (
<p className="mt-2 max-w-2xl text-[0.7rem] tracking-[0.06em] text-midforeground/75 normal-case">
{row.description}
</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2 shrink-0">
<Button
disabled={busy || row.runtime_status === "enabled"}
ghost
size="sm"
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.enableAgentPlugin(row.name);
showToast(t.pluginsPage.enableRuntime, "success");
});
}}
>
{t.pluginsPage.enableRuntime}
</Button>
<Button
disabled={busy || row.runtime_status === "disabled"}
ghost
size="sm"
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.disableAgentPlugin(row.name);
showToast(t.pluginsPage.disableRuntime, "success");
});
}}
>
{t.pluginsPage.disableRuntime}
</Button>
{tabPath ? (
<Link
className={cn(
"inline-flex items-center rounded-none px-3 py-1.5",
"border border-current/25 hover:bg-current/10",
"font-mondwest text-[0.65rem] tracking-[0.1em] uppercase",
)}
to={tabPath}
>
{t.pluginsPage.openTab}
</Link>
) : null}
{row.can_update_git ? (
<Button
disabled={busy}
ghost
size="sm"
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.updateAgentPlugin(row.name);
showToast(t.pluginsPage.updateGit, "success");
});
}}
>
{busy ? <Spinner /> : null}
{t.pluginsPage.updateGit}
</Button>
) : null}
{row.can_remove ? (
<Button
destructive
disabled={busy}
ghost
size="sm"
onClick={() => {
const ok =
typeof window !== "undefined"
? window.confirm(t.pluginsPage.removeConfirm)
: false;
if (!ok) return;
void setRuntimeLoading(row.name, async () => {
await api.removeAgentPlugin(row.name);
showToast(`${row.name} removed`, "success");
});
}}
>
{busy ? <Spinner /> : <Trash2 className="h-3.5 w-3.5" />}
</Button>
) : null}
</div>
</div>
{dm?.slots?.length ? (
<p className="text-[0.65rem] tracking-[0.05em] text-midforeground/55 normal-case">
{t.pluginsPage.dashboardSlots}: {dm.slots.join(", ")}
</p>
) : null}
{row.auth_required ? (
<CommandBlock
label={t.pluginsPage.authRequiredHint}
code={row.auth_command}
/>
) : null}
{!row.has_dashboard_manifest && !dm ? (
<p className="text-[0.65rem] italic text-midforeground/45 normal-case">
{t.pluginsPage.noDashboardTab}
</p>
) : null}
</CardContent>
</Card>
);
}

View file

@ -46,6 +46,8 @@ import React, { Fragment, useEffect, useState } from "react";
* - `cron:bottom` bottom of /cron page
* - `skills:top` top of /skills page
* - `skills:bottom` bottom of /skills page
* - `plugins:top` top of /plugins page
* - `plugins:bottom` bottom of /plugins page
* - `config:top` top of /config page
* - `config:bottom` bottom of /config page
* - `env:top` top of /env (Keys) page
@ -78,6 +80,8 @@ export const KNOWN_SLOT_NAMES = [
"cron:bottom",
"skills:top",
"skills:bottom",
"plugins:top",
"plugins:bottom",
"config:top",
"config:bottom",
"env:top",