Skip to content

feat: unified decision control across annotate and code review - #1458

Merged
backnotprop merged 38 commits into
mainfrom
feat/decision-control-pr7
Sep 3, 2026
Merged

feat: unified decision control across annotate and code review#1458
backnotprop merged 38 commits into
mainfrom
feat/decision-control-pr7

Conversation

@backnotprop

@backnotprop backnotprop commented Sep 2, 2026

Copy link
Copy Markdown
Owner

One adaptive decision control for every review surface

Implements the approved unified-header design (DESIGN_decision-control.md, DESIGN_final-proposal.html): a single joined split control per surface, with approve and send-feedback never rendered side by side anywhere. Built as a seven-stage stack; every stage got an independent adversarial review, and every finding was fixed in-stage with a pinning regression test before the next stage branched. 34 commits, 65 files, +6383/-552.

What changes for the user

  • Every annotate surface (file, folder, last, URL, HTML, live app) and both code-review destinations (agent and GitHub/GitLab) show one adaptive primary: [Done] or [Approve] when the session is empty, [Send Feedback · n] / [Post Comments · n] once it carries feedback. Alternates live behind the caret: send with a note (the menu morphs into a composer in place), request changes with zero annotations, approve with notes, approve-and-discard behind a single confirm.
  • Mod+Enter always fires exactly what the visible primary says, and an open confirm dialog owns it outright.
  • "Approve with notes" now actually delivers: the agent receives a framed approved-with-notes message (non-blocking guidance, not a change request) instead of the notes being silently discarded. Gated by a server capability advert with a fail-closed handshake on the OpenCode bridge, so an old component can never advertise what it cannot deliver.
  • The review sidebar gains "+ General comment" (a durable review-level comment without submitting), and general comments are distinguishable in the local feedback archive (new additive scope field).
  • The bare-LGTM placeholder string is gone; bare approvals archive as decision-only lgtm records.
  • Platform (PR) mode adopts the same shape: every action opens the existing submission dialog (its comment field remains the only note field on that side), self-approval muting preserved with an accessible reason and no dead-end states.

Intentional behavior changes (release-notes material)

  • The empty-session header shows a real primary (Done/Approve) where it previously hid the button; the previously keyboard-only silent empty submit is now the visible Done, byte-identical on the wire.
  • Approve-despite-annotations is two clicks (caret, discard, confirm) replacing the dimmed one-click Approve plus warning dialog.
  • Platform empty+self-authored: Mod+Enter is a no-op on the muted Approve (it previously fell through to the comment dialog against the visible-primary rule).
  • With the agent terminal, a delivered session's Done keeps posting the full payload; its copy now says so honestly.
  • Escape on an open header menu is consumed (one Escape, one rung) and no longer also fires the app's Escape ladder; vim defers to open popovers.
  • Narrow-desktop: the Done/Approve primaries go icon-only below the label breakpoint where the old ApproveButton showed OK; tooltips carry the accessible name.

Engineering shape

  • packages/ui gains the shared primitives (pure buildDecisionSpec state engine, DecisionControl, useDismissablePopover, one honest shortcut scope), deliberately off the host-supported surface except the scope on the blessed shortcuts barrel. @plannotator/ui stays at the unpublished 0.38.0 taken at stage one; ToolbarButtons' dead muted prop removed (internal).
  • Server work is isolated to one stage: the approvalNotesSupported advert on all diff-family payloads in BOTH runtimes, the shared approved-message composer through all four decision consumers, and the archive fork. Strict-gate exit codes and formatAnnotateOutcome shapes are byte-identical (pinned).
  • The old containers are fully deleted per the spec's inventory (residue-grepped): AgentReviewActions, the approve-warning dialogs, the approval-policy apparatus, the send-at-zero guard, the platform three-button row. Net-negative final cleanup stage.
  • Every stage's review hunted the repo's named past failure classes: no ladder collapses, no silently dropped notes, no copy-pin snapshots, no mock-theater tests; three long-skipped DOM suites are now registered in the CI lane.

Known follow-ups (tracked)

Supersedes #1452 (its commits are the first stage of this stack).

buildDecisionSpec maps { app, gate, count, hasFeedback,
approvalNotesSupported } onto one primary plus ordered menu items —
labels, subtitles and confirm strings verbatim from the approved
prototype (DESIGN_final-proposal.html). approvalNotesSupported gates
every approve-carrying item; the discard item is count-gated so it can
never offer to discard zero annotations; frozen copy is marked inline.
Pure (no React, no DOM) so the full state matrix runs in the plain
bun test lane.
…e-popover hook

