Skip to content
Merged
3 changes: 2 additions & 1 deletion apps/mobile/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp
- Google sign-in needs a development build, because Expo Go cannot receive OAuth redirects. Until then, sign-in is email code or demo code.
- Native code has no DOM. eslint rejects `window`, `document`, `localStorage`, `sessionStorage` and `location` outside `*.web.ts(x)` files. A `.web.tsx` file exists only for the web preview.
- `ios/` and `android/` are generated and gitignored. Configure native behaviour in `app.json`.
- A form sheet's root view is `SheetScrollView` (`src/components/sheet.tsx`), with nothing around it: no `KeyboardAvoidingView`, no wrapping `View`. react-native-screens positions only a scroll view that is the sheet's direct child; a nested one lands below the visible sheet and the sheet looks empty.

## Layout

Expand All @@ -22,7 +23,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp
- `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.
- 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.
- 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.
- `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.

Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
"userInterfaceStyle": "automatic",
"ios": {
"bundleIdentifier": "com.adam.debatelab",
"supportsTablet": true
"supportsTablet": true,
"infoPlist": {
"CADisableMinimumFrameDurationOnPhone": true
}
},
"android": {
"adaptiveIcon": {
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"dependencies": {
"@debatelab/core": "*",
"@expo-google-fonts/inter": "^0.4.2",
"@formatjs/intl-pluralrules": "^6.3.15",
"@react-native-async-storage/async-storage": "2.2.0",
"@supabase/supabase-js": "^2.49.1",
"aes-js": "^3.1.2",
Expand Down
78 changes: 75 additions & 3 deletions apps/mobile/scripts/screenshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
* therefore answer the app's user_settings reads with the run's language and
* the System theme; nothing is written. The Debate tab shows the account's
* real Current Debate, and its empty state comes from answering the
* current_debates read with no row. The Debate tab is shot once per side view.
* current_debates read with no row. The Debate tab is shot once per side view,
* and with one-off mode on, with and without a Current Debate; Prepare and
* Generate are shot in one-off mode too. One-off mode is in memory only; the
* script toggles it and never taps a debate row.
*
* Ask screenshots never call a model and never save a chat: the page answers
* ai-stream from a fake stream inside the page (expo/fetch uses the page's
Expand Down Expand Up @@ -149,12 +152,35 @@ async function shootDebateScreens(page: Page, prefix: string) {
await page.waitForTimeout(700);
await shoot(page, `${prefix}-debates-sheet`);

// One-off mode lives in memory only, so toggling it writes nothing. Never tap a debate row
// here: switching saves the pointer and last activity to staging.
await turnOnOneOff(page);
await shoot(page, `${prefix}-debates-oneoff`);
await closeSwitcher(page);
await page.getByTestId('one-off-notice').waitFor();
await shoot(page, `${prefix}-debate-oneoff`);
await page.getByTestId('one-off-notice').getByRole('button').click();
await page.getByTestId('one-off-notice').waitFor({ state: 'detached' });

await page.getByTestId('current-debate-pill').click();
await page.getByTestId('debates-sheet').waitFor();
await page.getByRole('button', { name: /^(New debate|Új vita|Nuevo debate)$/ }).click();
await page.getByTestId('new-debate-sheet').waitFor();
await page.waitForTimeout(700);
await shoot(page, `${prefix}-new-debate`);
}

async function turnOnOneOff(page: Page) {
const toggle = page.getByTestId('debates-sheet').getByRole('switch');
await toggle.click();
if (!(await toggle.isChecked())) throw new Error('The one-off switch did not turn on');
}

async function closeSwitcher(page: Page) {
await page.getByTestId('debates-sheet').getByRole('button', { name: /^(Done|Kész|Listo)$/ }).click();
await page.getByTestId('debates-sheet').waitFor({ state: 'detached' });
}

// A made-up answer in the shape of mock 07, streamed a word at a time.
const ASK_QUESTION = 'How do I answer "what about the nurse who drives in at 5 a.m."?';
const ASK_STREAM = 'Concede the edge case, then shrink it. Three moves:\n\n1. **Exemptions exist.** Every real ban permits shift workers and carers.\n2. **Night buses follow demand.** Cities that closed their centres added routes in the first year.\n3. **Flip it.** The same nurse breathes the air on her walk from the car park.';
Expand Down Expand Up @@ -433,6 +459,34 @@ async function shootAskScreens(context: BrowserContext, prefix: string, language
await streaming.close();
}

/** Prepare and Generate in one-off mode, reached in the app so the in-memory mode survives. Nothing is sent or saved. */
async function shootOneOffTools(page: Page, prefix: string) {
await page.goto(`${ORIGIN}/`);
// Opening the switcher takes the tab out of focus, which drops a load still running; let it finish first.
await page.getByTestId('current-debate-motion').waitFor({ timeout: 30_000 });
await page.waitForFunction(() => !document.querySelector('[data-testid="debate-counts"]')?.textContent?.includes('–'));
await settle(page);
await page.getByTestId('current-debate-pill').click();
await page.getByTestId('debates-sheet').waitFor();
await settle(page);
await turnOnOneOff(page);
await closeSwitcher(page);
await page.getByRole('tab', { name: /^(Prepare|Felkészülés|Preparar)$/ }).click();
await page.getByTestId('prepare-tool-generate').waitFor({ timeout: 30_000 });
// The Debate tab stays mounted behind Prepare with its own notice; only the visible one is Prepare's.
await page.getByTestId('one-off-notice').filter({ visible: true }).waitFor();
await settle(page);
await shoot(page, `${prefix}-prepare-oneoff`);
await page.getByTestId('prepare-tool-generate').click();
await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 });
const notice = page.getByTestId('generate-screen').getByTestId('one-off-notice');
await notice.waitFor();
await shoot(page, `${prefix}-generate-oneoff`);
// Return to debate turns the mode off in place, and the strip says where work goes again.
await notice.getByRole('button').click();
await page.getByTestId('generate-screen').getByTestId('working-in').waitFor();
}

async function shootEmptyDebate(page: Page, prefix: string) {
await page.route(`${REST}/current_debates*`, (route) =>
route.fulfill({ status: 200, contentType: 'application/json', json: [] }),
Expand All @@ -441,6 +495,18 @@ async function shootEmptyDebate(page: Page, prefix: string) {
await page.getByTestId('current-debate-pill').waitFor({ timeout: 30_000 });
await settle(page);
await shoot(page, `${prefix}-debate-empty`);

// One-off mode works without a Current Debate too; there is nothing to return to.
await page.getByTestId('current-debate-pill').click();
await page.getByTestId('debates-sheet').waitFor();
await settle(page);
await turnOnOneOff(page);
await closeSwitcher(page);
await page.getByTestId('one-off-notice').waitFor();
if ((await page.getByTestId('one-off-notice').getByRole('button').count()) > 0) {
throw new Error('One-off mode offered a return without a Current Debate');
}
await shoot(page, `${prefix}-debate-empty-oneoff`);
}

async function signIn(browser: Browser, problems: string[]): Promise<StorageState> {
Expand Down Expand Up @@ -555,7 +621,7 @@ async function main() {
await shoot(tabPage, `${prefix}-failed`);
throw error;
});
shots += 4;
shots += 6;
await shootLibraryScreens(tabPage, prefix).catch(async (error: unknown) => {
await shoot(tabPage, `${prefix}-failed`);
throw error;
Expand All @@ -573,11 +639,17 @@ async function main() {
watchErrors(emptyPage, problems, `${prefix} empty`);
await pinSettings(emptyPage, language);
await shootEmptyDebate(emptyPage, prefix);
shots += 1;
shots += 2;
await shootAskScreens(signedIn, prefix, language, problems);
shots += 3;
await shootResultScreens(signedIn, prefix, language, problems);
shots += 9;
const oneOffPage = await signedIn.newPage();
watchErrors(oneOffPage, problems, `${prefix} one-off tools`);
await pinSettings(oneOffPage, language);
await shootOneOffTools(oneOffPage, prefix);
await oneOffPage.close();
shots += 2;
await signedIn.close();
}
}
Expand Down
30 changes: 5 additions & 25 deletions apps/mobile/src/app/(tabs)/(debate)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { MainActionButton, TextButton } from '@/components/buttons';
import { Card, IconTile } from '@/components/card';
import { Icon } from '@/components/icon';
import type { IconName } from '@/components/icon-paths';
import { OneOffNotice } from '@/components/one-off-notice';
import { PREPARE_TOOLS } from '@/components/prepare-tools';
import { Screen } from '@/components/screen';
import { SegmentedControl } from '@/components/segmented-control';
Expand All @@ -31,7 +32,7 @@ const RECENT_LIMIT = 3;
export default function DebateScreen() {
const { t } = useTranslation();
const router = useRouter();
const { status, current, oneOff, setOneOff, refresh } = useDebates();
const { status, current, refresh } = useDebates();

// Debates switched on the website show up when the tab comes back into view.
const focusedBefore = useRef(false);
Expand Down Expand Up @@ -69,6 +70,7 @@ export default function DebateScreen() {
return (
<Screen safeTop>
<SwitcherPill onPress={openSwitcher} />
<OneOffNotice />
<View style={styles.emptyText}>
<Text accessibilityRole="header" weight="bold" style={styles.emptyTitle}>
{t('debate.noCurrentTitle')}
Expand All @@ -85,7 +87,7 @@ export default function DebateScreen() {
return (
<Screen safeTop gap={0}>
<SwitcherPill onPress={openSwitcher} />
{oneOff ? <OneOffNotice onReturn={() => setOneOff(false)} /> : null}
<OneOffNotice style={styles.notice} />
<Text accessibilityRole="header" weight="bold" style={styles.motion} testID="current-debate-motion">
{current.motion}
</Text>
Expand All @@ -112,19 +114,6 @@ function SwitcherPill({ onPress }: { onPress: () => void }) {
);
}

function OneOffNotice({ onReturn }: { onReturn: () => void }) {
const { t } = useTranslation();
const { colors } = useTheme();
return (
<View style={[styles.notice, { borderColor: colors.border }]}>
<Text tone="secondary" style={styles.noticeText}>
{t('debate.oneOffStrip')}
</Text>
<TextButton label={t('debate.returnToDebate')} onPress={onReturn} />
</View>
);
}

/**
* Counts, the main action, the Prepare tools and recent work for one debate.
* Students prepare both sides, so the toggle only chooses which side's work is
Expand Down Expand Up @@ -317,16 +306,7 @@ const styles = StyleSheet.create({
marginTop: 4,
},
pillLabel: { fontSize: 15 },
notice: {
marginTop: 12,
borderWidth: 1,
borderStyle: 'dashed',
borderRadius: radius.field,
paddingHorizontal: 14,
paddingTop: 10,
alignItems: 'flex-start',
},
noticeText: { fontSize: 14, lineHeight: 19 },
notice: { marginTop: 12 },
emptyText: { gap: 8 },
emptyTitle: { fontSize: 28, lineHeight: 34, letterSpacing: -0.6 },
motion: { fontSize: 27, lineHeight: 32, letterSpacing: -0.6, marginTop: 12, marginBottom: 15 },
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/app/(tabs)/(library)/library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Card, IconTile } from '@/components/card';
import { Chip } from '@/components/chip';
import { Icon } from '@/components/icon';
import { LibraryHeader, type AddSourceKind } from '@/components/library-header';
import { OneOffNotice } from '@/components/one-off-notice';
import { Screen } from '@/components/screen';
import { SearchField } from '@/components/search-field';
import { SOURCE_ICONS, sourceMeta } from '@/components/source-display';
Expand Down Expand Up @@ -47,6 +48,8 @@ export default function LibraryScreen() {
return (
<Screen>
<LibraryHeader title={t('mobile.tabs.library')} onAdd={onAdd} disabled={!debateId} />
{/* New sources still go to the Current Debate in one-off mode, as on the website, so this only says the mode is on. */}
<OneOffNotice message="debate.oneOffActive" />
{status === 'loading' ? <ActivityIndicator style={styles.loading} /> : null}
{status !== 'loading' && !debateId ? (
<Card>
Expand Down
13 changes: 9 additions & 4 deletions apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
import { TextButton } from '@/components/buttons';
import { Card, IconTile } from '@/components/card';
import { Icon } from '@/components/icon';
import { OneOffNotice } from '@/components/one-off-notice';
import { PREPARE_TOOLS, type PrepareTool } from '@/components/prepare-tools';
import { Screen } from '@/components/screen';
import { ScreenTitle } from '@/components/screen-title';
Expand All @@ -14,6 +15,7 @@ import { WorkRow, workHref } from '@/components/work-row';
import { useAuth } from '@/lib/auth';
import { useDebates } from '@/lib/debate-context';
import { fetchPrepareWork, type DebateItem } from '@/lib/debates';
import { keepIfSame } from '@/lib/keep-if-same';
import { useTheme } from '@/theme/use-theme';

const SAVED_LIMIT = 30;
Expand All @@ -25,7 +27,7 @@ export default function PrepareScreen() {
const { colors } = useTheme();
const { session } = useAuth();
const userId = session?.user.id ?? null;
const { current, oneOff } = useDebates();
const { current } = useDebates();
const debateId = current?.id ?? null;
// Each load belongs to the debate it was read for, so another debate's results never show, or open, under this one.
const [loaded, setLoaded] = useState<{ debateId: string; items: DebateItem[] | null; failed: boolean } | null>(null);
Expand All @@ -45,7 +47,8 @@ export default function PrepareScreen() {
try {
const next = await fetchPrepareWork(userId, forDebate, SAVED_LIMIT);
if (!isCurrent()) return;
setLoaded({ debateId: forDebate, items: next, failed: false });
// Every focus rereads the list; an unchanged one keeps its identity, so the rows do not re-render.
setLoaded((previous) => keepIfSame(previous, { debateId: forDebate, items: next, failed: false }));
} catch (cause) {
if (!isCurrent()) return;
console.warn('Could not load saved results:', cause instanceof Error ? cause.message : cause);
Expand All @@ -66,6 +69,7 @@ export default function PrepareScreen() {
return (
<Screen>
<ScreenTitle title={t('mobile.tabs.prepare')} />
<OneOffNotice />
<Card style={styles.list}>
{PREPARE_TOOLS.map((tool, index) => (
<Pressable
Expand All @@ -91,9 +95,10 @@ export default function PrepareScreen() {
<Text accessibilityRole="header" weight="bold" style={styles.section}>
{t('mobile.prepare.saved')}
</Text>
{oneOff || !debateId ? (
{/* The Current Debate's results stay listed in one-off mode, as on the Debate tab and the website's home. */}
{!debateId ? (
<Text tone="secondary" style={styles.body}>
{oneOff ? t('debate.oneOffStrip') : t('debate.noCurrentStrip')}
{t('debate.noCurrentStrip')}
</Text>
) : failed ? (
<Card>
Expand Down
Loading
Loading