You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Follow-up to #2547 (GEO-2992). Three states redrew the rematch picker when nothing about them had changed, and the third turned into a navigation change — see §3, which the earlier revisions of this description left out.
1. Leaving collapsed the card before the redirect
Clicking Leave ends the session server-side. An ended session isn't voice-capable, so RematchVoiceHeader fell to its no-controls state the moment the mutation answered — and the mute pill vanished for the second or so before the redirect landed, collapsing your card in front of someone who had already left. The other person leaving does the same thing, as does a request being accepted or the browsing window lapsing, so sessionEnded covers that window too.
The header takes an exiting flag and holds its shape until the page unmounts. Two things that flag must not do:
It must not raise anything. Leaving drops the room, which takes isMicrophoneEnabled with it — so a pair who joined unmuted from a recorded debate met the unmute notice for the first time on the way out, growing the header at the one moment the flag exists to hold it still. The notice now rides an exit only if it was already up, and the "{Name} is talking" nudge beside it is frozen the same way: leaving arms it too (the microphone reads as muted while "they are talking" is still true on that render), and a nudge already up can hit its ten seconds mid-exit and go. Those two are the only things that change this header's height, and both now hold.
It must not hold the connection open. Holding the room and holding the header's shape are separate questions, and answering both with "was this session ever voice-capable" got the microphone out: that is true from the first render of a rematch already over when the link was opened, and true again for the render or two a stale status survives. In those states there is no room to hold, so holding became starting — tab lock, token, published microphone, into a session the viewer has just left. The connection now rides the exit only for a room that actually came up; the header holds its last shape on its own.
Connection messages are suppressed on the way out only for the branch that fires because the session ended — a room still connecting or reconnecting keeps saying so, or leaving would put live mute controls over nothing.
2. Sending a request grew the sticky block
A pending request put the claim heading and two Agree/Disagree chips into the header, pushing the whole list down at the exact moment the viewer was watching for an answer.
Those facts move into a card above the list, matching what the debates panel draws for a request you've sent: space chip, claim, the RequestParties "You VS Them" row carrying both positions, an expiry countdown, and "Awaiting response".
Built from the hub's parts (SpaceChip, RequestParties, useRequestCountdown, hubCardMotion) rather than reusing OutboundRequestCard itself — that takes a DebateRequest whose parties carry matchmaking presence a rematch session has no source for, and faking it to reach the component would be a worse kind of reuse than sharing what it's made of. No Withdraw button: geo-chat has no endpoint to back one, so the card says what's happening rather than offering a way out that doesn't exist. It does say when a request has lapsed — the hub never draws one, because useUnexpiredRequests filters them upstream, but this card is drawn from the session, which reads request_pending until geo-chat's next answer.
LockedClaim, PositionChip and ThumbIcon are deleted from the header along with the lockedClaim/positions plumbing.
3. The picker's navigation, to match the panel the card came from
Putting the request card above the search panel is where the debates side panel puts it — and once it was there, the rest of the picker's disagreement with that panel was the remaining reason the two surfaces read differently. Requested in review of §2, and landed here rather than split out because §2 is what made the mismatch visible.
Positions is a tab, not a source. Your own positions were a third entry in the Featured/Recommended/All menu; the panel gives them a tab. It carries a count badge like Lobby's, counted off the rows that will actually render rather than the raw ids — the viewer's claims are looked up alongside the opponent's, which reuses batches already in flight.
Featured is gone with the menu it lived in. The same removal claims-tab made in GEO-2863: a curated cut of the same tag, behind a menu most viewers never opened. Its tests go with it — the source menu's remaining behaviour (Recommended where a curator has a page, All otherwise) is covered by rewritten equivalents, and the one GEO-2771 regression test that used Featured covered a useLastSettled hold bridging two tagged sources. Only all is tagged now; the key-drift class it guarded is still covered on the topic filter.
The badge calls an outage an outage rather than a zero — react-query drops isLoading on failure, so a cold failure would otherwise leave it saying confidently that there is nothing to look at beside a tab reporting an error. opponentCountPending has the same gap and predates this PR; flagged in review, not changed here.
Lobby is not delayed by any of this: its gate excludes the viewer's chain. Related's first paint does now also wait on the viewer's entity batch, which runs in parallel with the opponent's identical one — flagged in review rather than silently traded away.
Testing
298 tests across the two rematch suites. tsc and eslint clean.
New tests, each verified against the old behaviour before landing:
Test
Broken by
Fails with
keeps the controls while the viewer is on their way out
dropping || exiting
mute button not found
does not swap in a connection message while leaving
dropping the exiting guard
mute button not found
holds its shape when the pair lock a claim
restoring the claim heading
expected <h2> to be null
draws a card for the request the viewer sent
removing the card
testid not found
does not raise the unmute notice on the way out
dropping the shown-first latch
expected <div> to be null
keeps saying a room is reconnecting even while leaving
suppressing the whole ladder
Reconnecting… not found
says so when the request the viewer sent has lapsed
ignoring countdown.expired
"Awaiting response" still there
does not arm voice for a status that went stale before the room came up
latching on the status
expected false to be true (a token was minted)
keeps the connecting line when it leaves before the room connected
latching on the status
room still mounted
gives the viewer’s own positions a tab rather than a source
leaving Positions in the menu
tab not found
does not arm voice while the leave request is still in flight
reading the status once exiting
expected false to be true (a token was minted)
keeps counting the rows it draws while the space types are unresolved
holding the badge on publishabilityPending
expected '' to match /^\d+$/
does not raise the talking nudge on the way out
the nudge hook before the freeze flag
expected <div> to be null
does not let the talking nudge time out while leaving
the nudge hook before the freeze flag
testid not found
counts nothing rather than zero when the lookup failed
the badge before the error term
skeleton label not found
keeps the count it has when a refetch fails under it
hoisting the error out of the length === 0 guard
expected '' to be '2'
lets a reconnect take the nudge down even while leaving
freezing the rendered toast too
toast still present
The last one is a pin rather than a fix: it fails against the change review asked for, which would have put a skeleton on the tab over a list already showing rows.
Two needed a second attempt to break properly — patching only the disconnected path left one passing because it runs against a connected room, and the badge test passed against a deliberately wrong count until the fixture had something to filter. Worth noting, since a break that doesn't fail is the same as no test.
Two states redrew the header when nothing about it had changed.
Leaving ends the session server-side, and an ended session is not
voice-capable — so the mute control tore itself down a second or so
before the redirect landed, collapsing the card in front of someone who
had already left. The room now stays mounted while the page is on its way
out, and a connection message cannot replace the controls in that window
either: dropping the room mid-exit would swap them for "Voice
disconnected · Retry", which is both the same shift and an offer of
something the viewer has just declined.
Sending a request grew the sticky block a claim heading and two position
chips, pushing the whole list down at the moment the viewer was watching
for an answer. Those facts move into a card above the list — the same
shape the debates panel draws for a request you sent, built from its
parts rather than from a faked `DebateRequest`, and without a Withdraw
button because geo-chat has no endpoint to back one.
The first pass only knew about our own Leave button. The other person
leaving ends the session too, and that reaches this page as a status
change with no mutation of ours behind it — so the controls still tore
themselves down in the render before the redirect landed. `exiting` now
comes from the same condition the redirect effect acts on, which covers
the pair's session ending however it ended: they left, the window lapsed,
or a request was accepted and both are walking into the debate room.
The unmute notice went with them, for a different reason: it was gated on
the opponent being in the room at that moment. It latches on their having
joined at all now. "Nobody to talk to" is a good reason never to raise it,
but once it is up, taking it away when the other side drops moves
everything under it at a moment the viewer did nothing to cause — and
what it says is still true of the visit. That also holds it through a
reconnect, which was the same shift on a shorter clock.
Review found that `exiting` was doing two jobs. It is also true from the
first render of a rematch that was already over when the link was opened
— so "keep what is there" became "take the tab lock, fetch a token and
publish a microphone" into a session the viewer is not in, on a page that
is busy redirecting. A debate-sourced rematch joins unmuted, so that was a
live microphone rather than a wasted request. Voice is now held open only
over a room that was already up.
The unmute notice was likewise gated too loosely. Riding out a reconnect
is right — the intent an unmute records is restored when the room comes
back — but a room that has given up is not a blip: its Unmute is a button
that cannot work, and pressing it would still spend the notice's one
dismissal on a click that did nothing. It gives way to the card's own
"Voice disconnected · Retry" there, except while exiting, where the whole
point is that nothing moves.
Also drops the shared pair card's `footer` slot, which has had no caller
since the position chips left.
Where the hub's claims tab keeps it: first in the sticky block, above the
search box and the space and topic menus, rather than scrolling away at
the top of the list. Inside that block rather than over it for the reason
the hub gives — two sticky elements would both claim `top-0` and overlap,
and a conditional card cannot offset the filters by a known height.
Explore's source menu held four answers to "which claims?", two of which
the hub had already stopped asking that way. GEO-2863 promoted its "My
positions" out of the menu and into a tab, and Featured went with the
menu it lived in — a curated cut of the same tag behind a control most
viewers never opened. The picker keeps both a year later, on a surface
that otherwise reads like the hub panel.
Positions is a tab here now, and Featured is gone. What is left in the
menu is the curator's page for this pairing and everything else — and
because that page exists for almost no pairing, the menu draws itself
only when there is something to pick between. For everyone else Explore
is what it is in the hub: a search box and the two facet menus over one
list.
The option is held while its lookup is in flight rather than dropped and
restored, which is the rule the selection already followed: let go once
the answer is in, not while it is on its way.
The same badge Lobby carries, of the same thing: how many rows are in
there. It counts the tab's own list rather than the ids behind it —
`participantClaimRows` drops the claim the pair just debated and anything
in a space that cannot carry a published debate, so a number taken from
the answered ids would sit above the list it describes.
Counting the list means the lookup behind it can no longer wait for the
tab to be opened, which it used to. That is a second entity batch and a
second rows request per visit, alongside the opponent's. The alternative
is a badge that reads `0` until the viewer clicks the tab it is on — a
specific claim about their backlog, and a wrong one — which is what
GEO-2656 took out of the other tab for the same reason.
While that chain is still running the badge holds a skeleton rather than
a number, exactly as the opponent's does.
Three from review, two of them the same mistake in reverse: `exiting` was
being used to suppress things that had never appeared.
The unmute notice could *arrive* on the way out. A pair from a recorded
debate join unmuted and never see it; leaving drops the room, which takes
`isMicrophoneEnabled` with it, and with the dead-room guard disabled by
`exiting` the notice rendered for the first time — growing the header at
the one moment the flag exists to hold it still. It is latched on having
been shown before the exit began, which has to exclude the exiting render
itself or the latch is set by the render it is suppressing.
`if (exiting) return null` killed the whole connection ladder rather than
the dead-room branch. A room still connecting, or reconnecting, when the
session ended was drawn with live mute controls over no connection at
all. Only the branch that fires *because* the session ended is suppressed
now.
The Positions badge waits on `publishabilityPending` too. A debater's own
responses often live in their personal space, and `canPublishDebateIn`
fails open until the space types land — so the count settled high and
then dropped.
Also: the request card says when it has lapsed. The hub never draws an
expired request, because `useUnexpiredRequests` filters them out upstream;
this card is drawn from the session, which reads `request_pending` until
geo-chat's next answer, so the lapse is reachable here.
The exit flag was doing two jobs with one signal. Keeping the pair header
from re-laying-out between Leave and the redirect needs the last shape held;
keeping a live microphone from tearing down needs the connection held. Both
were answered by "was this session ever voice-capable", which is true from the
first render of a rematch that was already over when the link was opened, and
true again for the render or two a stale status survives after the session
ends. In those states there is no room to hold — so holding became starting:
take the tab lock, mint a token, publish a microphone into a session the viewer
has just left. A debate-sourced rematch joins unmuted, so that is audio going
out after they are gone.
Split the two. The connection now rides the exit only for a room that actually
came up, latched off the same connection state `everConnected` reads. What the
header draws is held separately, as the last shape it had before the exit
began — so a room that never connected is allowed to go while its one line
stays on screen.
The viewerClaims.length === 0 guard bypasses publishabilityPending as soon as provisional rows exist. Because unresolved space types fail open, the new badge can therefore show a high count and then drop when a viewer claim resolves to an unpublishable personal space—the exact transition this pending check is intended to prevent. Keep the badge pending whenever publishability is unresolved, independently of whether provisional rows exist.
Leaving is a mutation, and the session keeps answering voice-capable for the
whole round trip after the click. So the keep-alive, written as
`voiceCapableNow || (exiting && roomWasLive)`, was still satisfied by its first
half for the length of the leave request — and a room that had not connected
yet would go on to take the tab lock, mint a token and publish the microphone
during it. The previous commit closed the window after the status flips; this
is the window before it. Once `exiting` is true the status is not consulted at
all: only a room that actually came up rides the exit out.
Also drops `publishabilityPending` from the Positions badge, added a commit ago
to stop the count settling high and narrowing as space types resolve. It never
did that — behind the `length === 0` guard it cannot fire, because fail-open
means the provisional rows are already there. Hoisting it out would have worked
and is the wrong thing: the count is of the rows the tab draws, fail-open rows
included, so waiting would put a skeleton on the tab over a list already
showing them. The rule is now written down and pinned by a test.
Re: "Keep badge pending while viewer publishability is unresolved" (previously missed, rematch-page-client.tsx:1326)
Replying here because that one came in the overview rather than as a thread. Right about the code, wrong about the fix — and the half it is right about is a correction to me, so taking it in two parts.
Right: the term is dead. I added publishabilityPending to viewerCountPending a commit ago for exactly the reason you give, and you have spotted that the viewerClaims.length === 0 guard means it can never fire. Fail-open is why: while the types are out the provisional rows are already on the list, so the guard is false in precisely the window the term was meant to cover. It was a no-op dressed as a fix, with a comment claiming otherwise. Removed in 420c950 rather than left sitting there.
Wrong: hoisting it out is not the fix. I tried your version — publishabilityPending || (viewerClaims.length === 0 && …) — and it does stop the count narrowing. It also puts a skeleton on the Positions tab while the list underneath it is showing rows. The badge's contract is that it is the count of the rows the tab draws; there is a test asserting badge === rows.length, and under your fix the new companion test fails with expected '' to match /^\d+$/.
The narrowing you describe is real, but it is not the badge's. The list narrows, because fail-open is a deliberate choice — an unresolved type reads as publishable so a slow lookup cannot empty a tab — and the badge narrows with it because that is its job. A count that disagrees with the list beneath it is a worse thing to show someone than a count that follows it down.
Second cost, smaller: candidateSpaceIds spans every catalog source, so publishabilityPending re-pends when Explore's corpus loads or its search changes. Hoisted out, the Positions badge would blink to a skeleton on those, while the Lobby badge beside it — same shape, no such term — sat still.
So: the dead term is gone, the rule is written down where the term used to be, and keeps counting the rows it draws while the space types are unresolved pins it, confirmed failing against your version before landing.
What would actually fix the narrowing, reported rather than done because it is out of this PR's scope: put publishabilityPending into viewerClaimsSettling, so useLastSettled holds the list and the badge through the window and the two stay in agreement. That changes the list, which predates this PR and has been tested on a device, and it delays the Positions tab's first paint by the space-type lookup. Worth noting the same exposure is on opponentClaims — the opponent's responses live in their personal space just as often — so the Lobby badge and list have had this since GEO-2656. One shared change, one decision, its own PR.
The unmute notice was taught to hold still on the way out; the toast beside it
was not, and leaving is the event that arms it. Every input it reads moves at
once when the room drops: an unmuted viewer's microphone reads as muted, and
"they are talking" is still true on that render — the one-shot fires and the
bubble arrives on the way out. The opposite transition is reachable too, since
ten seconds is long enough for a click to land nine into it and the timeout to
expire mid-exit.
`useMutedNudge` takes a freeze flag rather than being suppressed at the call
site: passing `muted && !exiting` would have read as an unmute and taken a
visible nudge down instead. Frozen, it neither raises nor expires, and the
pending timeout goes out with the effect's own cleanup. Dismissing is exempt —
a viewer who presses the control has asked for the change.
That leaves the notice and this toast as the only two things that change the
header's height, and both now hold. The caption inside the card is the
connection ladder, already handled, plus a device failure that cannot newly
arrive once the room has stopped asking for a microphone.
A cold failure in the positions, entity, or rematch-row query makes every loading flag false while viewerClaims remains empty, so this new badge renders 0 even though the Positions tab renders an error. Treat query failures as an unavailable count (rather than a settled zero), while continuing to show a held non-empty count during refetch failures.
Outbound card resizes sticky container and shifts claims
Rendering the outbound card inside this same .sticky container still increases the sticky block's in-flow height when the request arrives, so every claim below it is displaced by the card height—the exact list jump this change is intended to remove. Keep the card pinned only if its space is reserved before submission; otherwise render it outside the sticky block so adding it does not resize the header over the list.
react-query drops `isLoading` on failure, so a cold failure in the positions
or entity lookup leaves every flag false over an empty list — which reads from
the badge exactly like somebody who has answered nothing. The tab underneath
says the lookup failed while the number beside it says, confidently, that there
is nothing to look at. The same trap `opponentTabSettled` already documents a
few hundred lines down, reached from the other end.
The error term goes inside the `length === 0` guard, not in front of it: a
refetch that failed has not invalidated rows already drawn, and blanking the
number over a list still on screen is the flicker the guard is for. Both
directions have tests. The failing pair is now named once as `viewerTabError`
and shared with the tab's own error state, so the number and the list answer
the same question.
Also pins what the nudge freeze deliberately does not cover: a reconnect during
the exit still takes the toast down, because the cards have already swapped to
`Reconnecting…` and the presence subscription sourcing "they are talking" went
with them. The unmute notice stays — its claim is about the viewer's own
microphone, which survives a blip.
Both came in the overview rather than as threads, so they're answered here. One is right and fixed; one is the third pass at a placement that was asked for.
1. "Query failures incorrectly display a zero claims count" (rematch-page-client.tsx:1333) — right, fixed in e7eaecb
Confirmed before touching anything: mocks.positionsError with no positions renders the badge as 0, and the test looking for the skeleton failed with Unable to find a label with the text of: Counting your positions.
The diagnosis is exact, and this file already documents the same trap a few hundred lines down — opponentTabSettled carries a comment saying react-query drops isLoading on failure, so "an outage reads from here exactly like a pair with nothing to go again on". The new badge was reached from the other end and didn't get the memo.
Your qualifier is the part that shaped the fix. The error term goes inside the viewerClaims.length === 0 guard, not in front of it: a refetch that failed has not invalidated rows already on screen, and blanking the number over them is the flicker the guard exists to prevent. Both directions are pinned — counts nothing rather than zero when the lookup failed, and keeps the count it has when a refetch fails under it, the second confirmed failing against the hoisted version with expected '' to be '2'.
The failing pair is now named once as viewerTabError and shared with the tab's own error state, so the number and the list underneath answer the same question by construction.
Out of scope, reported rather than changed:opponentCountPending has this identically, and predates this PR (GEO-2656). It is the same one-line change. I have not made it, because it is pre-existing behaviour and not mine to trade — but it does mean that today a failing sessionQuery leaves Lobby showing 0 while Positions shows a skeleton, which is a visible inconsistency between two badges that were asked to match. Worth one word either way.
2. "Outbound card resizes sticky container and shifts claims" (rematch-page-client.tsx:2169) — correct, and it is the requested design
The card does add its height to the sticky block and push the list down. That is not a side effect that survived review; it is where the change was asked to put it, to match what the debates side panel does with a request you've sent.
The shift this PR removes is the header resizing — the claim heading and two Agree/Disagree chips appearing in the pair header, which moved the tab strip and everything under it while the viewer was watching for an answer. Moving those facts into a card below the header is the fix. The card then occupies space above the list, once, when a request is sent.
On the two alternatives:
Reserving the space before submission would mean a permanent empty gap above the list on a surface where most viewers never send a request, to avoid one movement by the viewer's own action.
Rendering it outside the sticky block is worse than it sounds: two sticky top-0 siblings both claim the same offset and overlap. The card would scroll away under the filters, which is the opposite of what a pending request wants — it is the thing you are waiting on.
A list moving down when you add something above it by pressing a button is not the same class of problem as a header resizing on its own while you watch. Leaving it, as in the two previous rounds.
Three conflicts in `rematch-page-client.tsx`, all against the debate-rooms
work (#2543) and the analytics attribution (#2522):
- `sessionEnded` and master's `requestSpaceRef` landed in the same place and are
independent. Both kept.
- `HideMyPositionsSwitch` keeps master's `analyticsSurface`, on this branch's
condition: Positions is a tab now, so `tab === 'explore'` already excludes it
and master's `source !== 'mine'` is dead.
- The source menu keeps master's `analytics` prop and this branch's label
narrowing, which has to agree with the `value` beside it.
One thing the merge broke that neither side got wrong on its own: in a debate
room an expired session is rejoined in place rather than redirected away, so
`sessionEnded` stopped meaning "about to navigate" — and the pair header would
have frozen, permanently, over a session that was coming back. The room case is
excepted, with tests both ways. Converted is not: that redirects out of a room
as much as into one.
Master's new analytics test needed a curated page to have a source menu to
attribute at all, for the same reason the switch lost its source check.
* feat(claims): answer every claim with Agree/Disagree
A claim carrying the "Is factual" flag was answered with Verify/Dispute, drawn
with chevrons, and published as its own vote kind. Every claim now asks the same
question, whatever the flag says.
The flag itself stays. The extractor still writes it and the debate surfaces
that read it are untouched — it just no longer decides how a claim is answered.
What goes with the vocabulary:
- the `veracity` response kind, its vote kind (2) and its verify/dispute/unverify
SDK methods, so there is no longer a way to publish one
- the chevron glyph, and the hand-written `#2A2B2E` a selected chevron needed
because it had no filled form to switch to
- `claimResponseKind`, and the branch in `resolveEntityResponseKind`. The latter
no longer takes a space: "Is factual" was the only per-space value it read
- the "Is factual" half of the unpublished-edit guard. A draft edit to the flag
cannot change which vote kind gets published any more, so blocking on it would
only disable the pills for no reason. The Claim *type* half still blocks, since
that really does move an entity between curation and stance
Existing veracity responses — 178 of them, on 145 claims — stop being read. That
is deliberate: those claims lose that part of their count and those responders
see them as unanswered. The votes are still on chain if we ever want to backfill
them as stances. The one exception is the curator onboarding checklist, which
still counts vote kind 2 when asking whether someone has *ever* taken a position
on a claim: they did, and un-ticking a finished step has nothing to recommend it.
Two things the wire forced, both of which would have been crashes:
geo-chat still labels old claims `veracity`, and that value arrives typed as the
narrowed kind it no longer matches — so `ENTITY_RESPONSE_COPY[kind]` and the
glyph map both missed and threw during render, blanking the claims ticker and
the matches list. Claim surfaces now read `CLAIM_RESPONSE_COPY` without indexing
anything, and the glyph map falls back rather than throwing.
geo-chat also sends `position_label` per side, and the pills preferred it over
their own copy — which would have printed "Verify" on a control that can only
publish an Agree. Our label wins now.
* fix(claims): stop reading geo-chat's word for the response kind
The first pass narrowed the type and patched the two lookups that happened to
throw in a test. It left the actual source alone: geo-chat's `response_kind`,
which still says "veracity" for a claim minted before the vocabularies merged,
was still being read and threaded through the claim UI.
TypeScript cannot see it. The field is typed as the narrowed kind it no longer
matches, so every use type-checks and then misses at runtime:
- `matchmaking-claim-card` handed it to `useEntityResponse`, which is the write.
`getResponseActionMethod` selects no SDK method for it, so clicking Agree
threw on `undefined['positive']` instead of publishing — on every claim with
existing verify or dispute activity.
- `ClaimSummary` keyed the responders read on it, where `responseKindToVoteKind`
returns `undefined` and the query asks for no vote kind at all.
- the backfill forwarded it to geo-chat, recording the retired kind against a
response published as a stance.
`CLAIM_RESPONSE_KIND` is now the authority and no surface reads the row. The
leaf guards stay as defence in depth rather than as the only defence.
Three more places were still reading vote kind 2, which the clean break says
nothing reads:
- the profile's position *count* asked for kinds [1, 2] while the list beside it
had already narrowed to [1], so a profile would have printed a number bigger
than its own list. The two were separate literals; one now derives from the
other.
- `decodeVoteOrder` still decoded a kind-2 row into a `veracity` field that
nothing renders any more, so a claim answered only that way entered the
Positions list with no verdict under either button.
- the indexed-response notifier admitted "veracity" through a hand-written guard
and an `as DebateResponseKind` that made the cast look safe. Both copies now
share one type guard.
* fix(debates): name every side from the position, not geo-chat's label
Review follow-up. "Our label wins" only held for the pills; eight other surfaces
still printed geo-chat's `position_label`, which reads "Verify" or "Dispute" on
a claim it still calls factual — the rematch page, the debate room, the pre-join
screen, the request dialog and the request parties row.
Every participant type already carries `position` beside the label, and
geo-chat's label is only ever one of four fixed words (`position_label_for_
response` in crates/debates), so the label is fully derivable and nothing is
lost by deriving it. `DebateVideoTile` now takes only the side and names it
itself, which removes the prop from four call sites.
Also from the review:
- `DebateEntityResponseControls` is deleted. It had no callers outside its own
test — already true on master — and passed a wire-typed kind straight into
`EntityVoteButtons`, so it was a trap for whoever revived it.
- `responsePositionLabel`'s doc comment was orphaned above `CLAIM_RESPONSE_KIND`
when that constant was inserted; it is back on the function. `getUserHas
VoteOfKind` had two stacked comments, the first still describing veracity as a
live kind; they are one comment that says why the retired kind is still passed.
- Comments that still described Verify/Dispute as current: the ticker's card and
share, the `ClaimResponseState.responseKind` docs, the two in
`use-claim-response-state`, the `position` prop on `ResponsePositionIcon`, and
the end-slot prop in `claim-position-comment`.
Not fixed here, because it cannot be: geo-chat still resolves a claim's kind
from the graph's "Is factual" flag and rejects a mismatching response kind, so
"Request debate" will not appear on factual claims until geo-chat changes too.
Verified against geo-chat main at f8a2129. See the PR thread.
* fix(og): name the share card's speaker sides from the position
Review follow-up, found by re-sweeping after the rebase. The debate share card
took each speaker's side from geo-chat's `position_label`, which still reads
"Verify" or "Dispute" on a claim geo-chat calls factual.
This is the worst remaining place for the retired word to land. The card's own
comment says it: generated once at publish time and never revisited, so a wrong
label is baked into the share image permanently and cannot be corrected.
`cardSpeaker` now names the side itself, like every other surface. Nothing was
lost — `position_label` is only ever one of four fixed words, and the `position`
boolean it is derived from sits right beside it on the participant.
The existing fixtures carry `position` but no `position_label`, which is why
this went unnoticed: the field was simply `undefined` in every test. The new
cases pass one, so a regression has something to fail against.
Also dropped a reimplementation of `responsePositionLabel` in the claim card's
action-title helper.
* fix(claims): type the wire kind apart, and stop gating the profile tag
Copilot review, plus the preference rule.
**The wire type was lying.** `DebateResponseKind` was narrowed to `'stance'`
while the endpoint still returns `'veracity'`, and `geoChatRequest` casts raw
JSON straight to its type parameter — so there is no parse step that could make
the narrowing true. The tests carried `as 'stance'` casts to work around it,
which is the lie showing through.
`WireResponseKind` now describes what geo-chat can send and types the inbound
`response_kind` fields; `DebateResponseKind` stays what the app resolves to and
sends back. The casts are gone, and the compiler guards the boundary instead of
a comment: a wire value no longer fits where a vocabulary gets chosen. That
turned up two props typed off the wire field which should always have been the
app's kind.
It also made one test's input unrepresentable — the ticker's share verb comes
from the hook's kind, which cannot be `veracity` — so that case is gone and the
type is the guard. The claim-payload cases keep their meaning and now compile
without a cast; breaking `WireResponseKind` proves they rely on it.
**The profile tag no longer waits on claim metadata.** It was held until the
response kind resolved, because a factual claim would have read "agrees" for a
beat and then corrected itself to "verifies". One wording now, so the wait can
change nothing — and `isResponseKindResolved` is `row !== null || entity !==
null`, so on a claim where neither ever lands it never flips and the tag was
hidden for good. The gallery card renders it directly; the interactive controls
keep their gate, which is a different question.
`responseNote` loses its response-kind parameter across all three callers.
**Preference, per the new rule.** Someone holding both a verify and an agree
resolves to the agree, because kind 2 is not read anywhere a position or label
comes from. Already true; now pinned, including the sharp case where the two
disagree — a Verify beside a Disagree reads as Disagree, not as the agreement
the verify row implies.
* chore(claims): drop a type import the switch to ResponseKind left behind
`claim-position-comment` stopped typing its prop off `MatchmakingReadiness` last
commit and kept importing it.
Worth saying how it survived: the lint gate I had been checking each round was
running against an empty file list. The command built its list with
`git diff -- apps/web` from *inside* `apps/web`, where that pathspec matches
nothing, so eslint was handed no files and reported no problems. Seventy files
were going unchecked and reading as clean.
Re-run against the real list, the whole diff has exactly this one warning, so
the broken gate had hidden one stale import rather than a backlog.
* fix(votes): describe an entity by its newest vote row, not its oldest
Both from Patrick's review, and both are the same migration doing the damage:
Verify is gone, so a factual claim's responder answers it again with Agree —
and their kind-2 row stays on chain, because nothing can clear it any more.
That person now holds two vote rows on one claim.
**The Agreed tab dropped those claims.** `voteKindByObjectId` was built with
`Object.fromEntries`, which gives a repeated key its *last* value, and rows
arrive `VOTED_AT_DESC` — so the map described every entity by its *oldest* vote.
On a Verify-then-Agree claim it reported kind 2, `useVoteTabEntities` compared
that against the kind 1 the claim resolves to now, and dropped it. Before this
PR the claim resolved to kind 2 as well, so it matched and showed: the
regression is ours, on exactly the path this PR sends people down.
Extracted as `indexVoteRowsByObject` so it can be tested at all, and it now
takes the first row per entity. `votedAtByObjectId` is built in the same pass:
read from a different row it can disagree with the kind, and it is the list's
sort key. The cross-page merge in `use-user-voted-entity-ids` had the same
last-wins shape for an entity whose rows straddle a page boundary.
Note this changes the Agreed/Disagreed tab's sort position for a claim holding
both kinds — it now sorts by the Agree, which is the vote being shown.
**The Positions order still spent its budget on retired rows.** The query asked
for kinds 1 and 2; the decode stopped treating kind 2 as an answer last commit,
but the rows still arrived, still took a slot in the vote order, and still
counted against `ORDER_MAX_PAGES`. So a claim Agreed last month and Verified
yesterday sorted by yesterday, and enough retired rows could push real stance
rows past the cap. The filter asks for kind 1 alone.
Swept the class: the only other `Object.fromEntries` over query rows is a static
step list, and the remaining `voteKind: { in: … }` filters are the onboarding
checklist's documented exception and one already narrowed to stance.
* test(claims): cover the two stale-wire guards, and correct their docs
Copilot's "previously missed" set, all three mine.
**The backfill's normalization had nothing holding it.** It sends
`CLAIM_RESPONSE_KIND` rather than the row's word, and every fixture in the suite
was already `response_kind: 'stance'` — so reading the row produced the same
call and the assertion could not tell the two apart. A case with a `'veracity'`
row now asserts we still send `stance`.
Sweeping that class turned up the same gap one file over:
`claim-response-indexed-notifier` guards on `isClaimResponseKind`, and every
negative case there differed by *account* rather than by kind, so the guard
itself was unasserted. A key written under the retired kind now has to be
ignored. Both fail when their guard is reverted.
The other normalization sites already had discriminating fixtures — the card's
write path, the ticker, matches-list, the rematch page, the share card and the
glyph fallback all feed a veracity value and assert the stance result.
**Two comments were stale, and one was self-contradicting.**
`taggedPositionSummaries` carried both "a server-supplied label wins" and the
line replacing it, which is worse than either alone.
The `CLAIM_RESPONSE_KIND` and `CLAIM_RESPONSE_COPY` docs both said TypeScript
could not catch a wire value being used as a kind. That was true when they were
written and stopped being true two commits later, when `WireResponseKind` split
the inbound type from the app's. They now describe the split that does the
guarding rather than the hazard it removed.
* fix(votes): skip retired rows rather than out-ordering them
Copilot found the reverse of Patrick's case, and my fix for his did not cover
it. The two kinds are independent, so the Verify can be the *newer* row: answer
a claim Agree while it is ordinary, have it flagged factual, answer it again
with Verify. "Newest wins" then reports kind 2 and `useVoteTabEntities` drops
the claim from the Agreed tab, with the kind-1 stance still live — the same
symptom his finding had, reached from the other direction.
Nothing resolves to kind 2 any more, so a retired row can never be an entity's
current answer. `indexVoteRowsByObject` skips them instead of ranking them.
`RETIRED_VERACITY_VOTE_KIND` moves to `entity-response.ts`, beside the
vocabulary, since two places now have to name it and they do opposite things
with it: a tally or a current side ignores it, the onboarding checklist counts
it because it asks what somebody has ever done.
Swept the rest of the vote-row queries: the response-count and summary queries
pin an exact kind, the positions and participant queries were narrowed earlier,
and `UserHasVoteOfKind` admits kind 2 on purpose for that checklist. This was
the only query returning mixed kinds into a current-answer computation.
Not taking the other half of the suggestion — excluding kind 2 in the query
itself. It would also stop retired rows using pagination slots, but the tab
pages on scroll rather than capping, so those rows defer a few later rows by one
page rather than losing any; against that, the document is codegen'd and the
config points at a remote schema, so changing it means either regenerating
`gql.ts` wholesale or hand-writing the query the way `participant-positions`
does to avoid exactly that. Not worth it for 178 rows that can only shrink.
* fix(votes): let a page report only the ids it can describe
Skipping retired rows in the page's lookups left them in its id list, and the
two are read by different halves of the same hook. `useUserVotedEntityIds`
binds an id to the first page it appears on and reads its kind from the
lookups merged across every page, so a claim whose retired row ended one page
and whose live stance began the next was claimed by the earlier page — which
had no kind for it — and skipped as a duplicate on the later one, which did.
`useVoteTabEntities` banks a page against its ids, and those do not change
when the kind arrives, so the claim was never re-hydrated and stayed missing
from the tab for the rest of the session.
Deriving the ids in the same pass also stops a retired-only entity costing an
entity fetch it could never be classified by. `decodeVoteOrder` already has
this shape — its `entityIds` are the ids a response survived for.
* fix(claims): carry the merged vocabulary into master's new debate surfaces
Two features landed on master while this branch was open, and both were
written against the two-kind world the branch removes. Neither conflicted
textually, so the rebase produced code that was wrong rather than code that
refused to apply.
The feed player's new position chip (#2543) read geo-chat's `position_label`,
which still says "Verify" on a claim it calls factual — the one word this app
can no longer publish, printed over a debater's video. It now names the side
from `position` like every other caller. Its fixture keeps the retired label
deliberately, so the chip ignoring it is what the assertions prove; the test
for an empty label is gone, because `position` is a non-null boolean and there
is nothing left for the server to leave blank.
The People tab's disagreement counts (#2556) spelled the kinds again as a
local `'stance' | 'veracity'` literal and hand-rolled the Verify/Dispute
wording beside `responsePositionLabel`. The type now comes from
`ParticipantPosition` so the two cannot diverge a second time, and the badge
uses the shared label. Kind stays in the pairing key: it is half of what makes
two responses comparable even while it holds one value.
Three of those tests used veracity as a second axis to prove a claim is
counted once. A second space is the only axis left, so they use that, and the
"different response kinds" case is noted as unreachable rather than quietly
rewritten into something that passes for another reason.
* fix(claims): name the scheduled request card's sides from the position
master's rematch-request-card (#2566) still preferred geo-chat's label and
passed a response kind, which the merged vocabulary no longer takes.
---------
Co-authored-by: Patrick O'Reilly <p@ovpl.co>
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
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.
Follow-up to #2547 (GEO-2992). Three states redrew the rematch picker when nothing about them had changed, and the third turned into a navigation change — see §3, which the earlier revisions of this description left out.
1. Leaving collapsed the card before the redirect
Clicking Leave ends the session server-side. An ended session isn't voice-capable, so
RematchVoiceHeaderfell to its no-controls state the moment the mutation answered — and the mute pill vanished for the second or so before the redirect landed, collapsing your card in front of someone who had already left. The other person leaving does the same thing, as does a request being accepted or the browsing window lapsing, sosessionEndedcovers that window too.The header takes an
exitingflag and holds its shape until the page unmounts. Two things that flag must not do:isMicrophoneEnabledwith it — so a pair who joined unmuted from a recorded debate met the unmute notice for the first time on the way out, growing the header at the one moment the flag exists to hold it still. The notice now rides an exit only if it was already up, and the "{Name} is talking" nudge beside it is frozen the same way: leaving arms it too (the microphone reads as muted while "they are talking" is still true on that render), and a nudge already up can hit its ten seconds mid-exit and go. Those two are the only things that change this header's height, and both now hold.Connection messages are suppressed on the way out only for the branch that fires because the session ended — a room still connecting or reconnecting keeps saying so, or leaving would put live mute controls over nothing.
2. Sending a request grew the sticky block
A pending request put the claim heading and two Agree/Disagree chips into the header, pushing the whole list down at the exact moment the viewer was watching for an answer.
Those facts move into a card above the list, matching what the debates panel draws for a request you've sent: space chip, claim, the
RequestParties"You VS Them" row carrying both positions, an expiry countdown, and "Awaiting response".Built from the hub's parts (
SpaceChip,RequestParties,useRequestCountdown,hubCardMotion) rather than reusingOutboundRequestCarditself — that takes aDebateRequestwhose parties carry matchmaking presence a rematch session has no source for, and faking it to reach the component would be a worse kind of reuse than sharing what it's made of. No Withdraw button: geo-chat has no endpoint to back one, so the card says what's happening rather than offering a way out that doesn't exist. It does say when a request has lapsed — the hub never draws one, becauseuseUnexpiredRequestsfilters them upstream, but this card is drawn from the session, which readsrequest_pendinguntil geo-chat's next answer.LockedClaim,PositionChipandThumbIconare deleted from the header along with thelockedClaim/positionsplumbing.3. The picker's navigation, to match the panel the card came from
Putting the request card above the search panel is where the debates side panel puts it — and once it was there, the rest of the picker's disagreement with that panel was the remaining reason the two surfaces read differently. Requested in review of §2, and landed here rather than split out because §2 is what made the mismatch visible.
claims-tabmade in GEO-2863: a curated cut of the same tag, behind a menu most viewers never opened. Its tests go with it — the source menu's remaining behaviour (Recommended where a curator has a page, All otherwise) is covered by rewritten equivalents, and the one GEO-2771 regression test that used Featured covered auseLastSettledhold bridging two tagged sources. Onlyallis tagged now; the key-drift class it guarded is still covered on the topic filter.The badge calls an outage an outage rather than a zero — react-query drops
isLoadingon failure, so a cold failure would otherwise leave it saying confidently that there is nothing to look at beside a tab reporting an error.opponentCountPendinghas the same gap and predates this PR; flagged in review, not changed here.Lobby is not delayed by any of this: its gate excludes the viewer's chain. Related's first paint does now also wait on the viewer's entity batch, which runs in parallel with the opponent's identical one — flagged in review rather than silently traded away.
Testing
298 tests across the two rematch suites.
tscandeslintclean.New tests, each verified against the old behaviour before landing:
keeps the controls while the viewer is on their way out|| exitingdoes not swap in a connection message while leavingexitingguardholds its shape when the pair lock a claimexpected <h2> to be nulldraws a card for the request the viewer sentdoes not raise the unmute notice on the way outexpected <div> to be nullkeeps saying a room is reconnecting even while leavingReconnecting…not foundsays so when the request the viewer sent has lapsedcountdown.expireddoes not arm voice for a status that went stale before the room came upexpected false to be true(a token was minted)keeps the connecting line when it leaves before the room connectedgives the viewer’s own positions a tab rather than a sourcedoes not arm voice while the leave request is still in flightexpected false to be true(a token was minted)keeps counting the rows it draws while the space types are unresolvedpublishabilityPendingexpected '' to match /^\d+$/does not raise the talking nudge on the way outexpected <div> to be nulldoes not let the talking nudge time out while leavingcounts nothing rather than zero when the lookup failedkeeps the count it has when a refetch fails under itlength === 0guardexpected '' to be '2'lets a reconnect take the nudge down even while leavingThe last one is a pin rather than a fix: it fails against the change review asked for, which would have put a skeleton on the tab over a list already showing rows.
Two needed a second attempt to break properly — patching only the disconnected path left one passing because it runs against a connected room, and the badge test passed against a deliberately wrong count until the fixture had something to filter. Worth noting, since a break that doesn't fail is the same as no test.
🤖 Generated with Claude Code