fix(claims): don't let a click on a confirming response retract it - #2587
Merged
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
ohohoreilly
force-pushed
the
ohohoreilly/claim-position-confirming
branch
from
September 25, 2026 18:49
05c4cce to
bf517b7
Compare
While a claim response is publishing or waiting on the indexer, the held pill is the client's optimistic guess, and pressing a held pill sends a retraction. Ignore presses on either pill until the response is indexed, mark them pending, and say so under the claim page's pills. Let the readiness backfill re-report a side the chain holds again when geo-chat's row is marked claim_response_withdrawn, and turn an intent_missing refusal of Request debate into a re-report plus a readiness refresh, with copy the reader can act on.
ohohoreilly
force-pushed
the
ohohoreilly/claim-position-confirming
branch
from
September 25, 2026 19:06
bf517b7 to
9f7d01e
Compare
ohohoreilly
added a commit
that referenced
this pull request
Sep 25, 2026
#2587 showed "Waiting for confirmation" under the claim page's pills and a progress cursor on them while a response confirmed. It read as the side not having been taken, where it used to look taken at once. Drop both; the pills still quietly drop presses until the response lands, which is what stops a second press retracting it, and their tooltip still explains the wait.
jwalkingjew
added a commit
that referenced
this pull request
Sep 25, 2026
#2595 removed two things #2587 had added for the 10-50s a response spends confirming: the note under the claim page's Agree/Disagree pills, and the progress cursor on the pills themselves. The note was the part that made the side look not yet taken, so it stays gone. The cursor was not, and without it the row says nothing at all: presses are dropped for up to 50s with no visible reason, so the pill reads as broken rather than busy. This brings back only the cursor. aria-disabled already covered assistive tech; the pointer had nothing. Tested where the behaviour lives rather than on the claim page, since every surface that renders the pills gets it: position-row.test.tsx now pins the cursor on a pending pill, its absence once the response lands, and — in the same test — that the side still reads as held with no note under it, so the two halves of this decision cannot drift apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jwalkingjew
added a commit
that referenced
this pull request
Sep 25, 2026
#2595 removed two things #2587 had added for the 10-50s a response spends confirming: the note under the claim page's Agree/Disagree pills, and the progress cursor on the pills themselves. The note was the part that made the side look not yet taken, so it stays gone. The cursor was not, and without it the row says nothing at all: presses are dropped for up to 50s with no visible reason, so the pill reads as broken rather than busy. This brings back only the cursor. aria-disabled already covered assistive tech; the pointer had nothing. Tested where the behaviour lives rather than on the claim page, since every surface that renders the pills gets it: position-row.test.tsx now pins the cursor on a pending pill, its absence once the response lands, and — in the same test — that the side still reads as held with no note under it, so the two halves of this decision cannot drift apart. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
jwalkingjew
added a commit
that referenced
this pull request
Sep 26, 2026
The pills learned how to behave in the confirming window in #2587 and #2598, and nothing tied the thumbs to them, so the two drifted. The thumbs — what the sticky header votes with, and every entity header besides — now match all of it: - presses are ignored. The held thumb is this client's guess until the write lands, and pressing a held thumb means "remove", so a second press or a double-click published a retraction mid-confirmation; - `aria-disabled`, and a progress cursor on the pointer; - no hover step, since nothing under the pointer is going to happen; - the tooltip reads the confirming copy, as the pills' `actionTitle` does; - full strength — `aria-disabled` rather than `disabled`, so the held side still reads as taken. All of it lives in one `voteButtonProps` both thumbs spread, so the two thumbs cannot drift from each other. Keeping the thumbs and the pills from drifting again needs them to be one control, which is tracked separately. Inline only. `DebateVotePill` shares the handlers, so the guard sits on the inline buttons rather than in them; the debate overlay keeps its current behaviour and goes with the unification. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jwalkingjew
added a commit
that referenced
this pull request
Sep 26, 2026
* feat: keep the entity's name and interaction in view while scrolling
Long entity pages lose their subject: by the time you are in the property
sheet, the comments or a claim's sources, nothing on screen says which
entity you are reading, and voting means scrolling back to the top.
Docks a bar under the navbar once the title scrolls away, carrying the
name, the avatar or cover if there is one, and the entity's own response
control — `EntityVoteButtons` unmodified, so a claim keeps agree/disagree
and everything else keeps upvote/downvote without a second place deciding
which is which.
Mounted once in the `(entity)` layout, above every branch that draws a
page, and portalled into a zero-height host in the app shell. The shell is
the only place that can express "full width of the content column, under
the navbar" — the route renders inside a width-capped, transform-animated
`main` where neither `fixed` nor a full-bleed `sticky` behaves — and the
host takes no height so appearing costs no layout shift.
The title is found by `data-entity-page-title`, not by a ref: four
unrelated components draw it (generic header, claim hero, topic hero,
profile layout) and only one is mounted at a time.
* fix: raise the sticky header on a topic page too
A topic draws its own `<h1>` in browse mode rather than going through
`EntityPageTitle`, so the bar had nothing to watch and never appeared —
verified in a browser against a real topic page, alongside the claim and
person routes, which were already right.
* fix: let the sidebar toggle and the responder faces be clicked
Two things the sticky bar sat on top of, or left inert.
The browse sidebar's collapse toggle hangs off the sidebar's right edge at
52px from the top, squarely inside the bar's band, and the `z-[60]` on the
button cannot lift it out of the sidebar's own `z-50` stacking context —
so the bar covered it and cut the circle in half. The bar drops to `z-40`:
matching 50 would not have been enough, since the host comes later in the
DOM and an equal layer still wins.
The responder faces beside a claim's tally were a plain span. They are
pictures of the people the list names, so they are what a reader reaches
for, but only the tally was ever wired to the popover — the cluster looked
like a control and did nothing. Both halves now open it, the way
`ClaimSideResponders` already opens the same list from the same faces on
the claim hero. A `Popover.Root` each rather than one root with two
triggers, which Radix does not support; two roots also behave when one is
already open, since the outside pointerdown dismisses it and the trigger
that was clicked opens its own. The faces stay a plain span until there is
somebody to list, so there is no invisible tab stop around nothing.
* fix: order the bar's controls and stop it cutting the sidebar rail
The responder faces move after the thumbs. Everywhere else they lead,
because they sit inside a card with the claim's text above them; in the bar
the name runs right up to the control, so faces between the two read as
part of the name rather than as part of the tally they belong to.
The collapsed sidebar keeps a vertical rail 24px into this column with
nothing holding the space, so a full-width bar ran its background and
bottom border out past the rail and cut the one line still saying where the
sidebar is. The bar now starts on the rail, which draws over it from z-50 —
so it reads as beginning the pixel after. Full width again below the mobile
breakpoint, where there is no sidebar and so no rail; an expanded sidebar
needs nothing either, having real width and its own border-r.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: line the sticky bar up with each page's own content column
The bar was 900 wide with a gutter of its own, which is the generic entity
page and nothing else: a claim's column is 840 at px-4/px-5, a topic's 720
at the same, and a page with a rail 1142. So on every page but one the name
and the controls sat a little inside or outside the text they belong to.
No number here would have fixed that — it would have been a fourth opinion
that drifts as views are added. The bar is portalled into the app shell, so
no CSS reaches it from the page either. It now measures the column the
title it is already tracking sits in, marked with one shared attribute, and
copies its content box: padding taken off, so the bar's text starts exactly
where the page's does rather than a gutter outside it. A view with a width
of its own is matched by carrying the attribute, with nothing to keep in
sync here; one that does not falls back to the generic width as before.
Measured in a browser at three widths: claim, topic and person pages line
up to the pixel on both edges, including a phone, where the bar follows the
page's own 17px inset.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: pin the sticky bar's re-measure on a column or host resize
The observer is what makes the bar follow a width rather than record one,
and nothing was asserting that it fires. Both halves are covered: the
column changing size, and the host moving under a column that has not — a
column at its max width does not resize when the window does, it only
moves, which an observer on the column alone would miss.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: review findings on the sticky entity header
Five defects and one piece of duplication, from a review of the branch.
The row's mirrored offset was unclamped. A collapsed sidebar insets the
host by the rail's 24px while the page's column still starts at the content
edge, so a viewport narrow enough for the column to reach that edge put the
offset below zero and painted the avatar and the name outside the bar's own
background, over the rail. The left edge is clamped and the right held, so
what gives is the one edge that cannot be honoured.
`useScrolledPastElement` did not reset when its selector changed. The next
entity's title is not in the document at that moment and `next === watched`
is true when both are null, so the early return left the previous entity's
answer standing and kept handing out its detached title to be measured.
`useMirroredContentColumn` passed a detached or hidden column on as
`{ width: 0 }` rather than as the absence it is, so the bar drew a blank
strip instead of falling back to its own width.
The responder faces became a trigger on the optimistic count while the list
they open reads the served responders, so a viewer's first vote made their
own face open a popover saying nobody had responded — beside a tally that
stayed disabled. Both now read the served counts.
The body `MutationObserver` re-queried the document on every batch on a
route that draws no title. The debates feed mutates continuously; lookups
are now coalesced to one a frame.
And the title attribute was spelled out by hand in four files while the
content one was a constant. Both now live in `entity-page-anchors`, beside
the selector built from them — `space-tabs-anchor`'s pattern, for its
reason: they are one contract with two halves and must not drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: keep the sticky bar off side-panel titles, and repair the layout test
Two findings from the PR review.
The layout test was broken and I had not run it — `profile-layout.test.tsx`
renders the layout with no SyncEngine provider, so the header's
`useQueryEntity` threw before any assertion ran. Stubbed like every other
child of that layout, and its props are captured rather than discarded,
because that suite's subject is that the route's id reaches everything the
layout draws. The branch that draws no profile shell now also asserts the
bar is still there: it belongs to the route, not to the shell.
The title selector matched side-panel headings. It is value-scoped by
entity, and document order covers the case where the route draws a title of
its own — but a Debate route draws the live feed instead, so with the panel
open on that same entity its heading was the only match, and the panel
scrolls in a container of its own. Scoped to `<main>`, which holds the
routed page and nothing else; both of the panel's branches portal to
`document.body`. An allowlist rather than naming the panel, so the next
surface portalled out of the page is excluded by construction.
Not the fix the review proposed, which was a route-only flag threaded
through `EditableHeading`, `ClaimPageView` and `TopicPageView`: three props
to remember at every future call site, and no help for a view nobody has
written yet. It was also wrong that `PowerToolsScreen` is out of scope —
it routes under this layout, so its heading is that route's title.
The assertion that pinned the selector as a literal went stale against the
change, which is the same lesson as the anchors module: it now derives from
`entityPageTitleSelector`, and what that selector matches is pinned in
`entity-page-anchors.test.tsx` instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: read the sheet's popover container where opening re-renders
Splitting the tally and the responder faces into a `Popover.Root` each
moved the open state down into `RespondersPopover` and left the container
read behind in `EntityVoteButtons`.
That read is deliberately non-subscribing — a subscription would re-render
every claim on a list whenever any sheet opened — and what kept a bare read
current was stated in the comment beside it: `Popover.Portal` only mounts
when the popover opens, and opening renders the component holding `open`.
Moving that state moved the render with it, so the parent stopped
re-rendering on open and kept whatever container it captured when the row
first drew. A sheet registers its host after the rows inside it mount, so
that capture was null: the responder list portalled to `body`, outside the
sheet's `RemoveScroll` shard, where it can be seen and not scrolled.
The read now lives with the state it depends on. Not a subscription, which
would cost more than the original did — there are two of these per claim
now, not one.
Enumerated across the branch: this is the only non-reactive store read in
it, and the only state that moved. The three other `useState`s added are in
new hooks with no prior consumer whose render timing could have shifted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: no stale frame on navigation, no prose in the 48px bar
Two findings from the review, both in code it had already passed once.
The reset for a changed selector ran in a passive effect, which is a paint
too late: the render that first saw the new entity still handed back the
previous one's `scrolledPast` and its detached title, so navigating to an
already-hydrated entity painted its bar for a frame over a page whose title
had never been observed. Adjusted during render instead — React re-runs the
component and discards that pass without committing it, so the wrong value
is never emitted at all. `useLayoutEffect`, the suggested fix, would beat
the paint only by committing once and correcting itself.
The control's three prose states do not fit the bar. Measured at 390px with
the flag forced: the indexing notice pushed the row 94px past its own width
and ran 62px past the bar, giving the document a horizontal scrollbar. The
other two — "Publish changes before responding" and "Response unavailable"
— stand in for the control entirely and are the same class; the finding
named only the first. A `compact` flag drops all three.
Dropped rather than truncated, since a sentence cut to "Response s…" tells
nobody anything, and nothing is lost either way: the page's own copy of the
control is still mounted below, merely scrolled out of view, so it keeps the
text and the `aria-live` announcement. Confirmed in the browser — with the
notice forced on, one lives on the page and none in the bar.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: measure a swapped-in title instead of inheriting the old one's answer
A tab replaces the title with another element belonging to the same entity,
so `selector` never changes and the render-time reset never runs. `sync`
swapped the watched element but left the previous title's answer standing,
and `IntersectionObserver` says nothing about a freshly observed element
until a task later — long enough to paint the bar over a title sitting at
the top of a newly opened tab.
Measured at the swap rather than cleared, which is where this departs from
the review's suggestion. Clearing only moves the wrong frame to the other
direction: a tab opened already scrolled past its title would blink the bar
off and back on. Both directions are pinned, and the clear-only variant
fails the second one.
The same line closes a latent member of the class: were `topOffset` ever to
change, the effect would re-run with a fresh target and take this path too,
rather than showing the old offset's answer until the observer caught up.
It is a module constant today, so that is insurance rather than a fix.
Checked the other observers in the branch for the same shape. Both prime
themselves with a synchronous `sync()` before observing, so neither ever
displays a value from before its current target.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: measure the content column before paint, not after it
The measurement ran in a passive effect, so the commit that first saw a new
anchor painted with the previous column's geometry — or, on a title
mounting late, with the caller's 900px fallback. On a claim page that is a
60px snap in the bar's width on the frame after it appears.
A layout effect is the only place this can go. It cannot be derived during
render the way the scroll hook's reset was: the DOM it reads is the DOM
being rendered, so the anchor is not committed yet and `closest` would walk
the previous tree.
Last round I said both of this branch's observers primed themselves with a
synchronous `sync()` and so had none of this. That conflated synchronous
*within the effect* with *before paint*, and was wrong for this one.
`useScrolledPastElement` stays passive on purpose. Its initial state hides
the bar, so a late answer shows a decoration a frame late rather than
showing it wrongly — and moving it would put a `querySelector` and a
`MutationObserver` setup in front of every entity page's first paint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: cover the third prose state compact suppresses
`compact` drops three sentences from the bar and only two were exercised.
`hasUnpublishedResponseKindEdit` was never true in any test, so a
regression restoring "Publish changes before responding" — and the mobile
overflow the flag exists to stop — would have passed. My own proof of that
fix was two-thirds of one: neutering `compact` failed two tests and never
touched this branch.
The entity is built rather than the predicate stubbed. It is pure over
`values`, and this branch only runs when the caller passes no
`responseKind`, so the entity is what decides — driving it is both more
faithful and no harder.
Neutering only this branch now fails only the new test; neutering `compact`
wholesale fails three where it failed two.
Also drops an assertion that could not fail: a check that the unpublished
sentence was absent, in a case where nothing would have drawn it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: keep the vote confirmation announced, and key the bar's media
Two findings from the review, both real, both mine.
`compact` dropped the only `aria-live` a vote gets — and a vote can be cast
from the bar. I had claimed the page's own control still announced. That is
false on the surface it matters most: `ClaimPageView` answers a claim with
`ClaimPositionCommentControl`, which puts the same sentence in a `title`,
read on focus and never announced as a live update. So a screen reader got
nothing at all after voting from the bar on a claim. The node is now
`sr-only` when compact — absolutely positioned and clipped, so it takes no
width and cannot overflow the row, which is all `compact` needed from it.
My original check for this was sloppy in a way worth recording: I counted
`aria-live` nodes in the document and found one, without checking which
entity it belonged to. It was a claim card's, for a different entity.
The bar read media through `useEntityMediaUrl`, which composes two hooks
that hold their fetched URL as a bare string with nothing recording which
entity it was fetched for. This bar deliberately outlives an entity change,
so it is the caller those hooks were waiting to bite: the previous entity's
face could stay up, and because avatar wins over cover it could mask the new
entity's cover. `useEntityMedia` keys by `entityId:spaceId` and documents
it. Avatar over cover is the same preference, made at the call site.
The review also said `TopicPageView` renders no `EntityVoteButtons`. That
was true when it was written and is not now — #2580 gave the topic page
`EntityPageActions isVoteable` in the merge. It does not change the fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: ignore notifications about a title already dropped
`unobserve` stops future records; it does not purge ones already queued. So
a notification about the title a tab just replaced can land after the swap,
and taking the batch's last entry without asking what it describes let that
stale record overwrite the measurement taken for the new title — undoing,
for a frame, the very thing measuring at the swap was added to fix two
rounds ago. Entries are filtered by target first.
The `ResizeObserver` alongside it needed nothing: its callback re-reads the
current geometry and never touches entry data, so a stale record cannot
mislead it.
Also rewrites two comments that had gone actively dangerous. Both still
said `compact` drops all three prose states and that the page's copy
carries the announcement — the claim that turned out to be false for claim
pages, and the reason the indexing notice is now `sr-only` rather than
gone. Left as they were, they invited the next reader to remove the fix.
The contract now says which two are dropped and why, which one is not, and
what would happen if somebody "finished the job".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: ignore a retired observer, and correct two stale contracts
A disconnected `IntersectionObserver` can still call back. `disconnect()`
empties `[[ObservationTargets]]`; the spec does not empty
`[[QueuedEntries]]`, and "notify intersection observers" invokes the
callback of any observer whose queue is non-empty. That closure keeps its
own `watched`, so last round's target filter waved the record through and
it landed on top of the render-time reset the new selector had just
performed. An effect-local `disposed` flag, set on both cleanup paths.
The other two observers need no equivalent, and the difference is in their
specs rather than in luck: `ResizeObserver.disconnect` clears
`activeTargets`, `MutationObserver.disconnect` empties the record queue.
This one is alone in leaving work behind.
The sticky header's contract promised verify/dispute for a factual claim.
That kind no longer exists — #2541 reduced `ResponseKind` to
`curation | stance` and every claim now answers Agree/Disagree. Restating
the delegate's behaviour is what let this rot, so the contract now says so
about itself, and documents the two things it does ask for: `compact`, and
the faces after the thumbs.
And `inlineButtons` carried two docblocks, the older still claiming the row
draws up, score, down — untrue since the responder faces became a trigger
ahead of the up arrow, which is what the newer one exists to explain.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: show a progress cursor on the thumbs while a vote confirms
The claim pills gained a progress cursor for the confirming window in
#2598 — tens of seconds in which the side is already drawn as taken and
nothing else says the press registered. The thumbs `EntityVoteButtons`
draws, which is what the sticky header votes with, had nothing: the sentence
that used to say so is `sr-only` in the bar, and #2595 removed its visible
equivalent from the pills for reading as an unsettled side.
Driven by the hook's own `isProcessingResponse` rather than a re-derived
predicate, and applied through one class value both thumbs share, so the two
cannot drift apart the way this control drifted from the pills.
Every surface that draws these thumbs gets it, not only the bar: "busy" is as
true on an entity header. `progress` rather than `wait`, because the thumbs
are still usable — a second press still goes through, which is a separate
difference from the pills and not changed here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: make the thumbs behave like the claim pills while a vote confirms
The pills learned how to behave in the confirming window in #2587 and
#2598, and nothing tied the thumbs to them, so the two drifted. The thumbs —
what the sticky header votes with, and every entity header besides — now
match all of it:
- presses are ignored. The held thumb is this client's guess until the write
lands, and pressing a held thumb means "remove", so a second press or a
double-click published a retraction mid-confirmation;
- `aria-disabled`, and a progress cursor on the pointer;
- no hover step, since nothing under the pointer is going to happen;
- the tooltip reads the confirming copy, as the pills' `actionTitle` does;
- full strength — `aria-disabled` rather than `disabled`, so the held side
still reads as taken.
All of it lives in one `voteButtonProps` both thumbs spread, so the two
thumbs cannot drift from each other. Keeping the thumbs and the pills from
drifting again needs them to be one control, which is tracked separately.
Inline only. `DebateVotePill` shares the handlers, so the guard sits on the
inline buttons rather than in them; the debate overlay keeps its current
behaviour and goes with the unification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follows #2541 (merged).
The bug
A reader had Agree highlighted on a claim page, pressed Request debate, and got geo-chat's raw error: "respond to this claim before sending a debate request" (
intent_missing). On chain, their latest vote on the claim was a retraction, and geo-chat's readiness row saidclaim_response_withdrawn. So the page showed a side the chain no longer held.Cause
useClaimPositionControldraws the selected side from the local optimistic snapshot first, then geo-chat'sviewer_response, then the indexed read. Pressing the pill that is drawn as selected sends'clear'.useEntityResponseserializes overlapping submissions, but it does not. A double-click sent set then clear. So did a press on an Agree that was still confirming, or stuck indelayed, because it still looked held. The claim page showed no sign that anything was still confirming. OnlyEntityVoteButtonsdid.useBackfillReadinessForHeldPositionrefuses any row that has areadiness_disabled_reason. A row marked withdrawn was never repaired, even after the chain held a side again.intent_missingwas printed as geo-chat worded it, beside a pill that looked held.Changes
A. A press on a confirming response is ignored, not sent (
core/debates/matchmaking/matchmaking-claim-card.tsx)useClaimPositionControlnow returnsisResponsePending, which is true while the snapshot isreconcilingordelayed. While it is true,respond()returns early and the title says the response is confirming.PositionRow/PositionButtontake apendingprop. The pill isaria-disabled, shows a progress cursor, gets no hover style, and drops the press. The pills are still not dimmed, for the reason the old comment gave.indexedis not pending, because the chain has confirmed the side and only geo-chat is catching up. Removing a confirmed or settled position with one press still works.pendingis passed throughClaimPositionCommentControl, so an ignored press does not open the explanation composer. It is set on the claim page, the explore card, the hub card and the debate claims panel.B. The claim page shows the wait (
core/claims/browse/claim-page-view.tsx)ResponseConfirmingNoteappears under the pills while a response is pending.EntityVoteButtons' existing sentence, now shared asRESPONSE_CONFIRMING_COPY(core/responses/entity-response.ts).C. Backfill after a withdrawal (
core/debates/backfill-readiness-for-held-position.ts)indexedPosition. When the row's reason isclaim_response_withdrawnand the chain holds a side again, it reports that side.claim_response_kind_changed, already ready, no row.trustedIndexedPositiongivesnullwhile the indexed read is still loading, and while the viewer's own response is pending. The in-flight write has already told geo-chat (GEO-2784), so a withdrawal marks the row withdrawn before the indexed read catches up. Reporting the read then would put the viewer back on the side they just left.D. Recover from
intent_missing(core/claims/browse/use-claim-matchup.ts,claim-end-slot.tsx)useClaimMatchuptakes the viewer's drawn side and their trusted indexed side.ClaimEndSlotpasses both through, and the claim page supplies the indexed side.intent_missing, if the indexed side agrees with the pills, the hook sendsnotifyClaimResponseIndexedagain. Either way it then invalidates the readiness queries.readinessQueryPrefixesis now exported from the notifier for this.Tests
matchmaking-claim-card.test.tsx: this replaces the test that asserted a press while publishing is sent. Presses on either pill are ignored whilereconcilingordelayed, and both pills arearia-disabled.clearis sent fromindexedand from a settled position.claim-page-view.test.tsx: the note andpendingshow while a response is pending, and are gone once it lands.backfill-readiness-for-held-position.test.tsx:trustedIndexedPositionis covered on its own.use-claim-matchup.test.tsx(new):intent_missingtext never shows.matches-list.test.tsx: updated for theonErroroption thatmutatenow receives.Checks, from
apps/web:bun run typecheck,bun run lint,bun run test(652 files, 7757 tests) all pass.