12 KiB
Reviewer gate UX fixes — design
Date: 2026-07-02 · Status: approved (design) · Scope: frontend widgets + harness Pi gate (
tht-gate.js,builders.js) +SKILL.md. NothtCLI,phase.py, orworkflow.yamlsemantic changes.
Problem
Driving a live session surfaced four defects at the reviewer gates. Two user-reported symptoms ("the multiselect has no Exit/Back"; "phase completion only offers Exit and Other-specify, no way forward, and Other is ignored") decompose into four root causes, all verified against source.
-
The multiselect widget renders no Exit/Back/Other controls.
MultiselectWidget(frontend/src/widgets/MultiselectWidget.tsx:30-58) is the ONLY pick widget that never renders<ReservedControls>—SelectWidget(SelectWidget.tsx:16) andArtifactGateWidget(ArtifactGateWidget.tsx:54-57) both do. The backend already setsreserved: ["back","exit","other"]on the multiselect descriptor (builders.js:95) and thereviewer_decidehandler already mapscontrol:"back"|"exit"| "freetext"(tht-gate.js:524-532) — the field is just never consumed by the frontend. -
The phase/artifact gate renders no merito buttons — and "Other" silently approves.
reviewer_confirmbuildsbuildArtifactGate({..., action:{kind:"approve_reject"}})(tht-gate.js:578-584), butbuildArtifactGate(builders.js:102-126) never populates anoptionsarray.ArtifactGateWidgetrenders merito buttons only fromdescriptor.options?.map(...)(ArtifactGateWidget.tsx:44-52) and ignoresactionentirely → zero Approva/Rifiuta buttons; the reviewer sees only the reserved [Go back][Exit][Other] controls. There is no forward button. Worse, in the handler (tht-gate.js:586-594) the only guards arecontrol:"freetext"/choice:"reject"→ reject,control:"back"→ back,control:"exit"→ exit;control:"other"matches none and falls through to the "approved" branch (:596), advancing the phase — with no text captured. This is the exact "only Exit and Other, no way forward, Other ignored" symptom: "Other" is an accidental approve. -
"Altro — specifica" never captures text. The working free-text path is an option carrying
opens:{widget:"freetext"}(altroOption,builders.js:38-44) routed throughLinkageHost— butLinkageHostis wired ONLY inArtifactGateWidget(ArtifactGateWidget.tsx:17-33);SelectWidget/MultiselectWidgetignoreopens. The reserved "Other — specify" button (ReservedControls.tsx:1-6) sends a bare{control:"other"}with no text and no child widget, and the harness maps onlycontrol:"freetext"(resolveSelectOutcome,tht-gate.js:245-254; the three handlers) — so "Other" collects nothing and is dropped (select/decide) or mis-approves (confirm, see #2). -
The empty memory phase (F2) shows a pointless empty checklist.
SKILL.mdPhase 2 (SKILL.md:162-175) always presentsreviewer_decide(allow_empty:true, advance:true), even whentht memory searchreturned zero candidates.buildMultiselectRequestacceptsallowEmpty:truewith zero options (builders.js:78-97), so the reviewer sees an empty checklist + a "Select all" over nothing + an enabled Confirm; only after that dead click doesadvanceIfReady(tht-gate.js:540,:193-204) auto-advance F2 → F3. No "there are no memories" message is ever shown. F2 already auto-advances; the empty widget is the only friction.
Goals
- Give
reviewer_decide(multiselect) the same Back/Exit/Other escape hatches as the other pick widgets. - Make the phase/artifact gate render an explicit forward control ("Approva e continua") and a "Rifiuta" — and stop "Other" from silently approving.
- Make "Altro — specifica" open a text field, carry the text to the model as actionable feedback (recorded verbatim), consistently across pick widgets and the gate.
- When F2 memory is empty, skip the widget: show an info message and auto-advance.
Non-goals
- No new auto-advance phases. F1/F3/F5/F7 keep their
reviewer_confirm kind:"phase"gate — each reviews something real (F1 "done clarifying", F3question.md, F5 the schema-linking readable view per Discipline 7, F7sql_final.sql)._AUTO_ADVANCE_PHASESstays{2,6}— the deliberate HITL contract (SKILL.md:8-11). - No change to the
advancesemantics inworkflow.yamlor theauto_advance_eligiblerule inphase.py. - No widget-registry restructure and no new widget kind (info/freetext already exist).
Part 1 — Multiselect reserved controls (frontend only)
MultiselectWidget.tsx: import ReservedControls and render it after the Confirm button,
mirroring SelectWidget/ArtifactGateWidget:
<ReservedControls
reserved={descriptor.reserved}
onControl={(c) => onRespond({ id: descriptor.id, control: c })}
/>
No backend change — reviewer_decide already emits reserved and handles the responses.
Result: options + Select-all + [Go back] [Exit] [Other — specify]. ("Other" is made
functional in Part 3.)
Tests — vitest (MultiselectWidget.test.tsx, extend): the three reserved buttons render;
clicking each fires onRespond({id, control}) with back/exit/other; the merito Confirm
still emits {kind:"multiselect", choices}.
Part 2 — Gate renders explicit merito options; "Other" no longer approves (harness + test)
Backend — derive options from action.kind in buildArtifactGate (builders.js).
ArtifactGateWidget is unchanged (it already maps descriptor.options); the fix is to make
the builder populate them:
action.kind:"approve_reject"→options:[{id:"approve", label:"Conferma e prosegui", recommended:true}, {id:"reject", label:"Rifiuta"}]action.kind:"confirm"→options:[{id:"approve", label:"Conferma e prosegui", recommended:true}]action.kind:"view_only"→options:[](no merito action; reserved controls only)
The forward option is labelled "Conferma e prosegui" so the "advance to next phase"
affordance is unambiguous (the user's core complaint). recommended floats it to the top
(the frontend already renders (consigliato) on select; the artifact-gate button list keeps
approve first).
Backend — tighten the reviewer_confirm handler (tht-gate.js:585-594): advance ONLY on
an explicit approve choice. Replace the permissive fall-through with:
selectedChoice(resp) === "approve"→ run the privileged action (existingkindbranches).selectedChoice(resp) === "reject"→ "Rifiutato: rivedi e riprova."control:"freetext"→ actionable feedback (Part 3), NOT reject.control:"back"/"exit"→ unchanged.- anything else (incl. a stray
control:"other"that Part 3 will eliminate) → re-present the widget via the no-limbo loop; never auto-approve.
Tests — node builders.test.js (extend): buildArtifactGate with each action.kind
yields the documented options; approve is first and recommended. The ArtifactGateWidget
test already injects options:[{id:"approve",...}] — keep it aligned. Handler wiring is L2/live
per repo convention; the behavioral guarantee rides on builders.test.js + a deferred live
phase-gate check.
Part 3 — "Altro — specifica" captures text and is actionable (frontend + harness)
Frontend — make the reserved "other" control open a freetext child, uniformly. Rather than
per-widget opens wiring, centralize: when a reserved control "other" is clicked, collect
text via FreetextWidget (reusing the LinkageHost merge shape) and submit
{id, control:"freetext", text} — the response the harness already understands
(resolveSelectOutcome/handlers map control:"freetext"). Concretely, ReservedControls
(or a small wrapper it delegates to) renders an inline FreetextWidget on "other" instead of
firing onControl("other") immediately. This fixes "Other" for select, multiselect, and the
gate in one place, and the bare control:"other" disappears from the wire.
Harness — treat gate free-text as actionable feedback, not rejection (tht-gate.js:586-589).
Split the current combined guard: choice:"reject" → "Rifiutato: rivedi e riprova.";
control:"freetext" → return Altro (reviewer): <text>. Valuta e agisci, poi ri-presenta il gate. — matching the wording already used by reviewer_decide/reviewer_select
(tht-gate.js:456,:526) and Discipline 10 (record the reviewer's words verbatim in the
decision rationale). "Other" is present only where a real widget renders — with Part 4 the
empty-memory case shows no widget, so "Other" never appears on a no-op gate (approach A / C).
Tests — vitest: clicking reserved "Other" reveals a text input; submitting sends
{control:"freetext", text} (not control:"other"). node: the reviewer_confirm freetext
branch returns the actionable "Altro (reviewer): …" string, distinct from the reject string.
Part 4 — Empty memory phase auto-advances with a message (harness + SKILL)
Chosen mechanism (option i): short-circuit the empty case inside reviewer_decide. The
model already calls reviewer_decide(allow_empty:true, advance:true) in Phase 2 with zero
merito options when memory is empty. In the handler (tht-gate.js:508-546), before emitting
the widget: if the merito option list is empty AND allow_empty AND advance, do NOT emit the
multiselect — show the reviewer an info notice via ctx.ui.notify("Nessuna memory riutilizzabile per questa domanda — passo alla fase successiva.", "info") (bridged to the
client {type:"info"} event by session-bridge.ts:26-27) and call advanceIfReady. Return a textResult reporting the auto-advance. This keeps the harness as the
advancer (respects the anti-bypass hook), reuses advanceIfReady (which only advances an
auto-eligible phase — F2/F6 with zero substantive decisions), needs no new tool, and leaves the
model's Phase-2 call unchanged.
SKILL.md Phase 2 (SKILL.md:162-175): document the short-circuit — when
tht memory search returns no candidates, still issue the single
reviewer_decide(allow_empty:true, advance:true) with empty merito options; the gate shows the
"no memories" info and auto-advances (no separate reviewer_confirm kind:"phase"). The
non-empty path is unchanged (selected applied, deselected recorded, closed by the phase gate).
Tests — node: reviewer_decide with empty options + allow_empty:true + advance:true
emits an info descriptor and does not emit a multiselect, then calls the advance path
(assert via the fake ctx/tht shim used by the gate tests). Non-empty options → unchanged
(multiselect emitted, no info). No phase.py change, so no pytest.
Build order & testing
Order: Part 1 → Part 2 → Part 3 → Part 4. Part 1 is a self-contained frontend fix. Part 2
makes the gate usable (its forward button). Part 3 depends on Part 2 (both touch the
reviewer_confirm handler + the "other" wire) and removes the bare control:"other". Part 4
is orthogonal but lands last so the SKILL text reflects the finished gate behavior.
TDD where cheap: vitest RED→GREEN on the widget changes (cd frontend && npx vitest run);
node RED→GREEN on builders.test.js and the gate handler tests
(node --test under harness/.pi/extensions/gate/__tests__). Typecheck the frontend
(cd frontend && npx tsc -b). The tht-gate.js glue is L2/live per repo convention; full
verification of the phase gate + empty-memory advance through the real stack is deferred (needs
VPN — see PROJECT_STATE open items).
Risks
- Gate glue is not unit-tested (repo convention): the
reviewer_confirmhandler tightening (Part 2) and the empty-memory short-circuit (Part 4) rely onbuilders.test.js+ gate handler tests + a deferred live check. Mitigation: keep handler logic minimal; push option-shaping into the purebuilders.js(which IS unit-tested). - Reserved "other" → freetext centralization (Part 3) changes a shared component
(
ReservedControls) used by every pick widget. Mitigation: cover all three widgets with a vitest each; keep the non-"other" controls (back/exit) firing immediately as today. - Empty-memory short-circuit scope: the
reviewer_decideshort-circuit triggers on any empty+allow_empty+advancecall, not F2 by name. This matches intent (only a trivially empty auto-eligible phase advances —advanceIfReadyenforces eligibility), but the test must assert a non-empty call is untouched so normal decides never short-circuit.