Files
openclaw/ui/src/ui/views/agents-panels-tools-skills.ts
Val Alexander 3bbbe33a1b UI: gateway dashboard with glassmorphism theme system
Add a full-featured gateway dashboard UI built on Lit web components.

Shell & plumbing:
- App shell with router, controllers, and dependency wiring
- Login gate, i18n keys, and base layout scaffolding

Styles & theming:
- Base styles, chat styles, and responsive layout CSS
- 6-theme glassmorphism system (Obsidian, Aurora, Solar, etc.)
- Glass card, glass panel, and glass input components
- Favicon logo in expanded sidebar header

Views & features:
- Overview with attention cards, event log, quick actions, and log tail
- Chat view with markdown rendering, tool-call collapse, and delete support
- Command palette with fuzzy search
- Agent overview with config display, slash commands, and sidebar filtering
- Session list navigation and agent selector

Privacy & polish:
- Redact toggle with stream-mode default
- Blur host/IP in Connected Instances with reveal toggle
- Sensitive config value masking with count badge
- Card accent borders, hover lift effects, and responsive grid
2026-02-22 05:24:54 -06:00

488 lines
16 KiB
TypeScript

import { html, nothing } from "lit";
import { normalizeToolName } from "../../../../src/agents/tool-policy-shared.js";
import type { SkillStatusEntry, SkillStatusReport } from "../types.ts";
import {
isAllowedByPolicy,
matchesList,
PROFILE_OPTIONS,
resolveAgentConfig,
resolveToolProfile,
TOOL_SECTIONS,
} from "./agents-utils.ts";
import type { SkillGroup } from "./skills-grouping.ts";
import { groupSkills } from "./skills-grouping.ts";
import {
computeSkillMissing,
computeSkillReasons,
renderSkillStatusChips,
} from "./skills-shared.ts";
export function renderAgentTools(params: {
agentId: string;
configForm: Record<string, unknown> | null;
configLoading: boolean;
configSaving: boolean;
configDirty: boolean;
onProfileChange: (agentId: string, profile: string | null, clearAllow: boolean) => void;
onOverridesChange: (agentId: string, alsoAllow: string[], deny: string[]) => void;
onConfigReload: () => void;
onConfigSave: () => void;
}) {
const config = resolveAgentConfig(params.configForm, params.agentId);
const agentTools = config.entry?.tools ?? {};
const globalTools = config.globalTools ?? {};
const profile = agentTools.profile ?? globalTools.profile ?? "full";
const profileSource = agentTools.profile
? "agent override"
: globalTools.profile
? "global default"
: "default";
const hasAgentAllow = Array.isArray(agentTools.allow) && agentTools.allow.length > 0;
const hasGlobalAllow = Array.isArray(globalTools.allow) && globalTools.allow.length > 0;
const editable =
Boolean(params.configForm) && !params.configLoading && !params.configSaving && !hasAgentAllow;
const alsoAllow = hasAgentAllow
? []
: Array.isArray(agentTools.alsoAllow)
? agentTools.alsoAllow
: [];
const deny = hasAgentAllow ? [] : Array.isArray(agentTools.deny) ? agentTools.deny : [];
const basePolicy = hasAgentAllow
? { allow: agentTools.allow ?? [], deny: agentTools.deny ?? [] }
: (resolveToolProfile(profile) ?? undefined);
const toolIds = TOOL_SECTIONS.flatMap((section) => section.tools.map((tool) => tool.id));
const resolveAllowed = (toolId: string) => {
const baseAllowed = isAllowedByPolicy(toolId, basePolicy);
const extraAllowed = matchesList(toolId, alsoAllow);
const denied = matchesList(toolId, deny);
const allowed = (baseAllowed || extraAllowed) && !denied;
return {
allowed,
baseAllowed,
denied,
};
};
const enabledCount = toolIds.filter((toolId) => resolveAllowed(toolId).allowed).length;
const updateTool = (toolId: string, nextEnabled: boolean) => {
const nextAllow = new Set(
alsoAllow.map((entry) => normalizeToolName(entry)).filter((entry) => entry.length > 0),
);
const nextDeny = new Set(
deny.map((entry) => normalizeToolName(entry)).filter((entry) => entry.length > 0),
);
const baseAllowed = resolveAllowed(toolId).baseAllowed;
const normalized = normalizeToolName(toolId);
if (nextEnabled) {
nextDeny.delete(normalized);
if (!baseAllowed) {
nextAllow.add(normalized);
}
} else {
nextAllow.delete(normalized);
nextDeny.add(normalized);
}
params.onOverridesChange(params.agentId, [...nextAllow], [...nextDeny]);
};
const updateAll = (nextEnabled: boolean) => {
const nextAllow = new Set(
alsoAllow.map((entry) => normalizeToolName(entry)).filter((entry) => entry.length > 0),
);
const nextDeny = new Set(
deny.map((entry) => normalizeToolName(entry)).filter((entry) => entry.length > 0),
);
for (const toolId of toolIds) {
const baseAllowed = resolveAllowed(toolId).baseAllowed;
const normalized = normalizeToolName(toolId);
if (nextEnabled) {
nextDeny.delete(normalized);
if (!baseAllowed) {
nextAllow.add(normalized);
}
} else {
nextAllow.delete(normalized);
nextDeny.add(normalized);
}
}
params.onOverridesChange(params.agentId, [...nextAllow], [...nextDeny]);
};
return html`
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Tool Access</div>
<div class="card-sub">
Profile + per-tool overrides for this agent.
<span class="mono">${enabledCount}/${toolIds.length}</span> enabled.
</div>
</div>
<div class="row" style="gap: 8px;">
<button class="btn btn--sm" ?disabled=${!editable} @click=${() => updateAll(true)}>
Enable All
</button>
<button class="btn btn--sm" ?disabled=${!editable} @click=${() => updateAll(false)}>
Disable All
</button>
<button class="btn btn--sm" ?disabled=${params.configLoading} @click=${params.onConfigReload}>
Reload Config
</button>
<button
class="btn btn--sm primary"
?disabled=${params.configSaving || !params.configDirty}
@click=${params.onConfigSave}
>
${params.configSaving ? "Saving…" : "Save"}
</button>
</div>
</div>
${
!params.configForm
? html`
<div class="callout info" style="margin-top: 12px">
Load the gateway config to adjust tool profiles.
</div>
`
: nothing
}
${
hasAgentAllow
? html`
<div class="callout info" style="margin-top: 12px">
This agent is using an explicit allowlist in config. Tool overrides are managed in the Config tab.
</div>
`
: nothing
}
${
hasGlobalAllow
? html`
<div class="callout info" style="margin-top: 12px">
Global tools.allow is set. Agent overrides cannot enable tools that are globally blocked.
</div>
`
: nothing
}
<div class="agent-tools-meta" style="margin-top: 16px;">
<div class="agent-kv">
<div class="label">Profile</div>
<div class="mono">${profile}</div>
</div>
<div class="agent-kv">
<div class="label">Source</div>
<div>${profileSource}</div>
</div>
${
params.configDirty
? html`
<div class="agent-kv">
<div class="label">Status</div>
<div class="mono">unsaved</div>
</div>
`
: nothing
}
</div>
<div class="agent-tools-presets" style="margin-top: 16px;">
<div class="label">Quick Presets</div>
<div class="agent-tools-buttons">
${PROFILE_OPTIONS.map(
(option) => html`
<button
class="btn btn--sm ${profile === option.id ? "active" : ""}"
?disabled=${!editable}
@click=${() => params.onProfileChange(params.agentId, option.id, true)}
>
${option.label}
</button>
`,
)}
<button
class="btn btn--sm"
?disabled=${!editable}
@click=${() => params.onProfileChange(params.agentId, null, false)}
>
Inherit
</button>
</div>
</div>
<div class="agent-tools-grid" style="margin-top: 20px;">
${TOOL_SECTIONS.map(
(section) =>
html`
<div class="agent-tools-section">
<div class="agent-tools-header">${section.label}</div>
<div class="agent-tools-list">
${section.tools.map((tool) => {
const { allowed } = resolveAllowed(tool.id);
return html`
<div class="agent-tool-row">
<div>
<div class="agent-tool-title mono">${tool.label}</div>
<div class="agent-tool-sub">${tool.description}</div>
</div>
<label class="cfg-toggle">
<input
type="checkbox"
.checked=${allowed}
?disabled=${!editable}
@change=${(e: Event) =>
updateTool(tool.id, (e.target as HTMLInputElement).checked)}
/>
<span class="cfg-toggle__track"></span>
</label>
</div>
`;
})}
</div>
</div>
`,
)}
</div>
</section>
`;
}
export function renderAgentSkills(params: {
agentId: string;
report: SkillStatusReport | null;
loading: boolean;
error: string | null;
activeAgentId: string | null;
configForm: Record<string, unknown> | null;
configLoading: boolean;
configSaving: boolean;
configDirty: boolean;
filter: string;
onFilterChange: (next: string) => void;
onRefresh: () => void;
onToggle: (agentId: string, skillName: string, enabled: boolean) => void;
onClear: (agentId: string) => void;
onDisableAll: (agentId: string) => void;
onConfigReload: () => void;
onConfigSave: () => void;
}) {
const editable = Boolean(params.configForm) && !params.configLoading && !params.configSaving;
const config = resolveAgentConfig(params.configForm, params.agentId);
const allowlist = Array.isArray(config.entry?.skills) ? config.entry?.skills : undefined;
const allowSet = new Set((allowlist ?? []).map((name) => name.trim()).filter(Boolean));
const usingAllowlist = allowlist !== undefined;
const reportReady = Boolean(params.report && params.activeAgentId === params.agentId);
const rawSkills = reportReady ? (params.report?.skills ?? []) : [];
const filter = params.filter.trim().toLowerCase();
const filtered = filter
? rawSkills.filter((skill) =>
[skill.name, skill.description, skill.source].join(" ").toLowerCase().includes(filter),
)
: rawSkills;
const groups = groupSkills(filtered);
const enabledCount = usingAllowlist
? rawSkills.filter((skill) => allowSet.has(skill.name)).length
: rawSkills.length;
const totalCount = rawSkills.length;
return html`
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Skills</div>
<div class="card-sub">
Per-agent skill allowlist and workspace skills.
${
totalCount > 0
? html`<span class="mono">${enabledCount}/${totalCount}</span>`
: nothing
}
</div>
</div>
<div class="row" style="gap: 8px; flex-wrap: wrap;">
<div class="row" style="gap: 4px; border: 1px solid var(--border); border-radius: var(--radius-md); padding: 2px;">
<button class="btn btn--sm" ?disabled=${!editable} @click=${() => params.onClear(params.agentId)}>
Enable All
</button>
<button
class="btn btn--sm"
?disabled=${!editable}
@click=${() => params.onDisableAll(params.agentId)}
>
Disable All
</button>
<button
class="btn btn--sm"
?disabled=${!editable || !usingAllowlist}
@click=${() => params.onClear(params.agentId)}
title="Remove per-agent allowlist and use all skills"
>
Reset
</button>
</div>
<button class="btn btn--sm" ?disabled=${params.configLoading} @click=${params.onConfigReload}>
Reload Config
</button>
<button class="btn btn--sm" ?disabled=${params.loading} @click=${params.onRefresh}>
${params.loading ? "Loading…" : "Refresh"}
</button>
<button
class="btn btn--sm primary"
?disabled=${params.configSaving || !params.configDirty}
@click=${params.onConfigSave}
>
${params.configSaving ? "Saving…" : "Save"}
</button>
</div>
</div>
${
!params.configForm
? html`
<div class="callout info" style="margin-top: 12px">
Load the gateway config to set per-agent skills.
</div>
`
: nothing
}
${
usingAllowlist
? html`
<div class="callout info" style="margin-top: 12px">This agent uses a custom skill allowlist.</div>
`
: html`
<div class="callout info" style="margin-top: 12px">
All skills are enabled. Disabling any skill will create a per-agent allowlist.
</div>
`
}
${
!reportReady && !params.loading
? html`
<div class="callout info" style="margin-top: 12px">
Load skills for this agent to view workspace-specific entries.
</div>
`
: nothing
}
${
params.error
? html`<div class="callout danger" style="margin-top: 12px;">${params.error}</div>`
: nothing
}
<div class="filters" style="margin-top: 14px;">
<label class="field" style="flex: 1;">
<span>Filter</span>
<input
.value=${params.filter}
@input=${(e: Event) => params.onFilterChange((e.target as HTMLInputElement).value)}
placeholder="Search skills"
/>
</label>
<div class="muted">${filtered.length} shown</div>
</div>
${
filtered.length === 0
? html`
<div class="muted" style="margin-top: 16px">No skills found.</div>
`
: html`
<div class="agent-skills-groups" style="margin-top: 16px;">
${groups.map((group) =>
renderAgentSkillGroup(group, {
agentId: params.agentId,
allowSet,
usingAllowlist,
editable,
onToggle: params.onToggle,
}),
)}
</div>
`
}
</section>
`;
}
function renderAgentSkillGroup(
group: SkillGroup,
params: {
agentId: string;
allowSet: Set<string>;
usingAllowlist: boolean;
editable: boolean;
onToggle: (agentId: string, skillName: string, enabled: boolean) => void;
},
) {
const collapsedByDefault = group.id === "workspace" || group.id === "built-in";
return html`
<details class="agent-skills-group" ?open=${!collapsedByDefault}>
<summary class="agent-skills-header">
<span>${group.label}</span>
<span class="muted">${group.skills.length}</span>
</summary>
<div class="list skills-grid">
${group.skills.map((skill) =>
renderAgentSkillRow(skill, {
agentId: params.agentId,
allowSet: params.allowSet,
usingAllowlist: params.usingAllowlist,
editable: params.editable,
onToggle: params.onToggle,
}),
)}
</div>
</details>
`;
}
function renderAgentSkillRow(
skill: SkillStatusEntry,
params: {
agentId: string;
allowSet: Set<string>;
usingAllowlist: boolean;
editable: boolean;
onToggle: (agentId: string, skillName: string, enabled: boolean) => void;
},
) {
const enabled = params.usingAllowlist ? params.allowSet.has(skill.name) : true;
const missing = computeSkillMissing(skill);
const reasons = computeSkillReasons(skill);
return html`
<div class="list-item agent-skill-row">
<div class="list-main">
<div class="list-title">${skill.emoji ? `${skill.emoji} ` : ""}${skill.name}</div>
<div class="list-sub">${skill.description}</div>
${renderSkillStatusChips({ skill })}
${
missing.length > 0
? html`<div class="muted" style="margin-top: 6px;">Missing: ${missing.join(", ")}</div>`
: nothing
}
${
reasons.length > 0
? html`<div class="muted" style="margin-top: 6px;">Reason: ${reasons.join(", ")}</div>`
: nothing
}
</div>
<div class="list-meta">
<label class="cfg-toggle">
<input
type="checkbox"
.checked=${enabled}
?disabled=${!params.editable}
@change=${(e: Event) =>
params.onToggle(params.agentId, skill.name, (e.target as HTMLInputElement).checked)}
/>
<span class="cfg-toggle__track"></span>
</label>
</div>
</div>
`;
}