tests: pin ink engine in _make_tui_argv npm-bootstrap tests (post-merge semantic fix)

Main's rewritten test_tui_npm_install.py tests call _make_tui_argv expecting
the Ink/npm flow unconditionally; with the dual-engine dispatch merged in,
_resolve_tui_engine() auto-selects opentui whenever ui-opentui/dist is built
in the repo, routing the call away from the path under test (first subprocess
became 'node --version' instead of 'npm run build'). Pin the engine to ink
via an autouse fixture, mirroring the existing pinning precedent in
test_tui_resume_flow.py.
This commit is contained in:
alt-glitch 2026-06-12 10:32:40 +05:30
parent ab37440ce6
commit e1067dbbe5
756 changed files with 79874 additions and 19585 deletions

View file

@ -26,6 +26,7 @@ import {
Database,
Download,
Eye,
FolderOpen,
FileText,
Globe,
Heart,
@ -63,17 +64,23 @@ import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
import { AuthWidget } from "@/components/AuthWidget";
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
import { ProfileProvider } from "@/contexts/ProfileProvider";
import { useProfileScope } from "@/contexts/useProfileScope";
import { ProfileSwitcher } from "@/components/ProfileSwitcher";
import { ProfileScopeBanner } from "@/components/ProfileScopeBanner";
import { useSystemActions } from "@/contexts/useSystemActions";
import type { SystemAction } from "@/contexts/system-actions-context";
import ConfigPage from "@/pages/ConfigPage";
import DocsPage from "@/pages/DocsPage";
import EnvPage from "@/pages/EnvPage";
import FilesPage from "@/pages/FilesPage";
import SessionsPage from "@/pages/SessionsPage";
import LogsPage from "@/pages/LogsPage";
import AnalyticsPage from "@/pages/AnalyticsPage";
import ModelsPage from "@/pages/ModelsPage";
import CronPage from "@/pages/CronPage";
import ProfilesPage from "@/pages/ProfilesPage";
import ProfileBuilderPage from "@/pages/ProfileBuilderPage";
import SkillsPage from "@/pages/SkillsPage";
import PluginsPage from "@/pages/PluginsPage";
import McpPage from "@/pages/McpPage";
@ -124,6 +131,7 @@ const CHAT_NAV_ITEM: NavItem = {
const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
"/": RootRedirect,
"/sessions": SessionsPage,
"/files": FilesPage,
"/analytics": AnalyticsPage,
"/models": ModelsPage,
"/logs": LogsPage,
@ -136,6 +144,7 @@ const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
"/webhooks": WebhooksPage,
"/system": SystemPage,
"/profiles": ProfilesPage,
"/profiles/new": ProfileBuilderPage,
"/config": ConfigPage,
"/env": EnvPage,
"/docs": DocsPage,
@ -156,6 +165,7 @@ const BUILTIN_NAV_REST: NavItem[] = [
label: "Sessions",
icon: MessageSquare,
},
{ path: "/files", label: "Files", icon: FolderOpen },
{
path: "/analytics",
labelKey: "analytics",
@ -194,6 +204,7 @@ const ICON_MAP: Record<string, ComponentType<{ className?: string }>> = {
Clock,
Cpu,
FileText,
FolderOpen,
KeyRound,
MessageSquare,
Package,
@ -467,6 +478,7 @@ export default function App() {
}, []);
return (
<ProfileProvider>
<div
data-layout-variant={layoutVariant}
className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black text-text-primary antialiased"
@ -521,6 +533,7 @@ export default function App() {
)}
<PluginSlot name="header-banner" />
<ProfileScopeBanner />
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden pt-14 lg:pt-0">
<div className="flex min-h-0 min-w-0 flex-1">
@ -595,6 +608,8 @@ export default function App() {
</Button>
</div>
<ProfileSwitcher collapsed={isDesktopCollapsed} />
<nav
className="min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden border-t border-current/10 py-2"
aria-label={t.app.navigation}
@ -720,17 +735,19 @@ export default function App() {
"min-h-0 flex flex-1 flex-col",
)}
>
<Routes>
{routes.map(({ key, path, element }) => (
<Route key={key} path={path} element={element} />
))}
<Route
path="*"
element={
<UnknownRouteFallback pluginsLoading={pluginsLoading} />
}
/>
</Routes>
<ProfileKeyedRoutes>
<Routes>
{routes.map(({ key, path, element }) => (
<Route key={key} path={path} element={element} />
))}
<Route
path="*"
element={
<UnknownRouteFallback pluginsLoading={pluginsLoading} />
}
/>
</Routes>
</ProfileKeyedRoutes>
{embeddedChat &&
!chatOverriddenByPlugin &&
@ -768,9 +785,25 @@ export default function App() {
<PluginSlot name="overlay" />
</div>
</ProfileProvider>
);
}
/**
* Remounts the entire routed page tree when the global management profile
* changes. Pages load their data on mount; without this, a page opened
* under profile A would keep showing A's state while writes (via the
* fetchJSON ?profile= injection) silently targeted the newly selected
* profile B the exact stale-target footgun the switcher exists to kill.
* Keying by profile resets every page's local state so it refetches under
* the new scope. The persistent ChatPage host below handles its own
* remount (channel keyed on scopedProfile).
*/
function ProfileKeyedRoutes({ children }: { children: ReactNode }) {
const { profile } = useProfileScope();
return <div key={profile || "__own__"} className="contents">{children}</div>;
}
function SidebarNavLink({
closeMobile,
collapsed,

View file

@ -0,0 +1,222 @@
import { useCallback, useEffect, useState } from "react";
import { Clock, Wand2 } from "lucide-react";
import { Button } from "@nous-research/ui/ui/components/button";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { api } from "@/lib/api";
import type { AutomationBlueprint, AutomationBlueprintField } from "@/lib/api";
import { cn, themedBody } from "@/lib/utils";
interface AutomationBlueprintsProps {
profile: string;
/** Called after a blueprint is instantiated so the parent can refresh its job list. */
onCreated?: () => void;
}
/** Initial form values for a blueprint = each field's default (or ""). */
function initialValues(blueprint: AutomationBlueprint): Record<string, string> {
const out: Record<string, string> = {};
for (const f of blueprint.fields) out[f.name] = f.default ?? "";
return out;
}
function FieldInput({
field,
value,
onChange,
}: {
field: AutomationBlueprintField;
value: string;
onChange: (v: string) => void;
}) {
if (field.type === "enum" || field.type === "weekdays") {
return (
<Select value={value} onValueChange={(v) => onChange(v)}>
{field.options.map((opt) => (
<SelectOption key={opt} value={opt}>
{opt}
</SelectOption>
))}
</Select>
);
}
if (field.type === "time") {
return (
<Input
type="time"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
}
// text
return (
<Input
type="text"
value={value}
placeholder={field.help || field.label}
onChange={(e) => onChange(e.target.value)}
/>
);
}
function BlueprintCard({
blueprint,
profile,
showToast,
onCreated,
}: {
blueprint: AutomationBlueprint;
profile: string;
showToast: (message: string, type: "error" | "success") => void;
onCreated?: () => void;
}) {
const [open, setOpen] = useState(false);
const [values, setValues] = useState<Record<string, string>>(() => initialValues(blueprint));
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = useCallback(async () => {
setSubmitting(true);
setError(null);
try {
const job = await api.instantiateAutomationBlueprint({ blueprint: blueprint.key, values }, profile);
const when = job.schedule_display ? `${job.schedule_display}` : "";
showToast(`${blueprint.title} scheduled${when}`, "success");
setOpen(false);
setValues(initialValues(blueprint));
onCreated?.();
} catch (e) {
// 422 from the API carries the slot-level validation message.
const msg = e instanceof Error ? e.message : String(e);
setError(msg.replace(/^\d+:\s*/, ""));
} finally {
setSubmitting(false);
}
}, [blueprint, values, profile, showToast, onCreated]);
return (
<Card className={cn("overflow-hidden", themedBody)}>
<CardContent className="space-y-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Wand2 className="h-4 w-4 shrink-0 opacity-70" />
<span className="font-medium">{blueprint.title}</span>
</div>
<p className="mt-1 text-sm opacity-70">{blueprint.description}</p>
<div className="mt-2 flex flex-wrap gap-1">
{blueprint.tags.map((t) => (
<Badge key={t} tone="secondary">
{t}
</Badge>
))}
</div>
</div>
<Button
ghost={open}
size="sm"
onClick={() => setOpen((o) => !o)}
>
{open ? "Cancel" : "Set up"}
</Button>
</div>
{open && (
<div className="space-y-3 border-t pt-3">
{blueprint.fields.map((f) => (
<div key={f.name} className="space-y-1">
<Label htmlFor={`${blueprint.key}-${f.name}`}>{f.label}</Label>
<FieldInput
field={f}
value={values[f.name] ?? ""}
onChange={(v) => setValues((prev) => ({ ...prev, [f.name]: v }))}
/>
{f.help && f.type !== "text" ? (
<p className="text-xs opacity-60">{f.help}</p>
) : null}
</div>
))}
{error ? (
<p className="text-sm text-red-500" role="alert">
{error}
</p>
) : null}
<div className="flex items-center gap-2">
<Button onClick={() => void submit()} disabled={submitting}>
{submitting ? <Spinner className="h-4 w-4" /> : <Clock className="h-4 w-4" />}
Schedule it
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}
/**
* Automation Blueprints gallery the form-where-there's-a-screen surface. Each blueprint
* card expands into an inline form (one field per typed slot); submitting POSTs
* to /api/cron/blueprints/instantiate which fills the blueprint and creates the job
* via the same create_job path as everything else.
*/
export function AutomationBlueprints({ profile, onCreated }: AutomationBlueprintsProps) {
const { toast, showToast } = useToast();
const [blueprints, setBlueprints] = useState<AutomationBlueprint[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
api
.getAutomationBlueprints()
.then((r) => {
if (!cancelled) setBlueprints(r.blueprints);
})
.catch((e) => {
if (!cancelled) setLoadError(e instanceof Error ? e.message : String(e));
});
return () => {
cancelled = true;
};
}, []);
if (loadError) {
return <p className="text-sm text-red-500">Couldn't load blueprints: {loadError}</p>;
}
if (blueprints === null) {
return (
<div className="flex items-center gap-2 opacity-70">
<Spinner className="h-4 w-4" /> Loading blueprints
</div>
);
}
if (blueprints.length === 0) {
return <p className="opacity-70">No automation blueprints available.</p>;
}
return (
<>
<Toast toast={toast} />
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{blueprints.map((r) => (
<BlueprintCard
key={r.key}
blueprint={r}
profile={profile}
showToast={showToast}
onCreated={onCreated}
/>
))}
</div>
</>
);
}
export default AutomationBlueprints;

View file

@ -120,7 +120,11 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
if (cancelled) {
return;
}
return gw.request<{ session_id: string }>("session.create", {});
// close_on_disconnect: the gateway reaps this sidecar session (and its
// slash_worker subprocess) when the WS drops, instead of leaking it.
return gw.request<{ session_id: string }>("session.create", {
close_on_disconnect: true,
});
})
.then((created) => {
if (cancelled || !created?.session_id) {
@ -288,24 +292,6 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
setVersion((v) => v + 1);
}, []);
// Picker hands us a fully-formed slash command (e.g. "/model anthropic/...").
// Fire-and-forget through `slash.exec`; the TUI pane will render the result
// via PTY, so the sidebar doesn't need to surface output of its own.
const onModelSubmit = useCallback(
(slashCommand: string) => {
if (!sessionId) {
return;
}
void gw.request("slash.exec", {
session_id: sessionId,
command: slashCommand,
});
setModelOpen(false);
},
[gw, sessionId],
);
const canPickModel = state === "open" && !!sessionId;
const modelLabel = (info.model ?? "—").split("/").slice(-1)[0] ?? "—";
const banner = error ?? info.credential_warning ?? null;
@ -386,7 +372,6 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
gw={gw}
sessionId={sessionId}
onClose={() => setModelOpen(false)}
onSubmit={onModelSubmit}
/>
)}
</aside>

View file

@ -0,0 +1,122 @@
import { Button } from "@nous-research/ui/ui/components/button";
import { AlertTriangle } from "lucide-react";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { cn, themedBody } from "@/lib/utils";
interface ConfirmDialogProps {
cancelLabel?: string;
confirmLabel?: string;
description?: string;
destructive?: boolean;
loading?: boolean;
onCancel: () => void;
onConfirm: () => void;
open: boolean;
title: string;
}
export function ConfirmDialog({
cancelLabel = "Cancel",
confirmLabel = "Confirm",
description,
destructive = false,
loading = false,
onCancel,
onConfirm,
open,
title,
}: ConfirmDialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const prevActive = document.activeElement as HTMLElement | null;
dialogRef.current
?.querySelector<HTMLButtonElement>("[data-confirm]")
?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
};
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
prevActive?.focus?.();
};
}, [open, onCancel]);
if (!open) return null;
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
aria-describedby={description ? "confirm-dialog-desc" : undefined}
onClick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
className="fixed inset-0 z-[200] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
>
<div
ref={dialogRef}
className={cn(
themedBody,
"relative w-full max-w-md border border-border bg-card shadow-2xl",
)}
>
<div className="flex items-start gap-3 p-4 border-b border-border">
{destructive && (
<div aria-hidden className="mt-0.5 shrink-0 text-destructive">
<AlertTriangle className="h-4 w-4" />
</div>
)}
<div className="flex-1 min-w-0 flex flex-col gap-1">
<h2
id="confirm-dialog-title"
className="font-mondwest text-display text-base tracking-wider"
>
{title}
</h2>
{description && (
<p
id="confirm-dialog-desc"
className="text-xs text-muted-foreground leading-relaxed whitespace-pre-line"
>
{description}
</p>
)}
</div>
</div>
<div className="flex items-center justify-end gap-2 p-3">
<Button type="button" outlined onClick={onCancel} disabled={loading}>
{cancelLabel}
</Button>
<Button
data-confirm
type="button"
destructive={destructive}
onClick={onConfirm}
disabled={loading}
>
{loading ? "…" : confirmLabel}
</Button>
</div>
</div>
</div>,
document.body,
);
}

View file