The joined split control: incumbent primary segment (never opens the
popover, never fades while it is open — the popover holds only
alternates, so the primary keeps its meaning), caret popover with
role=menu rows and roving arrow-key focus, in-place composer morph
(Esc ladder consumes on exactly two rungs, drafts kept; Mod+Enter
submits trimmed; plain Enter is a newline; empty note refocuses instead
of graying), and the single discard confirm through ConfirmDialog.

useDismissablePopover is the shared pointerdown-outside + Escape effect
with the framed-surface strategy: window blur to an iframe dismisses,
since iframe clicks never reach the parent document. ActionMenuItem
gains additive role/className props and ExitButton an additive
appearance='ghost' form; defaults are byte-identical for existing
consumers. DOM tests registered in the CI seam-contract step so they
cannot silently skip.
One scope at the shortcuts root (both apps mount the identical control)
documenting only the two chords the control implements: Mod+Enter
submits the note, Escape steps back keeping it. Enter is deliberately
not documented as submit — it is a newline. Registry test guards that
the scope composes into both adopting settings registries without id
or token collisions ahead of the PR2/PR3 registration.
Internal (non-host-surface) decision-control primitives only:
DecisionControl, decisionSpec, useDismissablePopover join the
unsupported list — no supported import is added. Core pin stays exact
at 0.25.1; smoke:package verified the 0.38.0 tarball.
Review F1: the hook's document-level Escape dismissed the popover but let
the event travel on to the apps' window-level Escape ladders — from PR2
one Esc would both close the menu AND drop an HTML-annotate surface to
Interact. The hook now skips defaultPrevented events and, when it acts,
calls preventDefault + stopPropagation (document bubbles before window).
An outside-focus Escape while the composer is open closes the whole
popover — an outside-dismissal gesture like an outside click; the draft
is kept — commented at the dismiss call site. New DOM test pins all
three: open+outside-focus consumes, composer case closes-all keeping the
draft, closed control consumes nothing (verified failing without the
hook fix).

Review F3: pin the frozen 'Request changes…' label (it is in the spec's
frozen set but was never asserted) and downgrade the unfrozen sentence
pins (composer action labels, approve-note labels, discard sentences) to
data assertions — flow verb, live count, singular form, composer
distinctness — per the Testing Rules.
…lback

F2 (maintainer default, option A, pending final confirmation): the
count:0 + hasFeedback:true cell now offers approve-with-notes on capable
approval flows with zero-form copy (no annotation-count language);
discard-and-finish stays omitted (nothing to discard).

F6: when the live spec changes while the composer is open and the active
item id leaves the spec, the control morphs back to the menu (draft
kept); an emptied menu closes. Implemented in the control so PR2/PR3
cannot diverge.
The annotate header's Exit/Send Feedback/Approve cluster becomes ghost-X
Close + DecisionControl driven by buildDecisionSpec. One
submitPrimaryDecision() serves the header primary, the global Mod+Enter
handler (via a render-assigned ref), and the compact primary row. The
zero-state keyboard-only silent submit becomes the visible Done primary
and keeps posting the byte-identical legacy /api/feedback body; the gate
empty primary stays Approve on /api/approve.

Note transports reimplement the held #1436 mechanism: the note becomes
a GLOBAL_COMMENT at submit time with a one-render deferred submit whose
route is captured at menu-choice time. Non-gated "Done with a note…"
carries the approval-framing sentence (buildCompleteAnnotateFeedback
approvalFraming); discard flows post the legacy zero payload with empty
annotation arrays on their flow's endpoint.

Deletions per spec §5.3-§5.5: getAnnotateApprovalPolicy + its confirm
dialog and suppression refs, exitWarningAction's approve half,
requestAnnotateApprove, handleHeaderAnnotateApprove/Feedback, the
annotate half of the shared Approve cluster and its header props.

Compact/touch rows are generated from the same spec (visible positive
action in every state); composer items open DecisionNoteDialog, the
discard item the shared ConfirmDialog. Header predicate consolidation:
the flip is hasFeedbackToSend, so agent-terminal-delivered feedback no
longer leaves a stale Send Feedback primary. decision-control scope
registered in the annotate settings shortcut registry; the
submitAnnotations entry now describes the adaptive primary.
… Esc-ladder coverage

