Skip to content

Commit 3ac60bb

Browse files
Programcodeclaude
andcommitted
Show one-off mode on Prepare, Generate and Attack, and keep Prepare's list on refresh
The phone-test fixes put OneOffNotice on the Debate tab, Ask and the Library. The merged Prepare tab and the Generate and Attack forms had their own one-off text with no way back. They now show the same notice, with "Return to debate" when there is a Current Debate, as the website's CurrentDebateStrip does. Prepare keeps listing the Current Debate's saved results in one-off mode, like the Debate tab and the website's home. Prepare rereads its saved results on every focus. An unchanged list now keeps its identity, the way the debate list does, through a shared keepIfSame. The screenshot run turns one-off mode on, shoots Prepare and Generate, and checks that Return turns it off again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1216aee commit 3ac60bb

6 files changed

Lines changed: 58 additions & 20 deletions

File tree

‎apps/mobile/AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp
2323
- `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.
2424
- 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.
2525
- 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.
26-
- 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, Ask and Library. 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.
26+
- 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.
2727
- `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts.
2828
- `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs.
2929

‎apps/mobile/scripts/screenshots.ts‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@
2222
* the System theme; nothing is written. The Debate tab shows the account's
2323
* real Current Debate, and its empty state comes from answering the
2424
* current_debates read with no row. The Debate tab is shot once per side view,
25-
* and with one-off mode on, with and without a Current Debate. One-off mode is
26-
* in memory only; the script toggles it and never taps a debate row.
25+
* and with one-off mode on, with and without a Current Debate; Prepare and
26+
* Generate are shot in one-off mode too. One-off mode is in memory only; the
27+
* script toggles it and never taps a debate row.
2728
*
2829
* Ask screenshots never call a model and never save a chat: the page answers
2930
* ai-stream from a fake stream inside the page (expo/fetch uses the page's
@@ -458,6 +459,34 @@ async function shootAskScreens(context: BrowserContext, prefix: string, language
458459
await streaming.close();
459460
}
460461

462+
/** Prepare and Generate in one-off mode, reached in the app so the in-memory mode survives. Nothing is sent or saved. */
463+
async function shootOneOffTools(page: Page, prefix: string) {
464+
await page.goto(`${ORIGIN}/`);
465+
// Opening the switcher takes the tab out of focus, which drops a load still running; let it finish first.
466+
await page.getByTestId('current-debate-motion').waitFor({ timeout: 30_000 });
467+
await page.waitForFunction(() => !document.querySelector('[data-testid="debate-counts"]')?.textContent?.includes('–'));
468+
await settle(page);
469+
await page.getByTestId('current-debate-pill').click();
470+
await page.getByTestId('debates-sheet').waitFor();
471+
await settle(page);
472+
await turnOnOneOff(page);
473+
await closeSwitcher(page);
474+
await page.getByRole('tab', { name: /^(Prepare|Felkészülés|Preparar)$/ }).click();
475+
await page.getByTestId('prepare-tool-generate').waitFor({ timeout: 30_000 });
476+
// The Debate tab stays mounted behind Prepare with its own notice; only the visible one is Prepare's.
477+
await page.getByTestId('one-off-notice').filter({ visible: true }).waitFor();
478+
await settle(page);
479+
await shoot(page, `${prefix}-prepare-oneoff`);
480+
await page.getByTestId('prepare-tool-generate').click();
481+
await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 });
482+
const notice = page.getByTestId('generate-screen').getByTestId('one-off-notice');
483+
await notice.waitFor();
484+
await shoot(page, `${prefix}-generate-oneoff`);
485+
// Return to debate turns the mode off in place, and the strip says where work goes again.
486+
await notice.getByRole('button').click();
487+
await page.getByTestId('generate-screen').getByTestId('working-in').waitFor();
488+
}
489+
461490
async function shootEmptyDebate(page: Page, prefix: string) {
462491
await page.route(`${REST}/current_debates*`, (route) =>
463492
route.fulfill({ status: 200, contentType: 'application/json', json: [] }),
@@ -615,6 +644,12 @@ async function main() {
615644
shots += 3;
616645
await shootResultScreens(signedIn, prefix, language, problems);
617646
shots += 9;
647+
const oneOffPage = await signedIn.newPage();
648+
watchErrors(oneOffPage, problems, `${prefix} one-off tools`);
649+
await pinSettings(oneOffPage, language);
650+
await shootOneOffTools(oneOffPage, prefix);
651+
await oneOffPage.close();
652+
shots += 2;
618653
await signedIn.close();
619654
}
620655
}

‎apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
66
import { TextButton } from '@/components/buttons';
77
import { Card, IconTile } from '@/components/card';
88
import { Icon } from '@/components/icon';
9+
import { OneOffNotice } from '@/components/one-off-notice';
910
import { PREPARE_TOOLS, type PrepareTool } from '@/components/prepare-tools';
1011
import { Screen } from '@/components/screen';
1112
import { ScreenTitle } from '@/components/screen-title';
@@ -14,6 +15,7 @@ import { WorkRow, workHref } from '@/components/work-row';
1415
import { useAuth } from '@/lib/auth';
1516
import { useDebates } from '@/lib/debate-context';
1617
import { fetchPrepareWork, type DebateItem } from '@/lib/debates';
18+
import { keepIfSame } from '@/lib/keep-if-same';
1719
import { useTheme } from '@/theme/use-theme';
1820

1921
const SAVED_LIMIT = 30;
@@ -25,7 +27,7 @@ export default function PrepareScreen() {
2527
const { colors } = useTheme();
2628
const { session } = useAuth();
2729
const userId = session?.user.id ?? null;
28-
const { current, oneOff } = useDebates();
30+
const { current } = useDebates();
2931
const debateId = current?.id ?? null;
3032
// Each load belongs to the debate it was read for, so another debate's results never show, or open, under this one.
3133
const [loaded, setLoaded] = useState<{ debateId: string; items: DebateItem[] | null; failed: boolean } | null>(null);
@@ -45,7 +47,8 @@ export default function PrepareScreen() {
4547
try {
4648
const next = await fetchPrepareWork(userId, forDebate, SAVED_LIMIT);
4749
if (!isCurrent()) return;
48-
setLoaded({ debateId: forDebate, items: next, failed: false });
50+
// Every focus rereads the list; an unchanged one keeps its identity, so the rows do not re-render.
51+
setLoaded((previous) => keepIfSame(previous, { debateId: forDebate, items: next, failed: false }));
4952
} catch (cause) {
5053
if (!isCurrent()) return;
5154
console.warn('Could not load saved results:', cause instanceof Error ? cause.message : cause);
@@ -66,6 +69,7 @@ export default function PrepareScreen() {
6669
return (
6770
<Screen>
6871
<ScreenTitle title={t('mobile.tabs.prepare')} />
72+
<OneOffNotice />
6973
<Card style={styles.list}>
7074
{PREPARE_TOOLS.map((tool, index) => (
7175
<Pressable
@@ -91,9 +95,10 @@ export default function PrepareScreen() {
9195
<Text accessibilityRole="header" weight="bold" style={styles.section}>
9296
{t('mobile.prepare.saved')}
9397
</Text>
94-
{oneOff || !debateId ? (
98+
{/* The Current Debate's results stay listed in one-off mode, as on the Debate tab and the website's home. */}
99+
{!debateId ? (
95100
<Text tone="secondary" style={styles.body}>
96-
{oneOff ? t('debate.oneOffStrip') : t('debate.noCurrentStrip')}
101+
{t('debate.noCurrentStrip')}
97102
</Text>
98103
) : failed ? (
99104
<Card>

‎apps/mobile/src/components/tool-form.tsx‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
44
import { ActivityIndicator, StyleSheet, View } from 'react-native';
55

66
import { TextButton } from './buttons';
7+
import { OneOffNotice } from './one-off-notice';
78
import { Text } from './text';
89

910
import type { AIErrorKind } from '@/lib/ai-request';
@@ -25,18 +26,15 @@ export function ToolTitle({ title, subtitle }: { title: string; subtitle: string
2526
);
2627
}
2728

28-
/** Where new work will be saved, as the website's CurrentDebateStrip says it. */
29+
/** Where new work will be saved, as the website's CurrentDebateStrip says it. In one-off mode it offers the way back to the Current Debate. */
2930
export function WorkingIn() {
3031
const { t } = useTranslation();
3132
const { colors } = useTheme();
3233
const { current, oneOff } = useDebates();
34+
if (oneOff) return <OneOffNotice />;
3335
return (
34-
<View style={[styles.strip, { borderColor: colors.border }, oneOff && styles.dashed]} testID="working-in">
35-
{oneOff ? (
36-
<Text tone="secondary" style={styles.stripText}>
37-
{t('debate.oneOffStrip')}
38-
</Text>
39-
) : current ? (
36+
<View style={[styles.strip, { borderColor: colors.border }]} testID="working-in">
37+
{current ? (
4038
<Text tone="secondary" style={styles.stripText} numberOfLines={2}>
4139
{`${t('debate.workingIn')} `}
4240
<Text weight="semibold" style={styles.stripText}>
@@ -108,7 +106,6 @@ const styles = StyleSheet.create({
108106
title: { fontSize: 30, lineHeight: 36, letterSpacing: -0.8 },
109107
subtitle: { fontSize: 16, lineHeight: 22 },
110108
strip: { borderWidth: 1, borderRadius: radius.field, paddingHorizontal: 14, paddingVertical: 10 },
111-
dashed: { borderStyle: 'dashed' },
112109
stripText: { fontSize: 14.5, lineHeight: 20 },
113110
error: { borderWidth: 1, borderRadius: radius.card, padding: 14, gap: 4 },
114111
errorTitle: { fontSize: 16 },

‎apps/mobile/src/lib/debate-context.tsx‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type Debate,
1111
type DebateSettings,
1212
} from './debates';
13+
import { keepIfSame } from './keep-if-same';
1314

1415
type Status = 'loading' | 'ready' | 'error';
1516

@@ -29,11 +30,6 @@ type DebateContextValue = {
2930

3031
const DebateContext = createContext<DebateContextValue | null>(null);
3132

32-
/** Refreshes run on every Debate tab focus; an unchanged list keeps its identity so the tabs do not re-render. */
33-
function keepIfSame(previous: Debate[], next: Debate[]): Debate[] {
34-
return JSON.stringify(previous) === JSON.stringify(next) ? previous : next;
35-
}
36-
3733
export function DebateProvider({ children }: { children: ReactNode }) {
3834
const { session } = useAuth();
3935
const userId = session?.user.id ?? null;
@@ -59,6 +55,7 @@ export function DebateProvider({ children }: { children: ReactNode }) {
5955
try {
6056
const next = await fetchDebates(forUser);
6157
if (owner.current !== forUser || refreshes.current !== request || writes.current !== writesBefore) return;
58+
// Refreshes run on every Debate tab focus; an unchanged list keeps its identity so the tabs do not re-render.
6259
setDebates((previous) => keepIfSame(previous, next.debates));
6360
setCurrentId(next.currentId);
6461
setStatus('ready');
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/** Focus refreshes usually reread unchanged data; returning the previous value keeps its identity, so nothing re-renders. */
2+
export function keepIfSame<T>(previous: T, next: T): T {
3+
return JSON.stringify(previous) === JSON.stringify(next) ? previous : next;
4+
}

0 commit comments

Comments
 (0)