From d517660bbfb7e8f5e6dd8c98dbfc6cb6974cc810 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 15:19:37 +0000 Subject: [PATCH 1/8] Add the Timer to the Practice tab The website's formats, speeches and signals in a full-screen ring like the live round in mock 05. The clock keeps start timestamps instead of counting ticks, so it shows the right time after the app comes back from the background, and a gap that crosses both thresholds sounds only time up. The beeps are bundled WAVs generated by scripts/render-timer-sounds.ts from the website's synth, played with expo-audio, with an expo-haptics tap on each signal. expo-keep-awake keeps the screen on while a speech runs. The web preview records the signals instead, and the screenshot run checks them on Playwright's clock. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/assets/sounds/README.md | 7 + apps/mobile/assets/sounds/beep.wav | Bin 0 -> 22094 bytes apps/mobile/assets/sounds/double-beep.wav | Bin 0 -> 26504 bytes apps/mobile/package.json | 3 + apps/mobile/scripts/render-timer-sounds.ts | 68 ++++++ apps/mobile/scripts/screenshots.ts | 40 +++ .../src/app/(tabs)/(practice)/practice.tsx | 6 +- apps/mobile/src/app/_layout.tsx | 2 + apps/mobile/src/app/timer.tsx | 229 ++++++++++++++++++ apps/mobile/src/components/icon-paths.ts | 9 + apps/mobile/src/components/practice-tools.ts | 2 +- apps/mobile/src/components/round-controls.tsx | 117 +++++++++ apps/mobile/src/components/timer-ring.tsx | 58 +++++ apps/mobile/src/lib/timer-signals.ts | 50 ++++ apps/mobile/src/lib/timer-signals.web.ts | 16 ++ apps/mobile/src/lib/timer.test.ts | 39 +++ apps/mobile/src/lib/timer.ts | 119 +++++++++ eslint.config.js | 2 +- package-lock.json | 24 ++ src/locales/en.json | 6 + src/locales/es.json | 6 + src/locales/hu.json | 6 + 22 files changed, 805 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/assets/sounds/README.md create mode 100644 apps/mobile/assets/sounds/beep.wav create mode 100644 apps/mobile/assets/sounds/double-beep.wav create mode 100644 apps/mobile/scripts/render-timer-sounds.ts create mode 100644 apps/mobile/src/app/timer.tsx create mode 100644 apps/mobile/src/components/round-controls.tsx create mode 100644 apps/mobile/src/components/timer-ring.tsx create mode 100644 apps/mobile/src/lib/timer-signals.ts create mode 100644 apps/mobile/src/lib/timer-signals.web.ts create mode 100644 apps/mobile/src/lib/timer.test.ts create mode 100644 apps/mobile/src/lib/timer.ts diff --git a/apps/mobile/assets/sounds/README.md b/apps/mobile/assets/sounds/README.md new file mode 100644 index 000000000..b7b815e82 --- /dev/null +++ b/apps/mobile/assets/sounds/README.md @@ -0,0 +1,7 @@ +# Timer sounds + +`beep.wav` (one minute left) and `double-beep.wav` (time up) are generated by +[scripts/render-timer-sounds.ts](../../scripts/render-timer-sounds.ts), which +copies the website's Web Audio synth in `src/pages/TimerPage.tsx`: an 880 Hz +sine at 22.05 kHz, 16-bit mono. Rerun the script to change them; don't edit +the files by hand. diff --git a/apps/mobile/assets/sounds/beep.wav b/apps/mobile/assets/sounds/beep.wav new file mode 100644 index 0000000000000000000000000000000000000000..eaa18f98a0a145aea1d4b1a7edd8a7e9b3caf006 GIT binary patch literal 22094 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 literal 0 HcmV?d00001 diff --git a/apps/mobile/assets/sounds/double-beep.wav b/apps/mobile/assets/sounds/double-beep.wav new file mode 100644 index 0000000000000000000000000000000000000000..a037431ac71b511c85a9e8c6d0c40320f755d2c9 GIT binary patch literal 26504 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 bNtV1yIbFi{imAHaBE-D-ioc5gQ`qny^_wz) literal 0 HcmV?d00001 diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c1ef574cc..1b11e8420 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -16,11 +16,14 @@ "aes-js": "^3.1.2", "date-fns": "^3.6.0", "expo": "~57.0.27", + "expo-audio": "~57.0.5", "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.21", "expo-crypto": "~57.0.3", "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", + "expo-haptics": "~57.0.3", + "expo-keep-awake": "~57.0.2", "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.12", "expo-localization": "~57.0.2", diff --git a/apps/mobile/scripts/render-timer-sounds.ts b/apps/mobile/scripts/render-timer-sounds.ts new file mode 100644 index 000000000..691727656 --- /dev/null +++ b/apps/mobile/scripts/render-timer-sounds.ts @@ -0,0 +1,68 @@ +/** + * Writes the Timer's signal sounds to assets/sounds as 16-bit mono WAV files. + * They copy the website's Web Audio synth (playSignal in src/pages/TimerPage.tsx): + * an 880 Hz sine, one beep fading out over 0.5 s, and two beeps for time up + * (0.1 s on, a 50 ms gap, then a fade to 0.6 s). The website plays them at a + * tenth of full scale; a phone speaker needs more, so these peak at half. + * Run from the repository root after changing a sound: + * + * npx tsx apps/mobile/scripts/render-timer-sounds.ts + */ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +const RATE = 22_050; +const FREQUENCY = 880; +const PEAK = 0.5; +// A few milliseconds of ramp at each edge, so the tone starts and stops without a click. +const EDGE = 0.004; +const outDir = join(__dirname, '../assets/sounds'); + +/** Gain at time t for a tone that starts at `from`, holds, then fades exponentially to 0.001 by `to`. */ +function envelope(t: number, from: number, to: number, holdUntil = from): number { + if (t < from || t > to) return 0; + const attack = Math.min(1, (t - from) / EDGE); + const release = Math.min(1, (to - t) / EDGE); + const fade = t <= holdUntil ? 1 : Math.pow(0.001 / PEAK, (t - holdUntil) / (to - holdUntil)); + return attack * release * fade; +} + +function render(seconds: number, gain: (t: number) => number): Buffer { + const samples = Math.round(seconds * RATE); + const data = Buffer.alloc(samples * 2); + for (let i = 0; i < samples; i += 1) { + const t = i / RATE; + const value = Math.sin(2 * Math.PI * FREQUENCY * t) * PEAK * gain(t); + data.writeInt16LE(Math.round(Math.max(-1, Math.min(1, value)) * 32_767), i * 2); + } + const header = Buffer.alloc(44); + header.write('RIFF', 0); + header.writeUInt32LE(36 + data.length, 4); + header.write('WAVE', 8); + header.write('fmt ', 12); + header.writeUInt32LE(16, 16); + header.writeUInt16LE(1, 20); + header.writeUInt16LE(1, 22); + header.writeUInt32LE(RATE, 24); + header.writeUInt32LE(RATE * 2, 28); + header.writeUInt16LE(2, 32); + header.writeUInt16LE(16, 34); + header.write('data', 36); + header.writeUInt32LE(data.length, 40); + return Buffer.concat([header, data]); +} + +async function main() { + await mkdir(outDir, { recursive: true }); + await writeFile(join(outDir, 'beep.wav'), render(0.5, (t) => envelope(t, 0, 0.5))); + await writeFile( + join(outDir, 'double-beep.wav'), + render(0.6, (t) => (t < 0.1 ? envelope(t, 0, 0.1, 0.1) : envelope(t, 0.15, 0.6))), + ); + console.log(`Wrote beep.wav and double-beep.wav to ${outDir}`); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 9628be503..c740f261c 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -35,6 +35,9 @@ * locally. The PDF export's HTML (the web export cannot run expo-print) is * rendered in a page of its own, and the "Copy as text" output is written * next to the screenshots. + * + * The Timer runs on Playwright's clock, so its minutes pass at once, and the + * run checks that it signalled one beep at the last minute and two at time up. */ import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page } from '@playwright/test'; import { createReadStream, existsSync, statSync } from 'node:fs'; @@ -433,6 +436,41 @@ async function shootAskScreens(context: BrowserContext, prefix: string, language await streaming.close(); } +/** + * The Timer from the Practice tab: idle, running, and after time up. The page's + * clock is Playwright's, so minutes pass at once; the web preview records the + * signals it would sound (timer-signals.web.ts) and the run checks them. + */ +async function shootTimerScreens(context: BrowserContext, prefix: string, language: string, problems: string[]) { + const page = await context.newPage(); + watchErrors(page, problems, `${prefix} timer`); + await pinSettings(page, language); + await page.clock.install(); + await page.goto(`${ORIGIN}/practice`); + await page.getByTestId('practice-tool-timer').click({ timeout: 30_000 }); + await page.getByTestId('timer-screen').waitFor({ timeout: 15_000 }); + await settle(page); + await shoot(page, `${prefix}-timer`); + + // The first Standard speech is 4:00. After 1:14 it shows 2:46 and keeps running. + await page.getByTestId('timer-toggle').click(); + await page.clock.fastForward('01:14'); + await page.getByTestId('timer-time').filter({ hasText: '2:46' }).waitFor(); + await shoot(page, `${prefix}-timer-running`); + + // Past the last minute and on to time up: one beep, then two. + await page.clock.fastForward('01:50'); + await page.getByTestId('timer-time').filter({ hasText: '0:56' }).waitFor(); + await page.clock.fastForward('01:00'); + await page.getByTestId('timer-time').filter({ hasText: '0:00' }).waitFor(); + await page.waitForTimeout(100); + const signals = await page.evaluate(() => (window as unknown as { __debatelabTimerSignals?: { signal: string; sound: boolean }[] }).__debatelabTimerSignals ?? []); + const heard = signals.map((item) => item.signal).join(','); + if (heard !== 'beep,double-beep') throw new Error(`Timer signals were ${heard || 'none'}, expected beep,double-beep`); + await shoot(page, `${prefix}-timer-time-up`); + await page.close(); +} + async function shootEmptyDebate(page: Page, prefix: string) { await page.route(`${REST}/current_debates*`, (route) => route.fulfill({ status: 200, contentType: 'application/json', json: [] }), @@ -578,6 +616,8 @@ async function main() { shots += 3; await shootResultScreens(signedIn, prefix, language, problems); shots += 9; + await shootTimerScreens(signedIn, prefix, language, problems); + shots += 3; await signedIn.close(); } } diff --git a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx index 0cd759c39..e52c2c6c5 100644 --- a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx +++ b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx @@ -10,7 +10,7 @@ import { Screen } from '@/components/screen'; import { Text } from '@/components/text'; import { useTheme } from '@/theme/use-theme'; -/** The Practice tools. Attack and Ask open full screen; the others say they are on their way. */ +/** The Practice tools. Attack, Ask and Timer open full screen; the others say they are on their way. */ export default function PracticeScreen() { const { t } = useTranslation(); const router = useRouter(); @@ -28,7 +28,9 @@ export default function PracticeScreen() { ? router.push('/ask') : tool.key === 'attack' ? router.push({ pathname: '/attack', params: { from: 'practice' } }) - : router.push({ pathname: '/soon/[tool]', params: { tool: tool.key } })} + : tool.key === 'timer' + ? router.push('/timer') + : router.push({ pathname: '/soon/[tool]', params: { tool: tool.key } })} testID={`practice-tool-${tool.key}`} style={({ pressed }) => [styles.row, index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 289e641ee..67dad4f1b 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -86,6 +86,8 @@ function Root() { + {/* Live practice screens, full screen with no tab bar, as the live round in mock 05. */} + diff --git a/apps/mobile/src/app/timer.tsx b/apps/mobile/src/app/timer.tsx new file mode 100644 index 000000000..6aec05a00 --- /dev/null +++ b/apps/mobile/src/app/timer.tsx @@ -0,0 +1,229 @@ +import { useRouter } from 'expo-router'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AppState, Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { ActionMenu } from '@/components/action-menu'; +import { Chip } from '@/components/chip'; +import { Icon } from '@/components/icon'; +import { LiveTopBar, MainRoundControl, RoundIconButton, SideControl } from '@/components/round-controls'; +import { Text } from '@/components/text'; +import { TimerRing } from '@/components/timer-ring'; +import { + formatClock, + idleClock, + pauseClock, + remainingSeconds, + signalBetween, + startClock, + TIMER_FORMATS, + type TimerClock, +} from '@/lib/timer'; +import { useKeepAwakeWhile, useTimerSignals } from '@/lib/timer-signals'; +import { useTheme } from '@/theme/use-theme'; + +const TICK_MS = 250; + +/** + * The debate timer, as the website's TimerPage: pick a format and a speech, + * then run its clock with a beep at one minute left and two at time up. Full + * screen with no tab bar, like the live Sparring round in mock 05. + */ +export default function TimerScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { width, height } = useWindowDimensions(); + const { colors } = useTheme(); + const [formatId, setFormatId] = useState(TIMER_FORMATS[0].id); + const [speechIndex, setSpeechIndex] = useState(0); + const format = TIMER_FORMATS.find((item) => item.id === formatId) ?? TIMER_FORMATS[0]; + const speech = format.speeches[speechIndex] ?? format.speeches[0]; + const [clock, setClock] = useState(() => idleClock(speech.minutes)); + const [now, setNow] = useState(() => Date.now()); + const [sound, setSound] = useState(true); + const [formatMenu, setFormatMenu] = useState(false); + const signal = useTimerSignals(); + + const remaining = remainingSeconds(clock, now); + const running = clock.startedAt !== null && remaining > 0; + const finished = remaining === 0; + const total = speech.minutes * 60; + useKeepAwakeWhile(running); + + // The clock reads the time from its start timestamp; the tick only redraws it. + useEffect(() => { + if (clock.startedAt === null) return; + const timer = setInterval(() => setNow(Date.now()), TICK_MS); + // Coming back from the background redraws at once, with any signal that came due. + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') setNow(Date.now()); + }); + return () => { + clearInterval(timer); + subscription.remove(); + }; + }, [clock.startedAt]); + + // Signals fire as the time left crosses a threshold, and the clock stops at zero. + const lastRemaining = useRef(remaining); + useEffect(() => { + const previous = lastRemaining.current; + lastRemaining.current = remaining; + if (clock.startedAt === null) return; + const due = signalBetween(previous, remaining); + if (due) signal(due, sound); + if (remaining === 0) setClock((current) => pauseClock(current, Date.now())); + }, [clock.startedAt, remaining, signal, sound]); + + const selectSpeech = (index: number, formatSpeeches = format.speeches) => { + const next = formatSpeeches[index]; + if (!next) return; + setSpeechIndex(index); + setClock(idleClock(next.minutes)); + setNow(Date.now()); + }; + + const toggle = () => { + const at = Date.now(); + setNow(at); + if (finished) { + // At time up the main button starts the next speech, or sets the last one up again. + const next = format.speeches[speechIndex + 1]; + if (next) { + setSpeechIndex(speechIndex + 1); + setClock(startClock(idleClock(next.minutes), at)); + } else { + setClock(idleClock(speech.minutes)); + } + return; + } + // TODO(live-activity): start a Live Activity here, from the same timestamps, once the dev build lands. + setClock((current) => (current.startedAt === null ? startClock(current, at) : pauseClock(current, at))); + }; + + const reset = () => { + setClock(idleClock(speech.minutes)); + setNow(Date.now()); + }; + + const ringSize = Math.round(Math.min(width - 72, height * 0.36, 320)); + const warning = remaining < 30 && remaining > 0; + const status = finished ? t('timer.timeUp') : running ? t('timer.speaking') : t('timer.paused'); + const hasNext = speechIndex < format.speeches.length - 1; + const mainLabel = finished && !hasNext ? t('mobile.timer.reset') : running ? t('timer.pause') : t('timer.start'); + const mainIcon = finished && !hasNext ? 'reset' : running ? 'pause' : 'play'; + + return ( + + router.back()} testID="timer-close" />} + right={ + setSound((value) => !value)} + testID="timer-sound" + /> + } + title={t(speech.labelKey)} + subtitle={t('mobile.timer.speechOf', { n: speechIndex + 1, of: format.speeches.length })} + /> + + setFormatMenu(true)} + testID="timer-format" + style={({ pressed }) => [styles.format, { borderColor: colors.border, backgroundColor: colors.input }, pressed && styles.pressed]}> + + {t(format.nameKey)} + + + + + + 0 ? remaining / total : 0} warning={warning || finished}> + + {status} + + + {formatClock(remaining)} + + + {t('mobile.timer.ofTotal', { total: formatClock(total) })} + + + + + + {format.speeches.map((item, index) => ( + selectSpeech(index)} + testID={`timer-speech-${item.id}`} + /> + ))} + + + + + + + {t('timer.signals')} + + + + {`${t('timer.oneMinLeft')}: ${t('timer.oneBeep')}`} + + + {`${t('timer.timeUpSignal')}: ${t('timer.twoBeeps')}`} + + + + + + + + selectSpeech(speechIndex + 1)} disabled={!hasNext} testID="timer-next" /> + + + ({ key: item.id, label: t(item.nameKey), icon: item.id === formatId ? 'check' : undefined }))} + cancelLabel={t('common.cancel')} + onClose={() => setFormatMenu(false)} + onSelect={(key) => { + const next = TIMER_FORMATS.find((item) => item.id === key); + if (!next) return; + setFormatId(next.id); + selectSpeech(0, next.speeches); + }} + /> + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + content: { alignItems: 'center', gap: 22, paddingHorizontal: 20, paddingTop: 14 }, + format: { flexDirection: 'row', alignItems: 'center', gap: 6, borderWidth: 1, borderRadius: 999, paddingHorizontal: 14, minHeight: 38 }, + formatLabel: { fontSize: 15 }, + pressed: { opacity: 0.7 }, + status: { fontSize: 14, lineHeight: 19 }, + time: { letterSpacing: -2, fontVariant: ['tabular-nums'] }, + speechRow: { flexGrow: 0, alignSelf: 'stretch', marginHorizontal: -20 }, + speeches: { gap: 8, paddingHorizontal: 20 }, + signals: { alignSelf: 'stretch', maxWidth: 520, width: '100%', borderWidth: 1, borderRadius: 14, paddingHorizontal: 14, paddingVertical: 11, gap: 3 }, + signalTitle: { flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 2 }, + signalText: { fontSize: 14, lineHeight: 19 }, + controls: { position: 'absolute', left: 0, right: 0, bottom: 0, flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'space-evenly', paddingTop: 12 }, +}); diff --git a/apps/mobile/src/components/icon-paths.ts b/apps/mobile/src/components/icon-paths.ts index 802e979aa..c74077206 100644 --- a/apps/mobile/src/components/icon-paths.ts +++ b/apps/mobile/src/components/icon-paths.ts @@ -41,6 +41,15 @@ export const iconPaths = { more: '', download: '', copy: '', + play: '', + pause: '', + skip: '', + skipBack: '', + reset: '', + volume: '', + volumeOff: '', + send: '', + mic: '', alert: '', } as const; diff --git a/apps/mobile/src/components/practice-tools.ts b/apps/mobile/src/components/practice-tools.ts index 95349fa22..63034356c 100644 --- a/apps/mobile/src/components/practice-tools.ts +++ b/apps/mobile/src/components/practice-tools.ts @@ -1,6 +1,6 @@ import type { IconName } from './icon-paths'; -/** The Practice tools, in the website's order. Attack and Ask are in the app; the rest follow. */ +/** The Practice tools, in the website's order. Attack, Ask and Timer are in the app; the rest follow. */ export const PRACTICE_TOOLS = [ { key: 'attack', icon: 'shield' }, { key: 'ask', icon: 'chat' }, diff --git a/apps/mobile/src/components/round-controls.tsx b/apps/mobile/src/components/round-controls.tsx new file mode 100644 index 000000000..93128bac2 --- /dev/null +++ b/apps/mobile/src/components/round-controls.tsx @@ -0,0 +1,117 @@ +import { LinearGradient } from 'expo-linear-gradient'; +import type { ReactNode } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { Icon } from './icon'; +import type { IconName } from './icon-paths'; +import { Text } from './text'; + +import { mainActionGradient } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** The round button in a live screen's top bar, as mock 05's close and flow buttons. */ +export function RoundIconButton({ icon, label, onPress, testID }: { icon: IconName; label: string; onPress: () => void; testID?: string }) { + const { colors, scheme } = useTheme(); + return ( + [styles.round, { backgroundColor: scheme === 'dark' ? 'rgba(255,255,255,0.1)' : colors.segmentTrack }, pressed && styles.pressed]}> + + + ); +} + +/** A live screen's top bar: a button either side of a centred title and line under it. */ +export function LiveTopBar({ left, right, title, subtitle }: { left: ReactNode; right?: ReactNode; title: string; subtitle?: string }) { + const insets = useSafeAreaInsets(); + return ( + + {left} + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {right} + + ); +} + +/** A small round control with its label under it, beside the main one. */ +export function SideControl({ icon, label, onPress, disabled, testID }: { icon: IconName; label: string; onPress: () => void; disabled?: boolean; testID?: string }) { + const { colors, scheme } = useTheme(); + return ( + [styles.control, (pressed || disabled) && styles.pressed]}> + + + + + {label} + + + ); +} + +/** The one main action of a live screen, in the gradient, with its label under it. */ +export function MainRoundControl({ icon, label, onPress, testID }: { icon: IconName; label: string; onPress: () => void; testID?: string }) { + return ( + [styles.control, pressed && styles.pressed]}> + + + {/* The gradient carries the rounding, so the circle's shadow is not clipped. */} + + {/* Above the gradient: the web preview paints positioned views over static ones. */} + + + + + + + {label} + + + ); +} + +const styles = StyleSheet.create({ + bar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, gap: 8 }, + side: { width: 44, alignItems: 'flex-start' }, + sideRight: { alignItems: 'flex-end' }, + titles: { flex: 1, alignItems: 'center', gap: 1 }, + title: { fontSize: 17 }, + subtitle: { fontSize: 14 }, + round: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' }, + pressed: { opacity: 0.6 }, + control: { alignItems: 'center', gap: 6, minWidth: 88 }, + controlLabel: { fontSize: 13.5 }, + sideCircle: { width: 56, height: 56, borderRadius: 28, alignItems: 'center', justifyContent: 'center' }, + sideBorder: { borderWidth: 1 }, + mainHalo: { width: 100, height: 100, borderRadius: 50, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(56,189,248,0.14)' }, + mainGradient: { borderRadius: 42 }, + mainIcon: { position: 'relative' }, + mainCircle: { + width: 84, + height: 84, + borderRadius: 42, + alignItems: 'center', + justifyContent: 'center', + boxShadow: '0 10px 30px -6px rgba(20,184,166,0.6)', + }, +}); diff --git a/apps/mobile/src/components/timer-ring.tsx b/apps/mobile/src/components/timer-ring.tsx new file mode 100644 index 000000000..7819c470d --- /dev/null +++ b/apps/mobile/src/components/timer-ring.tsx @@ -0,0 +1,58 @@ +import type { ReactNode } from 'react'; +import { StyleSheet, View } from 'react-native'; +import Svg, { Circle, Defs, LinearGradient, Stop } from 'react-native-svg'; + +import { useTheme } from '@/theme/use-theme'; + +const STROKE = 9; + +/** + * The live round's ring from mock 05: a faint track and a gradient arc for the + * time left, drawn from the top. `warning` turns the arc red for the last seconds. + */ +export function TimerRing({ size, fraction, warning, children }: { size: number; fraction: number; warning?: boolean; children: ReactNode }) { + const { colors, scheme } = useTheme(); + const radius = (size - STROKE) / 2; + const circumference = 2 * Math.PI * radius; + const left = Math.min(1, Math.max(0, fraction)); + const [from, to] = scheme === 'dark' ? ['#38bdf8', '#2dd4bf'] : ['#0284c7', '#14b8a6']; + return ( + + + + + + + + + + {left > 0 ? ( + + ) : null} + + {children} + + ); +} + +const styles = StyleSheet.create({ + rotated: { transform: [{ rotate: '-90deg' }] }, + center: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, alignItems: 'center', justifyContent: 'center' }, +}); diff --git a/apps/mobile/src/lib/timer-signals.ts b/apps/mobile/src/lib/timer-signals.ts new file mode 100644 index 000000000..85e0b215b --- /dev/null +++ b/apps/mobile/src/lib/timer-signals.ts @@ -0,0 +1,50 @@ +import { setAudioModeAsync, useAudioPlayer } from 'expo-audio'; +import * as Haptics from 'expo-haptics'; +import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'; +import { useCallback, useEffect } from 'react'; + +import type { TimerSignal } from './timer'; + +const KEEP_AWAKE_TAG = 'debate-timer'; + +/** + * The Timer's signals: the website's beeps from bundled sounds + * (assets/sounds, see its README) and a haptic tap with each. Muting the + * sound keeps the tap, so a muted timer still says when time is up. + */ +export function useTimerSignals(): (signal: TimerSignal, sound: boolean) => void { + const beep = useAudioPlayer(require('@/assets/sounds/beep.wav')); + const doubleBeep = useAudioPlayer(require('@/assets/sounds/double-beep.wav')); + + useEffect(() => { + // A timer is heard in a round even with the ringer off. + setAudioModeAsync({ playsInSilentMode: true, interruptionMode: 'duckOthers' }).catch((cause: unknown) => + console.warn('Could not set the audio mode:', cause instanceof Error ? cause.message : cause)); + }, []); + + return useCallback((signal: TimerSignal, sound: boolean) => { + const haptic = signal === 'beep' + ? Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium) + : Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + haptic.catch(() => {}); + if (!sound) return; + const player = signal === 'beep' ? beep : doubleBeep; + try { + player.seekTo(0).catch(() => {}); + player.play(); + } catch (cause) { + console.warn('Could not play the timer signal:', cause instanceof Error ? cause.message : cause); + } + }, [beep, doubleBeep]); +} + +/** Keeps the screen on while a timer runs, and lets it sleep again when it stops or the screen closes. */ +export function useKeepAwakeWhile(active: boolean) { + useEffect(() => { + if (!active) return; + activateKeepAwakeAsync(KEEP_AWAKE_TAG).catch(() => {}); + return () => { + deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => {}); + }; + }, [active]); +} diff --git a/apps/mobile/src/lib/timer-signals.web.ts b/apps/mobile/src/lib/timer-signals.web.ts new file mode 100644 index 000000000..ce2777ac6 --- /dev/null +++ b/apps/mobile/src/lib/timer-signals.web.ts @@ -0,0 +1,16 @@ +import type { TimerSignal } from './timer'; + +/** + * The web preview has no haptics or wake lock, and a headless browser cannot + * play the beeps. It records each signal on `window` instead, so the + * screenshot script can check that the right one fired. The phone app uses + * timer-signals.ts. + */ +export function useTimerSignals(): (signal: TimerSignal, sound: boolean) => void { + return (signal, sound) => { + const target = window as unknown as { __debatelabTimerSignals?: { signal: TimerSignal; sound: boolean }[] }; + (target.__debatelabTimerSignals ??= []).push({ signal, sound }); + }; +} + +export function useKeepAwakeWhile(_active: boolean) {} diff --git a/apps/mobile/src/lib/timer.test.ts b/apps/mobile/src/lib/timer.test.ts new file mode 100644 index 000000000..de4398510 --- /dev/null +++ b/apps/mobile/src/lib/timer.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { idleClock, pauseClock, remainingSeconds, signalBetween, startClock } from './timer'; + +const T0 = 1_000_000; + +describe('timer clock', () => { + it('shows the right time after a gap with no ticks, as after the app was in the background', () => { + const clock = startClock(idleClock(4), T0); + // No tick ran for 90 s; the time comes from the start timestamp alone. + expect(remainingSeconds(clock, T0 + 90_000)).toBe(150); + }); + + it('keeps the time that ran before a pause and stops at zero', () => { + let clock = startClock(idleClock(1), T0); + clock = pauseClock(clock, T0 + 20_500); + // Paused time does not count. + expect(remainingSeconds(clock, T0 + 600_000)).toBe(40); + clock = startClock(clock, T0 + 600_000); + expect(remainingSeconds(clock, T0 + 610_000)).toBe(30); + expect(remainingSeconds(clock, T0 + 900_000)).toBe(0); + // A finished clock does not start again. + expect(startClock(pauseClock(clock, T0 + 900_000), T0 + 900_000).startedAt).toBeNull(); + }); +}); + +describe('timer signals', () => { + it('beeps once on reaching the last minute and twice at time up', () => { + expect(signalBetween(61, 60)).toBe('beep'); + expect(signalBetween(60, 59)).toBeNull(); + expect(signalBetween(1, 0)).toBe('double-beep'); + expect(signalBetween(0, 0)).toBeNull(); + }); + + it('sounds only time up when a background gap crossed both thresholds', () => { + expect(signalBetween(150, 0)).toBe('double-beep'); + expect(signalBetween(150, 30)).toBe('beep'); + }); +}); diff --git a/apps/mobile/src/lib/timer.ts b/apps/mobile/src/lib/timer.ts new file mode 100644 index 000000000..c4c77e940 --- /dev/null +++ b/apps/mobile/src/lib/timer.ts @@ -0,0 +1,119 @@ +/** + * The debate timer, as the website's TimerPage (src/pages/TimerPage.tsx): the + * same formats, speeches and signals. The clock keeps timestamps, never counts + * ticks, so it shows the right time after the app has been in the background. + */ + +export interface TimerSpeech { + id: string; + labelKey: string; + minutes: number; +} + +export interface TimerFormat { + id: string; + nameKey: string; + speeches: TimerSpeech[]; +} + +export const TIMER_FORMATS: TimerFormat[] = [ + { + id: 'standard', + nameKey: 'timer.formatStandard', + speeches: [ + { id: 'intro', labelKey: 'timer.speeches.standard.intro', minutes: 4 }, + { id: 'r1', labelKey: 'timer.speeches.standard.r1', minutes: 6 }, + { id: 'r2', labelKey: 'timer.speeches.standard.r2', minutes: 6 }, + { id: 'conc', labelKey: 'timer.speeches.standard.conc', minutes: 3 }, + ], + }, + { + id: 'ld', + nameKey: 'timer.formatLD', + speeches: [ + { id: 'ac', labelKey: 'timer.speeches.ld.ac', minutes: 6 }, + { id: 'nc', labelKey: 'timer.speeches.ld.nc', minutes: 7 }, + { id: 'ar1', labelKey: 'timer.speeches.ld.ar1', minutes: 4 }, + { id: 'nr', labelKey: 'timer.speeches.ld.nr', minutes: 6 }, + { id: 'ar2', labelKey: 'timer.speeches.ld.ar2', minutes: 3 }, + ], + }, + { + id: 'pf', + nameKey: 'timer.formatPF', + speeches: [ + { id: 'team1_c', labelKey: 'timer.speeches.pf.team1c', minutes: 4 }, + { id: 'team2_c', labelKey: 'timer.speeches.pf.team2c', minutes: 4 }, + { id: 'team1_r', labelKey: 'timer.speeches.pf.team1r', minutes: 4 }, + { id: 'team2_r', labelKey: 'timer.speeches.pf.team2r', minutes: 4 }, + { id: 'team1_s', labelKey: 'timer.speeches.pf.team1s', minutes: 3 }, + { id: 'team2_s', labelKey: 'timer.speeches.pf.team2s', minutes: 3 }, + { id: 'team1_ff', labelKey: 'timer.speeches.pf.team1ff', minutes: 2 }, + { id: 'team2_ff', labelKey: 'timer.speeches.pf.team2ff', minutes: 2 }, + ], + }, + { + id: 'policy', + nameKey: 'timer.formatPolicy', + speeches: [ + { id: '1ac', labelKey: 'timer.speeches.policy.1ac', minutes: 8 }, + { id: '1nc', labelKey: 'timer.speeches.policy.1nc', minutes: 8 }, + { id: '2ac', labelKey: 'timer.speeches.policy.2ac', minutes: 8 }, + { id: '2nc', labelKey: 'timer.speeches.policy.2nc', minutes: 8 }, + { id: '1nr', labelKey: 'timer.speeches.policy.1nr', minutes: 5 }, + { id: '1ar', labelKey: 'timer.speeches.policy.1ar', minutes: 5 }, + { id: '2nr', labelKey: 'timer.speeches.policy.2nr', minutes: 5 }, + { id: '2ar', labelKey: 'timer.speeches.policy.2ar', minutes: 5 }, + ], + }, +]; + +/** One speech's clock: its length, when it last started running, and the time it ran before that. */ +export interface TimerClock { + durationMs: number; + /** When the clock last started; null while it is stopped. */ + startedAt: number | null; + /** Time that ran before the last start, across pauses. */ + elapsedBeforeMs: number; +} + +export function idleClock(minutes: number): TimerClock { + return { durationMs: minutes * 60_000, startedAt: null, elapsedBeforeMs: 0 }; +} + +export function elapsedMs(clock: TimerClock, now: number): number { + const running = clock.startedAt === null ? 0 : Math.max(0, now - clock.startedAt); + return Math.min(clock.durationMs, clock.elapsedBeforeMs + running); +} + +/** Whole seconds left, rounded up as the website shows them: 0:01 until the last moment. */ +export function remainingSeconds(clock: TimerClock, now: number): number { + return Math.max(0, Math.ceil((clock.durationMs - elapsedMs(clock, now)) / 1000)); +} + +export function startClock(clock: TimerClock, now: number): TimerClock { + if (clock.startedAt !== null || elapsedMs(clock, now) >= clock.durationMs) return clock; + return { ...clock, startedAt: now }; +} + +export function pauseClock(clock: TimerClock, now: number): TimerClock { + if (clock.startedAt === null) return clock; + return { ...clock, startedAt: null, elapsedBeforeMs: elapsedMs(clock, now) }; +} + +export type TimerSignal = 'beep' | 'double-beep'; + +/** + * The signal due when the time left moves from `previous` to `current` + * seconds: one beep on reaching the last minute, two beeps at time up. After + * a gap in the background that crossed both, only time up sounds. + */ +export function signalBetween(previous: number, current: number): TimerSignal | null { + if (previous > 0 && current === 0) return 'double-beep'; + if (previous > 60 && current <= 60 && current > 0) return 'beep'; + return null; +} + +export function formatClock(seconds: number): string { + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; +} diff --git a/eslint.config.js b/eslint.config.js index e8ffe872c..4d719f838 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -80,7 +80,7 @@ export default tseslint.config( ...reactHooks.configs.recommended.rules, "@typescript-eslint/no-unused-vars": "off", // Metro bundles images through require(). - "@typescript-eslint/no-require-imports": ["error", { allow: ["\\.png$"] }], + "@typescript-eslint/no-require-imports": ["error", { allow: ["\\.png$", "\\.wav$"] }], "no-restricted-globals": [ "error", ...["window", "document", "localStorage", "sessionStorage", "location"].map((name) => ({ diff --git a/package-lock.json b/package-lock.json index 1cb4e067a..c6a730bfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -125,11 +125,14 @@ "aes-js": "^3.1.2", "date-fns": "^3.6.0", "expo": "~57.0.27", + "expo-audio": "~57.0.5", "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.21", "expo-crypto": "~57.0.3", "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", + "expo-haptics": "~57.0.3", + "expo-keep-awake": "~57.0.2", "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.12", "expo-localization": "~57.0.2", @@ -553,6 +556,18 @@ "react-native": "*" } }, + "apps/mobile/node_modules/expo-audio": { + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/expo-audio/-/expo-audio-57.0.5.tgz", + "integrity": "sha512-39fX5JC5zDvELB4/eDJPj2XAYRN0W9+K2DfDWm5N3aC5WZ7HRrhupWtT6hbH09NlfYpsRSqYOwEZnocFM42ynQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "expo-asset": "*", + "react": "*", + "react-native": "*" + } + }, "apps/mobile/node_modules/expo-clipboard": { "version": "57.0.2", "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-57.0.2.tgz", @@ -621,6 +636,15 @@ "react-native": "*" } }, + "apps/mobile/node_modules/expo-haptics": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-57.0.3.tgz", + "integrity": "sha512-qucUXtwtJdtECKSQK5D6TQBc/0SxKj7s5wQ4mrfSxhZsHrS9a4rcxINl/gbruMaY9spTXStco32ST4pV0vXmxw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "apps/mobile/node_modules/expo-keep-awake": { "version": "57.0.2", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.2.tgz", diff --git a/src/locales/en.json b/src/locales/en.json index 9b7fa90ca..52eac808d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2525,6 +2525,12 @@ }, "attack": { "caseRequired": "Paste or write the case you want attacked." + }, + "timer": { + "speechOf": "Speech {{n}} of {{of}}", + "ofTotal": "of {{total}}", + "next": "Next speech", + "reset": "Reset" } } } diff --git a/src/locales/es.json b/src/locales/es.json index 309581610..79aeb78e7 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2525,6 +2525,12 @@ }, "attack": { "caseRequired": "Pega o escribe el caso que quieres atacar." + }, + "timer": { + "speechOf": "Discurso {{n}} de {{of}}", + "ofTotal": "de {{total}}", + "next": "Siguiente discurso", + "reset": "Reiniciar" } } } diff --git a/src/locales/hu.json b/src/locales/hu.json index 7abbb88ac..c5789d698 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2525,6 +2525,12 @@ }, "attack": { "caseRequired": "Illeszd be vagy írd le a megtámadandó esetet." + }, + "timer": { + "speechOf": "{{n}}. beszéd / {{of}}", + "ofTotal": "összesen {{total}}", + "next": "Következő beszéd", + "reset": "Visszaállítás" } } } From 84fd6c699aaa27940d8c82c2e8cafbf12c5b4d1c Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 15:27:04 +0000 Subject: [PATCH 2/8] Add typed Crossfire to the Practice tab Crossfire as the website's CrossfirePage in its typed mode: the same setup, the shared reducer and schemas from @debatelab/core, and the crossfire and crossfireSignal requests with the website's bodies, Ask model settings, debate context, 75 s and 5 s deadlines and response checks. The round runs full screen with its clock read from the end timestamp, keeps for the session if the student leaves it (a request cut off by leaving becomes one to retry), and is saved to crossfires in its debate when the debrief arrives. The debrief reads as a document in #130's style. Voice rounds need the development build, so the website's voice switch shows disabled with a note. The screenshot run fakes every turn, signal and debrief in the run's language. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 112 +++ .../src/app/(tabs)/(practice)/practice.tsx | 6 +- apps/mobile/src/app/_layout.tsx | 1 + apps/mobile/src/app/crossfire.tsx | 681 ++++++++++++++++++ .../mobile/src/components/crossfire-views.tsx | 240 ++++++ apps/mobile/src/components/practice-tools.ts | 2 +- apps/mobile/src/lib/ai-request.ts | 12 +- apps/mobile/src/lib/crossfire.ts | 176 +++++ src/locales/en.json | 3 + src/locales/es.json | 3 + src/locales/hu.json | 3 + 11 files changed, 1233 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/src/app/crossfire.tsx create mode 100644 apps/mobile/src/components/crossfire-views.tsx create mode 100644 apps/mobile/src/lib/crossfire.ts diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index c740f261c..28fac823e 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -36,6 +36,10 @@ * rendered in a page of its own, and the "Copy as text" output is written * next to the screenshots. * + * Crossfire screenshots never call a model and never save: the page answers + * each turn, signal and debrief with fixed text in the run's language, and + * answers the crossfire write locally. + * * The Timer runs on Playwright's clock, so its minutes pass at once, and the * run checks that it signalled one beep at the last minute and two at time up. */ @@ -436,6 +440,112 @@ async function shootAskScreens(context: BrowserContext, prefix: string, language await streaming.close(); } +// A made-up Crossfire exchange and debrief, in English and Hungarian, so the Hungarian shots carry no English model text. +const CROSSFIRE_TEXT = { + en: { + argument: 'Banning private cars from city centres cuts roadside pollution and gives streets back to people who walk, cycle and take the bus.', + questions: ['Who checks that the exemptions for carers actually work?', 'What happens to shops that depend on drivers?'], + answers: [ + 'Councils already issue permits for carers and shift workers, so the checks exist before the ban starts.', + 'Footfall studies show shops gain visitors when streets close to traffic, though a few car-dependent stores may lose trade.', + ], + debrief: { + concession: 'The opponent admitted that some car-dependent stores may lose trade (T4).', + controlled: 'Asking who checks the exemptions forced a concrete answer about permits.', + missed: 'You could have pressed on how many permits a typical city issues.', + better: 'How many permits would a city of a million people need to issue?', + reason: 'A number tests whether the exemptions undercut the health benefit.', + strongest: 'The question about shops drew out a concession you can use in rebuttal.', + }, + }, + hu: { + argument: 'Ha kitiltjuk a magánautókat a belvárosból, csökken az út menti szennyezés, és az utcák visszakerülnek a gyalogosokhoz, a biciklisekhez és a buszokhoz.', + questions: ['Ki ellenőrzi, hogy a gondozóknak adott kivételek valóban működnek?', 'Mi lesz azokkal a boltokkal, amelyek az autósokból élnek?'], + answers: [ + 'Az önkormányzatok már most is adnak engedélyt a gondozóknak és a műszakban dolgozóknak, így az ellenőrzés a tilalom előtt megvan.', + 'A forgalmi felmérések szerint a boltok több látogatót kapnak, ha az utcát lezárják, bár néhány autós vásárlókra épülő üzlet veszíthet.', + ], + debrief: { + concession: 'Az ellenfél elismerte, hogy néhány autós vásárlókra épülő üzlet veszíthet (T4).', + controlled: 'A kivételek ellenőrzéséről szóló kérdés konkrét választ kényszerített ki az engedélyekről.', + missed: 'Rákérdezhettél volna, hány engedélyt ad ki egy átlagos város.', + better: 'Hány engedélyt kellene kiadnia egy egymilliós városnak?', + reason: 'Egy szám megmutatja, hogy a kivételek nem rontják-e le az egészségügyi hasznot.', + strongest: 'A boltokról szóló kérdés olyan engedményt hozott, amelyet a cáfolatban felhasználhatsz.', + }, + }, +} as const; + +/** Fakes Crossfire's AI calls and its save for this page (see the header). Replies follow the request, as the client checks them. */ +async function fakeCrossfire(page: Page, language: keyof typeof CROSSFIRE_TEXT) { + const text = CROSSFIRE_TEXT[language]; + await page.route(`https://${STAGING_HOST}/functions/v1/ai`, async (route) => { + const body = route.request().postDataJSON() as { feature?: string; input?: { operation?: string; expectedReply?: 'question' | 'answer'; transcript?: { id: string }[] } } | null; + if (body?.feature !== 'crossfire') return route.abort(); + const input = body.input ?? {}; + const turns = input.transcript ?? []; + const exchange = Math.floor(turns.length / 2) % text.answers.length; + let output: unknown; + let extra: Record = {}; + if (input.operation === 'next-turn') { + const type = input.expectedReply ?? 'answer'; + output = { type, text: type === 'answer' ? text.answers[exchange] : text.questions[exchange] }; + extra = { signalToken: 'screenshot-signal' }; + } else if (input.operation === 'signal') { + output = { signal: { answerTurnId: `T${turns.length + 1}`, answerQuality: exchange === 0 ? 'direct' : 'partial', answerQualityConfidence: 0.82, concession: exchange === 0 ? 'none' : 'implicit', concessionConfidence: 0.74, followUpOpportunity: 0.81 } }; + } else { + const d = text.debrief; + output = { + concessionsSecured: [{ turnIds: ['T3', 'T4'], claim: d.concession }], + controlledQuestions: [{ turnIds: ['T1', 'T2'], claim: d.controlled }], + weakOrRepetitiveQuestions: [], + evasiveOrIncompleteAnswers: [], + missedFollowUps: [{ turnIds: ['T2'], claim: d.missed }], + betterFollowUpQuestions: [{ turnIds: ['T2'], question: d.better, reason: d.reason }], + strongestExchange: { turnIds: ['T3', 'T4'], claim: d.strongest }, + }; + } + return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: { output, ...extra } }); + }); + await page.route(`${REST}/crossfires*`, (route) => + route.request().method() === 'POST' ? route.fulfill({ status: 201, json: { id: '00000000-0000-4000-8000-00000000cf01' } }) : route.continue(), + ); +} + +/** Crossfire from the Practice tab: the setup, a typed exchange with its live signals, and the debrief, all from fixed outputs. */ +async function shootCrossfireScreens(context: BrowserContext, prefix: string, language: string, problems: string[]) { + const text = CROSSFIRE_TEXT[language === 'hu' ? 'hu' : 'en']; + const page = await context.newPage(); + watchErrors(page, problems, `${prefix} crossfire`); + await pinSettings(page, language); + await fakeCrossfire(page, language === 'hu' ? 'hu' : 'en'); + await page.goto(`${ORIGIN}/practice`); + await page.getByTestId('practice-tool-crossfire').click({ timeout: 30_000 }); + await page.getByTestId('crossfire-setup').waitFor({ timeout: 15_000 }); + // The resolution comes from the Current Debate, so the round attaches to it without the motion question. + await page.waitForFunction(() => (document.querySelector('[data-testid="crossfire-resolution"]') as HTMLTextAreaElement | null)?.value.trim()); + await page.getByTestId('crossfire-argument').fill(text.argument); + await settle(page); + await shoot(page, `${prefix}-crossfire-setup`); + + await page.getByRole('button', { name: /^(Start crossfire|Keresztkérdezés indítása|Iniciar interrogatorio)$/ }).click(); + await page.getByTestId('crossfire-live').waitFor({ timeout: 15_000 }); + for (const [index, question] of text.questions.entries()) { + await page.getByTestId('crossfire-input').fill(question); + await page.getByTestId('crossfire-send').click(); + await page.getByTestId('crossfire-turn').nth(index * 2 + 1).waitFor({ timeout: 15_000 }); + await page.getByTestId('crossfire-signal').nth(index).waitFor({ timeout: 15_000 }); + } + await page.waitForTimeout(400); + await shoot(page, `${prefix}-crossfire-exchange`); + + await page.getByTestId('crossfire-end').click(); + await page.getByTestId('crossfire-debrief').waitFor({ timeout: 15_000 }); + await page.waitForTimeout(400); + await shoot(page, `${prefix}-crossfire-debrief`); + await page.close(); +} + /** * The Timer from the Practice tab: idle, running, and after time up. The page's * clock is Playwright's, so minutes pass at once; the web preview records the @@ -618,6 +728,8 @@ async function main() { shots += 9; await shootTimerScreens(signedIn, prefix, language, problems); shots += 3; + await shootCrossfireScreens(signedIn, prefix, language, problems); + shots += 3; await signedIn.close(); } } diff --git a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx index e52c2c6c5..7d5f944f1 100644 --- a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx +++ b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx @@ -10,7 +10,7 @@ import { Screen } from '@/components/screen'; import { Text } from '@/components/text'; import { useTheme } from '@/theme/use-theme'; -/** The Practice tools. Attack, Ask and Timer open full screen; the others say they are on their way. */ +/** The Practice tools. Attack, Ask, Crossfire and Timer open full screen; the others say they are on their way. */ export default function PracticeScreen() { const { t } = useTranslation(); const router = useRouter(); @@ -30,7 +30,9 @@ export default function PracticeScreen() { ? router.push({ pathname: '/attack', params: { from: 'practice' } }) : tool.key === 'timer' ? router.push('/timer') - : router.push({ pathname: '/soon/[tool]', params: { tool: tool.key } })} + : tool.key === 'crossfire' + ? router.push({ pathname: '/crossfire', params: { from: 'practice' } }) + : router.push({ pathname: '/soon/[tool]', params: { tool: tool.key } })} testID={`practice-tool-${tool.key}`} style={({ pressed }) => [styles.row, index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 67dad4f1b..c56617187 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -88,6 +88,7 @@ function Root() { {/* Live practice screens, full screen with no tab bar, as the live round in mock 05. */} + diff --git a/apps/mobile/src/app/crossfire.tsx b/apps/mobile/src/app/crossfire.tsx new file mode 100644 index 000000000..73fa4cce9 --- /dev/null +++ b/apps/mobile/src/app/crossfire.tsx @@ -0,0 +1,681 @@ +import { createCrossfireSetupState, crossfireReducer, expectedUserTurnKind } from '@debatelab/core/crossfire/reducer'; +import { + CrossfireConfigSchema, + MAX_CROSSFIRE_ARGUMENT_CHARS, + MAX_CROSSFIRE_RESOLUTION_CHARS, + MAX_CROSSFIRE_TURN_CHARS, + type CrossfireState, + type CrossfireTurn, +} from '@debatelab/core/crossfire/schemas'; +import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; +import { useCallback, useEffect, useReducer, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, AppState, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { MainActionButton, TextButton } from '@/components/buttons'; +import { ChoiceCards, CrossfireDebriefDocument, CrossfireTranscript } from '@/components/crossfire-views'; +import { DocumentScreen, DocumentTitle, SectionHeading } from '@/components/document'; +import { DocumentHeader } from '@/components/document-header'; +import { Field } from '@/components/field'; +import { FormGroup, SwitchRow } from '@/components/form-rows'; +import { Icon } from '@/components/icon'; +import { LiveTopBar, RoundIconButton } from '@/components/round-controls'; +import { SegmentedControl } from '@/components/segmented-control'; +import { Text } from '@/components/text'; +import { TimerRing } from '@/components/timer-ring'; +import { ToolTitle, WorkingIn } from '@/components/tool-form'; +import { AIError, isAbortError, type ResponseExtras } from '@/lib/ai-request'; +import { useAuth } from '@/lib/auth'; +import { backLabel } from '@/lib/back-label'; +import { buildDebateContext } from '@/lib/context-request'; +import { + countActiveSources, + crossfireContext, + crossfireDebrief, + crossfireDraft, + CrossfireResponseError, + crossfireSignal, + crossfireTurn, + recordCrossfire, +} from '@/lib/crossfire'; +import { useDebates } from '@/lib/debate-context'; +import { fetchFeatureConfig } from '@/lib/feature-config'; +import { decideMotionTarget, shouldPrefillMotion } from '@/lib/motion'; +import { chooseMotionTarget } from '@/lib/motion-gate'; +import { keepSourceAssessments } from '@/lib/results'; +import { useSettings } from '@/lib/settings'; +import { formatClock } from '@/lib/timer'; +import { fontFamily } from '@/theme/fonts'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +type Active = Extract; +type Debriefing = Extract; +type ErrorKind = 'timeout' | 'response' | 'request'; + +const PLAN_MESSAGES = new Set(['mobile.results.errors.trialExpired', 'mobile.results.errors.limitReached', 'mobile.results.errors.modelRestricted']); + +/** The round left behind, as the website's restoreState: a request that was cut off becomes one to retry. */ +function restoredState(): CrossfireState { + const draft = crossfireDraft.get()?.state; + if (draft?.phase === 'active' && draft.request.status === 'pending') { + return { ...draft, request: { status: 'failed', expectedReply: draft.request.expectedReply, errorKind: 'request', message: 'crossfire.interruptedRequest' } }; + } + if (draft?.phase === 'debriefing' && draft.requestStatus === 'pending') { + return { ...draft, requestStatus: 'failed', errorKind: 'request', errorMessage: 'crossfire.interruptedDebrief' }; + } + if (draft?.phase === 'active' || draft?.phase === 'debriefing') return draft; + // A form keeps its texts and goes back to the website's defaults for the rest. + return createCrossfireSetupState(draft?.phase === 'setup' + ? { resolution: draft.config.resolution, argument: draft.config.argument, seedQuestions: draft.config.seedQuestions } + : undefined); +} + +/** Errors are stored as translation keys, so a restored round still reads in the current language. */ +function classify(cause: unknown, debrief: boolean): { kind: ErrorKind; message: string } { + if (cause instanceof CrossfireResponseError) return { kind: 'response', message: debrief ? 'crossfire.invalidDebriefResponseDesc' : 'crossfire.invalidResponseDesc' }; + if (cause instanceof AIError) return { kind: cause.kind === 'timeout' ? 'timeout' : 'request', message: `mobile.results.errors.${cause.kind}` }; + console.warn('Crossfire request failed:', cause instanceof Error ? cause.message : cause); + return { kind: 'request', message: 'mobile.results.errors.failed' }; +} + +function nextTurn(sequence: number, speaker: 'user' | 'ai', kind: 'question' | 'answer', text: string): CrossfireTurn { + return { id: `T${sequence}`, sequence, speaker, kind, text: text.trim(), createdAt: Date.now() }; +} + +/** + * Crossfire in its typed mode, as the website's CrossfirePage: set up a + * resolution and a case, question the AI or be questioned for a few timed + * minutes, then read the debrief. The live round is full screen; the round + * stays for the session if the student leaves it. Voice rounds need the + * development build (speech recognition and the opponent's voice). + */ +export default function CrossfireScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { from } = useLocalSearchParams<{ from?: string }>(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { debates, current, oneOff, create, switchTo, refresh } = useDebates(); + const { outputLanguage } = useSettings(); + const { colors } = useTheme(); + const [state, dispatch] = useReducer(crossfireReducer, undefined, restoredState); + // The debate chosen when the round started; null for one-off rounds. + const debateTarget = useRef(crossfireDraft.get()?.debateId ?? null); + const [draftTurn, setDraftTurn] = useState(''); + const [now, setNow] = useState(() => Date.now()); + const [formError, setFormError] = useState(''); + const [notice, setNotice] = useState(''); + const [activeSources, setActiveSources] = useState(null); + const requestRef = useRef(null); + const signalRequests = useRef(new Set()); + const live = useRef(true); + const owner = useRef(userId); + const scroll = useRef(null); + + useEffect(() => { + owner.current = userId; + }, [userId]); + + const abortSignals = useCallback(() => { + signalRequests.current.forEach((controller) => controller.abort()); + signalRequests.current.clear(); + }, []); + + // Leaving cancels what is on its way; the round itself stays for the session. + useEffect(() => { + live.current = true; + return () => { + live.current = false; + requestRef.current?.abort(); + abortSignals(); + }; + }, [abortSignals]); + + // The round is kept as it changes, as the website saves its draft; a finished one is not. + useEffect(() => { + crossfireDraft.set(state.phase === 'complete' ? null : { state, debateId: debateTarget.current }); + }, [state]); + + // The resolution follows the Current Debate unless the student typed their own. + const resolution = state.phase === 'setup' ? state.config.resolution : null; + useEffect(() => { + if (resolution === null || oneOff || !current) return; + if (shouldPrefillMotion(resolution, current.motion, debates.map((debate) => debate.motion))) { + dispatch({ type: 'config-patched', patch: { resolution: current.motion } }); + } + }, [current, debates, oneOff, resolution]); + + // The grounding switch says when the Current Debate's Library has no active sources. + const sourceDebate = oneOff ? null : current?.id ?? null; + useFocusEffect( + useCallback(() => { + if (!userId || !sourceDebate) { + setActiveSources(0); + return; + } + let active = true; + countActiveSources(userId, sourceDebate) + .then((count) => { + if (active) setActiveSources(count); + }) + .catch((cause: unknown) => console.warn('Could not count active sources:', cause instanceof Error ? cause.message : cause)); + return () => { + active = false; + }; + }, [userId, sourceDebate]), + ); + + // The round's clock reads its end timestamp; the tick only redraws it, and coming back from the background redraws at once. + useEffect(() => { + if (state.phase !== 'active') return; + setNow(Date.now()); + const timer = setInterval(() => setNow(Date.now()), 500); + const subscription = AppState.addEventListener('change', (next) => { + if (next === 'active') setNow(Date.now()); + }); + return () => { + clearInterval(timer); + subscription.remove(); + }; + }, [state.phase]); + + /** What the website does with every whole answer: save the source rankings, and say when the input was shortened. */ + const afterResponse = useCallback((forUser: string, debateId: string | null, extras: ResponseExtras) => { + if (extras.truncated) setNotice(`${t('aiClient.inputShortened')}. ${t('aiClient.inputShortenedDesc')}`); + void keepSourceAssessments(forUser, debateId, extras.assessments).then((saved) => { + if (!saved && live.current) setNotice(`${t('aiClient.sourceSaveFailed')}. ${t('aiClient.sourceAssessmentSaveFailed')}`); + }); + }, [t]); + + const requestContext = useCallback(async (forUser: string, debateId: string | null) => { + const [config, context] = await Promise.all([ + // The website sends Crossfire with the Ask tool's model settings. + fetchFeatureConfig(forUser, 'ask'), + debateId ? buildDebateContext(forUser, debateId, 'crossfire', 'both') : Promise.resolve(undefined), + ]); + return { config, context }; + }, []); + + const runNextTurn = useCallback(async (snapshot: Active, expectedReply: 'question' | 'answer', forUser: string) => { + requestRef.current?.abort(); + const controller = new AbortController(); + requestRef.current = controller; + const debateId = debateTarget.current; + try { + const { config, context } = await requestContext(forUser, debateId); + if (controller.signal.aborted) return; + const { reply, ...extras } = await crossfireTurn( + { operation: 'next-turn', config: snapshot.config, transcript: snapshot.turns, expectedReply }, + { config, language: outputLanguage, debateContext: crossfireContext(context, snapshot.config), signal: controller.signal }, + ); + if (controller.signal.aborted) return; + dispatch({ type: 'ai-turn-received', turn: nextTurn(snapshot.turns.length + 1, 'ai', reply.type, reply.text) }); + afterResponse(forUser, debateId, extras); + if (reply.signalToken) { + const signalController = new AbortController(); + signalRequests.current.add(signalController); + crossfireSignal( + { config: snapshot.config, transcript: snapshot.turns, reply: { type: reply.type, text: reply.text }, signalToken: reply.signalToken }, + { config, language: outputLanguage, signal: signalController.signal }, + ) + .then((signal) => { + if (signal && !signalController.signal.aborted) dispatch({ type: 'signal-received', signal }); + }) + .catch((): void => {}) + .finally(() => signalRequests.current.delete(signalController)); + } + } catch (cause) { + if (controller.signal.aborted || isAbortError(cause)) return; + if (cause instanceof AIError && cause.kind === 'debateMissing') void refresh(); + const { kind, message } = classify(cause, false); + dispatch({ type: 'request-failed', expectedReply, errorKind: kind, message }); + } + }, [afterResponse, outputLanguage, refresh, requestContext]); + + const runDebrief = useCallback(async (snapshot: Debriefing, forUser: string) => { + requestRef.current?.abort(); + const controller = new AbortController(); + requestRef.current = controller; + const debateId = debateTarget.current; + try { + const { config, context } = await requestContext(forUser, debateId); + if (controller.signal.aborted) return; + const { debrief, ...extras } = await crossfireDebrief( + { operation: 'debrief', config: snapshot.config, transcript: snapshot.turns }, + { config, language: outputLanguage, debateContext: crossfireContext(context, snapshot.config), signal: controller.signal }, + ); + if (controller.signal.aborted) return; + const action = { type: 'debrief-received' as const, debrief }; + const finished = crossfireReducer(snapshot, action); + dispatch(action); + afterResponse(forUser, debateId, extras); + // A round that finishes here is recorded in its debate once; one-off rounds are not saved, as on the website. + if (finished.phase === 'complete' && debateId) { + recordCrossfire(forUser, debateId, finished).catch((cause: unknown) => { + console.warn('Could not save the crossfire to the debate:', cause instanceof Error ? cause.message : cause); + if (live.current && owner.current === forUser) setNotice(t('debate.recordFailed')); + }); + } + } catch (cause) { + if (controller.signal.aborted || isAbortError(cause)) return; + if (cause instanceof AIError && cause.kind === 'debateMissing') void refresh(); + const { kind, message } = classify(cause, true); + dispatch({ type: 'debrief-failed', errorKind: kind, message }); + } + }, [afterResponse, outputLanguage, refresh, requestContext, t]); + + const endSession = useCallback((reason: 'manual' | 'timer') => { + if (state.phase !== 'active' || !userId) return; + requestRef.current?.abort(); + if (state.turns.length === 0) { + dispatch({ type: 'session-reset', prefill: { resolution: state.config.resolution, argument: state.config.argument, seedQuestions: state.config.seedQuestions } }); + setFormError(t('crossfire.debriefEmptyDesc')); + return; + } + const action = { type: 'session-ended' as const, now: Date.now(), reason }; + const next = crossfireReducer(state, action); + dispatch(action); + if (next.phase === 'debriefing') void runDebrief(next, userId); + }, [runDebrief, state, t, userId]); + + useEffect(() => { + if (state.phase === 'active' && now >= state.endsAt) endSession('timer'); + }, [endSession, now, state]); + + const beginSession = (debateId: string | null, forUser: string) => { + if (state.phase !== 'setup') return; + debateTarget.current = debateId; + setNotice(''); + const action = { type: 'session-started' as const, sessionId: `crossfire-${Date.now()}`, now: Date.now() }; + const next = crossfireReducer(state, action); + dispatch(action); + if (next.phase === 'active' && next.request.status === 'pending') void runNextTurn(next, next.request.expectedReply, forUser); + }; + + const startSession = async () => { + if (state.phase !== 'setup' || !userId) return; + const parsed = CrossfireConfigSchema.safeParse(state.config); + if (!parsed.success) { + setFormError(t('crossfire.validationError')); + return; + } + setFormError(''); + const forUser = userId; + const motion = parsed.data.resolution; + // Where the round belongs is settled before it starts, as the website's motion gate does. + const decision = decideMotionTarget(motion, { debates, current, oneOff }); + if (decision.kind !== 'ask') { + beginSession(decision.kind === 'attach' ? decision.debateId : null, forUser); + return; + } + const choice = await chooseMotionTarget(decision.matches, { + title: current ? t('debate.mismatchTitle') : t('debate.offerTitle'), + message: `“${motion}”\n\n${current ? t('debate.mismatchDesc', { motion: current.motion }) : t('debate.offerDesc')}`, + switchLabel: t('debate.switchToExisting'), + createLabel: current ? t('debate.createFromMotion') : t('debate.startDebate'), + onceLabel: current ? t('debate.useOnce') : t('debate.offerNotNow'), + cancel: t('common.cancel'), + }); + if (!choice || !live.current) return; + let debateId: string | null = null; + try { + if (choice.kind === 'switch') { + await switchTo(choice.debateId); + debateId = choice.debateId; + } else if (choice.kind === 'create') { + debateId = (await create({ motion, settings: {} })).id; + } + } catch (cause) { + console.warn('Could not save the debate:', cause instanceof Error ? cause.message : cause); + if (live.current) setFormError(t('debate.saveFailed')); + return; + } + if (live.current) beginSession(debateId, forUser); + }; + + const submitTurn = () => { + if (state.phase !== 'active' || !userId) return; + const kind = expectedUserTurnKind(state); + const text = draftTurn.trim(); + if (!kind || !text || text.length > MAX_CROSSFIRE_TURN_CHARS) return; + const expectedReply = kind === 'question' ? 'answer' : 'question'; + const action = { type: 'user-turn-submitted' as const, turn: nextTurn(state.turns.length + 1, 'user', kind, text), expectedReply } as const; + const next = crossfireReducer(state, action); + if (next === state) return; + dispatch(action); + setDraftTurn(''); + if (next.phase === 'active') void runNextTurn(next, expectedReply, userId); + }; + + const retryTurn = () => { + if (state.phase !== 'active' || state.request.status !== 'failed' || !userId) return; + const expectedReply = state.request.expectedReply; + const next = crossfireReducer(state, { type: 'request-retried' }); + dispatch({ type: 'request-retried' }); + if (next.phase === 'active') void runNextTurn(next, expectedReply, userId); + }; + + const retryDebrief = () => { + if (state.phase !== 'debriefing' || state.requestStatus !== 'failed' || !userId) return; + const next = crossfireReducer(state, { type: 'debrief-retried' }); + dispatch({ type: 'debrief-retried' }); + if (next.phase === 'debriefing') void runDebrief(next, userId); + }; + + const restart = () => { + requestRef.current?.abort(); + abortSignals(); + debateTarget.current = null; + dispatch({ type: 'session-reset' }); + setDraftTurn(''); + setFormError(''); + setNotice(''); + }; + + if (state.phase === 'setup') { + const config = state.config; + return ( + } + actions={ void startSession()} />}> + + + dispatch({ type: 'config-patched', patch: { resolution: value } })} + placeholder={t('crossfire.resolutionPlaceholder')} + multiline + style={styles.resolution} + testID="crossfire-resolution" + /> + dispatch({ type: 'config-patched', patch: { argument: value } })} + placeholder={t('crossfire.argumentPlaceholder')} + multiline + style={styles.argument} + testID="crossfire-argument" + /> + dispatch({ type: 'config-patched', patch: { mode } })} + testIDPrefix="crossfire-mode" + options={[ + { value: 'question-ai', label: t('crossfire.modeQuestionAi'), description: t('crossfire.modeQuestionAiDesc') }, + { value: 'ai-questions-user', label: t('crossfire.modeAiQuestionsUser'), description: t('crossfire.modeAiQuestionsUserDesc') }, + ]} + /> + dispatch({ type: 'config-patched', patch: { style } })} + testIDPrefix="crossfire-style" + options={[ + { value: 'cooperative', label: t('crossfire.styleCooperative'), description: t('crossfire.styleCooperativeDesc') }, + { value: 'evasive', label: t('crossfire.styleEvasive'), description: t('crossfire.styleEvasiveDesc') }, + { value: 'aggressive', label: t('crossfire.styleAggressive'), description: t('crossfire.styleAggressiveDesc') }, + ]} + /> + + + {t('crossfire.durationLabel')} + + + value={String(config.durationSeconds) as '180' | '300' | '480'} + onChange={(value) => dispatch({ type: 'config-patched', patch: { durationSeconds: Number(value) as 180 | 300 | 480 } })} + options={[ + { value: '180', label: t('crossfire.duration3m') }, + { value: '300', label: t('crossfire.duration5m') }, + { value: '480', label: t('crossfire.duration8m') }, + ]} + /> + + {config.seedQuestions.length > 0 ? ( + + {config.seedQuestions.map((question, index) => ( + + {question} + + ))} + + ) : null} + + dispatch({ type: 'config-patched', patch: { useSourceLibrary } })} + /> + {/* Voice rounds need speech recognition and the opponent's voice, which come with the development build. */} + {}} + disabled + /> + + {formError ? ( + + {formError} + + ) : null} + + ); + } + + if (state.phase === 'complete') { + return ( + } + notice={notice} + actions={}> + + + {t('crossfire.debriefSubtitle')} + + + + + + ); + } + + // The live round and its debrief wait: full screen, following the system theme like mock 05. + const inputKind = expectedUserTurnKind(state); + const total = state.config.durationSeconds; + const remaining = state.phase === 'active' ? Math.max(0, Math.ceil((state.endsAt - now) / 1000)) : 0; + const modeLabel = state.config.mode === 'question-ai' ? t('crossfire.modeQuestionAi') : t('crossfire.modeAiQuestionsUser'); + const styleKey = `crossfire.style${state.config.style[0].toUpperCase()}${state.config.style.slice(1)}`; + const canSend = Boolean(draftTurn.trim()) && inputKind !== null; + + return ( + + {/* The setup's document header is replaced by the live bar. */} + + router.back()} testID="crossfire-close" />} + title={t('crossfire.title')} + subtitle={`${modeLabel} · ${t(styleKey)}`} + /> + + + 0 ? remaining / total : 0} warning={state.phase === 'active' && remaining < 30}> + + {formatClock(remaining)} + + + + + + {t('crossfire.timerLabel')} + + + {state.config.resolution} + + + {state.phase === 'active' ? ( + endSession('manual')} + testID="crossfire-end" + style={({ pressed }) => [styles.end, { borderColor: colors.border, backgroundColor: colors.input }, pressed && styles.pressed]}> + + {t('crossfire.endSession')} + + + ) : null} + + + scroll.current?.scrollToEnd({ animated: true })}> + + + {notice ? ( + + {notice} + + ) : null} + + {state.phase === 'active' && state.request.status === 'pending' ? ( + + + + {t('crossfire.thinking')} + + + ) : null} + + {state.phase === 'active' && state.request.status === 'failed' ? ( + + + {state.request.errorKind === 'timeout' ? t('crossfire.timeout') : state.request.errorKind === 'response' ? t('crossfire.invalidResponse') : t('crossfire.error')} + + + {state.request.errorKind === 'timeout' ? t('crossfire.timeoutDesc') : t(state.request.message)} + + + + {PLAN_MESSAGES.has(state.request.message) ? router.navigate('/you')} /> : null} + + + ) : null} + + {state.phase === 'debriefing' ? ( + + {state.requestStatus === 'pending' ? ( + <> + + + {t('crossfire.debriefLoading')} + + + {t('crossfire.debriefSubtitle')} + + + ) : ( + <> + + + {state.errorKind === 'timeout' ? t('crossfire.debriefTimeout') : t('crossfire.debriefError')} + + + {t(state.errorMessage ?? 'crossfire.errorDesc')} + + + + )} + + ) : null} + + + {inputKind ? ( + + + + {inputKind === 'question' ? t('crossfire.questionLabel') : t('crossfire.answerLabel')} + + + + + + + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + flex: { flex: 1 }, + resolution: { minHeight: 76, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, + argument: { minHeight: 150, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, + duration: { gap: 7 }, + groupLabel: { fontSize: 14, marginLeft: 4 }, + seed: { fontSize: 15.5, lineHeight: 22, paddingHorizontal: 15, paddingVertical: 10 }, + formError: { fontSize: 15, lineHeight: 21, marginLeft: 4 }, + subtitle: { fontSize: 15.5, lineHeight: 22, marginTop: -6 }, + clockRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 18, paddingTop: 12, paddingBottom: 12, borderBottomWidth: StyleSheet.hairlineWidth, width: '100%', maxWidth: 720, alignSelf: 'center' }, + clockText: { fontSize: 14.5, fontVariant: ['tabular-nums'] }, + clockInfo: { flex: 1, gap: 1 }, + clockLabel: { fontSize: 13 }, + resolutionLine: { fontSize: 15, lineHeight: 20 }, + end: { borderWidth: 1, borderRadius: radius.pill, paddingHorizontal: 14, minHeight: 38, justifyContent: 'center' }, + endLabel: { fontSize: 14.5 }, + pressed: { opacity: 0.7 }, + liveContent: { paddingHorizontal: 18, paddingTop: 16, paddingBottom: 24, gap: 14, width: '100%', maxWidth: 720, alignSelf: 'center' }, + notice: { fontSize: 14, lineHeight: 19 }, + thinking: { flexDirection: 'row', alignItems: 'center', gap: 10 }, + thinkingText: { fontSize: 15 }, + error: { borderWidth: 1, borderRadius: radius.card, padding: 14, gap: 4 }, + errorTitle: { fontSize: 16, textAlign: 'center' }, + errorBody: { fontSize: 15, lineHeight: 21 }, + errorActions: { flexDirection: 'row', gap: 20 }, + debriefWait: { borderWidth: 1, borderStyle: 'dashed', borderRadius: radius.card + 2, paddingVertical: 30, paddingHorizontal: 20, alignItems: 'center', gap: 10 }, + waitBody: { fontSize: 15, lineHeight: 21, textAlign: 'center' }, + composer: { borderTopWidth: StyleSheet.hairlineWidth, paddingTop: 10 }, + composerColumn: { width: '100%', maxWidth: 720, alignSelf: 'center', paddingHorizontal: 16, gap: 6 }, + composerLabel: { fontSize: 13.5, marginLeft: 6 }, + inputRow: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 24, paddingLeft: 16, paddingRight: 6, paddingVertical: 6, minHeight: 48 }, + input: { flex: 1, fontSize: 17, fontFamily: fontFamily.regular, paddingTop: 7, paddingBottom: 7, maxHeight: 140 }, + send: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, + sendDisabled: { opacity: 0.45 }, +}); diff --git a/apps/mobile/src/components/crossfire-views.tsx b/apps/mobile/src/components/crossfire-views.tsx new file mode 100644 index 000000000..1cb314d03 --- /dev/null +++ b/apps/mobile/src/components/crossfire-views.tsx @@ -0,0 +1,240 @@ +import type { CrossfireDebrief, CrossfireTurn } from '@debatelab/core/crossfire/schemas'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { Callout, DocSection, Paragraph, Tag } from './document'; +import { Icon } from './icon'; +import { Text } from './text'; + +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** One option card of a radio group, as the website's mode and style cards: a label, a line under it and a check. */ +export function ChoiceCards({ + label, + options, + value, + onChange, + testIDPrefix, +}: { + label: string; + options: { value: T; label: string; description: string }[]; + value: T; + onChange: (value: T) => void; + testIDPrefix: string; +}) { + const { colors } = useTheme(); + return ( + + + {label} + + + {options.map((option, index) => { + const selected = option.value === value; + return ( + onChange(option.value)} + testID={`${testIDPrefix}-${option.value}`} + style={({ pressed }) => [styles.card, index > 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }, pressed && styles.pressed]}> + + + {option.label} + + + {option.description} + + + {selected ? : } + + ); + })} + + + ); +} + +/** The labels of a turn's live coaching signal, as the website's badges. */ +function SignalTags({ turn }: { turn: CrossfireTurn }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const signal = turn.kind === 'answer' ? turn.signal : undefined; + if (!signal) return null; + return ( + + + + + + + + {signal.followUpOpportunity > 0.7 ? ( + + + + ) : null} + + ); +} + +/** The exchange so far: the student's turns on the right, the opponent's on the left, each with its id. */ +export function CrossfireTranscript({ turns }: { turns: CrossfireTurn[] }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + if (turns.length === 0) { + return ( + + + {t('crossfire.emptyDesc')} + + + ); + } + return ( + + {turns.map((turn) => { + const mine = turn.speaker === 'user'; + return ( + + + + {turn.id} + + + {turn.kind === 'question' ? t('crossfire.question') : t('crossfire.answer')} + + + + {turn.text} + + + + ); + })} + + ); +} + +/** The transcript turns a debrief point rests on. */ +function TurnRefs({ turnIds }: { turnIds: string[] }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + return ( + + {turnIds.map((id, index) => ( + + + {id} + + + ))} + + ); +} + +function Findings({ title, items }: { title: string; items: { turnIds: string[]; claim: string }[] }) { + const { t } = useTranslation(); + return ( + + {items.length === 0 ? ( + + {t('crossfire.noGroundedFinding')} + + ) : ( + items.map((item, index) => ( + + + + + )) + )} + + ); +} + +/** The debrief as a document (mock 03's style), in the website's order. */ +export function CrossfireDebriefDocument({ debrief }: { debrief: CrossfireDebrief }) { + const { t } = useTranslation(); + return ( + + + + + + + + {debrief.betterFollowUpQuestions.length === 0 ? ( + + {t('crossfire.noGroundedFinding')} + + ) : ( + debrief.betterFollowUpQuestions.map((item, index) => ( + + + {item.question} + + + + + )) + )} + + + + {debrief.strongestExchange ? ( + + + + + ) : ( + + {t('crossfire.noGroundedFinding')} + + )} + + + + ); +} + +const styles = StyleSheet.create({ + group: { gap: 7 }, + groupLabel: { fontSize: 14, marginLeft: 4 }, + cards: { borderWidth: 1, borderRadius: radius.field, overflow: 'hidden' }, + card: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 15, paddingVertical: 11, minHeight: 56 }, + cardText: { flex: 1, gap: 2 }, + cardLabel: { fontSize: 16.5 }, + cardDescription: { fontSize: 14, lineHeight: 19 }, + checkSpace: { width: 20 }, + pressed: { opacity: 0.7 }, + tags: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 4 }, + empty: { borderWidth: 1, borderStyle: 'dashed', borderRadius: radius.card, padding: 22 }, + emptyText: { fontSize: 15, lineHeight: 21, textAlign: 'center' }, + turns: { gap: 10 }, + turn: { borderWidth: 1, borderRadius: 18, paddingHorizontal: 14, paddingVertical: 11, gap: 5, maxWidth: '92%' }, + mine: { alignSelf: 'flex-end', borderBottomRightRadius: 6 }, + theirs: { alignSelf: 'flex-start', borderBottomLeftRadius: 6 }, + turnHead: { flexDirection: 'row', justifyContent: 'space-between', gap: 16 }, + turnId: { fontSize: 13 }, + turnKind: { fontSize: 13 }, + turnText: { fontSize: 16, lineHeight: 23 }, + refs: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, + ref: { borderWidth: 1, borderRadius: 7, paddingHorizontal: 7, paddingVertical: 2 }, + refText: { fontSize: 12.5, lineHeight: 17 }, + none: { fontSize: 15, lineHeight: 21 }, + finding: { gap: 8 }, + question: { fontSize: 15.5, lineHeight: 22 }, + debrief: { gap: 4 }, + strongest: { marginTop: 12 }, +}); diff --git a/apps/mobile/src/components/practice-tools.ts b/apps/mobile/src/components/practice-tools.ts index 63034356c..bc26f7788 100644 --- a/apps/mobile/src/components/practice-tools.ts +++ b/apps/mobile/src/components/practice-tools.ts @@ -1,6 +1,6 @@ import type { IconName } from './icon-paths'; -/** The Practice tools, in the website's order. Attack, Ask and Timer are in the app; the rest follow. */ +/** The Practice tools, in the website's order. Attack, Ask, Crossfire and Timer are in the app; the rest follow. */ export const PRACTICE_TOOLS = [ { key: 'attack', icon: 'shield' }, { key: 'ask', icon: 'chat' }, diff --git a/apps/mobile/src/lib/ai-request.ts b/apps/mobile/src/lib/ai-request.ts index f6af55746..03dc3378e 100644 --- a/apps/mobile/src/lib/ai-request.ts +++ b/apps/mobile/src/lib/ai-request.ts @@ -80,12 +80,18 @@ export async function payloadOf(response: { json: () => Promise }): Pro export type FunctionResponse = Awaited>; -/** One POST with the website's headers, a 200 s timeout and the caller's abort. */ -export async function postFunction(path: string, body: Record, signal: AbortSignal | undefined, run: (response: FunctionResponse) => Promise): Promise { +/** One POST with the website's headers, its timeout (200 s unless the feature sets its own) and the caller's abort. */ +export async function postFunction( + path: string, + body: Record, + signal: AbortSignal | undefined, + run: (response: FunctionResponse) => Promise, + timeoutMs = REQUEST_TIMEOUT_MS, +): Promise { // The deadline and the caller's abort start before the session read, so // leaving the screen while the token refreshes never sends the request. const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const timeout = setTimeout(() => controller.abort(), timeoutMs); const abortForCaller = () => controller.abort(); signal?.addEventListener('abort', abortForCaller); if (signal?.aborted) controller.abort(); diff --git a/apps/mobile/src/lib/crossfire.ts b/apps/mobile/src/lib/crossfire.ts new file mode 100644 index 000000000..2ea35af4b --- /dev/null +++ b/apps/mobile/src/lib/crossfire.ts @@ -0,0 +1,176 @@ +import { + collectDebriefTurnIds, + CrossfireDebriefSchema, + CrossfireReplySchema, + CrossfireTurnSignalSchema, + type CrossfireAIInput, + type CrossfireConfig, + type CrossfireDebrief, + type CrossfireReply, + type CrossfireState, + type CrossfireTurn, + type CrossfireTurnSignal, +} from '@debatelab/core/crossfire/schemas'; +import type { DebateContextRequest } from '@debatelab/core/contract/debate-context'; + +import { functionOutput, postFunction, PROVIDER, responseExtras, type ResponseExtras } from './ai-request'; +import type { FeatureConfig } from './feature-config'; +import { sessionSlot } from './session-memory'; +import { supabase } from './supabase'; + +/** + * Typed Crossfire, sent and saved as the website does it: crossfire and + * crossfireSignal in src/lib/ai/client.ts for the requests, and + * CrossfirePage's recordResult call for the saved row. Voice rounds need the + * development build; the requests here never ask for a spoken reply. + */ + +/** The website's Crossfire deadlines: 75 s for a turn or the debrief, 5 s for a coaching signal. */ +const TURN_TIMEOUT_MS = 75 * 1000; +const SIGNAL_TIMEOUT_MS = 5 * 1000; + +/** The model answered, but not in the shape the round needs. Shown as an unreadable response. */ +export class CrossfireResponseError extends Error { + constructor(message: string) { + super(message); + this.name = 'CrossfireResponseError'; + } +} + +interface RequestOptions { + config: FeatureConfig; + language: string; + /** Absent for one-off work. */ + debateContext?: DebateContextRequest; + signal?: AbortSignal; +} + +// Turn signals and measured delivery facts are client annotations. The Edge +// Function accepts only the turn fields it signs and prompts with. +function serverTranscript(turns: CrossfireTurn[]) { + return turns.map(({ signal: _signal, delivery: _delivery, ...turn }) => turn); +} + +function requestBody(input: Record & { transcript: CrossfireTurn[] }, options: RequestOptions) { + return { + feature: 'crossfire', + input: { ...input, transcript: serverTranscript(input.transcript), language: options.language }, + provider: PROVIDER, + model: options.config.model, + temperature: options.config.temperature, + reasoningEffort: options.config.reasoningEffort, + // Servers from before Current Debate context read only this field. + sourceIds: options.debateContext?.sourceIds ?? [], + ...(options.debateContext ? { debateContext: options.debateContext } : {}), + }; +} + +/** The Library sources a round may use: none unless the student turned grounding on. */ +export function crossfireContext(context: DebateContextRequest | undefined, config: CrossfireConfig): DebateContextRequest | undefined { + return context && !config.useSourceLibrary ? { ...context, sourceIds: [] } : context; +} + +export type CrossfireNextTurn = CrossfireReply & { signalToken?: string }; + +/** The opponent's next question or answer, checked as the website checks it. */ +export function crossfireTurn( + input: Extract, + options: RequestOptions, +): Promise<{ reply: CrossfireNextTurn } & ResponseExtras> { + return postFunction('ai', requestBody(input, options), options.signal, async (response) => { + const { output, data } = await functionOutput(response); + const reply = CrossfireReplySchema.safeParse(output); + if (!reply.success || reply.data.type !== input.expectedReply) { + throw new CrossfireResponseError('AI response did not match the expected Crossfire turn.'); + } + const token: unknown = data.signalToken; + if (token !== undefined && (typeof token !== 'string' || !token.trim() || token.length > 128)) { + throw new CrossfireResponseError('Crossfire signal token was invalid.'); + } + return { reply: typeof token === 'string' ? { ...reply.data, signalToken: token } : reply.data, ...responseExtras(data) }; + }, TURN_TIMEOUT_MS); +} + +/** The debrief at the end of a round. Every turn it cites must be in the transcript. */ +export function crossfireDebrief( + input: Extract, + options: RequestOptions, +): Promise<{ debrief: CrossfireDebrief } & ResponseExtras> { + return postFunction('ai', requestBody(input, options), options.signal, async (response) => { + const { output, data } = await functionOutput(response); + const debrief = CrossfireDebriefSchema.safeParse(output); + if (!debrief.success) throw new CrossfireResponseError('AI response did not match the expected Crossfire debrief.'); + const known = new Set(input.transcript.map((turn) => turn.id)); + if (collectDebriefTurnIds(debrief.data).some((id) => !known.has(id))) { + throw new CrossfireResponseError('AI debrief referenced transcript turns that do not exist.'); + } + return { debrief: debrief.data, ...responseExtras(data) }; + }, TURN_TIMEOUT_MS); +} + +/** + * The live coaching signal for an answer, asked for after a turn as the + * website does: no debate material, a 5 s budget, and nothing at all when + * the server has none to give. + */ +export function crossfireSignal( + input: { config: CrossfireConfig; transcript: CrossfireTurn[]; reply: CrossfireReply; signalToken: string }, + options: Omit, +): Promise { + return postFunction('ai', requestBody({ operation: 'signal', ...input }, options), options.signal, async (response) => { + const { output } = await functionOutput(response); + if (!output || typeof output !== 'object' || Array.isArray(output) || !('signal' in output)) return undefined; + const parsed = CrossfireTurnSignalSchema.safeParse((output as { signal: unknown }).signal); + if (!parsed.success) throw new CrossfireResponseError('Crossfire signal did not match the expected schema.'); + const turns = [...input.transcript, { id: `T${input.transcript.length + 1}`, kind: input.reply.type }]; + const target = turns.find((turn) => turn.id === parsed.data.answerTurnId); + if (!target || target.kind !== 'answer') throw new CrossfireResponseError('Crossfire signal referenced an unknown or non-answer turn.'); + return parsed.data; + }, SIGNAL_TIMEOUT_MS); +} + +/** + * Saves a finished round into its debate, as CrossfirePage's recordResult + * call: the resolution as title, the config as input, and the transcript, + * debrief and why it ended as output. Rounds have no side. Returns the row's id. + */ +export async function recordCrossfire( + userId: string, + debateId: string, + round: Extract, +): Promise { + const { data, error } = await supabase + .from('crossfires') + .insert({ + user_id: userId, + debate_id: debateId, + side: 'neutral', + title: round.config.resolution.slice(0, 300) || 'Untitled', + input: round.config, + output: { turns: round.turns, debrief: round.debrief, endedReason: round.endedReason }, + }) + .select('id') + .single(); + if (error || !data) throw new Error(error?.message ?? 'Could not save the crossfire'); + return (data as { id: string }).id; +} + +/** How many of the debate's Library sources are active, for the grounding switch's hint. */ +export async function countActiveSources(userId: string, debateId: string): Promise { + const { count, error } = await supabase + .from('sources') + .select('id', { count: 'exact', head: true }) + .eq('user_id', userId) + .eq('debate_id', debateId) + .eq('is_active', true); + if (error) throw new Error(error.message); + return count ?? 0; +} + +/** + * The unfinished round, kept for the session as the website keeps its draft + * in the browser: leaving the screen and coming back continues it. It + * belongs to the signed-in account and is forgotten when that changes. The + * debate is the one chosen when the round started; null for one-off rounds. + */ +export const crossfireDraft = sessionSlot<{ state: CrossfireState; debateId: string | null } | null>(() => null); diff --git a/src/locales/en.json b/src/locales/en.json index 52eac808d..fbf255a5b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2531,6 +2531,9 @@ "ofTotal": "of {{total}}", "next": "Next speech", "reset": "Reset" + }, + "crossfire": { + "voiceLater": "Coming with the app build." } } } diff --git a/src/locales/es.json b/src/locales/es.json index 79aeb78e7..48f66e69f 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2531,6 +2531,9 @@ "ofTotal": "de {{total}}", "next": "Siguiente discurso", "reset": "Reiniciar" + }, + "crossfire": { + "voiceLater": "Llegará con la versión de la app." } } } diff --git a/src/locales/hu.json b/src/locales/hu.json index c5789d698..8a9f247fa 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2531,6 +2531,9 @@ "ofTotal": "összesen {{total}}", "next": "Következő beszéd", "reset": "Visszaállítás" + }, + "crossfire": { + "voiceLater": "Az alkalmazás következő verziójával érkezik." } } } From 077a5183bd1b5813e1d8a82cfe7d48b56b24e3e1 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 15:32:56 +0000 Subject: [PATCH 3/8] Describe the Timer and Crossfire in the app guide Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 0d9febb40..a77638245 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -16,12 +16,14 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp ## Layout -- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/index.tsx` and `attack/[id].tsx`, full screen above the tabs. All are declared in the root `_layout.tsx`. `(prepare)/coming/[tool].tsx` and `(practice)/soon/[tool].tsx` stand in for tools not in the app yet. +- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/index.tsx`, `attack/[id].tsx`, `crossfire.tsx` and `timer.tsx` (a full-screen modal), full screen above the tabs. All are declared in the root `_layout.tsx`. `(prepare)/coming/[tool].tsx` and `(practice)/soon/[tool].tsx` stand in for tools not in the app yet. - `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview. - `src/lib/`: auth (`auth.tsx`), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror. - `sources.ts` mirrors the session store's `addSource` and `removeSource` and `src/lib/source-import.ts`. The Library always shows the Current Debate, also in one-off mode, as on the website. Adding a file (the website parses files in the browser) and Scan pages are not in the app yet. - Ask mirrors `src/lib/ai/streaming.ts` and `askChat` in `src/lib/ai/client.ts`: `ask.ts` sends the requests (plain questions stream from `ai-stream` through `expo/fetch`; with Citations, Web search, Pros/Cons or Argument map on, the whole answer comes from `ai`, because `ai-stream` refuses those options), `ai-request.ts` holds the shared POST with the website's headers, timeout and error kinds, `feature-config.ts` reads each tool's model from `user_settings.feature_configs`, `context-request.ts` builds the Current Debate context with the website's default selection and pins, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM. - Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`, checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export and Google Docs are not in the app. +- Timer mirrors `src/pages/TimerPage.tsx`: `timer.ts` holds the formats and a clock made of start timestamps, never counted ticks, so it is right after the app returns from the background (`timer.test.ts`). `timer-signals.ts` plays the bundled beeps in `assets/sounds` (made by `scripts/render-timer-sounds.ts`) with expo-audio, taps with expo-haptics and keeps the screen awake while a speech runs; `timer-signals.web.ts` only records the signals for the screenshot script. Lock-screen timers wait for the development build (`TODO(live-activity)`). +- Crossfire is the website's typed mode: `crossfire.ts` sends `crossfire` and its signal requests as `src/lib/ai/client.ts` does (Ask's model settings, 75 s and 5 s deadlines) and saves a finished round to `crossfires` in its debate, as CrossfirePage's `recordResult` call; the reducer and schemas are `packages/core/src/crossfire`. The unfinished round is a session slot, so leaving the screen keeps it. Voice rounds need the development build, so the voice switch shows disabled. The website cannot reopen a saved round, so neither can the app. - One-off mode is session-only and never written, as on the website. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. - `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts. - `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs. From 96afa66325f6f881db886e0ae95e633c17096c33 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 15:37:29 +0000 Subject: [PATCH 4/8] Fit the Crossfire end button and centre the Timer on iPad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Hungarian, "Munkamenet befejezése" squeezed the round's resolution to two words; the button now wraps its label. On iPad the speech chips and the signals card sat left of the centred ring. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/crossfire.tsx | 9 +++++---- apps/mobile/src/app/timer.tsx | 5 +++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/app/crossfire.tsx b/apps/mobile/src/app/crossfire.tsx index 73fa4cce9..2a5526a49 100644 --- a/apps/mobile/src/app/crossfire.tsx +++ b/apps/mobile/src/app/crossfire.tsx @@ -533,7 +533,7 @@ export default function CrossfireScreen() { onPress={() => endSession('manual')} testID="crossfire-end" style={({ pressed }) => [styles.end, { borderColor: colors.border, backgroundColor: colors.input }, pressed && styles.pressed]}> - + {t('crossfire.endSession')} @@ -655,11 +655,12 @@ const styles = StyleSheet.create({ subtitle: { fontSize: 15.5, lineHeight: 22, marginTop: -6 }, clockRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 18, paddingTop: 12, paddingBottom: 12, borderBottomWidth: StyleSheet.hairlineWidth, width: '100%', maxWidth: 720, alignSelf: 'center' }, clockText: { fontSize: 14.5, fontVariant: ['tabular-nums'] }, - clockInfo: { flex: 1, gap: 1 }, + clockInfo: { flex: 1, minWidth: 0, gap: 1 }, clockLabel: { fontSize: 13 }, resolutionLine: { fontSize: 15, lineHeight: 20 }, - end: { borderWidth: 1, borderRadius: radius.pill, paddingHorizontal: 14, minHeight: 38, justifyContent: 'center' }, - endLabel: { fontSize: 14.5 }, + // Long labels ("Munkamenet befejezése") wrap instead of squeezing the resolution. + end: { borderWidth: 1, borderRadius: 19, paddingHorizontal: 14, paddingVertical: 6, minHeight: 38, justifyContent: 'center', maxWidth: '38%' }, + endLabel: { fontSize: 14.5, textAlign: 'center' }, pressed: { opacity: 0.7 }, liveContent: { paddingHorizontal: 18, paddingTop: 16, paddingBottom: 24, gap: 14, width: '100%', maxWidth: 720, alignSelf: 'center' }, notice: { fontSize: 14, lineHeight: 19 }, diff --git a/apps/mobile/src/app/timer.tsx b/apps/mobile/src/app/timer.tsx index 6aec05a00..91ec8f3e6 100644 --- a/apps/mobile/src/app/timer.tsx +++ b/apps/mobile/src/app/timer.tsx @@ -221,8 +221,9 @@ const styles = StyleSheet.create({ status: { fontSize: 14, lineHeight: 19 }, time: { letterSpacing: -2, fontVariant: ['tabular-nums'] }, speechRow: { flexGrow: 0, alignSelf: 'stretch', marginHorizontal: -20 }, - speeches: { gap: 8, paddingHorizontal: 20 }, - signals: { alignSelf: 'stretch', maxWidth: 520, width: '100%', borderWidth: 1, borderRadius: 14, paddingHorizontal: 14, paddingVertical: 11, gap: 3 }, + // Centred when they fit, as on iPad; scrolling from the start when they don't. + speeches: { gap: 8, paddingHorizontal: 20, flexGrow: 1, justifyContent: 'center' }, + signals: { alignSelf: 'center', maxWidth: 520, width: '100%', borderWidth: 1, borderRadius: 14, paddingHorizontal: 14, paddingVertical: 11, gap: 3 }, signalTitle: { flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 2 }, signalText: { fontSize: 14, lineHeight: 19 }, controls: { position: 'absolute', left: 0, right: 0, bottom: 0, flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'space-evenly', paddingTop: 12 }, From be93c5ac9260237e7c520d34d31984cff1ee1e20 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 16:45:53 +0000 Subject: [PATCH 5/8] Count the characters left in a Crossfire turn The website shows crossfire.charactersRemaining under the turn field. It was left out because plural strings did not render on Hermes; #129's Intl.PluralRules polyfill fixes that, so the composer now shows it beside its label, and the screenshot script checks it reads as a number. The app guide notes that the Crossfire setup shows one-off mode through WorkingIn, as the website's CurrentDebateStrip does. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 2 +- apps/mobile/scripts/screenshots.ts | 3 +++ apps/mobile/src/app/crossfire.tsx | 16 ++++++++++++---- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 78e989989..36690565f 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -25,7 +25,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`, checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export and Google Docs are not in the app. - Timer mirrors `src/pages/TimerPage.tsx`: `timer.ts` holds the formats and a clock made of start timestamps, never counted ticks, so it is right after the app returns from the background (`timer.test.ts`). `timer-signals.ts` plays the bundled beeps in `assets/sounds` (made by `scripts/render-timer-sounds.ts`) with expo-audio, taps with expo-haptics and keeps the screen awake while a speech runs; `timer-signals.web.ts` only records the signals for the screenshot script. Lock-screen timers wait for the development build (`TODO(live-activity)`). - Crossfire is the website's typed mode: `crossfire.ts` sends `crossfire` and its signal requests as `src/lib/ai/client.ts` does (Ask's model settings, 75 s and 5 s deadlines) and saves a finished round to `crossfires` in its debate, as CrossfirePage's `recordResult` call; the reducer and schemas are `packages/core/src/crossfire`. The unfinished round is a session slot, so leaving the screen keeps it. Voice rounds need the development build, so the voice switch shows disabled. The website cannot reopen a saved round, so neither can the app. -- One-off mode is session-only and never written, as on the website. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library, Generate and Attack (through `WorkingIn`). The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. +- One-off mode is session-only and never written, as on the website. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library and the Generate, Attack and Crossfire forms (through `WorkingIn`). The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. - `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts. - `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs. diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index a742f3e98..0b7422481 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -558,6 +558,9 @@ async function shootCrossfireScreens(context: BrowserContext, prefix: string, la await page.getByTestId('crossfire-live').waitFor({ timeout: 15_000 }); for (const [index, question] of text.questions.entries()) { await page.getByTestId('crossfire-input').fill(question); + // The count is a plural string; without plural rules it shows its key instead. + const count = (await page.getByTestId('crossfire-count').textContent()) ?? ''; + if (!/\d/.test(count) || count.includes('charactersRemaining')) throw new Error(`Crossfire character count read "${count}"`); await page.getByTestId('crossfire-send').click(); await page.getByTestId('crossfire-turn').nth(index * 2 + 1).waitFor({ timeout: 15_000 }); await page.getByTestId('crossfire-signal').nth(index).waitFor({ timeout: 15_000 }); diff --git a/apps/mobile/src/app/crossfire.tsx b/apps/mobile/src/app/crossfire.tsx index 2a5526a49..452571881 100644 --- a/apps/mobile/src/app/crossfire.tsx +++ b/apps/mobile/src/app/crossfire.tsx @@ -610,9 +610,15 @@ export default function CrossfireScreen() { {inputKind ? ( - - {inputKind === 'question' ? t('crossfire.questionLabel') : t('crossfire.answerLabel')} - + + + {inputKind === 'question' ? t('crossfire.questionLabel') : t('crossfire.answerLabel')} + + {/* The website's count; plural forms need the Intl.PluralRules polyfill on Hermes. */} + + {t('crossfire.charactersRemaining', { count: MAX_CROSSFIRE_TURN_CHARS - draftTurn.length })} + + Date: Fri, 9 Oct 2026 16:45:53 +0000 Subject: [PATCH 6/8] Keep timer signals across a pause and bound Crossfire's reads Pausing as the Timer turned from 1:01 to 1:00 moved its last reading past the threshold without a decision, so that speech never beeped. Time left only falls while the clock runs, so every fall is now checked (signalFor in timer.ts); only a fresh clock after a reset or another speech is skipped. A Crossfire turn or debrief read its model settings and Library context before its 75 s deadline and outside its abort, so a stalled read kept the turn pending past 75 s and leaving could not cancel it. withTurnDeadline now starts the deadline first and passes one signal through the reads and the request. A finished round was saved from the state when the debrief was asked for, so a coaching signal that arrived meanwhile was shown but not saved. The round is now saved from the state it reaches, as CrossfirePage does. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/crossfire.tsx | 77 ++++++++++++++++----------- apps/mobile/src/app/timer.tsx | 11 ++-- apps/mobile/src/lib/crossfire.test.ts | 33 ++++++++++++ apps/mobile/src/lib/crossfire.ts | 40 +++++++++++++- apps/mobile/src/lib/timer.test.ts | 42 ++++++++++++++- apps/mobile/src/lib/timer.ts | 11 ++++ 6 files changed, 176 insertions(+), 38 deletions(-) create mode 100644 apps/mobile/src/lib/crossfire.test.ts diff --git a/apps/mobile/src/app/crossfire.tsx b/apps/mobile/src/app/crossfire.tsx index 452571881..b6b9d4200 100644 --- a/apps/mobile/src/app/crossfire.tsx +++ b/apps/mobile/src/app/crossfire.tsx @@ -25,7 +25,7 @@ import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; import { TimerRing } from '@/components/timer-ring'; import { ToolTitle, WorkingIn } from '@/components/tool-form'; -import { AIError, isAbortError, type ResponseExtras } from '@/lib/ai-request'; +import { abortError, AIError, isAbortError, type ResponseExtras } from '@/lib/ai-request'; import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; import { buildDebateContext } from '@/lib/context-request'; @@ -38,6 +38,8 @@ import { crossfireSignal, crossfireTurn, recordCrossfire, + untilAborted, + withTurnDeadline, } from '@/lib/crossfire'; import { useDebates } from '@/lib/debate-context'; import { fetchFeatureConfig } from '@/lib/feature-config'; @@ -110,6 +112,8 @@ export default function CrossfireScreen() { const [notice, setNotice] = useState(''); const [activeSources, setActiveSources] = useState(null); const requestRef = useRef(null); + // The finished round still to be recorded in its debate. + const pendingRecord = useRef<{ sessionId: string; forUser: string; debateId: string } | null>(null); const signalRequests = useRef(new Set()); const live = useRef(true); const owner = useRef(userId); @@ -190,12 +194,16 @@ export default function CrossfireScreen() { }); }, [t]); - const requestContext = useCallback(async (forUser: string, debateId: string | null) => { - const [config, context] = await Promise.all([ - // The website sends Crossfire with the Ask tool's model settings. - fetchFeatureConfig(forUser, 'ask'), - debateId ? buildDebateContext(forUser, debateId, 'crossfire', 'both') : Promise.resolve(undefined), - ]); + // Read inside the request's deadline and abort, so a stalled read neither outlasts 75 s nor survives leaving. + const requestContext = useCallback(async (forUser: string, debateId: string | null, signal: AbortSignal) => { + const [config, context] = await untilAborted( + Promise.all([ + // The website sends Crossfire with the Ask tool's model settings. + fetchFeatureConfig(forUser, 'ask'), + debateId ? buildDebateContext(forUser, debateId, 'crossfire', 'both') : Promise.resolve(undefined), + ]), + signal, + ); return { config, context }; }, []); @@ -205,12 +213,15 @@ export default function CrossfireScreen() { requestRef.current = controller; const debateId = debateTarget.current; try { - const { config, context } = await requestContext(forUser, debateId); - if (controller.signal.aborted) return; - const { reply, ...extras } = await crossfireTurn( - { operation: 'next-turn', config: snapshot.config, transcript: snapshot.turns, expectedReply }, - { config, language: outputLanguage, debateContext: crossfireContext(context, snapshot.config), signal: controller.signal }, - ); + const { config, reply, ...extras } = await withTurnDeadline(controller.signal, async (signal) => { + const { config, context } = await requestContext(forUser, debateId, signal); + if (signal.aborted) throw abortError(); + const turn = await crossfireTurn( + { operation: 'next-turn', config: snapshot.config, transcript: snapshot.turns, expectedReply }, + { config, language: outputLanguage, debateContext: crossfireContext(context, snapshot.config), signal }, + ); + return { config, ...turn }; + }); if (controller.signal.aborted) return; dispatch({ type: 'ai-turn-received', turn: nextTurn(snapshot.turns.length + 1, 'ai', reply.type, reply.text) }); afterResponse(forUser, debateId, extras); @@ -241,31 +252,37 @@ export default function CrossfireScreen() { requestRef.current = controller; const debateId = debateTarget.current; try { - const { config, context } = await requestContext(forUser, debateId); + const { debrief, ...extras } = await withTurnDeadline(controller.signal, async (signal) => { + const { config, context } = await requestContext(forUser, debateId, signal); + if (signal.aborted) throw abortError(); + return crossfireDebrief( + { operation: 'debrief', config: snapshot.config, transcript: snapshot.turns }, + { config, language: outputLanguage, debateContext: crossfireContext(context, snapshot.config), signal }, + ); + }); if (controller.signal.aborted) return; - const { debrief, ...extras } = await crossfireDebrief( - { operation: 'debrief', config: snapshot.config, transcript: snapshot.turns }, - { config, language: outputLanguage, debateContext: crossfireContext(context, snapshot.config), signal: controller.signal }, - ); - if (controller.signal.aborted) return; - const action = { type: 'debrief-received' as const, debrief }; - const finished = crossfireReducer(snapshot, action); - dispatch(action); + // The effect below saves the finished round from the state it reaches, with any signal that came in meanwhile. + if (debateId) pendingRecord.current = { sessionId: snapshot.sessionId, forUser, debateId }; + dispatch({ type: 'debrief-received', debrief }); afterResponse(forUser, debateId, extras); - // A round that finishes here is recorded in its debate once; one-off rounds are not saved, as on the website. - if (finished.phase === 'complete' && debateId) { - recordCrossfire(forUser, debateId, finished).catch((cause: unknown) => { - console.warn('Could not save the crossfire to the debate:', cause instanceof Error ? cause.message : cause); - if (live.current && owner.current === forUser) setNotice(t('debate.recordFailed')); - }); - } } catch (cause) { if (controller.signal.aborted || isAbortError(cause)) return; if (cause instanceof AIError && cause.kind === 'debateMissing') void refresh(); const { kind, message } = classify(cause, true); dispatch({ type: 'debrief-failed', errorKind: kind, message }); } - }, [afterResponse, outputLanguage, refresh, requestContext, t]); + }, [afterResponse, outputLanguage, refresh, requestContext]); + + // A round that finishes here is recorded in its debate once, from its latest state, as CrossfirePage does; one-off rounds are not saved. + useEffect(() => { + const pending = pendingRecord.current; + if (!pending || state.phase !== 'complete' || state.sessionId !== pending.sessionId) return; + pendingRecord.current = null; + recordCrossfire(pending.forUser, pending.debateId, state).catch((cause: unknown) => { + console.warn('Could not save the crossfire to the debate:', cause instanceof Error ? cause.message : cause); + if (live.current && owner.current === pending.forUser) setNotice(t('debate.recordFailed')); + }); + }, [state, t]); const endSession = useCallback((reason: 'manual' | 'timer') => { if (state.phase !== 'active' || !userId) return; diff --git a/apps/mobile/src/app/timer.tsx b/apps/mobile/src/app/timer.tsx index 91ec8f3e6..99e990b27 100644 --- a/apps/mobile/src/app/timer.tsx +++ b/apps/mobile/src/app/timer.tsx @@ -15,7 +15,7 @@ import { idleClock, pauseClock, remainingSeconds, - signalBetween, + signalFor, startClock, TIMER_FORMATS, type TimerClock, @@ -66,16 +66,15 @@ export default function TimerScreen() { }; }, [clock.startedAt]); - // Signals fire as the time left crosses a threshold, and the clock stops at zero. + // Signals fire as the time left crosses a threshold, also when a pause lands on it, and the clock stops at zero. const lastRemaining = useRef(remaining); useEffect(() => { const previous = lastRemaining.current; lastRemaining.current = remaining; - if (clock.startedAt === null) return; - const due = signalBetween(previous, remaining); + const due = signalFor(previous, remaining, clock); if (due) signal(due, sound); - if (remaining === 0) setClock((current) => pauseClock(current, Date.now())); - }, [clock.startedAt, remaining, signal, sound]); + if (remaining === 0 && clock.startedAt !== null) setClock((current) => pauseClock(current, Date.now())); + }, [clock, remaining, signal, sound]); const selectSpeech = (index: number, formatSpeeches = format.speeches) => { const next = formatSpeeches[index]; diff --git a/apps/mobile/src/lib/crossfire.test.ts b/apps/mobile/src/lib/crossfire.test.ts new file mode 100644 index 000000000..fa9874697 --- /dev/null +++ b/apps/mobile/src/lib/crossfire.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./supabase', () => ({ supabase: {} })); +vi.mock('expo/fetch', () => ({ fetch: vi.fn() })); + +import { AIError } from './ai-request'; +import { untilAborted, withTurnDeadline } from './crossfire'; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('withTurnDeadline', () => { + it('times out a turn whose settings read stalls, 75 s after the turn started', async () => { + vi.useFakeTimers(); + const stalled = new Promise(() => {}); + const turn = withTurnDeadline(new AbortController().signal, (signal) => untilAborted(stalled, signal)); + const outcome = turn.catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(74_999); + expect(await Promise.race([outcome, Promise.resolve('pending')])).toBe('pending'); + await vi.advanceTimersByTimeAsync(1); + const error = await outcome; + expect(error).toBeInstanceOf(AIError); + expect((error as AIError).kind).toBe('timeout'); + }); + + it('cancels a stalled read as soon as the student leaves', async () => { + const leaving = new AbortController(); + const turn = withTurnDeadline(leaving.signal, (signal) => untilAborted(new Promise(() => {}), signal)); + leaving.abort(); + await expect(turn).rejects.toMatchObject({ name: 'AbortError' }); + }); +}); diff --git a/apps/mobile/src/lib/crossfire.ts b/apps/mobile/src/lib/crossfire.ts index 2ea35af4b..f7bcaae1b 100644 --- a/apps/mobile/src/lib/crossfire.ts +++ b/apps/mobile/src/lib/crossfire.ts @@ -13,7 +13,7 @@ import { } from '@debatelab/core/crossfire/schemas'; import type { DebateContextRequest } from '@debatelab/core/contract/debate-context'; -import { functionOutput, postFunction, PROVIDER, responseExtras, type ResponseExtras } from './ai-request'; +import { abortError, AIError, functionOutput, postFunction, PROVIDER, responseExtras, type ResponseExtras } from './ai-request'; import type { FeatureConfig } from './feature-config'; import { sessionSlot } from './session-memory'; import { supabase } from './supabase'; @@ -70,6 +70,44 @@ export function crossfireContext(context: DebateContextRequest | undefined, conf return context && !config.useSourceLibrary ? { ...context, sourceIds: [] } : context; } +/** + * A turn or debrief within the website's 75 s, counted from before the model + * settings and Library context are read, so a stalled read times out like the + * request. The caller's signal (leaving the screen) cancels it at any await. + */ +export async function withTurnDeadline(signal: AbortSignal, work: (signal: AbortSignal) => Promise): Promise { + const controller = new AbortController(); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, TURN_TIMEOUT_MS); + const abortForCaller = () => controller.abort(); + signal.addEventListener('abort', abortForCaller); + if (signal.aborted) controller.abort(); + try { + if (controller.signal.aborted) throw abortError(); + return await work(controller.signal); + } catch (error) { + if (signal.aborted) throw abortError(); + if (timedOut) throw new AIError('timeout', 'The request took too long'); + throw error; + } finally { + clearTimeout(timeout); + signal.removeEventListener('abort', abortForCaller); + } +} + +/** Settles with the read, or rejects as soon as the signal aborts. The read itself cannot be cancelled, only no longer waited for. */ +export function untilAborted(read: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(abortError()); + return new Promise((resolve, reject) => { + const onAbort = () => reject(abortError()); + signal.addEventListener('abort', onAbort, { once: true }); + read.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort)); + }); +} + export type CrossfireNextTurn = CrossfireReply & { signalToken?: string }; /** The opponent's next question or answer, checked as the website checks it. */ diff --git a/apps/mobile/src/lib/timer.test.ts b/apps/mobile/src/lib/timer.test.ts index de4398510..15afc441c 100644 --- a/apps/mobile/src/lib/timer.test.ts +++ b/apps/mobile/src/lib/timer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { idleClock, pauseClock, remainingSeconds, signalBetween, startClock } from './timer'; +import { idleClock, pauseClock, remainingSeconds, signalBetween, signalFor, startClock, type TimerClock, type TimerSignal } from './timer'; const T0 = 1_000_000; @@ -37,3 +37,43 @@ describe('timer signals', () => { expect(signalBetween(150, 30)).toBe('beep'); }); }); + +describe('timer signals across a pause', () => { + /** Reads the clock as the screen does on each redraw, collecting the signals it sounds. */ + function reader(clock: TimerClock, now: number) { + let last = remainingSeconds(clock, now); + const heard: TimerSignal[] = []; + return { + heard, + read(next: TimerClock, at: number) { + const current = remainingSeconds(next, at); + const due = signalFor(last, current, next); + last = current; + if (due) heard.push(due); + }, + }; + } + + it('beeps once when a pause lands as 1:01 turns to 1:00, and not again on resume', () => { + let clock = startClock(idleClock(2), T0); + const screen = reader(clock, T0); + screen.read(clock, T0 + 59_200); // shows 1:01 + clock = pauseClock(clock, T0 + 60_100); // real time left is now 59.9 s, shown as 1:00 + screen.read(clock, T0 + 60_100); + clock = startClock(clock, T0 + 90_000); + screen.read(clock, T0 + 91_000); // 0:59 + expect(screen.heard).toEqual(['beep']); + }); + + it('sounds nothing when a reset or another speech moves the time left', () => { + let clock = startClock(idleClock(2), T0); + const screen = reader(clock, T0); + screen.read(clock, T0 + 30_000); // 1:30 + // A fresh one-minute clock drops the reading from 1:30 to 1:00 without the speech running. + clock = idleClock(1); + screen.read(clock, T0 + 30_000); + clock = idleClock(4); + screen.read(clock, T0 + 30_000); + expect(screen.heard).toEqual([]); + }); +}); diff --git a/apps/mobile/src/lib/timer.ts b/apps/mobile/src/lib/timer.ts index c4c77e940..6850b5177 100644 --- a/apps/mobile/src/lib/timer.ts +++ b/apps/mobile/src/lib/timer.ts @@ -114,6 +114,17 @@ export function signalBetween(previous: number, current: number): TimerSignal | return null; } +/** + * The signal due between two readings of the time left, as the screen redraws. + * Time left only falls while the clock runs, so every fall is checked, also one + * that ends in a pause: pausing as 1:01 turns to 1:00 still beeps. A fresh + * clock (a reset or another speech) moves the reading up and sounds nothing. + */ +export function signalFor(previous: number, current: number, clock: TimerClock): TimerSignal | null { + if (clock.startedAt === null && clock.elapsedBeforeMs === 0) return null; + return signalBetween(previous, current); +} + export function formatClock(seconds: number): string { return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; } From 595cbdc936014feef0296d3a00ab45ff9bce6e5b Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 11:35:56 +0000 Subject: [PATCH 7/8] End every AI request at its deadline when the session read stalls The shared request helper now waits for the session read only until the deadline or the caller's abort, so a stalled token refresh can't hold a Crossfire turn or any other request past its timeout. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/crossfire.tsx | 3 +-- apps/mobile/src/lib/ai-request.ts | 17 +++++++++++++---- apps/mobile/src/lib/crossfire.test.ts | 23 +++++++++++++++++++---- apps/mobile/src/lib/crossfire.ts | 10 ---------- 4 files changed, 33 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/app/crossfire.tsx b/apps/mobile/src/app/crossfire.tsx index b6b9d4200..ccb38b211 100644 --- a/apps/mobile/src/app/crossfire.tsx +++ b/apps/mobile/src/app/crossfire.tsx @@ -25,7 +25,7 @@ import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; import { TimerRing } from '@/components/timer-ring'; import { ToolTitle, WorkingIn } from '@/components/tool-form'; -import { abortError, AIError, isAbortError, type ResponseExtras } from '@/lib/ai-request'; +import { abortError, AIError, isAbortError, type ResponseExtras, untilAborted } from '@/lib/ai-request'; import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; import { buildDebateContext } from '@/lib/context-request'; @@ -38,7 +38,6 @@ import { crossfireSignal, crossfireTurn, recordCrossfire, - untilAborted, withTurnDeadline, } from '@/lib/crossfire'; import { useDebates } from '@/lib/debate-context'; diff --git a/apps/mobile/src/lib/ai-request.ts b/apps/mobile/src/lib/ai-request.ts index 03dc3378e..b428a506b 100644 --- a/apps/mobile/src/lib/ai-request.ts +++ b/apps/mobile/src/lib/ai-request.ts @@ -43,6 +43,16 @@ export function abortError(): Error { return error; } +/** Settles with the read, or rejects as soon as the signal aborts. The read itself cannot be cancelled, only no longer waited for. */ +export function untilAborted(read: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(abortError()); + return new Promise((resolve, reject) => { + const onAbort = () => reject(abortError()); + signal.addEventListener('abort', onAbort, { once: true }); + read.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort)); + }); +} + export function utf8Length(value: string): number { let bytes = 0; for (const char of value) { @@ -89,16 +99,15 @@ export async function postFunction( timeoutMs = REQUEST_TIMEOUT_MS, ): Promise { // The deadline and the caller's abort start before the session read, so - // leaving the screen while the token refreshes never sends the request. + // leaving the screen while the token refreshes never sends the request, and + // a refresh that stalls ends at the deadline instead of when it resolves. const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); const abortForCaller = () => controller.abort(); signal?.addEventListener('abort', abortForCaller); if (signal?.aborted) controller.abort(); try { - if (controller.signal.aborted) throw abortError(); - const token = await accessToken(); - if (controller.signal.aborted) throw abortError(); + const token = await untilAborted(accessToken(), controller.signal); const json = JSON.stringify(body); if (utf8Length(json) > MAX_REQUEST_BODY_BYTES) throw new AIError('tooLarge', 'Request is too large'); const response = await fetch(`${supabaseProject.url}/functions/v1/${path}`, { diff --git a/apps/mobile/src/lib/crossfire.test.ts b/apps/mobile/src/lib/crossfire.test.ts index fa9874697..a782309be 100644 --- a/apps/mobile/src/lib/crossfire.test.ts +++ b/apps/mobile/src/lib/crossfire.test.ts @@ -1,10 +1,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -vi.mock('./supabase', () => ({ supabase: {} })); -vi.mock('expo/fetch', () => ({ fetch: vi.fn() })); +const fetchMock = vi.hoisted(() => vi.fn()); -import { AIError } from './ai-request'; -import { untilAborted, withTurnDeadline } from './crossfire'; +// The session read never settles, as when a token refresh stalls. +vi.mock('./supabase', () => ({ supabase: { auth: { getSession: () => new Promise(() => {}) } } })); +vi.mock('expo/fetch', () => ({ fetch: fetchMock })); + +import { AIError, postFunction, untilAborted } from './ai-request'; +import { withTurnDeadline } from './crossfire'; afterEach(() => { vi.useRealTimers(); @@ -31,3 +34,15 @@ describe('withTurnDeadline', () => { await expect(turn).rejects.toMatchObject({ name: 'AbortError' }); }); }); + +describe('postFunction', () => { + it('ends at its deadline when the session read stalls, without sending the request', async () => { + vi.useFakeTimers(); + const request = postFunction('ai', {}, undefined, async () => 'answer', 75_000).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(75_000); + const error = await request; + expect(error).toBeInstanceOf(AIError); + expect((error as AIError).kind).toBe('timeout'); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/lib/crossfire.ts b/apps/mobile/src/lib/crossfire.ts index f7bcaae1b..496fa5403 100644 --- a/apps/mobile/src/lib/crossfire.ts +++ b/apps/mobile/src/lib/crossfire.ts @@ -98,16 +98,6 @@ export async function withTurnDeadline(signal: AbortSignal, work: (signal: Ab } } -/** Settles with the read, or rejects as soon as the signal aborts. The read itself cannot be cancelled, only no longer waited for. */ -export function untilAborted(read: Promise, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.reject(abortError()); - return new Promise((resolve, reject) => { - const onAbort = () => reject(abortError()); - signal.addEventListener('abort', onAbort, { once: true }); - read.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort)); - }); -} - export type CrossfireNextTurn = CrossfireReply & { signalToken?: string }; /** The opponent's next question or answer, checked as the website checks it. */ From 8f05fe3a211678d47f355d43db2f7fef28712ed7 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 11:47:45 +0000 Subject: [PATCH 8/8] Skip the session read for a request cancelled before it starts A request whose caller had already left still read the session, and if that read failed nobody handled the error. Check the abort first, and let untilAborted absorb a read it no longer waits for. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/ai-request.ts | 7 ++++++- apps/mobile/src/lib/crossfire.test.ts | 11 ++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/lib/ai-request.ts b/apps/mobile/src/lib/ai-request.ts index b428a506b..ee9674304 100644 --- a/apps/mobile/src/lib/ai-request.ts +++ b/apps/mobile/src/lib/ai-request.ts @@ -45,7 +45,11 @@ export function abortError(): Error { /** Settles with the read, or rejects as soon as the signal aborts. The read itself cannot be cancelled, only no longer waited for. */ export function untilAborted(read: Promise, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.reject(abortError()); + if (signal.aborted) { + // Nobody waits for the read any more, so its own failure must not go unhandled. + read.catch(() => {}); + return Promise.reject(abortError()); + } return new Promise((resolve, reject) => { const onAbort = () => reject(abortError()); signal.addEventListener('abort', onAbort, { once: true }); @@ -107,6 +111,7 @@ export async function postFunction( signal?.addEventListener('abort', abortForCaller); if (signal?.aborted) controller.abort(); try { + if (controller.signal.aborted) throw abortError(); const token = await untilAborted(accessToken(), controller.signal); const json = JSON.stringify(body); if (utf8Length(json) > MAX_REQUEST_BODY_BYTES) throw new AIError('tooLarge', 'Request is too large'); diff --git a/apps/mobile/src/lib/crossfire.test.ts b/apps/mobile/src/lib/crossfire.test.ts index a782309be..3e7aa4b0f 100644 --- a/apps/mobile/src/lib/crossfire.test.ts +++ b/apps/mobile/src/lib/crossfire.test.ts @@ -3,13 +3,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const fetchMock = vi.hoisted(() => vi.fn()); // The session read never settles, as when a token refresh stalls. -vi.mock('./supabase', () => ({ supabase: { auth: { getSession: () => new Promise(() => {}) } } })); +const getSession = vi.hoisted(() => vi.fn(() => new Promise(() => {}))); +vi.mock('./supabase', () => ({ supabase: { auth: { getSession } } })); vi.mock('expo/fetch', () => ({ fetch: fetchMock })); import { AIError, postFunction, untilAborted } from './ai-request'; import { withTurnDeadline } from './crossfire'; afterEach(() => { + vi.clearAllMocks(); vi.useRealTimers(); }); @@ -45,4 +47,11 @@ describe('postFunction', () => { expect((error as AIError).kind).toBe('timeout'); expect(fetchMock).not.toHaveBeenCalled(); }); + + it('never reads the session for a request cancelled before it starts', async () => { + const leaving = new AbortController(); + leaving.abort(); + await expect(postFunction('ai', {}, leaving.signal, async () => 'answer')).rejects.toMatchObject({ name: 'AbortError' }); + expect(getSession).not.toHaveBeenCalled(); + }); });