Publish curated ITWorx_MCP_Hub source from 6c7434368ce2a594005983c5a6601a31a639e7f1

This commit is contained in:
Public Source Publisher committed 2026-10-01 18:03:00 +00:00
1 parent 77946d62c4
commit 6d40ab975c
86 files changed
+6784 -1478

No files matched your search

+211 -24
View File
@@ -1,66 +1,253 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { availableNavigation } from "../navigation";
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import type { ManagementApiClient, ResourceKind, ResourceRecord } from "../contracts";
import { ApiError, safeApiMessage } from "../api";
import { availableNavigation, navigation, type NavigationItem } from "../navigation";
import { Icon } from "./Icon";
import { Modal } from "./Modal";
type DestinationOption = {
id: string;
href: string;
label: string;
description: string;
icon: NavigationItem["icon"];
source: "navigation" | "resource";
};
type DestinationGroup = { label: string; items: DestinationOption[] };
type SearchResults = Partial<Record<ResourceKind, ResourceRecord[]>>;
type SearchState = { query: string; status: "idle" | "loading" | "ready"; results: SearchResults; error: string | null };
const ENTITY_LABELS: Record<ResourceKind, string> = {
projects: "Projects",
servers: "MCP Servers",
tools: "Catalog",
connectors: "Connectors",
clients: "Clients",
policies: "Policies",
audit: "Audit",
};
const SEARCH_KINDS: readonly ResourceKind[] = ["projects", "servers", "tools", "connectors", "clients", "policies", "audit"];
const EMPTY_SEARCH: SearchState = { query: "", status: "idle", results: {}, error: null };
function recordDestination(kind: ResourceKind, record: ResourceRecord): string {
if (kind === "projects" || kind === "connectors") return `/${kind}/${encodeURIComponent(record.id)}`;
if (kind === "tools") {
const params = new URLSearchParams({ reopen: record.canonicalName ?? record.id });
if (record.project) params.set("project", record.project);
if (record.environment) params.set("environment", record.environment);
return `/tools?${params.toString()}`;
}
if (kind === "clients") return record.onboardingStage ? `/clients?onboard=${encodeURIComponent(record.id)}` : `/clients?q=${encodeURIComponent(record.id)}`;
const exactKey = kind === "servers" ? record.id : kind === "audit" ? record.traceId ?? record.name : record.name;
return `/${kind}?q=${encodeURIComponent(exactKey)}`;
}
function searchFailure(errors: unknown[]): string | null {
if (!errors.length) return null;
const unauthorized = errors.find((error) => error instanceof ApiError && error.status === 401);
if (unauthorized) return safeApiMessage(unauthorized);
if (errors.some((error) => error instanceof ApiError && error.status === 403)) {
return "Some results are unavailable to your role, so this search is incomplete.";
}
return errors.length === 1
? "One resource type could not be searched. Results may be incomplete."
: "Some resource types could not be searched. Results may be incomplete.";
}
export function CommandMenu({
open,
api,
canReadAudit,
navigate,
onClose,
}: {
open: boolean;
api: ManagementApiClient;
canReadAudit: boolean;
navigate: (next: string) => void;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(-1);
const [recentHrefs, setRecentHrefs] = useState<string[]>([]);
const [search, setSearch] = useState<SearchState>(EMPTY_SEARCH);
const inputRef = useRef<HTMLInputElement>(null);
const items = useMemo(() => availableNavigation(canReadAudit), [canReadAudit]);
const results = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return items;
return items.filter((item) => `${item.label} ${item.description}`.toLowerCase().includes(needle));
}, [items, query]);
const normalizedQuery = query.trim().toLowerCase();
const allowedNavigation = useMemo(() => availableNavigation(canReadAudit), [canReadAudit]);
const iconForKind = (kind: ResourceKind) => allowedNavigation.find((item) => item.href === `/${kind}`)?.icon ?? "overview";
useEffect(() => {
if (!open) return;
setQuery("");
setActiveIndex(-1);
const focusTimer = window.setTimeout(() => inputRef.current?.focus(), 0);
return () => window.clearTimeout(focusTimer);
}, [open]);
const select = (href: string) => {
useEffect(() => {
if (!open) return;
const term = query.trim().toLowerCase();
if (term.length < 2) {
setSearch({ query: term, status: "idle", results: {}, error: null });
return;
}
let current = true;
setSearch({ query: term, status: "loading", results: {}, error: null });
const kinds = SEARCH_KINDS.filter((kind) => kind !== "audit" || canReadAudit);
const timer = window.setTimeout(() => {
void Promise.all(kinds.map(async (kind) => {
try {
const page = await api.resource(kind, { filter: term, limit: 6 });
return { kind, rows: page.rows, error: null as unknown };
} catch (error) {
return { kind, rows: [] as ResourceRecord[], error };
}
})).then((outcomes) => {
if (!current) return;
const results: SearchResults = {};
const errors: unknown[] = [];
for (const outcome of outcomes) {
if (outcome.error) errors.push(outcome.error);
else results[outcome.kind] = outcome.rows.slice(0, 6);
}
setSearch({ query: term, status: "ready", results, error: searchFailure(errors) });
setActiveIndex(-1);
});
}, 250);
return () => {
current = false;
window.clearTimeout(timer);
};
}, [api, canReadAudit, open, query]);
const navigationGroups = useMemo(() => navigation.flatMap((group) => {
const items = group.items
.filter((item) => allowedNavigation.some((allowed) => allowed.href === item.href))
.filter((item) => !normalizedQuery || `${item.label} ${item.description}`.toLowerCase().includes(normalizedQuery))
.map((item) => ({ id: `nav:${item.href}`, href: item.href, label: item.label, description: item.description, icon: item.icon, source: "navigation" as const }));
return items.length ? [{ label: group.label, items }] : [];
}), [allowedNavigation, normalizedQuery]);
const groups = useMemo<DestinationGroup[]>(() => {
const result: DestinationGroup[] = [];
if (!normalizedQuery) {
const recent = recentHrefs.flatMap((href) => {
const item = allowedNavigation.find((candidate) => candidate.href === href);
return item ? [{ id: `recent:${href}`, href, label: item.label, description: item.description, icon: item.icon, source: "navigation" as const }] : [];
});
if (recent.length) result.push({ label: "Recent destinations", items: recent });
}
result.push(...navigationGroups);
const hasCurrentSearch = search.query === normalizedQuery && search.status === "ready";
if (normalizedQuery.length >= 2 && hasCurrentSearch) {
for (const kind of SEARCH_KINDS) {
if (kind === "audit" && !canReadAudit) continue;
const rows = search.results[kind] ?? [];
if (!rows.length) continue;
result.push({
label: ENTITY_LABELS[kind],
items: rows.map((record) => ({
id: `resource:${kind}:${record.id}`,
href: recordDestination(kind, record),
label: record.name,
description: [record.project, record.summary].filter(Boolean).join(" · ") || ENTITY_LABELS[kind],
icon: iconForKind(kind),
source: "resource" as const,
})),
});
}
}
return result;
}, [allowedNavigation, canReadAudit, iconForKind, navigationGroups, normalizedQuery, recentHrefs, search]);
const options = useMemo(() => groups.flatMap((group) => group.items), [groups]);
const activeOptionId = activeIndex >= 0 && activeIndex < options.length ? `quick-jump-option-${activeIndex}` : undefined;
useEffect(() => {
if (!activeOptionId) return;
document.getElementById(activeOptionId)?.scrollIntoView?.({ block: "nearest" });
}, [activeOptionId]);
const select = (option: DestinationOption) => {
if (option.source === "navigation") {
setRecentHrefs((current) => [option.href, ...current.filter((href) => href !== option.href)].slice(0, 4));
}
onClose();
navigate(href);
navigate(option.href);
};
const onInputKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
if (event.key === "ArrowDown") {
if (!options.length) return;
event.preventDefault();
setActiveIndex((current) => (current + 1) % options.length);
} else if (event.key === "ArrowUp") {
if (!options.length) return;
event.preventDefault();
setActiveIndex((current) => current <= 0 ? options.length - 1 : current - 1);
} else if (event.key === "Enter" && activeIndex >= 0 && options[activeIndex]) {
event.preventDefault();
select(options[activeIndex]);
}
};
const currentSearch = search.query === normalizedQuery ? search : null;
const searching = normalizedQuery.length >= 2 && (!currentSearch || currentSearch.status === "loading");
const error = currentSearch?.status === "ready" ? currentSearch.error : null;
const empty = !searching && !error && options.length === 0;
return (
<Modal open={open} title="Quick jump" onClose={onClose} className="command-modal">
<label className="command-search">
<span className="sr-only">Find a workspace</span>
<span className="sr-only">Find a workspace or resource</span>
<Icon name="search" size={17} />
<input
ref={inputRef}
role="combobox"
aria-autocomplete="list"
aria-haspopup="listbox"
aria-expanded="true"
aria-controls="command-results"
aria-activedescendant={activeOptionId}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Find projects, catalog, policies…"
onKeyDown={onInputKeyDown}
onChange={(event) => { setQuery(event.target.value); setActiveIndex(-1); }}
placeholder="Find projects, connectors, clients…"
autoComplete="off"
/>
<kbd>Esc</kbd>
</label>
<div className="command-results" aria-live="polite">
{results.length ? results.map((item) => (
<button type="button" className="command-result" key={item.href} onClick={() => select(item.href)}>
<span className="command-result-icon"><Icon name={item.icon} /></span>
<span><strong>{item.label}</strong><small>{item.description}</small></span>
<Icon name="arrow" size={16} />
</button>
)) : (
<div className="command-empty"><strong>No workspace found</strong><span>Try a broader name or capability.</span></div>
)}
<div className="command-results" id="command-results" role="listbox" aria-label="Quick jump destinations" aria-live="polite">
{groups.map((group) => <section className="command-group" role="group" aria-label={group.label} key={group.label}>
<h3>{group.label}</h3>
{group.items.map((item) => {
const index = options.findIndex((option) => option.id === item.id);
return <button
type="button"
role="option"
tabIndex={-1}
id={`quick-jump-option-${index}`}
aria-selected={activeIndex === index}
className={activeIndex === index ? "command-result command-result-active" : "command-result"}
key={item.id}
onMouseMove={() => setActiveIndex(index)}
onClick={() => select(item)}
>
<span className="command-result-icon"><Icon name={item.icon} /></span>
<span><strong>{item.label}</strong><small>{item.description}</small></span>
<Icon name="arrow" size={16} />
</button>;
})}
</section>)}
{searching ? <div className="command-empty" role="status"><strong>Searching platform records…</strong><span>Checking destinations available to your role.</span></div> : null}
{error ? <div className="command-search-error" role="alert">{error}</div> : null}
{normalizedQuery.length === 1 ? <div className="command-empty"><strong>Keep typing to search resources</strong><span>Enter at least two characters to search platform records.</span></div> : null}
{empty ? <div className="command-empty"><strong>No destinations found</strong><span>Try a broader name or capability.</span></div> : null}
</div>
<p className="command-hint">Navigate without leaving the keyboard. Access-controlled destinations remain hidden.</p>
<p className="command-hint">Use ↑ and ↓ to move, Enter to open, and Esc to close. Destinations follow your current role.</p>
</Modal>
);
}
+16 -5
View File
@@ -19,7 +19,7 @@ const ALL_COLUMNS: Record<ResourceKind, ColumnKey[]> = {
export function columnsFor(kind: ResourceKind): ColumnKey[] { return ALL_COLUMNS[kind]; }
export function DataTable({
data, kind, density, visibleColumns, onClientAction, onConfig, onTest,
data, kind, density, visibleColumns, onClientAction, onConfig, onTest, navigate, busyClientId,
}: {
data: ResourcePage;
kind: ResourceKind;
@@ -28,14 +28,25 @@ export function DataTable({
onClientAction: (id: string, action: "disable" | "revoke") => void;
onConfig: (id: string) => void;
onTest: (row: ResourceRecord) => void;
navigate?: (next: string) => void;
busyClientId?: string | null;
}) {
const show = (column: ColumnKey) => visibleColumns.includes(column);
const [confirmingAction, setConfirmingAction] = useState<{ id: string; action: "disable" | "revoke"; name: string } | null>(null);
const actions = (row: ResourceRecord) => {
if (kind === "clients" && show("actions")) return <div className="inline-actions"><button className="button button-small" onClick={() => setConfirmingAction({ id: row.id, action: "disable", name: row.name })} disabled={row.status !== "healthy"}>Disable</button><button className="button button-small button-danger" onClick={() => setConfirmingAction({ id: row.id, action: "revoke", name: row.name })} disabled={row.status === "unavailable"}>Revoke</button><button className="button button-small button-secondary" onClick={() => onConfig(row.id)}>Config</button></div>;
if (kind === "clients" && show("actions")) {
const disabled = busyClientId === row.id;
const resume = `/clients?onboard=${encodeURIComponent(row.id)}`;
const secondary = <><button type="button" className="button button-small button-secondary" onClick={() => setConfirmingAction({ id: row.id, action: "disable", name: row.name })} disabled={disabled || row.status !== "healthy"}>Disable</button><button type="button" className="button button-small button-danger" onClick={() => setConfirmingAction({ id: row.id, action: "revoke", name: row.name })} disabled={disabled || row.status === "unavailable"}>Revoke</button>{row.onboardingStage ? <a className="button button-small button-secondary" href={resume} onClick={navigate ? (event) => { event.preventDefault(); navigate(resume); } : undefined}>Onboarding</a> : null}</>;
return <div className="inline-actions client-actions"><button type="button" className="button button-small button-secondary" disabled={disabled} onClick={() => onConfig(row.id)}>{disabled ? "Working…" : "Config"}</button><div className="desktop-client-actions">{secondary}</div><details className="mobile-client-actions"><summary>More actions</summary><div>{secondary}</div></details></div>;
}
if (kind === "tools" && show("actions")) return <div className="inline-actions"><button className="button button-small button-secondary" onClick={() => onTest(row)}>Readiness</button></div>;
return null;
};
const name = (row: ResourceRecord) => {
const href = kind === "projects" ? `/projects/${encodeURIComponent(row.id)}` : kind === "connectors" ? `/connectors/${encodeURIComponent(row.id)}` : kind === "clients" ? row.onboardingStage ? `/clients?onboard=${encodeURIComponent(row.id)}` : `/clients?q=${encodeURIComponent(row.id)}` : null;
return href ? <a className="record-link" href={href} onClick={navigate ? (event) => { event.preventDefault(); navigate(href); } : undefined}><strong className="long-name">{row.name}</strong></a> : <strong className="long-name">{row.name}</strong>;
};
return (
<Fragment>
<div className={`table-wrap desktop-data-table table-kind-${kind} density-${density}`} role="region" tabIndex={0} aria-label={`${kind} records table`}>
@@ -55,10 +66,10 @@ export function DataTable({
<tbody>
{data.rows.map((row) => (
<tr key={row.id}>
{show("name") && <td><strong className="long-name">{row.name}</strong><small>{row.id}</small></td>}
{show("name") && <td>{name(row)}<small>{row.id}</small></td>}
{show("status") && <td><StatusPill value={row.status} /></td>}
{show("project") && <td>{row.project ?? "—"}</td>}
{show("summary") && <td>{row.summary}</td>}
{show("summary") && <td>{row.summary}{row.onboardingStage ? <small>{row.onboardingStage}</small> : null}</td>}
{kind === "tools" && show("risk") && <td>{row.risk ?? "R0"}</td>}
{kind === "audit" && show("decision") && <td>{row.decision ?? "unknown"}{row.traceId && <small>{row.traceId}</small>}</td>}
{(kind === "clients" || kind === "tools") && show("actions") && <td>{actions(row)}</td>}
@@ -68,7 +79,7 @@ export function DataTable({
</table>
</div>
<div className="mobile-record-list" aria-label={`${kind} records`}>
{data.rows.map((row) => <article className="mobile-record" key={row.id}><div className="mobile-record-heading"><div><strong>{row.name}</strong><small>{row.id}</small></div>{show("status") && <StatusPill value={row.status} />}</div><dl>{show("project") && <><dt>Project</dt><dd>{row.project ?? "—"}</dd></>}{show("summary") && <><dt>Summary</dt><dd>{row.summary}</dd></>}{kind === "tools" && show("risk") && <><dt>Risk</dt><dd><span className="risk-label" title="Risk classification">{row.risk ?? "R0"}</span></dd></>}{kind === "audit" && show("decision") && <><dt>Decision / trace</dt><dd>{row.decision ?? "unknown"}{row.traceId && <small>{row.traceId}</small>}</dd></>}</dl>{actions(row)}</article>)}
{data.rows.map((row) => <article className="mobile-record" key={row.id}><div className="mobile-record-heading"><div>{name(row)}<small>{row.id}</small></div>{show("status") && <StatusPill value={row.status} />}</div><dl>{show("project") && <><dt>Project</dt><dd>{row.project ?? "—"}</dd></>}{show("summary") && <><dt>Summary</dt><dd>{row.summary}{row.onboardingStage ? <small>{row.onboardingStage}</small> : null}</dd></>}{kind === "tools" && show("risk") && <><dt>Risk</dt><dd><span className="risk-label" title="Risk classification">{row.risk ?? "R0"}</span></dd></>}{kind === "audit" && show("decision") && <><dt>Decision / trace</dt><dd>{row.decision ?? "unknown"}{row.traceId && <small>{row.traceId}</small>}</dd></>}</dl>{actions(row)}</article>)}
</div>
<Modal
open={confirmingAction !== null}
+38 -42
View File
@@ -1,4 +1,6 @@
import { useEffect, useId, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { focusableElements, isolateOutside, lockBodyScroll, trapTab } from "./modal-focus";
export function Modal({ open, title, onClose, children, className = "" }: { open: boolean; title: string; onClose: () => void; children: ReactNode; className?: string }) {
const dialogRef = useRef<HTMLDivElement>(null);
@@ -7,56 +9,49 @@ export function Modal({ open, title, onClose, children, className = "" }: { open
onCloseRef.current = onClose;
useEffect(() => {
if (!open) return;
// Kept in its own effect, keyed only on `open` (not `onClose`): if this
// ran on every `onClose` identity change (callers commonly pass a fresh
// inline arrow each render), it would re-capture document.activeElement
// after focus had already moved into the dialog, overwriting the real
// trigger element with the dialog itself.
const previouslyFocused = document.activeElement as HTMLElement | null;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialogRef.current?.focus();
return () => {
document.body.style.overflow = previousOverflow;
previouslyFocused?.focus();
};
}, [open]);
const dialog = dialogRef.current;
const overlay = dialog?.parentElement;
if (!open || !dialog || !overlay) return;
useEffect(() => {
if (!open) return;
// Capture the trigger before moving focus into the body-level portal. This
// effect only follows `open`, so fresh inline onClose callbacks cannot
// replace the original trigger during a dialog render.
const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const restoreIsolation = isolateOutside(overlay);
const restoreScroll = lockBodyScroll();
let lastFocused: HTMLElement = dialog;
const focusables = () => focusableElements(dialog);
const onFocusIn = (event: FocusEvent) => {
if (event.target instanceof Node && dialog.contains(event.target)) {
lastFocused = event.target instanceof HTMLElement ? event.target : dialog;
return;
}
(lastFocused.isConnected && dialog.contains(lastFocused) ? lastFocused : focusables()[0] ?? dialog).focus();
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onCloseRef.current();
return;
}
if (event.key !== "Tab" || !dialogRef.current) return;
const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
)).filter((element) => !element.hasAttribute("hidden"));
if (!focusable.length) {
event.preventDefault();
dialogRef.current.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
} else {
trapTab(event, dialog);
}
};
dialog.focus();
document.addEventListener("focusin", onFocusIn, true);
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("focusin", onFocusIn, true);
document.removeEventListener("keydown", onKeyDown);
restoreIsolation();
restoreScroll();
if (previouslyFocused?.isConnected) previouslyFocused.focus();
};
}, [open]);
if (!open) return null;
return (
<div className="modal-overlay" onClick={onClose}>
if (!open || typeof document === "undefined") return null;
return createPortal(
<div className="modal-overlay" data-modal-overlay onClick={(event) => { if (event.target === event.currentTarget) onCloseRef.current(); }}>
<div
className={`modal ${className}`.trim()}
role="dialog"
@@ -68,10 +63,11 @@ export function Modal({ open, title, onClose, children, className = "" }: { open
>
<div className="modal-heading">
<h2 id={titleId}>{title}</h2>
<button className="modal-close" onClick={onClose} aria-label="Close dialog">×</button>
<button className="modal-close" type="button" onClick={() => onCloseRef.current()} aria-label="Close dialog">×</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,44 @@
import { useEffect, useState } from "react";
import type { ManagementApiClient } from "../contracts";
import { formatBrusselsTime } from "../format";
import { readToolHistory, TOOL_HISTORY_EVENT, type ToolRunMetadata } from "../tool-history";
function reopenUrl(run: ToolRunMetadata): string {
const params = new URLSearchParams({ reopen: run.tool, project: run.project, environment: run.environment });
return `/tools?${params.toString()}`;
}
export function RecentToolRuns({ navigate }: {
/** Accepted so callers can mount this beside API-backed tool content; recent
* runs deliberately read only the bounded browser metadata store. */
api?: ManagementApiClient;
navigate: (next: string) => void;
}) {
const [runs, setRuns] = useState<ToolRunMetadata[]>(() => readToolHistory());
useEffect(() => {
const reload = () => setRuns(readToolHistory());
window.addEventListener(TOOL_HISTORY_EVENT, reload);
window.addEventListener("storage", reload);
window.addEventListener("focus", reload);
return () => {
window.removeEventListener(TOOL_HISTORY_EVENT, reload);
window.removeEventListener("storage", reload);
window.removeEventListener("focus", reload);
};
}, []);
return <section className="panel recent-tool-runs" aria-labelledby="recent-tool-runs-title">
<div className="panel-heading"><div><p className="eyebrow">Operator history</p><h3 id="recent-tool-runs-title">Recent Tool Runs</h3></div><span className="tag">Session metadata only</span></div>
<p className="muted">Recent success and controlled-error records include scope and trace only. Arguments, results, errors and credentials are never retained here.</p>
{runs.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span><strong>No recent tool runs</strong><small>Completed invocations in this browser session will appear here.</small></span></div> : <ol className="recent-run-list">
{runs.map((run) => <li className="recent-run" key={`${run.traceId}:${run.at}`}>
<div className="recent-run-main">
<div className="recent-run-heading"><strong className="recent-run-tool">{run.tool}</strong><span className={run.ok ? "recent-run-outcome recent-run-success" : "recent-run-outcome recent-run-error"}>{run.ok ? "Success" : "Controlled error"}</span></div>
<dl><div><dt>Project</dt><dd>{run.project}</dd></div><div><dt>Environment</dt><dd>{run.environment}</dd></div><div><dt>Time</dt><dd>{formatBrusselsTime(run.at)}</dd></div><div><dt>Trace</dt><dd><code>{run.traceId}</code></dd></div></dl>
</div>
<button className="button button-small button-secondary recent-run-open" onClick={() => navigate(reopenUrl(run))}>Open tool context</button>
</li>)}
</ol>}
</section>;
}
@@ -0,0 +1,104 @@
import { useId, type ChangeEvent } from "react";
import type { ToolInputProperty, ToolInputSchema } from "../schema-input";
import { NULL_INPUT_VALUE } from "../schema-input";
function inputType(property: ToolInputProperty): string {
const types = Array.isArray(property.type) ? property.type : [property.type ?? "string"];
return types.find((type) => type !== "null") ?? "string";
}
function optionKey(value: unknown): string {
try {
return JSON.stringify(value) ?? String(value);
} catch {
return String(value);
}
}
function optionLabel(value: unknown): string {
return typeof value === "string" ? value : optionKey(value);
}
export function SchemaInputFields({
schema,
rawValues,
errors,
onChange,
disabled = false,
}: {
schema: ToolInputSchema;
rawValues: Record<string, string>;
errors: Record<string, string>;
onChange: (name: string, value: string) => void;
disabled?: boolean;
}) {
const idPrefix = useId();
const properties = Object.entries(schema.properties ?? {});
const required = new Set(schema.required ?? []);
if (properties.length === 0) return <p className="muted schema-input-empty">This tool takes no arguments.</p>;
return <div className="form-grid readiness-form schema-inputs">
{properties.map(([name, property]) => {
const id = `${idPrefix}-${name}`;
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
const isRequired = required.has(name);
const raw = rawValues[name] ?? "";
const error = errors[name];
const type = inputType(property);
const nullableByType = Array.isArray(property.type) && property.type.includes("null");
const nullAllowed = nullableByType && (!property.enum || property.enum.some((value) => value === null));
const setValue = (value: string) => onChange(name, value);
const fieldProps = {
id,
"aria-required": isRequired,
"aria-invalid": Boolean(error),
"aria-describedby": error ? `${hintId} ${errorId}` : hintId,
disabled,
value: raw,
onChange: (event: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => setValue(event.currentTarget.value),
};
const isNull = raw === NULL_INPUT_VALUE;
return <div className="schema-input-field" key={name}>
<label className="schema-input-label" htmlFor={id}>{property.title ?? name}{isRequired ? <span className="required-mark" aria-hidden="true"> *</span> : null}</label>
{property.enum ? <select {...fieldProps}>
<option value="">Select a value…</option>
{property.enum.map((option) => <option key={optionKey(option)} value={optionKey(option)}>{optionLabel(option)}</option>)}
</select> : type === "boolean" ? <select {...fieldProps}>
<option value="">Choose…</option>
<option value="true">Yes</option>
<option value="false">No</option>
{nullAllowed ? <option value={NULL_INPUT_VALUE}>Null</option> : null}
</select> : <>
{nullAllowed ? <select
className="schema-null-choice"
aria-label={`${property.title ?? name} value`}
value={isNull ? NULL_INPUT_VALUE : "value"}
disabled={disabled}
onChange={(event) => setValue(event.currentTarget.value === NULL_INPUT_VALUE ? NULL_INPUT_VALUE : raw === NULL_INPUT_VALUE ? "" : raw)}
><option value="value">Set a value</option><option value={NULL_INPUT_VALUE}>Null</option></select> : null}
{isNull ? <span className="schema-null-value">Null will be sent for this field.</span> : type === "object" || type === "array" ? <textarea
{...fieldProps}
rows={5}
spellCheck={false}
placeholder={type === "array" ? "Enter a JSON array" : "Enter a JSON object"}
/> : <input
{...fieldProps}
type={type === "integer" || type === "number" ? "number" : "text"}
step={type === "integer" ? 1 : type === "number" ? "any" : undefined}
min={type === "integer" || type === "number" ? property.minimum : undefined}
max={type === "integer" || type === "number" ? property.maximum : undefined}
minLength={type === "string" ? property.minLength : undefined}
maxLength={type === "string" ? property.maxLength : undefined}
disabled={isNull}
placeholder={property.description ? "Enter a value" : undefined}
/>}
</>}
<small id={hintId}>{property.description ? `${property.description} ` : ""}{isRequired ? "Required." : "Optional."}</small>
{error ? <small className="schema-field-error" id={errorId} role="alert">{error}</small> : null}
</div>;
})}
</div>;
}
+15 -1
View File
@@ -17,5 +17,19 @@ export function SessionMenu({ subject, roles }: { subject: string | null; roles:
setBusy(false);
}
};
return <div className="session-menu"><span className="tag">Authenticated</span><div className="session-identity"><span className="avatar" aria-hidden="true">{initial}</span><span><strong>{subject ?? "Authenticated user"}</strong><small>{roles.join(" · ") || "management"}</small></span></div><button className="button button-small button-secondary" type="button" disabled={busy} onClick={() => void signOut()}>{busy ? "Signing out…" : "Sign out"}</button>{error ? <span className="session-error" role="alert">{error}</span> : null}</div>;
return <div className="session-menu">
<span className="tag">Authenticated</span>
<div className="session-identity">
<span className="avatar" aria-hidden="true">{initial}</span>
<span><strong>{subject ?? "Authenticated user"}</strong><small>{roles.join(" · ") || "management"}</small></span>
</div>
<button
className="button button-small button-secondary session-signout"
type="button"
disabled={busy}
aria-busy={busy}
onClick={() => void signOut()}
>{busy ? "Signing out…" : "Sign out"}</button>
{error ? <span className="session-error" role="alert">{error}</span> : null}
</div>;
}
+114
View File
@@ -0,0 +1,114 @@
const FOCUSABLE_SELECTOR = [
"a[href]",
"area[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"iframe",
"object",
"embed",
"[contenteditable='true']",
"[tabindex]:not([tabindex='-1'])",
].join(",");
export function focusableElements(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter((element) => {
if (element.hidden || element.closest("[hidden], [inert], [aria-hidden='true']")) return false;
const closedDetails = element.closest("details:not([open])");
if (closedDetails && !closedDetails.querySelector(":scope > summary")?.contains(element)) return false;
let ancestor: HTMLElement | null = element;
while (ancestor) {
const style = window.getComputedStyle(ancestor);
if (style.display === "none" || style.visibility === "hidden" || style.contentVisibility === "hidden") return false;
if (ancestor === container) break;
ancestor = ancestor.parentElement;
}
return element.getClientRects().length > 0;
});
}
export function trapTab(event: KeyboardEvent, container: HTMLElement): void {
if (event.key !== "Tab") return;
const focusable = focusableElements(container);
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (!first || !last) {
event.preventDefault();
container.focus();
return;
}
if (!container.contains(active)) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
} else if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
}
/** Make everything outside a focus scope unavailable until its cleanup runs. */
export function isolateOutside(container: HTMLElement, interactiveExceptions: HTMLElement[] = []): () => void {
const body = document.body;
const changed = new Map<HTMLElement, { inert: boolean; ariaHidden: string | null }>();
const protect = (element: HTMLElement) => {
if (changed.has(element)) return;
changed.set(element, { inert: element.inert, ariaHidden: element.getAttribute("aria-hidden") });
element.inert = true;
element.setAttribute("aria-hidden", "true");
};
const isException = (element: HTMLElement) => interactiveExceptions.some((exception) => element === exception || element.contains(exception));
let branch: HTMLElement = container;
let parent = branch.parentElement;
while (parent) {
for (const sibling of Array.from(parent.children)) {
if (sibling !== branch && sibling instanceof HTMLElement && !isException(sibling)) protect(sibling);
}
if (parent === body) break;
branch = parent;
parent = parent.parentElement;
}
return () => {
for (const [element, original] of changed) {
element.inert = original.inert;
if (original.ariaHidden === null) element.removeAttribute("aria-hidden");
else element.setAttribute("aria-hidden", original.ariaHidden);
}
};
}
/** Lock document scrolling while preserving inline styles and the scroll position. */
export function lockBodyScroll(): () => void {
const body = document.body;
const root = document.documentElement;
const scrollX = window.scrollX;
const scrollY = window.scrollY;
const styleNames = ["overflow", "position", "top", "left", "right", "width"] as const;
const bodyStyles = new Map(styleNames.map((name) => [name, { value: body.style.getPropertyValue(name), priority: body.style.getPropertyPriority(name) }]));
const rootOverflow = { value: root.style.getPropertyValue("overflow"), priority: root.style.getPropertyPriority("overflow") };
body.style.overflow = "hidden";
body.style.position = "fixed";
body.style.top = `${-scrollY}px`;
body.style.left = `${-scrollX}px`;
body.style.right = "0";
body.style.width = "100%";
root.style.overflow = "hidden";
return () => {
for (const [name, original] of bodyStyles) {
if (original.value) body.style.setProperty(name, original.value, original.priority);
else body.style.removeProperty(name);
}
if (rootOverflow.value) root.style.setProperty("overflow", rootOverflow.value, rootOverflow.priority);
else root.style.removeProperty("overflow");
window.scrollTo(scrollX, scrollY);
};
}