feat(connectors): put the trigger approval under Propose triggers

The approval mode of proposed triggers sat at the bottom of the
connection page, after roles, channels and pairing, and was easy to
miss. Show it as an option of the permission Propose triggers: nested
and indented, greyed with a reason while the permission is off, with
its limits in a closed Limits list. The row says the current choice
("Proposals: asks first") without opening anything. The bottom section
is gone; an older host with no capability list still shows it as one
block. The saved approval object does not change.

Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
Ettore Di Giacinto committed 2026-11-04 10:17:25 +00:00
1 parent b349946b49
commit bb3933e9d5
6 files changed
+76 -20

No files matched your search

@@ -1,7 +1,9 @@
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
// How the triggers that the agent proposes in a chat are approved. The same on
// every platform, so the words live here as defaults, like the other shared
// How the triggers that the agent proposes in a chat are approved. It is shown
// under the permission "Propose triggers" (see PermissionFields), as an option
// of it. The same on every platform, so the words live here as defaults, like the other shared
// fields. The server enforces the limits; the page only bounds what is typed.
import { useId } from 'react'
import { Field, RadioCards, TextInput } from './controls'
import { APPROVAL_LIMITS, APPROVAL_MODES, approvalProblem } from './connector'
@@ -19,24 +21,33 @@ export const APPROVAL_TEXT = {
'approval.invalid': 'Use a whole number from 1 to {{max}}, or leave it empty.',
'approval.default': 'Default: {{value}}.',
'approval.summary': 'Triggers: {{mode}}',
'approval.effect': 'Proposals: {{mode}}',
'approval.limits': 'Limits',
'approval.short.ask': 'asks first',
'approval.short.auto': 'set up at once',
'approval.short.auto_readonly': 'at once if read-only',
}
export default function ApprovalFields({ form, setForm, s, triggersOff = false }) {
/**
* The approval mode and its limits, as an option under "Propose triggers".
* With off set (the permission is not ticked) the choice is greyed, with the
* reason, and nothing can be changed. The limits sit in a closed disclosure.
*/
export default function ApprovalOptions({ form, setForm, s, off = false }) {
const a = form.approval
const reasonId = useId()
const set = (patch) => setForm({ ...form, approval: { ...a, ...patch } })
const auto = a.mode !== 'ask'
const bad = approvalProblem(a)
return (
<fieldset className="cn-group" data-testid="connector-approval">
<legend>{s('approval.title')}</legend>
<fieldset className="cn-option" data-testid="connector-approval" data-off={off ? 'true' : undefined} disabled={off} aria-describedby={off ? reasonId : undefined}>
<legend className="cn-vh">{s('approval.title')}</legend>
<p className="cn-option__title">{s('approval.title')}</p>
<p className="dk-hint">{s('approval.help')}</p>
{triggersOff && <p className="cn-muted" data-testid="connector-approval-off">{s('perm.triggersNeed')}</p>}
{off && <p className="cn-muted" id={reasonId} data-testid="connector-approval-off">{s('perm.triggersNeed')}</p>}
<RadioCards name="approval" legend={s('approval.title')} value={a.mode} onChange={(mode) => set({ mode })}
options={APPROVAL_MODES.map((m) => ({ value: m, label: s(`approval.mode.${m}`) }))} />
{auto && (<>
<details className="cn-option__limits" data-testid="connector-approval-limits" open={bad ? true : undefined}>
<summary>{s('approval.limits')}</summary>
<p className="dk-hint">{s('approval.limitsHelp')}</p>
{APPROVAL_LIMITS.map(({ key, max, fallback }) => (
<Field key={key} label={s(`approval.${key}`)} hint={s('approval.default', { value: fallback })} error={bad === key ? s('approval.invalid', { max }) : undefined}>
@@ -44,7 +55,7 @@ export default function ApprovalFields({ form, setForm, s, triggersOff = false }
value={a[key]} onChange={(e) => set({ [key]: e.target.value })} />}
</Field>
))}
</>)}
</details>
</fieldset>
)
}
@@ -10,7 +10,7 @@ import ConfirmDialog from '../../host-compat/src/components/ConfirmDialog'
import Icon from '../../host-compat/src/components/Icon'
import AnswerFields, { answerSummary, answerT } from './AnswerFields'
import AccessView from './AccessView'
import ApprovalFields from './ApprovalFields'
import ApprovalOptions from './ApprovalFields'
import FilesFields from './FilesFields'
import { Check, Field, Notice, RadioCards, Select, Switch, TextInput } from './controls'
import NameList from './NameList'
@@ -259,7 +259,8 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
<AnswerFields form={form} setForm={setForm} t={t} kindInfo={kindInfo} />
<FilesFields form={form} setForm={setForm} kind={kindFiles} saved={form.files} s={answerT(t)} />
<PermissionFields form={form} setForm={setForm} kind={kindInfo} capabilities={capabilities} s={s} onPaired={pairedChanged} agent={agents.find((a) => a.id === form.agent_id)} channelsOnly={channelOnly(form, lists)} />
<ApprovalFields form={form} setForm={setForm} s={s} triggersOff={capabilities.length > 0 && !(form.caps || []).includes('triggers.propose')} />
{/* The approval of proposed triggers sits under the permission Propose triggers; an older host with no capability list has no such row, so it shows here. */}
{capabilities.length === 0 && <ApprovalOptions form={form} setForm={setForm} s={s} />}
{capabilities.length > 0 && <AccessView binding={form} capabilities={capabilities} s={s} version={accessVersion} />}
</>)}
<div className="cn-actions">
@@ -10,6 +10,7 @@ import { Field, Hint, Notice, Switch, TextInput } from './controls'
import Icon from '../../host-compat/src/components/Icon'
import { MAX_CHANNELS, MAX_CHANNEL_ID, MAX_CHANNEL_LABEL, agentHas, agentHeld, channelsProblem, copyFromAgent, grantableFor, hasNoCaps, presetBlock, roleHas, setCap, setGrant, unpairedSensitive, usualFor, withAddedCaps, withCopiedCaps } from './connector'
import PairingFields from './PairingFields'
import ApprovalOptions from './ApprovalFields'
export const PERMISSION_TEXT = {
'perm.title': 'What senders may do',
@@ -68,14 +69,14 @@ export const PERMISSION_TEXT = {
'perm.channelProblem.label': 'A name has at most {{max}} characters.',
'perm.channelProblem.repeat': 'A channel is listed twice.',
'perm.channelProblem.count': 'At most {{max}} channels.',
'perm.triggersNeed': 'These settings apply only while "Propose triggers" is on above.',
'perm.triggersNeed': 'Turn on Propose triggers to use this.',
}
export function permissionT(t) {
return (key, opts = {}) => t(key, { defaultValue: PERMISSION_TEXT[key], ...opts })
}
function Cap({ cap, on, onChange, s, lacks }) {
function Cap({ cap, on, onChange, s, lacks, children, effect }) {
// A permission the agent does not have cannot be switched on; one that is
// already on can still be switched off.
const blocked = lacks && !on
@@ -86,6 +87,8 @@ function Cap({ cap, on, onChange, s, lacks }) {
{cap.sensitive && <span className="cn-badge cn-badge--warn" data-testid={`connector-cap-sensitive-${cap.name}`}>{s('perm.sensitive')}</span>}
</Switch>
<Hint more={s('more')} less={s('less')}>{cap.description}{cap.sensitive ? ` ${s('perm.sensitiveHelp')}` : ''}</Hint>
{effect && <p className="cn-cap__effect" data-testid={`connector-cap-effect-${cap.name}`}>{effect}</p>}
{children}
{lacks && <p className="cn-cap__lacks" id={`cn-lacks-${cap.name}`} data-testid={`connector-cap-lacks-${cap.name}`}>{s('perm.agentLacks')}</p>}
</li>
)
@@ -303,7 +306,10 @@ export default function PermissionFields({ form, setForm, kind, capabilities, s,
)}
<Presets key={`${form.id || 'new'}:${form.agent_id || ''}`} form={form} setForm={setForm} capabilities={caps} agent={agent} s={s} />
<ul className="cn-caps" aria-label={s('perm.capsLegend')}>
{caps.map((c) => <Cap key={c.name} cap={c} on={on(c.name)} s={s} lacks={Boolean(form.agent_id) && held !== null && !agentHas(held, c.name)} onChange={(v) => setForm(setCap(form, c.name, v))} />)}
{caps.map((c) => <Cap key={c.name} cap={c} on={on(c.name)} s={s}
effect={c.name === 'triggers.propose' && form.approval ? s('approval.effect', { mode: s(`approval.short.${form.approval.mode || 'ask'}`) }) : undefined} lacks={Boolean(form.agent_id) && held !== null && !agentHas(held, c.name)} onChange={(v) => setForm(setCap(form, c.name, v))}>
{c.name === 'triggers.propose' && form.approval && <ApprovalOptions form={form} setForm={setForm} s={s} off={!on(c.name)} />}
</Cap>)}
</ul>
<Roles form={form} setForm={setForm} kind={kind} capabilities={caps} s={s} />
<Channels form={form} setForm={setForm} s={s} />
@@ -199,3 +199,11 @@ fieldset.cn-group {
.cn-confirm__list { margin: 0; padding-left: var(--dk-space-5); font-size: var(--fs-small); line-height: var(--lh-small); }
.cn-cap__lacks { margin: 2px 0 0; font-size: var(--fs-small); line-height: var(--lh-small); color: var(--color-text-muted); }
.cn-cap[data-lacks="true"] .cn-cap__title { color: var(--color-text-muted); }
/* The approval of proposed triggers, nested under the permission Propose triggers. */
.cn-cap__effect { margin: 2px 0 0; font-size: var(--fs-small); line-height: var(--lh-small); color: var(--color-text-secondary); }
.cn-option { margin: var(--dk-space-2) 0 0 var(--dk-space-3); padding: 0 0 0 var(--dk-space-3); border: 0; border-left: 2px solid var(--color-border-subtle); min-width: 0; display: flex; flex-direction: column; gap: var(--dk-space-2); }
.cn-option[data-off="true"] { opacity: 0.6; }
.cn-option__title { margin: 0; font-size: var(--fs-small); line-height: var(--lh-small); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); }
.cn-option__limits summary { cursor: pointer; font-size: var(--fs-small); color: var(--color-text-secondary); }
.cn-option__limits[open] summary { margin-bottom: var(--dk-space-2); }
@@ -319,13 +319,41 @@ test.describe('Connector permissions', () => {
await shot(page, 'perm-5-access')
})
test('trigger approval says it applies only while triggers are allowed', async ({ page }) => {
await api(page, [SET]).install()
test('the approval of proposed triggers sits under Propose triggers, shows its choice in the row and is greyed while the permission is off', async ({ page }) => {
const a = api(page, [{ ...LEGACY, caps: ['memory.write'], approval: { mode: 'auto_readonly' } }])
await a.install()
await page.goto(PAGE)
await page.getByRole('button', { name: 'Edit' }).click()
await expect(page.getByTestId('connector-approval-off')).toBeVisible()
await page.getByTestId('connector-cap-switch-triggers.propose').click()
await expect(page.getByTestId('connector-approval-off')).toHaveCount(0)
const row = page.getByTestId('connector-cap-triggers.propose')
// One place only: nothing at the bottom of the page.
await expect(page.getByTestId('connector-approval')).toHaveCount(1)
await expect(row.getByTestId('connector-approval')).toBeVisible()
await expect(row.getByTestId('connector-cap-effect-triggers.propose')).toHaveText('Proposals: at once if read-only')
// Permission off: greyed, with the reason, and the radios cannot be used.
await expect(row.getByTestId('connector-approval-off')).toHaveText('Turn on Propose triggers to use this.')
await expect(row.getByRole('radio').first()).toBeDisabled()
await expect(row.getByTestId('connector-approval')).toHaveAccessibleDescription('Turn on Propose triggers to use this.')
await shot(page, 'perm-9-approval-greyed')
await row.getByTestId('connector-cap-switch-triggers.propose').click()
await expect(row.getByTestId('connector-approval-off')).toHaveCount(0)
await expect(row.getByRole('radio', { name: /Ask me/ })).toBeEnabled()
// The three modes change the line of the row.
await row.getByRole('radio', { name: /Ask me/ }).check()
await expect(row.getByTestId('connector-cap-effect-triggers.propose')).toHaveText('Proposals: asks first')
await shot(page, 'perm-10-approval-ask')
await row.getByRole('radio', { name: /Set it up at once/ }).check()
await expect(row.getByTestId('connector-cap-effect-triggers.propose')).toHaveText('Proposals: set up at once')
await shot(page, 'perm-11-approval-auto')
await row.getByRole('radio', { name: /At once when the runs only read/ }).check()
await expect(row.getByTestId('connector-cap-effect-triggers.propose')).toHaveText('Proposals: at once if read-only')
await shot(page, 'perm-12-approval-readonly')
// The limits are in a closed disclosure and are sent as before.
const limits = row.getByTestId('connector-approval-limits')
await expect(limits).not.toHaveAttribute('open', '')
await limits.getByText('Limits', { exact: true }).click()
await limits.getByTestId('connector-approval-max_pending').fill('3')
await page.getByRole('button', { name: 'Save' }).click()
await expect.poll(() => a.s.calls.find(c => c.method === 'PUT')?.body.approval).toEqual({ mode: 'auto_readonly', max_pending: 3, min_gap_minutes: 0, max_runs_per_day: 0, max_lifetime_days: 0 })
})
test('an older host that sends no capability list shows no permission section', async ({ page }) => {
+3 -1
View File
@@ -666,7 +666,9 @@ trigger stays in Studio. Nothing runs before the owner confirms.
### Approval modes
The owner chooses, per binding, what happens to a proposal. The choice is on the settings
page of the connector add-on, or in the `approval` field of the binding
page of the connector add-on, nested under the permission **Propose triggers** (greyed while
that permission is off, with its limits in a closed **Limits** list, and the current mode in
the line of the row), or in the `approval` field of the binding
(`PUT /api/chats/connectors/{id}`), and is the same for every connector.
| `mode` | Behavior |