From d2c274a8bce96ad86b6e6f8b30598ae2d298786a Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 10:39:44 -0700 Subject: [PATCH 01/11] fix(debates): stop the rematch header resizing under the viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../[sessionId]/rematch-page-client.test.tsx | 40 +++++++++ .../[sessionId]/rematch-page-client.tsx | 23 +++++- .../[sessionId]/rematch-pair-header.tsx | 74 +---------------- .../[sessionId]/rematch-request-card.tsx | 82 +++++++++++++++++++ .../[sessionId]/rematch-voice.test.tsx | 68 +++++++++++++-- .../rematches/[sessionId]/rematch-voice.tsx | 37 +++++---- 6 files changed, 227 insertions(+), 97 deletions(-) create mode 100644 apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-request-card.tsx diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx index 5f76110de8..eadac729b0 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx @@ -1120,6 +1120,46 @@ describe('DebateRematchPageClient', () => { expect(within(claimCard!).getByRole('button', { name: /^Dispute/ })).toBeEnabled(); }); + // The header used to grow the claim and two position chips the moment a request went out, moving + // everything under it at the exact moment the viewer was watching for an answer. The same facts + // arrive as a card in the content instead — the shape the debates panel uses for a request you + // sent. + it('draws a card for the request the viewer sent, leaving the header alone', async () => { + mocks.session = session({ + status: 'request_pending', + request: { + id: 'request-1', + status: 'pending', + claim: claimSummary(CLAIM_SHARED, 'A claim both participants chose'), + requester_user_id: 'user-local', + recipient_user_id: 'user-remote', + requester_position: true, + requester_position_label: 'Agree', + recipient_position: false, + recipient_position_label: 'Disagree', + response_kind: 'stance', + turn_format_id: 'standard', + created_at: '2026-07-10T10:00:00.000Z', + expires_at: '2026-07-10T10:02:00.000Z', + }, + }); + + render(); + + const card = await screen.findByTestId('rematch-outbound-request'); + expect(within(card).getByText('A claim both participants chose')).toBeInTheDocument(); + expect(within(card).getByText('Awaiting response')).toBeInTheDocument(); + // Both sides and what each of them holds — the chips that used to sit in the header. + expect(within(card).getByText('You')).toBeInTheDocument(); + expect(within(card).getByText('Agree')).toBeInTheDocument(); + expect(within(card).getByText('Disagree')).toBeInTheDocument(); + + // It is in the content, under the pinned block, not inside it. + expect(card.closest('.sticky')).toBeNull(); + // And the viewer is the requester, so no dialog asking them to answer their own request. + expect(screen.queryByRole('dialog')).toBeNull(); + }); + it('shows authoritative stance labels in the incoming request dialog and preserves rematch actions', async () => { mocks.session = session({ status: 'request_pending', diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx index 2b116ecb47..2e2750e444 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx @@ -101,6 +101,7 @@ import { Skeleton } from '~/design-system/skeleton'; import { tabGroupTabLinkStyles } from '~/design-system/tab-group'; import { Text } from '~/design-system/text'; +import { RematchRequestCard } from './rematch-request-card'; import { RematchVoiceHeader } from './rematch-voice'; import { rematchHideMyPositionsAtom, rematchMatchesOnlyAtom } from '~/atoms'; @@ -1944,6 +1945,8 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { const pendingRequest = session?.status === 'request_pending' ? session.request : null; const incomingRequest = pendingRequest?.recipient_user_id === currentUserId ? pendingRequest : null; + // The other side of the same pending request: the viewer asked, and is waiting to hear back. + const outboundRequest = pendingRequest?.requester_user_id === currentUserId ? pendingRequest : null; const incomingRequestParticipants = incomingRequest && session ? session.participants.map(participant => { @@ -2006,7 +2009,15 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { with no header. */}
{session && currentUserId ? ( - + ) : (
{leaveButton}
)} @@ -2156,6 +2167,16 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { )} + {/* Above the list rather than in the header. Growing the sticky block a claim and two + position chips moved everything under it down at the moment the viewer was watching for + an answer; a card that arrives in the content says the same thing without pushing the + page around. Same shape as the one the debates panel draws for a request you sent. */} + {outboundRequest && currentUserId ? ( +
+ +
+ ) : null} + void; onDismiss: () => void } | { kind: 'talking-while-muted'; onUnmute: () => void; onDismiss: () => void }; -/** Both sides of a locked pairing, once the pair have agreed what they are debating. */ -export type PairHeaderPositions = { localAgrees: boolean; opponentAgrees: boolean }; - /** * "Jenna Ruiz" -> "Jenna". The notice and the toasts address the opponent directly, and a display * handle read out in the middle of a sentence reads like a username, not like the person talking. @@ -90,9 +87,6 @@ type RematchPairHeaderProps = { voice: PairHeaderVoice; notice?: PairHeaderNotice | null; toast?: PairHeaderToast | null; - /** Set once the pair lock a claim — the claim sits above the cards and each card takes a side chip. */ - lockedClaim?: { claim: string; spaceName?: string | null } | null; - positions?: PairHeaderPositions | null; /** Opens the opponent's profile in the side panel — see `useOpenDebaterProfile`. */ onOpenOpponentSpace: (event: React.MouseEvent) => void; /** @@ -112,8 +106,6 @@ export function RematchPairHeader({ voice, notice, toast, - lockedClaim, - positions, onOpenOpponentSpace, leaveAction, }: RematchPairHeaderProps) { @@ -131,17 +123,10 @@ export function RematchPairHeader({ * other person mutes, and a control whose name moves is harder to use than one that is quiet. */ const opponentStatusId = React.useId(); - const opponentStatus = [ - voice.kind === 'live' ? opponentMicLabel(voice.opponentState, opponentName) : null, - positions ? `${opponentName} ${positions.opponentAgrees ? 'agrees' : 'disagrees'}` : null, - ] - .filter(Boolean) - .join('. '); + const opponentStatus = voice.kind === 'live' ? opponentMicLabel(voice.opponentState, opponentName) : ''; return (
- {lockedClaim ? : null} - {/* Them, then the badge, then you — the home side of a scoreboard, and the side a reader's eye lands on last. Three tracks on desktop, stacked on a phone: two 150px cards side by side at 375px leave no room for a labelled pill, and a labelled pill is the entire point @@ -152,18 +137,11 @@ export function RematchPairHeader({ name={opponentName} state={voice.kind === 'live' ? voice.opponentState : 'waiting'} showMicState={voice.kind === 'live'} - agrees={positions?.opponentAgrees} onOpen={onOpenOpponentSpace} describedBy={opponentStatusId} /> - +
{/* The spoken half of the nudges: a region that is always mounted and only changes text. A @@ -191,15 +169,6 @@ function toastAnnouncement(toast: PairHeaderToast, opponentName: string) { : `You’re talking while muted.`; } -function LockedClaim({ claim, spaceName }: { claim: string; spaceName?: string | null }) { - return ( -
- {spaceName ? {spaceName} : null} -

{claim}

-
- ); -} - function VsBadge() { return (
@@ -284,13 +253,11 @@ function YouCard({ local, voice, opponentName, - agrees, leaveAction, }: { local: PairHeaderParticipant | null; voice: PairHeaderVoice; opponentName: string; - agrees?: boolean; leaveAction?: React.ReactNode; }) { const live = voice.kind === 'live'; @@ -393,7 +360,6 @@ function YouCard({ } action={leaveAction} - footer={agrees === undefined ? null : } />
); @@ -404,7 +370,6 @@ function OpponentCard({ name, state, showMicState, - agrees, onOpen, describedBy, }: { @@ -412,7 +377,6 @@ function OpponentCard({ name: string; state: PairMicState; showMicState: boolean; - agrees?: boolean; onOpen: (event: React.MouseEvent) => void; /** The header's live region, which also serves as this button's description. */ describedBy: string; @@ -446,7 +410,6 @@ function OpponentCard({ } - footer={agrees === undefined ? null : } /> ); @@ -540,39 +503,6 @@ function TalkingBars() { ); } -/** Which side of the locked claim this person is on. */ -function PositionChip({ agrees }: { agrees: boolean }) { - return ( - - - {agrees ? 'Agree' : 'Disagree'} - - ); -} - -function ThumbIcon({ down }: { down: boolean }) { - return ( - - - - - ); -} - /** * Says out loud what the header only implies: there is another person on the line right now, and * the viewer is muted by a default they did not choose. diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-request-card.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-request-card.tsx new file mode 100644 index 0000000000..ade4c99324 --- /dev/null +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-request-card.tsx @@ -0,0 +1,82 @@ +'use client'; + +import * as React from 'react'; + +import { motion } from 'framer-motion'; + +import type { DebateRematchParticipant, DebateRematchRequest } from '~/core/debates/api'; +import { hubCardMotion } from '~/core/debates/matchmaking/hub-motion'; +import { SpaceChip } from '~/core/debates/matchmaking/matchmaking-claim-card'; +import { RequestParties } from '~/core/debates/matchmaking/request-parties'; +import { useRequestCountdown } from '~/core/debates/matchmaking/use-request-countdown'; +import { responsePositionLabel } from '~/core/responses/entity-response'; + +import { Time } from '~/design-system/icons/time'; +import { Text } from '~/design-system/text'; + +/** + * The request the viewer has sent from the claim picker, waiting on the other side. + * + * Built from the hub's pieces — `SpaceChip`, `RequestParties`, `useRequestCountdown` — rather than + * reusing `OutboundRequestCard` itself, which takes a `DebateRequest` whose parties carry + * matchmaking presence this session has no source for. Faking that to reach the component would be + * a worse kind of reuse than sharing the parts it is made of. + * + * The one real difference from the hub's card: a rematch request cannot be withdrawn — geo-chat has + * no endpoint for it — so this says what is happening and waits, rather than offering a way out + * that does not exist. + */ +export function RematchRequestCard({ + request, + participants, + currentUserId, +}: { + request: DebateRematchRequest; + participants: readonly DebateRematchParticipant[]; + currentUserId: string; +}) { + const countdown = useRequestCountdown(request.expires_at); + + // Each side as the parties row wants them: the person, plus the side they took on this claim. + const parties = participants.map(participant => { + const isRequester = participant.user_id === request.requester_user_id; + const position = isRequester ? request.requester_position : request.recipient_position; + const label = isRequester ? request.requester_position_label : request.recipient_position_label; + + return { + ...participant, + position, + position_label: label ?? responsePositionLabel(request.response_kind ?? null, position), + }; + }); + + const viewer = parties.find(party => party.user_id === currentUserId) ?? null; + const opponent = parties.find(party => party.user_id !== currentUserId); + if (!opponent) return null; + + return ( + +
+ + {/* The clock is the only thing on this card that changes, and it is the reason to look at + it twice: a request lapses on its own whether or not anyone is watching. */} + + +
+ + + {request.claim.claim} + + + + + Awaiting response +
+ ); +} diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx index ededc007a4..e01be064d8 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx @@ -1722,17 +1722,71 @@ describe('RematchVoiceHeader', () => { expect(screen.getByTestId('rematch-opponent-card')).toHaveAttribute('aria-label', 'Open Salina’s personal space'); }); - // Same label, same problem: once the pair lock a claim the opponent's side is a chip inside that - // button, and the accessible name hides it too. - it('carries the opponent locked position in the card description', async () => { + // The header used to grow a claim heading and two position chips the moment a request went out, + // pushing everything under it down at exactly the moment the viewer was watching for an answer. + // That belongs in a card in the content — see `RematchRequestCard` — not in the sticky block. + it('holds its shape when the pair lock a claim', async () => { mocks.remoteParticipants = [remoteOpponent()]; mocks.opponentMicPublication = { isMuted: true }; - render(); + const browsing = makeSession('browsing'); + const { rerender } = render(); await flushOwnership(); + const before = screen.getByTestId('rematch-you-card').className; - // Visible on the card, and reachable from the control that hides it. - expect(within(screen.getByTestId('rematch-opponent-card')).getByText('Disagree')).toBeInTheDocument(); - expect(screen.getByTestId('rematch-opponent-status')).toHaveTextContent('Salina is muted. Salina disagrees'); + rerender(); + + expect(screen.queryByText('A man should always pay for the first date')).toBeNull(); + expect(screen.queryByText('Agree')).toBeNull(); + expect(screen.queryByText('Disagree')).toBeNull(); + expect(screen.getByTestId('rematch-you-card').className).toBe(before); + // The control the header exists for is untouched by the lock. + expect(screen.getByRole('button', { name: /^(Mute|Unmute) microphone$/ })).toBeInTheDocument(); + }); + + // Leaving ends the session, and an ended session is not voice-capable — so the controls used to + // tear themselves down in the second before the redirect landed, collapsing the card in front of + // someone who had already left. + it('keeps the controls while the viewer is on their way out', async () => { + mocks.remoteParticipants = [remoteOpponent()]; + const browsing = makeSession('browsing'); + const { rerender } = render( + Leave debate} + /> + ); + await flushOwnership(); + expect(screen.getByRole('button', { name: /^(Mute|Unmute) microphone$/ })).toBeInTheDocument(); + + // What leaving does: the session comes back ended, and the page says it is on its way out. + rerender( + Leave debate} + exiting + /> + ); + + expect(screen.getByRole('button', { name: /^(Mute|Unmute) microphone$/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Leave debate' })).toBeInTheDocument(); + }); + + // A dropped room on the way out is the same layout shift wearing a different hat, and "Retry" is + // an offer of something the viewer has just declined. + it('does not swap in a connection message while leaving', async () => { + mocks.remoteParticipants = [remoteOpponent()]; + const session = makeSession('browsing'); + const { rerender } = render(); + await flushOwnership(); + + mocks.connectionState = 'disconnected'; + rerender(); + + expect(screen.queryByText('Voice disconnected')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull(); + expect(screen.getByRole('button', { name: /^(Mute|Unmute) microphone$/ })).toBeInTheDocument(); }); // A hover variant outranks a plain utility on specificity whichever order they are written in, diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx index 41d63e3069..d29bc582ce 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx @@ -39,7 +39,6 @@ import { useElevatedPopoverPortal } from '~/design-system/use-elevated-popover-p import { PAIR_PILL, type PairHeaderParticipant, - type PairHeaderPositions, type PairHeaderToast, type PairHeaderVoice, type PairMicState, @@ -198,8 +197,6 @@ type PairContext = { opponent: PairHeaderParticipant; opponentName: string; onOpenOpponentSpace: (event: React.MouseEvent) => void; - lockedClaim: { claim: string; spaceName?: string | null } | null; - positions: PairHeaderPositions | null; leaveAction?: React.ReactNode; }; @@ -229,6 +226,15 @@ type RematchVoiceHeaderProps = { currentUserId: string; /** The page's Leave button. It lives in your card's corner now, not at the end of the tab row. */ leaveAction?: React.ReactNode; + /** + * The viewer is on their way out, and the page is about to unmount. + * + * Leaving ends the session server-side, and an ended session is not voice-capable — so without + * this the controls tear themselves down a second or so before the redirect lands, and the card + * collapses in front of someone who has already left. Nothing about this header is worth + * re-laying-out on the way to somewhere else. + */ + exiting?: boolean; }; export function RematchVoiceHeader(props: RematchVoiceHeaderProps) { @@ -238,8 +244,10 @@ export function RematchVoiceHeader(props: RematchVoiceHeaderProps) { return ; } -function SessionRematchVoiceHeader({ session, currentUserId, leaveAction }: RematchVoiceHeaderProps) { - const voiceActive = voiceCapable(session.status); +function SessionRematchVoiceHeader({ session, currentUserId, leaveAction, exiting = false }: RematchVoiceHeaderProps) { + // Sticky on the way out. The room keeps its connection and its controls until the page unmounts, + // which is what stops the card resizing between the click and the redirect. + const voiceActive = voiceCapable(session.status) || exiting; const opponent = session.participants.find(participant => participant.user_id !== currentUserId) ?? null; const local = session.participants.find(participant => participant.user_id === currentUserId) ?? null; @@ -417,16 +425,6 @@ function SessionRematchVoiceHeader({ session, currentUserId, leaveAction }: Rema // local copy got wrong by leaving the card inert until it landed. const openOpponentProfile = useOpenDebaterProfile(opponent); - // Once the pair lock a claim the header stops being only about voice: it is who is arguing what, - // which side each of them took, and the claim itself above both cards. - const request = session.status === 'request_pending' ? session.request : null; - const lockedClaim = request ? { claim: request.claim.claim } : null; - const positions: PairHeaderPositions | null = request - ? request.requester_user_id === currentUserId - ? { localAgrees: request.requester_position, opponentAgrees: request.recipient_position } - : { localAgrees: request.recipient_position, opponentAgrees: request.requester_position } - : null; - // No pair to draw, but the viewer is still in a session they must be able to leave — and Leave // lives in the header now. The row is the header's, minus everything that needs two people. if (!opponent) return leaveAction ?
{leaveAction}
: null; @@ -436,8 +434,6 @@ function SessionRematchVoiceHeader({ session, currentUserId, leaveAction }: Rema opponent: toHeaderParticipant(opponent) as PairHeaderParticipant, opponentName, onOpenOpponentSpace: openOpponentProfile, - lockedClaim, - positions, leaveAction, }; @@ -491,6 +487,7 @@ function SessionRematchVoiceHeader({ session, currentUserId, leaveAction }: Rema > ; function VoiceHeaderBody({ pair, + exiting, opponentUserId, micFailure, onMicIntentChange, @@ -575,6 +573,7 @@ function VoiceHeaderBody({ analytics, }: { pair: PairContext; + exiting: boolean; opponentUserId: string; micFailure: MediaDeviceFailure | null; onMicIntentChange: (enabled: boolean) => void; @@ -704,6 +703,10 @@ function VoiceHeaderBody({ * opponent may well be talking, and the viewer simply cannot hear it until they click. */ const connectionMessage = ((): Extract | null => { + // Not while leaving. Ending the session can drop the room within the second it takes the + // redirect to land, and swapping the controls for "Voice disconnected · Retry" on the way out + // is both a layout shift and an offer of something the viewer cannot want. + if (exiting) return null; if (connectionState === ConnectionState.Disconnected && everConnected) { return { kind: 'message', message: 'Voice disconnected', actionLabel: 'Retry', onAction: onRetry }; } From bba37d0f6defab2c8145c05af6d8a75476d701cc Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 11:31:59 -0700 Subject: [PATCH 02/11] fix(debates): hold the rematch header when the other person leaves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../[sessionId]/rematch-page-client.test.tsx | 36 +++++++++++++++++-- .../[sessionId]/rematch-page-client.tsx | 20 +++++++++-- .../[sessionId]/rematch-voice.test.tsx | 34 ++++++++++++++++++ .../rematches/[sessionId]/rematch-voice.tsx | 22 ++++++++++-- 4 files changed, 105 insertions(+), 7 deletions(-) diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx index eadac729b0..e3b67b916c 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx @@ -202,8 +202,12 @@ vi.mock('next/navigation', () => ({ vi.mock('./rematch-voice', () => ({ // Renders its `leaveAction`, which really does live in the header now — stubbing it away would // take the Leave button off the page for every test below. - RematchVoiceHeader: ({ leaveAction }: { leaveAction?: ReactNode }) => ( -
{leaveAction}
+ // `exiting` is surfaced because that flag is where the header's layout stability is decided, and + // the bug it exists for was in the wiring here rather than inside the header. + RematchVoiceHeader: ({ leaveAction, exiting }: { leaveAction?: ReactNode; exiting?: boolean }) => ( +
+ {leaveAction} +
), })); @@ -3380,6 +3384,34 @@ describe('DebateRematchPageClient', () => { expect(screen.getByText('A newly published claim').closest('.sticky')).toBeNull(); }); + // Leaving is not the only way out. The other person leaving ends the session too, and it reaches + // this page as a status change with no mutation of ours behind it — so a header that only knew + // about our own Leave button rebuilt itself in the render before the redirect landed. + it('tells the header it is on its way out however the session ended', async () => { + for (const status of ['ended', 'expired'] as const) { + mocks.session = session({ status }); + const { unmount } = render(); + + expect(screen.getByTestId('rematch-pair-header')).toHaveAttribute('data-exiting', 'true'); + unmount(); + } + }); + + // And a request being accepted, which walks the pair into the debate room. + it('tells the header it is on its way out when the session converts to a debate', async () => { + mocks.session = session({ status: 'converted', converted_debate_id: 'debate-9' }); + render(); + + expect(screen.getByTestId('rematch-pair-header')).toHaveAttribute('data-exiting', 'true'); + }); + + // The ordinary case stays ordinary: nothing is exiting, so nothing is frozen. + it('does not tell the header it is exiting while the session is live', async () => { + render(); + + expect(screen.getByTestId('rematch-pair-header')).not.toHaveAttribute('data-exiting'); + }); + // This page is a `fixed inset-0` layer over the whole app, and Leave is the only way off it. // Moving it into the pair header put it behind `session && currentUserId` for a release — // signed out, mid identity exchange, or on a failed session lookup the picker covered the screen diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx index 2e2750e444..805a383583 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx @@ -1882,6 +1882,20 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { : curatedClaimsQuery.isLoading || Boolean(curatedClaimsQuery.error), }); + /** + * The session is over and this page is about to navigate away — whoever ended it. + * + * Leaving is not the only way out: the other person leaving ends the session too, and so does a + * request being accepted or the browsing window lapsing. All of them land here as a status the + * effect below redirects on, and in the render before that redirect the header must not start + * rebuilding itself for a page nobody will see. + */ + const sessionEnded = + session !== null && + ((session.status === 'converted' && Boolean(session.converted_debate_id)) || + session.status === 'ended' || + session.status === 'expired'); + React.useEffect(() => { if (!session) return; if (session.status === 'converted' && session.converted_debate_id) { @@ -2015,8 +2029,10 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { leaveAction={leaveButton} // `isSuccess` as well as `isPending`: the session is ended the moment the mutation // answers, and the redirect lands a beat later. That gap is the whole window in - // which the controls used to vanish. - exiting={leaveSession.isPending || leaveSession.isSuccess} + // which the controls used to vanish. `sessionEnded` covers the same window when it + // was the other person who left, which reaches this page as a status change with no + // mutation of ours behind it. + exiting={leaveSession.isPending || leaveSession.isSuccess || sessionEnded} /> ) : (
{leaveButton}
diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx index e01be064d8..cd8d5b741a 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx @@ -1743,6 +1743,40 @@ describe('RematchVoiceHeader', () => { expect(screen.getByRole('button', { name: /^(Mute|Unmute) microphone$/ })).toBeInTheDocument(); }); + // The other person dropping out is not a reason to move everything under the notice. It says the + // viewer is muted and names who they came here to talk to, and neither stops being true of the + // visit when the other side reconnects — or goes for good, taking the session with it. + it('keeps the unmute notice when the opponent drops out of the room', async () => { + mocks.isMicrophoneEnabled = false; + mocks.remoteParticipants = [remoteOpponent()]; + const session = makeSession('browsing'); + const { rerender } = render(); + await flushOwnership(); + expect(screen.getByTestId('rematch-unmute-notice')).toBeInTheDocument(); + + mocks.remoteParticipants = []; + rerender(); + + expect(screen.getByTestId('rematch-unmute-notice')).toBeInTheDocument(); + // The opponent's own card still reports the truth — they are not here. + expect(screen.getByTitle('Waiting for Salina to join')).toBeInTheDocument(); + }); + + // A blip is the same shift on a shorter clock. + it('keeps the unmute notice across a reconnect', async () => { + mocks.isMicrophoneEnabled = false; + mocks.remoteParticipants = [remoteOpponent()]; + const session = makeSession('browsing'); + const { rerender } = render(); + await flushOwnership(); + expect(screen.getByTestId('rematch-unmute-notice')).toBeInTheDocument(); + + mocks.connectionState = 'reconnecting'; + rerender(); + + expect(screen.getByTestId('rematch-unmute-notice')).toBeInTheDocument(); + }); + // Leaving ends the session, and an ended session is not voice-capable — so the controls used to // tear themselves down in the second before the redirect landed, collapsing the card in front of // someone who had already left. diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx index d29bc582ce..ace5195aaa 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx @@ -634,6 +634,20 @@ function VoiceHeaderBody({ const [opponentMuted, setOpponentMuted] = React.useState(true); + /** + * Whether the opponent has been in the room at all this visit. + * + * The unmute notice is gated on this rather than on them being here right now. "Nobody to talk + * to" is a reason never to raise it, but once it is up, taking it away again when the other + * person drops — for a reconnect, or for good — moves everything under it at a moment the viewer + * did nothing to cause. What the notice says is still true of the visit: you are muted, and there + * is somebody you came here to talk to. + */ + const [opponentEverJoined, setOpponentEverJoined] = React.useState(false); + React.useEffect(() => { + if (opponentParticipant) setOpponentEverJoined(true); + }, [opponentParticipant]); + // Everything below used to live in a `ConnectedPairHeader` this rendered instead of a message. // Swapping one component for another at the same position is a remount, and this subtree is the // wrong place for one: it would close an open Audio settings popover, drop focus from the Leave @@ -746,10 +760,12 @@ function VoiceHeaderBody({ talkingWhileMuted: TALKING_WHILE_MUTED, }; - // Only while muted, only with somebody to talk to, only once the room is actually up, and only - // until the viewer has answered it once. + // Only while muted, only once there has been somebody to talk to, and only until the viewer has + // answered it once. Deliberately not gated on the connection: a dropped room does not make the + // viewer un-muted or the other person un-paired, and hiding the notice for the length of a blip + // is a layout shift charged for nothing. const notice = - !connectionMessage && muted && !micFailed && !noticeDismissed && opponentParticipant + muted && !micFailed && !noticeDismissed && opponentEverJoined ? { onUnmute: unmute, onDismiss: onDismissNotice } : null; From 3e8068691593d3725b8564673434ab44cbf0e54f Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 11:59:43 -0700 Subject: [PATCH 03/11] fix(debates): hold voice open on the way out without starting it there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../[sessionId]/rematch-pair-header.tsx | 4 -- .../[sessionId]/rematch-voice.test.tsx | 56 +++++++++++++++++++ .../rematches/[sessionId]/rematch-voice.tsx | 39 ++++++++++--- 3 files changed, 88 insertions(+), 11 deletions(-) diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-pair-header.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-pair-header.tsx index 7866e9da2b..476552cb9b 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-pair-header.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-pair-header.tsx @@ -220,7 +220,6 @@ function PairCardBody({ nameTitle, state, action, - footer, }: { avatar: React.ReactNode; name: string; @@ -229,8 +228,6 @@ function PairCardBody({ state?: React.ReactNode; /** The corner: Leave on your side, View profile on theirs. */ action?: React.ReactNode; - /** Below the whole row, spanning the card — the locked position chip. */ - footer?: React.ReactNode; }) { return ( <> @@ -244,7 +241,6 @@ function PairCardBody({
{action ?
{action}
: null} - {footer} ); } diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx index cd8d5b741a..c245996ac9 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx @@ -1777,6 +1777,62 @@ describe('RematchVoiceHeader', () => { expect(screen.getByTestId('rematch-unmute-notice')).toBeInTheDocument(); }); + // Holding voice open on the way out must not be the same thing as starting it. Open a link to a + // rematch that is already over and the page is exiting from its very first render — so a flag + // that only says "exiting" would take the tab lock, fetch a token and publish the microphone + // into a session the viewer is not in, on a page that is busy redirecting. + it('does not arm voice on a session that was already over when the page loaded', async () => { + const carriedOver = { ...makeSession('ended'), source_debate_id: 'debate-1' }; + const { container } = render( + Leave debate} + exiting + /> + ); + await flushOwnership(); + + expect(mocks.joinCalls.every(call => !call.enabled)).toBe(true); + expect(container.querySelector('[data-testid="livekit-room"]')).toBeNull(); + expect(mocks.getUserMedia).not.toHaveBeenCalled(); + // The pair are still drawn; there is simply no voice to arm. + expect(screen.getByText('Salina')).toBeInTheDocument(); + }); + + // 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 — so here the + // notice gives way to the card's own "Voice disconnected · Retry", which is the actionable thing. + it('drops the unmute notice when the room is gone rather than away', async () => { + mocks.isMicrophoneEnabled = false; + mocks.remoteParticipants = [remoteOpponent()]; + const session = makeSession('browsing'); + const { rerender } = render(); + await flushOwnership(); + expect(screen.getByTestId('rematch-unmute-notice')).toBeInTheDocument(); + + mocks.connectionState = 'disconnected'; + rerender(); + + expect(screen.queryByTestId('rematch-unmute-notice')).toBeNull(); + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + }); + + // Except on the way out, where the whole point is that nothing moves. + it('keeps the unmute notice over a dead room while leaving', async () => { + mocks.isMicrophoneEnabled = false; + mocks.remoteParticipants = [remoteOpponent()]; + const session = makeSession('browsing'); + const { rerender } = render(); + await flushOwnership(); + expect(screen.getByTestId('rematch-unmute-notice')).toBeInTheDocument(); + + mocks.connectionState = 'disconnected'; + rerender(); + + expect(screen.getByTestId('rematch-unmute-notice')).toBeInTheDocument(); + }); + // Leaving ends the session, and an ended session is not voice-capable — so the controls used to // tear themselves down in the second before the redirect landed, collapsing the card in front of // someone who had already left. diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx index ace5195aaa..4984dbed3f 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx @@ -245,9 +245,25 @@ export function RematchVoiceHeader(props: RematchVoiceHeaderProps) { } function SessionRematchVoiceHeader({ session, currentUserId, leaveAction, exiting = false }: RematchVoiceHeaderProps) { - // Sticky on the way out. The room keeps its connection and its controls until the page unmounts, - // which is what stops the card resizing between the click and the redirect. - const voiceActive = voiceCapable(session.status) || exiting; + const voiceCapableNow = voiceCapable(session.status); + /** + * Whether voice has been live at all this visit. + * + * `exiting` alone is not enough to hold the room open, because it is also true from the first + * render of a rematch that was already over when the link was opened — and "keep what is there" + * would become "start a room, take the tab lock 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 is a live microphone rather than a wasted request. + */ + const [voiceWasActive, setVoiceWasActive] = React.useState(false); + React.useEffect(() => { + if (voiceCapableNow) setVoiceWasActive(true); + }, [voiceCapableNow]); + + // Sticky on the way out, but only over a room that was already up: the connection and its + // controls last until the page unmounts, which is what stops the card resizing between the click + // and the redirect. + const voiceActive = voiceCapableNow || (exiting && voiceWasActive); const opponent = session.participants.find(participant => participant.user_id !== currentUserId) ?? null; const local = session.participants.find(participant => participant.user_id === currentUserId) ?? null; @@ -760,12 +776,21 @@ function VoiceHeaderBody({ talkingWhileMuted: TALKING_WHILE_MUTED, }; + /** + * The room is gone, rather than merely away. + * + * A blip does not make the viewer un-muted or the other person un-paired, and unmuting through it + * records an intent the reconnect restores — so the notice rides a reconnect out rather than + * taking everything under it with it. A room that has given up is different: 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. Not while `exiting`, where nothing changes shape at all. + */ + const roomGone = !exiting && connectionState === ConnectionState.Disconnected && everConnected; + // Only while muted, only once there has been somebody to talk to, and only until the viewer has - // answered it once. Deliberately not gated on the connection: a dropped room does not make the - // viewer un-muted or the other person un-paired, and hiding the notice for the length of a blip - // is a layout shift charged for nothing. + // answered it once. const notice = - muted && !micFailed && !noticeDismissed && opponentEverJoined + muted && !micFailed && !noticeDismissed && opponentEverJoined && !roomGone ? { onUnmute: unmute, onDismiss: onDismissNotice } : null; From a0b5e6ca87ba873d5aff79cdbc989452d1d0a4bc Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 12:23:12 -0700 Subject: [PATCH 04/11] refactor(debates): pin the rematch request card above the filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../[sessionId]/rematch-page-client.test.tsx | 9 +++++++-- .../[sessionId]/rematch-page-client.tsx | 18 ++++++++---------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx index e3b67b916c..aeba6658fe 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx @@ -1158,8 +1158,13 @@ describe('DebateRematchPageClient', () => { expect(within(card).getByText('Agree')).toBeInTheDocument(); expect(within(card).getByText('Disagree')).toBeInTheDocument(); - // It is in the content, under the pinned block, not inside it. - expect(card.closest('.sticky')).toBeNull(); + // Pinned with the filters and the search box, where the hub's claims tab keeps it — so a + // request stays on screen while the viewer keeps browsing rather than scrolling away. + const pinned = screen.getByRole('textbox', { name: 'Search claims' }).closest('.sticky'); + expect(card.closest('.sticky')).toBe(pinned); + // Above both of them, not wedged between. + const filters = screen.getByRole('button', { name: /Any space/ }); + expect(card.compareDocumentPosition(filters) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); // And the viewer is the requester, so no dialog asking them to answer their own request. expect(screen.queryByRole('dialog')).toBeNull(); }); diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx index 805a383583..5d1b16aa9e 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx @@ -2093,6 +2093,14 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) {
+ {/* Pinned above the filters and the search box, where the hub's claims tab keeps it — + and inside the sticky block rather than over it, because two stickies would both + claim `top-0` and overlap. A request sent from here otherwise left no trace on the + surface that sent it: the claim card looks exactly as it did before. */} + {outboundRequest && currentUserId ? ( + + ) : null} + )} - {/* Above the list rather than in the header. Growing the sticky block a claim and two - position chips moved everything under it down at the moment the viewer was watching for - an answer; a card that arrives in the content says the same thing without pushing the - page around. Same shape as the one the debates panel draws for a request you sent. */} - {outboundRequest && currentUserId ? ( -
- -
- ) : null} - Date: Thu, 24 Sep 2026 13:21:13 -0700 Subject: [PATCH 05/11] refactor(debates): give the rematch picker Positions its own tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../[sessionId]/rematch-page-client.test.tsx | 184 +++++++----------- .../[sessionId]/rematch-page-client.tsx | 102 ++++++---- 2 files changed, 134 insertions(+), 152 deletions(-) diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx index aeba6658fe..add0317de4 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx @@ -1343,16 +1343,17 @@ describe('DebateRematchPageClient', () => { // The whole tagged corpus is the wider net; the curated cut is one pick below it, which is the // order the hub's Explore menu opens in too. - it('opens on All claims when no curator has a page for this pairing', async () => { - mocks.featuredClaims = [featuredTag()]; - mocks.entities = [sharedEntity(), publishedEntity(), featuredEntity()]; + // A menu of one is a control that cannot do anything, so without a curated page for this + // pairing Explore draws none at all — the shape the hub's claims tab has. + it('draws no source menu when no curator has a page for this pairing', async () => { + mocks.entities = [sharedEntity(), publishedEntity()]; render(); await showExplore(); expect(screen.getByRole('button', { name: 'Explore' })).toHaveAttribute('aria-pressed', 'true'); - expect(screen.getByRole('button', { name: 'All claims' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'All claims' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Recommended' })).toBeNull(); expect(screen.getByText('A newly published claim')).toBeInTheDocument(); - expect(screen.queryByText('A featured claim')).toBeNull(); }); /** @@ -1422,7 +1423,8 @@ describe('DebateRematchPageClient', () => { await showExplore(); expect(screen.queryByRole('button', { name: 'Recommended' })).toBeNull(); - expect(screen.getByRole('button', { name: 'All claims' })).toBeInTheDocument(); + // And with only one source left there is no menu at all. + expect(screen.queryByRole('button', { name: 'All claims' })).toBeNull(); expect(screen.queryByText(/Nothing recommended/)).toBeNull(); }); @@ -1431,6 +1433,7 @@ describe('DebateRematchPageClient', () => { // as one control — so the menus go back to being a run on the left, and the end of the row // belongs to the switch. it('groups the source, space and topic menus on the left, in that order', async () => { + curatedPage(); render(); await showExplore(); @@ -1476,35 +1479,10 @@ describe('DebateRematchPageClient', () => { openSourceMenu(); - const labels = ['Recommended', 'All claims', 'Featured', 'My positions']; + const labels = ['Recommended', 'All claims']; const options = screen.getAllByRole('button').filter(button => labels.includes(button.textContent?.trim() ?? '')); // The trigger carries the current label too, and it is rendered ahead of the options. - expect(options.slice(-4).map(button => button.textContent?.trim())).toEqual(labels); - }); - - it('leaves Recommended out of the menu when there is no curated page', async () => { - render(); - - openSourceMenu(); - - expect(screen.queryByRole('button', { name: 'Recommended' })).toBeNull(); - // Twice: the trigger reads All claims, and so does the option it opened onto. - expect(screen.getAllByRole('button', { name: 'All claims' })).toHaveLength(2); - }); - - // Unlike Recommended, Featured is a tag any space can carry, so it fans out across the corpus - // the way All claims does -- and is bounded the same way. - it('drops tagged claims from spaces outside the viewer’s allowed set', async () => { - mocks.spaceAllowlist = new Set([SPACE_1.replace(/-/g, '')]); - mocks.featuredClaims = [featuredTag()]; - mocks.entities = [sharedEntity(), featuredEntity()]; - render(); - await showFeatured(); - - await settleTabSwap(); - - expect(screen.queryByText('A featured claim')).toBeNull(); - expect(screen.getByText('No featured claims are available to debate yet.')).toBeInTheDocument(); + expect(options.slice(-2).map(button => button.textContent?.trim())).toEqual(labels); }); // The space ranking picks the highest-ranked space a claim is *named* in and knows nothing of @@ -1513,7 +1491,7 @@ describe('DebateRematchPageClient', () => { it('draws a tagged claim in the space it was featured in, not the highest-ranked one', async () => { // SPACE_1 (Crypto, rank 2) outranks SPACE_2, and the claim is named in both. mocks.spaceAllowlist = new Set([SPACE_2.replace(/-/g, '')]); - mocks.featuredClaims = [featuredTag(FEATURED, 'A featured claim', SPACE_2)]; + mocks.debateTagClaims = [featuredTag(FEATURED, 'A featured claim', SPACE_2)]; mocks.entities = [ sharedEntity(), { @@ -1526,36 +1504,13 @@ describe('DebateRematchPageClient', () => { }, ]; render(); - await showFeatured(); - - await settleTabSwap(); + await showAllClaims(); expect(screen.getByText('A featured claim')).toBeInTheDocument(); expect(mocks.rematchClaimIds.flat()).toContain(FEATURED); expect(screen.queryByText('No featured claims are available to debate yet.')).toBeNull(); }); - // `enabled: false` leaves react-query's cached rows in place, and the hub shares this query key - // -- so a catalog fetched there arrives pre-populated and would keep the hydration mounted. - it('asks for nothing while Featured is off screen, even with a cached catalog', async () => { - curatedPage(); - mocks.featuredClaims = [featuredTag()]; - mocks.entities = [sharedEntity(), featuredEntity()]; - render(); - - // Read by *tag* rather than by the enabled flag: the tab opens on All claims, so the same - // hook is legitimately running — for the Debate tag. What must stay quiet is Featured's tag - // and the geo-chat rows behind it. There is no entity lookup to hold off any more; the page - // carries what a row is built from. - expect(mocks.taggedClaimsAskedFor).not.toContain('ec3086a54ddf43d8aaefd6cc6e1b0556'); - expect(mocks.rematchClaimIds.flat()).not.toContain(FEATURED); - - await chooseSource('Featured'); - - expect(mocks.taggedClaimsAskedFor).toContain('ec3086a54ddf43d8aaefd6cc6e1b0556'); - await waitFor(() => expect(mocks.rematchClaimIds.flat()).toContain(FEATURED)); - }); - /** * GEO-2861 moved the landing tab to the opponent's positions, which left Explore's whole chain * to start from cold on the click that opens it: a paged catalog and two facets, and then @@ -1679,20 +1634,6 @@ describe('DebateRematchPageClient', () => { expect(screen.queryByText('No featured claims are available to debate yet.')).toBeNull(); }); - - it('says nothing is featured rather than nothing is debatable', async () => { - // Nothing carries the tag, so the source has nothing to show. It used to be expressed as a - // tagged claim with no entity behind it; the page carries its own now, so an untagged corpus - // is the honest way to say it. - mocks.featuredClaims = []; - mocks.entities = [sharedEntity()]; - render(); - await showFeatured(); - - await settleTabSwap(); - - expect(screen.getByText('No featured claims are available to debate yet.')).toBeInTheDocument(); - }); }); /** @@ -1722,7 +1663,7 @@ describe('DebateRematchPageClient', () => { viewerOnlyClaim(); render(); await showExplore(); - await chooseSource('My positions'); + await showMyPositions(); expect(await screen.findByText('Only mine')).toBeInTheDocument(); // A claim nobody has answered belongs to the corpus, not to this list. Waited for rather than @@ -1744,7 +1685,7 @@ describe('DebateRematchPageClient', () => { expect(mocks.entityIdLookups.flat()).not.toContain(VIEWER_ONLY); expect(mocks.rematchClaimIds.flat()).not.toContain(VIEWER_ONLY); - await chooseSource('My positions'); + await showMyPositions(); await waitFor(() => expect(mocks.entityIdLookups.flat()).toContain(VIEWER_ONLY)); await waitFor(() => expect(mocks.rematchClaimIds.flat()).toContain(VIEWER_ONLY)); @@ -1758,7 +1699,7 @@ describe('DebateRematchPageClient', () => { mocks.currentUserId = null; render(); await showExplore(); - await chooseSource('My positions'); + await showMyPositions(); expect(screen.queryByText('You haven’t taken a position on any claims yet.')).toBeNull(); }); @@ -1769,7 +1710,7 @@ describe('DebateRematchPageClient', () => { mocks.positions = [position('profile-remote', CLAIM_SHARED, SPACE_1, false)]; render(); await showExplore(); - await chooseSource('My positions'); + await showMyPositions(); expect(await screen.findByText('You haven’t taken a position on any claims yet.')).toBeInTheDocument(); @@ -1788,7 +1729,7 @@ describe('DebateRematchPageClient', () => { mocks.memberSpaceIds = new Set([SPACE_1.replace(/-/g, '')]); render(); await showExplore(); - await chooseSource('My positions'); + await showMyPositions(); // Seeded to the space the viewer belongs to, so their position in the other one is filtered // out — and clearing the filter brings it back. @@ -2327,7 +2268,9 @@ describe('DebateRematchPageClient', () => { mocks.recommendedLoading = true; const { rerender } = render(); - await chooseSource('All claims'); + // Explicitly, rather than leaning on where the picker happens to land: the subject here is what + // Explore does when a curated page arrives after the viewer is already looking at something. + await showAllClaims(); expect(screen.getByText('A newly published claim')).toBeInTheDocument(); mocks.recommendedLoading = false; @@ -3451,6 +3394,37 @@ describe('DebateRematchPageClient', () => { expect(header.compareDocumentPosition(tabs) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); + // GEO-2863 promoted the hub's "My positions" out of Explore's source menu and into a tab of its + // own; this does the same here, so the two surfaces name the same lists the same way. + it('gives the viewer’s own positions a tab rather than a source', async () => { + render(); + + const positions = screen.getByRole('button', { name: 'Positions' }); + expect(positions).toHaveAttribute('aria-pressed', 'false'); + + fireEvent.click(positions); + await settleTabSwap(); + + expect(screen.getByRole('button', { name: 'Positions' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'Explore' })).toHaveAttribute('aria-pressed', 'false'); + // And it is no longer reachable as a source, from Explore or anywhere else. + expect(screen.queryByRole('button', { name: 'My positions' })).toBeNull(); + }); + + // The tab draws the same list the source did — the viewer's own side of the lookup the opponent's + // tab reads — so the promotion is a move, not a rewrite. + it('lists the claims the viewer holds a side on under Positions', async () => { + mocks.positions = [ + position('profile-local', CLAIM_SHARED, SPACE_1, true), + position('profile-remote', CLAIM_MORE, SPACE_1, false), + ]; + render(); + await showMyPositions(); + + expect(await screen.findByText('A claim both participants chose')).toBeInTheDocument(); + expect(screen.queryByText('A newly published claim')).toBeNull(); + }); + // The hub panel's tab row, reused (GEO-2992): two surfaces doing the same job had two different // tab treatments. it('names the tabs Lobby and Explore, in the hub panel\u2019s styles', async () => { @@ -3499,35 +3473,6 @@ describe('DebateRematchPageClient', () => { expect(screen.getByText('A newly published claim')).toBeInTheDocument(); }); - /** - * Reported: flipping between Featured and All on first load left the list unchanged, and only - * started switching after a few goes. - * - * `useLastSettled` holds the last settled list while a new one loads, keyed on the session — which - * does not change when the source does. Before GEO-2771 that was safe, because Featured was the - * only tagged source and All came from a different variable entirely. Routing both through - * `taggedClaims` made the hold bridge two genuinely different lists: switch, and the previous - * source's rows stay up for as long as the new tag takes to fetch. Once both catalogs are cached - * the fetch is instant, which is why it comes right after a few switches. - */ - it('does not hold the previous source’s claims while the new tag loads', async () => { - const ONLY_FEATURED = '019fedc4-5555-7000-8000-000000000005'; - mocks.featuredClaims = [debateTag(ONLY_FEATURED, 'Only featured')]; - mocks.debateTagClaims = [debateTag()]; - mocks.entities = [sharedEntity(), publishedEntity(), publishedEntity(ONLY_FEATURED, 'Only featured')]; - - render(); - - await showExplore(); - await waitFor(() => expect(screen.getByText('A newly published claim')).toBeInTheDocument()); - - // Featured's tag has never been fetched, so switching to it starts a load. - mocks.featuredCatalogLoading = true; - await chooseSource('Featured'); - - await waitFor(() => expect(screen.queryByText('A newly published claim')).toBeNull()); - }); - // Every other entity lookup on this page is gated by the source that shows its rows. Ungated, this // one fanned out graph batches behind the opponent's tab and Recommended, which never list them. it('does not hydrate the saved claims on tabs that do not show them', async () => { @@ -4077,7 +4022,7 @@ function openSourceMenu() { const explore = screen.queryByRole('button', { name: 'Explore' }); if (explore && explore.getAttribute('aria-pressed') !== 'true') fireEvent.click(explore); - const label = (['Recommended', 'Featured', 'All claims', 'My positions'] as const).find( + const label = (['Recommended', 'All claims'] as const).find( name => screen.queryAllByRole('button', { name }).length > 0 ); fireEvent.click(screen.getAllByRole('button', { name: label! })[0]!); @@ -4109,16 +4054,21 @@ async function showRecommended() { await chooseSource('Recommended'); } -/** Explore opens on All claims since the two menus were aligned, so Featured is a pick away. */ -async function showFeatured() { +/** + * The picker opens on the opponent's positions (GEO-2861); most assertions want the browse index. + * + * One click now. Explore is a single list unless a curator has made a page for this pairing, which + * is the only thing left in its menu. + */ +async function showAllClaims() { fireEvent.click(screen.getByRole('button', { name: 'Explore' })); - await chooseSource('Featured'); + await settleTabSwap(); } -/** The picker opens on the opponent's positions (GEO-2861); most assertions want the browse index. */ -async function showAllClaims() { - fireEvent.click(screen.getByRole('button', { name: 'Explore' })); - await chooseSource('All claims'); +/** The viewer's own backlog, a tab of its own since it left Explore's menu. */ +async function showMyPositions() { + fireEvent.click(screen.getByRole('button', { name: 'Positions' })); + await settleTabSwap(); } /** The opponent's own responses, which are a tab of their own rather than a source of Explore. */ @@ -4464,7 +4414,7 @@ describe('Hide my positions', () => { it('is not offered on My positions', async () => { render(); await showExplore(); - await chooseSource('My positions'); + await showMyPositions(); expect(screen.queryByRole('switch', SWITCH)).toBeNull(); }); @@ -4629,7 +4579,7 @@ describe('the matches-only default', () => { mocks.positions = []; render(); - expect(await screen.findByRole('button', { name: 'All claims' })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: 'Explore' })).toHaveAttribute('aria-pressed', 'true'); }); }); diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx index 5d1b16aa9e..64f2d2f286 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx @@ -9,7 +9,6 @@ import { useAtom } from 'jotai'; import { useRouter } from 'next/navigation'; import { claimResponseKind } from '~/core/claims/response-kind'; -import { FEATURED_TAG_ID } from '~/core/constants'; import { type DebateClaimPositionSummary, type DebateRematchClaim, @@ -116,19 +115,27 @@ const NO_PARTICIPANTS: DebateRematchParticipant[] = []; * Related is not a way of browsing; it is the continuation of the debate that just happened, which is * why it is also where the pair land. */ -type PickerTab = 'related' | 'explore' | 'opponent'; +type PickerTab = 'related' | 'explore' | 'positions' | 'opponent'; /** * GEO-2683. Where Explore draws its list from. Recommended, All claims, Featured and the viewer's * own positions are four answers to one question — "which claims?" — so they belong in a menu * rather than in four tabs the viewer has to notice appearing and disappearing. */ -type ClaimsSource = 'recommended' | 'all' | 'featured' | 'mine'; +type ClaimsSource = 'recommended' | 'all' | 'mine'; -const CLAIMS_SOURCE_LABELS: Record = { +/** + * What Explore's menu can still offer. + * + * `mine` left it for a tab of its own, the way GEO-2863 promoted the hub's, and Featured left with + * it — a curated cut of the same tag behind a menu most viewers never opened, which is the reason + * the hub gives for dropping it there. What remains is the curator's page for this pairing, where + * one exists, and everything else; the menu draws itself only when both are on offer. + */ +type ExploreSource = Exclude; + +const CLAIMS_SOURCE_LABELS: Record = { recommended: 'Recommended', all: 'All claims', - featured: 'Featured', - mine: 'My positions', }; /** Stable identity so the hydration below doesn't restart whenever Featured isn't the source. */ @@ -196,7 +203,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { // Left unset until the viewer picks one: Recommended is the best default when a curator has put // something together for this pairing, and it doesn't exist otherwise. Deciding in state would // fix the default before that lookup settles. - const [chosenSource, setChosenSource] = React.useState(null); + const [chosenSource, setChosenSource] = React.useState(null); // The opponent's positions is where this opens (GEO-2861). A returning pair are here *because* // they just debated each other, so a general catalogue is not the first thing they came for — @@ -356,6 +363,9 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { /** Whether the viewer is in the browse tab, which is the only way to be in it. */ const browsing = chosenForSession === 'explore'; + /** Likewise for their own positions, which is now a tab rather than a source inside that one. */ + const viewingPositions = chosenForSession === 'positions'; + // GEO-2683. Fetched only when Featured is the source on screen — it is one option in a menu, and // the other two answer for themselves. // @@ -389,7 +399,9 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { * later. */ const chosenRecommendedIsGone = chosenSource === 'recommended' && !recommendedLoading && !hasRecommended; - const source: ClaimsSource = chosenRecommendedIsGone ? 'all' : (chosenSource ?? 'all'); + // The tab answers first: Positions is its own now, so the menu below only ever chooses between + // the curator's page and everything else. + const source: ClaimsSource = viewingPositions ? 'mine' : chosenRecommendedIsGone ? 'all' : (chosenSource ?? 'all'); /** * "My positions": the viewer's own side of the lookup the opponent's tab reads. @@ -399,7 +411,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { * on screen. The picker lands on the opponent's positions, and a returning pair should not wait * behind a lookup for a list nobody has asked for. Same shape as `taggedEnabled` below. */ - const viewerSourced = browsing && source === 'mine'; + const viewerSourced = viewingPositions; const viewerClaimIds = React.useMemo( () => (viewerSourced ? claimIdsAnsweredBy(positions.byClaim, localParticipant?.profile_space_id ?? null) : []), [localParticipant, positions.byClaim, viewerSourced] @@ -430,7 +442,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { // Explore pays for is a page and two counts, not a corpus. // // The effect that ends it is below `taggedClaimsQuery`, which is the last hop it waits for. - const claimsTagId = source === 'featured' ? FEATURED_TAG_ID : DEBATE_TAG_ID; + const claimsTagId = DEBATE_TAG_ID; // Kept with the session it was spent on, the way `useCurrentGeoChatUserId` keeps its id with the // account. The route reuses this component when it moves between rematches — `useLastSettled` // takes `sessionId` as its reset key for the same reason — so a bare boolean would say "already @@ -438,7 +450,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { // would open Explore cold. The rows lookup is keyed on the session; the warm-up has to be too. const [warmedSessionId, setWarmedSessionId] = React.useState(null); const browseWarmed = warmedSessionId === sessionId; - const taggedEnabled = (browsing || !browseWarmed) && (source === 'featured' || source === 'all'); + const taggedEnabled = (browsing || !browseWarmed) && source === 'all'; // What goes to the server, so the page and both facet menus describe the same set of spaces. // // Two of the three gates can be sent; one cannot. The viewer's allowlist and the acceptor's @@ -1300,18 +1312,30 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { // a source that appears doesn't reshuffle the ones already in the menu. The rest are in the hub's // order — All claims, Featured, My positions — so the same menu means the same thing on both // surfaces. - const sourceOptions = React.useMemo[]>( + /** + * `hasRecommended` alone would drop the option — and with it the whole menu — for as long as the + * next pairing's lookup is out, then put it back. Holding it while it is the source on screen is + * the same rule `chosenRecommendedIsGone` applies to the selection: let go once the answer is in, + * not while it is in flight. + */ + const offersRecommended = hasRecommended || source === 'recommended'; + const sourceOptions = React.useMemo[]>( () => - (hasRecommended - ? (['recommended', 'all', 'featured', 'mine'] as const) - : (['all', 'featured', 'mine'] as const) - ).map(value => ({ + (offersRecommended ? (['recommended', 'all'] as const) : (['all'] as const)).map(value => ({ value, label: CLAIMS_SOURCE_LABELS[value], })), - [hasRecommended] + [offersRecommended] ); + /** + * A menu of one is not a choice. + * + * Without a curated page for this pairing there is nothing to pick between, and Explore reads as + * the hub's does: a search box and the two facet menus over one list. + */ + const offersSourceMenu = sourceOptions.length > 1; + const claims = tab === 'opponent' ? opponentClaims @@ -1327,7 +1351,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { taggedClaims; // Whether the list on screen was narrowed by its own query. Only the tagged sources are. - const graphFiltered = tab === 'explore' && (source === 'featured' || source === 'all'); + const graphFiltered = tab === 'explore' && source === 'all'; // Only the tagged sources are narrowed by their query. The opponent's tab, Recommended and My // positions are lists fetched by id, so nothing narrowed them on the way in and the filters below @@ -1710,11 +1734,13 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { positions.isLoading || opponentClaimsSettling : tab === 'related' ? relatedClaimsSettling - : source === 'recommended' - ? recommendedLoading || curatedClaimsQuery.isLoading - : source === 'mine' - ? viewerClaimsSettling - : taggedClaimsSettling); + : tab === 'positions' + ? viewerClaimsSettling + : source === 'recommended' + ? recommendedLoading || curatedClaimsQuery.isLoading + : source === 'mine' + ? viewerClaimsSettling + : taggedClaimsSettling); // The menu, and the handlers that drive it. Defaults to the spaces the viewer belongs to // (GEO-2789). @@ -1774,7 +1800,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { : source === 'mine' ? // The same two lookups the opponent's tab is built from, asked about the viewer. (positions.error ?? viewerEntitiesQuery.error) - : source === 'featured' || source === 'all' + : source === 'all' ? // The page is the list, and it carries everything a row is built from — so its failure // is the only one that leaves nothing to show. geo-chat's row lookup is metadata beside // it: losing it costs the faces and the readiness, not the claims, and blanking the tab @@ -1877,7 +1903,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { ? opponentClaimsQuery.isLoading || Boolean(opponentClaimsQuery.error) : source === 'mine' ? viewerClaimsQuery.isLoading || Boolean(viewerClaimsQuery.error) - : source === 'featured' || source === 'all' + : source === 'all' ? taggedClaimsQuery.isLoading || Boolean(taggedClaimsQuery.error) : curatedClaimsQuery.isLoading || Boolean(curatedClaimsQuery.error), }); @@ -2084,6 +2110,12 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { setTab('explore')}> Explore + {/* Promoted out of Explore's source menu, the way GEO-2863 promoted the hub's. It is + the viewer's own backlog rather than a way of browsing, which is the same reason + the hub gives for it being a tab rather than an option inside one. */} + setTab('positions')}> + Positions +
{/* Outside the scroll container so the rule spans the visible row rather than the scrollable width, and `z-0` so the active tab's marker paints over it rather than @@ -2148,16 +2180,18 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { // is gated on this tab, so on Related the switch drew a control that could not // change a single row under it. Never on "My positions" either, which is the list // it would empty. - tab === 'explore' && source !== 'mine' ? ( + tab === 'explore' ? ( ) : null } leading={ - tab === 'explore' ? ( + // Only where a curator has made a page for this pairing. Without one there is a + // single option, and a menu of one is a control that cannot do anything. + tab === 'explore' && offersSourceMenu ? ( ) : null @@ -2228,9 +2262,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { ? `Nothing recommended for you and ${remoteName} yet.` : source === 'mine' ? 'You haven’t taken a position on any claims yet.' - : source === 'featured' - ? 'No featured claims are available to debate yet.' - : 'No other eligible claims are available yet.' + : 'No other eligible claims are available yet.' } // Four dead ends, and each has a different way out. Ordered by how much the viewer has // to give up: clearing their filters, then dropping the toggle, then leaving the tab or @@ -2260,9 +2292,9 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { // resolve, and the catalogue next door is the whole of the way out of it. { label: 'Explore claims', onClick: () => setTab('explore') } : source === 'mine' - ? // The same dead end one level down: a viewer who has answered nothing cannot - // fill this list from here, and the whole corpus is one pick away. - { label: 'Show all claims', onClick: () => setChosenSource('all') } + ? // The same dead end one tab over: a viewer who has answered nothing + // cannot fill this list from here, and the whole corpus is next door. + { label: 'Show all claims', onClick: () => setTab('explore') } : undefined } > From 465dbef040e9af1f4a42969efb10d374658379b8 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 13:38:00 -0700 Subject: [PATCH 06/11] feat(debates): count the viewer's positions on their tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../[sessionId]/rematch-page-client.test.tsx | 33 +++++++++----- .../[sessionId]/rematch-page-client.tsx | 43 +++++++++++++++---- 2 files changed, 57 insertions(+), 19 deletions(-) diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx index add0317de4..be47f7a4a9 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx @@ -1677,18 +1677,31 @@ describe('DebateRematchPageClient', () => { * entities behind them and geo-chat's rows for those are not, and the picker lands somewhere * else. A returning pair should not wait behind a lookup for a list nobody has asked for. */ - it('asks for nothing until the source is on screen', async () => { + // It used to wait for the tab to be opened. The tab carries a count now, and the count is of + // the rows the tab will list — which `participantClaimRows` filters — so the lookup behind them + // has to have run before the viewer gets there. A number that waits is a number that reads `0` + // for a viewer with a backlog, which is what GEO-2656 took out of the other tab. + it('looks the viewer’s own claims up with the opponent’s, so the tab can count them', async () => { viewerOnlyClaim(); + // A position on the claim this session excludes — the one the pair just debated. It is one of + // the viewer's answered ids and never one of the tab's rows, which is exactly the difference + // between counting ids and counting the list. + mocks.positions = [...mocks.positions, position('profile-local', CLAIM_SOURCE, SPACE_1, true)]; render(); - await showExplore(); - - expect(mocks.entityIdLookups.flat()).not.toContain(VIEWER_ONLY); - expect(mocks.rematchClaimIds.flat()).not.toContain(VIEWER_ONLY); - - await showMyPositions(); await waitFor(() => expect(mocks.entityIdLookups.flat()).toContain(VIEWER_ONLY)); await waitFor(() => expect(mocks.rematchClaimIds.flat()).toContain(VIEWER_ONLY)); + + // And the number is on the tab before it is opened, and is of the rows the tab then lists — + // not of the ids behind them, which `participantClaimRows` filters on the way to becoming + // rows. + const badge = ( + screen.getByRole('button', { name: /^Positions/ }).textContent?.replace('Positions', '') ?? '' + ).trim(); + expect(badge).toMatch(/^\d+$/); + + await showMyPositions(); + expect(screen.getAllByRole('article')).toHaveLength(Number(badge)); }); // The same window the opponent's tab waits out, on the other side of it: the ids here are the @@ -3399,13 +3412,13 @@ describe('DebateRematchPageClient', () => { it('gives the viewer’s own positions a tab rather than a source', async () => { render(); - const positions = screen.getByRole('button', { name: 'Positions' }); + const positions = screen.getByRole('button', { name: /^Positions/ }); expect(positions).toHaveAttribute('aria-pressed', 'false'); fireEvent.click(positions); await settleTabSwap(); - expect(screen.getByRole('button', { name: 'Positions' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: /^Positions/ })).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByRole('button', { name: 'Explore' })).toHaveAttribute('aria-pressed', 'false'); // And it is no longer reachable as a source, from Explore or anywhere else. expect(screen.queryByRole('button', { name: 'My positions' })).toBeNull(); @@ -4067,7 +4080,7 @@ async function showAllClaims() { /** The viewer's own backlog, a tab of its own since it left Explore's menu. */ async function showMyPositions() { - fireEvent.click(screen.getByRole('button', { name: 'Positions' })); + fireEvent.click(screen.getByRole('button', { name: /^Positions/ })); await settleTabSwap(); } diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx index 64f2d2f286..f28cddf8e1 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx @@ -404,17 +404,19 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { const source: ClaimsSource = viewingPositions ? 'mine' : chosenRecommendedIsGone ? 'all' : (chosenSource ?? 'all'); /** - * "My positions": the viewer's own side of the lookup the opponent's tab reads. - * - * `positions` already covers both debaters, so the ids are free — what this costs is the claim - * entities behind them and geo-chat's rows for those, and it spends that only once the source is - * on screen. The picker lands on the opponent's positions, and a returning pair should not wait - * behind a lookup for a list nobody has asked for. Same shape as `taggedEnabled` below. + * "Positions": the viewer's own side of the lookup the opponent's tab reads. + * + * `positions` already covers both debaters, so the ids are free. The entities behind them and + * geo-chat's rows for those used to wait until the tab was opened — a lookup for a list nobody + * had asked for. The tab carries a count now, and a count has to be of the rows the tab will + * actually list: `participantClaimRows` drops claims this session has already ruled out and + * claims in spaces that cannot carry a published debate, so a number taken from `positions` + * alone would sit above the list it describes. That is the same confident-and-wrong badge + * GEO-2656 took out of the opponent's tab, so the lookup runs with the opponent's instead. */ - const viewerSourced = viewingPositions; const viewerClaimIds = React.useMemo( - () => (viewerSourced ? claimIdsAnsweredBy(positions.byClaim, localParticipant?.profile_space_id ?? null) : []), - [localParticipant, positions.byClaim, viewerSourced] + () => claimIdsAnsweredBy(positions.byClaim, localParticipant?.profile_space_id ?? null), + [localParticipant, positions.byClaim] ); const viewerEntitiesQuery = useClaimEntitiesByIds(viewerClaimIds); // Both graph-sourced options, one pipeline (GEO-2771). @@ -1280,6 +1282,12 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { [opponentClaims, opponentPositionOf] ); + /** The viewer's own, counted off the same list the tab draws — see `opponentPositionCount`. */ + const viewerPositionCount = React.useMemo( + () => viewerClaims.filter(claim => viewerPositionOf(claim) !== null).length, + [viewerClaims, viewerPositionOf] + ); + /** * GEO-2656. The badge drew `0` from the very first paint, because the count is derived from a * list that is empty until three dependent round trips land — positions, then the claim @@ -1308,6 +1316,11 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { const opponentCountPending = opponentClaims.length === 0 && (sessionQuery.isLoading || positions.isLoading || opponentClaimsSettling); + // The same rule for the same reason: `0` is a claim about the viewer's own backlog, and it is + // wrong for as long as the chain behind it is still running. + const viewerCountPending = + viewerClaims.length === 0 && (sessionQuery.isLoading || positions.isLoading || viewerClaimsSettling); + // Recommended is offered only when a curator has a page for this pairing; the order is fixed, so // a source that appears doesn't reshuffle the ones already in the menu. The rest are in the hub's // order — All claims, Featured, My positions — so the same menu means the same thing on both @@ -2115,6 +2128,18 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { the hub gives for it being a tab rather than an option inside one. */} setTab('positions')}> Positions + + {viewerCountPending ? ( + + ) : ( + viewerPositionCount + )} + {/* Outside the scroll container so the rule spans the visible row rather than the From 6b849e65c15643637d6cabf026598034e6a88ca8 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 15:13:33 -0700 Subject: [PATCH 07/11] fix(debates): stop the exit path raising what it was meant to hold still MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../[sessionId]/rematch-page-client.test.tsx | 33 ++++++++++++++- .../[sessionId]/rematch-page-client.tsx | 6 ++- .../[sessionId]/rematch-request-card.tsx | 18 ++++++-- .../[sessionId]/rematch-voice.test.tsx | 41 +++++++++++++++++++ .../rematches/[sessionId]/rematch-voice.tsx | 34 +++++++++++---- 5 files changed, 119 insertions(+), 13 deletions(-) diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx index be47f7a4a9..648d9feba4 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx @@ -1144,7 +1144,7 @@ describe('DebateRematchPageClient', () => { response_kind: 'stance', turn_format_id: 'standard', created_at: '2026-07-10T10:00:00.000Z', - expires_at: '2026-07-10T10:02:00.000Z', + expires_at: new Date(Date.now() + 5 * 60_000).toISOString(), }, }); @@ -1169,6 +1169,37 @@ describe('DebateRematchPageClient', () => { expect(screen.queryByRole('dialog')).toBeNull(); }); + // The hub never draws an expired request — `useUnexpiredRequests` filters them out before its card + // sees one. This card is drawn from the session, which says `request_pending` until geo-chat's + // next answer, so the lapse is reachable here and "Awaiting response" through it would be waiting + // on something that is over. + it('says so when the request the viewer sent has lapsed', async () => { + mocks.session = session({ + status: 'request_pending', + request: { + id: 'request-1', + status: 'pending', + claim: claimSummary(CLAIM_SHARED, 'A claim both participants chose'), + requester_user_id: 'user-local', + recipient_user_id: 'user-remote', + requester_position: true, + requester_position_label: 'Agree', + recipient_position: false, + recipient_position_label: 'Disagree', + response_kind: 'stance', + turn_format_id: 'standard', + created_at: '2026-07-10T10:00:00.000Z', + expires_at: new Date(Date.now() - 60_000).toISOString(), + }, + }); + + render(); + + const card = await screen.findByTestId('rematch-outbound-request'); + expect(within(card).getByText('This request has expired.')).toBeInTheDocument(); + expect(within(card).queryByText('Awaiting response')).toBeNull(); + }); + it('shows authoritative stance labels in the incoming request dialog and preserves rematch actions', async () => { mocks.session = session({ status: 'request_pending', diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx index f28cddf8e1..4db042994b 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx @@ -1319,7 +1319,11 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { // The same rule for the same reason: `0` is a claim about the viewer's own backlog, and it is // wrong for as long as the chain behind it is still running. const viewerCountPending = - viewerClaims.length === 0 && (sessionQuery.isLoading || positions.isLoading || viewerClaimsSettling); + viewerClaims.length === 0 && + // `publishabilityPending` as well, which the opponent's does not need as badly: a debater's own + // responses often live in their personal space, and `canPublishDebateIn` fails open until the + // space types land — so the number would settle high and then drop as they resolve. + (sessionQuery.isLoading || positions.isLoading || viewerClaimsSettling || publishabilityPending); // Recommended is offered only when a curator has a page for this pairing; the order is fixed, so // a source that appears doesn't reshuffle the ones already in the menu. The rest are in the hub's diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-request-card.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-request-card.tsx index ade4c99324..8940315a6e 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-request-card.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-request-card.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; +import cx from 'classnames'; import { motion } from 'framer-motion'; import type { DebateRematchParticipant, DebateRematchRequest } from '~/core/debates/api'; @@ -64,9 +65,14 @@ export function RematchRequestCard({ {/* The clock is the only thing on this card that changes, and it is the reason to look at it twice: a request lapses on its own whether or not anyone is watching. */} - + @@ -76,7 +82,13 @@ export function RematchRequestCard({ - Awaiting response + {/* The hub never draws an expired request — `useUnexpiredRequests` filters them out before its + card sees one. Here the card is drawn from the session, which says `request_pending` until + geo-chat's next answer, so the lapse is reachable and saying "Awaiting response" through it + would be waiting on something that is over. */} + + {countdown.expired ? 'This request has expired.' : 'Awaiting response'} + ); } diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx index c245996ac9..a6c950e4af 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx @@ -1833,6 +1833,47 @@ describe('RematchVoiceHeader', () => { expect(screen.getByTestId('rematch-unmute-notice')).toBeInTheDocument(); }); + // Keeping the notice through an exit is not the same as raising one there. A pair from a recorded + // debate join unmuted and never see it; leaving drops the room, which takes `isMicrophoneEnabled` + // with it — so it used to appear for the first time on the way out, growing the header at the one + // moment `exiting` exists to hold it still. + it('does not raise the unmute notice on the way out', async () => { + mocks.remoteParticipants = [remoteOpponent()]; + const carriedOver = { ...makeSession('browsing'), source_debate_id: 'debate-1' }; + const { rerender } = render(); + await flushOwnership(); + // Unmuted, so it has never been raised. + expect(screen.queryByTestId('rematch-unmute-notice')).toBeNull(); + + // Leaving: the session ends and the room goes with it. + mocks.isMicrophoneEnabled = false; + mocks.connectionState = 'disconnected'; + rerender( + + ); + + expect(screen.queryByTestId('rematch-unmute-notice')).toBeNull(); + }); + + // Suppressing the dead-room message on the way out must not suppress the rest of the ladder: a + // room that never connected would otherwise be drawn with live mute controls over nothing. + it('keeps saying a room is reconnecting even while leaving', async () => { + mocks.remoteParticipants = [remoteOpponent()]; + const session = makeSession('browsing'); + const { rerender } = render(); + await flushOwnership(); + + mocks.connectionState = 'reconnecting'; + rerender(); + + expect(screen.getByText('Reconnecting…')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^(Mute|Unmute) microphone$/ })).toBeNull(); + }); + // Leaving ends the session, and an ended session is not voice-capable — so the controls used to // tear themselves down in the second before the redirect landed, collapsing the card in front of // someone who had already left. diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx index 4984dbed3f..8528b7dddb 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx @@ -733,12 +733,15 @@ function VoiceHeaderBody({ * opponent may well be talking, and the viewer simply cannot hear it until they click. */ const connectionMessage = ((): Extract | null => { - // Not while leaving. Ending the session can drop the room within the second it takes the - // redirect to land, and swapping the controls for "Voice disconnected · Retry" on the way out - // is both a layout shift and an offer of something the viewer cannot want. - if (exiting) return null; + // Ending the session can drop a connected room within the second it takes the redirect to + // land, and swapping its controls for "Voice disconnected · Retry" on the way out is both a + // layout shift and an offer of something the viewer has just declined. Only that branch: a room + // that is still connecting, or reconnecting, has to keep saying so — leaving would otherwise + // put live mute controls over a room that has no connection at all. if (connectionState === ConnectionState.Disconnected && everConnected) { - return { kind: 'message', message: 'Voice disconnected', actionLabel: 'Retry', onAction: onRetry }; + return exiting + ? null + : { kind: 'message', message: 'Voice disconnected', actionLabel: 'Retry', onAction: onRetry }; } if (connectionState !== ConnectionState.Connected) { const reconnecting = @@ -789,10 +792,25 @@ function VoiceHeaderBody({ // Only while muted, only once there has been somebody to talk to, and only until the viewer has // answered it once. + const noticeApplies = muted && !micFailed && !noticeDismissed && opponentEverJoined && !roomGone; + + /** + * Keeping it through the exit is not the same as raising it there. + * + * A pair who arrived from a recorded debate join unmuted and never see this. Leaving drops the + * room, which takes `isMicrophoneEnabled` with it — so without this the notice appears for the + * first time on the way out, growing the header at the one moment this whole flag exists to hold + * it still. + */ + const [noticeWasShown, setNoticeWasShown] = React.useState(false); + React.useEffect(() => { + // `!exiting` matters as much as the condition itself: without it the latch is set by the very + // render it exists to suppress, and the notice arrives anyway one render later. + if (noticeApplies && !exiting) setNoticeWasShown(true); + }, [exiting, noticeApplies]); + const notice = - muted && !micFailed && !noticeDismissed && opponentEverJoined && !roomGone - ? { onUnmute: unmute, onDismiss: onDismissNotice } - : null; + noticeApplies && (!exiting || noticeWasShown) ? { onUnmute: unmute, onDismiss: onDismissNotice } : null; const toast: PairHeaderToast | null = !connectionMessage && nudgeVisible ? { kind: 'opponent-talking', onUnmute: unmute, onDismiss: dismissNudge } : null; From 4da48d3e7ca1c04f0708c2f0b0a32be221098ff8 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 16:05:14 -0700 Subject: [PATCH 08/11] fix(debates): hold the header's shape without holding the room open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../[sessionId]/rematch-voice.test.tsx | 42 ++++++ .../rematches/[sessionId]/rematch-voice.tsx | 135 ++++++++++++------ 2 files changed, 137 insertions(+), 40 deletions(-) diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx index a6c950e4af..6ba1405b45 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx @@ -1800,6 +1800,48 @@ describe('RematchVoiceHeader', () => { expect(screen.getByText('Salina')).toBeInTheDocument(); }); + // The status is not the room. A rematch that ended while this page was still working through + // ownership and the token answers `browsing` for a render or two longer — React Query serves the + // stale one — so latching "voice was live" off the status latched it where nothing was live. With + // `exiting`, holding that open is not holding anything: it is taking the tab lock, minting a + // token and publishing the microphone into a session the viewer has already left. + it('does not arm voice for a status that went stale before the room came up', async () => { + const carriedOver = { ...makeSession('browsing'), source_debate_id: 'debate-1' }; + // No `flushOwnership` between the two: the point is that the exit lands while the ladder is + // still several awaits from a room. + const { container, rerender } = render(); + + rerender( + + ); + await flushOwnership(); + + expect(container.querySelector('[data-testid="livekit-room"]')).toBeNull(); + expect(mocks.joinCalls.every(call => !call.enabled)).toBe(true); + expect(mocks.getUserMedia).not.toHaveBeenCalled(); + }); + + // Letting that room go must not take the header's shape with it. The connection is held open for + // a room that was up; the shape is held whatever was drawn — here the one line the room itself + // draws while it connects, which is what was on screen a moment before the click. + it('keeps the connecting line when it leaves before the room connected', async () => { + mocks.connectionState = 'connecting'; + const session = makeSession('browsing'); + const { container, rerender } = render(); + await flushOwnership(); + expect(screen.getByTestId('livekit-room')).toBeInTheDocument(); + expect(screen.getByText('Connecting voice…')).toBeInTheDocument(); + + rerender(); + + expect(screen.getByText('Connecting voice…')).toBeInTheDocument(); + expect(container.querySelector('[data-testid="livekit-room"]')).toBeNull(); + }); + // 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 — so here the // notice gives way to the card's own "Voice disconnected · Retry", which is the actionable thing. diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx index 8528b7dddb..512ee0cc40 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx @@ -63,6 +63,13 @@ const NUDGE_MS = 10_000; type OwnershipState = 'pending' | 'owned' | 'elsewhere'; +/** + * Said from three places — before the tab lock, before the token, and from inside a room that has + * mounted but not connected — because to the viewer they are one wait. Shared so that the exit + * path, which stands in for the third, cannot drift from what it is standing in for. + */ +const CONNECTING_VOICE = { kind: 'message', message: 'Connecting voice…' } as const satisfies PairHeaderVoice; + function voiceCapable(status: DebateRematchSession['status']) { return status === 'browsing' || status === 'request_pending'; } @@ -246,24 +253,45 @@ export function RematchVoiceHeader(props: RematchVoiceHeaderProps) { function SessionRematchVoiceHeader({ session, currentUserId, leaveAction, exiting = false }: RematchVoiceHeaderProps) { const voiceCapableNow = voiceCapable(session.status); + /** - * Whether voice has been live at all this visit. + * Whether a room has actually come up this visit. Latched by `onConnected` and never released — + * a retry is still a room that was live a moment ago. * - * `exiting` alone is not enough to hold the room open, because it is also true from the first - * render of a rematch that was already over when the link was opened — and "keep what is there" - * would become "start a room, take the tab lock 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 is a live microphone rather than a wasted request. + * The session's own status is the wrong thing to latch on, even though it is the thing that + * turns voice on. It reads voice-capable on the first render of a rematch that was already over + * when the link was opened — React Query serves the stale answer, and the ladder below is + * several awaits deep in ownership and token before anything connects — so a status latch is + * set in states where there is nothing up to keep. Paired with `exiting`, “hold what is there” + * then becomes “take the tab lock, mint a token and publish a microphone” into a session the + * viewer has just left. A debate-sourced rematch joins unmuted, so that is a live microphone + * going out after they are gone rather than a wasted request. */ - const [voiceWasActive, setVoiceWasActive] = React.useState(false); - React.useEffect(() => { - if (voiceCapableNow) setVoiceWasActive(true); - }, [voiceCapableNow]); + const [roomWasLive, setRoomWasLive] = React.useState(false); + + /** + * Sticky on the way out, but only over a room that was already up. + * + * This decides whether to keep *connecting*, which is a different question from what the header + * draws — `heldVoiceRef` below holds that still on its own. So the connection outlives the exit + * only where there is a connection to outlive. + */ + const voiceActive = voiceCapableNow || (exiting && roomWasLive); - // Sticky on the way out, but only over a room that was already up: the connection and its - // controls last until the page unmounts, which is what stops the card resizing between the click - // and the redirect. - const voiceActive = voiceCapableNow || (exiting && voiceWasActive); + /** + * The shape the header had before the exit began, `'room'` meaning the live tree itself. + * + * `exiting` freezes what is drawn so the card cannot re-lay-out between the click and the + * redirect — but freezing it by re-deriving it would mean keeping the connection open just to + * keep the derivation true, which is how the microphone got out. Holding the last shape instead + * separates the two: the room goes when it was never up, and the header still draws what it was + * drawing a moment ago. `null` is “nothing drawn yet”, which is a session already over on load. + * + * Written during render because the exit render is the one that needs it, and an effect runs a + * beat too late. The write is derived from the same props each time, so a double invocation + * under StrictMode stores the same value twice. + */ + const heldVoiceRef = React.useRef(null); const opponent = session.participants.find(participant => participant.user_id !== currentUserId) ?? null; const local = session.participants.find(participant => participant.user_id === currentUserId) ?? null; @@ -384,6 +412,11 @@ function SessionRematchVoiceHeader({ session, currentUserId, leaveAction, exitin connectedRef.current = true; setConnectFailed(false); }, []); + + // Latched from inside the room, off the same connection state `everConnected` reads, so "a room + // was up" means one thing on both sides of the boundary. `` fires for a + // first connect only; this also covers a room that came back. + const handleRoomLive = React.useCallback(() => setRoomWasLive(true), []); const handleError = React.useCallback(() => { if (connectedRef.current) return; setConnectFailed(true); @@ -455,37 +488,54 @@ function SessionRematchVoiceHeader({ session, currentUserId, leaveAction, exitin const headerWith = (voice: PairHeaderVoice) => ; - if (!voiceActive) return headerWith({ kind: 'absent' }); + /** Everything the header can say before there is a room to say it from. `null` hands over. */ + const preRoomVoice = ((): PairHeaderVoice | null => { + if (!voiceActive) return { kind: 'absent' }; - if (ownership === 'elsewhere') { - return headerWith({ - kind: 'message', - message: 'Voice is active in another tab', - actionLabel: 'Use voice here', - onAction: takeOver, - }); - } + if (ownership === 'elsewhere') { + return { + kind: 'message', + message: 'Voice is active in another tab', + actionLabel: 'Use voice here', + onAction: takeOver, + }; + } - if (ownership === 'pending' || join.isLoading) return headerWith({ kind: 'message', message: 'Connecting voice…' }); + if (ownership === 'pending' || join.isLoading) return CONNECTING_VOICE; - if (join.error) { - // No backend support: LiveKit unconfigured (503) or the endpoint not deployed yet (404). The - // picker works exactly as before voice existed. A blocked state (400/403) likewise has no - // user-facing remedy here. - if (join.error instanceof GeoChatRequestError && [400, 403, 404].includes(join.error.status)) { - return headerWith({ kind: 'absent' }); + if (join.error) { + // No backend support: LiveKit unconfigured (503) or the endpoint not deployed yet (404). The + // picker works exactly as before voice existed. A blocked state (400/403) likewise has no + // user-facing remedy here. + if (join.error instanceof GeoChatRequestError && [400, 403, 404].includes(join.error.status)) { + return { kind: 'absent' }; + } + if (join.error instanceof GeoChatRequestError && join.error.code === 'livekit_not_configured') { + return { kind: 'absent' }; + } + return { kind: 'message', message: 'Voice is unavailable', actionLabel: 'Retry', onAction: retry }; } - if (join.error instanceof GeoChatRequestError && join.error.code === 'livekit_not_configured') { - return headerWith({ kind: 'absent' }); + + if (!join.data) return CONNECTING_VOICE; + + if (connectFailed) { + return { kind: 'message', message: 'Voice is unavailable', actionLabel: 'Retry', onAction: retry }; } - return headerWith({ kind: 'message', message: 'Voice is unavailable', actionLabel: 'Retry', onAction: retry }); - } - if (!join.data) return headerWith({ kind: 'message', message: 'Connecting voice…' }); + return null; + })(); - if (connectFailed) { - return headerWith({ kind: 'message', message: 'Voice is unavailable', actionLabel: 'Retry', onAction: retry }); - } + if (!exiting) heldVoiceRef.current = preRoomVoice ?? 'room'; + + const held = heldVoiceRef.current; + const voice = exiting ? (held === 'room' ? null : (held ?? { kind: 'absent' })) : preRoomVoice; + + if (voice) return headerWith(voice); + + // Only reachable on the way out, and only where the room was mounted but had not connected when + // the exit began: there is nothing to hold open, so the header keeps the one line the room was + // drawing from inside rather than collapsing to nothing. + if (!voiceActive || !join.data) return headerWith(CONNECTING_VOICE); return ( ; function VoiceHeaderBody({ pair, exiting, + onRoomLive, opponentUserId, micFailure, onMicIntentChange, @@ -590,6 +642,8 @@ function VoiceHeaderBody({ }: { pair: PairContext; exiting: boolean; + /** Tells the header above that a room has come up — what makes holding one open on exit legal. */ + onRoomLive: () => void; opponentUserId: string; micFailure: MediaDeviceFailure | null; onMicIntentChange: (enabled: boolean) => void; @@ -633,9 +687,10 @@ function VoiceHeaderBody({ React.useEffect(() => { if (connectionState === ConnectionState.Connected) { setEverConnected(true); + onRoomLive(); analytics.recordJoined(); } - }, [analytics, connectionState]); + }, [analytics, connectionState, onRoomLive]); const remoteParticipants = useRemoteParticipants(); const opponentParticipant = remoteParticipants.find(participant => participant.identity === opponentUserId) ?? null; @@ -746,7 +801,7 @@ function VoiceHeaderBody({ if (connectionState !== ConnectionState.Connected) { const reconnecting = connectionState === ConnectionState.Reconnecting || connectionState === ConnectionState.SignalReconnecting; - return { kind: 'message', message: reconnecting ? 'Reconnecting…' : 'Connecting voice…' }; + return reconnecting ? { kind: 'message', message: 'Reconnecting…' } : CONNECTING_VOICE; } if (!canPlayAudio) { return { From 420c95052d553b43b1462a84c8149d68868c303f Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 16:21:38 -0700 Subject: [PATCH 09/11] fix(debates): stop reading the session status once the exit begins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../[sessionId]/rematch-page-client.test.tsx | 25 +++++++++++++++++++ .../[sessionId]/rematch-page-client.tsx | 21 ++++++++++------ .../[sessionId]/rematch-voice.test.tsx | 21 ++++++++++++++++ .../rematches/[sessionId]/rematch-voice.tsx | 8 +++++- 4 files changed, 67 insertions(+), 8 deletions(-) diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx index 648d9feba4..2be9fdb701 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx @@ -1735,6 +1735,31 @@ describe('DebateRematchPageClient', () => { expect(screen.getAllByRole('article')).toHaveLength(Number(badge)); }); + /** + * And it keeps counting them while the space types are still out. + * + * An unresolved type reads as publishable on purpose, so a slow lookup cannot empty a list — + * which means the rows are on screen during that window and the number narrows with them as + * the types land. Holding the badge back through it would put a skeleton on the tab over a + * list already showing rows: the count and the thing it counts have to agree, and agreeing + * with the list is the whole contract of the test above. + */ + it('keeps counting the rows it draws while the space types are unresolved', async () => { + viewerOnlyClaim(); + mocks.spacesHeldOver = true; + render(); + + await waitFor(() => expect(mocks.rematchClaimIds.flat()).toContain(VIEWER_ONLY)); + + const badge = ( + screen.getByRole('button', { name: /^Positions/ }).textContent?.replace('Positions', '') ?? '' + ).trim(); + expect(badge).toMatch(/^\d+$/); + + await showMyPositions(); + expect(screen.getAllByRole('article')).toHaveLength(Number(badge)); + }); + // The same window the opponent's tab waits out, on the other side of it: the ids here are the // viewer's own, so until their geo-chat id lands there are none, and "you haven't taken a // position" is a statement about them made before anyone knew who they were. diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx index 4db042994b..eadb993c49 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx @@ -1316,14 +1316,21 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { const opponentCountPending = opponentClaims.length === 0 && (sessionQuery.isLoading || positions.isLoading || opponentClaimsSettling); - // The same rule for the same reason: `0` is a claim about the viewer's own backlog, and it is - // wrong for as long as the chain behind it is still running. + /** + * The same rule for the same reason: `0` is a claim about the viewer's own backlog, and it is + * wrong for as long as the chain behind it is still running. + * + * Not `publishabilityPending`, though it is tempting here — a debater's own responses often live + * in their personal space, `canPublishDebateIn` fails open until the space types land, and the + * number can therefore settle high and narrow as they resolve. It narrows because *the list + * narrows*: fail-open is what keeps a slow lookup from emptying the tab, and this number is of + * the rows the tab draws. Holding it back through that window would put a skeleton on the tab + * over a list already showing rows, which is a worse thing to be told than a count that follows + * what is under it. (Behind the `length === 0` guard the term did nothing either way: fail-open + * means the provisional rows are already there, so the guard is false wherever it would matter.) + */ const viewerCountPending = - viewerClaims.length === 0 && - // `publishabilityPending` as well, which the opponent's does not need as badly: a debater's own - // responses often live in their personal space, and `canPublishDebateIn` fails open until the - // space types land — so the number would settle high and then drop as they resolve. - (sessionQuery.isLoading || positions.isLoading || viewerClaimsSettling || publishabilityPending); + viewerClaims.length === 0 && (sessionQuery.isLoading || positions.isLoading || viewerClaimsSettling); // Recommended is offered only when a curator has a page for this pairing; the order is fixed, so // a source that appears doesn't reshuffle the ones already in the menu. The rest are in the hub's diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx index 6ba1405b45..e23807109c 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx @@ -1825,6 +1825,27 @@ describe('RematchVoiceHeader', () => { expect(mocks.getUserMedia).not.toHaveBeenCalled(); }); + // Leaving is a mutation, and the session answers voice-capable for the whole round trip after the + // click. So `exiting` arrives while the status still says voice is on — and a keep-alive that + // reads the status at all would let the ladder finish the lock, the token and the connection + // during the request, publishing a microphone because the viewer asked to leave. + it('does not arm voice while the leave request is still in flight', async () => { + const session = { ...makeSession('browsing'), source_debate_id: 'debate-1' }; + // No `flushOwnership` before the click: the room has not come up yet, which is the only state + // where there is nothing to hold open and everything to avoid starting. + const { container, rerender } = render(); + + // What Leave does first. The mutation is pending, so the session is still `browsing`. + rerender(); + await flushOwnership(); + + expect(container.querySelector('[data-testid="livekit-room"]')).toBeNull(); + expect(mocks.joinCalls.every(call => !call.enabled)).toBe(true); + expect(mocks.getUserMedia).not.toHaveBeenCalled(); + // And the header has not collapsed for it: the line it was drawing is still the line it draws. + expect(screen.getByText('Connecting voice…')).toBeInTheDocument(); + }); + // Letting that room go must not take the header's shape with it. The connection is held open for // a room that was up; the shape is held whatever was drawn — here the one line the room itself // draws while it connects, which is what was on screen a moment before the click. diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx index 512ee0cc40..b00fa00eb6 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx @@ -275,8 +275,14 @@ function SessionRematchVoiceHeader({ session, currentUserId, leaveAction, exitin * This decides whether to keep *connecting*, which is a different question from what the header * draws — `heldVoiceRef` below holds that still on its own. So the connection outlives the exit * only where there is a connection to outlive. + * + * Once the exit begins the status stops being consulted at all, rather than being one half of an + * `||`. Leaving is a mutation: `exiting` goes true on the click, and the session answers + * voice-capable for the whole round trip after it. Reading the status there would let the ladder + * finish the lock, the token and the connection *during* the request — publishing a microphone + * into a session because the viewer asked to leave it. */ - const voiceActive = voiceCapableNow || (exiting && roomWasLive); + const voiceActive = exiting ? roomWasLive : voiceCapableNow; /** * The shape the header had before the exit began, `'room'` meaning the live tree itself. From 720ba6380f0b0521c251a9c6035bd0d6cbfea18e Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 16:38:29 -0700 Subject: [PATCH 10/11] fix(debates): stop the talking nudge moving under the exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../[sessionId]/rematch-voice.test.tsx | 50 +++++++++++++++++++ .../rematches/[sessionId]/rematch-voice.tsx | 32 +++++++++--- 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx index e23807109c..5dde0b95b7 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx @@ -1846,6 +1846,56 @@ describe('RematchVoiceHeader', () => { expect(screen.getByText('Connecting voice…')).toBeInTheDocument(); }); + // The nudge is the other thing in this header that can arrive on its own, and leaving is exactly + // the event that arms it: an unmuted viewer's microphone reads as muted the moment the room + // drops, and "they are talking" is still true on that render, so the one-shot fires — growing + // the header at the one moment `exiting` exists to hold it still. + it('does not raise the talking nudge on the way out', async () => { + mocks.remoteParticipants = [remoteOpponent()]; + mocks.opponentMicPublication = { isMuted: false }; + mocks.isSpeaking = true; + const session = makeSession('browsing'); + const { rerender } = render(); + await flushOwnership(); + // Unmuted while they talk, so the nudge is unspent and there is a live "they are audible". + expect(screen.queryByTestId('rematch-voice-toast-opponent-talking')).toBeNull(); + + // Leaving: the room drops, which takes the microphone and the remote participants with it. + mocks.isMicrophoneEnabled = false; + mocks.connectionState = 'disconnected'; + mocks.remoteParticipants = []; + rerender(); + + expect(screen.queryByTestId('rematch-voice-toast-opponent-talking')).toBeNull(); + }); + + // And the other direction: one already on screen must not time out mid-exit either. Ten seconds + // is long enough that a click can land nine into it, and the bubble going away on its own is the + // same shift upward that raising it is downward. + it('does not let the talking nudge time out while leaving', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + mocks.isMicrophoneEnabled = false; + mocks.remoteParticipants = [remoteOpponent()]; + const session = makeSession('browsing'); + const { rerender } = render(); + await flushOwnership(); + + mocks.isSpeaking = true; + rerender(); + expect(screen.getByTestId('rematch-voice-toast-opponent-talking')).toBeInTheDocument(); + + rerender(); + await act(async () => { + vi.advanceTimersByTime(12_000); + }); + + expect(screen.getByTestId('rematch-voice-toast-opponent-talking')).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + // Letting that room go must not take the header's shape with it. The connection is held open for // a room that was up; the shape is held whatever was drawn — here the one line the room itself // draws while it connects, which is what was on screen a moment before the click. diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx index b00fa00eb6..2cb4146c8b 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx @@ -157,15 +157,28 @@ function usePrimedMicrophonePermission(enabled: boolean, deviceId?: string) { * that shouts. `spentRef` is owned by the header's outermost component rather than declared here on * purpose: this hook's component is unmounted and rebuilt by every reconnect and every "audio is * blocked" detour, so a local ref would quietly reset the one-shot several times a session. + * + * `frozen` stops the clock in both directions, and leaving is the event it is for. Every input this + * hook 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, so the one-shot fires and the bubble arrives on + * the way out — while one already up can reach its ten seconds in the same window and go. Both are + * the header changing size under someone who has already left. Whatever is on screen when the exit + * begins stays there until the page does; `dismiss` is exempt, because a viewer who presses the + * control has asked for the change. */ -function useMutedNudge(muted: boolean, opponentAudible: boolean, spentRef: React.MutableRefObject) { +function useMutedNudge( + muted: boolean, + opponentAudible: boolean, + spentRef: React.MutableRefObject, + frozen: boolean +) { const [visible, setVisible] = React.useState(false); React.useEffect(() => { - if (spentRef.current || !muted || !opponentAudible) return; + if (frozen || spentRef.current || !muted || !opponentAudible) return; spentRef.current = true; setVisible(true); - }, [muted, opponentAudible, spentRef]); + }, [frozen, muted, opponentAudible, spentRef]); // The dismissal clock is deliberately its own effect, keyed only on `visible`. Sharing the // effect above would put `opponentAudible` in its dependencies, and the opponent stops talking @@ -173,15 +186,17 @@ function useMutedNudge(muted: boolean, opponentAudible: boolean, spentRef: React // early-return instead of re-arming it, and the toast would sit there for the rest of the // session. React.useEffect(() => { - if (!visible) return; + // Freezing mid-count cancels the pending timeout through this effect's own cleanup, which is + // what stops a bubble nine seconds old from going out from under the exit. + if (!visible || frozen) return; const timer = setTimeout(() => setVisible(false), NUDGE_MS); return () => clearTimeout(timer); - }, [visible]); + }, [frozen, visible]); // Unmuting is what the nudge was asking for; leaving it up afterwards is just noise. React.useEffect(() => { - if (!muted) setVisible(false); - }, [muted]); + if (!muted && !frozen) setVisible(false); + }, [frozen, muted]); // For the mute button, which cannot wait for `muted` to catch up: unmuting leaves // `isMicrophoneEnabled` false for as long as the permission dialog is open, so the effect above @@ -763,7 +778,8 @@ function VoiceHeaderBody({ const { visible: nudgeVisible, dismiss: dismissNudge } = useMutedNudge( muted && !micFailed, opponentAudible, - nudgeSpentRef + nudgeSpentRef, + exiting ); React.useEffect(() => { From e7eaecbd149e27043c3775e9bf40f6954c6ffde2 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 17:13:27 -0700 Subject: [PATCH 11/11] fix(debates): stop the Positions badge calling an outage a zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../[sessionId]/rematch-page-client.test.tsx | 40 +++++++++++++++++++ .../[sessionId]/rematch-page-client.tsx | 18 +++++++-- .../[sessionId]/rematch-voice.test.tsx | 32 +++++++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx index 2be9fdb701..53e9e30d68 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx @@ -1760,6 +1760,46 @@ describe('DebateRematchPageClient', () => { expect(screen.getAllByRole('article')).toHaveLength(Number(badge)); }); + /** + * A lookup that failed is not an answer about the viewer either. + * + * react-query drops `isLoading` on failure, so an outage leaves every flag false and the list + * empty — which reads from the badge exactly like somebody who has answered nothing. The tab + * underneath says the lookup failed; the number beside it would be saying, confidently, that + * there is nothing to look at. `Counting your positions` is the honest thing to show over an + * error the viewer can retry. + */ + it('counts nothing rather than zero when the lookup failed', async () => { + mocks.positions = []; + mocks.positionsError = new Error('positions exploded'); + render(); + await settleTabSwap(); + + expect( + within(screen.getByRole('button', { name: /^Positions/ })).getByLabelText('Counting your positions') + ).toBeInTheDocument(); + }); + + // The other half of that, and the reason the failure sits inside the `length === 0` guard rather + // than in front of it: a refetch that failed has not invalidated the rows already drawn. Going + // to a skeleton over a list still on screen would be the flicker the guard exists to prevent. + it('keeps the count it has when a refetch fails under it', async () => { + viewerOnlyClaim(); + const view = render(); + await settleTabSwap(); + + const badge = () => + (screen.getByRole('button', { name: /^Positions/ }).textContent?.replace('Positions', '') ?? '').trim(); + await waitFor(() => expect(badge()).toMatch(/^[1-9]\d*$/)); + const settled = badge(); + + // react-query keeps the data it has and reports the failure alongside it. + mocks.positionsError = new Error('positions exploded'); + view.rerender(); + + expect(badge()).toBe(settled); + }); + // The same window the opponent's tab waits out, on the other side of it: the ids here are the // viewer's own, so until their geo-chat id lands there are none, and "you haven't taken a // position" is a statement about them made before anyone knew who they were. diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx index eadb993c49..6e4ae59d74 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx @@ -1329,8 +1329,19 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { * what is under it. (Behind the `length === 0` guard the term did nothing either way: fail-open * means the provisional rows are already there, so the guard is false wherever it would matter.) */ + /** + * The viewer's own two sources, as `opponentTabError` is the opponent's — and the same pair the + * tab below draws its error state from, so the number and the list are answering one question. + * Named up here because the badge is decided long before `tabError`, which is the composite. + */ + const viewerTabError = sessionQuery.error ?? positions.error ?? viewerEntitiesQuery.error; const viewerCountPending = - viewerClaims.length === 0 && (sessionQuery.isLoading || positions.isLoading || viewerClaimsSettling); + viewerClaims.length === 0 && + // The failure belongs with the loading flags rather than beside them: react-query drops + // `isLoading` on failure, so an outage leaves every flag false over an empty list and reads + // from here exactly like somebody who has answered nothing. Inside the `length === 0` guard, + // so a held list keeps its number through a refetch that failed — those rows are still right. + (sessionQuery.isLoading || positions.isLoading || viewerClaimsSettling || Boolean(viewerTabError)); // Recommended is offered only when a curator has a page for this pairing; the order is fixed, so // a source that appears doesn't reshuffle the ones already in the menu. The rest are in the hub's @@ -1822,8 +1833,9 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { // still fail is turning those ids into rows. relatedRowsError : source === 'mine' - ? // The same two lookups the opponent's tab is built from, asked about the viewer. - (positions.error ?? viewerEntitiesQuery.error) + ? // The same two lookups the opponent's tab is built from, asked about the viewer. Shared + // with the Positions badge, which has to call an outage an outage rather than a zero. + viewerTabError : source === 'all' ? // The page is the list, and it carries everything a row is built from — so its failure // is the only one that leaves nothing to show. geo-chat's row lookup is metadata beside diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx index 5dde0b95b7..3a5f56efa1 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx @@ -1896,6 +1896,38 @@ describe('RematchVoiceHeader', () => { } }); + /** + * The freeze is over the nudge's own lifecycle, not over the gate that renders it. + * + * A reconnect during the exit is the one state where the toast still goes, and deliberately: the + * header has already swapped the cards for `Reconnecting…` — the exception this exit path keeps + * on purpose, because live mute controls over a room with no connection are worse than the + * change — and `OpponentPresence` is dropped with it, which is what was sourcing "they are + * talking". Holding the bubble through that would keep a claim about the other person after the + * subscription behind it is gone, over a card that says the room is reconnecting, offering an + * Unmute that is the only mute affordance left and cannot work. The unmute notice stays, and the + * asymmetry is the point: "you are muted" survives a blip, "they are talking" does not. + */ + it('lets a reconnect take the nudge down even while leaving', async () => { + mocks.isMicrophoneEnabled = false; + mocks.remoteParticipants = [remoteOpponent()]; + const session = makeSession('browsing'); + const { rerender } = render(); + await flushOwnership(); + mocks.isSpeaking = true; + rerender(); + expect(screen.getByTestId('rematch-voice-toast-opponent-talking')).toBeInTheDocument(); + + mocks.connectionState = 'reconnecting'; + rerender(); + + expect(screen.queryByTestId('rematch-voice-toast-opponent-talking')).toBeNull(); + // What replaced it, and what did not: the room says what it is doing, and the notice — whose + // claim is about the viewer's own microphone — rides the blip out as it always has. + expect(screen.getByText('Reconnecting…')).toBeInTheDocument(); + expect(screen.getByTestId('rematch-unmute-notice')).toBeInTheDocument(); + }); + // Letting that room go must not take the header's shape with it. The connection is held open for // a room that was up; the shape is held whatever was drawn — here the one line the room itself // draws while it connects, which is what was on screen a moment before the click.