Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+604
View File
@@ -0,0 +1,604 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
BarChart3,
Brain,
Cpu,
RefreshCw,
TrendingUp,
} from "lucide-react";
import { api } from "@/lib/api";
import type {
AnalyticsResponse,
AnalyticsDailyEntry,
AnalyticsModelEntry,
AnalyticsSkillEntry,
} from "@/lib/api";
import { timeAgo } from "@/lib/utils";
import { Button } from "@nous-research/ui/ui/components/button";
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 { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n";
import { PluginSlot } from "@/plugins";
const PERIODS = [
{ label: "7d", days: 7 },
{ label: "30d", days: 30 },
{ label: "90d", days: 90 },
] as const;
const CHART_HEIGHT_PX = 160;
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
function formatDate(day: string): string {
try {
const d = new Date(day + "T00:00:00");
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
} catch {
return day;
}
}
// ---------------------------------------------------------------------------
// Sorting
// ---------------------------------------------------------------------------
function useTableSort<T>(
data: T[],
defaultKey: keyof T & string,
defaultDir: "asc" | "desc" = "desc",
) {
const [sortKey, setSortKey] = useState<string>(defaultKey);
const [sortDir, setSortDir] = useState<"asc" | "desc">(defaultDir);
const sorted = useMemo(() => {
return [...data].sort((a, b) => {
const aVal = a[sortKey as keyof T];
const bVal = b[sortKey as keyof T];
// Nulls always last regardless of direction
if (aVal === null || aVal === undefined) return 1;
if (bVal === null || bVal === undefined) return -1;
if (aVal === bVal) return 0;
const cmp = aVal > bVal ? 1 : -1;
return sortDir === "asc" ? cmp : -cmp;
});
}, [data, sortKey, sortDir]);
const toggle = useCallback(
(key: string) => {
if (key === sortKey) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("desc");
}
},
[sortKey],
);
return { sorted, sortKey, sortDir, toggle };
}
function SortHeader({
label,
col,
sortKey,
sortDir,
toggle,
className,
}: {
label: string;
col: string;
sortKey: string;
sortDir: "asc" | "desc";
toggle: (key: string) => void;
className?: string;
}) {
const active = col === sortKey;
return (
<th
onClick={() => toggle(col)}
className={`cursor-pointer select-none ${className ?? ""}`}
>
<span className="inline-flex items-center gap-1.5 rounded px-1 -mx-1 py-0.5 hover:bg-muted/40 transition-colors">
{label}
{active ? (
sortDir === "asc" ? (
<ArrowUp className="h-3.5 w-3.5 text-foreground/80 shrink-0" />
) : (
<ArrowDown className="h-3.5 w-3.5 text-foreground/80 shrink-0" />
)
) : (
<ArrowUpDown className="h-3 w-3 text-text-tertiary shrink-0" />
)}
</span>
</th>
);
}
function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
const { t } = useI18n();
if (daily.length === 0) return null;
const maxTokens = Math.max(
...daily.map((d) => d.input_tokens + d.output_tokens),
1,
);
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
{t.analytics.dailyTokenUsage}
</CardTitle>
</div>
<div className="flex items-center gap-4 font-mondwest normal-case text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<div
className="h-2.5 w-2.5"
style={{ backgroundColor: "var(--series-input-token)" }}
/>
{t.analytics.input}
</div>
<div className="flex items-center gap-1.5">
<div
className="h-2.5 w-2.5"
style={{ backgroundColor: "var(--series-output-token)" }}
/>
{t.analytics.output}
</div>
</div>
</CardHeader>
<CardContent>
<div
className="flex items-end gap-[2px]"
style={{ height: CHART_HEIGHT_PX }}
>
{daily.map((d) => {
const total = d.input_tokens + d.output_tokens;
const inputH = Math.round(
(d.input_tokens / maxTokens) * CHART_HEIGHT_PX,
);
const outputH = Math.round(
(d.output_tokens / maxTokens) * CHART_HEIGHT_PX,
);
return (
<div
key={d.day}
className="flex-1 min-w-0 group relative flex flex-col justify-end"
style={{ height: CHART_HEIGHT_PX }}
>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10 pointer-events-none">
<div className="font-mondwest normal-case bg-card border border-border px-2.5 py-1.5 text-xs text-foreground shadow-lg whitespace-nowrap">
<div className="font-medium">{formatDate(d.day)}</div>
<div>
{t.analytics.input}: {formatTokens(d.input_tokens)}
</div>
<div>
{t.analytics.output}: {formatTokens(d.output_tokens)}
</div>
<div>
{t.analytics.total}: {formatTokens(total)}
</div>
</div>
</div>
<div
className="w-full"
style={{
backgroundColor:
"color-mix(in srgb, var(--series-input-token) 70%, transparent)",
height: Math.max(inputH, total > 0 ? 1 : 0),
}}
/>
<div
className="w-full"
style={{
backgroundColor:
"color-mix(in srgb, var(--series-output-token) 70%, transparent)",
height: Math.max(outputH, d.output_tokens > 0 ? 1 : 0),
}}
/>
</div>
);
})}
</div>
<div className="flex justify-between mt-2 font-mondwest normal-case text-xs text-text-tertiary">
<span>{daily.length > 0 ? formatDate(daily[0].day) : ""}</span>
{daily.length > 2 && (
<span>{formatDate(daily[Math.floor(daily.length / 2)].day)}</span>
)}
<span>
{daily.length > 1 ? formatDate(daily[daily.length - 1].day) : ""}
</span>
</div>
</CardContent>
</Card>
);
}
function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
const { t } = useI18n();
const { sorted, sortKey, sortDir, toggle } = useTableSort(daily, "day", "desc");
if (daily.length === 0) return null;
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
{t.analytics.dailyBreakdown}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full font-mondwest normal-case text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<SortHeader label={t.analytics.date} col="day" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
<SortHeader label={t.sessions.title} col="sessions" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
<SortHeader label={t.analytics.input} col="input_tokens" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
<SortHeader label={t.analytics.output} col="output_tokens" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 pl-4 font-medium" />
</tr>
</thead>
<tbody>
{sorted.map((d) => (
<tr
key={d.day}
className="border-b border-border/50 hover:bg-secondary/20 transition-colors"
>
<td className="py-2 pr-4 font-medium">
{formatDate(d.day)}
</td>
<td className="text-right py-2 px-4 text-muted-foreground">
{d.sessions}
</td>
<td className="text-right py-2 px-4">
<span style={{ color: "var(--series-input-token)" }}>
{formatTokens(d.input_tokens)}
</span>
</td>
<td className="text-right py-2 pl-4">
<span style={{ color: "var(--series-output-token)" }}>
{formatTokens(d.output_tokens)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
const { t } = useI18n();
const { sorted, sortKey, sortDir, toggle } = useTableSort(models, "input_tokens", "desc");
if (models.length === 0) return null;
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Cpu className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
{t.analytics.perModelBreakdown}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full font-mondwest normal-case text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<SortHeader label={t.analytics.model} col="model" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
<SortHeader label={t.sessions.title} col="sessions" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
<SortHeader label={t.analytics.tokens} col="input_tokens" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 pl-4 font-medium" />
</tr>
</thead>
<tbody>
{sorted.map((m) => (
<tr
key={m.model}
className="border-b border-border/50 hover:bg-secondary/20 transition-colors"
>
<td className="py-2 pr-4">
<span className="font-mono-ui text-xs">{m.model}</span>
</td>
<td className="text-right py-2 px-4 text-muted-foreground">
{m.sessions}
</td>
<td className="text-right py-2 pl-4">
<span style={{ color: "var(--series-input-token)" }}>
{formatTokens(m.input_tokens)}
</span>
{" / "}
<span style={{ color: "var(--series-output-token)" }}>
{formatTokens(m.output_tokens)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
function SkillTable({ skills }: { skills: AnalyticsSkillEntry[] }) {
const { t } = useI18n();
const { sorted, sortKey, sortDir, toggle } = useTableSort(skills, "total_count", "desc");
if (skills.length === 0) return null;
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Brain className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">{t.analytics.topSkills}</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full font-mondwest normal-case text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<SortHeader label={t.analytics.skill} col="skill" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
<SortHeader label={t.analytics.loads} col="view_count" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
<SortHeader label={t.analytics.edits} col="manage_count" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
<SortHeader label={t.analytics.total} col="total_count" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
<SortHeader label={t.analytics.lastUsed} col="last_used_at" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 pl-4 font-medium" />
</tr>
</thead>
<tbody>
{sorted.map((skill) => (
<tr
key={skill.skill}
className="border-b border-border/50 hover:bg-secondary/20 transition-colors"
>
<td className="py-2 pr-4">
<span className="font-mono-ui text-xs">{skill.skill}</span>
</td>
<td className="text-right py-2 px-4 text-muted-foreground">
{skill.view_count}
</td>
<td className="text-right py-2 px-4 text-muted-foreground">
{skill.manage_count}
</td>
<td className="text-right py-2 px-4">{skill.total_count}</td>
<td className="text-right py-2 pl-4 text-muted-foreground">
{skill.last_used_at ? timeAgo(skill.last_used_at) : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
export default function AnalyticsPage() {
const [days, setDays] = useState(30);
const [data, setData] = useState<AnalyticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Gated on `dashboard.show_token_analytics` (default off). When off the
// page renders an explanation card instead of fetching analytics — the
// local token counts exclude auxiliary calls and provider retries, so
// they diverge from provider billing in ways that mislead users.
const [showTokens, setShowTokens] = useState<boolean | null>(null);
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
useEffect(() => {
api
.getConfig()
.then((cfg) => {
const dash = (cfg?.dashboard ?? {}) as { show_token_analytics?: unknown };
setShowTokens(dash.show_token_analytics === true);
})
.catch(() => setShowTokens(false));
}, []);
const load = useCallback(() => {
if (!showTokens) return;
setLoading(true);
setError(null);
api
.getAnalytics(days)
.then(setData)
.catch((err) => setError(String(err)))
.finally(() => setLoading(false));
}, [days, showTokens]);
useLayoutEffect(() => {
// Period selector + refresh both live in afterTitle so the controls
// sit immediately next to the page title instead of being pinned to
// the far-right `end` slot. The active period is conveyed by the
// filled (non-outlined) button — no redundant period badge.
setAfterTitle(
showTokens === false ? null : (
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
>
{p.label}
</Button>
))}
<Button
type="button"
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={load}
disabled={loading}
aria-label={t.common.refresh}
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
</div>
),
);
setEnd(null);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [days, loading, load, setAfterTitle, setEnd, t.common.refresh, showTokens]);
useEffect(() => {
load();
}, [load]);
return (
<div className="flex flex-col gap-6">
<PluginSlot name="analytics:top" />
{showTokens === false && (
<Card>
<CardContent className="py-12">
<div className="mx-auto flex max-w-2xl flex-col gap-3 text-sm text-muted-foreground">
<h2 className="font-mondwest text-display text-base tracking-wider text-foreground">
Token analytics hidden
</h2>
<p>
The token, cost, and per-day analytics on this page are a
local debug estimate. They only count successful main-agent
responses with a usable <span className="font-mono">usage</span>{" "}
block, and silently exclude auxiliary calls (context
compression, title generation, vision, session search, web
extract, smart approvals, MCP routing, plugin LLM access)
plus provider-side retries and fallback attempts. Cache
writes are missing entirely.
</p>
<p>
On models with heavy auxiliary traffic (Kimi K2.6, MiniMax
M2.7) the local total can be 10x100x lower than what your
provider bills. Hiding these numbers is safer than letting
them look authoritative.
</p>
<p>
Check your provider dashboard (OpenRouter, Anthropic, etc.)
for actual usage and billing. To re-enable the local debug
estimate anyway, set{" "}
<span className="font-mono">
dashboard.show_token_analytics: true
</span>{" "}
in <a href="/config" className="underline">Config</a>.
</p>
</div>
</CardContent>
</Card>
)}
{showTokens && loading && !data && (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
)}
{showTokens && error && (
<Card>
<CardContent className="py-6">
<p className="text-sm text-destructive text-center">{error}</p>
</CardContent>
</Card>
)}
{showTokens && data && (
<>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardContent className="py-6">
<Stats
items={[
{
label: t.analytics.totalTokens,
value: formatTokens(
data.totals.total_input + data.totals.total_output,
),
},
{
label: t.analytics.input,
value: formatTokens(data.totals.total_input),
},
{
label: t.analytics.output,
value: formatTokens(data.totals.total_output),
},
{
label: t.analytics.totalSessions,
value: `${data.totals.total_sessions} (~${(data.totals.total_sessions / days).toFixed(1)}${t.analytics.perDayAvg})`,
},
{
label: t.analytics.apiCalls,
value: String(
data.totals.total_api_calls ??
data.daily.reduce((sum, d) => sum + d.sessions, 0),
),
},
]}
/>
</CardContent>
</Card>
<TokenBarChart daily={data.daily} />
</div>
<DailyTable daily={data.daily} />
<ModelTable models={data.by_model} />
<SkillTable skills={data.skills.top_skills} />
</>
)}
{data &&
data.daily.length === 0 &&
data.by_model.length === 0 &&
data.skills.top_skills.length === 0 && (
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center text-muted-foreground">
<BarChart3 className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">{t.analytics.noUsageData}</p>
<p className="text-xs mt-1 text-text-tertiary">
{t.analytics.startSession}
</p>
</div>
</CardContent>
</Card>
)}
<PluginSlot name="analytics:bottom" />
</div>
);
}
File diff suppressed because it is too large Load Diff
+459
View File
@@ -0,0 +1,459 @@
// @vitest-environment jsdom
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PTY_TICKET_TIMEOUT_MS } from "@/lib/pty-reconnect";
class FakeFitAddon {
fit() {}
}
class FakeWebglAddon {
onContextLoss() {
return { dispose() {} };
}
}
class FakeTerminal {
options: Record<string, unknown>;
rows = 24;
cols = 80;
parser = {
registerOscHandler: vi.fn(),
};
unicode = { activeVersion: "" };
constructor(options: Record<string, unknown>) {
this.options = options;
}
attachCustomKeyEventHandler() {
return true;
}
attachCustomWheelEventHandler() {
return true;
}
clearSelection() {}
dispose() {}
focus() {}
getSelection() {
return "";
}
loadAddon() {}
onData() {
return { dispose() {} };
}
onResize() {
return { dispose() {} };
}
onScroll() {
return { dispose() {} };
}
get buffer() {
// Minimal active-buffer surface for the resume follow-scroll pin
// (isViewportPinnedToBottom reads viewportY/baseY).
return { active: { baseY: 0, viewportY: 0 } };
}
scrollToBottom() {}
open() {}
paste() {}
refresh() {}
write() {}
}
const maybeReloadForLoopbackWsAuthFailure = vi.fn(() => false);
const apiMocks = vi.hoisted(() => ({
buildWsUrl: vi.fn(async () => "ws://localhost/api/pty?channel=chat-1"),
}));
vi.mock("@xterm/addon-fit", () => ({ FitAddon: FakeFitAddon }));
vi.mock("@xterm/addon-unicode11", () => ({ Unicode11Addon: class {} }));
vi.mock("@xterm/addon-web-links", () => ({ WebLinksAddon: class {} }));
vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: FakeWebglAddon }));
vi.mock("@xterm/xterm", () => ({ Terminal: FakeTerminal }));
vi.mock("@/components/ChatSidebar", () => ({
ChatSidebar: () => null,
}));
vi.mock("@/components/ChatSessionList", () => ({
ChatSessionList: () => null,
}));
vi.mock("@/components/Backdrop", () => ({ Backdrop: () => null }));
vi.mock("@/plugins", () => ({
PluginSlot: () => null,
}));
vi.mock("@/contexts/usePageHeader", () => ({
usePageHeader: () => ({ setEnd: vi.fn(), setTitle: vi.fn() }),
}));
vi.mock("@/contexts/useProfileScope", () => ({
useProfileScope: () => ({ profile: "" }),
}));
vi.mock("@/themes", () => ({
useTheme: () => ({ theme: { terminalBackground: "#000000" } }),
}));
vi.mock("@/i18n", () => ({
useI18n: () => ({
t: {
app: {
closeModelTools: "Close model tools",
modelToolsSheetSubtitle: "Tools",
modelToolsSheetTitle: "Model",
},
},
}),
}));
vi.mock("@/lib/dashboard-auth-reload", () => ({
maybeReloadForLoopbackWsAuthFailure,
}));
vi.mock("@/lib/api", () => ({
api: apiMocks,
buildWsUrl: apiMocks.buildWsUrl,
}));
class FakeWebSocket {
static instances: FakeWebSocket[] = [];
static OPEN = 1;
binaryType = "blob";
onclose: ((event: CloseEventLike) => void) | null = null;
onmessage: ((event: { data: ArrayBuffer | string }) => void) | null = null;
onopen: (() => void) | null = null;
readyState = FakeWebSocket.OPEN;
url: string;
constructor(url: string) {
this.url = url;
FakeWebSocket.instances.push(this);
}
close() {
this.readyState = 3;
}
send() {}
}
type CloseEventLike = {
code: number;
reason: string;
wasClean: boolean;
};
let container: HTMLDivElement;
let root: Root;
// jsdom runs without an origin here (per-file @vitest-environment jsdom on a
// node-default config), so localStorage is undefined. Stub it so components
// that persist UI state (side panel collapse) can be exercised.
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => {
store[key] = String(value);
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
};
})();
// React only routes updates through act() when this flag is set; without it
// the isActive re-renders in the keyboard-inset gate test warn.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
async function render(ui: ReactNode) {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
await act(async () => root.render(ui));
}
beforeEach(() => {
FakeWebSocket.instances = [];
maybeReloadForLoopbackWsAuthFailure.mockClear();
apiMocks.buildWsUrl.mockReset();
apiMocks.buildWsUrl.mockResolvedValue("ws://localhost/api/pty?channel=chat-1");
vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal(
"ResizeObserver",
class {
disconnect() {}
observe() {}
unobserve() {}
},
);
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
cb(0);
return 1;
});
vi.stubGlobal("cancelAnimationFrame", () => {});
vi.stubGlobal("matchMedia", () => ({
addEventListener() {},
matches: false,
media: "",
removeEventListener() {},
}));
vi.stubGlobal("crypto", {
getRandomValues: (values: Uint8Array) => {
values.fill(7);
return values;
},
randomUUID: () => "chat-test-id",
});
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: { addEventListener() {}, removeEventListener() {}, width: 1280 },
});
Object.defineProperty(window, "__HERMES_SESSION_TOKEN__", {
configurable: true,
value: "stale-token",
writable: true,
});
Object.defineProperty(window, "__HERMES_AUTH_REQUIRED__", {
configurable: true,
value: false,
writable: true,
});
Object.defineProperty(window.navigator, "clipboard", {
configurable: true,
value: {
readText: vi.fn(async () => ""),
writeText: vi.fn(async () => {}),
},
});
sessionStorage.clear();
vi.stubGlobal("localStorage", localStorageMock);
localStorageMock.clear();
});
afterEach(async () => {
await act(async () => root?.unmount());
container?.remove();
vi.unstubAllGlobals();
});
describe("ChatPage", () => {
it("treats loopback 4401 closes as stale-token reload candidates", async () => {
const { default: ChatPage } = await import("./ChatPage");
await render(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive />
</MemoryRouter>,
);
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
FakeWebSocket.instances[0].onclose?.({
code: 4401,
reason: "auth: token_mismatch",
wasClean: true,
});
expect(maybeReloadForLoopbackWsAuthFailure).toHaveBeenCalledWith(4401);
});
it("attaches visualViewport keyboard-inset listeners only while the chat tab is active", async () => {
// NS-434 follow-up: ChatPage stays mounted (hidden) on every dashboard
// route. The keyboard-inset/scroll-pin listeners must only be live while
// /chat is the active tab, or the scroll pin fires when a soft keyboard
// opens on Settings etc.
const addEventListener = vi.fn();
const removeEventListener = vi.fn();
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: { addEventListener, removeEventListener, width: 1280 },
});
const { default: ChatPage } = await import("./ChatPage");
await render(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive={false} />
</MemoryRouter>,
);
expect(addEventListener).not.toHaveBeenCalled();
await act(async () =>
root.render(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive />
</MemoryRouter>,
),
);
expect(addEventListener.mock.calls.map((c) => c[0]).sort()).toEqual([
"resize",
"scroll",
]);
expect(removeEventListener).not.toHaveBeenCalled();
await act(async () =>
root.render(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive={false} />
</MemoryRouter>,
),
);
expect(removeEventListener.mock.calls.map((c) => c[0]).sort()).toEqual([
"resize",
"scroll",
]);
});
});
describe("ChatPage side panel collapse", () => {
async function renderChat() {
const { default: ChatPage } = await import("./ChatPage");
await render(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive />
</MemoryRouter>,
);
}
it("collapses the desktop side panel and persists the choice", async () => {
localStorage.clear();
await renderChat();
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
const collapseButton = container.querySelector(
'[aria-label="Collapse chat side panel"]',
);
expect(collapseButton).not.toBeNull();
await act(async () => {
collapseButton!.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
expect(localStorage.getItem("hermes-chat-panel-collapsed")).toBe("1");
expect(
container.querySelector('[aria-label="Collapse chat side panel"]'),
).toBeNull();
expect(
container.querySelector('[aria-label="Show chat side panel"]'),
).not.toBeNull();
// Reopening restores the panel and clears the persisted flag.
await act(async () => {
container
.querySelector('[aria-label="Show chat side panel"]')!
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(localStorage.getItem("hermes-chat-panel-collapsed")).toBe("0");
expect(
container.querySelector('[aria-label="Collapse chat side panel"]'),
).not.toBeNull();
});
});
// The gated-mode ticket request runs before any socket exists, so a rejection
// or a hang emits no `close` event and never arms PTY_CONNECTING_TIMEOUT_MS
// (that timer is set after `new WebSocket`). Without its own deadline the tab
// strands on "connecting" with no retry. Mirrors the ChatSidebar events-feed
// coverage in src/components/ChatSidebar.test.tsx.
describe("ChatPage PTY ticket connect deadline", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
async function renderChat() {
const { default: ChatPage } = await import("./ChatPage");
await render(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive />
</MemoryRouter>,
);
}
/** Advance timers and flush the async connect that fires on the tick. */
async function advance(ms: number) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
}
it("retries when the ticket request rejects", async () => {
apiMocks.buildWsUrl.mockRejectedValueOnce(
new Error("ticket endpoint unavailable"),
);
await renderChat();
await advance(0);
expect(FakeWebSocket.instances).toHaveLength(0);
// First backoff step is 250ms; the retry must mint a fresh ticket.
await advance(250);
expect(apiMocks.buildWsUrl).toHaveBeenCalledTimes(2);
expect(FakeWebSocket.instances).toHaveLength(1);
});
it("times out a stalled ticket request and retries", async () => {
let resolveStalledRequest!: (url: string) => void;
apiMocks.buildWsUrl.mockImplementationOnce(
() =>
new Promise<string>((resolve) => {
resolveStalledRequest = resolve;
}),
);
await renderChat();
await advance(0);
expect(FakeWebSocket.instances).toHaveLength(0);
await advance(PTY_TICKET_TIMEOUT_MS);
expect(FakeWebSocket.instances).toHaveLength(0);
// A late ticket from the timed-out attempt must not open a socket behind
// the replacement the deadline scheduled.
await act(async () => {
resolveStalledRequest("ws://localhost/api/pty?channel=stale");
await Promise.resolve();
});
expect(FakeWebSocket.instances).toHaveLength(0);
await advance(250);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(FakeWebSocket.instances[0].url).not.toContain("channel=stale");
});
it("leaves a settled ticket's socket to the CONNECTING timer", async () => {
await renderChat();
await advance(0);
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
// NS-591 regression: once the socket exists the ticket deadline is
// disarmed, so PTY_CONNECTING_TIMEOUT_MS stays the only thing that may
// force-close a wedged handshake — the two must not both fire.
await advance(PTY_TICKET_TIMEOUT_MS);
expect(apiMocks.buildWsUrl).toHaveBeenCalledTimes(1);
});
});
File diff suppressed because it is too large Load Diff
+679
View File
@@ -0,0 +1,679 @@
import { useEffect, useLayoutEffect, useRef, useState, useMemo } from "react";
import {
Code,
Download,
FormInput,
RotateCcw,
Search,
Upload,
X,
Settings2,
FileText,
Settings,
Bot,
Monitor,
Palette,
Users,
Brain,
Package,
Lock,
Globe,
Mic,
Volume2,
Ear,
ClipboardList,
MessageCircle,
Wrench,
FileQuestion,
Filter,
Cloud,
Sparkles,
LayoutDashboard,
BookOpen,
Route,
History,
Shield,
FileOutput,
RefreshCw,
} from "lucide-react";
import { api } from "@/lib/api";
import { getNestedValue, setNestedValue } from "@/lib/nested";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { AutoField } from "@/components/AutoField";
import { Button } from "@nous-research/ui/ui/components/button";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
import { Input } from "@nous-research/ui/ui/components/input";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const CATEGORY_ICONS: Record<
string,
React.ComponentType<{ className?: string }>
> = {
general: Settings,
agent: Bot,
terminal: Monitor,
display: Palette,
delegation: Users,
memory: Brain,
compression: Package,
security: Lock,
browser: Globe,
voice: Mic,
tts: Volume2,
stt: Ear,
logging: ClipboardList,
discord: MessageCircle,
auxiliary: Wrench,
bedrock: Cloud,
curator: Sparkles,
kanban: LayoutDashboard,
model_catalog: BookOpen,
openrouter: Route,
sessions: History,
tool_loop_guardrails: Shield,
tool_output: FileOutput,
updates: RefreshCw,
};
function CategoryIcon({
category,
className,
}: {
category: string;
className?: string;
}) {
const Icon = CATEGORY_ICONS[category] ?? FileQuestion;
return <Icon className={className ?? "h-4 w-4"} />;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function ConfigPage() {
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
const [schema, setSchema] = useState<Record<
string,
Record<string, unknown>
> | null>(null);
const [categoryOrder, setCategoryOrder] = useState<string[]>([]);
const [defaults, setDefaults] = useState<Record<string, unknown> | null>(
null,
);
const [saving, setSaving] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [yamlMode, setYamlMode] = useState(false);
const [yamlText, setYamlText] = useState("");
const [yamlLoading, setYamlLoading] = useState(false);
const [yamlSaving, setYamlSaving] = useState(false);
const [configPath, setConfigPath] = useState<string | null>(null);
const [activeCategory, setActiveCategory] = useState<string>("");
const [confirmReset, setConfirmReset] = useState(false);
const { toast, showToast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const { t } = useI18n();
const { setEnd } = usePageHeader();
useLayoutEffect(() => {
if (!config || !schema) {
setEnd(null);
return;
}
setEnd(
<div className="relative w-full min-w-0 sm:max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="h-8 pl-8 pr-7 text-xs"
placeholder={t.common.search}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<Button
ghost
size="xs"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearchQuery("")}
aria-label={t.common.clear}
>
<X />
</Button>
)}
</div>,
);
return () => setEnd(null);
}, [config, schema, searchQuery, setEnd, t.common.clear, t.common.search]);
function prettyCategoryName(cat: string): string {
const key = cat as keyof typeof t.config.categories;
if (t.config.categories[key]) return t.config.categories[key];
return cat.charAt(0).toUpperCase() + cat.slice(1);
}
useEffect(() => {
api
.getConfig()
.then(setConfig)
.catch(() => {});
api
.getSchema()
.then((resp) => {
// memory.provider has a dedicated management UI on the Plugins page
// (provider cards + guided setup/switch flow). Hide it from the
// generic config form so the two surfaces don't fight; the schema
// keeps the field for other consumers (Desktop settings).
const fields = { ...resp.fields } as Record<
string,
Record<string, unknown>
>;
delete fields["memory.provider"];
setSchema(fields);
setCategoryOrder(resp.category_order ?? []);
})
.catch(() => {});
api
.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((prev) => prev ?? resp.config_path))
.catch(() => {});
}, []);
// Set active category when categories load
useEffect(() => {
if (categoryOrder.length > 0 && !activeCategory) {
setActiveCategory(categoryOrder[0]);
}
}, [categoryOrder, activeCategory]);
// Load YAML when switching to YAML mode
useEffect(() => {
if (yamlMode) {
setYamlLoading(true);
api
.getConfigRaw()
.then((resp) => setYamlText(resp.yaml))
.catch(() => showToast(t.config.failedToLoadRaw, "error"))
.finally(() => setYamlLoading(false));
}
}, [yamlMode]);
/* ---- Categories ---- */
const categories = useMemo(() => {
if (!schema) return [];
const allCats = [
...new Set(
Object.values(schema).map((s) => String(s.category ?? "general")),
),
];
const ordered = categoryOrder.filter((c) => allCats.includes(c));
const extra = allCats.filter((c) => !categoryOrder.includes(c)).sort();
return [...ordered, ...extra];
}, [schema, categoryOrder]);
/* ---- Category field counts ---- */
const categoryCounts = useMemo(() => {
if (!schema) return {};
const counts: Record<string, number> = {};
for (const s of Object.values(schema)) {
const cat = String(s.category ?? "general");
counts[cat] = (counts[cat] || 0) + 1;
}
return counts;
}, [schema]);
/* ---- Search ---- */
const isSearching = searchQuery.trim().length > 0;
const lowerSearch = searchQuery.toLowerCase();
const searchMatchedFields = useMemo(() => {
if (!isSearching || !schema) return [];
return Object.entries(schema).filter(([key, s]) => {
const label = key.split(".").pop() ?? key;
const humanLabel = label.replace(/_/g, " ");
return (
key.toLowerCase().includes(lowerSearch) ||
humanLabel.toLowerCase().includes(lowerSearch) ||
String(s.category ?? "")
.toLowerCase()
.includes(lowerSearch) ||
String(s.description ?? "")
.toLowerCase()
.includes(lowerSearch)
);
});
}, [isSearching, lowerSearch, schema]);
/* ---- Active tab fields ---- */
const activeFields = useMemo(() => {
if (!schema || isSearching) return [];
return Object.entries(schema).filter(
([, s]) => String(s.category ?? "general") === activeCategory,
);
}, [schema, activeCategory, isSearching]);
/* ---- Handlers ---- */
const handleSave = async () => {
if (!config) return;
setSaving(true);
try {
await api.saveConfig(config);
showToast(t.config.configSaved, "success");
} catch (e) {
showToast(`${t.config.failedToSave}: ${e}`, "error");
} finally {
setSaving(false);
}
};
const handleYamlSave = async () => {
setYamlSaving(true);
try {
await api.saveConfigRaw(yamlText);
showToast(t.config.yamlConfigSaved, "success");
api
.getConfig()
.then(setConfig)
.catch(() => {});
} catch (e) {
showToast(`${t.config.failedToSaveYaml}: ${e}`, "error");
} finally {
setYamlSaving(false);
}
};
const handleReset = () => {
if (!defaults || !config) return;
// Scope the reset to what the user is currently looking at:
// - search mode → the matched fields
// - form mode → the active category's fields
// Resetting the whole config here was a footgun (issue reported by @ykmfb001):
// the button sits next to the category tabs and users reasonably assumed
// "reset this tab", not "wipe my entire config.yaml".
const scopedFields = isSearching ? searchMatchedFields : activeFields;
if (scopedFields.length === 0) return;
setConfirmReset(true);
};
const executeReset = () => {
if (!defaults || !config) return;
setConfirmReset(false);
const scopedFields = isSearching ? searchMatchedFields : activeFields;
if (scopedFields.length === 0) return;
const scopeLabel = isSearching
? t.config.searchResults
: prettyCategoryName(activeCategory);
let next: Record<string, unknown> = config;
for (const [key] of scopedFields) {
next = setNestedValue(next, key, getNestedValue(defaults, key));
}
setConfig(next);
showToast(
t.config.resetScopeToast.replace("{scope}", scopeLabel),
"success",
);
};
const handleExport = () => {
if (!config) return;
const blob = new Blob([JSON.stringify(config, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "hermes-config.json";
a.click();
URL.revokeObjectURL(url);
};
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const imported = JSON.parse(reader.result as string);
setConfig(imported);
showToast(t.config.configImported, "success");
} catch {
showToast(t.config.invalidJson, "error");
}
};
reader.readAsText(file);
};
/* ---- Loading ---- */
if (!config || !schema) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
/* ---- Render field list (shared between search & normal) ---- */
const renderFields = (
fields: [string, Record<string, unknown>][],
showCategory = false,
) => {
let lastSection = "";
let lastCat = "";
return fields.map(([key, s]) => {
const parts = key.split(".");
const section = parts.length > 1 ? parts[0] : "";
const cat = String(s.category ?? "general");
const showCatBadge = showCategory && cat !== lastCat;
const showSection =
!showCategory &&
section &&
section !== lastSection &&
section !== activeCategory;
lastSection = section;
lastCat = cat;
return (
<div key={key}>
{showCatBadge && (
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
<CategoryIcon
category={cat}
className="h-4 w-4 text-muted-foreground"
/>
<span className="font-mondwest text-display text-xs font-semibold tracking-wider text-muted-foreground">
{prettyCategoryName(cat)}
</span>
<div className="flex-1 border-t border-border" />
</div>
)}
{showSection && (
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
<span className="font-mondwest text-display text-xs font-semibold tracking-wider text-muted-foreground">
{section.replace(/_/g, " ")}
</span>
<div className="flex-1 border-t border-border" />
</div>
)}
<div className="py-1">
<AutoField
schemaKey={key}
schema={s}
value={getNestedValue(config, key)}
onChange={(v) => setConfig(setNestedValue(config, key, v))}
/>
</div>
</div>
);
});
};
return (
<div className="flex flex-col gap-4">
<PluginSlot name="config:top" />
<Toast toast={toast} />
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-2 sm:flex-1">
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<code className="min-w-0 flex-1 break-words text-xs text-muted-foreground bg-muted/50 px-2 py-0.5">
{configPath ?? t.config.configPath}
</code>
</div>
<div className="flex flex-wrap items-center gap-1.5 sm:shrink-0">
<Button
ghost
size="icon"
onClick={handleExport}
title={t.config.exportConfig}
aria-label={t.config.exportConfig}
>
<Download />
</Button>
<Button
ghost
size="icon"
onClick={() => fileInputRef.current?.click()}
title={t.config.importConfig}
aria-label={t.config.importConfig}
>
<Upload />
</Button>
<input
ref={fileInputRef}
type="file"
accept=".json"
className="hidden"
onChange={handleImport}
/>
{!yamlMode &&
(() => {
const resetScopeLabel = isSearching
? t.config.searchResults
: prettyCategoryName(activeCategory);
const resetTitle = t.config.resetScopeTooltip.replace(
"{scope}",
resetScopeLabel,
);
return (
<Button
ghost
size="icon"
onClick={handleReset}
title={resetTitle}
aria-label={resetTitle}
>
<RotateCcw />
</Button>
);
})()}
<div className="w-px h-5 bg-border mx-1" />
<Button
size="sm"
outlined={!yamlMode}
onClick={() => setYamlMode(!yamlMode)}
prefix={yamlMode ? <FormInput /> : <Code />}
>
{yamlMode ? t.common.form : "YAML"}
</Button>
{yamlMode ? (
<Button
size="sm"
className="uppercase"
onClick={handleYamlSave}
disabled={yamlSaving}
>
{yamlSaving ? t.common.saving : t.common.save}
</Button>
) : (
<Button
size="sm"
className="uppercase"
onClick={handleSave}
disabled={saving}
>
{saving ? t.common.saving : t.common.save}
</Button>
)}
</div>
</div>
{yamlMode ? (
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm flex items-center gap-2">
<FileText className="h-4 w-4" />
{t.config.rawYaml}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{yamlLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner className="text-xl text-primary" />
</div>
) : (
<textarea
className="flex min-h-[600px] w-full bg-transparent px-4 py-3 text-sm font-mono leading-relaxed placeholder:text-muted-foreground focus-visible:outline-none border-t border-border"
value={yamlText}
onChange={(e) => setYamlText(e.target.value)}
spellCheck={false}
/>
)}
</CardContent>
</Card>
) : (
<div className="flex flex-col sm:flex-row gap-4">
<aside aria-label={t.config.filters} className="sm:w-56 sm:shrink-0">
<div className="sm:sticky sm:top-4">
<div className="flex flex-col border border-border bg-muted/20">
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
<Filter className="h-3 w-3 text-text-tertiary" />
<span className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
{t.config.filters}
</span>
</div>
<div className="hidden sm:block px-3 pt-2 pb-1 font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary">
{t.config.sections}
</div>
<div className="flex sm:flex-col gap-1 sm:gap-px p-2 sm:pt-1 overflow-x-auto sm:overflow-x-visible scrollbar-none sm:max-h-[calc(100vh-260px)] sm:overflow-y-auto">
{categories.map((cat) => {
const isActive = !isSearching && activeCategory === cat;
return (
<ListItem
key={cat}
active={isActive}
onClick={() => {
setSearchQuery("");
setActiveCategory(cat);
}}
className="rounded-none whitespace-nowrap px-2 py-1 text-xs"
>
<CategoryIcon
category={cat}
className="h-3.5 w-3.5 shrink-0"
/>
<span className="flex-1 truncate">
{prettyCategoryName(cat)}
</span>
<span
className={`text-xs tabular-nums ${
isActive
? "text-text-secondary"
: "text-text-tertiary"
}`}
>
{categoryCounts[cat] || 0}
</span>
</ListItem>
);
})}
</div>
</div>
</div>
</aside>
<div className="flex-1 min-w-0">
{isSearching ? (
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Search className="h-4 w-4" />
{t.config.searchResults}
</CardTitle>
<Badge tone="secondary" className="text-xs">
{searchMatchedFields.length}{" "}
{t.config.fields.replace(
"{s}",
searchMatchedFields.length !== 1 ? "s" : "",
)}
</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-2 px-4 pb-4">
{searchMatchedFields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
{t.config.noFieldsMatch.replace("{query}", searchQuery)}
</p>
) : (
renderFields(searchMatchedFields, true)
)}
</CardContent>
</Card>
) : (
/* Active category */
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<CategoryIcon
category={activeCategory}
className="h-4 w-4"
/>
{prettyCategoryName(activeCategory)}
</CardTitle>
<Badge tone="secondary" className="text-xs">
{activeFields.length}{" "}
{t.config.fields.replace(
"{s}",
activeFields.length !== 1 ? "s" : "",
)}
</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-2 px-4 pb-4">
{renderFields(activeFields)}
</CardContent>
</Card>
)}
</div>
</div>
)}
<PluginSlot name="config:bottom" />
<ConfirmDialog
open={confirmReset}
onCancel={() => setConfirmReset(false)}
onConfirm={executeReset}
title={t.config.confirmResetScope.replace(
"{scope}",
isSearching
? t.config.searchResults
: prettyCategoryName(activeCategory),
)}
description={`This will reset ${
(isSearching ? searchMatchedFields : activeFields).length
} field(s) to their default values.`}
destructive
confirmLabel={t.config.resetDefaults}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
import { useLayoutEffect } from "react";
import { ExternalLink } from "lucide-react";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { cn } from "@/lib/utils";
import { PluginSlot } from "@/plugins";
export const HERMES_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/";
const DS_BUTTON_OUTLINED_LINK_CN = cn(
"group relative inline-grid grid-cols-[auto_1fr_auto] items-center",
"px-[.9em_.75em] py-[1.25em] gap-2",
"leading-0 font-bold tracking-[0.2em] uppercase",
"text-midground bg-transparent shadow-midground",
"shadow-[inset_-1px_-1px_0_0_#00000080,inset_1px_1px_0_0_#ffffff80]",
);
export default function DocsPage() {
const { t } = useI18n();
const { setEnd } = usePageHeader();
useLayoutEffect(() => {
setEnd(
<a
href={HERMES_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className={DS_BUTTON_OUTLINED_LINK_CN}
>
<ExternalLink className="size-3.5" />
{t.app.openDocumentation}
</a>,
);
return () => {
setEnd(null);
};
}, [setEnd, t]);
return (
<div
className={cn(
"flex min-h-0 w-full min-w-0 flex-1 flex-col",
"pt-1 sm:pt-2",
)}
>
<PluginSlot name="docs:top" />
<iframe
title={t.app.nav.documentation}
src={HERMES_DOCS_URL}
className={cn(
"min-h-0 w-full min-w-0 flex-1",
"rounded-sm border border-current/20",
// Docusaurus paints over a transparent <html> / <body> and
// relies on the browser's canvas color (light by default) to
// fill the viewport. Inheriting the dashboard's dark color
// scheme makes that canvas dark, so the docs body text — which
// is tuned for a light canvas — becomes near-invisible. Force a
// light color scheme + white background on the iframe element so
// the docs render cleanly regardless of the active dashboard
// theme or the user's prefers-color-scheme.
"[color-scheme:light] bg-white",
)}
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
referrerPolicy="no-referrer-when-downgrade"
/>
<PluginSlot name="docs:bottom" />
</div>
);
}
File diff suppressed because it is too large Load Diff
+525
View File
@@ -0,0 +1,525 @@
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 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)) {
await api.uploadFile(joinPath(activePath, file.name), file, 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>
);
}
+237
View File
@@ -0,0 +1,237 @@
import {
useEffect,
useLayoutEffect,
useState,
useCallback,
useRef,
} from "react";
import { FileText, RefreshCw } from "lucide-react";
import { api } from "@/lib/api";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { FilterGroup, Segmented } from "@nous-research/ui/ui/components/segmented";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Switch } from "@nous-research/ui/ui/components/switch";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
import { Label } from "@nous-research/ui/ui/components/label";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
// Level classification is unit-tested in @/lib/log-classify; it prefers the
// structured level token and falls back to word-boundary matching so payload
// text like "parse_errors=0" can't render an INFO line red.
import { classifyLine } from "@/lib/log-classify";
const FILES = ["agent", "errors", "gateway"] as const;
const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] as const;
const COMPONENTS = ["all", "gateway", "agent", "tools", "cli", "cron"] as const;
const LINE_COUNTS = [50, 100, 200, 500] as const;
const LINE_COLORS: Record<string, string> = {
error: "text-destructive",
warning: "text-warning",
info: "text-foreground",
debug: "text-text-tertiary",
};
const formatFilterLabel = (value: string) => value.toUpperCase();
const toSegmentOptions = <T extends string>(values: readonly T[]) =>
values.map((v) => ({ value: v, label: formatFilterLabel(v) }));
const filterGroupClass =
"flex min-w-0 w-full flex-col items-start gap-1.5 sm:w-auto sm:max-w-full sm:flex-row sm:items-center";
const segmentedClass =
"w-fit max-w-full flex-wrap justify-start self-start";
export default function LogsPage() {
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL");
const [component, setComponent] =
useState<(typeof COMPONENTS)[number]>("all");
const [lineCount, setLineCount] = useState<(typeof LINE_COUNTS)[number]>(100);
const [autoRefresh, setAutoRefresh] = useState(false);
const [lines, setLines] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
const fetchLogs = useCallback(() => {
setLoading(true);
setError(null);
api
.getLogs({ file, lines: lineCount, level, component })
.then((resp) => {
setLines(resp.lines);
setTimeout(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, 50);
})
.catch((err) => setError(String(err)))
.finally(() => setLoading(false));
}, [file, lineCount, level, component]);
useLayoutEffect(() => {
setAfterTitle(
<span className="flex items-center gap-1.5">
<Badge tone="secondary" className="text-xs">
{formatFilterLabel(file)} · {formatFilterLabel(level)} ·{" "}
{formatFilterLabel(component)}
</Badge>
<Button
type="button"
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={fetchLogs}
disabled={loading}
aria-label={t.common.refresh}
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
</span>,
);
setEnd(
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-3">
<div className="flex items-center gap-2">
<Label htmlFor="logs-auto-refresh" className="text-xs cursor-pointer">
{t.logs.autoRefresh}
</Label>
<Switch
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
id="logs-auto-refresh"
/>
{autoRefresh && (
<Badge tone="success" className="text-xs">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
{t.common.live}
</Badge>
)}
</div>
</div>,
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [
autoRefresh,
component,
file,
level,
loading,
setAfterTitle,
setEnd,
t.common.live,
t.common.refresh,
t.logs.autoRefresh,
fetchLogs,
]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(fetchLogs, 5000);
return () => clearInterval(interval);
}, [autoRefresh, fetchLogs]);
return (
<div className="flex min-w-0 max-w-full flex-col gap-4">
<PluginSlot name="logs:top" />
<div
role="toolbar"
aria-label={t.logs.title}
className="flex min-w-0 max-w-full flex-col items-start gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:gap-x-6 sm:gap-y-3"
>
<FilterGroup label={t.logs.file} className={filterGroupClass}>
<Segmented
className={segmentedClass}
value={file}
onChange={setFile}
options={toSegmentOptions(FILES)}
/>
</FilterGroup>
<FilterGroup label={t.logs.level} className={filterGroupClass}>
<Segmented
className={segmentedClass}
value={level}
onChange={setLevel}
options={toSegmentOptions(LEVELS)}
/>
</FilterGroup>
<FilterGroup label={t.logs.component} className={filterGroupClass}>
<Segmented
className={segmentedClass}
value={component}
onChange={setComponent}
options={toSegmentOptions(COMPONENTS)}
/>
</FilterGroup>
<FilterGroup label={t.logs.lines} className={filterGroupClass}>
<Segmented
className={segmentedClass}
value={String(lineCount)}
onChange={(v) =>
setLineCount(Number(v) as (typeof LINE_COUNTS)[number])
}
options={LINE_COUNTS.map((n) => ({
value: String(n),
label: String(n),
}))}
/>
</FilterGroup>
</div>
<Card className="min-w-0 max-w-full overflow-hidden">
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm flex items-center gap-2">
<FileText className="h-4 w-4" />
{file}.log
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{error && (
<div className="bg-destructive/10 border-b border-destructive/20 p-3">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<div
ref={scrollRef}
className="max-w-full min-h-[400px] max-h-[calc(100vh-220px)] overflow-auto p-4 font-mono-ui text-xs leading-5 break-words"
>
{lines.length === 0 && !loading && (
<p className="text-muted-foreground text-center py-8">
{t.logs.noLogLines}
</p>
)}
{lines.map((line, i) => {
const cls = classifyLine(line);
return (
<div
key={i}
className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1`}
>
{line}
</div>
);
})}
</div>
</CardContent>
</Card>
<PluginSlot name="logs:bottom" />
</div>
);
}
+902
View File
@@ -0,0 +1,902 @@
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { KeyRound, Package, Power, Server, Trash2, X, Zap } 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";
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 {
McpCatalogDiagnostic,
McpCatalogEntry,
McpHttpAuth,
McpServer,
McpTestResult,
} from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
import { useModalBehavior } from "@/hooks/useModalBehavior";
import { Toast } from "@nous-research/ui/ui/components/toast";
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 { usePageHeader } from "@/contexts/usePageHeader";
import { cn, themedBody } from "@/lib/utils";
import {
buildMcpServerCreate,
type McpTransport,
} from "@/lib/mcp-server-create";
import { completeMcpDashboardOAuth } from "@/lib/mcp-dashboard-oauth";
function isHttpUrl(value: string): boolean {
return /^https?:\/\//i.test(value.trim());
}
function truncateText(value: string, maxLength: number): string {
return value.length > maxLength ? value.slice(0, maxLength) + "..." : value;
}
const TRANSPORT_TONE: Record<string, "success" | "warning" | "secondary"> = {
http: "success",
stdio: "warning",
unknown: "secondary",
};
export default function McpPage() {
const [servers, setServers] = useState<McpServer[]>([]);
const [catalog, setCatalog] = useState<McpCatalogEntry[]>([]);
const [diagnostics, setDiagnostics] = useState<McpCatalogDiagnostic[]>([]);
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
const { setEnd } = usePageHeader();
// Add server modal state
const [createModalOpen, setCreateModalOpen] = useState(false);
const [name, setName] = useState("");
const [transport, setTransport] = useState<McpTransport>("http");
const [url, setUrl] = useState("");
const [httpAuth, setHttpAuth] = useState<McpHttpAuth>("none");
const [bearerToken, setBearerToken] = useState("");
const [command, setCommand] = useState("");
const [args, setArgs] = useState("");
const [env, setEnv] = useState("");
const [creating, setCreating] = useState(false);
const closeCreateModal = useCallback(() => {
setBearerToken("");
setCreateModalOpen(false);
}, []);
const createModalRef = useModalBehavior({
open: createModalOpen,
onClose: closeCreateModal,
});
// Test results keyed by server name
const [testing, setTesting] = useState<string | null>(null);
const [authenticating, setAuthenticating] = useState<string | null>(null);
const [testResults, setTestResults] = useState<Record<string, McpTestResult>>(
{},
);
// Enable/disable state
const [togglingName, setTogglingName] = useState<string | null>(null);
const [restartNote, setRestartNote] = useState<string | null>(null);
// Catalog install modal state
const [installEntry, setInstallEntry] = useState<McpCatalogEntry | null>(
null,
);
const [installEnv, setInstallEnv] = useState<Record<string, string>>({});
const [installingName, setInstallingName] = useState<string | null>(null);
const closeInstallModal = useCallback(() => setInstallEntry(null), []);
const installModalRef = useModalBehavior({
open: installEntry !== null,
onClose: closeInstallModal,
});
const loadServers = useCallback(() => {
return api
.getMcpServers()
.then((res) => setServers(res.servers))
.catch((e) => showToast(`Error: ${e}`, "error"));
}, [showToast]);
const loadCatalog = useCallback(() => {
return api
.getMcpCatalog()
.then((res) => {
setCatalog(res.entries);
setDiagnostics(res.diagnostics);
})
.catch((e) => showToast(`Error: ${e}`, "error"));
}, [showToast]);
useEffect(() => {
Promise.all([loadServers(), loadCatalog()]).finally(() =>
setLoading(false),
);
}, [loadServers, loadCatalog]);
const handleCreate = async () => {
let body;
try {
body = buildMcpServerCreate({
name,
transport,
url,
httpAuth,
bearerToken,
command,
args,
env,
});
} catch (error) {
showToast(
error instanceof Error ? error.message : "Invalid MCP server",
"error",
);
return;
}
setCreating(true);
try {
await api.addMcpServer(body);
showToast(
transport === "http" && httpAuth === "oauth"
? "Added — authenticate with OAuth"
: "Add ✓",
"success",
);
setName("");
setUrl("");
setHttpAuth("none");
setBearerToken("");
setCommand("");
setArgs("");
setEnv("");
setTransport("http");
setCreateModalOpen(false);
loadServers();
} catch (e) {
showToast(`Failed to add: ${e}`, "error");
} finally {
setCreating(false);
}
};
const handleTest = async (server: McpServer) => {
setTesting(server.name);
try {
const result = await api.testMcpServer(server.name);
setTestResults((prev) => ({ ...prev, [server.name]: result }));
if (result.ok) {
showToast(`${server.name}: ${result.tools.length} tool(s)`, "success");
} else {
showToast(`${server.name}: ${result.error ?? "Failed"}`, "error");
}
} catch (e) {
showToast(`Error: ${e}`, "error");
} finally {
setTesting(null);
}
};
const handleAuthenticate = async (server: McpServer) => {
setAuthenticating(server.name);
try {
const result = await completeMcpDashboardOAuth({
serverName: server.name,
start: api.authMcpServer,
status: api.getMcpOAuthFlow,
open: window.open.bind(window),
});
setTestResults((prev) => ({
...prev,
[server.name]: { ok: true, tools: result.tools ?? [] },
}));
showToast(`${server.name}: OAuth authentication complete`, "success");
} catch (e) {
showToast(`OAuth error: ${e}`, "error");
} finally {
setAuthenticating(null);
}
};
const handleToggleEnabled = async (server: McpServer) => {
const next = !server.enabled;
setTogglingName(server.name);
try {
await api.setMcpServerEnabled(server.name, next);
setServers((prev) =>
prev.map((s) => (s.name === server.name ? { ...s, enabled: next } : s)),
);
setRestartNote(
"Enable/disable takes effect on the next gateway restart.",
);
} catch (e) {
showToast(`Error: ${e}`, "error");
} finally {
setTogglingName(null);
}
};
const serverDelete = useConfirmDelete({
onDelete: useCallback(
async (serverName: string) => {
try {
await api.removeMcpServer(serverName);
showToast(`Delete: "${truncateText(serverName, 30)}"`, "success");
setTestResults((prev) => {
const next = { ...prev };
delete next[serverName];
return next;
});
loadServers();
} catch (e) {
showToast(`Error: ${e}`, "error");
throw e;
}
},
[loadServers, showToast],
),
});
// ── Catalog install ──────────────────────────────────────────────────
const runInstall = useCallback(
async (entry: McpCatalogEntry, envMap: Record<string, string>) => {
setInstallingName(entry.name);
try {
const res = await api.installMcpCatalogEntry(entry.name, envMap, true);
if (res.background) {
showToast("Installing in background…", "success");
} else {
showToast(`Installed: "${truncateText(entry.name, 30)}"`, "success");
}
setInstallEntry(null);
setInstallEnv({});
await Promise.all([loadServers(), loadCatalog()]);
} catch (e) {
showToast(`Failed to install: ${e}`, "error");
} finally {
setInstallingName(null);
}
},
[loadServers, loadCatalog, showToast],
);
const handleInstallClick = (entry: McpCatalogEntry) => {
if (entry.required_env.length > 0) {
const initial: Record<string, string> = {};
entry.required_env.forEach((item) => {
initial[item.name] = "";
});
setInstallEnv(initial);
setInstallEntry(entry);
} else {
void runInstall(entry, {});
}
};
const handleInstallSubmit = () => {
if (!installEntry) return;
const missing = installEntry.required_env.filter(
(item) => item.required && !(installEnv[item.name] ?? "").trim(),
);
if (missing.length > 0) {
showToast(`${missing[0].prompt} required`, "error");
return;
}
const envMap: Record<string, string> = {};
Object.entries(installEnv).forEach(([k, v]) => {
if (v.trim()) envMap[k] = v.trim();
});
void runInstall(installEntry, envMap);
};
// Put "Add Server" button in page header
useLayoutEffect(() => {
setEnd(
<Button
className="uppercase"
size="sm"
onClick={() => setCreateModalOpen(true)}
>
Add Server
</Button>,
);
return () => {
setEnd(null);
};
}, [setEnd, loading]);
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
const diagnosticsByName: Record<string, McpCatalogDiagnostic[]> = {};
diagnostics.forEach((d) => {
(diagnosticsByName[d.name] ??= []).push(d);
});
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
<DeleteConfirmDialog
open={serverDelete.isOpen}
onCancel={serverDelete.cancel}
onConfirm={serverDelete.confirm}
title="Remove MCP server"
description={
serverDelete.pendingId
? `"${truncateText(serverDelete.pendingId, 40)}" — this will remove the server.`
: "This will remove the server."
}
loading={serverDelete.isDeleting}
/>
{/* Add server modal */}
{createModalOpen && (
<div
ref={createModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
onClick={(e) => e.target === e.currentTarget && closeCreateModal()}
role="dialog"
aria-modal="true"
aria-labelledby="create-mcp-title"
>
<div
className={cn(
themedBody,
"relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col",
)}
>
<Button
ghost
size="icon"
onClick={closeCreateModal}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X />
</Button>
<header className="p-5 pb-3 border-b border-border">
<h2
id="create-mcp-title"
className="font-mondwest text-display text-base tracking-wider"
>
Add MCP server
</h2>
</header>
<div className="p-5 grid gap-4">
<div className="grid gap-2">
<Label htmlFor="mcp-name">Name</Label>
<Input
id="mcp-name"
autoFocus
placeholder="my-server"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="mcp-transport">Transport</Label>
<Select
id="mcp-transport"
value={transport}
onValueChange={(value) => {
const nextTransport = value as McpTransport;
setTransport(nextTransport);
if (nextTransport === "stdio") setBearerToken("");
}}
>
<SelectOption value="http">HTTP/SSE</SelectOption>
<SelectOption value="stdio">stdio</SelectOption>
</Select>
</div>
{transport === "http" ? (
<>
<div className="grid gap-2">
<Label htmlFor="mcp-url">URL</Label>
<Input
id="mcp-url"
placeholder="https://example.com/mcp"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="mcp-auth">Authentication</Label>
<Select
id="mcp-auth"
value={httpAuth}
onValueChange={(value) => {
const nextAuth = value as McpHttpAuth;
setHttpAuth(nextAuth);
if (nextAuth !== "header") setBearerToken("");
}}
>
<SelectOption value="none">None</SelectOption>
<SelectOption value="header">Bearer token</SelectOption>
<SelectOption value="oauth">OAuth</SelectOption>
</Select>
</div>
{httpAuth === "header" && (
<div className="grid gap-2">
<Label htmlFor="mcp-bearer-token">Bearer token</Label>
<Input
id="mcp-bearer-token"
type="password"
autoComplete="new-password"
placeholder="Token or Bearer token"
value={bearerToken}
onChange={(e) => setBearerToken(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Stored in this profile&apos;s .env; config.yaml keeps
only an environment-variable reference.
</p>
</div>
)}
{httpAuth === "oauth" && (
<p className="text-xs text-muted-foreground">
Add the server, then use Authenticate. Hermes opens the
OAuth browser on the machine running the Dashboard
backend.
</p>
)}
</>
) : (
<>
<div className="grid gap-2">
<Label htmlFor="mcp-command">Command</Label>
<Input
id="mcp-command"
placeholder="npx"
value={command}
onChange={(e) => setCommand(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="mcp-args">Args</Label>
<Input
id="mcp-args"
placeholder="-y @modelcontextprotocol/server-foo"
value={args}
onChange={(e) => setArgs(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="mcp-env">
Environment (KEY=VALUE per line)
</Label>
<textarea
id="mcp-env"
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
placeholder={"API_KEY=secret\nDEBUG=1"}
value={env}
onChange={(e) => setEnv(e.target.value)}
/>
</div>
</>
)}
<div className="flex justify-end">
<Button
className="uppercase"
size="sm"
onClick={handleCreate}
disabled={creating}
prefix={creating ? <Spinner /> : undefined}
>
{creating ? "Adding..." : "Add"}
</Button>
</div>
</div>
</div>
</div>
)}
{/* Catalog install modal (required env vars) */}
{installEntry && (
<div
ref={installModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
onClick={(e) => e.target === e.currentTarget && setInstallEntry(null)}
role="dialog"
aria-modal="true"
aria-labelledby="install-mcp-title"
>
<div
className={cn(
themedBody,
"relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col",
)}
>
<Button
ghost
size="icon"
onClick={() => setInstallEntry(null)}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X />
</Button>
<header className="p-5 pb-3 border-b border-border">
<h2
id="install-mcp-title"
className="font-mondwest text-display text-base tracking-wider"
>
Install {installEntry.name}
</h2>
</header>
<div className="p-5 grid gap-4">
<p className="text-xs text-muted-foreground">
This MCP requires the following values to be configured.
</p>
{installEntry.required_env.map((item) => (
<div className="grid gap-2" key={item.name}>
<Label htmlFor={`install-env-${item.name}`}>
{item.prompt}
{item.required ? " *" : ""}
</Label>
<Input
id={`install-env-${item.name}`}
type="password"
placeholder={item.name}
value={installEnv[item.name] ?? ""}
onChange={(e) =>
setInstallEnv((prev) => ({
...prev,
[item.name]: e.target.value,
}))
}
/>
</div>
))}
<div className="flex justify-end">
<Button
className="uppercase"
size="sm"
onClick={handleInstallSubmit}
disabled={installingName === installEntry.name}
prefix={
installingName === installEntry.name ? (
<Spinner />
) : undefined
}
>
{installingName === installEntry.name
? "Installing..."
: "Install"}
</Button>
</div>
</div>
</div>
</div>
)}
{/* ── Your MCP servers ── */}
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<H2
variant="sm"
className="flex items-center gap-2 text-muted-foreground"
>
<Server className="h-4 w-4" />
Your MCP servers ({servers.length})
</H2>
</div>
{restartNote && <p className="text-xs text-warning">{restartNote}</p>}
{servers.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No MCP servers configured.
</CardContent>
</Card>
)}
{servers.map((server) => {
const envCount = Object.keys(server.env ?? {}).length;
const result = testResults[server.name];
return (
<Card key={server.name}>
<CardContent
className={cn(
"flex items-start gap-4 py-4",
!server.enabled && "opacity-60",
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm truncate">
{server.name}
</span>
<Badge
tone={TRANSPORT_TONE[server.transport] ?? "secondary"}
>
{server.transport}
</Badge>
{server.auth && (
<Badge tone="outline">
auth:{" "}
{server.auth === "header" ? "bearer" : server.auth}
</Badge>
)}
{!server.enabled && <Badge tone="outline">disabled</Badge>}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{server.transport === "http" ? (
<span className="font-mono truncate">
{server.url ?? "—"}
</span>
) : (
<span className="font-mono truncate">
{[server.command, ...(server.args ?? [])]
.filter(Boolean)
.join(" ") || "—"}
</span>
)}
{envCount > 0 && (
<span>
{envCount} env var{envCount === 1 ? "" : "s"}
</span>
)}
</div>
{result && (
<div className="mt-2 text-xs">
{result.ok ? (
<p className="text-success">
{result.tools.length === 0
? "Connected — no tools"
: `Tools: ${result.tools
.map((tool) => tool.name)
.join(", ")}`}
</p>
) : (
<p className="text-destructive">
{result.error ?? "Connection failed"}
</p>
)}
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{server.auth === "oauth" && (
<Button
ghost
size="sm"
title="Authenticate with OAuth"
onClick={() => handleAuthenticate(server)}
disabled={authenticating === server.name}
prefix={
authenticating === server.name ? (
<Spinner />
) : (
<KeyRound />
)
}
>
Authenticate
</Button>
)}
<Button
ghost
size="sm"
title={server.enabled ? "Disable" : "Enable"}
aria-label={server.enabled ? "Disable" : "Enable"}
onClick={() => handleToggleEnabled(server)}
disabled={togglingName === server.name}
prefix={
togglingName === server.name ? <Spinner /> : <Power />
}
className={server.enabled ? "text-success" : undefined}
>
{server.enabled ? "Disable" : "Enable"}
</Button>
<Button
ghost
size="icon"
title="Test connection"
aria-label="Test connection"
onClick={() => handleTest(server)}
disabled={testing === server.name}
>
{testing === server.name ? <Spinner /> : <Zap />}
</Button>
<Button
ghost
destructive
size="icon"
title="Delete"
aria-label="Delete"
onClick={() => serverDelete.requestDelete(server.name)}
>
<Trash2 />
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
{/* ── Catalog ── */}
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<H2
variant="sm"
className="flex items-center gap-2 text-muted-foreground"
>
<Package className="h-4 w-4" />
Catalog ({catalog.length})
</H2>
</div>
<p className="text-xs text-muted-foreground">
Browse Nous-approved MCP servers and install them with one click.
</p>
{catalog.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No catalog entries available.
</CardContent>
</Card>
)}
{catalog.map((entry) => {
const entryDiags = diagnosticsByName[entry.name] ?? [];
const isInstalling = installingName === entry.name;
return (
<Card key={entry.name}>
<CardContent className="flex items-start gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="font-medium text-sm truncate">
{entry.name}
</span>
<Badge
tone={TRANSPORT_TONE[entry.transport] ?? "secondary"}
>
{entry.transport}
</Badge>
<Badge tone="outline">auth: {entry.auth_type}</Badge>
{isHttpUrl(entry.source) ? (
<a
href={entry.source}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary underline underline-offset-2 hover:opacity-80"
>
source
</a>
) : (
entry.source && (
<Badge tone="outline">{entry.source}</Badge>
)
)}
{entry.installed && <Badge tone="success">Installed</Badge>}
{entry.installed && !entry.enabled && (
<Badge tone="outline">disabled</Badge>
)}
</div>
{entry.description && (
<p className="text-xs text-muted-foreground">
{entry.description}
</p>
)}
{/* Connection detail: what the agent actually talks to. */}
{entry.transport === "http" && entry.url && (
<p className="mt-1 text-xs text-muted-foreground">
<span className="font-medium">Endpoint:</span>{" "}
<code className="font-mono">{entry.url}</code>
</p>
)}
{entry.transport === "stdio" && entry.command && (
<p className="mt-1 text-xs text-muted-foreground break-all">
<span className="font-medium">Runs:</span>{" "}
<code className="font-mono">
{[entry.command, ...entry.args].join(" ")}
</code>
</p>
)}
{/* Git bootstrap — surfaced so users see what gets cloned/run
before they install (matches the docs trust model). */}
{entry.install_url && (
<p className="mt-1 text-xs text-muted-foreground break-all">
<span className="font-medium">Installs from:</span>{" "}
{isHttpUrl(entry.install_url) ? (
<a
href={entry.install_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2 hover:opacity-80"
>
{entry.install_url}
</a>
) : (
<code className="font-mono">{entry.install_url}</code>
)}
{entry.install_ref && <span> @ {entry.install_ref}</span>}
</p>
)}
{entry.bootstrap.length > 0 && (
<details className="mt-1 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none">
Bootstrap commands ({entry.bootstrap.length})
</summary>
<ul className="mt-1 ml-3 list-disc space-y-0.5">
{entry.bootstrap.map((cmd, i) => (
<li
key={`${entry.name}-bs-${i}`}
className="break-all"
>
<code className="font-mono">{cmd}</code>
</li>
))}
</ul>
</details>
)}
{entry.post_install && (
<details className="mt-1 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none">
Setup notes
</summary>
<p className="mt-1 whitespace-pre-wrap">
{entry.post_install.trim()}
</p>
</details>
)}
{entryDiags.map((d, i) => (
<p
key={`${entry.name}-diag-${i}`}
className="text-xs text-warning mt-1"
>
{d.message}
</p>
))}
</div>
<div className="flex items-center gap-1 shrink-0">
{entry.installed ? (
<Badge tone="success">Installed</Badge>
) : (
<Button
className="uppercase"
size="sm"
onClick={() => handleInstallClick(entry)}
disabled={isInstalling}
prefix={isInstalling ? <Spinner /> : undefined}
>
{isInstalling ? "Installing..." : "Install"}
</Button>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+273
View File
@@ -0,0 +1,273 @@
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { Check, ShieldCheck, Trash2, Users, X } from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
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 { PairingResponse, PairingUser } from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
import { usePageHeader } from "@/contexts/usePageHeader";
function getUserKey(user: PairingUser): string {
return `${user.platform}:${user.user_id}`;
}
function splitUserKey(key: string): { platform: string; user_id: string } {
const idx = key.indexOf(":");
if (idx === -1) return { platform: "", user_id: key };
return { platform: key.slice(0, idx), user_id: key.slice(idx + 1) };
}
function getUserLabel(user: PairingUser): string {
return user.user_name || user.user_id;
}
export default function PairingPage() {
const [pending, setPending] = useState<PairingUser[]>([]);
const [approved, setApproved] = useState<PairingUser[]>([]);
const [loading, setLoading] = useState(true);
const [approving, setApproving] = useState<string | null>(null);
const [clearing, setClearing] = useState(false);
const { toast, showToast } = useToast();
const { setEnd } = usePageHeader();
const loadPairing = useCallback(() => {
api
.getPairing()
.then((res: PairingResponse) => {
setPending(res.pending);
setApproved(res.approved);
})
.catch(() => showToast("Failed to load pairing requests", "error"))
.finally(() => setLoading(false));
}, [showToast]);
useEffect(() => {
loadPairing();
}, [loadPairing]);
const handleApprove = async (user: PairingUser) => {
if (!user.request_id) {
showToast("Missing pairing request", "error");
return;
}
const key = getUserKey(user);
setApproving(key);
try {
await api.approvePairing(user.platform, user.request_id);
showToast(`Approved: "${getUserLabel(user)}"`, "success");
loadPairing();
} catch (e) {
showToast(`Error: ${e}`, "error");
} finally {
setApproving(null);
}
};
const handleClearPending = async () => {
if (!window.confirm("Clear all pending pairing requests?")) return;
setClearing(true);
try {
const res = await api.clearPendingPairing();
showToast(`Cleared ${res.cleared} pending request(s)`, "success");
loadPairing();
} catch (e) {
showToast(`Error: ${e}`, "error");
} finally {
setClearing(false);
}
};
const userRevoke = useConfirmDelete({
onDelete: useCallback(
async (key: string) => {
const { platform, user_id } = splitUserKey(key);
const user = approved.find((u) => getUserKey(u) === key);
try {
await api.revokePairing(platform, user_id);
showToast(
`Revoked: "${user ? getUserLabel(user) : user_id}"`,
"success",
);
loadPairing();
} catch (e) {
showToast(`Error: ${e}`, "error");
throw e;
}
},
[approved, loadPairing, showToast],
),
});
// Put "Clear pending" button in page header
useLayoutEffect(() => {
setEnd(
<Button
className="uppercase"
size="sm"
onClick={handleClearPending}
disabled={clearing}
prefix={clearing ? <Spinner /> : <Trash2 className="h-4 w-4" />}
>
Clear pending
</Button>,
);
return () => {
setEnd(null);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setEnd, clearing]);
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
const pendingRevokeUser = userRevoke.pendingId
? approved.find((u) => getUserKey(u) === userRevoke.pendingId)
: null;
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
<DeleteConfirmDialog
open={userRevoke.isOpen}
onCancel={userRevoke.cancel}
onConfirm={userRevoke.confirm}
title="Revoke access"
description={
pendingRevokeUser
? `"${getUserLabel(pendingRevokeUser)}" will lose access. This cannot be undone.`
: "This user will lose access. This cannot be undone."
}
confirmLabel="Revoke"
loading={userRevoke.isDeleting}
/>
{/* Pending requests */}
<div className="flex flex-col gap-3">
<H2
variant="sm"
className="flex items-center gap-2 text-muted-foreground"
>
<Users className="h-4 w-4" />
Pending requests ({pending.length})
</H2>
{pending.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No pending pairing requests
</CardContent>
</Card>
)}
{pending.map((user) => {
const key = getUserKey(user);
return (
<Card key={key}>
<CardContent className="flex items-start gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge tone="outline">{user.platform}</Badge>
<span className="font-medium text-sm truncate">
{getUserLabel(user)}
</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="truncate">{user.user_id}</span>
{typeof user.age_minutes === "number" && (
<span>{user.age_minutes}m ago</span>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
size="sm"
className="uppercase"
onClick={() => handleApprove(user)}
disabled={approving === key || !user.request_id}
prefix={
approving === key ? (
<Spinner />
) : (
<Check className="h-4 w-4" />
)
}
>
Approve
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
{/* Approved users */}
<div className="flex flex-col gap-3">
<H2
variant="sm"
className="flex items-center gap-2 text-muted-foreground"
>
<ShieldCheck className="h-4 w-4" />
Approved users ({approved.length})
</H2>
{approved.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No approved users
</CardContent>
</Card>
)}
{approved.map((user) => {
const key = getUserKey(user);
return (
<Card key={key}>
<CardContent className="flex items-start gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge tone="outline">{user.platform}</Badge>
<span className="font-medium text-sm truncate">
{user.user_id}
</span>
</div>
{user.user_name && (
<div className="text-xs text-muted-foreground truncate">
{user.user_name}
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
ghost
size="icon"
title="Revoke"
aria-label="Revoke"
className="text-destructive"
onClick={() => userRevoke.requestDelete(key)}
>
<X />
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+833
View File
@@ -0,0 +1,833 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router";
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 {
McpHttpAuth,
McpServerCreate,
SkillInfo,
SkillHubResult,
} from "@/lib/api";
import {
buildMcpServerCreate,
emptyMcpServerDraft,
type McpServerDraft,
type McpTransport,
} from "@/lib/mcp-server-create";
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<McpServerDraft>(emptyMcpServerDraft);
// ── 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 = () => {
let entry: McpServerCreate;
try {
entry = buildMcpServerCreate(mcpDraft);
} catch (error) {
showToast(
error instanceof Error ? error.message : "Invalid MCP server",
"error",
);
return;
}
setMcpServers((prev) => [
...prev.filter((server) => server.name !== entry.name),
entry,
]);
setMcpDraft(emptyMcpServerDraft());
};
const removeMcp = (n: string) =>
setMcpServers((prev) => prev.filter((s) => s.name !== n));
const setMcpTransport = (transport: McpTransport) => {
setMcpDraft((draft) =>
transport === "http"
? { ...draft, transport, command: "", args: "", env: "" }
: {
...draft,
transport,
url: "",
httpAuth: "none",
bearerToken: "",
},
);
};
const setMcpHttpAuth = (httpAuth: McpHttpAuth) => {
setMcpDraft((draft) => ({
...draft,
httpAuth,
bearerToken: httpAuth === "header" ? draft.bearerToken : "",
}));
};
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: null,
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-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h3 className="font-expanded text-base font-bold tracking-[0.04em]">
MCP servers
</h3>
<p className="text-sm text-muted-foreground">
Add MCP servers to give this profile access to external
tools and data.
</p>
</div>
<span
className="text-xs text-muted-foreground"
aria-live="polite"
>
{mcpServers.length} configured
</span>
</div>
<div className="space-y-4 border border-border bg-background/20 p-4 md:p-5">
<h4 className="font-medium">Add server</h4>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="pb-mcp-name">Server name</Label>
<Input
id="pb-mcp-name"
placeholder="Enter server name"
value={mcpDraft.name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({ ...mcpDraft, name: e.target.value })
}
/>
</div>
<div className="grid gap-1.5">
<Label>Transport</Label>
<div
className="grid grid-cols-2 border border-border bg-background/30 p-0.5"
role="group"
aria-label="MCP transport"
>
{(
[
["http", "HTTP/SSE"],
["stdio", "stdio"],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
aria-pressed={mcpDraft.transport === value}
className={cn(
"px-3 py-2 text-sm font-medium transition-colors",
mcpDraft.transport === value
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
onClick={() => setMcpTransport(value)}
>
{label}
</button>
))}
</div>
</div>
</div>
{mcpDraft.transport === "http" ? (
<>
<div className="grid gap-1.5">
<Label htmlFor="pb-mcp-url">URL</Label>
<Input
id="pb-mcp-url"
placeholder="https://example.com/mcp"
value={mcpDraft.url}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({ ...mcpDraft, url: e.target.value })
}
/>
</div>
<div className="grid gap-1.5">
<Label>Authentication</Label>
<div
className="grid grid-cols-3 border border-border bg-background/30 p-0.5 md:max-w-md"
role="group"
aria-label="HTTP authentication"
>
{(
[
["none", "None"],
["header", "Bearer token"],
["oauth", "OAuth"],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
aria-pressed={mcpDraft.httpAuth === value}
className={cn(
"px-2 py-2 text-sm font-medium transition-colors",
mcpDraft.httpAuth === value
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
onClick={() => setMcpHttpAuth(value)}
>
{label}
</button>
))}
</div>
</div>
{mcpDraft.httpAuth === "header" && (
<div className="grid gap-1.5">
<Label htmlFor="pb-mcp-bearer-token">
Bearer token
</Label>
<Input
id="pb-mcp-bearer-token"
type="password"
autoComplete="new-password"
placeholder="Token or Bearer token"
value={mcpDraft.bearerToken}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({
...mcpDraft,
bearerToken: e.target.value,
})
}
/>
<p className="text-xs text-muted-foreground">
Stored in the new profile&apos;s .env; config.yaml
keeps only an environment-variable reference.
</p>
</div>
)}
{mcpDraft.httpAuth === "oauth" && (
<p className="text-xs text-muted-foreground">
After creating the profile, open its MCP page and use
Authenticate to complete OAuth.
</p>
)}
</>
) : (
<>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="pb-mcp-command">Command</Label>
<Input
id="pb-mcp-command"
placeholder="npx"
value={mcpDraft.command}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({
...mcpDraft,
command: e.target.value,
})
}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="pb-mcp-args">Arguments</Label>
<Input
id="pb-mcp-args"
placeholder="-y @modelcontextprotocol/server"
value={mcpDraft.args}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({ ...mcpDraft, args: e.target.value })
}
/>
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="pb-mcp-env">
Environment (KEY=VALUE per line)
</Label>
<textarea
id="pb-mcp-env"
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:border-foreground/25 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30"
placeholder={"API_KEY=secret\nDEBUG=1"}
value={mcpDraft.env}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setMcpDraft({ ...mcpDraft, env: e.target.value })
}
/>
</div>
</>
)}
<div className="flex justify-end">
<Button onClick={addMcpDraft}>Add server</Button>
</div>
</div>
{mcpServers.length > 0 && (
<div className="space-y-2">
{mcpServers.map((s) => (
<div
key={s.name}
className="flex items-center justify-between gap-4 border border-border bg-muted/40 p-4 text-sm"
>
<span className="min-w-0">
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium">{s.name}</span>
<Badge tone="outline">
{s.url ? "HTTP" : "stdio"}
</Badge>
{s.auth && (
<Badge tone="outline">
auth: {s.auth === "header" ? "bearer" : s.auth}
</Badge>
)}
</span>
<span className="mt-1 block break-all text-xs text-muted-foreground">
{s.url || [s.command, ...(s.args || [])].join(" ")}
</span>
</span>
<Button
size="sm"
ghost
destructive
className="shrink-0"
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>
);
}
File diff suppressed because it is too large Load Diff
+154
View File
@@ -0,0 +1,154 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const apiMocks = vi.hoisted(() => ({
getSessions: vi.fn(),
getSessionMessages: vi.fn(),
getEmptySessionsCount: vi.fn(),
getStatus: vi.fn(),
searchSessions: vi.fn(),
importSessions: vi.fn(),
exportSessionUrl: vi.fn(),
renameSession: vi.fn(),
pruneSessions: vi.fn(),
deleteSession: vi.fn(),
deleteEmptySessions: vi.fn(),
bulkDeleteSessions: vi.fn(),
getProfiles: vi.fn(),
getActiveProfile: vi.fn(),
getSessionStats: vi.fn(),
}));
vi.mock("@/lib/api", () => ({
api: apiMocks,
// ProfileProvider mirrors its selection into the api module.
setManagementProfile: vi.fn(),
getManagementProfile: vi.fn(() => ""),
}));
vi.mock("@/components/PlatformsCard", () => ({ PlatformsCard: () => null }));
vi.mock("@/components/Markdown", () => ({ Markdown: () => null }));
let container: HTMLDivElement;
let root: Root;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
async function waitFor(cond: () => boolean, timeoutMs = 5000) {
const start = Date.now();
while (!cond()) {
if (Date.now() - start > timeoutMs) throw new Error("waitFor: condition never became true");
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
}
}
function click(el: Element | null) {
if (!el) throw new Error("element not rendered");
el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
}
const button = (label: string) => document.querySelector(`button[aria-label="${label}"]`);
async function renderSessionsPage(rows: Record<string, unknown>[]) {
// Page list uses limit 20; the overview tab's recent-cards fetch uses 50 —
// keep the overview empty so the list view (with row actions) renders.
apiMocks.getSessions.mockImplementation(async (limit: number) => ({
sessions: limit >= 50 ? [] : rows,
total: limit >= 50 ? 0 : rows.length,
limit,
offset: 0,
}));
const [{ default: SessionsPage }, { I18nProvider }, { SystemActionsProvider }, { ProfileProvider }, { PageHeaderProvider }] =
await Promise.all([
import("./SessionsPage"),
import("@/i18n"),
import("@/contexts/SystemActions"),
import("@/contexts/ProfileProvider"),
import("@/contexts/PageHeaderProvider"),
]);
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
await act(async () =>
root.render(
<I18nProvider>
<MemoryRouter>
<SystemActionsProvider>
<ProfileProvider>
<PageHeaderProvider pluginTabs={[]}>
<SessionsPage />
</PageHeaderProvider>
</ProfileProvider>
</SystemActionsProvider>
</MemoryRouter>
</I18nProvider>,
),
);
await waitFor(() => Boolean(button("Delete session")));
}
beforeEach(() => {
for (const fn of Object.values(apiMocks)) fn.mockReset();
apiMocks.getStatus.mockResolvedValue({});
apiMocks.getEmptySessionsCount.mockResolvedValue({ count: 0 });
apiMocks.getProfiles.mockResolvedValue({ profiles: [] });
// active === current keeps the management profile "" — the precondition
// under which an unstamped request hits the process's own store.
apiMocks.getActiveProfile.mockResolvedValue({ current: "default", active: "default" });
apiMocks.getSessionStats.mockResolvedValue({ by_source: {} });
apiMocks.getSessionMessages.mockResolvedValue({ messages: [] });
apiMocks.deleteSession.mockResolvedValue({ ok: true });
apiMocks.renameSession.mockResolvedValue({ ok: true, title: "Renamed" });
apiMocks.exportSessionUrl.mockReturnValue("/api/sessions/x/export");
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 500 })));
vi.stubGlobal("ResizeObserver", class { disconnect() {} observe() {} unobserve() {} });
// gsap ticks through rAF; a synchronous callback recurses to death.
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => setTimeout(() => cb(0), 0) as unknown as number);
vi.stubGlobal("cancelAnimationFrame", (id: number) => clearTimeout(id));
vi.stubGlobal("matchMedia", () => ({ addEventListener() {}, matches: false, media: "", removeEventListener() {} }));
sessionStorage.clear();
});
afterEach(async () => {
await act(async () => root?.unmount());
container?.remove();
vi.unstubAllGlobals();
});
describe("SessionsPage per-row profile routing (#99387)", () => {
it("sends every per-row request to the row's owning profile, not the management default", async () => {
await renderSessionsPage([
{ id: "sid-guanli", profile: "guanli", source: "cli", model: null, title: "Managed", started_at: 1, ended_at: null,
last_active: 1, is_active: false, message_count: 2, tool_call_count: 0, input_tokens: 1, output_tokens: 1, preview: "hi" },
]);
// expand → transcript read
await act(async () => click(button("Delete session")!.closest("div.cursor-pointer")));
await waitFor(() => apiMocks.getSessionMessages.mock.calls.length > 0);
expect(apiMocks.getSessionMessages).toHaveBeenCalledWith("sid-guanli", "guanli");
await act(async () => click(button("Export session")));
expect(apiMocks.exportSessionUrl).toHaveBeenCalledWith("sid-guanli", "guanli");
await act(async () => click(button("Rename session")));
const input = document.querySelector<HTMLInputElement>('input[placeholder="Session title"]');
if (!input) throw new Error("rename input not rendered");
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "Renamed");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => click(button("Save title")));
expect(apiMocks.renameSession).toHaveBeenCalledWith("sid-guanli", "Renamed", "guanli");
await act(async () => click(button("Delete session")));
await waitFor(() => Boolean(document.querySelector('[role="alertdialog"]')));
const confirm = Array.from(document.querySelectorAll('[role="alertdialog"] button')).find(
(b) => b.textContent?.trim() === "Delete",
);
await act(async () => click(confirm ?? null));
expect(apiMocks.deleteSession).toHaveBeenCalledWith("sid-guanli", "guanli");
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+612
View File
@@ -0,0 +1,612 @@
import { useCallback, useEffect, useLayoutEffect, useState } from "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";
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 { WebhookRoute, WebhooksResponse } from "@/lib/api";
import { copyTextToClipboard } from "@/lib/clipboard";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
import { useModalBehavior } from "@/hooks/useModalBehavior";
import { Toast } from "@nous-research/ui/ui/components/toast";
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 { usePageHeader } from "@/contexts/usePageHeader";
import { cn, themedBody } from "@/lib/utils";
interface CreatedWebhook {
url: string;
secret: string;
}
function CopyButton({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
void copyTextToClipboard(value).then((copied) => {
if (!copied) return;
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
});
}, [value]);
return (
<Button
ghost
size="icon"
title="Copy"
aria-label="Copy"
onClick={handleCopy}
className="text-muted-foreground hover:text-foreground"
>
{copied ? <Check /> : <Copy />}
</Button>
);
}
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();
// New subscription modal state
const [createModalOpen, setCreateModalOpen] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [events, setEvents] = useState("");
const [deliver, setDeliver] = useState("log");
const [deliverOnly, setDeliverOnly] = useState(false);
const [prompt, setPrompt] = useState("");
const [creating, setCreating] = useState(false);
const [created, setCreated] = useState<CreatedWebhook | null>(null);
const closeCreateModal = useCallback(() => {
setCreateModalOpen(false);
setCreated(null);
}, []);
const createModalRef = useModalBehavior({
open: createModalOpen,
onClose: closeCreateModal,
});
const enabled = data?.enabled ?? false;
const subscriptions = data?.subscriptions ?? [];
const loadWebhooks = useCallback(() => {
return api
.getWebhooks()
.then(setData)
.catch(() => showToast("Failed to load webhooks", "error"))
.finally(() => setLoading(false));
}, [showToast]);
useEffect(() => {
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("");
setEvents("");
setDeliver("log");
setDeliverOnly(false);
setPrompt("");
}, []);
const handleCreate = async () => {
if (!name.trim()) {
showToast("Name required", "error");
return;
}
setCreating(true);
try {
const eventsList = events
.split(",")
.map((e) => e.trim())
.filter(Boolean);
const res = await api.createWebhook({
name: name.trim(),
description: description.trim() || undefined,
events: eventsList.length ? eventsList : undefined,
deliver,
deliver_only: deliverOnly,
prompt: prompt.trim() || undefined,
});
showToast("Created ✓", "success");
setCreated({ url: res.url, secret: res.secret });
resetForm();
loadWebhooks();
} catch (e) {
showToast(`Failed to create: ${e}`, "error");
} finally {
setCreating(false);
}
};
const [togglingName, setTogglingName] = useState<string | null>(null);
const handleToggleEnabled = useCallback(
async (subName: string, nextEnabled: boolean) => {
setTogglingName(subName);
try {
await api.setWebhookEnabled(subName, nextEnabled);
showToast(
nextEnabled ? `Enabled: "${subName}"` : `Disabled: "${subName}"`,
"success",
);
loadWebhooks();
} catch (e) {
showToast(`Error: ${e}`, "error");
} finally {
setTogglingName(null);
}
},
[loadWebhooks, showToast],
);
const webhookDelete = useConfirmDelete({
onDelete: useCallback(
async (name: string) => {
try {
await api.deleteWebhook(name);
showToast(`Deleted: "${name}"`, "success");
loadWebhooks();
} catch (e) {
showToast(`Error: ${e}`, "error");
throw e;
}
},
[loadWebhooks, showToast],
),
});
// Put "New subscription" button in page header
useLayoutEffect(() => {
setEnd(
<Button
className="uppercase"
size="sm"
disabled={!enabled || enabling}
prefix={<Plus />}
onClick={() => {
setCreated(null);
setCreateModalOpen(true);
}}
>
New subscription
</Button>,
);
return () => {
setEnd(null);
};
}, [setEnd, enabled, enabling, loading]);
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
const pendingName = webhookDelete.pendingId ?? "";
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
<DeleteConfirmDialog
open={webhookDelete.isOpen}
onCancel={webhookDelete.cancel}
onConfirm={webhookDelete.confirm}
title="Delete webhook"
description={
pendingName
? `"${pendingName}" — this will permanently remove this webhook subscription.`
: "This will permanently remove this webhook subscription."
}
loading={webhookDelete.isDeleting}
/>
{/* Create subscription modal */}
{createModalOpen && (
<div
ref={createModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
onClick={(e) => e.target === e.currentTarget && closeCreateModal()}
role="dialog"
aria-modal="true"
aria-labelledby="create-webhook-title"
>
<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")}>
<Button
ghost
size="icon"
onClick={closeCreateModal}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X />
</Button>
<header className="p-5 pb-3 border-b border-border">
<h2
id="create-webhook-title"
className="font-mondwest text-display text-base tracking-wider"
>
New subscription
</h2>
</header>
{created ? (
<div className="p-5 grid gap-4">
<p className="text-sm text-muted-foreground">
Subscription created. Copy the secret now it is only shown
once.
</p>
<div className="grid gap-2">
<Label>Webhook URL</Label>
<div className="flex items-center gap-2 border border-border bg-background/40 px-3 py-2">
<span className="flex-1 min-w-0 truncate font-mono text-xs">
{created.url}
</span>
<CopyButton value={created.url} />
</div>
</div>
<div className="grid gap-2">
<Label>Secret (shown once)</Label>
<div className="flex items-center gap-2 border border-warning/40 bg-warning/10 px-3 py-2">
<span className="flex-1 min-w-0 truncate font-mono text-xs">
{created.secret}
</span>
<CopyButton value={created.secret} />
</div>
</div>
<div className="flex justify-end">
<Button
className="uppercase"
size="sm"
onClick={closeCreateModal}
>
Done
</Button>
</div>
</div>
) : (
<div className="p-5 grid gap-4">
<div className="grid gap-2">
<Label htmlFor="webhook-name">Name</Label>
<Input
id="webhook-name"
autoFocus
placeholder="e.g. github-push"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="webhook-description">Description</Label>
<Input
id="webhook-description"
placeholder="What this webhook does (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="webhook-events">Events</Label>
<Input
id="webhook-events"
placeholder="comma-separated, leave empty for all"
value={events}
onChange={(e) => setEvents(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="webhook-deliver">Deliver to</Label>
<Select
id="webhook-deliver"
value={deliver}
onValueChange={(v) => setDeliver(v)}
>
<SelectOption value="log">Log</SelectOption>
<SelectOption value="telegram">Telegram</SelectOption>
<SelectOption value="discord">Discord</SelectOption>
<SelectOption value="slack">Slack</SelectOption>
<SelectOption value="email">Email</SelectOption>
<SelectOption value="github_comment">
GitHub comment
</SelectOption>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="webhook-deliver-only">Deliver only</Label>
<label className="flex items-center gap-2 text-sm text-muted-foreground h-9">
<input
id="webhook-deliver-only"
type="checkbox"
checked={deliverOnly}
onChange={(e) => setDeliverOnly(e.target.checked)}
/>
Skip the agent, deliver payload directly
</label>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="webhook-prompt">Prompt</Label>
<textarea
id="webhook-prompt"
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
placeholder="Instructions for the agent when this webhook fires (optional)"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
</div>
<div className="flex justify-end">
<Button
className="uppercase"
size="sm"
onClick={handleCreate}
disabled={creating}
prefix={creating ? <Spinner /> : undefined}
>
{creating ? "Creating…" : "Create"}
</Button>
</div>
</div>
)}
</div>
</div>
)}
{!enabled && (
<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>
)}
<div className="flex flex-col gap-3">
<H2
variant="sm"
className="flex items-center gap-2 text-muted-foreground"
>
<Webhook className="h-4 w-4" />
Subscriptions ({subscriptions.length})
</H2>
<p className="text-xs text-muted-foreground -mt-1">
Subscription changes hot-reload once the webhook receiver is running.
Disabled subscriptions reject incoming events.
</p>
{subscriptions.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No webhook subscriptions yet.
</CardContent>
</Card>
)}
{subscriptions.map((sub: WebhookRoute) => (
<Card key={sub.name}>
<CardContent className="flex items-start gap-4 py-4">
<div className={cn("flex-1 min-w-0", !sub.enabled && "opacity-60")}>
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="font-medium text-sm truncate">
{sub.name}
</span>
<Badge tone="outline">{sub.deliver}</Badge>
{sub.deliver_only && (
<Badge tone="secondary">deliver only</Badge>
)}
{!sub.enabled && <Badge tone="warning">disabled</Badge>}
</div>
{sub.description && (
<p className="text-xs text-muted-foreground mb-2">
{sub.description}
</p>
)}
<div className="flex items-center gap-1 flex-wrap mb-2">
{sub.events.length === 0 ? (
<Badge tone="secondary">(all)</Badge>
) : (
sub.events.map((evt) => (
<Badge key={evt} tone="secondary">
{evt}
</Badge>
))
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="flex-1 min-w-0 truncate font-mono">
{sub.url}
</span>
<CopyButton value={sub.url} />
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
ghost
size="sm"
className="uppercase"
disabled={togglingName === sub.name}
onClick={() => handleToggleEnabled(sub.name, !sub.enabled)}
>
{sub.enabled ? "Disable" : "Enable"}
</Button>
<Button
ghost
destructive
size="icon"
title="Delete"
aria-label="Delete"
onClick={() => webhookDelete.requestDelete(sub.name)}
>
<Trash2 />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}