@ -4,6 +4,7 @@ import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import type { GatewayClient } from "@/lib/gatewayClient";
import { Check, Search, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
@ -21,9 +22,8 @@ import { fuzzyRank } from "@/lib/fuzzy";
* Two invocation modes:
*
* 1. Chat-session mode (ChatSidebar) pass `gw` + `sessionId`. The picker
* loads options via `model.options` JSON-RPC and emits the result as a
* slash command string (`/model <model> --provider <slug> [--global]`)
* through `onSubmit`, which the ChatPage pipes to `slashExec`.
* loads options via `model.options` JSON-RPC and applies the choice via
* `config.set`, so expensive-model confirmation can happen before switch.
*
* 2. Standalone mode (ModelsPage, Config settings) pass a `loader` and
* `onApply`. The picker fetches options via the REST endpoint and calls
@ -47,6 +47,23 @@ interface ModelOptionsResponse {
providers?: ModelOptionProvider[];
}
interface ExpensiveModelConfirmResponse {
confirm_message?: string;
confirm_required?: boolean;
warning?: string;
}
interface ConfigSetResponse extends ExpensiveModelConfirmResponse {
value?: string;
}
interface PendingExpensiveConfirm {
message: string;
model: string;
persistGlobal: boolean;
provider: string;
}
interface Props {
/** Chat-mode: when present, picker emits a slash command via onSubmit. */
gw?: GatewayClient;
@ -56,10 +73,14 @@ interface Props {
/** Standalone-mode: when present (and onSubmit absent), picker calls onApply. */
loader?(): Promise<ModelOptionsResponse>;
onApply?(args: {
confirmExpensiveModel?: boolean;
provider: string;
model: string;
persistGlobal: boolean;
}): Promise<void> | void;
}):
| Promise<ExpensiveModelConfirmResponse | void>
| ExpensiveModelConfirmResponse
| void;
onClose(): void;
title?: string;
@ -90,6 +111,8 @@ export function ModelPickerDialog(props: Props) {
const [query, setQuery] = useState("");
const [persistGlobal, setPersistGlobal] = useState(alwaysGlobal);
const [applying, setApplying] = useState(false);
const [pendingConfirm, setPendingConfirm] =
useState<PendingExpensiveConfirm | null>(null);
const closedRef = useRef(false);
// Load providers + models on open.
@ -179,16 +202,65 @@ export function ModelPickerDialog(props: Props) {
const canConfirm = !!selectedProvider && !!selectedModel && !applying;
const confirm = async () => {
if (!canConfirm || !selectedProvider) return;
const applySelection = async (
confirmExpensiveModel = false,
forced?: PendingExpensiveConfirm,
) => {
const providerSlug = forced?.provider ?? selectedProvider?.slug ?? "";
const model = forced?.model ?? selectedModel;
const shouldPersistGlobal = forced?.persistGlobal ?? persistGlobal;
if (!providerSlug || !model || applying) return;
if (standalone && onApply) {
setApplying(true);
try {
await onApply({
provider: selectedProvider.slug,
model: selectedModel,
persistGlobal,
const result = await onApply({
confirmExpensiveModel,
provider: providerSlug,
model,
persistGlobal: shouldPersistGlobal,
});
if (result?.confirm_required) {
setPendingConfirm({
provider: providerSlug,
model,
persistGlobal: shouldPersistGlobal,
message:
result.confirm_message ||
result.warning ||
"This model has unusually high known pricing.",
});
return;
}
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setApplying(false);
}
} else if (gw && sessionId) {
setApplying(true);
try {
const global = shouldPersistGlobal ? " --global" : "";
const result = await gw.request<ConfigSetResponse>("config.set", {
confirm_expensive_model: confirmExpensiveModel,
key: "model",
session_id: sessionId,
value: `${model} --provider ${providerSlug}${global}`,
});
if (result?.confirm_required) {
setPendingConfirm({
provider: providerSlug,
model,
persistGlobal: shouldPersistGlobal,
message:
result.confirm_message ||
result.warning ||
"This model has unusually high known pricing.",
});
return;
}
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
@ -196,14 +268,17 @@ export function ModelPickerDialog(props: Props) {
setApplying(false);
}
} else if (onSubmit) {
const global = persistGlobal ? " --global" : "";
onSubmit(
`/model ${selectedModel} --provider ${selectedProvider.slug}${global}`,
);
const global = shouldPersistGlobal ? " --global" : "";
onSubmit(`/model ${model} --provider ${providerSlug}${global}`);
onClose();
}
};
const confirm = () => {
if (!canConfirm) return;
void applySelection();
};
// Portal to document.body: the main dashboard column in App.tsx is
// `relative z-2`, which creates a stacking context that traps fixed
// descendants below the app sidebar (z-50). Without the portal this
@ -280,8 +355,12 @@ export function ModelPickerDialog(props: Props) {
onSelect={setSelectedModel}
onConfirm={(m) => {
setSelectedModel(m);
// Confirm on next tick so state settles.
window.setTimeout(confirm, 0);
void applySelection(false, {
provider: selectedProvider?.slug ?? "",
model: m,
persistGlobal,
message: "",
});
}}
/>
</div>
@ -320,6 +399,22 @@ export function ModelPickerDialog(props: Props) {
</div>
</footer>
</div>
<ConfirmDialog
open={!!pendingConfirm}
title="Expensive Model Warning"
description={pendingConfirm?.message}
destructive
confirmLabel="Switch anyway"
cancelLabel="Cancel"
loading={applying}
onCancel={() => setPendingConfirm(null)}
onConfirm={() => {
const pending = pendingConfirm;
if (!pending) return;
setPendingConfirm(null);
void applySelection(true, pending);
}}
/>
</div>,
document.body,
);

View file

@ -0,0 +1,30 @@
import { Users } from "lucide-react";
import { useProfileScope } from "@/contexts/useProfileScope";
import { useI18n } from "@/i18n";
/**
* App-wide amber banner shown while the global switcher targets a profile
* OTHER than the dashboard's own every management write (config, keys,
* skills, MCPs, model) and new Chat sessions land in that profile.
*/
export function ProfileScopeBanner() {
const { profile, currentProfile } = useProfileScope();
const { t } = useI18n();
if (!profile || profile === currentProfile) return null;
return (
// mt-14 on mobile clears the fixed lg:hidden header (h-14, z-40) so the
// scope banner — the main safety signal for scoped writes — is never
// hidden behind it; lg:mt-0 restores desktop flow.
<div className="mt-14 lg:mt-0 flex items-center gap-2 border-b border-amber-500/40 bg-amber-500/10 px-4 py-1.5 text-xs text-amber-300">
<Users className="h-3.5 w-3.5 shrink-0" />
<span>
{(
t.app.managingProfileBanner ??
"Managing profile “{name}” — config, keys, skills, MCPs, model, and new chats apply to that profile."
).replace("{name}", profile)}
</span>
</div>
);
}

View file

@ -0,0 +1,67 @@
import { Users } from "lucide-react";
import { useProfileScope } from "@/contexts/useProfileScope";
import { useI18n } from "@/i18n";
import { cn } from "@/lib/utils";
/**
* The machine dashboard's single write-target selector.
*
* Rendered in the sidebar above the nav. Every management page (Config,
* Keys, Skills, MCP, Models) reads/writes the selected profile via the
* fetchJSON ?profile= injection. Hidden when only one profile exists.
*/
export function ProfileSwitcher({ collapsed }: { collapsed?: boolean }) {
const { profile, currentProfile, profiles, setProfile } = useProfileScope();
const { t } = useI18n();
if (profiles.length < 2) return null;
const managed = profile || currentProfile || "default";
const isOther = !!profile && profile !== currentProfile;
return (
<div
className={cn(
"flex items-center gap-2 border-b border-current/10 px-3 py-2",
collapsed && "lg:justify-center lg:px-0",
)}
title={t.app.managingProfile ?? "Managing profile"}
>
<Users
className={cn(
"h-3.5 w-3.5 shrink-0",
isOther ? "text-amber-300" : "text-text-tertiary",
)}
/>
<select
aria-label={t.app.managingProfile ?? "Managing profile"}
className={cn(
"h-7 w-full min-w-0 rounded-none border bg-background px-1 text-xs",
isOther
? "border-amber-500/50 text-amber-300"
: "border-border text-text-secondary",
collapsed && "lg:hidden",
)}
value={profile}
onChange={(e) => setProfile(e.target.value)}
>
<option value="">
{(t.app.currentProfileOption ?? "this dashboard ({name})").replace(
"{name}",
currentProfile || "default",
)}
</option>
{profiles
.filter((name) => name !== currentProfile)
.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
{collapsed && (
<span className="sr-only">{managed}</span>
)}
</div>
);
}

View file

@ -0,0 +1,215 @@
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { Button } from "@nous-research/ui/ui/components/button";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@nous-research/ui/ui/components/dialog";
/* ------------------------------------------------------------------ */
/* SkillEditorDialog — create or edit a SKILL.md from the dashboard */
/* */
/* Headless/VPS users have no editor besides this: the only other way */
/* to author a custom skill is SSH + a terminal editor. Create mode */
/* posts a brand-new skill (name + optional category + SKILL.md); */
/* edit mode loads the existing SKILL.md raw text and rewrites it. */
/* Validation (frontmatter, name, size) happens server-side via the */
/* same path the agent's skill_manage tool uses, so errors come back */
/* as actionable messages rendered inline. */
/* ------------------------------------------------------------------ */
const CREATE_TEMPLATE = `---
name: my-skill
description: One-line description of when to use this skill.
---
# My Skill
Numbered steps, exact commands, and pitfalls go here.
`;
export interface SkillEditorDialogProps {
open: boolean;
/** Skill name to edit, or null for create mode. */
editName: string | null;
/** Profile to scope reads/writes to ("" = the dashboard's own profile). */
profile?: string;
onClose: () => void;
/** Called after a successful save so the page can refresh its list. */
onSaved: (name: string) => void;
}
export function SkillEditorDialog({
open,
editName,
profile,
onClose,
onSaved,
}: SkillEditorDialogProps) {
// The body is remounted via `key` every time the dialog opens or the
// target skill changes, so all form state initializes through useState
// initializers — no reset-on-open effect (react-hooks/set-state-in-effect).
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-3xl">
{open && (
<EditorBody
key={editName ?? "__create__"}
editName={editName}
profile={profile}
onClose={onClose}
onSaved={onSaved}
/>
)}
</DialogContent>
</Dialog>
);
}
function EditorBody({
editName,
profile,
onClose,
onSaved,
}: Omit<SkillEditorDialogProps, "open">) {
const isEdit = editName !== null;
const [name, setName] = useState("");
const [category, setCategory] = useState("");
const [content, setContent] = useState(isEdit ? "" : CREATE_TEMPLATE);
const [loading, setLoading] = useState(isEdit);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!editName) return;
let cancelled = false;
api
.getSkillContent(editName, profile || undefined)
.then((res) => !cancelled && setContent(res.content))
.catch((e) => !cancelled && setError(String(e)))
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [editName, profile]);
const handleSave = async () => {
setError(null);
if (!isEdit && !name.trim()) {
setError("Skill name is required.");
return;
}
if (!content.trim()) {
setError("SKILL.md content is required.");
return;
}
setSaving(true);
try {
if (isEdit) {
await api.updateSkillContent(editName, content, profile || undefined);
onSaved(editName);
} else {
const trimmed = name.trim();
await api.createSkill(
{
name: trimmed,
content,
category: category.trim() || undefined,
},
profile || undefined,
);
onSaved(trimmed);
}
onClose();
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
};
return (
<>
<DialogHeader>
<DialogTitle>
{isEdit ? `Edit skill: ${editName}` : "New skill"}
</DialogTitle>
<DialogDescription>
{isEdit
? "Rewrite this skill's SKILL.md. Frontmatter (name, description) is validated on save."
: "Author a custom skill — YAML frontmatter plus markdown instructions. It becomes available to the agent and attachable to cron jobs."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
{!isEdit && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label htmlFor="skill-editor-name">Name</Label>
<Input
id="skill-editor-name"
autoFocus
placeholder="my-skill"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="skill-editor-category">Category (optional)</Label>
<Input
id="skill-editor-category"
placeholder="devops"
value={category}
onChange={(e) => setCategory(e.target.value)}
/>
</div>
</div>
)}
<div className="grid gap-1.5">
<Label htmlFor="skill-editor-content">SKILL.md</Label>
{loading ? (
<div className="flex items-center justify-center py-16">
<Spinner className="text-xl text-primary" />
</div>
) : (
<textarea
id="skill-editor-content"
spellCheck={false}
className="min-h-[320px] max-h-[55vh] w-full resize-y border border-border bg-background/40 px-3 py-2 font-mono text-xs leading-relaxed shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
value={content}
onChange={(e) => setContent(e.target.value)}
/>
)}
</div>
{error && (
<p className="whitespace-pre-wrap text-xs text-destructive">
{error}
</p>
)}
<div className="flex items-center justify-end gap-2">
<Button ghost size="sm" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button
size="sm"
className="uppercase"
onClick={handleSave}
disabled={saving || loading}
prefix={saving ? <Spinner /> : undefined}
>
{saving ? "Saving…" : isEdit ? "Save changes" : "Create skill"}
</Button>
</div>
</div>
</>
);
}

View file

@ -20,6 +20,9 @@ import { cn, themedBody } from "@/lib/utils";
interface Props {
/** The toolset whose backends are being configured. */
toolset: ToolsetInfo;
/** Optional profile to scope config reads/writes to (Skills page profile
* selector). Omitted = the dashboard process's own profile. */
profile?: string;
onClose: () => void;
/** Called after a toggle/provider/key change so the parent grid refreshes. */
onChanged: () => void;
@ -31,7 +34,7 @@ interface Props {
* the toolset on/off, pick a provider, enter API keys, and run a provider's
* post-setup install hook (npm/pip/binary) with a live log tail.
*/
export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Props) {
const { toast, showToast } = useToast();
const [config, setConfig] = useState<ToolsetConfig | null>(null);
const [loading, setLoading] = useState(true);
@ -60,7 +63,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
// react-hooks/set-state-in-effect — setState only fires inside the
// async .then/.catch/.finally callbacks.
return api
.getToolsetConfig(toolset.name)
.getToolsetConfig(toolset.name, profile)
.then((cfg) => {
setConfig(cfg);
setActiveProvider(cfg.active_provider);
@ -72,7 +75,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
})
.catch(() => showToast("Failed to load toolset config", "error"))
.finally(() => setLoading(false));
}, [toolset.name, showToast]);
}, [toolset.name, profile, showToast]);
useEffect(() => {
void loadConfig();
@ -121,7 +124,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
const handleToggle = async (next: boolean) => {
setToggling(true);
try {
await api.toggleToolset(toolset.name, next);
await api.toggleToolset(toolset.name, next, profile);
setEnabled(next);
showToast(
`${toolset.label || toolset.name} ${next ? "enabled" : "disabled"}`,
@ -138,7 +141,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
const handleSelectProvider = async (provider: ToolsetProvider) => {
setSelecting(provider.name);
try {
await api.selectToolsetProvider(toolset.name, provider.name);
await api.selectToolsetProvider(toolset.name, provider.name, profile);
setActiveProvider(provider.name);
showToast(`Provider set to ${provider.name}`, "success");
onChanged();
@ -164,7 +167,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
}
setSavingProvider(provider.name);
try {
const res = await api.saveToolsetEnv(toolset.name, env);
const res = await api.saveToolsetEnv(toolset.name, env, profile);
setIsSet((prev) => ({ ...prev, ...res.is_set }));
// Clear saved drafts so the inputs reset to the "saved" placeholder.
setDrafts((prev) => {
@ -195,7 +198,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
setPostSetupLog([]);
setPostSetupKey(provider.post_setup);
try {
await api.runToolsetPostSetup(toolset.name, provider.post_setup);
await api.runToolsetPostSetup(toolset.name, provider.post_setup, profile);
// Bump the trigger so the poll effect (re)starts tailing the log.
setPostSetupTrigger((n) => n + 1);
} catch (e) {

View file

@ -0,0 +1,115 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useLocation, useSearchParams } from "react-router-dom";
import { api, setManagementProfile } from "@/lib/api";
import { ProfileContext } from "@/contexts/profile-context";
/**
* Machine-level management-profile scope.
*
* One switcher (rendered in the sidebar) decides which profile every
* management page reads/writes. React STATE is the source of truth; the
* URL (`?profile=<name>`) is a synchronized projection of it so deep links
* land scoped and refresh survives. The selection is mirrored into the api
* module so `fetchJSON` transparently appends it to the profile-scoped
* endpoint families. "" = the dashboard's own profile.
*
* Why state-first instead of URL-first: sidebar nav links are bare paths
* (`/config`, `/skills`). A URL-derived scope would silently reset to the
* dashboard's own profile on every nav click the switcher would LOOK
* global while normal navigation dropped the write target. With state as
* truth, the effect below re-asserts `?profile=` onto the new location
* after each navigation, so the scope survives nav and stays deep-linkable.
*
* This exists because "Set as active" on the Profiles page only flips the
* sticky active_profile file (future CLI/gateway runs) it cannot retarget
* the running dashboard. The switcher is the dashboard's own, visible,
* write-target selector.
*/
export function ProfileProvider({ children }: { children: ReactNode }) {
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [profiles, setProfiles] = useState<string[]>([]);
const [currentProfile, setCurrentProfile] = useState("default");
// Initial value comes from the URL (deep link / refresh / unified-launch
// preselect); afterwards state leads and the URL follows.
const [profile, setProfileState] = useState(
() => searchParams.get("profile") ?? "",
);
// Mirror into the api module synchronously on every render where it
// changed, so fetches fired by child effects in the same commit see it.
setManagementProfile(profile);
// A profile param arriving via in-app navigation (e.g. the Profiles
// page's "Manage skills & tools" linking to /skills?profile=X) must win
// over current state — it's an explicit scope request.
const urlProfile = searchParams.get("profile");
useEffect(() => {
if (urlProfile !== null && urlProfile !== profile) {
setManagementProfile(urlProfile);
setProfileState(urlProfile);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [urlProfile]);
// Re-assert ?profile= after navigations that dropped it (bare nav links).
// Runs on every pathname/profile change; no-ops when already in sync.
useEffect(() => {
const inUrl = searchParams.get("profile") ?? "";
if ((profile || "") === inUrl) return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (profile) next.set("profile", profile);
else next.delete("profile");
return next;
},
{ replace: true },
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname, profile]);
useEffect(() => {
api
.getProfiles()
.then((res) => setProfiles(res.profiles.map((p) => p.name)))
.catch(() => {});
api
.getActiveProfile()
.then((info) => setCurrentProfile(info.current || "default"))
.catch(() => {});
}, []);
const setProfile = useCallback(
(name: string) => {
setManagementProfile(name);
setProfileState(name);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (name) next.set("profile", name);
else next.delete("profile");
return next;
},
{ replace: true },
);
},
[setSearchParams],
);
const value = useMemo(
() => ({ profile, currentProfile, profiles, setProfile }),
[profile, currentProfile, profiles, setProfile],
);
return (
<ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>
);
}

View file

@ -0,0 +1,19 @@
import { createContext } from "react";
export interface ProfileContextValue {
/** Profile every management surface reads/writes ("" = the dashboard
* process's own profile). */
profile: string;
/** The profile the dashboard process itself runs under. */
currentProfile: string;
/** Known profile names (includes "default"). */
profiles: string[];
setProfile: (name: string) => void;
}
export const ProfileContext = createContext<ProfileContextValue>({
profile: "",
currentProfile: "default",
profiles: [],
setProfile: () => {},
});

View file

@ -0,0 +1,6 @@
import { useContext } from "react";
import { ProfileContext } from "@/contexts/profile-context";
export function useProfileScope() {
return useContext(ProfileContext);
}

View file

@ -93,6 +93,10 @@ export const en: Translations = {
statusOverview: "Status overview",
system: "System",
webUi: "Web UI",
managingProfile: "Managing profile",
currentProfileOption: "this dashboard ({name})",
managingProfileBanner:
"Managing profile \u201c{name}\u201d \u2014 config, keys, skills, MCPs, model, and new chats apply to that profile.",
},
status: {
@ -362,7 +366,7 @@ export const en: Translations = {
inactive: "inactive",
installBtn: "Install",
installHeading: "Install from GitHub / Git URL",
installHint: "Use owner/repo shorthand or a full https:// or git@ clone URL.",
installHint: "Use owner/repo shorthand or a full https:// or git@ clone URL. For a plugin in a subdirectory, append the path: owner/repo/path/to/plugin (or <url>#path/to/plugin).",
memoryProviderLabel: "Memory provider",
missingEnvWarn: "Set these in Keys before the plugin can run:",
noDashboardTab: "No dashboard tab",
@ -408,6 +412,10 @@ export const en: Translations = {
setupNeeded: "Setup needed",
disabledForCli: "Disabled for CLI",
more: "+{count} more",
profileSelector: "Profile",
currentProfile: "current ({name})",
managingProfile:
"Managing profile \u201c{name}\u201d — toggles apply to that profile, not this dashboard\u2019s.",
},
config: {

View file

@ -110,6 +110,10 @@ export interface Translations {
statusOverview: string;
system: string;
webUi: string;
/** Optional — fall back to English literals until translated. */
managingProfile?: string;
currentProfileOption?: string;
managingProfileBanner?: string;
};
// ── Status page ──
@ -404,6 +408,8 @@ export interface Translations {
modelSaved?: string;
modelSelect?: string;
actions?: string;
manageSkills?: string;
activeSetHint?: string;
};
// ── Skills page ──
@ -425,6 +431,10 @@ export interface Translations {
setupNeeded: string;
disabledForCli: string;
more: string;
/** Optional — fall back to English literals until translated. */
profileSelector?: string;
currentProfile?: string;
managingProfile?: string;
};
// ── Config page ──

View file

@ -41,11 +41,54 @@ function setSessionHeader(headers: Headers, token: string): void {
}
}
// ── Global management-profile scope ──────────────────────────────────
// The dashboard is a machine-level management surface: one header switcher
// (ProfileProvider in App.tsx) decides which profile the management pages
// read/write, and fetchJSON transparently appends ?profile=<name> to the
// profile-scoped endpoint families below. "" = the dashboard process's own
// profile (legacy behavior). Calls that already carry an explicit profile
// (e.g. ProfileBuilder writes) are left untouched — explicit beats global.
let _managementProfile = "";
export function setManagementProfile(name: string): void {
_managementProfile = (name || "").trim();
}
export function getManagementProfile(): string {
return _managementProfile;
}
// Endpoint families that honor ?profile= on the backend (web_server.py
// _profile_scope). Anything else — sessions, analytics, ops, pairing,
// channels, cron (which has its own per-job profile params), profiles
// themselves — is machine-global or self-scoped and must NOT be rewritten.
const PROFILE_SCOPED_PREFIXES = [
"/api/skills",
"/api/tools/toolsets",
"/api/config",
"/api/env",
"/api/mcp",
"/api/model/info",
"/api/model/set",
"/api/model/auxiliary",
"/api/model/options",
];
function withManagementProfile(url: string): string {
if (!_managementProfile) return url;
if (url.includes("profile=")) return url; // explicit param wins
const path = url.split("?")[0];
if (!PROFILE_SCOPED_PREFIXES.some((p) => path.startsWith(p))) return url;
const sep = url.includes("?") ? "&" : "?";
return `${url}${sep}profile=${encodeURIComponent(_managementProfile)}`;
}
export async function fetchJSON<T>(
url: string,
init?: RequestInit,
options?: FetchJSONOptions,
): Promise<T> {
url = withManagementProfile(url);
// Inject the session token into all /api/ requests.
const headers = new Headers(init?.headers);
const token = window.__HERMES_SESSION_TOKEN__;
@ -249,6 +292,14 @@ export async function buildWsUrl(
return `${proto}//${window.location.host}${BASE}${path}?${qs}`;
}
/** Build a ``?profile=<name>`` query suffix, or "" when unset.
*
* Used by the skills/toolsets endpoints so the dashboard can manage a
* profile other than the one the server process runs under. */
function profileQuery(profile?: string): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
}
export const api = {
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
/**
@ -325,6 +376,32 @@ export const api = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ older_than_days, source }),
}),
listFiles: (path?: string) => {
const query = path ? `?path=${encodeURIComponent(path)}` : "";
return fetchJSON<ManagedFilesResponse>(`/api/files${query}`);
},
readFile: (path: string) =>
fetchJSON<ManagedFileReadResponse>(
`/api/files/read?path=${encodeURIComponent(path)}`,
),
uploadFile: (path: string, dataUrl: string, overwrite = true) =>
fetchJSON<ManagedFileWriteResponse>("/api/files/upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, data_url: dataUrl, overwrite }),
}),
createDirectory: (path: string) =>
fetchJSON<ManagedFileWriteResponse>("/api/files/mkdir", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
}),
deleteFile: (path: string, recursive = false) =>
fetchJSON<{ ok: boolean; path: string }>("/api/files", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, recursive }),
}),
getLogs: (params: { file?: string; lines?: number; level?: string; component?: string }) => {
const qs = new URLSearchParams();
if (params.file) qs.set("file", params.file);
@ -355,7 +432,7 @@ export const api = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ config }),
}),
getConfigRaw: () => fetchJSON<{ yaml: string }>("/api/config/raw"),
getConfigRaw: () => fetchJSON<{ yaml: string; path?: string }>("/api/config/raw"),
saveConfigRaw: (yaml_text: string) =>
fetchJSON<{ ok: boolean }>("/api/config/raw", {
method: "PUT",
@ -392,7 +469,7 @@ export const api = {
fetchJSON<CronJob[]>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`),
getCronDeliveryTargets: () =>
fetchJSON<{ targets: CronDeliveryTarget[] }>("/api/cron/delivery-targets"),
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }, profile = "default") =>
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string; skills?: string[] }, profile = "default") =>
fetchJSON<CronJob>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -402,7 +479,7 @@ export const api = {
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/pause?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
updateCronJob: (
id: string,
updates: { prompt?: string; schedule?: string; name?: string; deliver?: string },
updates: { prompt?: string; schedule?: string; name?: string; deliver?: string; skills?: string[] },
profile = "default",
) =>
fetchJSON<CronJob>(
@ -420,6 +497,19 @@ export const api = {
deleteCronJob: (id: string, profile = "default") =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, { method: "DELETE" }),
// Automation Blueprints — parameterized automation blueprints
getAutomationBlueprints: () =>
fetchJSON<{ blueprints: AutomationBlueprint[] }>("/api/cron/blueprints"),
instantiateAutomationBlueprint: (
body: { blueprint: string; values: Record<string, string> },
profile = "default",
) =>
fetchJSON<CronJob>(`/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
// Profiles
getProfiles: () =>
fetchJSON<{ profiles: ProfileInfo[] }>("/api/profiles"),
@ -439,8 +529,19 @@ export const api = {
description?: string;
provider?: string;
model?: string;
mcp_servers?: McpServerCreate[];
keep_skills?: string[];
hub_skills?: string[];
}) =>
fetchJSON<{ ok: boolean; name: string; path: string; model_set?: boolean }>("/api/profiles", {
fetchJSON<{
ok: boolean;
name: string;
path: string;
model_set?: boolean;
mcp_written?: number;
skills_disabled?: number;
hub_installs?: Array<{ identifier: string; pid: number | null }>;
}>("/api/profiles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@ -505,52 +606,74 @@ export const api = {
),
// Skills & Toolsets
getSkills: () => fetchJSON<SkillInfo[]>("/api/skills"),
toggleSkill: (name: string, enabled: boolean) =>
//
// All calls accept an optional ``profile`` so the Skills page can manage
// any profile's skills/toolsets — not just the one the dashboard process
// runs under. Omitted/empty profile = the dashboard's own profile.
getSkills: (profile?: string) =>
fetchJSON<SkillInfo[]>(`/api/skills${profileQuery(profile)}`),
toggleSkill: (name: string, enabled: boolean, profile?: string) =>
fetchJSON<{ ok: boolean }>("/api/skills/toggle", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, enabled }),
body: JSON.stringify({ name, enabled, profile: profile || undefined }),
}),
getToolsets: () => fetchJSON<ToolsetInfo[]>("/api/tools/toolsets"),
toggleToolset: (name: string, enabled: boolean) =>
getSkillContent: (name: string, profile?: string) =>
fetchJSON<SkillContent>(
`/api/skills/content?name=${encodeURIComponent(name)}${profile ? `&profile=${encodeURIComponent(profile)}` : ""}`,
),
createSkill: (skill: { name: string; content: string; category?: string }, profile?: string) =>
fetchJSON<SkillWriteResult>("/api/skills", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...skill, profile: profile || undefined }),
}),
updateSkillContent: (name: string, content: string, profile?: string) =>
fetchJSON<SkillWriteResult>("/api/skills/content", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, content, profile: profile || undefined }),
}),
getToolsets: (profile?: string) =>
fetchJSON<ToolsetInfo[]>(`/api/tools/toolsets${profileQuery(profile)}`),
toggleToolset: (name: string, enabled: boolean, profile?: string) =>
fetchJSON<{ ok: boolean; name: string; enabled: boolean }>(
`/api/tools/toolsets/${encodeURIComponent(name)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
body: JSON.stringify({ enabled, profile: profile || undefined }),
},
),
getToolsetConfig: (name: string) =>
getToolsetConfig: (name: string, profile?: string) =>
fetchJSON<ToolsetConfig>(
`/api/tools/toolsets/${encodeURIComponent(name)}/config`,
`/api/tools/toolsets/${encodeURIComponent(name)}/config${profileQuery(profile)}`,
),
selectToolsetProvider: (name: string, provider: string) =>
selectToolsetProvider: (name: string, provider: string, profile?: string) =>
fetchJSON<{ ok: boolean; name: string; provider: string }>(
`/api/tools/toolsets/${encodeURIComponent(name)}/provider`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider }),
body: JSON.stringify({ provider, profile: profile || undefined }),
},
),
saveToolsetEnv: (name: string, env: Record<string, string>) =>
saveToolsetEnv: (name: string, env: Record<string, string>, profile?: string) =>
fetchJSON<ToolsetEnvResult>(
`/api/tools/toolsets/${encodeURIComponent(name)}/env`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ env }),
body: JSON.stringify({ env, profile: profile || undefined }),
},
),
runToolsetPostSetup: (name: string, key: string) =>
runToolsetPostSetup: (name: string, key: string, profile?: string) =>
fetchJSON<ActionResponse & { key: string }>(
`/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
body: JSON.stringify({ key, profile: profile || undefined }),
},
),
@ -815,6 +938,8 @@ export const api = {
// ── Admin: Webhooks ─────────────────────────────────────────────────
getWebhooks: () => fetchJSON<WebhooksResponse>("/api/webhooks"),
enableWebhooks: () =>
fetchJSON<WebhookEnableResponse>("/api/webhooks/enable", { method: "POST" }),
createWebhook: (body: WebhookCreate) =>
fetchJSON<WebhookRoute & { secret: string }>("/api/webhooks", {
method: "POST",
@ -889,11 +1014,11 @@ export const api = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ output }),
}),
runImport: (archive: string) =>
runImport: (archive: string, force = false) =>
fetchJSON<ActionResponse>("/api/ops/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ archive }),
body: JSON.stringify({ archive, force }),
}),
getHooks: () => fetchJSON<HooksResponse>("/api/ops/hooks"),
createHook: (body: HookCreate) =>
@ -949,26 +1074,34 @@ export const api = {
fetchJSON<ActionResponse>("/api/ops/checkpoints/prune", { method: "POST" }),
// ── Admin: Skills hub ───────────────────────────────────────────────
installSkillFromHub: (identifier: string) =>
// ``profile`` scopes install/uninstall/update and the installed-state
// annotations to that profile (omitted = the dashboard's own profile).
installSkillFromHub: (identifier: string, profile?: string) =>
fetchJSON<ActionResponse>("/api/skills/hub/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identifier }),
body: JSON.stringify({ identifier, profile: profile || undefined }),
}),
uninstallSkillFromHub: (name: string) =>
uninstallSkillFromHub: (name: string, profile?: string) =>
fetchJSON<ActionResponse>("/api/skills/hub/uninstall", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
body: JSON.stringify({ name, profile: profile || undefined }),
}),
updateSkillsFromHub: () =>
fetchJSON<ActionResponse>("/api/skills/hub/update", { method: "POST" }),
searchSkillsHub: (q: string, source = "all", limit = 20) =>
updateSkillsFromHub: (profile?: string) =>
fetchJSON<ActionResponse>("/api/skills/hub/update", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile: profile || undefined }),
}),
searchSkillsHub: (q: string, source = "all", limit = 20, profile?: string) =>
fetchJSON<SkillHubSearchResponse>(
`/api/skills/hub/search?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}&limit=${limit}`,
`/api/skills/hub/search?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}&limit=${limit}${profile ? `&profile=${encodeURIComponent(profile)}` : ""}`,
),
getSkillHubSources: (profile?: string) =>
fetchJSON<SkillHubSourcesResponse>(
`/api/skills/hub/sources${profileQuery(profile)}`,
),
getSkillHubSources: () =>
fetchJSON<SkillHubSourcesResponse>("/api/skills/hub/sources"),
previewSkillFromHub: (identifier: string) =>
fetchJSON<SkillHubPreview>(
`/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`,
@ -1229,6 +1362,17 @@ export interface WebhooksResponse {
subscriptions: WebhookRoute[];
}
export interface WebhookEnableResponse {
ok: boolean;
platform: "webhook";
enabled: true;
needs_restart: boolean;
restart_started?: boolean;
restart_action?: string;
restart_pid?: number | null;
restart_error?: string;
}
export interface WebhookCreate {
name: string;
description?: string;
@ -1475,7 +1619,11 @@ export interface TelegramOnboardingApplyResponse {
ok: boolean;
platform: "telegram";
bot_username?: string;
needs_restart: true;
needs_restart: boolean;
restart_started?: boolean;
restart_action?: string;
restart_pid?: number | null;
restart_error?: string;
}
export interface SessionMessage {
@ -1500,6 +1648,44 @@ export interface LogsResponse {
lines: string[];
}
export interface ManagedFileEntry {
name: string;
path: string;
is_directory: boolean;
size: number | null;
mtime: number;
mime_type: string | null;
}
export interface ManagedFilesResponse {
root: string | null;
path: string;
parent: string | null;
locked_root: string | null;
can_change_path: boolean;
entries: ManagedFileEntry[];
}
export interface ManagedFileReadResponse {
name: string;
path: string;
size: number;
mime_type: string;
data_url: string;
root: string | null;
locked_root: string | null;
can_change_path: boolean;
}
export interface ManagedFileWriteResponse {
ok: boolean;
path: string;
entry: ManagedFileEntry;
root: string | null;
locked_root: string | null;
can_change_path: boolean;
}
export interface AnalyticsDailyEntry {
day: string;
input_tokens: number;
@ -1634,6 +1820,7 @@ export interface CronJob {
name?: string | null;
prompt?: string | null;
script?: string | null;
skills?: string[] | null;
schedule?: { kind?: string; expr?: string; display?: string };
schedule_display?: string | null;
enabled: boolean;
@ -1651,6 +1838,29 @@ export interface CronDeliveryTarget {
home_env_var: string | null;
}
export interface AutomationBlueprintField {
name: string;
type: "time" | "enum" | "text" | "weekdays";
label: string;
default: string | null;
options: string[];
optional: boolean;
/** When false, options are suggestions — any value is accepted. */
strict?: boolean;
help: string;
}
export interface AutomationBlueprint {
key: string;
title: string;
description: string;
category: string;
tags: string[];
fields: AutomationBlueprintField[];
command: string;
appUrl: string;
}
export interface SkillInfo {
name: string;
description: string;
@ -1658,6 +1868,19 @@ export interface SkillInfo {
enabled: boolean;
}
export interface SkillContent {
name: string;
content: string;
path: string;
}
export interface SkillWriteResult {
success: boolean;
message?: string;
path?: string;
error?: string;
}
export interface ToolsetInfo {
name: string;
label: string;
@ -1763,9 +1986,12 @@ export interface AuxiliaryModelsResponse {
}
export interface ModelAssignmentRequest {
confirm_expensive_model?: boolean;
scope: "main" | "auxiliary";
provider: string;
model: string;
/** Optional OpenAI-compatible endpoint URL for custom/local main providers. */
base_url?: string;
/** For auxiliary: task slot name, "" for all, "__reset__" to reset all. */
task?: string;
}
@ -1779,6 +2005,8 @@ export interface StaleAuxAssignment {
}
export interface ModelAssignmentResponse {
confirm_message?: string;
confirm_required?: boolean;
ok: boolean;
scope?: string;
provider?: string;

View file

@ -599,6 +599,32 @@ function TelegramOnboardingPanel({
setNewAllowedId("");
};
// restart_started only means the `hermes gateway restart` child spawned —
// not that the restart will succeed (e.g. systemd linger missing, service
// manager failure). Poll the action status briefly and surface a non-zero
// exit via the manual-restart banner. Note: in no-service installs the
// child becomes the foreground gateway and never exits, so "still running
// when the window closes" counts as success.
const watchRestartOutcome = async () => {
for (let i = 0; i < 20; i++) {
await new Promise((resolve) => setTimeout(resolve, 1500));
try {
const st = await api.getActionStatus("gateway-restart", 5);
if (st.running) continue;
if (st.exit_code !== 0 && st.exit_code !== null) {
onRestartNeeded();
showToast(
`Gateway restart failed (exit ${st.exit_code}) — restart manually`,
"error",
);
}
return;
} catch {
// transient fetch error; keep polling
}
}
};
const apply = async () => {
if (!setup) return;
if (allowedIds.length === 0) {
@ -608,19 +634,29 @@ function TelegramOnboardingPanel({
setPhase("applying");
setError("");
try {
await api.applyTelegramOnboarding(setup.pairing_id, {
const result = await api.applyTelegramOnboarding(setup.pairing_id, {
allowed_user_ids: allowedIds,
});
resetSetup();
showToast("Telegram saved", "success");
try {
await api.restartGateway();
showToast("Gateway restarting…", "success");
if (result.restart_started) {
showToast("Telegram saved; gateway restarting…", "success");
setRestartNeeded(false);
setTimeout(() => void onChanged(), 4000);
} catch (restartError) {
void watchRestartOutcome();
} else if (result.restart_started === undefined && result.needs_restart) {
try {
await api.restartGateway();
showToast("Telegram saved; gateway restarting…", "success");
setRestartNeeded(false);
setTimeout(() => void onChanged(), 4000);
} catch (restartError) {
onRestartNeeded();
showToast(`Telegram saved; gateway restart failed: ${restartError}`, "error");
}
} else {
onRestartNeeded();
showToast(`Telegram saved; restart failed: ${restartError}`, "error");
const detail = result.restart_error ? `: ${result.restart_error}` : "";
showToast(`Telegram saved; gateway restart failed${detail}`, "error");
}
await onChanged();
} catch (applyError) {

View file

@ -37,11 +37,13 @@ import { useI18n } from "@/i18n";
import { api } from "@/lib/api";
import { PluginSlot } from "@/plugins";
import { useTheme } from "@/themes";
import { useProfileScope } from "@/contexts/useProfileScope";
function buildWsUrl(
authParam: [string, string],
resume: string | null,
channel: string,
profile: string,
): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
// ``authParam`` is ``["token", <session>]`` in loopback mode and
@ -49,6 +51,10 @@ function buildWsUrl(
// ``_ws_auth_ok`` picks whichever shape matches the current gate state.
const qs = new URLSearchParams({ [authParam[0]]: authParam[1], channel });
if (resume) qs.set("resume", resume);
// Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the
// selected profile, so the conversation runs with that profile's model,
// skills, memory, and sessions (see web_server._resolve_chat_argv).
if (profile) qs.set("profile", profile);
return `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/pty?${qs.toString()}`;
}
@ -173,7 +179,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// treat the current resume target as part of the PTY identity and rebuild the
// terminal session when it changes.
const resumeParam = searchParams.get("resume");
const channel = useMemo(() => generateChannelId(), [resumeParam]);
// Profile-scoped chat: spawn the PTY under the globally selected
// management profile. Changing it remounts the terminal (key below /
// effect dep) so the user explicitly starts a fresh scoped session.
const { profile: scopedProfile } = useProfileScope();
const channel = useMemo(() => generateChannelId(), [resumeParam, scopedProfile]);
useEffect(() => {
if (!resumeParam) return;
@ -576,7 +586,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
void (async () => {
const authParam = await buildWsAuthParam();
if (unmounting) return;
const url = buildWsUrl(authParam, resumeParam, channel);
const url = buildWsUrl(authParam, resumeParam, channel, scopedProfile);
const ws = new WebSocket(url);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
@ -714,7 +724,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
copyResetRef.current = null;
}
};
}, [channel, resumeParam]);
}, [channel, resumeParam, scopedProfile]);
// When the user returns to the chat tab (isActive: false → true), the
// terminal host just transitioned from display:none to display:flex.

View file

@ -177,9 +177,19 @@ export default function ConfigPage() {
.getDefaults()
.then(setDefaults)
.catch(() => {});
// getConfigRaw is profile-scoped (fetchJSON appends ?profile=), so its
// `path` reflects the switched profile's config.yaml. /api/status's
// config_path is machine-global (the dashboard's own profile) — wrong
// header under the global profile switcher, so it's only a fallback.
api
.getConfigRaw()
.then((resp) => {
if (resp.path) setConfigPath(resp.path);
})
.catch(() => {});
api
.getStatus()
.then((resp) => setConfigPath(resp.config_path))
.then((resp) => setConfigPath((prev) => prev ?? resp.config_path))
.catch(() => {});
}, []);

View file

@ -6,7 +6,7 @@ import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api } from "@/lib/api";
import type { CronJob, CronDeliveryTarget, ProfileInfo } from "@/lib/api";
import type { CronJob, CronDeliveryTarget, ProfileInfo, SkillInfo } from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import {
DEFAULT_SCHEDULE_STATE,
@ -29,6 +29,8 @@ import { Label } from "@nous-research/ui/ui/components/label";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
import { Segmented } from "@nous-research/ui/ui/components/segmented";
import { AutomationBlueprints } from "@/components/AutomationBlueprints";
import { cn, themedBody } from "@/lib/utils";
function formatTime(iso?: string | null): string {
@ -51,6 +53,63 @@ function getJobPrompt(job: CronJob): string {
return asText(job.prompt);
}
/** Compact multi-select for attaching skills to a cron job.
*
* A checkbox list (native inputs the `onValueChange` rule is Select-only)
* capped to a scrollable box. Skills already on the job but missing from the
* available list (e.g. removed from disk, or the job was created via CLI in
* another profile) are still rendered so saving doesn't silently drop them.
*/
function SkillsPicker({
id,
available,
selected,
onChange,
emptyLabel,
}: {
id: string;
available: SkillInfo[];
selected: string[];
onChange: (skills: string[]) => void;
emptyLabel: string;
}) {
const names = available.map((s) => s.name);
const orphaned = selected.filter((s) => !names.includes(s));
const all = [...orphaned.map((name) => ({ name, description: "" })), ...available];
if (all.length === 0) {
return <p className="text-xs text-muted-foreground">{emptyLabel}</p>;
}
const toggle = (name: string, checked: boolean) => {
if (checked) onChange([...selected, name]);
else onChange(selected.filter((s) => s !== name));
};
return (
<div
id={id}
className="max-h-36 overflow-y-auto border border-border bg-background/40 p-1"
>
{all.map((skill) => (
<label
key={skill.name}
className="flex cursor-pointer items-center gap-2 px-2 py-1 text-xs hover:bg-muted/40"
title={skill.description || undefined}
>
<input
type="checkbox"
className="accent-foreground"
checked={selected.includes(skill.name)}
onChange={(e) => toggle(skill.name, e.target.checked)}
/>
<span className="font-mono-ui truncate">{skill.name}</span>
</label>
))}
</div>
);
}
function getJobName(job: CronJob): string {
return asText(job.name).trim();
}
@ -119,6 +178,7 @@ export default function CronPage() {
const [jobs, setJobs] = useState<CronJob[]>([]);
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
const [selectedProfile, setSelectedProfile] = useState("all");
const [view, setView] = useState<"jobs" | "blueprints">("jobs");
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
const { t, locale } = useI18n();
@ -157,6 +217,7 @@ export default function CronPage() {
onClose: closeCreateModal,
});
const [deliver, setDeliver] = useState("local");
const [jobSkills, setJobSkills] = useState<string[]>([]);
const [deliveryTargets, setDeliveryTargets] = useState<CronDeliveryTarget[]>([
{ id: "local", name: "Local", home_target_set: true, home_env_var: null },
]);
@ -169,6 +230,7 @@ export default function CronPage() {
const [editSchedule, setEditSchedule] = useState("");
const [editName, setEditName] = useState("");
const [editDeliver, setEditDeliver] = useState("local");
const [editSkills, setEditSkills] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const closeEditModal = useCallback(() => setEditJob(null), []);
const editModalRef = useModalBehavior({
@ -176,6 +238,12 @@ export default function CronPage() {
onClose: closeEditModal,
});
// Skills installed in the profile a job will run under, for the
// attach-skill selector (parity with `hermes cron edit --add-skill`).
// Keyed on the create-modal profile; the edit modal reuses the list —
// a job's current skills are always shown even if not in it.
const [availableSkills, setAvailableSkills] = useState<SkillInfo[]>([]);
const openEditModal = useCallback((job: CronJob) => {
setEditJob(job);
setEditPrompt(getJobPrompt(job));
@ -184,6 +252,7 @@ export default function CronPage() {
);
setEditName(getJobName(job));
setEditDeliver(asText(job.deliver) || "local");
setEditSkills(Array.isArray(job.skills) ? job.skills.filter(Boolean) : []);
}, []);
const loadJobs = useCallback(() => {
@ -217,6 +286,25 @@ export default function CronPage() {
loadJobs();
}, [loadJobs]);
// Load installed skills for the profile new jobs will be created under.
// "" / "default" maps to the dashboard's own profile via the optional
// ?profile= scoping on /api/skills.
useEffect(() => {
let cancelled = false;
api
.getSkills(createProfile === "default" ? undefined : createProfile)
.then((s) => {
if (!cancelled)
setAvailableSkills(
[...s].sort((a, b) => a.name.localeCompare(b.name)),
);
})
.catch(() => !cancelled && setAvailableSkills([]));
return () => {
cancelled = true;
};
}, [createProfile]);
const scheduleString = buildScheduleString(scheduleState);
// Label for a delivery option. Configured platforms missing their cron home
@ -284,6 +372,7 @@ export default function CronPage() {
schedule: scheduleString,
name: name.trim() || undefined,
deliver,
skills: jobSkills.length > 0 ? jobSkills : undefined,
},
createProfile,
);
@ -292,6 +381,7 @@ export default function CronPage() {
setScheduleState(DEFAULT_SCHEDULE_STATE);
setName("");
setDeliver("local");
setJobSkills([]);
setCreateModalOpen(false);
loadJobs();
} catch (e) {
@ -316,6 +406,7 @@ export default function CronPage() {
schedule: editSchedule.trim(),
name: editName.trim(),
deliver: editDeliver,
skills: editSkills,
},
getJobProfile(editJob),
);
@ -419,6 +510,23 @@ export default function CronPage() {
<PluginSlot name="cron:top" />
<Toast toast={toast} />
<Segmented
value={view}
onChange={(v) => setView(v as "jobs" | "blueprints")}
options={[
{ value: "jobs", label: "Jobs" },
{ value: "blueprints", label: "Blueprints" },
]}
/>
{view === "blueprints" && (
<AutomationBlueprints
profile={selectedProfile === "all" ? "default" : selectedProfile}
onCreated={loadJobs}
/>
)}
<DeleteConfirmDialog
open={jobDelete.isOpen}
onCancel={jobDelete.cancel}
@ -524,6 +632,21 @@ export default function CronPage() {
)}
</div>
<div className="grid gap-2">
<Label htmlFor="cron-skills">Skills (optional)</Label>
<SkillsPicker
id="cron-skills"
available={availableSkills}
selected={jobSkills}
onChange={setJobSkills}
emptyLabel="No skills installed for this profile."
/>
<p className="text-xs text-muted-foreground">
Selected skills are loaded before the prompt runs the cron
sets when, the skill sets how.
</p>
</div>
<div className="flex justify-end">
<Button
className="uppercase"
@ -616,6 +739,17 @@ export default function CronPage() {
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-cron-skills">Skills</Label>
<SkillsPicker
id="edit-cron-skills"
available={availableSkills}
selected={editSkills}
onChange={setEditSkills}
emptyLabel="No skills installed for this profile."
/>
</div>
<div className="flex justify-end">
<Button
className="uppercase"
@ -632,6 +766,7 @@ export default function CronPage() {
</div>
)}
{view === "jobs" && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<H2
@ -691,6 +826,13 @@ export default function CronPage() {
{deliver && deliver !== "local" && (
<Badge tone="outline">{deliver}</Badge>
)}
{Array.isArray(job.skills) && job.skills.length > 0 && (
<Badge tone="outline" title={job.skills.join(", ")}>
{job.skills.length === 1
? job.skills[0]
: `${job.skills.length} skills`}
</Badge>
)}
</div>
{hasName && promptText && (
<p className="text-xs text-muted-foreground truncate mb-1">
@ -767,6 +909,7 @@ export default function CronPage() {
);
})}
</div>
)}
<PluginSlot name="cron:bottom" />
</div>

538
web/src/pages/FilesPage.tsx Normal file
View file

@ -0,0 +1,538 @@
import {
useCallback,
useEffect,
useRef,
useState,
type DragEvent as ReactDragEvent,
} from "react";
import {
ArrowUp,
Download,
FileIcon,
Folder,
FolderOpen,
FolderPlus,
RefreshCw,
Trash2,
Upload,
} from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@nous-research/ui/ui/components/dialog";
import { Input } from "@nous-research/ui/ui/components/input";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { usePageHeader } from "@/contexts/usePageHeader";
import { api } from "@/lib/api";
import type { ManagedFileEntry, ManagedFilesResponse } from "@/lib/api";
import { PluginSlot } from "@/plugins";
const DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
function joinPath(base: string, name: string): string {
const cleanName = name.trim().replace(/^[\\/]+/, "");
if (!cleanName) return base;
const separator = base.includes("\\") && !base.includes("/") ? "\\" : "/";
if (!base || base.endsWith("/") || base.endsWith("\\")) return `${base}${cleanName}`;
return `${base}${separator}${cleanName}`;
}
function formatBytes(size: number | null): string {
if (size === null) return "-";
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function readAsDataUrl(file: globalThis.File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => {
if (typeof reader.result === "string") resolve(reader.result);
else reject(new Error("Could not read file"));
});
reader.addEventListener("error", () => reject(reader.error ?? new Error("Could not read file")));
reader.readAsDataURL(file);
});
}
function downloadDataUrl(dataUrl: string, name: string) {
const link = document.createElement("a");
link.href = dataUrl;
link.download = name || "download";
document.body.appendChild(link);
link.click();
link.remove();
}
function displayPath(path: string | null | undefined): string {
return path?.trim() || "Files";
}
function transferHasFiles(event: ReactDragEvent<HTMLElement>): boolean {
return Array.from(event.dataTransfer.types).includes("Files");
}
export default function FilesPage() {
const { toast, showToast } = useToast();
const { setAfterTitle, setEnd } = usePageHeader();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const dragDepthRef = useRef(0);
const [currentPath, setCurrentPath] = useState<string | undefined>(undefined);
const [pathInput, setPathInput] = useState("");
const [listing, setListing] = useState<ManagedFilesResponse | null>(null);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [draggingFiles, setDraggingFiles] = useState(false);
const [creating, setCreating] = useState(false);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [folderName, setFolderName] = useState("");
const [pendingDelete, setPendingDelete] = useState<ManagedFileEntry | null>(null);
const [error, setError] = useState<string | null>(null);
const activePath = listing?.path ?? currentPath ?? "";
const canChangePath = listing?.can_change_path ?? false;
const canUpload = Boolean(activePath) && !uploading;
const headerPath = displayPath(listing?.locked_root ?? listing?.path ?? currentPath);
const load = useCallback(
async (path = currentPath) => {
setLoading(true);
setError(null);
try {
const result = await api.listFiles(path);
setListing(result);
setCurrentPath(result.path);
setPathInput(result.path);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
},
[currentPath],
);
useEffect(() => {
// Existing dashboard data pages fetch from effects; keep this local and explicit
// until the shared lint profile is updated for async page loaders.
// eslint-disable-next-line react-hooks/set-state-in-effect
void load(currentPath);
}, [currentPath]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
setAfterTitle(
<Badge tone="outline" className="max-w-[22rem] truncate text-xs" title={headerPath}>
{headerPath}
</Badge>,
);
setEnd(
<div className="flex items-center gap-2">
<Button
ghost
size="icon"
type="button"
onClick={() => void load()}
disabled={loading}
aria-label="Refresh files"
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
</div>,
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [headerPath, load, loading, setAfterTitle, setEnd]);
const openDirectory = (entry: ManagedFileEntry) => {
if (entry.is_directory) {
setCurrentPath(entry.path);
}
};
const goToPath = async () => {
const nextPath = pathInput.trim();
if (!nextPath) {
showToast("Path required", "error");
return;
}
await load(nextPath);
};
const createDirectory = async () => {
const name = folderName.trim();
if (!activePath) {
showToast("Directory unavailable", "error");
return;
}
if (!name) {
showToast("Folder name required", "error");
return;
}
setCreating(true);
try {
await api.createDirectory(joinPath(activePath, name));
setFolderName("");
setCreateDialogOpen(false);
showToast("Folder created", "success");
await load();
} catch (e) {
showToast(`Create failed: ${e}`, "error");
} finally {
setCreating(false);
}
};
const uploadFiles = async (files: FileList | null) => {
if (!files?.length) return;
setUploading(true);
try {
for (const file of Array.from(files)) {
const dataUrl = await readAsDataUrl(file);
await api.uploadFile(joinPath(activePath, file.name), dataUrl, true);
}
showToast(`${files.length} file${files.length === 1 ? "" : "s"} uploaded`, "success");
await load();
} catch (e) {
showToast(`Upload failed: ${e}`, "error");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const handleDragEnter = (event: ReactDragEvent<HTMLElement>) => {
if (!canUpload || !transferHasFiles(event)) return;
event.preventDefault();
dragDepthRef.current += 1;
setDraggingFiles(true);
};
const handleDragOver = (event: ReactDragEvent<HTMLElement>) => {
if (!canUpload || !transferHasFiles(event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
};
const handleDragLeave = (event: ReactDragEvent<HTMLElement>) => {
if (!canUpload || !transferHasFiles(event)) return;
event.preventDefault();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) {
setDraggingFiles(false);
}
};
const handleDrop = (event: ReactDragEvent<HTMLElement>) => {
if (!canUpload) return;
event.preventDefault();
dragDepthRef.current = 0;
setDraggingFiles(false);
void uploadFiles(event.dataTransfer.files);
};
const downloadFile = async (entry: ManagedFileEntry) => {
if (entry.is_directory) return;
try {
const file = await api.readFile(entry.path);
downloadDataUrl(file.data_url, file.name);
} catch (e) {
showToast(`Download failed: ${e}`, "error");
}
};
const confirmDelete = async () => {
if (!pendingDelete) return;
setDeleting(true);
try {
await api.deleteFile(pendingDelete.path, pendingDelete.is_directory);
showToast("Deleted", "success");
setPendingDelete(null);
await load();
} catch (e) {
showToast(`Delete failed: ${e}`, "error");
} finally {
setDeleting(false);
}
};
return (
<div className="flex min-w-0 max-w-full flex-col gap-4">
<Toast toast={toast} />
<PluginSlot name="files:top" />
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => void uploadFiles(event.currentTarget.files)}
/>
<div className="flex min-w-0 flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
{canChangePath ? (
<form
className="flex min-w-0 flex-1 items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
void goToPath();
}}
>
<Input
value={pathInput}
onChange={(event) => setPathInput(event.target.value)}
aria-label="Path"
placeholder="Path"
className="h-9 min-w-0 flex-1 font-mono"
/>
<Button type="submit" size="sm" outlined className="uppercase">
Go
</Button>
</form>
) : (
<div className="min-w-0 truncate font-mono text-sm text-text-secondary" title={activePath}>
{activePath}
</div>
)}
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={!canUpload}
size="sm"
outlined
className="uppercase"
prefix={uploading ? <Spinner /> : <Upload />}
>
Upload
</Button>
<Button
type="button"
onClick={() => setCreateDialogOpen(true)}
disabled={!activePath}
size="sm"
outlined
className="uppercase"
prefix={<FolderPlus />}
>
Create
</Button>
</div>
</div>
<button
type="button"
onClick={() => canUpload && fileInputRef.current?.click()}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
disabled={!canUpload}
aria-label="Upload files"
className={`flex min-h-20 w-full min-w-0 items-center justify-between gap-4 border border-dashed px-4 py-3 text-left transition ${
draggingFiles
? "border-primary bg-primary/10 text-foreground"
: "border-border bg-background/20 text-text-secondary hover:border-text-tertiary hover:bg-background/35"
} disabled:cursor-not-allowed disabled:opacity-60`}
>
<span className="flex min-w-0 items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center border border-border bg-background/45 text-text-tertiary">
{uploading ? <Spinner /> : <Upload className="h-4 w-4" />}
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold uppercase tracking-[0.08em] text-foreground">
{uploading ? "Uploading" : draggingFiles ? "Release to upload" : "Drop files here"}
</span>
<span className="block truncate font-mono text-xs text-text-secondary" title={activePath}>
{activePath || "Loading"}
</span>
</span>
</span>
<span className="hidden shrink-0 text-xs font-semibold uppercase tracking-[0.08em] text-text-tertiary sm:block">
Choose files
</span>
</button>
<Card className="min-w-0 max-w-full overflow-hidden">
<CardContent className="overflow-x-auto p-0">
{error && (
<div className="border-b border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="grid min-w-[42rem] grid-cols-[minmax(12rem,1fr)_7rem_10rem_5.5rem] items-center gap-3 border-b border-border px-4 py-2 text-xs font-semibold uppercase tracking-[0.08em] text-text-tertiary">
<span>Name</span>
<span>Size</span>
<span>Modified</span>
<span className="text-right">Actions</span>
</div>
{listing?.parent && (
<button
type="button"
onClick={() => setCurrentPath(listing.parent ?? undefined)}
className="grid w-full min-w-[42rem] grid-cols-[minmax(12rem,1fr)_7rem_10rem_5.5rem] items-center gap-3 border-b border-border/60 px-4 py-2 text-left text-sm transition hover:bg-background/40"
>
<span className="flex min-w-0 items-center gap-2 font-mono text-text-secondary">
<ArrowUp className="h-4 w-4 shrink-0 text-text-tertiary" />
..
</span>
<span />
<span />
<span />
</button>
)}
{loading && !listing ? (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
<Spinner />
Loading files...
</div>
) : listing && listing.entries.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">No files</div>
) : (
listing?.entries.map((entry) => (
<div
key={entry.path}
className="grid min-w-[42rem] grid-cols-[minmax(12rem,1fr)_7rem_10rem_5.5rem] items-center gap-3 border-b border-border/60 px-4 py-2 text-sm last:border-b-0 hover:bg-background/35"
>
<button
type="button"
onClick={() => (entry.is_directory ? openDirectory(entry) : void downloadFile(entry))}
className="flex min-w-0 items-center gap-2 text-left font-mono text-foreground"
>
{entry.is_directory ? (
<Folder className="h-4 w-4 shrink-0 text-warning" />
) : (
<FileIcon className="h-4 w-4 shrink-0 text-text-tertiary" />
)}
<span className="truncate">{entry.name}</span>
</button>
<span className="text-xs tabular-nums text-text-secondary">{formatBytes(entry.size)}</span>
<span className="truncate text-xs text-text-secondary">
{Number.isFinite(entry.mtime) ? DATE_FORMAT.format(entry.mtime * 1000) : "-"}
</span>
<span className="flex justify-end gap-1">
{entry.is_directory ? (
<Button
ghost
size="icon"
type="button"
onClick={() => openDirectory(entry)}
aria-label={`Open ${entry.name}`}
>
<FolderOpen />
</Button>
) : (
<Button
ghost
size="icon"
type="button"
onClick={() => void downloadFile(entry)}
aria-label={`Download ${entry.name}`}
>
<Download />
</Button>
)}
<Button
ghost
size="icon"
type="button"
onClick={() => setPendingDelete(entry)}
aria-label={`Delete ${entry.name}`}
className="text-destructive hover:text-destructive"
>
<Trash2 />
</Button>
</span>
</div>
))
)}
</CardContent>
</Card>
<PluginSlot name="files:bottom" />
<Dialog
open={createDialogOpen}
onOpenChange={(open) => {
if (creating) return;
setCreateDialogOpen(open);
if (!open) setFolderName("");
}}
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Create folder</DialogTitle>
<DialogDescription>
Target: {activePath || "Loading"}
</DialogDescription>
</DialogHeader>
<div className="p-4">
<Input
autoFocus
value={folderName}
onChange={(event) => setFolderName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void createDirectory();
}}
placeholder="Folder name"
disabled={creating}
/>
</div>
<DialogFooter>
<Button
type="button"
outlined
onClick={() => {
setCreateDialogOpen(false);
setFolderName("");
}}
disabled={creating}
>
Cancel
</Button>
<Button
type="button"
onClick={() => void createDirectory()}
disabled={creating}
prefix={creating ? <Spinner /> : <FolderPlus />}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DeleteConfirmDialog
open={Boolean(pendingDelete)}
loading={deleting}
onCancel={() => setPendingDelete(null)}
onConfirm={() => void confirmDelete()}
title={pendingDelete ? `Delete ${pendingDelete.name}?` : "Delete item?"}
description={
pendingDelete?.is_directory
? "This removes the folder and everything inside it."
: "This removes the file."
}
/>
</div>
);
}

View file

@ -26,7 +26,7 @@ import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Stats } from "@nous-research/ui/ui/components/stats";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { useModalBehavior } from "@/hooks/useModalBehavior";
import { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n";
@ -209,10 +209,16 @@ function UseAsMenu({
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pendingConfirm, setPendingConfirm] = useState<{
message: string;
scope: "main" | "auxiliary";
task: string;
} | null>(null);
const assign = async (
scope: "main" | "auxiliary",
task: string,
confirmExpensiveModel = false,
) => {
if (!provider || !model) {
setError("Missing provider/model");
@ -221,7 +227,23 @@ function UseAsMenu({
setBusy(true);
setError(null);
try {
await api.setModelAssignment({ scope, provider, model, task });
const result = await api.setModelAssignment({
confirm_expensive_model: confirmExpensiveModel,
scope,
provider,
model,
task,
});
if (result.confirm_required) {
setPendingConfirm({
scope,
task,
message:
result.confirm_message ||
"This model has unusually high known pricing.",
});
return;
}
onAssigned();
setOpen(false);
} catch (e) {
@ -310,6 +332,22 @@ function UseAsMenu({
)}
</div>
)}
<ConfirmDialog
open={!!pendingConfirm}
title="Expensive Model Warning"
description={pendingConfirm?.message}
destructive
confirmLabel="Switch anyway"
cancelLabel="Cancel"
loading={busy}
onCancel={() => setPendingConfirm(null)}
onConfirm={() => {
const pending = pendingConfirm;
if (!pending) return;
setPendingConfirm(null);
void assign(pending.scope, pending.task, true);
}}
/>
</div>
);
}
@ -619,14 +657,16 @@ function AuxiliaryTasksModal({
AUX_TASKS.find((t) => t.key === picker.task)?.label ??
picker.task
}`}
onApply={async ({ provider, model }) => {
await api.setModelAssignment({
onApply={async ({ provider, model, confirmExpensiveModel }) => {
const result = await api.setModelAssignment({
confirm_expensive_model: confirmExpensiveModel,
scope: "auxiliary",
task: picker.task,
provider,
model,
});
onSaved();
if (!result.confirm_required) onSaved();
return result;
}}
onClose={() => setPicker(null)}
/>
@ -666,14 +706,23 @@ function ModelSettingsPanel({
task,
provider,
model,
confirmExpensiveModel,
}: {
confirmExpensiveModel?: boolean;
scope: "main" | "auxiliary";
task: string;
provider: string;
model: string;
}) => {
await api.setModelAssignment({ scope, task, provider, model });
onSaved();
const result = await api.setModelAssignment({
confirm_expensive_model: confirmExpensiveModel,
scope,
task,
provider,
model,
});
if (!result.confirm_required) onSaved();
return result;
};
// Count how many aux tasks have overrides
@ -749,14 +798,15 @@ function ModelSettingsPanel({
loader={api.getModelOptions}
alwaysGlobal
title="Set Main Model"
onApply={async ({ provider, model }) => {
await applyAssignment({
onApply={({ provider, model, confirmExpensiveModel }) =>
applyAssignment({
confirmExpensiveModel,
scope: "main",
task: "",
provider,
model,
});
}}
})
}
onClose={() => setPicker(null)}
/>
)}

View file

@ -240,7 +240,7 @@ export default function PluginsPage() {
<Input
className="font-mono-ui lowercase"
id="install-url"
placeholder="owner/repo or https://..."
placeholder="owner/repo, owner/repo/subdir, or https://..."
spellCheck={false}
value={installId}
onChange={(e) => setInstallId(e.target.value)}

View file

@ -0,0 +1,611 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { api } from "@/lib/api";
import type { McpServerCreate, SkillInfo, SkillHubResult } from "@/lib/api";
import { cn } from "@/lib/utils";
// Profile name rule mirrors the backend (`^[a-z0-9][a-z0-9_-]{0,63}$`).
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
type StepId = "identity" | "model" | "skills" | "mcp" | "review";
const STEPS: { id: StepId; label: string }[] = [
{ id: "identity", label: "Identity" },
{ id: "model", label: "Model" },
{ id: "skills", label: "Skills" },
{ id: "mcp", label: "MCPs" },
{ id: "review", label: "Review" },
];
interface ModelChoice {
provider: string;
model: string;
label: string;
}
/**
* Dashboard-native, full-featured profile builder.
*
* Composes the same elements the standalone Models / Skills / MCP pages
* manage Name, Description, Model+Provider, Skills (built-in/optional +
* hub), MCP servers into one stepped create flow. Nothing is written to
* disk until "Create profile" on the final step; the single POST /api/profiles
* call commits model + MCPs + skill selection synchronously and spawns any
* hub-skill installs (which the success toast reports as in-progress).
*
* Skills use REPLACE semantics: the default bundle is seeded server-side, then
* every seeded skill the user did NOT keep is disabled. The "Start from full
* bundle" toggle keeps everything (sends no keep list).
*/
export default function ProfileBuilderPage() {
const navigate = useNavigate();
const { toast, showToast } = useToast();
const [step, setStep] = useState<StepId>("identity");
// ── Step 1: identity ──────────────────────────────────────────────
const [name, setName] = useState("");
const [description, setDescription] = useState("");
// ── Step 2: model ─────────────────────────────────────────────────
const [modelChoices, setModelChoices] = useState<ModelChoice[] | null>(null);
const [modelChoice, setModelChoice] = useState(""); // `${provider}\u0000${model}`
const [modelFilter, setModelFilter] = useState("");
const modelLoading = useRef(false);
// ── Step 3: skills ────────────────────────────────────────────────
const [skills, setSkills] = useState<SkillInfo[] | null>(null);
// keepAll = true: don't send a keep list (full bundle stays active).
const [keepAll, setKeepAll] = useState(true);
const [keptSkills, setKeptSkills] = useState<Set<string>>(new Set());
const [skillFilter, setSkillFilter] = useState("");
const skillsLoading = useRef(false);
// Hub search
const [hubQuery, setHubQuery] = useState("");
const [hubResults, setHubResults] = useState<SkillHubResult[]>([]);
const [hubSearching, setHubSearching] = useState(false);
const [hubSkills, setHubSkills] = useState<SkillHubResult[]>([]);
// ── Step 4: MCPs ──────────────────────────────────────────────────
const [mcpServers, setMcpServers] = useState<McpServerCreate[]>([]);
const [mcpDraft, setMcpDraft] = useState<{
name: string;
url: string;
command: string;
args: string;
}>({ name: "", url: "", command: "", args: "" });
// ── Submit ────────────────────────────────────────────────────────
const [creating, setCreating] = useState(false);
const nameValid = PROFILE_NAME_RE.test(name.trim());
// Lazy-load model choices when the model step is first shown.
const loadModels = useCallback(() => {
if (modelChoices !== null || modelLoading.current) return;
modelLoading.current = true;
api
.getModelOptions()
.then((res) => {
const flat: ModelChoice[] = [];
for (const prov of res.providers ?? []) {
for (const m of prov.models ?? []) {
flat.push({ provider: prov.slug, model: m, label: `${prov.name} · ${m}` });
}
}
setModelChoices(flat);
})
.catch(() => setModelChoices([]))
.finally(() => {
modelLoading.current = false;
});
}, [modelChoices]);
const loadSkills = useCallback(() => {
if (skills !== null || skillsLoading.current) return;
skillsLoading.current = true;
api
.getSkills()
.then((res) => {
setSkills(res);
// Default keep = all currently-enabled skills (matches the seeded set).
setKeptSkills(new Set(res.filter((s) => s.enabled).map((s) => s.name)));
})
.catch(() => setSkills([]))
.finally(() => {
skillsLoading.current = false;
});
}, [skills]);
useEffect(() => {
if (step === "model") loadModels();
if (step === "skills") loadSkills();
}, [step, loadModels, loadSkills]);
const runHubSearch = useCallback(() => {
const q = hubQuery.trim();
if (!q) return;
setHubSearching(true);
api
.searchSkillsHub(q, "all", 20)
.then((res) => setHubResults(res.results ?? []))
.catch(() => setHubResults([]))
.finally(() => setHubSearching(false));
}, [hubQuery]);
const toggleKeep = (skillName: string) => {
setKeptSkills((prev) => {
const next = new Set(prev);
if (next.has(skillName)) next.delete(skillName);
else next.add(skillName);
return next;
});
};
const addHubSkill = (r: SkillHubResult) => {
setHubSkills((prev) =>
prev.some((x) => x.identifier === r.identifier) ? prev : [...prev, r],
);
};
const removeHubSkill = (identifier: string) =>
setHubSkills((prev) => prev.filter((x) => x.identifier !== identifier));
const addMcpDraft = () => {
const n = mcpDraft.name.trim();
if (!n) {
showToast("MCP server needs a name", "error");
return;
}
if (!mcpDraft.url.trim() && !mcpDraft.command.trim()) {
showToast("Give the MCP server a URL or a command", "error");
return;
}
const entry: McpServerCreate = { name: n };
if (mcpDraft.url.trim()) entry.url = mcpDraft.url.trim();
if (mcpDraft.command.trim()) {
entry.command = mcpDraft.command.trim();
const args = mcpDraft.args.trim();
if (args) entry.args = args.split(/\s+/);
}
setMcpServers((prev) => [...prev.filter((s) => s.name !== n), entry]);
setMcpDraft({ name: "", url: "", command: "", args: "" });
};
const removeMcp = (n: string) =>
setMcpServers((prev) => prev.filter((s) => s.name !== n));
const filteredModels = useMemo(() => {
if (!modelChoices) return [];
const f = modelFilter.trim().toLowerCase();
if (!f) return modelChoices;
return modelChoices.filter((c) => c.label.toLowerCase().includes(f));
}, [modelChoices, modelFilter]);
const filteredSkills = useMemo(() => {
if (!skills) return [];
const f = skillFilter.trim().toLowerCase();
if (!f) return skills;
return skills.filter(
(s) =>
s.name.toLowerCase().includes(f) ||
(s.description || "").toLowerCase().includes(f) ||
(s.category || "").toLowerCase().includes(f),
);
}, [skills, skillFilter]);
const pickedModel = useMemo(
() =>
modelChoice
? modelChoices?.find((c) => `${c.provider}\u0000${c.model}` === modelChoice)
: undefined,
[modelChoice, modelChoices],
);
const handleCreate = async () => {
const n = name.trim();
if (!PROFILE_NAME_RE.test(n)) {
showToast("Invalid profile name (lowercase, digits, - and _)", "error");
setStep("identity");
return;
}
setCreating(true);
try {
const res = await api.createProfile({
name: n,
clone_from_default: false,
description: description.trim() || undefined,
provider: pickedModel?.provider,
model: pickedModel?.model,
mcp_servers: mcpServers.length ? mcpServers : undefined,
keep_skills: keepAll ? undefined : Array.from(keptSkills),
hub_skills: hubSkills.length ? hubSkills.map((s) => s.identifier) : undefined,
});
const pending = (res.hub_installs ?? []).filter((h) => h.pid).length;
showToast(
pending
? `Profile "${n}" created — ${pending} hub skill${pending === 1 ? "" : "s"} installing`
: `Profile "${n}" created`,
"success",
);
navigate("/profiles");
} catch (e) {
showToast(`Create failed: ${e}`, "error");
} finally {
setCreating(false);
}
};
const stepIndex = STEPS.findIndex((s) => s.id === step);
const canAdvance = step !== "identity" || nameValid;
return (
<div className="mx-auto w-full max-w-3xl space-y-6 p-4">
<div className="flex items-center justify-between">
<H2>New profile</H2>
<Button ghost onClick={() => navigate("/profiles")}>
Cancel
</Button>
</div>
{/* Stepper */}
<div className="flex items-center gap-2 text-sm">
{STEPS.map((s, i) => (
<button
key={s.id}
// Identity must be valid before jumping ahead.
disabled={i > 0 && !nameValid}
onClick={() => setStep(s.id)}
className={cn(
"rounded-full px-3 py-1 transition-colors",
s.id === step
? "bg-primary text-primary-foreground"
: i <= stepIndex
? "bg-muted text-foreground"
: "text-muted-foreground",
i > 0 && !nameValid && "cursor-not-allowed opacity-50",
)}
>
{i + 1}. {s.label}
</button>
))}
</div>
<Card>
<CardContent className="space-y-4 p-5">
{step === "identity" && (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="pb-name">Profile name</Label>
<Input
id="pb-name"
placeholder="coder"
value={name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
/>
{name && !nameValid && (
<p className="text-xs text-destructive">
Lowercase letters, digits, hyphens and underscores; must start with a letter or digit.
</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="pb-desc">Description (optional)</Label>
<Input
id="pb-desc"
placeholder="What this agent profile is for"
value={description}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setDescription(e.target.value)
}
/>
</div>
</div>
)}
{step === "model" && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Pick the model+provider for this profile. Skip to use the default.
</p>
<Input
placeholder="Filter models…"
value={modelFilter}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setModelFilter(e.target.value)
}
/>
{modelChoices === null ? (
<p className="text-sm text-muted-foreground">Loading models</p>
) : (
<div className="max-h-72 space-y-1 overflow-y-auto">
<button
onClick={() => setModelChoice("")}
className={cn(
"block w-full rounded px-3 py-2 text-left text-sm",
modelChoice === "" ? "bg-primary/10" : "hover:bg-muted",
)}
>
Use default (set later)
</button>
{filteredModels.map((c) => {
const key = `${c.provider}\u0000${c.model}`;
return (
<button
key={key}
onClick={() => setModelChoice(key)}
className={cn(
"block w-full rounded px-3 py-2 text-left text-sm",
modelChoice === key ? "bg-primary/10" : "hover:bg-muted",
)}
>
{c.label}
</button>
);
})}
</div>
)}
</div>
)}
{step === "skills" && (
<div className="space-y-4">
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={keepAll}
onCheckedChange={(v) => setKeepAll(Boolean(v))}
/>
Start from the full default skill bundle (recommended)
</label>
{!keepAll && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
Choose which built-in / optional skills to keep active. Unchecked skills are disabled in the new profile.
</p>
<Input
placeholder="Filter skills…"
value={skillFilter}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setSkillFilter(e.target.value)
}
/>
{skills === null ? (
<p className="text-sm text-muted-foreground">Loading skills</p>
) : (
<div className="max-h-56 space-y-1 overflow-y-auto">
{filteredSkills.map((s) => (
<label
key={s.name}
className="flex items-start gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted"
>
<Checkbox
checked={keptSkills.has(s.name)}
onCheckedChange={() => toggleKeep(s.name)}
/>
<span className="flex-1">
<span className="font-medium">{s.name}</span>
{s.category && (
<Badge tone="secondary" className="ml-2">
{s.category}
</Badge>
)}
{s.description && (
<span className="block text-xs text-muted-foreground">
{s.description}
</span>
)}
</span>
</label>
))}
</div>
)}
</div>
)}
{/* Skills hub */}
<div className="space-y-2 border-t pt-4">
<Label>Add from the skills hub</Label>
<div className="flex gap-2">
<Input
placeholder="Search the hub (e.g. linear, hyperliquid)…"
value={hubQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setHubQuery(e.target.value)
}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") runHubSearch();
}}
/>
<Button outlined onClick={runHubSearch} disabled={hubSearching}>
{hubSearching ? "Searching…" : "Search"}
</Button>
</div>
{hubResults.length > 0 && (
<div className="max-h-48 space-y-1 overflow-y-auto">
{hubResults.map((r) => (
<div
key={r.identifier}
className="flex items-center justify-between rounded px-2 py-1.5 text-sm hover:bg-muted"
>
<span className="flex-1">
<span className="font-medium">{r.name}</span>
<Badge tone="secondary" className="ml-2">
{r.source}
</Badge>
{r.description && (
<span className="block text-xs text-muted-foreground">
{r.description}
</span>
)}
</span>
<Button size="sm" ghost onClick={() => addHubSkill(r)}>
Add
</Button>
</div>
))}
</div>
)}
{hubSkills.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{hubSkills.map((r) => (
<Badge key={r.identifier} className="gap-1">
{r.name}
<button
className="ml-1 text-xs"
onClick={() => removeHubSkill(r.identifier)}
aria-label={`Remove ${r.name}`}
>
×
</button>
</Badge>
))}
</div>
)}
</div>
</div>
)}
{step === "mcp" && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Add MCP servers for this profile. HTTP servers take a URL; stdio servers take a command + args.
</p>
<div className="grid grid-cols-2 gap-2">
<Input
placeholder="Server name"
value={mcpDraft.name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({ ...mcpDraft, name: e.target.value })
}
/>
<Input
placeholder="URL (https://…/mcp)"
value={mcpDraft.url}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({ ...mcpDraft, url: e.target.value })
}
/>
<Input
placeholder="Command (e.g. npx)"
value={mcpDraft.command}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({ ...mcpDraft, command: e.target.value })
}
/>
<Input
placeholder="Args (space-separated)"
value={mcpDraft.args}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({ ...mcpDraft, args: e.target.value })
}
/>
</div>
<Button outlined onClick={addMcpDraft}>
Add server
</Button>
{mcpServers.length > 0 && (
<div className="space-y-1">
{mcpServers.map((s) => (
<div
key={s.name}
className="flex items-center justify-between rounded bg-muted px-3 py-1.5 text-sm"
>
<span>
<span className="font-medium">{s.name}</span>{" "}
<span className="text-xs text-muted-foreground">
{s.url || `${s.command} ${(s.args || []).join(" ")}`}
</span>
</span>
<button
className="text-xs text-destructive"
onClick={() => removeMcp(s.name)}
>
Remove
</button>
</div>
))}
</div>
)}
</div>
)}
{step === "review" && (
<div className="space-y-3 text-sm">
<ReviewRow label="Name" value={name.trim() || "—"} />
<ReviewRow label="Description" value={description.trim() || "—"} />
<ReviewRow
label="Model"
value={pickedModel ? pickedModel.label : "Default (set later)"}
/>
<ReviewRow
label="Skills"
value={
keepAll
? "Full default bundle"
: `${keptSkills.size} built-in/optional kept` +
(hubSkills.length ? ` + ${hubSkills.length} hub` : "")
}
/>
{!keepAll && hubSkills.length > 0 && (
<p className="pl-24 text-xs text-muted-foreground">
Hub: {hubSkills.map((s) => s.name).join(", ")}
</p>
)}
{keepAll && hubSkills.length > 0 && (
<ReviewRow
label="Hub skills"
value={hubSkills.map((s) => s.name).join(", ")}
/>
)}
<ReviewRow
label="MCP servers"
value={mcpServers.length ? mcpServers.map((s) => s.name).join(", ") : "None"}
/>
</div>
)}
</CardContent>
</Card>
{/* Nav buttons */}
<div className="flex items-center justify-between">
<Button
ghost
disabled={stepIndex === 0}
onClick={() => setStep(STEPS[Math.max(0, stepIndex - 1)].id)}
>
Back
</Button>
{step === "review" ? (
<Button onClick={handleCreate} disabled={creating || !nameValid}>
{creating ? "Creating…" : "Create profile"}
</Button>
) : (
<Button
disabled={!canAdvance}
onClick={() => setStep(STEPS[Math.min(STEPS.length - 1, stepIndex + 1)].id)}
>
Next
</Button>
)}
</div>
<Toast toast={toast} />
</div>
);
}
function ReviewRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex gap-3">
<span className="w-24 shrink-0 text-muted-foreground">{label}</span>
<span className="flex-1 break-words">{value}</span>
</div>
);
}

View file

@ -6,6 +6,7 @@ import {
useRef,
useState,
} from "react";
import { useNavigate } from "react-router-dom";
import {
AlignLeft,
Check,
@ -95,6 +96,7 @@ function ProfileActionsMenu({
onEditDescription,
onEditModel,
onEditSoul,
onManageSkills,
onRename,
onSetActive,
}: ProfileActionsMenuProps) {
@ -200,6 +202,16 @@ function ProfileActionsMenu({
{labels.editSoul}
</button>
<button
type="button"
role="menuitem"
className={itemClass}
onClick={run(onManageSkills)}
>
<Package className="h-4 w-4" />
{labels.manageSkills}
</button>
<button
type="button"
role="menuitem"
@ -240,6 +252,7 @@ function ProfileActionsMenu({
}
export default function ProfilesPage() {
const navigate = useNavigate();
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
const [activeInfo, setActiveInfo] = useState<ActiveProfileInfo | null>(null);
const [loading, setLoading] = useState(true);
@ -289,6 +302,10 @@ export default function ProfilesPage() {
modelSaved: p.modelSaved ?? "Model updated",
modelSelect: p.modelSelect ?? "Select a model",
actions: p.actions ?? "Actions",
manageSkills: p.manageSkills ?? "Manage skills & tools",
activeSetHint:
p.activeSetHint ??
"Applies to new CLI/gateway runs. This dashboard still manages its own profile — use “Manage skills & tools” to edit {name}.",
};
}, [t.profiles]);
@ -478,7 +495,14 @@ export default function ProfilesPage() {
// The backend normalizes/validates the name; trust the canonical
// value it returns rather than the raw input.
const { active } = await api.setActiveProfile(name);
showToast(`${L.activeSet}: ${active}`, "success");
// "Set as active" only flips the sticky default for FUTURE CLI/gateway
// invocations — it does NOT retarget this running dashboard. Say so,
// or users assume skill/tool toggles now apply to the activated
// profile (they don't — that's what "Manage skills & tools" is for).
showToast(
`${L.activeSet}: ${active}${L.activeSetHint.replace("{name}", active)}`,
"success",
);
setActiveInfo((prev) =>
prev ? { ...prev, active } : { active, current: active },
);
@ -722,21 +746,31 @@ export default function ProfilesPage() {
: base;
})();
// Put "Create" button in page header
// Put "Build" (full builder) + "Create" (quick modal) buttons in header
useLayoutEffect(() => {
setEnd(
<Button
className="uppercase"
size="sm"
onClick={() => setCreateModalOpen(true)}
>
{t.common.create}
</Button>,
<div className="flex items-center gap-2">
<Button
className="uppercase"
size="sm"
outlined
onClick={() => navigate("/profiles/new")}
>
Build
</Button>
<Button
className="uppercase"
size="sm"
onClick={() => setCreateModalOpen(true)}
>
{t.common.create}
</Button>
</div>,
);
return () => {
setEnd(null);
};
}, [setEnd, t.common.create, loading]);
}, [setEnd, t.common.create, loading, navigate]);
const cloning = cloneAll || cloneFromDefault;
@ -782,7 +816,7 @@ export default function ProfilesPage() {
<div
className={cn(
themedBody,
"relative w-full max-w-md border border-border bg-card shadow-2xl flex flex-col max-h-[90vh] overflow-y-auto",
"relative w-full max-w-md border border-border bg-card shadow-2xl flex flex-col max-h-[90vh]",
)}
>
<Button
@ -804,7 +838,7 @@ export default function ProfilesPage() {
</h2>
</header>
<div className="p-5 grid gap-4">
<div className="min-h-0 overflow-y-auto p-5 grid gap-4">
<div className="grid gap-2">
<Label htmlFor="profile-name">{t.profiles.name}</Label>
@ -1098,6 +1132,7 @@ export default function ProfilesPage() {
editModel: L.editModel,
editDescription: L.editDescription,
editSoul: t.profiles.editSoul,
manageSkills: L.manageSkills,
openInTerminal: t.profiles.openInTerminal,
rename: t.profiles.rename,
delete: t.common.delete,
@ -1109,6 +1144,11 @@ export default function ProfilesPage() {
onEditDescription={() => openDescEditor(p)}
onEditModel={() => openModelEditor(p)}
onEditSoul={() => openSoulEditor(p.name)}
onManageSkills={() =>
navigate(
`/skills?profile=${encodeURIComponent(p.name)}`,
)
}
onRename={() => {
setRenamingFrom(p.name);
setRenameTo(p.name);
@ -1195,7 +1235,7 @@ export default function ProfilesPage() {
<div
className={cn(
themedBody,
"relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col max-h-[90vh] overflow-y-auto",
"relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col max-h-[90vh]",
)}
>
<Button
@ -1222,7 +1262,12 @@ export default function ProfilesPage() {
</h2>
</header>
<div className="p-5 grid gap-4">
<div
className={cn(
"p-5 grid gap-4",
editorKind === "soul" && "min-h-0 overflow-y-auto",
)}
>
{editorKind === "model" &&
(modelChoices !== null && modelChoices.length === 0 ? (
<p className="text-xs text-muted-foreground">{L.modelNone}</p>
@ -1358,6 +1403,7 @@ interface ProfileActionsMenuProps {
editDescription: string;
editModel: string;
editSoul: string;
manageSkills: string;
openInTerminal: string;
rename: string;
setActive: string;
@ -1368,6 +1414,7 @@ interface ProfileActionsMenuProps {
onEditDescription: () => void;
onEditModel: () => void;
onEditSoul: () => void;
onManageSkills: () => void;
onRename: () => void;
onSetActive: () => void;
}

View file

@ -25,6 +25,8 @@ import {
AlertTriangle,
Sparkles,
Loader2,
Pencil,
Plus,
} from "lucide-react";
import { api } from "@/lib/api";
import type {
@ -36,7 +38,9 @@ import type {
SkillHubPreview,
SkillHubScan,
} from "@/lib/api";
import { useProfileScope } from "@/contexts/useProfileScope";
import { ToolsetConfigDrawer } from "@/components/ToolsetConfigDrawer";
import { SkillEditorDialog } from "@/components/SkillEditorDialog";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
@ -129,25 +133,50 @@ export default function SkillsPage() {
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
const [configToolset, setConfigToolset] = useState<ToolsetInfo | null>(null);
// Skill editor dialog: open + which skill is being edited (null = create).
const [editorOpen, setEditorOpen] = useState(false);
const [editorSkill, setEditorSkill] = useState<string | null>(null);
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
// ── Profile scoping ──
// The write target comes from the GLOBAL profile switcher (sidebar) via
// ProfileContext — one selector for the whole dashboard, deep-linkable
// as ?profile=<name>. This page just consumes it: the fetchJSON layer
// appends the param automatically; we still pass it explicitly where the
// call signature supports it (clearer, and robust if a caller bypasses
// the auto-injection).
const {
profile: selectedProfile,
} = useProfileScope();
useEffect(() => {
Promise.all([api.getSkills(), api.getToolsets()])
// Promise-chain shape: setState fires only inside async callbacks so the
// effect body stays lint-clean (react-hooks/set-state-in-effect). On a
// profile switch the old list stays visible until the new one arrives.
let cancelled = false;
Promise.all([
api.getSkills(selectedProfile || undefined),
api.getToolsets(selectedProfile || undefined),
])
.then(([s, tsets]) => {
if (cancelled) return;
setSkills(s);
setToolsets(tsets);
})
.catch(() => showToast(t.common.loading, "error"))
.finally(() => setLoading(false));
}, []);
.catch(() => !cancelled && showToast(t.common.loading, "error"))
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [selectedProfile]);
/* ---- Toggle skill ---- */
const handleToggleSkill = async (skill: SkillInfo) => {
setTogglingSkills((prev) => new Set(prev).add(skill.name));
try {
await api.toggleSkill(skill.name, !skill.enabled);
await api.toggleSkill(skill.name, !skill.enabled, selectedProfile || undefined);
setSkills((prev) =>
prev.map((s) =>
s.name === skill.name ? { ...s, enabled: !s.enabled } : s,
@ -178,6 +207,28 @@ export default function SkillsPage() {
}
};
/* ---- Skill editor (create / edit SKILL.md) ---- */
const openCreateEditor = useCallback(() => {
setEditorSkill(null);
setEditorOpen(true);
}, []);
const openEditEditor = useCallback((skillName: string) => {
setEditorSkill(skillName);
setEditorOpen(true);
}, []);
const handleEditorSaved = useCallback(
(skillName: string) => {
showToast(`${skillName} saved ✓`, "success");
// Reload the list so a newly created skill (or an edited description)
// shows up immediately.
api
.getSkills(selectedProfile || undefined)
.then(setSkills)
.catch(() => {});
},
[selectedProfile, showToast],
);
/* ---- Derived data ---- */
const lowerSearch = search.toLowerCase();
const isSearching = search.trim().length > 0;
@ -233,7 +284,7 @@ export default function SkillsPage() {
return;
}
setAfterTitle(
<span className="whitespace-nowrap text-xs text-muted-foreground">
<span className="flex items-center gap-2 whitespace-nowrap text-xs text-muted-foreground">
{t.skills.enabledOf
.replace("{enabled}", String(enabledCount))
.replace("{total}", String(skills.length))}
@ -265,7 +316,15 @@ export default function SkillsPage() {
setAfterTitle(null);
setEnd(null);
};
}, [enabledCount, loading, search, setAfterTitle, setEnd, skills.length, t]);
}, [
enabledCount,
loading,
search,
setAfterTitle,
setEnd,
skills.length,
t,
]);
const filteredToolsets = useMemo(() => {
return toolsets.filter(
@ -405,6 +464,7 @@ export default function SkillsPage() {
skill={skill}
toggling={togglingSkills.has(skill.name)}
onToggle={() => handleToggleSkill(skill)}
onEdit={() => openEditEditor(skill.name)}
noDescriptionLabel={t.skills.noDescription}
/>
))}
@ -426,11 +486,22 @@ export default function SkillsPage() {
)
: t.skills.all}
</CardTitle>
<Badge tone="secondary" className="text-xs">
{t.skills.skillCount
.replace("{count}", String(activeSkills.length))
.replace("{s}", activeSkills.length !== 1 ? "s" : "")}
</Badge>
<div className="flex items-center gap-2">
<Badge tone="secondary" className="text-xs">
{t.skills.skillCount
.replace("{count}", String(activeSkills.length))
.replace("{s}", activeSkills.length !== 1 ? "s" : "")}
</Badge>
<Button
size="xs"
outlined
className="uppercase"
onClick={openCreateEditor}
prefix={<Plus />}
>
New skill
</Button>
</div>
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
@ -448,6 +519,7 @@ export default function SkillsPage() {
skill={skill}
toggling={togglingSkills.has(skill.name)}
onToggle={() => handleToggleSkill(skill)}
onEdit={() => openEditEditor(skill.name)}
noDescriptionLabel={t.skills.noDescription}
/>
))}
@ -540,17 +612,25 @@ export default function SkillsPage() {
)}
</>
) : (
<HubBrowser showToast={showToast} />
<HubBrowser showToast={showToast} profile={selectedProfile || undefined} />
)}
</div>
</div>
{configToolset && (
<ToolsetConfigDrawer
toolset={configToolset}
profile={selectedProfile || undefined}
onClose={() => setConfigToolset(null)}
onChanged={() => void refreshToolsets()}
/>
)}
<SkillEditorDialog
open={editorOpen}
editName={editorSkill}
profile={selectedProfile || undefined}
onClose={() => setEditorOpen(false)}
onSaved={handleEditorSaved}
/>
<PluginSlot name="skills:bottom" />
</div>
);
@ -560,6 +640,7 @@ function SkillRow({
skill,
toggling,
onToggle,
onEdit,
noDescriptionLabel,
}: SkillRowProps) {
return (
@ -585,6 +666,16 @@ function SkillRow({
{skill.description || noDescriptionLabel}
</p>
</div>
<Button
ghost
size="icon"
className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 hover:text-foreground"
title="Edit SKILL.md"
aria-label={`Edit ${skill.name}`}
onClick={onEdit}
>
<Pencil />
</Button>
</div>
);
}
@ -616,6 +707,7 @@ interface PanelItemProps {
interface SkillRowProps {
noDescriptionLabel: string;
onToggle: () => void;
onEdit: () => void;
skill: SkillInfo;
toggling: boolean;
}
@ -668,8 +760,11 @@ const SEVERITY_TONE: Record<string, "destructive" | "warning" | "secondary" | "o
function HubBrowser({
showToast,
profile,
}: {
showToast: (msg: string, kind: "success" | "error") => void;
/** Optional profile scoping installs + installed-state badges. */
profile?: string;
}) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SkillHubResult[]>([]);
@ -699,7 +794,7 @@ function HubBrowser({
useEffect(() => {
let cancelled = false;
api
.getSkillHubSources()
.getSkillHubSources(profile)
.then((r) => {
if (cancelled) return;
setSources(r.sources);
@ -715,7 +810,7 @@ function HubBrowser({
return () => {
cancelled = true;
};
}, []);
}, [profile]);
/* ---- Search ---- */
const runSearch = useCallback(async () => {
@ -725,7 +820,7 @@ function HubBrowser({
setSearched(true);
const t0 = performance.now();
try {
const r = await api.searchSkillsHub(q);
const r = await api.searchSkillsHub(q, "all", 20, profile);
setResults(r.results);
setSourceCounts(r.source_counts || {});
setTimedOut(r.timed_out || []);
@ -739,7 +834,7 @@ function HubBrowser({
setSearchMs(Math.round(performance.now() - t0));
setSearching(false);
}
}, [query, showToast]);
}, [query, showToast, profile]);
/* ---- Poll a spawned action's log until it exits ---- */
useEffect(() => {
@ -757,7 +852,7 @@ function HubBrowser({
} else {
// Install finished — refresh installed-state so badges update.
api
.getSkillHubSources()
.getSkillHubSources(profile)
.then((r) => !cancelled && setInstalled(r.installed))
.catch(() => {});
}
@ -770,12 +865,12 @@ function HubBrowser({
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [action]);
}, [action, profile]);
const install = useCallback(
async (identifier: string) => {
try {
const res = await api.installSkillFromHub(identifier);
const res = await api.installSkillFromHub(identifier, profile);
showToast(`Installing ${identifier}`, "success");
setActionLog([]);
setActionRunning(true);
@ -785,12 +880,12 @@ function HubBrowser({
showToast(`Install failed: ${e}`, "error");
}
},
[showToast],
[showToast, profile],
);
const updateAll = useCallback(async () => {
try {
const res = await api.updateSkillsFromHub();
const res = await api.updateSkillsFromHub(profile);
showToast("Updating installed skills…", "success");
setActionLog([]);
setActionRunning(true);
@ -798,7 +893,7 @@ function HubBrowser({
} catch (e) {
showToast(`Update failed: ${e}`, "error");
}
}, [showToast]);
}, [showToast, profile]);
const isInstalled = useCallback(
(identifier: string) => Boolean(installed[identifier]),

View file

@ -170,6 +170,11 @@ export default function SystemPage() {
const [addingCred, setAddingCred] = useState(false);
const [importPath, setImportPath] = useState("");
// Restore-from-backup is destructive (overwrites the live config) and the
// spawned `hermes import` runs non-interactively (stdin is /dev/null), so
// its CLI "Continue? [y/N]" prompt would auto-abort. The dashboard owns the
// consent: confirm here, then call the endpoint with force=true.
const [importConfirmOpen, setImportConfirmOpen] = useState(false);
// Create-hook modal.
const [hookModalOpen, setHookModalOpen] = useState(false);
@ -1181,11 +1186,24 @@ export default function SystemPage() {
disabled={!importPath.trim()}
onClick={() => {
if (!importPath.trim()) return;
runOp(() => api.runImport(importPath.trim()), "Import");
setImportConfirmOpen(true);
}}
>
Import
</Button>
<ConfirmDialog
open={importConfirmOpen}
title="Restore from backup?"
description={`This will overwrite your current Hermes configuration, skills, sessions, and data with the contents of ${importPath.trim() || "the archive"}. This cannot be undone.`}
destructive
confirmLabel="Restore"
cancelLabel="Cancel"
onCancel={() => setImportConfirmOpen(false)}
onConfirm={() => {
setImportConfirmOpen(false);
runOp(() => api.runImport(importPath.trim(), true), "Import");
}}
/>
</CardContent>
</Card>
</section>

View file

@ -1,5 +1,14 @@
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { Webhook, Plus, Trash2, X, Copy, Check } from "lucide-react";
import {
AlertTriangle,
Check,
Copy,
Plus,
RotateCw,
Trash2,
Webhook,
X,
} from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
@ -51,6 +60,11 @@ function CopyButton({ value }: { value: string }) {
export default function WebhooksPage() {
const [data, setData] = useState<WebhooksResponse | null>(null);
const [loading, setLoading] = useState(true);
const [enabling, setEnabling] = useState(false);
const [restartNeeded, setRestartNeeded] = useState(false);
const [restartMessage, setRestartMessage] = useState<string | null>(null);
const [restartError, setRestartError] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false);
const { toast, showToast } = useToast();
const { setEnd } = usePageHeader();
@ -78,7 +92,7 @@ export default function WebhooksPage() {
const subscriptions = data?.subscriptions ?? [];
const loadWebhooks = useCallback(() => {
api
return api
.getWebhooks()
.then(setData)
.catch(() => showToast("Failed to load webhooks", "error"))
@ -89,6 +103,78 @@ export default function WebhooksPage() {
loadWebhooks();
}, [loadWebhooks]);
const watchRestartOutcome = useCallback(async () => {
for (let i = 0; i < 20; i++) {
await new Promise((resolve) => setTimeout(resolve, 1500));
try {
const st = await api.getActionStatus("gateway-restart", 5);
if (st.running) continue;
if (st.exit_code !== 0 && st.exit_code !== null) {
setRestartMessage(null);
setRestartNeeded(true);
setRestartError(`Gateway restart failed with exit ${st.exit_code}.`);
showToast(
`Gateway restart failed (exit ${st.exit_code}) — restart manually`,
"error",
);
} else {
setRestartMessage(null);
setRestartNeeded(false);
setRestartError(null);
}
return;
} catch {
// The dashboard may briefly lose its connection while the gateway restarts.
}
}
setRestartMessage(null);
}, [showToast]);
const handleRestart = useCallback(async () => {
setRestarting(true);
try {
await api.restartGateway();
setRestartNeeded(false);
setRestartError(null);
setRestartMessage("Gateway restarting…");
showToast("Gateway restarting…", "success");
setTimeout(() => void loadWebhooks(), 4000);
void watchRestartOutcome();
} catch (e) {
setRestartNeeded(true);
setRestartError(String(e));
showToast(`Failed to restart: ${e}`, "error");
} finally {
setRestarting(false);
}
}, [loadWebhooks, showToast, watchRestartOutcome]);
const handleEnableWebhooks = useCallback(async () => {
setEnabling(true);
setRestartNeeded(false);
setRestartError(null);
try {
const result = await api.enableWebhooks();
await loadWebhooks();
if (result.restart_started) {
setRestartMessage("Webhooks enabled; gateway restarting…");
showToast("Webhooks enabled; gateway restarting…", "success");
setTimeout(() => void loadWebhooks(), 4000);
void watchRestartOutcome();
} else {
const detail = result.restart_error ? `: ${result.restart_error}` : ".";
setRestartMessage(null);
setRestartNeeded(true);
setRestartError(`Gateway restart failed${detail}`);
showToast(`Webhooks enabled; gateway restart failed${detail}`, "error");
}
} catch (e) {
showToast(`Failed to enable webhooks: ${e}`, "error");
} finally {
setEnabling(false);
}
}, [loadWebhooks, showToast, watchRestartOutcome]);
const resetForm = useCallback(() => {
setName("");
setDescription("");
@ -171,7 +257,7 @@ export default function WebhooksPage() {
<Button
className="uppercase"
size="sm"
disabled={!enabled}
disabled={!enabled || enabling}
prefix={<Plus />}
onClick={() => {
setCreated(null);
@ -184,7 +270,7 @@ export default function WebhooksPage() {
return () => {
setEnd(null);
};
}, [setEnd, enabled, loading]);
}, [setEnd, enabled, enabling, loading]);
if (loading) {
return (
@ -375,17 +461,61 @@ export default function WebhooksPage() {
)}
{!enabled && (
<Card>
<CardContent className="py-6 flex items-start gap-3 text-sm">
<Webhook className="h-5 w-5 shrink-0 text-warning" />
<div className="flex flex-col gap-1">
<span className="font-medium">Webhook platform disabled</span>
<span className="text-muted-foreground">
The webhook platform must be enabled in your messaging settings
before you can create subscriptions. Enable it, then return to
this page.
<Card className="border-warning/50">
<CardContent className="flex flex-col gap-4 py-6 text-sm sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3">
<Webhook className="h-5 w-5 shrink-0 text-warning" />
<div className="flex flex-col gap-1">
<span className="font-medium">Webhook receiver disabled</span>
<span className="text-muted-foreground">
Webhooks are their own gateway platform. Enable them here to
accept incoming HTTP events; chat channels are only needed
when a subscription delivers to Telegram, Discord, Slack, or
another channel.
</span>
</div>
</div>
<Button
size="sm"
className="uppercase shrink-0"
onClick={handleEnableWebhooks}
disabled={enabling}
prefix={enabling ? <Spinner /> : <Webhook className="h-4 w-4" />}
>
{enabling ? "Enabling…" : "Enable webhooks"}
</Button>
</CardContent>
</Card>
)}
{restartMessage && !restartNeeded && (
<Card className="border-border">
<CardContent className="flex items-center gap-2 p-4 text-sm text-muted-foreground">
<RotateCw className="h-4 w-4 shrink-0 text-warning" />
<span>{restartMessage}</span>
</CardContent>
</Card>
)}
{restartNeeded && (
<Card className="border-warning/50">
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-2 text-sm">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<span>
{restartError ??
"Webhooks are enabled, but the gateway still needs a restart before the receiver can come online."}
</span>
</div>
<Button
size="sm"
className="uppercase shrink-0"
onClick={handleRestart}
disabled={restarting}
prefix={restarting ? <Spinner /> : <RotateCw className="h-4 w-4" />}
>
{restarting ? "Restarting…" : "Restart gateway"}
</Button>
</CardContent>
</Card>
)}
@ -400,8 +530,8 @@ export default function WebhooksPage() {
</H2>
<p className="text-xs text-muted-foreground -mt-1">
Disabled webhooks reject incoming events; the gateway hot-reloads
changes (no restart needed).
Subscription changes hot-reload once the webhook receiver is running.
Disabled subscriptions reject incoming events.
</p>
{subscriptions.length === 0 && (