diff --git a/frontend/src/agents/ApiWriteAllowlist.test.tsx b/frontend/src/agents/ApiWriteAllowlist.test.tsx
index 98465b1f..7caa2608 100644
--- a/frontend/src/agents/ApiWriteAllowlist.test.tsx
+++ b/frontend/src/agents/ApiWriteAllowlist.test.tsx
@@ -315,6 +315,22 @@ describe('ApiWriteAllowlist', () => {
expect(savedConfig().api_write_allowlist).toEqual(['old:gone_action']);
});
+ // Every tool has a Customize link; each names its tool to screen readers.
+ it('ties each Customize link to its tool', async () => {
+ await render(agent({ tools: ['tg', 'crm'] }), { defaultOpen: true });
+ for (const [id, name] of [
+ ['tg', 'Telegram'],
+ ['crm', 'CRM API'],
+ ]) {
+ const link = Array.from(group(id).querySelectorAll('button')).find(
+ (b) => b.getAttribute('aria-expanded') === 'false',
+ )!;
+ const describedBy = link.getAttribute('aria-describedby');
+ expect(describedBy).toBeTruthy();
+ expect(document.getElementById(describedBy!)?.textContent).toBe(name);
+ }
+ });
+
it('allows one action under Customize, leaving the tool choice mixed', async () => {
updateAgent.mockResolvedValue({ ok: true });
await render(agent());
diff --git a/frontend/src/agents/ApiWriteAllowlist.tsx b/frontend/src/agents/ApiWriteAllowlist.tsx
index 921d2409..f5c6a87a 100644
--- a/frontend/src/agents/ApiWriteAllowlist.tsx
+++ b/frontend/src/agents/ApiWriteAllowlist.tsx
@@ -1,5 +1,5 @@
import { ChevronRight } from 'lucide-react';
-import { useEffect, useState } from 'react';
+import { useEffect, useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
@@ -56,6 +56,8 @@ function ToolAllowlist({
}) {
const { t } = useTranslation();
const [unfolded, setUnfolded] = useState(false);
+ // Names the tool for its Customize link, which reads the same in each.
+ const titleId = useId();
const allowedCount = tool.actions.filter((item) =>
allowed.has(item.entry),
).length;
@@ -73,7 +75,7 @@ function ToolAllowlist({
as="h4"
size="xs"
className="min-w-0 flex-1"
- title={tool.name}
+ title={{tool.name}}
description={t(`${K}.toolCount`, {
allowed: formatCount(allowedCount),
formatted: formatCount(tool.actions.length),
@@ -107,6 +109,7 @@ function ToolAllowlist({
size="inline"
className="self-start"
aria-expanded={unfolded}
+ aria-describedby={titleId}
onClick={() => setUnfolded(!unfolded)}
>
{unfolded
diff --git a/frontend/src/agents/components/AgentUsesSection.test.tsx b/frontend/src/agents/components/AgentUsesSection.test.tsx
index afabf5fb..d68c3692 100644
--- a/frontend/src/agents/components/AgentUsesSection.test.tsx
+++ b/frontend/src/agents/components/AgentUsesSection.test.tsx
@@ -505,6 +505,25 @@ describe('AgentUsesSection', () => {
expect(openDetails).toHaveBeenCalledTimes(1);
});
+ // Access details lists the writes only once the agent has an API key, so
+ // without one the note says to create it there first.
+ it('says the allowlist needs an API key when the agent has none', async () => {
+ const blocked = [
+ item({ id: 't1', name: 'Blocked', owner_credential_writes: ['x'] }),
+ ];
+ await render(agentWith(blocked), 'me', vi.fn());
+ await open();
+ const alert = () => container.querySelector('[data-slot="alert"]');
+ expect(alert()?.textContent).toContain(`${K}.accessDetailsNeedsKey`);
+
+ act(() => root.unmount());
+ root = createRoot(container);
+ await render(agentWith(blocked, { key: 'abcd...wxyz' }), 'me', vi.fn());
+ await open();
+ expect(alert()?.textContent).not.toContain(`${K}.accessDetailsNeedsKey`);
+ expect(alert()?.textContent).toContain(`${K}.openAccessDetails`);
+ });
+
it('offers no Access details button to an editor or without a way there', async () => {
const blocked = [
item({ id: 't1', name: 'Blocked', owner_credential_writes: ['x'] }),
diff --git a/frontend/src/agents/components/AgentUsesSection.tsx b/frontend/src/agents/components/AgentUsesSection.tsx
index 605e525c..0b5a8868 100644
--- a/frontend/src/agents/components/AgentUsesSection.tsx
+++ b/frontend/src/agents/components/AgentUsesSection.tsx
@@ -233,7 +233,12 @@ export default function AgentUsesSection({
{t(`${K}.accessDetailsNeedsKey`)}
+ )}