mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
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:
13
CHANGELOG.md
13
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
69
ui/src/ui/app-render.helpers.browser.test.ts
Normal file
69
ui/src/ui/app-render.helpers.browser.test.ts
Normal 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"));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user