Skip to content

fix(ui): /m approval card sends the tapped option as the decision — select → note → explicit Send, alert Got it, real type labels; one respond-payload builder shared with desktop (#2370) - #2378

Open
webmixgamer wants to merge 3 commits into
devfrom
feature/2370-mobile-approval-payload

Conversation

@webmixgamer

Copy link
Copy Markdown
Contributor

Summary

  • The bug: the /m mobile admin's queue card POSTed { response: 'approved', response_text: <tapped option> } for every answer — a Deny was recorded as an approval, a typed answer became a note, the card answered on one tap with no note and no explicit submit (desktop QueueCard is select → optional note → Send), and the type line read a nonexistent request_type (blank). The agent reads response as the decision (sync write-back + the ent#329 resume framing), so the field is load-bearing. Live artifact: the two cards answered on /m during the bug: background polls re-flash loaded content and reset UI state (design-system p13/p14) #1927 eyeball sit in the agent's queue file as response='approved', response_text='Approve'.
  • The fix: utils/operatorQueue.js is now the ONE builder of the respond payload, shared by the desktop store and /m (the bug class was a second hand-built producer). /m reaches desktop parity: select (aria-pressed, inset ring) → restated consequence → optional note (no Enter-submit) → Cancel (focused on reveal, p19) + Send: <option>; question = text + Send; alert = Got it; title line; real type labels (Needs approval / Question / Heads up, shared with QueueCard).
  • Honest state: success drops the card locally before the refetch (fetchQueue swallows its own errors) and a monotonic guard stops a stale poll from resurrecting an answered card; a 5xx/transport failure renders an InlineError inside the card (p18) with the selection + note kept for a retry; a 409/400/404 "no longer pending" refusal drops the card and shows a persistent page-level notice, scrolled into view. Per-card state is keyed by item id, pruned on every poll, cleared on logout. Colour-free CSS only.

Changes

  • src/frontend/src/utils/operatorQueue.js (new) — queueResponseBody / buildQueueResponse / queueResponseKind (by item type: approval-with-options → buttons, alert → Got it, else text answer) / optionsOf (the DB stores whatever JSON the agent wrote) / queueTypeLabel / QUEUE_RESPONSE_NOT_RECORDED + respondRefusedAsNotPending (409/400/404)
  • src/frontend/src/stores/operatorQueue.js — body via the shared builder (note trimmed, empty → null; a blank decision never reaches the wire); 400/404 join 409 with the shared attribution-free copy
  • src/frontend/src/components/operator/QueueCard.vue — imports the shared type label
  • src/frontend/src/views/MobileAdmin.vue — the card rebuild above; .ops-card-type off gray-500 (the contract's dark-ink floor); data-testid hooks
  • src/frontend/tests/unit/operatorQueueResponse.spec.js (new) — decision table, behavioural store test (vi.mock('axios')), source-wiring + residue guards
  • src/frontend/e2e/mobile-admin-queue-respond.spec.js (new, @smoke) — one method-aware page.route serves the GET and captures the real POST body; "no POST on tap" is proved by order (tap → form → Send → exactly one captured body)
  • Docs: feature-flows/mobile-admin-pwa.md, feature-flows/operating-room.md, requirements/public-access.md §27.3, flows index, 2 learnings.md entries, CSO diff report

Test Plan

  • vitest run57 files / 1224 tests (45 new)
  • Playwright against the live stack — 16/16: the new spec (7) + the bug: background polls re-flash loaded content and reset UI state (design-system p13/p14) #1927 /m (5) + Operations (3) regression
  • Proved to fail on the pre-fix code (source stashed, spec kept): test 1 fails with a tap must not answer; captured POSTs: [{"id":"e2e-2370-approval","body":{"response":"approved","response_text":"Deny"}}]
  • vite build ✓ · check:tokens ✓ · loading-gate ratchet ✓ · raw-colour scan (review-enforced, not CI): views/MobileAdmin.vue 0 / 4 / 128 (baseline 0 / 4 / 130), QueueCard.vue 28 / 59 / 0, stores/operatorQueue.js 4 / 0 / 0 — all unchanged or smaller
  • Operator eyeball on localhost /m (three seeded cards), then the agent's own queue file: approval → response='Approve', response_text='test' · question → response='test answer' · alert → response='acknowledged' — never 'approved'
  • /review — 0 critical, 7 low items applied · /cso --diff — 0 findings (docs/security-reports/cso-diff-2026-08-21-2370-mobile-approval-payload.md)

Notes for reviewers

Fixes #2370

🤖 Generated with Claude Code

…elect → note → explicit Send, alert Got it, real type labels; one respond-payload builder shared with desktop (#2370)

The mobile admin's queue card POSTed `{response: 'approved', response_text: <tapped option>}`
for EVERY answer — so a Deny was recorded as an approval, a typed answer became a note, the
card answered on one tap with no note and no explicit submit, and the type line read a
nonexistent `request_type` (blank). The agent reads `response` as the decision (sync
write-back + the ent#329 resume framing), so the field is load-bearing. Live artifact: the
two cards answered on /m during the #1927 eyeball sit in the agent's queue file as
`response='approved'`, `response_text='Approve'`.

- `utils/operatorQueue.js` (new): the ONE home of the respond payload (`queueResponseBody`,
  `buildQueueResponse`), the controls-kind switch (`queueResponseKind`, by item TYPE — an
  approval with usable options → buttons, alert → Got it, everything else → text answer), the
  `optionsOf` normaliser (the DB stores any JSON the agent wrote), the shared type labels and
  the attribution-free 409/400/404 "no longer pending" copy + predicate.
- `stores/operatorQueue.js`: builds its body through the shared builder (note trimmed, empty →
  null; a blank decision never reaches the wire); 400/404 join 409 with the shared copy.
- `QueueCard.vue`: imports the shared type label.
- `MobileAdmin.vue`: approval = select (aria-pressed, inset ring) → restated consequence
  (role=status) → optional note (no Enter-submit) → `Cancel` (focused on reveal, p19) +
  `Send: <option>`; question = text + Send; alert = Got it; title line; real type label (and
  the type line off gray-500). Success drops the card locally before the refetch (fetchQueue
  swallows its own errors); a monotonic guard stops a stale poll from resurrecting an answered
  card; 5xx/transport → per-card InlineError with selection + note kept (p18); 409/400/404 →
  card dropped + persistent page-level notice scrolled into view. Per-card state keyed by id,
  pruned on every poll, cleared on logout. Colour-free CSS only (raw-colour ratchet: MobileAdmin
  0/4/128 vs baseline 0/4/130).
- Tests: `tests/unit/operatorQueueResponse.spec.js` (decision table, behavioural store test with
  axios mocked, source-wiring/residue guards) + `e2e/mobile-admin-queue-respond.spec.js`
  (@smoke; one method-aware route captures the real POST body; proved to fail on the pre-fix
  code with `captured POSTs: [{"response":"approved","response_text":"Deny"}]`).
- Docs: mobile-admin-pwa.md, operating-room.md, requirements §27.3, flows index, 2 learnings
  entries; CSO diff report (0 findings).
- Follow-ups filed: #2375 (Workspace asks panel — same inversion, different endpoint), #2376
  (server-side `response ∈ options` check), #2377 (desktop never shows the not-recorded copy).

Fixes #2370

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@webmixgamer webmixgamer added the ui PR touches the frontend UI — triggers Playwright e2e tests label Aug 21, 2026
Comment thread src/frontend/tests/unit/operatorQueueResponse.spec.js Fixed
…odeQL js/incomplete-multi-character-sanitization)

The helper removes comments from repo source text before the residue assertions; a single-pass replace of `<!--…-->` is what the rule flags. Loop until stable — the documented remediation — so the red check clears without weakening the assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/frontend/tests/unit/operatorQueueResponse.spec.js Fixed
…, no quoted old literals in comments (clears CodeQL)

CodeQL flags any replace() whose regex contains `<!--` (js/incomplete-multi-character-sanitization), fixpoint loop or not. The helper existed only so two of this fix's own comments could quote the old `request_type` / `response: 'approved'` literals; reword the comments instead and assert on raw source, which is stricter — the literals may not return even in a comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@webmixgamer
webmixgamer requested a review from dolho August 21, 2026 19:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants