Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/mobile/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,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/`, `brainstorm/` and `factoids/` (each a form `index.tsx` and a document `[id].tsx`), full screen above the tabs. All are declared in the root `_layout.tsx`. `(prepare)/coming/[tool].tsx` (Flowchart) 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.
- 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.
- Brainstorm and Factoids mirror `brainstorm` and `factoids` in `src/lib/ai/client.ts` and their pages, through the same `results.ts`: the brainstorm transform and the verified-factoid selection live in `packages/core/src/ai/brainstorm-output.ts` and `factoids-output.ts`, rows go to `brainstorms` and `factoids`. Brainstorm has the motion gate; Factoids, like Attack, follows the Current Debate and saves with side `neutral`. A factoid's bookmark saves it to the Current Debate's `Factoids` folder, and a search suggestion reopens the Factoids form with that query. `prepareToolHref` in `components/prepare-tools.ts` is where both tabs open a tool.
- 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, Brainstorm and Factoids 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.

Expand Down
123 changes: 115 additions & 8 deletions apps/mobile/scripts/screenshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
* for the "generating" shot, and answers case, attack and source writes
* 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.
* next to the screenshots. Brainstorm and Factoids are shot the same way,
* from a fixed brainstorm and fixed factoids, and save nothing.
*/
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page } from '@playwright/test';
import { createReadStream, existsSync, statSync } from 'node:fs';
Expand Down Expand Up @@ -318,16 +319,50 @@ const FAKE_ROW = (table: string) => ({
created_at: new Date().toISOString(),
});

/** Fakes Generate's and Attack's AI calls and saves for this page (see the header). `hold` keeps Generate waiting. */
async function fakeResults(page: Page, hold: boolean) {
// A made-up brainstorm in the shape the model returns (Top 5), before the website's transform.
const FAKE_BRAINSTORM = {
needsMoreInfo: false,
topFive: [
{ rank: 1, whyTop: 'It turns a convenience debate into a health one.', idea: { id: 'a', title: 'Cleaner air for children', description: 'Traffic fumes peak at school gates; closing centres to cars cuts them where children walk.', side: 'pro', strength: 'strong', tradeoffs: ['Needs air-quality data'], relatedTo: [] } },
{ rank: 2, whyTop: 'Shops are the opposition’s strongest group.', idea: { id: 'b', title: 'Footfall helps local shops', description: 'People on foot stop more often than drivers passing through.', side: 'pro', strength: 'moderate', tradeoffs: ['Evidence varies by city size'], relatedTo: [] } },
{ rank: 3, whyTop: 'Pre-empts the fairness attack.', idea: { id: 'c', title: 'Who loses access', description: 'Carers, disabled drivers and night-shift workers rely on cars.', side: 'con', strength: 'strong', tradeoffs: [], relatedTo: [] } },
{ rank: 4, whyTop: 'A cheap, concrete mechanism.', idea: { id: 'd', title: 'Faster buses', description: 'Empty streets let buses keep to their timetable.', side: 'pro', strength: 'moderate', tradeoffs: [], relatedTo: [] } },
{ rank: 5, whyTop: 'Frames the debate on space, not cars.', idea: { id: 'e', title: 'Street space as a public good', description: 'Parking takes space that could be parks or cafés.', side: 'neutral', strength: 'weak', tradeoffs: ['Abstract for some judges'], relatedTo: [] } },
],
nextSteps: ['Find one city that closed its centre and measured the air', 'Prepare an answer on exemptions'],
warningsAndTradeoffs: ['The footfall evidence comes from large cities.'],
};
// Made-up factoids: three verified, one unverified that the website's selection drops.
const FAKE_FACTOIDS = {
factoids: [
{ id: 'f1', shortClaim: 'Asthma admissions fell after a city centre closed to cars', keyFigure: '−12% in two years', context: 'A made-up city compared admissions before and after its car-free zone.', limitations: 'One city; other changes happened at the same time.', citation: { title: 'Clean Streets Review', publisher: 'Example Health Institute', date: '2024', url: 'https://example.org/clean-streets', verified: 'verified' }, howToUse: 'Lead with it in the first argument.', relevanceScore: 92 },
{ id: 'f2', shortClaim: 'Shops in pedestrian streets report more visits', keyFigure: '+18% footfall', context: 'Counts on three streets before and after closure.', limitations: 'Weekday counts only.', citation: { title: 'Footfall Study', publisher: 'Example Retail Council', date: '2023', url: 'https://example.org/footfall', verified: 'verified' }, howToUse: 'Answer the shopkeepers’ objection.', relevanceScore: 81 },
{ id: 'f3', shortClaim: 'Buses ran closer to their timetable', keyFigure: '4 minutes saved per trip', context: 'Average delay on routes through the centre.', limitations: 'Peak hours only.', citation: { title: 'Transit Report', publisher: 'Example Transport Board', url: 'https://example.org/transit-report', verified: 'verified' }, howToUse: 'Use it for the access argument.', relevanceScore: 74 },
{ id: 'f4', shortClaim: 'An unverified claim', keyFigure: '?', context: '', limitations: '', citation: { title: 'Unknown', publisher: 'Unknown', verified: 'unverified' }, howToUse: '', relevanceScore: 10 },
],
searchSuggestions: ['car-free city centre asthma study', 'pedestrian zone retail footfall'],
warnings: ['Most studies come from large European cities.'],
verificationStatus: 'complete',
};

type FakeFeature = 'generate' | 'brainstorm' | 'factoids';

/** Fakes the tools' AI calls and saves for this page (see the header). `hold` keeps that tool waiting. */
async function fakeResults(page: Page, hold: FakeFeature | null) {
const outputs: Record<string, { output: unknown; discoveredSources: unknown[] }> = {
generate: { output: FAKE_CASE, discoveredSources: FAKE_SOURCES },
attack: { output: FAKE_ATTACK, discoveredSources: [] },
brainstorm: { output: FAKE_BRAINSTORM, discoveredSources: FAKE_SOURCES },
factoids: { output: FAKE_FACTOIDS, discoveredSources: FAKE_SOURCES },
};
await page.route(`https://${STAGING_HOST}/functions/v1/ai`, async (route) => {
const feature = (route.request().postDataJSON() as { feature?: string } | null)?.feature;
if (feature === 'generate' && hold) return; // Never answered: the page shows its progress state.
if (feature === 'generate') return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: { output: FAKE_CASE, discoveredSources: FAKE_SOURCES } });
if (feature === 'attack') return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: { output: FAKE_ATTACK, discoveredSources: [] } });
if (feature && feature === hold) return; // Never answered: the page shows its progress state.
const json = feature ? outputs[feature] : undefined;
if (json) return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json });
return route.abort();
});
for (const table of ['cases', 'attacks', 'sources', 'source_folders']) {
for (const table of ['cases', 'attacks', 'brainstorms', 'factoids', 'sources', 'source_folders']) {
await page.route(`${REST}/${table}*`, (route) =>
route.request().method() === 'POST' ? route.fulfill({ status: 201, json: FAKE_ROW(table) }) : route.continue(),
);
Expand All @@ -341,7 +376,7 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu
watchErrors(page, problems, `${prefix} results`);
page.on('dialog', (dialog) => void dialog.dismiss());
await pinSettings(page, language);
await fakeResults(page, hold);
await fakeResults(page, hold ? 'generate' : null);
return page;
};

Expand Down Expand Up @@ -399,6 +434,67 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu
await page.close();
}

const TOOLS = {
brainstorm: { field: 'brainstorm-motion', submit: /^(Start Brainstorming|Ötletelés Indítása|Comenzar Lluvia de Ideas)$/, document: 'brainstorm-document', working: 'brainstorming' },
factoids: { field: 'factoids-claim', submit: /^(Find Factoids|Tények Keresése|Buscar Datos)$/, document: 'factoids-document', working: 'finding-factoids' },
} as const;
const FACTOIDS_CLAIM = 'Car-free city centres cut asthma admissions';

/** Brainstorm or Factoids: the form, its progress, the document open and collapsed, and Export, from fixed outputs. */
async function shootTool(context: BrowserContext, prefix: string, language: string, problems: string[], tool: keyof typeof TOOLS) {
const spec = TOOLS[tool];
const open = async (hold: boolean) => {
const page = await context.newPage();
watchErrors(page, problems, `${prefix} ${tool}`);
page.on('dialog', (dialog) => void dialog.dismiss());
await pinSettings(page, language);
await fakeResults(page, hold ? tool : null);
await page.goto(`${ORIGIN}/prepare`);
await page.getByTestId(`prepare-tool-${tool}`).waitFor({ timeout: 30_000 });
await settle(page);
await page.getByTestId(`prepare-tool-${tool}`).click();
await page.getByTestId(spec.field).waitFor({ timeout: 30_000 });
// Brainstorm takes the Current Debate's motion; Factoids research a claim the student types.
if (tool === 'brainstorm') await page.waitForFunction((id) => (document.querySelector(`[data-testid="${id}"]`) as HTMLTextAreaElement | null)?.value.trim(), spec.field);
else await page.getByTestId(spec.field).fill(FACTOIDS_CLAIM);
return page;
};

const waiting = await open(true);
await shoot(waiting, `${prefix}-${tool}-form`);
await waiting.getByRole('button', { name: spec.submit }).click();
await waiting.getByTestId('tool-progress').waitFor({ timeout: 15_000 });
await shoot(waiting, `${prefix}-${spec.working}`);
await waiting.close();

const page = await open(false);
await page.getByRole('button', { name: spec.submit }).click();
await page.getByTestId(spec.document).waitFor({ timeout: 30_000 });
await page.waitForTimeout(400);
await shoot(page, `${prefix}-${tool}`);
// Close the open item, so every item shows collapsed.
await page.getByTestId('document-item').first().click();
await shoot(page, `${prefix}-${tool}-collapsed`);
await page.getByTestId('document-item').first().click();

await page.getByTestId('export-button').click();
await page.getByTestId('export-menu').waitFor();
await shoot(page, `${prefix}-${tool}-export-menu`);
await page.getByTestId('export-menu').getByRole('menuitem').first().click();
await page.getByTestId('export-button').click();
await page.getByTestId('export-menu').getByRole('menuitem').nth(1).click();
const exported = await page.evaluate(() => (window as unknown as { __debatelabExport?: { html?: string; fileName?: string; copied?: string } }).__debatelabExport ?? {});
if (!exported.html || !exported.copied) throw new Error(`${tool} export did not hand over the PDF HTML or the copied text`);
await writeFile(join(OUT, `${prefix}-${tool}-copy.txt`), exported.copied);
const pdf = await context.newPage();
await pdf.setViewportSize({ width: 794, height: 1123 });
await pdf.setContent(exported.html);
await pdf.screenshot({ path: join(OUT, `${prefix}-${tool}-pdf-html.png`), fullPage: true });
await pdf.close();
console.log(`${prefix} ${tool}: PDF file name ${exported.fileName}, copied ${exported.copied.length} characters`);
await page.close();
}

async function shootLibraryScreens(page: Page, prefix: string) {
await page.goto(`${ORIGIN}/library`);
await page.getByTestId('library-add').waitFor({ timeout: 30_000 });
Expand Down Expand Up @@ -477,6 +573,13 @@ async function shootOneOffTools(page: Page, prefix: string) {
await page.getByTestId('one-off-notice').filter({ visible: true }).waitFor();
await settle(page);
await shoot(page, `${prefix}-prepare-oneoff`);
// Brainstorm and Factoids say it through the same strip; a reload would end one-off mode, so go back in the app.
for (const tool of ['brainstorm', 'factoids'] as const) {
await page.getByTestId(`prepare-tool-${tool}`).click();
await page.getByTestId(`${tool}-screen`).getByTestId('one-off-notice').waitFor({ timeout: 30_000 });
await page.goBack();
await page.getByTestId('prepare-tool-generate').filter({ visible: true }).waitFor();
}
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');
Expand Down Expand Up @@ -644,6 +747,10 @@ async function main() {
shots += 3;
await shootResultScreens(signedIn, prefix, language, problems);
shots += 9;
for (const tool of ['brainstorm', 'factoids'] as const) {
await shootTool(signedIn, prefix, language, problems, tool);
shots += 6;
}
const oneOffPage = await signedIn.newPage();
watchErrors(oneOffPage, problems, `${prefix} one-off tools`);
await pinSettings(oneOffPage, language);
Expand Down
7 changes: 2 additions & 5 deletions apps/mobile/src/app/(tabs)/(debate)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ 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 { PREPARE_TOOLS, prepareToolHref } from '@/components/prepare-tools';
import { Screen } from '@/components/screen';
import { SegmentedControl } from '@/components/segmented-control';
import { Text } from '@/components/text';
Expand Down Expand Up @@ -194,10 +194,7 @@ function DebateDetails({ debateId }: { debateId: string }) {
key={tool.key}
icon={tool.icon}
label={t(`nav.${tool.key}`)}
onPress={() =>
tool.key === 'generate'
? router.push({ pathname: '/generate', params: { from: 'debate' } })
: router.push({ pathname: '/coming/[tool]', params: { tool: tool.key } })}
onPress={() => router.push(prepareToolHref(tool.key, 'debate'))}
/>
))}
</View>
Expand Down
Loading
Loading