@@ -346,129 +378,7 @@ function ClaimListItem({
);
}
-function PositionButtonGroup({
- debatesEnabled,
- canJoinDebate,
- pendingPosition,
- onlineChoices,
- joinPending,
- onJoinPosition,
- className,
-}: {
- debatesEnabled: boolean;
- canJoinDebate: boolean;
- pendingPosition: boolean | null;
- onlineChoices: DebateOnlineChoice[];
- joinPending: boolean;
- onJoinPosition: (position: boolean) => void;
- className?: string;
-}) {
- const positions = [
- { label: 'Yes', value: true },
- { label: 'No', value: false },
- ];
-
- return (
-
-
- Position
-
-
- {positions.map(position => {
- const choice = onlineChoices.find(choice => choice.position === position.value);
- const label = choice?.position_label ?? position.label;
- const participantCount = choice?.participant_count ?? 0;
- const selected = pendingPosition === position.value;
- const accessibleLabel = `${label}, ${participantCount} participant${participantCount === 1 ? '' : 's'} available${selected ? ', selected' : ''}`;
-
- if (debatesEnabled) {
- return (
-
- );
- }
-
- return (
-
- {label}
-
- );
- })}
-
-
- );
-}
-
-function OnlineChoiceParticipants({ choice }: { choice: DebateOnlineChoice }) {
- const participants = choice.participants.slice(0, 2);
- const overflowCount = Math.max(0, choice.participant_count - participants.length);
-
- if (participants.length === 0 && overflowCount === 0) return null;
-
- return (
-
- {participants.map(participant => {
- const label = participant.display_name || participant.profile_space_id;
-
- return (
-
-
-
- );
- })}
- {overflowCount > 0 && (
-
- +{overflowCount}
-
- )}
-
- );
-}
-
-function ClaimDebateStatus({
- debateClaim,
- mutationError,
- published,
-}: {
- debateClaim: DebateClaim | null;
- mutationError: string | null;
- published: boolean;
-}) {
- if (mutationError) {
- return (
-
- {mutationError}
-
- );
- }
-
+function ClaimDebateStatus({ debateClaim, published }: { debateClaim: DebateClaim | null; published: boolean }) {
if (!published) return null;
if (debateClaim?.active_debate) {
@@ -487,14 +397,6 @@ function ClaimDebateStatus({
);
}
- if (debateClaim?.viewer_waiting_position !== null && debateClaim?.viewer_waiting_position !== undefined) {
- return (
-
- Waiting for someone with the opposite position.
-
- );
- }
-
return null;
}
@@ -531,3 +433,13 @@ function RelationChipGroup({
function relationsForProperty(relations: Relation[], propertyId: string): Relation[] {
return relations.filter(relation => relation.type.id === propertyId && relation.isDeleted !== true);
}
+
+function claimResponseKind(claim: Entity, spaceId: string): 'stance' | 'veracity' {
+ const isFactual = claim.values.find(
+ value =>
+ value.isDeleted !== true &&
+ uuidToHex(value.spaceId) === uuidToHex(spaceId) &&
+ uuidToHex(value.property.id) === uuidToHex(CLAIM_IS_FACTUAL_PROPERTY_ID)
+ )?.value;
+ return getChecked(isFactual) === true ? 'veracity' : 'stance';
+}
diff --git a/apps/web/app/space/[id]/(space)/debates/[debateId]/debate-room-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/[debateId]/debate-room-page-client.test.tsx
index 09a2b438f2..9597ef1dc4 100644
--- a/apps/web/app/space/[id]/(space)/debates/[debateId]/debate-room-page-client.test.tsx
+++ b/apps/web/app/space/[id]/(space)/debates/[debateId]/debate-room-page-client.test.tsx
@@ -3136,6 +3136,7 @@ function completedDebate(): Debate {
description: null,
},
status: 'complete',
+ response_kind: null,
room_name: 'geo-debate-debate-1',
first_participant_slot: 1,
current_turn_index: 0,
diff --git a/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx
index 6bf126f8a4..5f14273e19 100644
--- a/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx
+++ b/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx
@@ -48,7 +48,7 @@ vi.mock('~/core/debates/hooks', () => ({
useDebateMediaArtifactUrl: () => ({ mutate: mocks.mediaArtifactMutate }),
useDebateTranscript: () => ({ data: { segments: [] }, isLoading: false, error: null }),
useDebateClaims: () => ({ data: { claims: [] } }),
- useJoinDebateQueue: () => ({ mutate: vi.fn(), isPending: false }),
+ useJoinDebateQueue: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
vi.mock('~/core/hooks/use-space', () => ({
@@ -138,6 +138,7 @@ function completedDebate(): Debate {
description: null,
},
status: 'complete',
+ response_kind: null,
room_name: 'debate-1',
first_participant_slot: 1,
current_turn_index: 1,
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 0f59b1eaf4..60e691b0a2 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
@@ -17,6 +17,8 @@ const mocks = vi.hoisted(() => ({
mutate: vi.fn(),
acceptMutate: vi.fn(),
rejectMutate: vi.fn(),
+ submitResponse: vi.fn(),
+ optimisticResponses: new Map
(),
}));
vi.mock('next/navigation', () => ({
@@ -36,7 +38,6 @@ vi.mock('~/core/debates/hooks', () => ({
error: null,
}),
useDebate: () => ({ data: { claim: { claim_entity_id: 'claim-source' } } }),
- useUpdateDebateRematchPosition: () => mutation(),
useCreateDebateRematchRequest: () => mutation(),
useLeaveDebateRematch: () => mutation(),
useAcceptDebateRematchRequest: () => mutation(mocks.acceptMutate),
@@ -64,6 +65,14 @@ vi.mock('~/core/sync/use-store', () => ({
}),
}));
+vi.mock('~/core/hooks/use-entity-vote', () => ({
+ useEntityResponse: ({ entityId }: { entityId: string }) => ({
+ submitResponse: (direction: 'positive' | 'negative' | 'clear') => mocks.submitResponse(entityId, direction),
+ optimisticResponse: mocks.optimisticResponses.get(entityId),
+ isConnected: true,
+ }),
+}));
+
function mutation(mutate = mocks.mutate) {
return { mutate, mutateAsync: mutate, isPending: false, error: null };
}
@@ -73,6 +82,8 @@ beforeEach(() => {
mocks.mutate.mockReset();
mocks.acceptMutate.mockReset();
mocks.rejectMutate.mockReset();
+ mocks.submitResponse.mockReset();
+ mocks.optimisticResponses.clear();
mocks.session = session();
mocks.claims = [sharedClaim()];
document.body.style.overflow = '';
@@ -120,17 +131,63 @@ describe('DebateRematchPageClient', () => {
expect(screen.getAllByRole('button', { name: 'Request debate' })[0]).toBeEnabled();
});
- it('shows each side its holder avatar in the claim position controls', () => {
+ it('renders active semantic response buttons with holder avatars', () => {
render();
const sharedClaimCard = screen.getByRole('heading', { name: 'A claim both participants chose' }).closest('article');
expect(sharedClaimCard).not.toBeNull();
- // Local picked Yes and the opponent picked No, so each pill carries one avatar.
- expect(within(sharedClaimCard!).getByRole('button', { name: 'Yes' }).querySelector('img, svg')).not.toBeNull();
- expect(within(sharedClaimCard!).getByRole('button', { name: 'No' }).querySelector('img, svg')).not.toBeNull();
+ expect(within(sharedClaimCard!).getByRole('button', { name: 'Agree' })).toBeEnabled();
+ expect(within(sharedClaimCard!).getByRole('button', { name: 'Disagree' })).toBeEnabled();
+ expect(within(sharedClaimCard!).getByRole('button', { name: 'Agree' }).querySelector('img, svg')).not.toBeNull();
+ expect(within(sharedClaimCard!).getByRole('button', { name: 'Disagree' }).querySelector('img, svg')).not.toBeNull();
+ });
+
+ it('changes responses through the semantic buttons without rendering a second response area', () => {
+ mocks.claims = [
+ {
+ ...sharedClaim(),
+ participants: [
+ { user_id: 'user-local', position: true, position_label: 'Agree' },
+ { user_id: 'user-remote', position: true, position_label: 'Agree' },
+ ],
+ },
+ ];
+
+ render();
+
+ const sharedClaimCard = screen.getByRole('heading', { name: 'A claim both participants chose' }).closest('article');
+ expect(sharedClaimCard).not.toBeNull();
+ fireEvent.click(within(sharedClaimCard!).getByRole('button', { name: 'Disagree' }));
+ expect(mocks.submitResponse).toHaveBeenCalledWith('claim-shared', 'negative');
+ expect(screen.queryByText('You both have the same response. Change yours to request this debate.')).toBeNull();
+ const syntheticClaimCard = screen.getByRole('heading', { name: 'A newly published claim' }).closest('article');
+ expect(syntheticClaimCard).not.toBeNull();
+ expect(within(syntheticClaimCard!).queryByText('Respond before requesting')).toBeNull();
+ expect(within(syntheticClaimCard!).getByRole('button', { name: 'Agree' })).toBeEnabled();
+ expect(within(syntheticClaimCard!).getByRole('button', { name: 'Disagree' })).toBeEnabled();
+ });
+
+ it('uses Verify and Dispute for factual claims', () => {
+ mocks.claims = [
+ {
+ ...sharedClaim(),
+ response_kind: 'veracity',
+ participants: [
+ { user_id: 'user-local', position: true, position_label: 'Verify' },
+ { user_id: 'user-remote', position: false, position_label: 'Dispute' },
+ ],
+ },
+ ];
+
+ render();
+
+ const claimCard = screen.getByRole('heading', { name: 'A claim both participants chose' }).closest('article');
+ expect(claimCard).not.toBeNull();
+ expect(within(claimCard!).getByRole('button', { name: 'Verify' })).toBeEnabled();
+ expect(within(claimCard!).getByRole('button', { name: 'Dispute' })).toBeEnabled();
});
- it('shows an incoming request in the shared dialog and preserves rematch actions', () => {
+ it('shows authoritative stance labels in the incoming request dialog and preserves rematch actions', () => {
mocks.session = session({
status: 'request_pending',
request: {
@@ -140,7 +197,10 @@ describe('DebateRematchPageClient', () => {
requester_user_id: 'user-remote',
recipient_user_id: 'user-local',
requester_position: false,
+ requester_position_label: 'Disagree',
recipient_position: true,
+ recipient_position_label: 'Agree',
+ response_kind: 'stance',
turn_format_id: 'standard',
created_at: '2026-07-10T10:00:00.000Z',
expires_at: '2026-07-10T10:02:00.000Z',
@@ -154,8 +214,8 @@ describe('DebateRematchPageClient', () => {
expect(within(dialog).getByText('You')).toBeInTheDocument();
expect(within(dialog).getByText('Salina')).toBeInTheDocument();
expect(within(dialog).getByText('VS')).toBeInTheDocument();
- expect(within(within(dialog).getByText('You').parentElement!).getByText('Yes')).toBeInTheDocument();
- expect(within(within(dialog).getByText('Salina').parentElement!).getByText('No')).toBeInTheDocument();
+ expect(within(within(dialog).getByText('You').parentElement!).getByText('Agree')).toBeInTheDocument();
+ expect(within(within(dialog).getByText('Salina').parentElement!).getByText('Disagree')).toBeInTheDocument();
expect(within(dialog).getAllByText('1m')).toHaveLength(2);
expect(within(dialog).getAllByText('45s')).toHaveLength(2);
expect(document.body.style.overflow).toBe('hidden');
@@ -173,7 +233,31 @@ describe('DebateRematchPageClient', () => {
expect(document.documentElement.style.overflow).toBe('');
});
- it('disables every claim card while a rematch request is pending', () => {
+ it('falls back to Agree and Disagree for incoming requests without response metadata', () => {
+ mocks.session = session({
+ status: 'request_pending',
+ request: {
+ id: 'request-legacy',
+ status: 'pending',
+ claim: claimSummary('claim-shared', 'A claim both participants chose'),
+ requester_user_id: 'user-remote',
+ recipient_user_id: 'user-local',
+ requester_position: false,
+ recipient_position: true,
+ turn_format_id: 'standard',
+ created_at: '2026-07-10T10:00:00.000Z',
+ expires_at: '2026-07-10T10:02:00.000Z',
+ },
+ });
+
+ render();
+
+ const dialog = screen.getByRole('dialog', { name: 'A claim both participants chose' });
+ expect(within(within(dialog).getByText('You').parentElement!).getByText('Agree')).toBeInTheDocument();
+ expect(within(within(dialog).getByText('Salina').parentElement!).getByText('Disagree')).toBeInTheDocument();
+ });
+
+ it('disables debate requests while a rematch request is pending', () => {
mocks.session = session({
status: 'request_pending',
request: {
@@ -183,7 +267,10 @@ describe('DebateRematchPageClient', () => {
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',
@@ -193,9 +280,34 @@ describe('DebateRematchPageClient', () => {
render();
expect(screen.getByRole('button', { name: 'Requesting...' })).toBeDisabled();
- expect(screen.getAllByRole('button', { name: /^(Yes|No)$/ }).every(button => button.hasAttribute('disabled'))).toBe(
- true
- );
+ expect(screen.getAllByRole('button', { name: /^(Agree|Disagree)$/ })).toHaveLength(4);
+ });
+
+ it('explains when response changes cancel a rematch request', () => {
+ mocks.session = session({
+ request: {
+ id: 'request-1',
+ status: 'expired',
+ 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',
+ cancellation_reason: 'claim_response_position_changed',
+ turn_format_id: 'standard',
+ created_at: '2026-07-10T10:00:00.000Z',
+ expires_at: '2026-07-10T10:02:00.000Z',
+ },
+ });
+
+ render();
+
+ expect(
+ screen.getByText('This request was cancelled because the responses no longer oppose each other.')
+ ).toBeInTheDocument();
});
it('filters to opponent-committed claims on the Debate now tab', () => {
@@ -217,7 +329,7 @@ describe('DebateRematchPageClient', () => {
fireEvent.click(screen.getByRole('button', { name: /Debate now/ }));
- expect(screen.getByText(/Salina hasn't picked a side yet/)).toBeInTheDocument();
+ expect(screen.getByText(/Salina hasn't responded yet/)).toBeInTheDocument();
});
it('narrows the list to the selected topic', () => {
@@ -278,9 +390,10 @@ function session(overrides: Partial = {}): DebateRematchSe
function sharedClaim(): DebateRematchClaim {
return {
claim: claimSummary('claim-shared', 'A claim both participants chose'),
+ response_kind: 'stance',
participants: [
- { user_id: 'user-local', position: true },
- { user_id: 'user-remote', position: false },
+ { user_id: 'user-local', position: true, position_label: 'Agree' },
+ { user_id: 'user-remote', position: false, position_label: 'Disagree' },
],
shared_preference: true,
recently_rejected: false,
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 8df38d75bf..8c04079b73 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
@@ -7,7 +7,7 @@ import * as React from 'react';
import cx from 'classnames';
import { useRouter } from 'next/navigation';
-import { CLAIM_TYPE_ID, TOPICS_PROPERTY_ID } from '~/core/claims/ontology';
+import { CLAIM_IS_FACTUAL_PROPERTY_ID, CLAIM_TYPE_ID, TOPICS_PROPERTY_ID } from '~/core/claims/ontology';
import {
type DebateRematchClaim,
type DebateRematchParticipant,
@@ -24,12 +24,15 @@ import {
useDebateRematchClaims,
useLeaveDebateRematch,
useRejectDebateRematchRequest,
- useUpdateDebateRematchPosition,
} from '~/core/debates/hooks';
+import { useEntityResponse } from '~/core/hooks/use-entity-vote';
+import { uuidToHex } from '~/core/id/normalize';
+import { responsePositionLabel } from '~/core/responses/entity-response';
import { useQueryEntities } from '~/core/sync/use-store';
import { Avatar } from '~/design-system/avatar';
import { Button } from '~/design-system/button';
+import { getChecked } from '~/design-system/checkbox';
import { ChevronDownSmall } from '~/design-system/icons/chevron-down-small';
import { Text } from '~/design-system/text';
@@ -63,7 +66,6 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) {
const publishedClaimIds = React.useMemo(() => publishedClaims.map(claim => claim.id), [publishedClaims]);
const savedClaimsQuery = useDebateRematchClaims(sessionId);
const publishedClaimsQuery = useDebateRematchClaims(sessionId, publishedClaimIds);
- const updatePosition = useUpdateDebateRematchPosition(sessionId);
const createRequest = useCreateDebateRematchRequest(sessionId);
const leaveSession = useLeaveDebateRematch(sessionId);
const acceptRequest = useAcceptDebateRematchRequest();
@@ -98,9 +100,11 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) {
claim: claim.name!,
description: claim.description,
},
+ response_kind: claimResponseKind(claim, claim.spaces[0]!),
participants: (session?.participants ?? []).map(participant => ({
user_id: participant.user_id,
position: null,
+ position_label: null,
})),
shared_preference: false,
recently_rejected: false,
@@ -149,7 +153,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) {
const [tab, setTab] = React.useState<'all' | 'debate-now'>('all');
const [topicFilter, setTopicFilter] = React.useState('');
- // "Debate now" = claims the opponent has taken a position on; the tab badge counts them.
+ // "Debate now" = claims the opponent has responded to; the tab badge counts them.
const opponentPositionCount = React.useMemo(
() => claims.filter(claim => opponentPositionOf(claim) !== null).length,
[claims, opponentPositionOf]
@@ -194,15 +198,16 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) {
const incomingRequestParticipants =
incomingRequest && session
? session.participants.map(participant => {
- const position =
- participant.user_id === incomingRequest.requester_user_id
- ? incomingRequest.requester_position
- : incomingRequest.recipient_position;
+ const requester = participant.user_id === incomingRequest.requester_user_id;
+ const position = requester ? incomingRequest.requester_position : incomingRequest.recipient_position;
+ const positionLabel = requester
+ ? incomingRequest.requester_position_label
+ : incomingRequest.recipient_position_label;
return {
...participant,
position,
- position_label: position ? 'Yes' : 'No',
+ position_label: positionLabel ?? responsePositionLabel(incomingRequest.response_kind ?? null, position),
};
})
: [];
@@ -259,17 +264,18 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) {
: publishedClaimsQuery.error?.message}
)}
- {(updatePosition.error instanceof Error ||
- createRequest.error instanceof Error ||
- leaveSession.error instanceof Error) && (
+ {(createRequest.error instanceof Error || leaveSession.error instanceof Error) && (
- {updatePosition.error instanceof Error
- ? updatePosition.error.message
- : createRequest.error instanceof Error
- ? createRequest.error.message
- : leaveSession.error instanceof Error
- ? leaveSession.error.message
- : null}
+ {createRequest.error instanceof Error
+ ? createRequest.error.message
+ : leaveSession.error instanceof Error
+ ? leaveSession.error.message
+ : null}
+
+ )}
+ {session?.request?.status === 'expired' && session.request.cancellation_reason && (
+
+ {rematchCancellationMessage(session.request.cancellation_reason)}
)}
@@ -281,13 +287,6 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) {
topic={topicsByClaimId.get(claim.claim.claim_entity_id)?.[0] ?? null}
session={session}
currentUserId={currentUserId}
- onPositionChange={position =>
- updatePosition.mutate({
- claimId: claim.claim.claim_entity_id,
- position,
- sourceSpaceId: claim.claim.space_id,
- })
- }
onRequest={() =>
createRequest.mutate({
source_space_id: claim.claim.space_id,
@@ -295,7 +294,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) {
format_id: defaultDebateFormatId,
})
}
- busy={updatePosition.isPending || createRequest.isPending || session?.status === 'request_pending'}
+ busy={createRequest.isPending || session?.status === 'request_pending'}
/>
))}
@@ -304,7 +303,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) {