- decisionHandlers.test.ts (pure, spec §8C): every id buildDecisionSpec
  can emit resolves to a transport route; note/discard routes follow the
  gate matrix; compact row ids stay collision-free.
- App.decisionControl.test.tsx (DOM, spec §8D/E16, adapted from the held
  App.submitNote.test.tsx harness): Done posts the byte-identical legacy
  /api/feedback body, Mod+Enter equals the visible primary, the two note
  items do not collapse into one payload, gate approves on /api/approve,
  the discard confirm drops annotations, composer Escape keeps the note,
  compact touch has a working positive row at zero, and the closed
  popover never consumes the HTML Esc ladder's Escape.
- Registered the DOM file in the CI seam-contract step so it cannot
  silently skip.
…-spec confirms, armed retry

M1 (ruling: honest label, keep transport): additive feedbackDelivered on
DecisionSpecInput; the agent-terminal delivered empty state keeps the
Done primary and its full-payload /api/feedback transport but stops
claiming 'no feedback' in the tooltip and note-item subtitle (free
prose, not frozen). App passes isCurrentFeedbackDeliveredToAgent;
decisionSpec fact-guard pins the delivered-state copy.

L1: dead gate prop removed from AppHeader (decl, destructure, App call
site, both test prop objects).

L2: confirm/composer surfaces resolve from the LIVE spec — DecisionControl
stores only the confirm item id and steps back to the menu when the item
leaves the spec; the App's compact composer/confirm dialogs do the same.
F6 DOM test extended with a confirm-open spec-flip case.

L3: a failed submit keeps the captured note decision armed —
handleAnnotateFeedback/Approve report success, pendingDecisionSubmit is
cleared only on success (one auto-dispatch per arming, in-flight ref
guards double submit), and the next primary invocation replays the
captured route/framing. Pinned: note-commit -> POST 500 -> primary retry
posts approvalFraming intact on the captured route. Trivial guard: a
pending whose note id left annotations state disarms.

L4 (ruling: delete): the F6 empty-items close branch and its test are
gone — buildDecisionSpec guarantees at least one item.

L5: discard confirm copy neutralized (counts can include other tools'
findings; the non-gate message notes direct edits still ride).

Nit: dropped the review-registry absence assertion in shortcuts.test.ts.
Agent-mode review replaces AgentReviewActions (Close/SendFeedback flip +
dimmed Approve + hover tooltip) with the ghost-X Close + DecisionControl
driven by buildDecisionSpec: Approve at zero, Send Feedback . n otherwise,
with Request changes... / Send with a note... and the explicit
'Approve, discard n annotations...' confirm behind the caret. One
submitPrimaryDecision() serves the header primary, the global Mod+Enter
handler, and the compact primary row; compact rows are generated from the
same spec so a visible positive decision exists in every state, with
DecisionNoteDialog / ConfirmDialog resolving from the LIVE spec by item id.

Transport routing is pure in reviewDecision.ts and single-endpoint: every
decision POSTs /api/feedback with approved as the only fork. A note commits
a scope:'general' CodeAnnotation (filePath ''/0/0 sentinels, crypto-random
id, no PR-context stamp, not history-recorded) and submits one render later;
a failed POST keeps the armed note decision for a primary retry (cleared
only on success). Approve-carrying items stay capability-gated on
reviewApprovalNotesSupported, hardcoded false until PR5 ships the server
advert + consumer delivery (spec 6.4) - no server field is invented here.

Deletions per spec 5.1/5.2: AgentReviewActions.tsx outright, the
'Annotations Won't Be Sent' approve confirm + its suppression references,
and handleSendFeedback's send-at-zero guard (unreachable once no send is
offered at zero, and it would swallow a request-changes submission). The
exit warning and the copy-path 'No Annotations' dialog survive. The
decision-control shortcut scope joins the review settings registry and the
submit entry now describes the adaptive primary.
…overage

- reviewDecision.test.ts (pure lane): every id the spec can emit resolves
  to a route, routes fork only on approved, compact row ids stay unique -
  the runtime stand-in for the missing app typecheck (spec 8C).
- review-note-payload.test.ts, ported as-is from the held branch: the
  scope:'general' note survives waitForDecision unmodified on BOTH runtimes
  and archives as a feedback decision counting the note.
