From 982230f460b5449fc4ec28fe18a999977c6c7d91 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:22:53 -0500 Subject: [PATCH] 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 --- CHANGELOG.md | 13 + ui/src/styles/components.css | 547 +++++++++++++++++- ui/src/styles/layout.css | 2 + ui/src/ui/app-render.helpers.browser.test.ts | 69 +++ ui/src/ui/app-render.helpers.ts | 37 +- ...agents-panels-tools-skills.browser.test.ts | 217 ++++++- ui/src/ui/views/agents-panels-tools-skills.ts | 545 ++++++++++++----- 7 files changed, 1263 insertions(+), 167 deletions(-) create mode 100644 ui/src/ui/app-render.helpers.browser.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 444dad8495c..58a4c921196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 68e762cf62c..5422887a59a 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -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 { diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 87e71a80364..08774c6d6e2 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -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 { diff --git a/ui/src/ui/app-render.helpers.browser.test.ts b/ui/src/ui/app-render.helpers.browser.test.ts new file mode 100644 index 00000000000..71450e7b795 --- /dev/null +++ b/ui/src/ui/app-render.helpers.browser.test.ts @@ -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 = {}) { + 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(".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")); + } + }); +}); diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 529c92b0ad7..f0b4128705c 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -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` ${refreshIcon} @@ -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} @@ -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} @@ -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} @@ -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)} diff --git a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts index 70d36983b33..8a64ae7833e 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts @@ -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(".agent-tools-group"); + const tool = container.querySelector(".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(".agent-tool-card"); + const summary = container.querySelector(".agent-tool-summary"); + const toggle = container.querySelector(".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(".agent-tool-card"); + tool!.open = true; + + const sourceDetail = Array.from( + container.querySelectorAll(".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(".agent-tools-group"); + const tool = container.querySelector(".agent-tool-card"); + const chip = container.querySelector( + '.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(); + }); }); diff --git a/ui/src/ui/views/agents-panels-tools-skills.ts b/ui/src/ui/views/agents-panels-tools-skills.ts index 566139953bb..f6404bdb7ae 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.ts @@ -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` +
+ ${labels.map((label) => html`${label}`)} +
+ `; +} + +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` -
- ${badges.map((badge) => html`${badge}`)} -
- `; + 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(".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(".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("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`
-
-
+
+
Tool Access
Profile + per-tool overrides for this agent. ${enabledCount}/${toolIds.length} enabled.
-
+
@@ -242,150 +403,260 @@ export function renderAgentTools(params: { ` : nothing} -
-
-
Profile
-
${profile}
-
-
-
Source
-
${profileSource}
-
- ${params.configDirty - ? html` -
-
Status
-
unsaved
-
- ` - : nothing} -
- -
-
Available Right Now
-
- What this agent can use in the current chat session. - ${params.runtimeSessionKey || "no session"} -
- ${!params.runtimeSessionMatchesSelectedAgent - ? html` -
- Switch chat to this agent to view its live runtime tools. -
- ` - : params.toolsEffectiveLoading && - !params.toolsEffectiveResult && - !params.toolsEffectiveError - ? html` -
Loading available tools…
- ` - : params.toolsEffectiveError +
+
+
+
Available Right Now
+
+ What this agent can use in the current chat session. + ${params.runtimeSessionKey || "no session"} +
+ ${!params.runtimeSessionMatchesSelectedAgent ? html`
- Could not load available tools for this session. + Switch chat to this agent to view its live runtime tools.
` - : (params.toolsEffectiveResult?.groups?.length ?? 0) === 0 + : params.toolsEffectiveLoading && + !params.toolsEffectiveResult && + !params.toolsEffectiveError ? html`
- No tools are available for this session right now. + Loading available tools…
` - : html` -
- ${params.toolsEffectiveResult?.groups.map( - (group) => html` -
-
${group.label}
-
- ${group.tools.map((tool) => { - return html` -
-
-
${tool.label}
-
${tool.description}
-
- ${renderEffectiveToolBadge(tool)} -
-
-
- `; - })} -
-
- `, - )} -
- `} -
+ : params.toolsEffectiveError + ? html` +
+ Could not load available tools for this session. +
+ ` + : (params.toolsEffectiveResult?.groups?.length ?? 0) === 0 + ? html` +
+ No tools are available for this session right now. +
+ ` + : html` +
+ ${visibleEffectiveTools.map((tool) => { + const anchorId = toToolAnchorId(tool.id); + return html` + handleRuntimeToolJump(event, anchorId)} + > + ${tool.label} + ${renderEffectiveToolBadge(tool)} + + `; + })} + ${hiddenEffectiveToolCount > 0 + ? html` + + +${hiddenEffectiveToolCount} more live tools + + ` + : nothing} +
+ `} +
-
-
Quick Presets
-
- ${profileOptions.map( - (option) => html` +
+
Quick Presets
+
+ ${profileOptions.map( + (option) => html` + + `, + )} - `, - )} - +
+
+
+ +
+
+
Profile
+
${profile}
+
+
+
Source
+
${profileSource}
+
+
+
Enabled
+
${enabledCount}/${toolIds.length}
+
+
+
Live
+
${liveToolCount}
+
+
+
Status
+
+ ${params.configSaving ? "saving…" : params.configDirty ? "unsaved" : "saved"} +
+
-
- ${toolSections.map( - (section) => html` -
-
- ${section.label} - ${section.source === "plugin" && section.pluginId - ? html`plugin:${section.pluginId}` - : nothing} -
-
- ${section.tools.map((tool) => { - const { allowed } = resolveAllowed(tool.id); +
+ ${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` +
+ + + + ${section.label} + ${section.source === "plugin" && section.pluginId + ? html`Plugin: ${section.pluginId}` + : nothing} + + + ${previewTools.map( + (tool) => + html`${tool.label}`, + )} + ${remainingPreviewCount > 0 + ? html`+${remainingPreviewCount} more` + : nothing} + + + + ${formatCountLabel(section.tools.length, "Tool")} + ${formatCountLabel(enabledSectionCount, "Enabled Tool")} + ${activeSectionCount > 0 + ? html`${formatCountLabel(activeSectionCount, "Live Tool")}` + : nothing} + + +
+ ${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` -
-
-
${tool.label}
-
${tool.description}
- ${renderToolBadges(section, tool)} +
+ +
+
+ ${tool.label} +
+
${tool.description}
+
+
+
+
Access
+
${accessSummary}
+
+
+
Session
+
${runtimeSummary}
+
+
+
+ ${renderToolMetaBadges(rowBadges)} +
+ +
+
+
+
+
Access
+
${formatToolPolicyState(resolved)}
+
+
+
Source
+
${formatToolSourceLabel(section, tool)}
+
+ ${defaultProfiles.length > 0 + ? html` +
+
Default Presets
+
+ ${defaultProfiles.map( + (profileId) => + html`${profileId}`, + )} +
+
+ ` + : nothing} +
+
Current Session
+
+ ${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."} +
+
+ Link to This Tool +
- -
+
`; })}
-
- `, - )} + + `; + })}
`;