Refine tool access controls (#71405)

* feat(ui): refine tool access controls

* fix(ui): tighten tool access scanning

* fix(ui): keep tool access toggles visible (#71405)

* test(daemon): cover launchd restart fallback plist reads (#71405)

* test(daemon): drop duplicate launchd read mock (#71405)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Val Alexander
2026-04-25 01:22:53 -05:00
committed by GitHub
parent d7fae7a5e7
commit 982230f460
7 changed files with 1263 additions and 167 deletions

View File

@@ -2,6 +2,19 @@
Docs: https://docs.openclaw.ai
## Unreleased
### Changes
- Control UI: refine the agent Tool Access panel with compact live-tool chips,
collapsible tool groups, direct per-tool toggles, and clearer runtime/source
provenance. (#71405) Thanks @BunsDev.
### Fixes
- CI/release-checks: pass workflow inputs and matrix values through step environment variables instead of embedding them directly into `run:` shell commands, reducing template-injection surface in the cross-OS release-check workflow. (#66884) Thanks @alexlomt.
- fix(ci): harden release checks workflow inputs (#66884). Thanks @alexlomt
## 2026.4.24 (Unreleased)
### Breaking

View File

@@ -753,6 +753,96 @@
stroke-linejoin: round;
}
.chat-controls {
display: inline-flex;
align-items: center;
gap: 8px;
position: relative;
overflow: visible;
}
.chat-controls__separator {
color: var(--muted);
font-size: 12px;
line-height: 1;
user-select: none;
}
.chat-controls .btn--icon[data-tooltip] {
position: relative;
overflow: visible;
}
.chat-controls .btn--icon[data-tooltip]::before,
.chat-controls .btn--icon[data-tooltip]::after {
position: absolute;
left: 50%;
pointer-events: none;
opacity: 0;
transition:
opacity var(--duration-fast) var(--ease-out),
transform var(--duration-fast) var(--ease-out);
z-index: 40;
}
.chat-controls .btn--icon[data-tooltip]::before {
content: "";
top: calc(100% + 4px);
border-width: 6px;
border-style: solid;
border-color: transparent transparent color-mix(in srgb, var(--card) 94%, black 6%) transparent;
transform: translate(-50%, -3px);
}
.chat-controls .btn--icon[data-tooltip]::after {
content: attr(data-tooltip);
top: calc(100% + 10px);
min-width: max-content;
max-width: min(260px, 60vw);
padding: 7px 9px;
border: 1px solid color-mix(in srgb, var(--border-strong) 84%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--card) 94%, black 6%);
box-shadow:
0 10px 28px rgba(0, 0, 0, 0.24),
0 0 0 1px rgba(255, 255, 255, 0.04);
color: var(--text);
font-size: 11px;
font-weight: 500;
line-height: 1.35;
text-align: center;
white-space: normal;
transform: translate(-50%, -4px);
}
@media (hover: hover) {
.chat-controls .btn--icon[data-tooltip]:hover::before,
.chat-controls .btn--icon[data-tooltip]:hover::after {
opacity: 1;
}
.chat-controls .btn--icon[data-tooltip]:hover::before {
transform: translate(-50%, 0);
}
.chat-controls .btn--icon[data-tooltip]:hover::after {
transform: translate(-50%, 0);
}
}
.chat-controls .btn--icon[data-tooltip]:focus-visible::before,
.chat-controls .btn--icon[data-tooltip]:focus-visible::after {
opacity: 1;
}
.chat-controls .btn--icon[data-tooltip]:focus-visible::before {
transform: translate(-50%, 0);
}
.chat-controls .btn--icon[data-tooltip]:focus-visible::after {
transform: translate(-50%, 0);
}
.btn--ghost {
border-color: transparent;
background: transparent;
@@ -3691,62 +3781,483 @@ td.data-table-key-col {
}
}
.agent-tools-meta {
.agent-tools-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.agent-tools-header__intro {
min-width: 0;
}
.agent-tools-header__actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.agent-tools-overview {
display: grid;
gap: 16px;
grid-template-columns: minmax(0, 1.75fr) minmax(280px, 0.9fr);
align-items: start;
margin-top: 16px;
}
.agent-tools-overview__primary {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.agent-tools-pane {
display: grid;
gap: 10px;
min-width: 0;
}
.agent-tools-facts {
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-content: start;
}
.agent-tools-fact {
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--bg-elevated) 92%, transparent);
}
.agent-tools-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
.agent-tools-grid {
display: grid;
gap: 16px;
margin-top: 20px;
}
.agent-tools-section {
.agent-tools-runtime {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.agent-tools-runtime-chip {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
max-width: 100%;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 10px;
background: var(--bg-elevated);
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--card) 86%, transparent);
color: inherit;
text-decoration: none;
transition:
background-color var(--duration-fast) var(--ease-in-out),
border-color var(--duration-fast) var(--ease-in-out),
color var(--duration-fast) var(--ease-in-out);
touch-action: manipulation;
}
.agent-tools-header {
.agent-tools-runtime-chip:hover {
background: color-mix(in srgb, var(--card) 92%, transparent);
border-color: color-mix(in srgb, var(--border-strong) 58%, var(--border));
}
.agent-tools-runtime-chip:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.agent-tools-runtime-chip--more {
color: var(--muted);
cursor: default;
background: color-mix(in srgb, var(--bg-elevated) 84%, transparent);
}
.agent-tools-runtime-chip--more:hover {
background: color-mix(in srgb, var(--bg-elevated) 88%, transparent);
border-color: var(--border);
}
.agent-tools-runtime-chip__meta {
color: var(--muted);
font-size: 11px;
white-space: nowrap;
}
.agent-tools-group {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-elevated);
overflow: hidden;
}
.agent-tools-group summary::-webkit-details-marker,
.agent-tool-summary::-webkit-details-marker {
display: none;
}
.agent-tools-group summary::marker,
.agent-tool-summary::marker {
content: "";
}
.agent-tools-group__summary {
display: flex;
align-items: flex-start;
gap: 12px;
justify-content: space-between;
padding: 12px 14px;
cursor: pointer;
list-style: none;
transition:
background-color var(--duration-fast) var(--ease-in-out),
color var(--duration-fast) var(--ease-in-out);
touch-action: manipulation;
}
.agent-tools-group__summary::before {
content: "▸";
color: var(--muted);
font-size: 11px;
line-height: 20px;
transition: transform var(--duration-fast) var(--ease-in-out);
}
.agent-tools-group[open] .agent-tools-group__summary::before {
transform: rotate(90deg);
}
.agent-tools-group__summary:hover {
background: color-mix(in srgb, var(--bg-elevated) 96%, var(--text) 4%);
}
.agent-tools-group__summary:hover::before {
color: color-mix(in srgb, var(--text) 78%, var(--muted));
}
.agent-tools-group__summary:focus-visible {
outline: none;
box-shadow: inset var(--focus-ring);
}
.agent-tools-group__title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
font-weight: 600;
margin-bottom: 10px;
}
.agent-tools-group__summary-main {
display: grid;
gap: 6px;
min-width: 0;
flex: 1;
}
.agent-tools-group__preview {
display: flex;
gap: 6px;
flex-wrap: wrap;
min-width: 0;
color: var(--muted);
font-size: 11px;
}
.agent-tools-group__preview > span {
min-width: 0;
max-width: min(180px, 100%);
padding: 2px 6px;
border: 1px solid color-mix(in srgb, var(--border) 74%, transparent);
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--card) 70%, transparent);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-tools-group__counts {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
color: var(--muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.agent-tools-group__counts > span {
white-space: nowrap;
}
.agent-tools-list {
display: grid;
gap: 8px 12px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
padding: 0 12px 12px;
}
.agent-tool-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 6px 8px;
.agent-tools-list--stacked {
grid-template-columns: 1fr;
}
.agent-tool-card {
position: relative;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
overflow: hidden;
scroll-margin-top: 16px;
}
.agent-tool-card[open] {
border-color: color-mix(in srgb, var(--accent) 22%, var(--border));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 10%, transparent);
}
.agent-tool-summary {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(220px, 0.9fr) auto;
gap: 12px 16px;
align-items: center;
min-width: 0;
padding: 12px 92px 12px 12px;
cursor: pointer;
list-style: none;
transition:
background-color var(--duration-fast) var(--ease-in-out),
color var(--duration-fast) var(--ease-in-out);
touch-action: manipulation;
}
.agent-tool-summary::after {
content: "▸";
position: absolute;
top: 18px;
right: 64px;
color: var(--muted);
font-size: 11px;
transition: transform var(--duration-fast) var(--ease-in-out);
}
.agent-tool-card[open] .agent-tool-summary::after {
transform: rotate(90deg);
}
.agent-tool-summary:hover {
background: color-mix(in srgb, var(--card) 97%, var(--text) 3%);
}
.agent-tool-summary:hover::after {
color: color-mix(in srgb, var(--text) 78%, var(--muted));
}
.agent-tool-summary:focus-visible {
outline: none;
box-shadow: inset var(--focus-ring);
}
.agent-tool-summary__main {
min-width: 0;
}
.agent-tool-summary__title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.agent-tool-summary__badges {
min-width: 0;
}
.agent-tool-summary__badges .agent-tool-badges {
margin-top: 0;
justify-content: flex-end;
}
.agent-tool-summary__facts {
display: grid;
gap: 8px 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0;
min-width: 0;
}
.agent-tool-summary__fact {
min-width: 0;
}
.agent-tool-summary__fact dd {
margin: 2px 0 0;
font-size: 12px;
}
.agent-tool-toggle {
position: absolute;
top: 12px;
right: 12px;
margin: 0;
z-index: 1;
}
.agent-tool-badges {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 8px;
}
.agent-tool-title {
font-weight: 600;
font-size: 13px;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-tool-sub {
color: var(--muted);
font-size: 11px;
margin-top: 2px;
margin-top: 3px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-tool-details {
padding: 0 12px 12px;
border-top: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
}
.agent-tool-card:not([open]) .agent-tool-details {
display: none;
}
.agent-tool-details-strip {
display: flex;
flex-wrap: wrap;
gap: 10px 20px;
align-items: flex-start;
padding-top: 10px;
}
.agent-tool-detail {
min-width: 0;
}
.agent-tool-detail--inline {
max-width: min(100%, 260px);
}
.agent-tool-detail .label {
margin-bottom: 4px;
}
.agent-tool-card[open] .agent-tool-sub {
overflow: visible;
text-overflow: clip;
white-space: normal;
}
.agent-tool-jump {
color: var(--accent);
text-decoration: none;
align-self: end;
margin-left: auto;
}
.agent-tool-jump:hover {
color: var(--accent-hover);
text-decoration: underline;
}
.agent-tool-jump:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
border-radius: var(--radius-sm);
}
@media (prefers-reduced-motion: reduce) {
.agent-tools-runtime-chip,
.agent-tools-group__summary,
.agent-tool-summary,
.agent-tools-group__summary::before,
.agent-tool-summary::after {
transition: none;
}
}
@media (max-width: 1180px) {
.agent-tools-overview {
grid-template-columns: 1fr;
}
.agent-tools-facts {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
.agent-tool-summary {
grid-template-columns: minmax(0, 1fr) auto;
}
.agent-tool-summary__facts {
grid-column: 1 / -1;
}
.agent-tool-summary__badges .agent-tool-badges {
justify-content: flex-start;
}
}
@media (max-width: 760px) {
.agent-tools-group__summary {
flex-wrap: wrap;
align-items: flex-start;
}
.agent-tools-group__summary::before {
line-height: 18px;
}
.agent-tools-group__counts {
justify-content: flex-start;
width: 100%;
padding-left: 24px;
}
.agent-tool-summary {
grid-template-columns: 1fr;
padding-right: 92px;
}
.agent-tool-summary__facts {
grid-template-columns: 1fr;
}
.agent-tool-jump {
margin-left: 0;
}
}
.agent-skills-groups {

View File

@@ -1024,6 +1024,7 @@
justify-content: space-between;
gap: 16px;
padding-bottom: 0;
overflow: visible;
}
.content--chat .content-header > div:first-child {
@@ -1032,6 +1033,7 @@
.content--chat .page-meta {
justify-content: flex-start;
overflow: visible;
}
.content--chat .chat-controls {

View File

@@ -0,0 +1,69 @@
import { render } from "lit";
import { describe, expect, it } from "vitest";
import { t } from "../i18n/index.ts";
import { renderChatControls } from "./app-render.helpers.ts";
import type { AppViewState } from "./app-view-state.ts";
function createState(overrides: Partial<AppViewState> = {}) {
return {
connected: true,
chatLoading: false,
onboarding: false,
sessionKey: "main",
sessionsHideCron: true,
sessionsResult: {
ts: 0,
path: "",
count: 0,
defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null },
sessions: [],
},
settings: {
gatewayUrl: "",
token: "",
locale: "en",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "dark",
splitRatio: 0.6,
navWidth: 280,
navCollapsed: false,
navGroupsCollapsed: {},
borderRadius: 50,
chatFocusMode: false,
chatShowThinking: false,
chatShowToolCalls: true,
},
applySettings: () => undefined,
...overrides,
} as unknown as AppViewState;
}
describe("chat header controls (browser)", () => {
it("renders explicit hover tooltip metadata for the top-right action buttons", async () => {
const container = document.createElement("div");
render(renderChatControls(createState()), container);
await Promise.resolve();
const buttons = Array.from(
container.querySelectorAll<HTMLButtonElement>(".chat-controls .btn--icon[data-tooltip]"),
);
expect(buttons).toHaveLength(5);
const labels = buttons.map((button) => button.getAttribute("data-tooltip"));
expect(labels).toEqual([
t("chat.refreshTitle"),
t("chat.thinkingToggle"),
t("chat.toolCallsToggle"),
t("chat.focusToggle"),
t("chat.showCronSessions"),
]);
for (const button of buttons) {
expect(button.getAttribute("title")).toBe(button.getAttribute("data-tooltip"));
expect(button.getAttribute("aria-label")).toBe(button.getAttribute("data-tooltip"));
}
});
});

View File

@@ -184,6 +184,19 @@ export function renderChatControls(state: AppViewState) {
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const showToolCalls = state.onboarding ? true : state.settings.chatShowToolCalls;
const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
const refreshLabel = t("chat.refreshTitle");
const thinkingLabel = disableThinkingToggle
? t("chat.onboardingDisabled")
: t("chat.thinkingToggle");
const toolCallsLabel = disableThinkingToggle
? t("chat.onboardingDisabled")
: t("chat.toolCallsToggle");
const focusLabel = disableFocusToggle ? t("chat.onboardingDisabled") : t("chat.focusToggle");
const cronLabel = hideCron
? hiddenCronCount > 0
? t("chat.showCronSessionsHidden", { count: String(hiddenCronCount) })
: t("chat.showCronSessions")
: t("chat.hideCronSessions");
const toolCallsIcon = html`
<svg
width="18"
@@ -256,7 +269,9 @@ export function renderChatControls(state: AppViewState) {
});
}
}}
title=${t("chat.refreshTitle")}
title=${refreshLabel}
aria-label=${refreshLabel}
data-tooltip=${refreshLabel}
>
${refreshIcon}
</button>
@@ -274,7 +289,9 @@ export function renderChatControls(state: AppViewState) {
});
}}
aria-pressed=${showThinking}
title=${disableThinkingToggle ? t("chat.onboardingDisabled") : t("chat.thinkingToggle")}
title=${thinkingLabel}
aria-label=${thinkingLabel}
data-tooltip=${thinkingLabel}
>
${icons.brain}
</button>
@@ -291,7 +308,9 @@ export function renderChatControls(state: AppViewState) {
});
}}
aria-pressed=${showToolCalls}
title=${disableThinkingToggle ? t("chat.onboardingDisabled") : t("chat.toolCallsToggle")}
title=${toolCallsLabel}
aria-label=${toolCallsLabel}
data-tooltip=${toolCallsLabel}
>
${toolCallsIcon}
</button>
@@ -308,7 +327,9 @@ export function renderChatControls(state: AppViewState) {
});
}}
aria-pressed=${focusActive}
title=${disableFocusToggle ? t("chat.onboardingDisabled") : t("chat.focusToggle")}
title=${focusLabel}
aria-label=${focusLabel}
data-tooltip=${focusLabel}
>
${focusIcon}
</button>
@@ -318,11 +339,9 @@ export function renderChatControls(state: AppViewState) {
state.sessionsHideCron = !hideCron;
}}
aria-pressed=${hideCron}
title=${hideCron
? hiddenCronCount > 0
? t("chat.showCronSessionsHidden", { count: String(hiddenCronCount) })
: t("chat.showCronSessions")
: t("chat.hideCronSessions")}
title=${cronLabel}
aria-label=${cronLabel}
data-tooltip=${cronLabel}
>
${renderCronFilterIcon(hiddenCronCount)}
</button>

View File

@@ -105,12 +105,13 @@ describe("agents tools panel (browser)", () => {
await Promise.resolve();
const text = container.textContent ?? "";
expect(text).toContain("core");
expect(text).toContain("plugin:voice-call");
expect(text).toContain("optional");
expect(text).toContain("Built-In");
expect(text).toContain("Plugin: voice-call");
expect(text).toContain("Optional");
expect(text).toContain("Available Right Now");
expect(text).toContain("Message Actions");
expect(text).toContain("Channel: guildchat");
expect(container.querySelector(".agent-tool-card[open]")).toBeNull();
});
it("shows fallback warning when runtime catalog fails", async () => {
@@ -128,4 +129,214 @@ describe("agents tools panel (browser)", () => {
expect(container.textContent ?? "").toContain("Could not load runtime tool catalog");
});
it("closes expanded tool rows when the parent group collapses", async () => {
const container = document.createElement("div");
render(
renderAgentTools(
createBaseParams({
toolsCatalogResult: {
agentId: "main",
profiles: [{ id: "full", label: "Full" }],
groups: [
{
id: "files",
label: "Files",
source: "core",
tools: [
{
id: "read",
label: "read",
description: "Read file contents",
source: "core",
defaultProfiles: ["full"],
},
],
},
],
},
}),
),
container,
);
await Promise.resolve();
const group = container.querySelector<HTMLDetailsElement>(".agent-tools-group");
const tool = container.querySelector<HTMLDetailsElement>(".agent-tool-card");
expect(group).not.toBeNull();
expect(tool).not.toBeNull();
if (!group || !tool) {
return;
}
group.open = true;
tool.open = true;
group.open = false;
group.dispatchEvent(new Event("toggle"));
expect(tool.open).toBe(false);
});
it("keeps the access toggle inside the collapsed tool summary", async () => {
const container = document.createElement("div");
render(
renderAgentTools(
createBaseParams({
toolsCatalogResult: {
agentId: "main",
profiles: [{ id: "full", label: "Full" }],
groups: [
{
id: "files",
label: "Files",
source: "core",
tools: [
{
id: "read",
label: "read",
description: "Read file contents",
source: "core",
defaultProfiles: ["full"],
},
],
},
],
},
}),
),
container,
);
await Promise.resolve();
const tool = container.querySelector<HTMLDetailsElement>(".agent-tool-card");
const summary = container.querySelector<HTMLElement>(".agent-tool-summary");
const toggle = container.querySelector<HTMLInputElement>(".agent-tool-toggle input");
expect(tool?.open).toBe(false);
expect(toggle?.closest(".agent-tool-summary")).toBe(summary);
});
it("uses section-level plugin provenance for tool details", async () => {
const container = document.createElement("div");
render(
renderAgentTools(
createBaseParams({
toolsCatalogResult: {
agentId: "main",
profiles: [{ id: "full", label: "Full" }],
groups: [
{
id: "plugin:voice-call",
label: "voice-call",
source: "plugin",
pluginId: "voice-call",
tools: [
{
id: "voice_call",
label: "voice_call",
description: "Voice call tool",
source: undefined as never,
defaultProfiles: ["full"],
},
],
},
],
},
}),
),
container,
);
await Promise.resolve();
const tool = container.querySelector<HTMLDetailsElement>(".agent-tool-card");
tool!.open = true;
const sourceDetail = Array.from(
container.querySelectorAll<HTMLElement>(".agent-tool-detail"),
).find((detail) => detail.textContent?.includes("Source"));
expect(sourceDetail?.textContent).toContain("Plugin: voice-call");
});
it("opens the collapsed group and tool row from a live tool chip", async () => {
const container = document.createElement("div");
document.body.append(container);
render(
renderAgentTools(
createBaseParams({
toolsCatalogResult: {
agentId: "main",
profiles: [{ id: "full", label: "Full" }],
groups: [
{
id: "files",
label: "Files",
source: "core",
tools: [
{
id: "read",
label: "read",
description: "Read file contents",
source: "core",
defaultProfiles: ["full"],
},
],
},
],
},
toolsEffectiveResult: {
agentId: "main",
profile: "full",
groups: [
{
id: "core",
label: "Built-in tools",
source: "core",
tools: [
{
id: "read",
label: "read",
description: "Read file contents",
rawDescription: "Read file contents",
source: "core",
},
],
},
],
},
}),
),
container,
);
await Promise.resolve();
const group = container.querySelector<HTMLDetailsElement>(".agent-tools-group");
const tool = container.querySelector<HTMLDetailsElement>(".agent-tool-card");
const chip = container.querySelector<HTMLAnchorElement>(
'.agent-tools-runtime-chip[href="#agent-tool-read"]',
);
expect(group).not.toBeNull();
expect(tool).not.toBeNull();
expect(chip).not.toBeNull();
if (!group || !tool || !chip) {
container.remove();
return;
}
expect(group.open).toBe(false);
expect(tool.open).toBe(false);
chip.click();
await new Promise((resolve) => requestAnimationFrame(resolve));
expect(group.open).toBe(true);
expect(tool.open).toBe(true);
container.remove();
});
});

View File

@@ -6,6 +6,7 @@ import type {
SkillStatusEntry,
SkillStatusReport,
ToolsCatalogResult,
ToolsEffectiveEntry,
ToolsEffectiveResult,
} from "../types.ts";
import {
@@ -26,26 +27,152 @@ import {
renderSkillStatusChips,
} from "./skills-shared.ts";
function renderToolBadges(section: AgentToolSection, tool: AgentToolEntry) {
function renderToolMetaBadges(labels: string[]) {
if (labels.length === 0) {
return nothing;
}
return html`
<div class="agent-tool-badges">
${labels.map((label) => html`<span class="agent-pill">${label}</span>`)}
</div>
`;
}
function buildCatalogBadgeLabels(section: AgentToolSection, tool: AgentToolEntry): string[] {
const source = tool.source ?? section.source;
const pluginId = tool.pluginId ?? section.pluginId;
const badges: string[] = [];
if (source === "plugin" && pluginId) {
badges.push(`plugin:${pluginId}`);
badges.push(`Plugin: ${pluginId}`);
} else if (source === "core") {
badges.push("core");
badges.push("Built-In");
}
if (tool.optional) {
badges.push("optional");
badges.push("Optional");
}
if (badges.length === 0) {
return nothing;
return badges;
}
function buildRowStatusBadges(params: {
section: AgentToolSection;
tool: AgentToolEntry;
activeEntry: ToolsEffectiveEntry | null;
}) {
const badges = buildCatalogBadgeLabels(params.section, params.tool);
if (params.activeEntry) {
badges.unshift("Live Now");
}
return html`
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px;">
${badges.map((badge) => html`<span class="agent-pill">${badge}</span>`)}
</div>
`;
return badges;
}
function formatToolPolicyState(params: {
allowed: boolean;
baseAllowed: boolean;
denied: boolean;
}) {
if (params.denied) {
return "Disabled by agent override.";
}
if (params.allowed && params.baseAllowed) {
return "Enabled by the current profile.";
}
if (params.allowed) {
return "Enabled by agent override.";
}
return "Not included in the current profile.";
}
function formatToolSourceLabel(section: AgentToolSection, tool: AgentToolEntry) {
const source = tool.source ?? section.source;
const pluginId = tool.pluginId ?? section.pluginId;
if (source === "plugin" && pluginId) {
return `Plugin: ${pluginId}`;
}
return "Built-In";
}
function formatToolAccessSummary(params: {
allowed: boolean;
baseAllowed: boolean;
denied: boolean;
}) {
if (params.denied) {
return "Override Off";
}
if (params.allowed && params.baseAllowed) {
return "Enabled";
}
if (params.allowed) {
return "Override On";
}
return "Profile Off";
}
function formatToolRuntimeSummary(params: {
activeEntry: ToolsEffectiveEntry | null;
runtimeSessionMatchesSelectedAgent: boolean;
}) {
if (params.activeEntry) {
return "Live Now";
}
if (params.runtimeSessionMatchesSelectedAgent) {
return "Not Live";
}
return "Other Agent";
}
function toToolAnchorId(toolId: string) {
const safe = normalizeToolName(toolId).replace(/[^a-z0-9_-]+/g, "-");
return `agent-tool-${safe}`;
}
function formatCountLabel(count: number, singular: string, plural = `${singular}s`) {
return `${count} ${count === 1 ? singular : plural}`;
}
function flattenEffectiveTools(groups: ToolsEffectiveResult["groups"] | null | undefined) {
return (groups ?? []).flatMap((group) => group.tools);
}
const MAX_RUNTIME_TOOL_CHIPS = 12;
function handleToolGroupToggle(event: Event) {
const group = event.currentTarget;
if (!(group instanceof HTMLDetailsElement) || group.open) {
return;
}
for (const tool of group.querySelectorAll<HTMLDetailsElement>(".agent-tool-card[open]")) {
tool.open = false;
}
}
function handleRuntimeToolJump(event: Event, anchorId: string) {
const target = document.getElementById(anchorId);
if (!(target instanceof HTMLDetailsElement)) {
return;
}
event.preventDefault();
const parentGroup = target.closest<HTMLDetailsElement>(".agent-tools-group");
if (parentGroup) {
parentGroup.open = true;
}
target.open = true;
const nextUrl = new URL(window.location.href);
nextUrl.hash = anchorId;
window.history.replaceState(null, "", nextUrl);
requestAnimationFrame(() => {
const reducedMotion =
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
target.scrollIntoView?.({
block: "center",
behavior: reducedMotion ? "auto" : "smooth",
});
target.querySelector<HTMLElement>("summary")?.focus();
});
}
function renderEffectiveToolBadge(tool: {
@@ -127,6 +254,40 @@ export function renderAgentTools(params: {
};
};
const enabledCount = toolIds.filter((toolId) => resolveAllowed(toolId).allowed).length;
const effectiveTools =
params.runtimeSessionMatchesSelectedAgent && !params.toolsEffectiveError
? flattenEffectiveTools(params.toolsEffectiveResult?.groups)
: [];
const uniqueEffectiveTools = Array.from(
new Map(effectiveTools.map((tool) => [normalizeToolName(tool.id), tool])).values(),
);
const visibleEffectiveTools = uniqueEffectiveTools.slice(0, MAX_RUNTIME_TOOL_CHIPS);
const hiddenEffectiveToolCount = Math.max(
0,
uniqueEffectiveTools.length - visibleEffectiveTools.length,
);
const liveToolCount = uniqueEffectiveTools.length;
const activeToolMap = new Map(
effectiveTools.map((tool) => [normalizeToolName(tool.id), tool] as const),
);
const activeToolIds = new Set(activeToolMap.keys());
const sortSectionTools = (tools: AgentToolEntry[]) =>
tools.toSorted((left, right) => {
const leftId = normalizeToolName(left.id);
const rightId = normalizeToolName(right.id);
const leftActive = activeToolIds.has(leftId) ? 1 : 0;
const rightActive = activeToolIds.has(rightId) ? 1 : 0;
if (leftActive !== rightActive) {
return rightActive - leftActive;
}
const leftAllowed = resolveAllowed(left.id).allowed ? 1 : 0;
const rightAllowed = resolveAllowed(right.id).allowed ? 1 : 0;
if (leftAllowed !== rightAllowed) {
return rightAllowed - leftAllowed;
}
return left.label.localeCompare(right.label);
});
const updateTool = (toolId: string, nextEnabled: boolean) => {
const nextAllow = new Set(
@@ -174,15 +335,15 @@ export function renderAgentTools(params: {
return html`
<section class="card">
<div class="row" style="justify-content: space-between; flex-wrap: wrap;">
<div style="min-width: 0;">
<div class="agent-tools-header">
<div class="agent-tools-header__intro">
<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; flex-wrap: wrap;">
<div class="agent-tools-header__actions">
<button class="btn btn--sm" ?disabled=${!editable} @click=${() => updateAll(true)}>
Enable All
</button>
@@ -242,150 +403,260 @@ export function renderAgentTools(params: {
`
: 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 style="margin-top: 18px;">
<div class="label">Available Right Now</div>
<div class="card-sub">
What this agent can use in the current chat session.
<span class="mono">${params.runtimeSessionKey || "no session"}</span>
</div>
${!params.runtimeSessionMatchesSelectedAgent
? html`
<div class="callout info" style="margin-top: 12px">
Switch chat to this agent to view its live runtime tools.
</div>
`
: params.toolsEffectiveLoading &&
!params.toolsEffectiveResult &&
!params.toolsEffectiveError
? html`
<div class="callout info" style="margin-top: 12px">Loading available tools…</div>
`
: params.toolsEffectiveError
<div class="agent-tools-overview">
<div class="agent-tools-overview__primary">
<div class="agent-tools-pane">
<div class="label">Available Right Now</div>
<div class="card-sub">
What this agent can use in the current chat session.
<span class="mono">${params.runtimeSessionKey || "no session"}</span>
</div>
${!params.runtimeSessionMatchesSelectedAgent
? html`
<div class="callout info" style="margin-top: 12px">
Could not load available tools for this session.
Switch chat to this agent to view its live runtime tools.
</div>
`
: (params.toolsEffectiveResult?.groups?.length ?? 0) === 0
: params.toolsEffectiveLoading &&
!params.toolsEffectiveResult &&
!params.toolsEffectiveError
? html`
<div class="callout info" style="margin-top: 12px">
No tools are available for this session right now.
Loading available tools…
</div>
`
: html`
<div class="agent-tools-grid" style="margin-top: 16px;">
${params.toolsEffectiveResult?.groups.map(
(group) => html`
<div class="agent-tools-section">
<div class="agent-tools-header">${group.label}</div>
<div class="agent-tools-list">
${group.tools.map((tool) => {
return html`
<div class="agent-tool-row">
<div>
<div class="agent-tool-title">${tool.label}</div>
<div class="agent-tool-sub">${tool.description}</div>
<div
style="display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px;"
>
<span class="agent-pill"
>${renderEffectiveToolBadge(tool)}</span
>
</div>
</div>
</div>
`;
})}
</div>
</div>
`,
)}
</div>
`}
</div>
: params.toolsEffectiveError
? html`
<div class="callout info" style="margin-top: 12px">
Could not load available tools for this session.
</div>
`
: (params.toolsEffectiveResult?.groups?.length ?? 0) === 0
? html`
<div class="callout info" style="margin-top: 12px">
No tools are available for this session right now.
</div>
`
: html`
<div class="agent-tools-runtime">
${visibleEffectiveTools.map((tool) => {
const anchorId = toToolAnchorId(tool.id);
return html`
<a
class="agent-tools-runtime-chip"
href="#${anchorId}"
@click=${(event: Event) => handleRuntimeToolJump(event, anchorId)}
>
<span class="mono" translate="no">${tool.label}</span>
<span class="agent-tools-runtime-chip__meta"
>${renderEffectiveToolBadge(tool)}</span
>
</a>
`;
})}
${hiddenEffectiveToolCount > 0
? html`
<span
class="agent-tools-runtime-chip agent-tools-runtime-chip--more"
title=${`${hiddenEffectiveToolCount} more live tools are available in the groups below.`}
>
+${hiddenEffectiveToolCount} more live tools
</span>
`
: nothing}
</div>
`}
</div>
<div class="agent-tools-presets" style="margin-top: 16px;">
<div class="label">Quick Presets</div>
<div class="agent-tools-buttons">
${profileOptions.map(
(option) => html`
<div class="agent-tools-pane">
<div class="label">Quick Presets</div>
<div class="agent-tools-buttons">
${profileOptions.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 ${profile === option.id ? "active" : ""}"
class="btn btn--sm"
?disabled=${!editable}
@click=${() => params.onProfileChange(params.agentId, option.id, true)}
@click=${() => params.onProfileChange(params.agentId, null, false)}
>
${option.label}
Inherit
</button>
`,
)}
<button
class="btn btn--sm"
?disabled=${!editable}
@click=${() => params.onProfileChange(params.agentId, null, false)}
>
Inherit
</button>
</div>
</div>
</div>
<div class="agent-tools-facts">
<div class="agent-tools-fact">
<div class="label">Profile</div>
<div class="mono">${profile}</div>
</div>
<div class="agent-tools-fact">
<div class="label">Source</div>
<div>${profileSource}</div>
</div>
<div class="agent-tools-fact">
<div class="label">Enabled</div>
<div class="mono">${enabledCount}/${toolIds.length}</div>
</div>
<div class="agent-tools-fact">
<div class="label">Live</div>
<div class="mono">${liveToolCount}</div>
</div>
<div class="agent-tools-fact">
<div class="label">Status</div>
<div class="mono">
${params.configSaving ? "saving…" : params.configDirty ? "unsaved" : "saved"}
</div>
</div>
</div>
</div>
<div class="agent-tools-grid" style="margin-top: 20px;">
${toolSections.map(
(section) => html`
<div class="agent-tools-section">
<div class="agent-tools-header">
${section.label}
${section.source === "plugin" && section.pluginId
? html`<span class="agent-pill" style="margin-left: 8px;"
>plugin:${section.pluginId}</span
>`
: nothing}
</div>
<div class="agent-tools-list">
${section.tools.map((tool) => {
const { allowed } = resolveAllowed(tool.id);
<div class="agent-tools-grid">
${toolSections.map((section) => {
const sortedTools = sortSectionTools(section.tools);
const enabledSectionCount = section.tools.filter(
(tool) => resolveAllowed(tool.id).allowed,
).length;
const activeSectionCount = section.tools.filter((tool) =>
activeToolIds.has(normalizeToolName(tool.id)),
).length;
const previewTools = sortedTools.slice(0, 4);
const remainingPreviewCount = Math.max(0, sortedTools.length - previewTools.length);
return html`
<details class="agent-tools-group" @toggle=${handleToolGroupToggle}>
<summary class="agent-tools-group__summary">
<span class="agent-tools-group__summary-main">
<span class="agent-tools-group__title">
${section.label}
${section.source === "plugin" && section.pluginId
? html`<span class="agent-pill">Plugin: ${section.pluginId}</span>`
: nothing}
</span>
<span class="agent-tools-group__preview" aria-label="Tool preview">
${previewTools.map(
(tool) =>
html`<span class="mono" translate="no" title=${tool.label}
>${tool.label}</span
>`,
)}
${remainingPreviewCount > 0
? html`<span>+${remainingPreviewCount} more</span>`
: nothing}
</span>
</span>
<span class="agent-tools-group__counts">
<span>${formatCountLabel(section.tools.length, "Tool")}</span>
<span>${formatCountLabel(enabledSectionCount, "Enabled Tool")}</span>
${activeSectionCount > 0
? html`<span>${formatCountLabel(activeSectionCount, "Live Tool")}</span>`
: nothing}
</span>
</summary>
<div class="agent-tools-list agent-tools-list--stacked">
${sortedTools.map((tool) => {
const anchorId = toToolAnchorId(tool.id);
const resolved = resolveAllowed(tool.id);
const activeEntry = activeToolMap.get(normalizeToolName(tool.id)) ?? null;
const defaultProfiles = tool.defaultProfiles ?? [];
const rowBadges = buildRowStatusBadges({
section,
tool,
activeEntry,
});
const accessSummary = formatToolAccessSummary(resolved);
const runtimeSummary = formatToolRuntimeSummary({
activeEntry,
runtimeSessionMatchesSelectedAgent: params.runtimeSessionMatchesSelectedAgent,
});
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>
${renderToolBadges(section, tool)}
<details class="agent-tool-card" id=${anchorId}>
<summary class="agent-tool-summary">
<div class="agent-tool-summary__main">
<div class="agent-tool-summary__title-row">
<span class="agent-tool-title mono" translate="no">${tool.label}</span>
</div>
<div class="agent-tool-sub">${tool.description}</div>
</div>
<dl class="agent-tool-summary__facts">
<div class="agent-tool-summary__fact">
<dt class="label">Access</dt>
<dd>${accessSummary}</dd>
</div>
<div class="agent-tool-summary__fact">
<dt class="label">Session</dt>
<dd>${runtimeSummary}</dd>
</div>
</dl>
<div class="agent-tool-summary__badges">
${renderToolMetaBadges(rowBadges)}
</div>
<label
class="cfg-toggle agent-tool-toggle"
@click=${(event: Event) => event.stopPropagation()}
@keydown=${(event: KeyboardEvent) => event.stopPropagation()}
>
<input
type="checkbox"
.checked=${resolved.allowed}
?disabled=${!editable}
aria-label=${`${resolved.allowed ? "Disable" : "Enable"} ${tool.label}`}
@change=${(e: Event) =>
updateTool(tool.id, (e.target as HTMLInputElement).checked)}
/>
<span class="cfg-toggle__track"></span>
</label>
</summary>
<div class="agent-tool-details">
<div class="agent-tool-details-strip">
<div class="agent-tool-detail agent-tool-detail--inline">
<div class="label">Access</div>
<div>${formatToolPolicyState(resolved)}</div>
</div>
<div class="agent-tool-detail agent-tool-detail--inline">
<div class="label">Source</div>
<div>${formatToolSourceLabel(section, tool)}</div>
</div>
${defaultProfiles.length > 0
? html`
<div class="agent-tool-detail agent-tool-detail--inline">
<div class="label">Default Presets</div>
<div class="agent-tool-badges">
${defaultProfiles.map(
(profileId) =>
html`<span class="agent-pill">${profileId}</span>`,
)}
</div>
</div>
`
: nothing}
<div class="agent-tool-detail agent-tool-detail--inline">
<div class="label">Current Session</div>
<div>
${activeEntry
? `Available now via ${renderEffectiveToolBadge(activeEntry)}.`
: params.runtimeSessionMatchesSelectedAgent
? "Not available in this chat session right now."
: "Switch chat to this agent to inspect live availability."}
</div>
</div>
<a class="agent-tool-jump" href="#${anchorId}"> Link to This Tool </a>
</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>
</details>
`;
})}
</div>
</div>
`,
)}
</details>
`;
})}
</div>
</section>
`;