Skip to content

Commit fa0c95a

Browse files
Programcodeclaude
andcommitted
Show today's AI uses, a round's cost, and the website's limit dialogs
Each tool's header says how many AI uses are left today, read again after every request. Crossfire and Sparring say what a round costs before it starts and check it first, as the website does. A limit or model refusal opens the website's dialog as a sheet with its words: a paid plan's daily cap and the weekly allowance say when access returns, other limits lead to the plan in You (purchases wait for in-app purchase), and a model refusal can switch the tool to low reasoning. The screenshot script shoots both sheets and the cost notes, answers the quota check in its Crossfire and Sparring fakes, and sends a debrief that fits the current schema. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9f93eed commit fa0c95a

19 files changed

Lines changed: 568 additions & 23 deletions

File tree

‎apps/mobile/AGENTS.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ The iOS app DebateOwl (`@debatelab/mobile`; the bundle id is still `com.adam.deb
1818

1919
## Layout
2020

21-
- `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`, `delete-data.tsx`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/`, `factoids/` and `sparring/` (each a form `index.tsx` and a document `[id].tsx`), `crossfire.tsx` and `timer.tsx` (a full-screen modal), `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), and `history.tsx`, full screen above the tabs. All are declared in the root `_layout.tsx`. Every Practice tool is in the app, so `(practice)/soon/[tool].tsx` is no longer reached from the Practice tab.
21+
- `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`, `delete-data.tsx`, `add-link.tsx`, `new-note.tsx`, `source/[id].tsx` and `limit.tsx` (the website's limit-reached and model-restricted dialogs); and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/`, `factoids/` and `sparring/` (each a form `index.tsx` and a document `[id].tsx`), `crossfire.tsx` and `timer.tsx` (a full-screen modal), `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), and `history.tsx`, full screen above the tabs. All are declared in the root `_layout.tsx`. Every Practice tool is in the app, so `(practice)/soon/[tool].tsx` is no longer reached from the Practice tab.
2222
- `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview.
2323
- `src/lib/`: auth (`auth.tsx`), `native-capabilities.ts` (what the running binary has beyond Expo Go), 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.
2424
- `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.
2525
- 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` (the model picker, `components/model-picker.tsx`, writes it back through `settings.tsx`'s serial queue in the website's canonical shape; tools read it through the same queue, so they run with the model just picked), `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.
2626
- 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`'s `postAI`, which streams them as the website does (`stream: true` with a `resultKey`; the reader is `packages/core/src/ai/live.ts`, shared with the website's `src/lib/ai/live.ts`) and reads a dropped stream's answer back from `ai_run_results` (`packages/core/src/ai/run-recovery.ts`). The four tool screens show the draft with `components/live-run.tsx` (the website's `LiveToolRun`; the sections come from `packages/core/src/ai/live-sections.ts`). `results.ts` also 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.
2727
- 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.
28+
- A case's and an attack's next steps are `result-links.ts`: "Open in Crossfire" fills the next Crossfire form through the one-shot `crossfirePrefill` slot (the website's route state; the builders are `packages/core/src/crossfire/prefill.ts`), and a case is built into an unsaved map with `flowchartFromCase` (`packages/core/src/flowchart.ts`, shared with the website), only in the debate the Flowchart is on, as on the website.
29+
- Limits mirror the website's subscription dialogs: `refusal.ts` turns a 402 or 403 refusal (with the limit details `errorFrom` keeps) into the `limit` sheet's parameters and holds the website's messages (`limit-message.ts`); the tools open the sheet instead of an inline error, and Ask, Crossfire and Sparring open it beside theirs. Upgrading waits for in-app purchase, so the sheet's next step is the plan in You. `usage.ts` reads today's uses for the line under each tool's title (`DailyUsageLine`, the website's DailyUsage) and reads again after every AI request (`onAIRequestSettled`). `session-quota.ts` asks what a Crossfire or Sparring round costs (`quotaCheck`), shows it above Start (`SessionCostNote`) and opens the sheet before a round the day cannot cover.
2830
- 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)`).
2931
- 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), with the round's id as `quotaSession` on turns and the debrief so the round is charged once, as the website does 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.
3032
- Sparring is the website's typed mode: `sparring.ts` sends the opponent's turns and the judge as `generateSparringTurn` and `judgeSparringSession` in `src/lib/ai/client.ts` do (the Sparring model settings, a transcript-only debate context), and the round's moves (turn order, the deadline clock, judging, saved rows) are `packages/core/src/sparring/session.ts`, shared with the website's sparring store. The server saves the judged round to `sparring_sessions` when it scores it; the app then files it in its debate with `attach_to_debate` and the student's side, as the store does. The side is asked when the round starts. The unfinished round is a session slot (`sparringDraft`), so leaving the screen keeps it, and a request cut off by leaving is offered again. The flow button opens the Flowchart on the Current Debate's map, above the round, which keeps running. Practice lists the Current Debate's judged rounds and the Debate tab lists them by side; both open `sparring/[id]`, which can delete them. Voice rounds need the development build, so the voice switch shows disabled.

‎apps/mobile/scripts/screenshots.ts‎

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* ONLY=iphone-light-en limits a run to the combinations whose name starts with it;
1212
* separate several with commas (ONLY=iphone-light-en,ipad-dark-hu).
1313
* SCREENS=you,timer limits the signed-in screens to those groups: debate,
14-
* library, prepare, practice, you, empty, ask, results, brainstorm, factoids,
14+
* library, prepare, practice, you, empty, ask, results, brainstorm, factoids, limits,
1515
* timer, crossfire, sparring, flowchart, one-off, history, announcements,
1616
* account, model. Sign-in screens are always shot.
1717
* ONLY=offline runs only the offline reading check (see shootOfflineScreens).
@@ -587,6 +587,25 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu
587587
await pdf.close();
588588
console.log(`${prefix}: PDF file name ${exported.fileName}, copied ${exported.copied.length} characters`);
589589

590+
// The case's menu leads on: Open in Crossfire with the case filled in, and the case built into a map.
591+
// Nothing is saved: the Crossfire form is not started and the map is not saved.
592+
await page.getByTestId('document-more').click();
593+
await page.getByRole('menu').waitFor();
594+
await shoot(page, `${prefix}-case-menu`);
595+
await page.getByRole('menu').getByRole('menuitem').first().click();
596+
await page.getByTestId('crossfire-imported').waitFor({ timeout: 15_000 });
597+
await settle(page);
598+
await shoot(page, `${prefix}-crossfire-from-case`);
599+
await page.goBack();
600+
await page.getByTestId('case-document').waitFor({ timeout: 15_000 });
601+
await page.getByTestId('document-more').click();
602+
await page.getByRole('menu').getByRole('menuitem').nth(1).click();
603+
await page.getByTestId('flowchart-screen').waitFor({ timeout: 15_000 });
604+
await page.waitForTimeout(500);
605+
await shoot(page, `${prefix}-flowchart-from-case`);
606+
await page.goBack();
607+
await page.getByTestId('case-document').waitFor({ timeout: 15_000 });
608+
590609
await page.getByTestId('attack-this-case').click();
591610
await page.getByTestId('attack-case-text').waitFor({ timeout: 15_000 });
592611
await page.waitForFunction(() => (document.querySelector('[data-testid="attack-case-text"]') as HTMLTextAreaElement | null)?.value.startsWith('THESIS:'));
@@ -595,6 +614,14 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu
595614
await page.getByTestId('attack-document').waitFor({ timeout: 30_000 });
596615
await page.waitForTimeout(400);
597616
await shoot(page, `${prefix}-attack-result`);
617+
// The case stays mounted under the attack, so its menu is looked up inside the attack.
618+
await page.getByTestId('attack-document').getByTestId('document-more').click();
619+
await page.getByRole('menu').last().getByRole('menuitem').first().click();
620+
await page.getByTestId('crossfire-imported').waitFor({ timeout: 15_000 });
621+
// Not settle(): the attack's score meter (a progressbar) stays mounted under Crossfire.
622+
await page.waitForLoadState('networkidle');
623+
await page.waitForTimeout(400);
624+
await shoot(page, `${prefix}-crossfire-from-attack`);
598625
await page.close();
599626
}
600627

@@ -666,6 +693,39 @@ async function shootTool(context: BrowserContext, prefix: string, language: stri
666693
await page.close();
667694
}
668695

696+
/**
697+
* The limit and model sheets: Generate answered with the ai function's
698+
* refusals (402 limit_reached with its counts, then 403 upgrade_required).
699+
* Nothing is sent to a model; "Use low reasoning instead" is never pressed.
700+
*/
701+
async function shootLimitSheets(context: BrowserContext, prefix: string, language: string, problems: string[]) {
702+
const refusals = [
703+
{ name: 'limit-sheet', status: 402, json: { error: 'Daily limit reached', code: 'limit_reached', limit: 20, current: 20, units: 1 } },
704+
{ name: 'model-sheet', status: 403, json: { error: 'This model requires a Pro subscription.', code: 'upgrade_required' } },
705+
];
706+
for (const refusal of refusals) {
707+
const page = await context.newPage();
708+
// The browser logs the refused request itself; only that error is expected.
709+
const seen: string[] = [];
710+
watchErrors(page, seen, `${prefix} ${refusal.name}`);
711+
page.on('dialog', (dialog) => void dialog.dismiss());
712+
await pinSettings(page, language);
713+
await page.route(`https://${STAGING_HOST}/functions/v1/ai`, (route) =>
714+
route.fulfill({ status: refusal.status, headers: { 'access-control-allow-origin': '*' }, json: refusal.json }),
715+
);
716+
await page.goto(`${ORIGIN}/generate?from=prepare`);
717+
await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 });
718+
await page.waitForFunction(() => (document.querySelector('[data-testid="generate-motion"]') as HTMLTextAreaElement | null)?.value.trim());
719+
await page.getByRole('button', { name: /^(Generate debate case|Vitaeset generálása|Generar caso de debate)$/i }).click();
720+
await page.getByTestId('limit-sheet').waitFor({ timeout: 15_000 });
721+
await settle(page);
722+
await page.waitForTimeout(500);
723+
await shoot(page, `${prefix}-${refusal.name}`);
724+
await page.close();
725+
problems.push(...seen.filter((problem) => !problem.includes(`status of ${refusal.status}`)));
726+
}
727+
}
728+
669729
// Made-up saved maps in the website's React Flow shape, after mock A.
670730
const FLOW_NODE = (id: string, x: number, y: number, label: string, type: string, strength?: string) => ({
671731
id, type: 'custom', position: { x, y }, width: 220, height: 96,
@@ -908,11 +968,15 @@ const CROSSFIRE_TEXT = {
908968
} as const;
909969

910970
/** Fakes Crossfire's AI calls and its save for this page (see the header). Replies follow the request, as the client checks them. */
971+
// The round's cost, as the ai function answers a quotaCheck: one use, 16 of 20 left today.
972+
const FAKE_QUOTA = { quota: { allowed: true, units: 1, limit: 20, current: 4 } };
973+
911974
async function fakeCrossfire(page: Page, language: keyof typeof CROSSFIRE_TEXT) {
912975
const text = CROSSFIRE_TEXT[language];
913976
await page.route(`https://${STAGING_HOST}/functions/v1/ai`, async (route) => {
914977
const body = route.request().postDataJSON() as { feature?: string; input?: { operation?: string; expectedReply?: 'question' | 'answer'; transcript?: { id: string }[] } } | null;
915978
if (body?.feature !== 'crossfire') return route.abort();
979+
if ((body as { quotaCheck?: boolean }).quotaCheck) return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: FAKE_QUOTA });
916980
const input = body.input ?? {};
917981
const turns = input.transcript ?? [];
918982
const exchange = Math.floor(turns.length / 2) % text.answers.length;
@@ -927,8 +991,9 @@ async function fakeCrossfire(page: Page, language: keyof typeof CROSSFIRE_TEXT)
927991
} else {
928992
const d = text.debrief;
929993
output = {
930-
concessionsSecured: [{ turnIds: ['T3', 'T4'], claim: d.concession }],
931-
controlledQuestions: [{ turnIds: ['T1', 'T2'], claim: d.controlled }],
994+
// Concessions and controlled questions name one turn each (SingleTurnReferenceSchema).
995+
concessionsSecured: [{ turnIds: ['T4'], claim: d.concession }],
996+
controlledQuestions: [{ turnIds: ['T1'], claim: d.controlled }],
932997
weakOrRepetitiveQuestions: [],
933998
evasiveOrIncompleteAnswers: [],
934999
missedFollowUps: [{ turnIds: ['T2'], claim: d.missed }],
@@ -958,6 +1023,10 @@ async function shootCrossfireScreens(context: BrowserContext, prefix: string, la
9581023
await page.getByTestId('crossfire-argument').fill(text.argument);
9591024
await settle(page);
9601025
await shoot(page, `${prefix}-crossfire-setup`);
1026+
// The round's cost sits above the start button, below the fold on a phone.
1027+
await page.getByTestId('session-cost').scrollIntoViewIfNeeded();
1028+
await page.waitForTimeout(300);
1029+
await shoot(page, `${prefix}-crossfire-cost`);
9611030

9621031
await page.getByRole('button', { name: /^(Start crossfire|Keresztkérdezés indítása|Iniciar interrogatorio)$/ }).click();
9631032
await page.getByTestId('crossfire-live').waitFor({ timeout: 15_000 });
@@ -1026,6 +1095,7 @@ async function fakeSparring(page: Page, language: keyof typeof SPARRING_TEXT) {
10261095
await page.route(`https://${STAGING_HOST}/functions/v1/ai`, async (route) => {
10271096
const body = route.request().postDataJSON() as { feature?: string; input?: { action?: 'respond' | 'score'; speeches?: { speaker: string }[] } } | null;
10281097
if (body?.feature !== 'sparring') return route.abort();
1098+
if ((body as { quotaCheck?: boolean }).quotaCheck) return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: FAKE_QUOTA });
10291099
const action = body.input?.action ?? 'respond';
10301100
if (holding[action]) await new Promise<void>((resolve) => { held[action] = resolve; });
10311101
let output: unknown;
@@ -1078,6 +1148,10 @@ async function shootSparringScreens(context: BrowserContext, prefix: string, lan
10781148
await page.getByRole('menuitem', { name: 'Lincoln-Douglas' }).click();
10791149
await settle(page);
10801150
await shoot(page, `${prefix}-sparring-setup`);
1151+
// The round's cost sits above the start button, below the fold on a phone.
1152+
await page.getByTestId('session-cost').scrollIntoViewIfNeeded();
1153+
await page.waitForTimeout(300);
1154+
await shoot(page, `${prefix}-sparring-cost`);
10811155

10821156
fake.hold('respond');
10831157
await page.getByRole('button', { name: /^(Start round|Forduló indítása|Iniciar ronda)$/ }).click();
@@ -1747,25 +1821,29 @@ async function main() {
17471821
shots += 3;
17481822
}
17491823
if (want('results')) {
1750-
await shootResultScreens(signedIn, prefix, language, problems);
1751-
shots += 10;
1824+
await shootResultScreens(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'results'));
1825+
shots += 14;
17521826
}
17531827
for (const tool of ['brainstorm', 'factoids'] as const) {
17541828
if (!want(tool)) continue;
17551829
await shootTool(signedIn, prefix, language, problems, tool);
17561830
shots += 7;
17571831
}
1832+
if (want('limits')) {
1833+
await shootLimitSheets(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'limits'));
1834+
shots += 2;
1835+
}
17581836
if (want('timer')) {
17591837
await shootTimerScreens(signedIn, prefix, language, problems);
17601838
shots += 3;
17611839
}
17621840
if (want('crossfire')) {
1763-
await shootCrossfireScreens(signedIn, prefix, language, problems);
1764-
shots += 3;
1841+
await shootCrossfireScreens(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'crossfire'));
1842+
shots += 4;
17651843
}
17661844
if (want('sparring')) {
17671845
await shootSparringScreens(signedIn, prefix, language, problems);
1768-
shots += 6;
1846+
shots += 7;
17691847
}
17701848
if (want('flowchart')) {
17711849
await shootFlowchart(signedIn, prefix, language, problems);

0 commit comments

Comments
 (0)