Skip to content

feat(portal): render the reject answer and keep the action bar usable - #84

Merged
abrichr merged 2 commits into
mainfrom
feat/attended-reject-action
Jul 28, 2026
Merged

feat(portal): render the reject answer and keep the action bar usable#84
abrichr merged 2 commits into
mainfrom
feat/attended-reject-action

Conversation

@abrichr

@abrichr abrichr commented Jul 28, 2026

Copy link
Copy Markdown
Member

Third of four repos. Renders Flow's new reject action — an explicit "Stop, this is wrong" that terminates the run — on the runner-local phone portal.

Depends on openadapt-types 0.6.3 (merged, published) and openadapt-flow #295.

What the shell adds

The shell decides nothing; it renders what the signed task allows and filters buttons through ACTION_WIRE. So this is the wire name, its closed disposition, its copy, and its terminal receipt pair.

escalate reject
label Needs more help Stop — this is wrong
brief Hands this to someone else Ends this run
the run stays paused exactly where it is is over and cannot be resumed

Worded so they can never read as synonyms: escalate says someone will pick this up; reject says nobody will, because there is no longer a run to pick up. An operator told the wrong one of those acts on it. Pinned by test in both directions, including that the rejection receipt copy does not contain "picks it up".

Reject's label is the assertion, and it is about this run — not the saved workflow, which is teach's job and carries a requalification gate this button does not. The consequence text says so explicitly.

Reject is not the new emphasised answer

R1 removed all visual recommendation, because allowed_actions[0] is set by an engine predicate about what can be re-verified rather than about what the operator should do. Making the disagreement answer prominent instead would be the same mistake pointed the other way. Nothing on the task means "recommended", so nothing is emphasised — asserted behaviourally (all four buttons, identical className) and structurally (no .primary rule, no --accent inside any .actions rule).

The action bar, measured rather than assumed

app.js already carried a comment recording that a constant height guess broke at three buttons. Reject makes four the ordinary case and five reachable. Measured in headless Chromium at three viewports:

device 3 buttons 4 (with reject) 5 (+ skip)
360×640 small Android 207px (32%) 270px (42%) 333px (52%)
375×667 iPhone SE 207px (31%) 270px (41%) 333px (50%)
360×780 iPhone 12 mini 207px (27%) 248px (35%) 286px (43%)

At five buttons on the tightest device the operator could see 307px of the decision at a time. Nothing was occluded — the page reserves the bar's measured height and no label wrapped — but half the screen is a legibility problem in its own right.

A count-keyed compaction rule brings it to 248px (39%) at four and 286px (45%) at five:

  • Every button shrinks by the same amount. A per-action size would be a prominence difference decided by list position, which is exactly what the flat styling exists to prevent. Asserted: no [data-action="…"] selector may appear in a sizing rule.
  • 44px is the floor and is not crossed. A mis-tap here is a wrong answer to a governed decision, so the bar sheds padding, not touch target. Asserted: min(min-height) >= 44.
  • Keyed on :has(button:nth-child(n)). A browser without :has() keeps today's taller buttons — which degrades to the current layout, safe because the reserve is measured, rather than to a broken one.

The alternative — letting the bar scroll — was rejected: it would put whichever answers fall below its fold out of sight, which is the same prominence-by-position defect.

Tests

src/portalShell.test.ts drives the real script in a DOM: the four-button bar, reject vs escalate as distinct consequences, the terminal rejection outcome (and that it never says someone will pick it up), the legacy {status: "rejected"} reply, and that the posted body carries disposition: "rejected_by_operator" and exactly six keys — no field on the wire could carry a reason an operator typed.

tests/test_portal/test_shell.py pins the contract surface: the wire vocabulary is exactly Flow's five, every receipt state has copy, the compaction rule is count-keyed and above the touch-target floor, and nothing is emphasised.

Verified locally: 128 pytest passed, 65 vitest passed.

Deferred, deliberately

openadapt-flow==1.25.0 is unchanged. The shell filters buttons from the signed task, so against a runner that does not offer reject nothing appears and nothing breaks. The pin bumps in a follow-up once the Flow release carrying reject is published — bumping it now would pin to a version that does not exist.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM

abrichr added 2 commits July 27, 2026 21:36
Flow's attended vocabulary gains `reject` -- an explicit "Stop, this is
wrong" that TERMINATES the run, as distinct from `escalate`, which parks
it for a colleague. The shell decides nothing; it renders what the signed
task allows, so this adds the wire name, its closed disposition, its copy,
and its terminal receipt pair.

The two answers are worded so they can never read as synonyms. Escalate
says someone will pick this up; reject says nobody will, because there is
no longer a run to pick up. An operator told the wrong one of those acts
on it.

Reject is NOT the new emphasised answer. R1 removed all visual
recommendation because `allowed_actions[0]` is set by a predicate about
what the engine can re-verify, not about what the operator should do;
making the disagreement answer prominent instead would be the same
mistake pointed the other way, and nothing on the task means
"recommended".

Layout. Four two-line buttons is now the ordinary bar and five is
reachable. Measured in headless Chromium at 360x640, the tightest device
that still ships: three buttons 207px (32% of the viewport), four 270px
(42%), five 333px (52%) -- at five the operator could see 307px of the
decision at a time. A count-keyed compaction rule brings four to 248px
and five to 286px (45%). Every button shrinks by the same amount, because
a per-action size would be a prominence difference decided by list
position. 44px is the floor and is not crossed: a mis-tap here is a wrong
answer to a governed decision, so the bar sheds padding rather than touch
target. A browser without `:has()` keeps today's taller buttons, which is
safe -- the page reserves the bar's MEASURED height, so nothing is
occluded.

The `openadapt-flow==` pin is unchanged and still 1.25.0. The shell
filters buttons from the signed task, so against a runner that does not
offer `reject` nothing appears; the pin bumps when the Flow release
carrying it lands.
The decision-portal doc enumerated three answers and described them by
whether the saved workflow changes. Reject differs on a second axis --
what becomes of the RUN -- and the doc now states both, plus the reason
reject and escalate are separate actions rather than two labels on one,
the measured action-bar heights, and that nothing on the bar is
emphasised.
@abrichr
abrichr merged commit 89dde2c into main Jul 28, 2026
15 checks passed
@abrichr
abrichr deleted the feat/attended-reject-action branch July 28, 2026 01:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant