Skip to content

Unify the approval cards onto one shell - #381

Merged
abhishekgahlot2 merged 2 commits into
useagenthq:mainfrom
coolestnick:unify-approval-cards
Oct 26, 2026
Merged

abhishekgahlot2 merged 2 commits into
useagenthq:mainfrom
coolestnick:unify-approval-cards

Conversation

@coolestnick

@coolestnick coolestnick commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Closes #351

What this changes

The thread drew human approvals two different ways, so the same decision looked and worked differently each time:

  • Gateway (tool) approval wrapped a radio-select card with an always-visible denial-reason input and rounded Approve/Deny in a separate sub-card.
  • Provider-native (command) approval was a flat card with four buttons in a different order.

Neither was reachable by keyboard.

This PR makes one approval card. The shared presentation now lives in components/ai/approval-card.tsx (replacing the old radio-select version, which only one lane ever used), and both lanes render through it. The two old layouts are gone.

Approach

  • components/ai/approval-card.tsx is the single card. It renders a focusable role="group", the shield header + optional subtitle, an optional status chip, a body slot (the gateway argument list or the native detail block), a note/error line, an optional reason slot, and the footer actions in the order given (primary last).
  • Keyboard contract is a pure function, approvalShortcut(key, modifiers) → "approve" | "reject" | null: Escape rejects, Cmd/Ctrl+Enter approves. It is unit-tested without a DOM.
  • Focus: only the single pending card takes focus, and only when nothing else holds it, so decided cards never grab it (Cmd+Enter lands on the pending one; a reload does not scroll to an old card) and stacked cards do not fight. The focused card shows a visible ring.
  • gateway-approval-card.tsx keeps its optimistic resolve/rollback and status, and captures the optional denial reason only after the user chooses to deny (a two-step deny: Deny → reason box appears and is focused → confirm). Backing out returns focus to the card.
  • native-approval-card.tsx keeps its four decisions — Approve once, Always allow this session, Decline, Cancel turn — in one consistent order. Its public props are unchanged, so conversation.tsx and agent-ui/approval-request.tsx are untouched.
  • Updated the frontend/AGENTS.md seam note to point at the single card.

Keyboard mapping

Key Gateway Native
Cmd/Ctrl+Enter Approve Approve once
Escape enter the deny flow (then Escape backs out) Decline

Done when

  • Both approval kinds render through one component and the old layouts are gone.
  • A keyboard-only user can approve or deny either kind.
  • Render tests cover pending, submitting and submission-error for both kinds, the gateway resolved states, and removal of a native card after resolution.

Tests

  • components/ai/approval-card.test.tsx — the pure key-map and static render states.
  • components/ai/approval-card.interaction.test.tsx — real key presses, focus (pending vs decided), and the two-step deny, mounted under happy-dom.

Checks

  • bun run typecheck (repo root) — pass
  • cd frontend && bun run test — 1761 pass, 0 fail
  • cd frontend && bun run lint — clean
  • cd frontend && bun run build — pass
  • bun test scripts/agent-docs.test.ts — pass

How to see it

A /lab section ("Chat · Approval cards (one card, two lanes)") renders both lanes — native command, gateway pending, gateway resolved — for visual review in every theme.

Screenshots

Before — two divergent cards (native's four flat buttons vs. the gateway's always-on reason input above a separate radio sub-card):

After — one card, consistent header/body/footer; only the pending card holds focus (visible ring), the resolved card never does:

Two-step deny — Deny reveals the reason box already focused; footer swaps to Keep reviewing / confirm Deny:

The thread drew human approvals two different ways (useagenthq#351): the gateway
tool approval wrapped a radio-select card with an always-visible denial
reason and rounded Approve/Deny, while the provider-native approval was
a flat card with four buttons in another order. The same decision looked
and worked differently each time, and neither was reachable by keyboard.

Add one presentational shell, components/chat/approval-shell.tsx, that
owns the header, body, footer button order, focus-on-appear, and the
Escape (reject) / Cmd-Enter (approve) shortcuts. Both lanes now render
through it:

- GatewayApprovalCard keeps its optimistic resolve/rollback and status,
  and now captures the optional denial reason only after the user
  chooses to deny (a two-step deny), instead of a dangling input.
- NativeApprovalCard keeps its four decisions (Approve once, Always
  allow this session, Decline, Cancel turn) in one consistent order.

The old divergent layouts are gone; the keyboard contract is a pure,
unit-tested function. Render tests cover pending, submitting and
submission-error for both kinds, the gateway resolved states, and the
native card's removal after resolution. A /lab section shows both lanes
for visual review.
Fold the shared shell into the single card and fix the focus and test
gaps from review of useagenthq#351:

- The shell now lives in components/ai/approval-card.tsx, replacing the
  old radio-select version, so there is one approval card and no second
  component. Both lanes render through it; the AGENTS.md seam note points
  at it.
- Only the pending card takes focus. Decided cards no longer auto-focus
  (so Cmd+Enter lands on the pending one and a reload does not scroll to
  an old card). The claim only happens when nothing else holds focus, so
  stacked cards do not fight.
- Opening the deny flow moves focus to the reason box, so the shortcuts
  keep working after the Deny button unmounts; backing out returns focus
  to the card.
- The focused card shows a visible ring.
- Drop the self-mapping TONE_VARIANT; pass the tone straight to Button.
- Keep the lab change to the section only, with no unrelated reformat.
- Add interaction tests that press keys, assert focus (pending vs
  decided), and exercise the two-step deny, alongside the static render
  and pure key-map tests.
@coolestnick

Copy link
Copy Markdown
Contributor Author

Thanks for the review — all points addressed, pushed to this branch.

Must fix — one card: folded the shared presentation into components/ai/approval-card.tsx and deleted the old radio-select version, so there is a single approval card now and both lanes render through it. The frontend/AGENTS.md seam note points at it.

Deny dropped focus: opening the deny flow now moves focus to the reason box (so the shortcuts keep working after the Deny button unmounts); backing out returns focus to the card.

Every card grabbed focus: only the single pending card auto-focuses, and only when nothing else holds focus — decided cards never take it, so Cmd+Enter lands on the pending one and a reload no longer scrolls to an old card.

No focus ring: the focused card now shows a visible ring.

Tests: added approval-card.interaction.test.tsx (happy-dom) that presses keys, asserts focus (pending vs decided), and exercises the two-step deny, alongside the static render + pure key-map tests.

Small: dropped the self-mapping TONE_VARIANT (tone goes straight to Button); reverted the lab to a section-only diff with no unrelated reformatting.

Screenshots (before / after / two-step deny) are on the description.

@abhishekgahlot2
abhishekgahlot2 merged commit cbcbd41 into useagenthq:main Oct 26, 2026
21 checks passed
@abhishekgahlot2

Copy link
Copy Markdown
Contributor

Merged, thanks @coolestnick! One card for every approval now, and the keyboard and focus work is a real upgrade.

@github-actions github-actions Bot locked and limited conversation to collaborators Oct 26, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Use one approval card for tool and command approvals

2 participants