- exportFeedback.reviewNote.test.ts, ported as-is: the note renders under
  '## General', co-exists with placed annotations, and is real feedback.
- App.decisionControl.test.tsx (DOM lane): mounts the real review App
  (workerPool's Vite-only ?worker&inline import stubbed per the
  AllFilesCodeView.lifecycle idiom) and asserts through the posted body:
  empty-state Approve posts the legacy LGTM shape, Send Feedback posts the
  live annotations, Request changes... delivers the sentinel general note in
  the array and the export, discard posts empty arrays only after its
  confirm, Mod+Enter equals the visible primary, approve-carrying items are
  absent while the advert is off, and compact touch offers a positive row at
  zero that posts (E16-review). Registered in the CI DOM lane so it cannot
  silently skip.
- shortcuts.test.ts now asserts the decision-control scope is registered in
  BOTH adopting registries (the PR2-era compose-cleanly probe became real).
…ed-note failure coverage, loud PR5-gated refusal

HIGH-1: one Mod+Enter over the open discard confirm posted TWO contradictory
decisions — the app's window-level Mod+Enter effect fired submitPrimaryDecision
(approved:false send) while ConfirmDialog's own window-level handler fired
onConfirm -> handleApprove (approved:true LGTM) from the same event;
stopPropagation cannot stop same-target listeners. Fixed with PR2's idiom: the
review Mod+Enter effect bails on the data-plannotator-confirm-dialog sentinel,
which covers both the DecisionControl-internal confirm and the compact
confirm. Pinned by a DOM repro test asserting exactly ONE POST (the confirm's
LGTM body); revert-verified — with the guard neutralized the test fails with
two recorded POSTs.

MEDIUM-1: added the armed-note failure-path DOM test (500-then-200 double):
a failed note POST keeps the scope:'general' note in state with the primary
flipped to Send Feedback, the next primary click retries the same
note-carrying body, and success clears the armed decision so a subsequent
Mod+Enter dispatches nothing.

MEDIUM-2: the approve-with-notes refusal now console.errors naming PR5, and
the route object carries implemented:false. The advert constant moved to
reviewDecision.ts (REVIEW_APPROVAL_NOTES_SUPPORTED) so the new pure contract
test can pin 'the advert never emits an id whose route is an unimplemented
refusal' — flipping the advert in PR5 without wiring delivery fails the test,
and deleting the constant without updating it breaks at import.

Docs: the review-header section now records F8 (Approve primary icon-only
below lg where ApproveButton showed OK; title carries the accessible name),
the two-click discard replacing the dimmed one-click Approve, the Mod+Enter
confirm-ownership rule, and the accepted compact-composer draft asymmetry.
Also trimmed the pure routing test's per-id echo assertions to the
load-bearing equalities.
Maintainer ruling on spec open question 2 (rides PR4): normalizeAnnotation's
copied-field allowlist gains scope?: string, so a review-level general
comment archives distinguishably from a line comment in index.jsonl.
Additive only — the JSONL line shape is a cross-tool contract (fields are
added, never repurposed) and readers must keep ignoring unknown fields; an
absent scope stays absent (the pre-scope line default), never defaulted-in.

apps/pi-extension/generated/ is gitignored and rebuilt by vendor.sh, so the
shared edit reaches the Pi runtime at build time; the dual-runtime
review-note-payload archive test now asserts the archived general-note
record carries scope on BOTH runtimes, and the pure shared-lane test pins
the normalizer directly (cannot silently skip).
…comment producer (PR4)

Spec §3.3: scope:'general' annotations already render, badge, and export;
the sole producer was Call Flow. This adds the human one — the community's
"integrated global comment field" ask.

