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({ {writesNote} {/* Only the owner sets the allowlist. */} {ownerReads && onOpenAccessDetails && ( -
+
+ {/* Access details lists these writes once there is a key + (a draft gets one when it is published). */} + {!agent.key && ( +

{t(`${K}.accessDetailsNeedsKey`)}

+ )}