- ReviewSidebar gains optional onAddGeneralComment. The SAME button renders
  in the General section header (which now renders whenever the callback is
  present, even with zero general comments) AND in the all-empty state — the
  state the affordance is most useful in. The composer is the shared
  DecisionNoteField in a small anchored popover (the third consumer the
  branches' extraction tripwire named); the draft survives a dismissal, an
  empty commit refocuses the field (the decision-composer contract).
- Both human producers now share one shape factory,
  createGeneralReviewComment in reviewDecision.ts: scope:'general',
  sentinel filePath ''/0/0, review-note-${randomUUID()} id, and NO PR
  context — an unstamped annotation passes every PR scope predicate
  (utils/annotationScope.ts), so the comment survives an in-place PR switch.
- Unlike the header composer's one-submit note, the sidebar comment goes
  through addCodeAnnotationsWithHistory: undoable, draft-persisted, and
  deletable via the sidebar's existing delete. Creating one raises
  totalAnnotationCount, which flips the header control to Send Feedback · n
  — the control is state-driven by construction.

No server change (spec §6.1): the comment rides the existing /api/feedback
annotations array and the export's ## General section on both runtimes.
…PR-switch survival

Spec §7 PR4 hunt list + §8 test 17:

- ReviewSidebar.generalComment.test.tsx (DOM, registered in test.yml's DOM
  lane so it cannot silently skip): reachable at totalCount === 0; the
  General section header offers the button with only line comments; a commit
  delivers the trimmed text exactly once (button and Mod+Enter); an empty
  note never commits; Escape dismisses but keeps the draft.
- App.decisionControl.test.tsx: end-to-end flip proof — a sidebar general
  comment flips the header primary from Approve to Send Feedback · 1 and
  rides the posted /api/feedback body with the sentinel shape and no prUrl.
  Guards the wiring a component test cannot see (prop dropped from the
  sidebar mount, handler stamping PR context).
- reviewDecision.test.ts (pure lane): createGeneralReviewComment shape —
  trimmed scope:'general' with sentinels, randomUUID ids that cannot collide
  in one millisecond (spec §9), whitespace-only returns null, and the
  PR-switch survival pin: no prUrl/diffScope, passes annotationMatchesPrScope
  for any PR + diff scope.
The portable viewer's Tailwind scan covers packages/review-editor, so the
sidebar composer's new utility classes changed the built viewer CSS hash;
check:manifest fails on a stale pin.
…t, in-flight guard

- M1: clamp the general-comment popover to the sidebar panel width (inline
  maxWidth = panelWidth - 32) so it never clips inside OverlayScrollArea's
  overflow-x hidden at the 200px end of the persisted 200-600px range; the
  viewport-relative class remains only as the overlay-presentation guard.
- L2: lift the composer's open + draft state into ReviewSidebar, shared by
  both placements, so an SSE placement flip (empty state -> General header)
  or a tab switch keeps a half-typed draft; sidebar collapse discards it.
- L1: guard handleAddGeneralComment against submitted/in-flight decisions so
  a commit during a decision POST cannot vanish from wire and archive.
- I1: AGENTS.md — platform-mode presence is deliberate (rides the pr-action
  body via scope:'general'), and honest draft-lifetime wording.
- NIT: the empty-commit test also asserts the refocus contract; new flip
  test drives the draft survival through the parent.

Claude-Session: https://claude.ai/code/session_01Drrzd1x4EfnH9N3z7nNwo9
…age composer (PR5)

Both runtimes gain an `approvalNotesSupported` option on the review server
(default false) echoed on every diff payload — /api/diff, /api/diff/switch,
/api/pr-diff-scope, /api/pr-switch — so the advert survives a diff switch
(spec §6.4). An old caller that never passes the option advertises false and
the client keeps rendering no approve-carrying items.

packages/shared/prompts.ts gains composeReviewApprovedMessage (vendored to Pi
with the rest of prompts.ts): the one composer every review decision consumer
emits approvals through — the approved prompt, then the approve-time feedback
when the decision carries any. It filters the legacy
'LGTM - no changes requested.' placeholder so a new consumer reading an old
built client's approval never appends filler the reviewer did not write.

Dual-runtime tests: advert default false / true-and-survives-switch on both
servers; approve-time feedback reaches waitForDecision byte-identical and
archives as approved-with-notes; a bare approval (post-placeholder shape)
archives as lgtm with no sidecar — the decision the placeholder made
unreachable (spec §6.2 fact 1).

Claude-Session: https://claude.ai/code/session_01Drrzd1x4EfnH9N3z7nNwo9
…carding consumers (PR5)

The four waitForDecision consumers that threw result.feedback away on the
approved branch (spec §6.3) now emit composeReviewApprovedMessage — the
approved prompt, then the note when one rides the decision:

1. Claude Code CLI `plannotator review` (apps/hook/server/index.ts); the
   amp/droid plugins relay its stdout and inherit the delivery.
2. OpenCode native (apps/opencode-plugin/commands.ts) — also fixes the
   delivery gate: it rode on the LGTM placeholder making feedback truthy, so
   with the placeholder gone a bare approval would have been silently dropped;
   the gate is now `feedback || approved`.
3. OpenCode CLI bridge (buildReviewPromptFromBridgeOutcome) — the CLI's JSON
   record always carried the feedback; the bridge stops discarding it.
4. Pi (apps/pi-extension/index.ts) via the vendored prompts module.

Each consumer's startReviewServer call now passes the matching advert:
supportsReviewApprovalNotes(origin) for the hook CLI (new seam in
apps/hook/server/review-output.ts — every origin shares the one stdout
relay today), Boolean(sessionId) for OpenCode native (no session, no
delivery — the annotate precedent), unconditional true for Pi and the
standalone dev server (which already emitted feedback on approve).

Claude-Session: https://claude.ai/code/session_01Drrzd1x4EfnH9N3z7nNwo9
… placeholder removed (PR5)

Client half of spec §6.4, extending PR3's tripwire exactly as its comment
instructed:

- REVIEW_APPROVAL_NOTES_SUPPORTED is deleted. The spec input is now the
  server advert, read off /api/diff (and re-read from any diff payload that
  carries it) through readApprovalNotesAdvert — absent reads false, so an old
  server renders no approve-carrying items and a new server against an old
  client changes nothing.
- The approve-with-notes route stops being a marked refusal: it forks on
  withAnnotations ('Approve with notes' ships the live annotations plus their
  export; 'Approve with a note…' ships the composer note alone) and lands on
  handleApprove via the new pure buildReviewApprovalBody.
- handleApprove drops the 'LGTM - no changes requested.' placeholder: a bare
  approval sends feedback '' — consumers now print approve-time feedback, and
  the empty body is what makes the archive's lgtm decision reachable and
  stops bare approvals writing sidecars (spec §6.2 fact 1).
- reviewDecision.test.ts: the PR3 tripwire becomes the delivery assertion
  (under a true advert every approve-carrying item's payload carries the
  content, never the placeholder) plus the absent-advert-is-false pin.
- App.decisionControl.test.tsx: bare-approval assertions move to the empty
  body, the advert-off test pins the old-server payload shape, and a new
  advert-on case proves 'Approve with notes' posts the live annotations.

Claude-Session: https://claude.ai/code/session_01Drrzd1x4EfnH9N3z7nNwo9
…e, PR-payload advert, note fold

Applies the PR5 stage-review rulings:

M0: approve-time feedback is no longer appended raw after the approved
prompt ("no changes requested" beside a change-request-shaped export read as
a contradiction). composeReviewApprovedMessage now resolves the new
DEFAULT_REVIEW_APPROVED_WITH_NOTES_PROMPT (configurable as
prompts.review.approvedWithNotes; field added to the config review section),
which frames the notes as non-blocking guidance and says not to revise or
reopen. Signature is now (runtime, feedback, config) so one function fixes
all four consumers; re-vendored to Pi; prompts.test.ts pins the framing,
the config override, the byte-identical bare approval, and the legacy
placeholder filter; cli-bridge.test.ts asserts the bridge routes through
the composer.

M1: fail-closed approval-notes handshake for the OpenCode CLI bridge. The
plugin declares supportsApprovalNotes: true on the opencode-review stdin
JSON; the binary adverts approvalNotesSupported for opencode ONLY when the
declaration is present, so a new binary + old plugin (advert in the binary,
delivery in the independently-versioned plugin) renders no approve-carrying
items instead of silently dropping the reviewer's note.
supportsReviewApprovalNotes stays the seam; documented at both ends beside
the existing version-skew reasoning. Pinned end to end by
apps/hook/server/opencode-review-advert.test.ts, which spawns the real
entrypoint and reads /api/diff: stdin without the declaration serves false,
with it true.

m1: applyPRResponse re-applies the advert from the PR-family payloads
(pr-diff-scope, pr-switch, load-full-diff), so the client stays in lockstep
with whatever diff payload it last applied — the "whole diff family" comment
is now literally true.

m2: buildReviewApprovalBody folds a note in ahead of the export when
annotations also ride, so a future combined item cannot lose data; pinned
with a pure assertion.

i1: AGENTS.md corrected — the standalone dev server emits raw decision JSON
with unfiltered feedback and does not route through the composer; the
consumer list, framing, and handshake are now described accurately.

Claude-Session: https://claude.ai/code/session_01Drrzd1x4EfnH9N3z7nNwo9
buildDecisionSpec grows a platform input arm (§3.4): the same DecisionSpec
shape with no composer, confirm, or discard items, ever — labels per the
approved DESIGN_header-pr-mode mock. Approve-carrying rows gate only on
selfAuthored and mute rather than disappear; approvalNotesSupported is
deliberately ignored (the platform posts to the forge natively).

DecisionControl renders the mute: a muted primary dims, no-ops on click, and
keeps its native title tooltip (never `disabled`, which would eat the reason);
muted menu rows disable with the reason in their subtitle, and the roving
focus skips disabled rows so arrow keys keep working.

Pure tests pin the §3.4 hard rules at spec level: no composer/confirm on the
platform side in any state, mute-not-remove with a live row always left, and
the advert ignored.

Claude-Session: https://claude.ai/code/session_01Drrzd1x4EfnH9N3z7nNwo9
The platform three-button row (ExitButton + conditional "Post Comments"
FeedbackButton + self-approval ApproveButton with its hover tooltip div) is
replaced by the same ghost-X Close + DecisionControl shape agent mode wears,
rendered from buildDecisionSpec's platform arm. Every action routes through
the pure resolvePlatformDecisionAction into openPlatformDialog — the existing
ReviewSubmissionDialog (per-target state, retry, "leave PR open" toggle) is
untouched and remains the only note field on this side.

Mod+Enter in platform mode now fires the visible primary via the same
runPlatformDecisionAction the header and compact rows call, so the muted
self-approval empty state is a keyboard no-op (previously it silently fell
back to the comment dialog); while the submission dialog is open the keydown
effect still routes Mod+Enter to the dialog's own submit before any primary
dispatch, so nothing double-fires. Compact/touch rows are generated from the
platform spec exactly like agent mode's, reusing the same row-id mappers
(unique per spec, swept in the pure lane). The destination dropdown,
double-tap Alt, and the ghost-X exit warning are untouched.

DOM tests cover the four platform states (n>0/empty x self/not): the primary
and every menu item open the dialog in the right mode with nothing posted,
the mute is a dead-end-free no-op, Mod+Enter over the open dialog submits it
exactly once, and a destination flip swaps the spec while stranding nothing.

Claude-Session: https://claude.ai/code/session_01Drrzd1x4EfnH9N3z7nNwo9
The mute classes added to the shared ui components changed the viewer CSS
hash; regenerate so CI's staleness check passes.

Claude-Session: https://claude.ai/code/session_01Drrzd1x4EfnH9N3z7nNwo9
… a11y, item mute guard

- AGENTS.md: the platform-mode sentence now describes the shipped PR6 state
  (ghost-X + DecisionControl, no composer, everything through the existing
  ReviewSubmissionDialog, self-approval mute preserved) instead of the
  pre-PR6 three-button row.
- ToolbarButtons: delete the now-dead `muted` prop from FeedbackButton and
  ApproveButton — PR6 removed its last consumer. `dimmed` stays (live plan-
  mode consumer in packages/editor AppHeader); ToolbarButtons is not on the
  host-supported README surface, so the removal is internal.
- DecisionControl: the muted primary's reason now renders through the shared
  Tooltip (Base UI trigger wires floating-ui useFocus, so it shows on hover
  AND keyboard focus-visible) plus a persistent aria-describedby description
  via a hidden span; the native title is dropped for the muted case only so
  the tooltip is not doubled. Frozen sentence unchanged.
- runPlatformDecisionAction: structural mute guard for the approve ITEM ids
  too — a muted item's handler is inert from any surface, not only where the
  DOM disables the row.
- DOM test: the empty+self menu pins initial focus on the first non-disabled
  row (the roving-focus :not(:disabled) skip now has a regression pin), and
  the muted-primary assertions follow the reason to aria-describedby.

Claude-Session: https://claude.ai/code/session_01Drrzd1x4EfnH9N3z7nNwo9
…UUID global-comment id (PR7)

ActionMenu and ApproveDropdown drop their hand-rolled outside-pointerdown +
Escape effects for the shared useDismissablePopover (spec §5.7/§7 PR7). The
one deliberate behavior delta is the hook's documented contract: the Escape
that dismisses an open menu is consumed, so it no longer also runs the host
app's Escape ladder — one Escape, one rung. ApproveDropdown's listeners now
run only while open (they were no-ops when closed). FloatingQuickLabelPicker
keeps its own dismissal on purpose (deferred capture-phase registration so
the opening selection gesture cannot dismiss it; Escape shares the
digit-select listener) — the spec's third named copy, QuickLabelDropdown.tsx,
carries no dismissal effect to converge.

Viewer's global comment id moves from global-${Date.now()} to a UUID
(spec §9): two comments minted in the same millisecond would collide on a
timestamp id.
…0.38.0 handoff accuracy (PR7)

tests/UI-TESTING.md gains the Decision Control Manual Checklist (annotate
file/gate/folder/last, HTML/live-app iframe dismissal, review agent +
platform modes, compact/touch, sidebar general comment) and fixes the
test-opencode-review.sh flow that asserted a Send Feedback button at zero
annotations. AGENTS.md: the code-review flow diagram stops claiming Approve
sends the literal LGTM (gone since the approve-with-notes delivery), and the
shortcuts-root scope list names decisionControl.shortcuts.ts beside
history.shortcuts.ts. packages/ui/HANDOFF.md's 0.38.0 line now covers the
whole stack accurately: internal primitives stay off the host-supported
surface, decisionControlShortcuts rides the blessed shortcuts barrel, and
the ToolbarButtons muted-prop removal is internal. The stack ships as one
unpublished 0.38.0 — no further version bump (spec §2.1/§9: batch the bump
into PR1 and PR7; nothing was published in between).
…low accuracy (PR7 review)

F1: with vim enabled, Escape on an open ActionMenu/ApproveDropdown/
DecisionControl was consumed by useVimDocumentFocus (registered first on
document, preventDefaults while reclaiming focus; the popover hook then
skips the defaultPrevented event) and the menu stayed open. Open popovers
now stamp data-pn-dismissable-popover and the vim hook's
BLOCKING_OVERLAY_SELECTOR treats them as blocking overlays, so vim defers.
Pinned in ActionMenu.test.tsx (verified failing without the selector entry).

F2: the plan editor's diff-exit Escape listener gains the defaultPrevented
early-return so a popover-consumed Escape no longer also exits the diff
view. Audit of the remaining document-level Escape listeners found only
component-internal ones (Settings theme preview, which already consumes;
dialog focus traps handle Tab, not Escape) — left alone.

F7: onDismiss callbacks memoized in ActionMenu/ApproveDropdown so
useDismissablePopover stops re-registering listeners every render while
open.

F8: ActionMenu.test.tsx, PlanHeaderMenu.mobile.test.tsx, and
ReviewHeaderMenu.mobile.test.tsx were DOM-gated but never registered in the
CI DOM lane; added to the test.yml file list so they actually run (the F1
pin lives in a lane-registered file).

F3-F5 (docs): test-opencode-review.sh stdout describes the decision control
(Approve at zero) instead of the removed always-on Send Feedback button;
UI-TESTING.md's platform-mode flow uses the real positional PR URL; the
AGENTS.md annotate flow diagram ends on the adaptive primary like the
review diagram.
…-pr7

# Conflicts:
#	packages/core/guide-viewer-manifest.ts
Post-demo maintainer feedback: without a gate there is no approval, so the
non-gate Done primary and its note row drop the success tone and check icon
(gate and review Approve keep them), and the ghost close button renders the
word Close at every breakpoint instead of a bare X.
…; purge em dashes from decision copy

Maintainer rulings: the non-gated annotate empty state offered 'Done with a
note…' and 'Request changes…' whose only difference was the approval-framing
sentence on the same /api/feedback transport - they collapse into a single
'Send a note…' item (id 'request-changes', plain feedback, no framing). The
approvalFraming machinery stays for the non-gated discard path and gate mode;
the non-gate 'note-with-approval' route is now dead code pinned as unframed
feedback so a stray dispatch can never fabricate approval. And no user-facing
decision-control string carries an em dash any more: titles, subtitles,
composer action labels, confirm copy, and the two Mod+Enter shortcut
descriptions are rewritten with plain punctuation (agent-facing prompt
constants and frozen labels untouched).
@backnotprop
backnotprop merged commit 87e4468 into main Sep 3, 2026
28 checks passed
@backnotprop
backnotprop deleted the feat/decision-control-pr7 branch September 3, 2026 01:50
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