Skip to content

Commit 7b939df

Browse files
Programcodeclaude
andcommitted
Keep session memory and auto-saved undo to their owner
Forms and open results from one account no longer reach the next: they live in session memory that the auth provider clears on every account change. Undo of auto-saved sources only deletes ids read for the Current Debate and filters the delete by that debate. Cancelling Generate or Attack stops the source autosave before the next write. Prepare shows only the Current Debate's saved results, and Ask clears its citations library on a debate change. Generate, Attack and Ask's whole answers now save the server's source rankings to the request's debate and say when the input was shortened, as the website does. The ranking helpers move to @debatelab/core so both clients share them. Attack weaknesses are keyed by position. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3d689ab commit 7b939df

20 files changed

Lines changed: 347 additions & 124 deletions

File tree

‎apps/mobile/AGENTS.md‎

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

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,23 +27,25 @@ export default function PrepareScreen() {
2727
const userId = session?.user.id ?? null;
2828
const { current, oneOff } = useDebates();
2929
const debateId = current?.id ?? null;
30-
const [items, setItems] = useState<DebateItem[] | null>(null);
31-
const [failed, setFailed] = useState(false);
30+
// Each load belongs to the debate it was read for, so another debate's results never show, or open, under this one.
31+
const [loaded, setLoaded] = useState<{ debateId: string; items: DebateItem[] | null; failed: boolean } | null>(null);
32+
const items = loaded?.debateId === debateId ? loaded.items : null;
33+
const failed = loaded?.debateId === debateId && loaded.failed;
3234

3335
const openTool = (tool: PrepareTool) =>
3436
tool === 'generate' ? router.push({ pathname: '/generate', params: { from: 'prepare' } }) : router.push({ pathname: '/coming/[tool]', params: { tool } });
3537

3638
const load = useCallback(async (isCurrent: () => boolean) => {
3739
if (!userId || !debateId) return;
40+
const forDebate = debateId;
3841
try {
39-
const next = await fetchPrepareWork(userId, debateId, SAVED_LIMIT);
42+
const next = await fetchPrepareWork(userId, forDebate, SAVED_LIMIT);
4043
if (!isCurrent()) return;
41-
setItems(next);
42-
setFailed(false);
44+
setLoaded({ debateId: forDebate, items: next, failed: false });
4345
} catch (cause) {
4446
if (!isCurrent()) return;
4547
console.warn('Could not load saved results:', cause instanceof Error ? cause.message : cause);
46-
setFailed(true);
48+
setLoaded({ debateId: forDebate, items: null, failed: true });
4749
}
4850
}, [userId, debateId]);
4951

‎apps/mobile/src/app/ask.tsx‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@ import { Chip } from '@/components/chip';
1212
import { Icon } from '@/components/icon';
1313
import type { IconName } from '@/components/icon-paths';
1414
import { Text } from '@/components/text';
15-
import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request';
15+
import { AIError, isAbortError, type AIErrorKind, type ResponseExtras } from '@/lib/ai-request';
1616
import { askOnce, canStream, isFinalAskError, isMessageWithinBudget, streamAsk, type AskOptions } from '@/lib/ask';
1717
import { buildDebateContext } from '@/lib/context-request';
1818
import { fetchFeatureConfig, type FeatureConfig } from '@/lib/feature-config';
1919
import { useAuth } from '@/lib/auth';
2020
import { ChatSaveError, fetchChat, saveChat, type ChatMessage, type ChatRecord } from '@/lib/chats';
2121
import { useDebates } from '@/lib/debate-context';
22+
import { keepSourceAssessments } from '@/lib/results';
2223
import { useSettings } from '@/lib/settings';
2324
import { fetchSources, type Source } from '@/lib/sources';
2425
import { radius } from '@/theme/colors';
@@ -125,10 +126,9 @@ export default function AskScreen() {
125126
// The debate this conversation reads from: a loaded chat keeps its own.
126127
const chatDebateId = record.chatId ? record.debateId : activeDebateId;
127128
useEffect(() => {
128-
if (!chatDebateId || !userId) {
129-
setLibrary([]);
130-
return;
131-
}
129+
// The previous debate's sources never stand in for this one's while they load.
130+
setLibrary([]);
131+
if (!chatDebateId || !userId) return;
132132
let active = true;
133133
fetchSources(userId, chatDebateId)
134134
.then((next) => {
@@ -200,6 +200,10 @@ export default function AskScreen() {
200200
};
201201

202202
let result: AskOutput;
203+
const whole: { extras?: ResponseExtras } = {};
204+
const keepExtras = (extras: ResponseExtras) => {
205+
whole.extras = extras;
206+
};
203207
if (canStream(requestOptions)) {
204208
let received = '';
205209
let pending = '';
@@ -231,16 +235,27 @@ export default function AskScreen() {
231235
} else {
232236
// Nothing arrived: ask once more without streaming, as the website does.
233237
setStreaming(null);
234-
result = await askOnce(askRequest, controller.signal);
238+
result = await askOnce(askRequest, controller.signal, keepExtras);
235239
}
236240
} finally {
237241
if (flush) clearTimeout(flush);
238242
}
239243
} else {
240-
result = await askOnce(askRequest, controller.signal);
244+
result = await askOnce(askRequest, controller.signal, keepExtras);
241245
}
242246

243247
if (!isCurrent()) return;
248+
// A whole answer ranks the Library sources it used and says when the question was shortened, as on the website.
249+
if (whole.extras) {
250+
const { truncated, assessments } = whole.extras;
251+
const assessed = await keepSourceAssessments(userId, debateId, assessments);
252+
if (!isCurrent()) return;
253+
const notices = [
254+
...(assessed ? [] : [`${t('aiClient.sourceSaveFailed')}. ${t('aiClient.sourceAssessmentSaveFailed')}`]),
255+
...(truncated ? [`${t('aiClient.inputShortened')}. ${t('aiClient.inputShortenedDesc')}`] : []),
256+
];
257+
if (notices.length > 0) setNotice(notices.join(' '));
258+
}
244259
const output = {
245260
...result,
246261
model: result.model || askSettings.model,

‎apps/mobile/src/app/attack/[id].tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { copyText, sharePdf, shareText } from '@/lib/export';
1414
import { attackExportFileName, attackExportHtml } from '@/lib/export-html';
1515
import { attackToText, discussAttackMessage } from '@/lib/result-text';
1616
import { isCompleteAttack } from '@/lib/results';
17-
import { useResultDocument } from '@/lib/use-result-document';
17+
import { arrivalNoticeText, useResultDocument } from '@/lib/use-result-document';
1818

1919
/**
2020
* An attack analysis in the same document style as a case. Its pinned next
@@ -29,8 +29,8 @@ export default function AttackResultScreen() {
2929
const { setNotice } = doc;
3030

3131
useEffect(() => {
32-
if (arrival === 'recordFailed') setNotice(t('debate.recordFailed'));
33-
if (arrival === 'sourcesFailed') setNotice(t('mobile.results.sourcesFailed', { count: Number(failed) || 1 }));
32+
const text = arrivalNoticeText(t, arrival, failed);
33+
if (text) setNotice(text);
3434
}, [arrival, failed, setNotice, t]);
3535

3636
const result = doc.state.status === 'ready' ? doc.state.result : null;

‎apps/mobile/src/app/attack/index.tsx‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ import { useDebates } from '@/lib/debate-context';
1919
import { fetchFeatureConfig } from '@/lib/feature-config';
2020
import { keepOpenResult, openResult } from '@/lib/open-results';
2121
import { caseToText } from '@/lib/result-text';
22-
import { attackCase, autosaveSources, fetchResult, isCompleteCase, recordResult } from '@/lib/results';
22+
import { arrivalNotices, attackCase, autosaveSources, fetchResult, isCompleteCase, keepSourceAssessments, recordResult } from '@/lib/results';
23+
import { sessionSlot } from '@/lib/session-memory';
2324
import { useSettings } from '@/lib/settings';
2425
import { useTheme } from '@/theme/use-theme';
2526

@@ -31,7 +32,7 @@ const DEFAULT_FORM: AttackInput = {
3132
detectFallacies: true,
3233
analyzeWeighing: true,
3334
};
34-
let lastForm: AttackInput = DEFAULT_FORM;
35+
const lastForm = sessionSlot<AttackInput>(() => DEFAULT_FORM);
3536

3637
const PROGRESS = ['attackForm.loadingMsg1', 'attackForm.loadingMsg2', 'attackForm.loadingMsg3', 'attackForm.loadingMsg4', 'attackForm.loadingMsg5'];
3738

@@ -48,7 +49,7 @@ export default function AttackScreen() {
4849
const { activeDebateId, refresh } = useDebates();
4950
const { outputLanguage } = useSettings();
5051
const { colors } = useTheme();
51-
const [form, setFormState] = useState<AttackInput>(lastForm);
52+
const [form, setFormState] = useState<AttackInput>(lastForm.get);
5253
const [textError, setTextError] = useState(false);
5354
const [phase, setPhase] = useState<'form' | 'attacking'>('form');
5455
const [error, setError] = useState<AIErrorKind | null>(null);
@@ -59,7 +60,7 @@ export default function AttackScreen() {
5960
const setForm = useCallback((patch: Partial<AttackInput>) =>
6061
setFormState((previous) => {
6162
const next = { ...previous, ...patch };
62-
lastForm = next;
63+
lastForm.set(next);
6364
return next;
6465
}), []);
6566

@@ -117,9 +118,11 @@ export default function AttackScreen() {
117118
debateId ? buildDebateContext(forUser, debateId, 'attack', 'both') : Promise.resolve(undefined),
118119
]);
119120
if (!isCurrent()) return;
120-
const { result, discoveredSources } = await attackCase(input, { config, language: outputLanguage, debateContext, signal: controller.signal });
121+
const { result, discoveredSources, truncated, assessments } = await attackCase(input, { config, language: outputLanguage, debateContext, signal: controller.signal });
121122
if (!isCurrent()) return;
122-
const sources = await autosaveSources(forUser, discoveredSources, 'attack', debateId);
123+
const assessed = await keepSourceAssessments(forUser, debateId, assessments);
124+
if (!isCurrent()) return;
125+
const sources = await autosaveSources(forUser, discoveredSources, 'attack', debateId, controller.signal);
123126
if (!isCurrent()) return;
124127
const title = result.overallAssessment?.summary?.slice(0, 80) || input.caseText.slice(0, 80);
125128
let savedId: string | null = null;
@@ -134,7 +137,7 @@ export default function AttackScreen() {
134137
}
135138
if (!isCurrent()) return;
136139
const key = keepOpenResult({ kind: 'attack', savedId, title, side: 'neutral', debateId, createdAt: Date.now(), input, output: result, sources: discoveredSources });
137-
const notice = recordFailed ? 'recordFailed' : sources.failed > 0 ? 'sourcesFailed' : undefined;
140+
const notice = arrivalNotices({ recordFailed, sourcesFailed: sources.failed, assessed, truncated });
138141
router.replace({
139142
pathname: '/attack/[id]',
140143
params: {

‎apps/mobile/src/app/case/[id].tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { copyText, sharePdf, shareText } from '@/lib/export';
1515
import { caseExportFileName, caseExportHtml } from '@/lib/export-html';
1616
import { caseToText, discussCaseMessage } from '@/lib/result-text';
1717
import { isCompleteCase } from '@/lib/results';
18-
import { useResultDocument } from '@/lib/use-result-document';
18+
import { arrivalNoticeText, useResultDocument } from '@/lib/use-result-document';
1919

2020
/** A generated case read as a document, as in mock 03. */
2121
export default function CaseScreen() {
@@ -28,8 +28,8 @@ export default function CaseScreen() {
2828

2929
// Problems from saving a fresh result are told once, on arrival.
3030
useEffect(() => {
31-
if (arrival === 'recordFailed') setNotice(t('debate.recordFailed'));
32-
if (arrival === 'sourcesFailed') setNotice(t('mobile.results.sourcesFailed', { count: Number(failed) || 1 }));
31+
const text = arrivalNoticeText(t, arrival, failed);
32+
if (text) setNotice(text);
3333
}, [arrival, failed, setNotice, t]);
3434

3535
const result = doc.state.status === 'ready' ? doc.state.result : null;

‎apps/mobile/src/app/generate.tsx‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ import { fetchFeatureConfig } from '@/lib/feature-config';
2121
import { decideMotionTarget, shouldPrefillMotion } from '@/lib/motion';
2222
import { chooseMotionTarget } from '@/lib/motion-gate';
2323
import { keepOpenResult } from '@/lib/open-results';
24-
import { autosaveSources, generateCase, recordResult } from '@/lib/results';
24+
import { arrivalNotices, autosaveSources, generateCase, keepSourceAssessments, recordResult } from '@/lib/results';
25+
import { sessionSlot } from '@/lib/session-memory';
2526
import { useSettings } from '@/lib/settings';
2627
import { useTheme } from '@/theme/use-theme';
2728

@@ -35,7 +36,7 @@ const DEFAULT_FORM: GenerateInput = {
3536
audience: 'intermediate',
3637
tone: 'formal',
3738
};
38-
let lastForm: GenerateInput = DEFAULT_FORM;
39+
const lastForm = sessionSlot<GenerateInput>(() => DEFAULT_FORM);
3940

4041
const PROGRESS = ['generateForm.loadingMsg1', 'generateForm.loadingMsg2', 'generateForm.loadingMsg3', 'generateForm.loadingMsg4', 'generateForm.loadingMsg5'];
4142

@@ -49,7 +50,7 @@ export default function GenerateScreen() {
4950
const { debates, current, oneOff, create, switchTo, refresh } = useDebates();
5051
const { outputLanguage } = useSettings();
5152
const { colors } = useTheme();
52-
const [form, setFormState] = useState<GenerateInput>(lastForm);
53+
const [form, setFormState] = useState<GenerateInput>(lastForm.get);
5354
const [motionError, setMotionError] = useState(false);
5455
const [phase, setPhase] = useState<'form' | 'generating'>('form');
5556
const [error, setError] = useState<AIErrorKind | 'debate' | null>(null);
@@ -60,7 +61,7 @@ export default function GenerateScreen() {
6061
const setForm = useCallback((patch: Partial<GenerateInput>) =>
6162
setFormState((previous) => {
6263
const next = { ...previous, ...patch };
63-
lastForm = next;
64+
lastForm.set(next);
6465
return next;
6566
}), []);
6667

@@ -79,7 +80,7 @@ export default function GenerateScreen() {
7980
// The motion follows the Current Debate unless the student typed their own.
8081
useEffect(() => {
8182
if (oneOff || !current) return;
82-
if (shouldPrefillMotion(lastForm.motion, current.motion, debates.map((debate) => debate.motion))) setForm({ motion: current.motion });
83+
if (shouldPrefillMotion(lastForm.get().motion, current.motion, debates.map((debate) => debate.motion))) setForm({ motion: current.motion });
8384
}, [current, debates, oneOff, setForm]);
8485

8586
const start = async (input: GenerateInput, debateId: string | null, forUser: string) => {
@@ -96,9 +97,11 @@ export default function GenerateScreen() {
9697
debateId ? buildDebateContext(forUser, debateId, 'generate', input.side) : Promise.resolve(undefined),
9798
]);
9899
if (!isCurrent()) return;
99-
const { result, discoveredSources } = await generateCase(input, { config, language: outputLanguage, debateContext, signal: controller.signal });
100+
const { result, discoveredSources, truncated, assessments } = await generateCase(input, { config, language: outputLanguage, debateContext, signal: controller.signal });
100101
if (!isCurrent()) return;
101-
const sources = await autosaveSources(forUser, discoveredSources, 'generate', debateId);
102+
const assessed = await keepSourceAssessments(forUser, debateId, assessments);
103+
if (!isCurrent()) return;
104+
const sources = await autosaveSources(forUser, discoveredSources, 'generate', debateId, controller.signal);
102105
if (!isCurrent()) return;
103106
let savedId: string | null = null;
104107
let recordFailed = false;
@@ -122,7 +125,7 @@ export default function GenerateScreen() {
122125
output: result,
123126
sources: discoveredSources,
124127
});
125-
const notice = recordFailed ? 'recordFailed' : sources.failed > 0 ? 'sourcesFailed' : undefined;
128+
const notice = arrivalNotices({ recordFailed, sourcesFailed: sources.failed, assessed, truncated });
126129
router.replace({
127130
pathname: '/case/[id]',
128131
params: { id: key, from: from ?? 'prepare', ...(notice ? { notice } : {}), ...(sources.failed > 0 ? { failed: String(sources.failed) } : {}) },

‎apps/mobile/src/components/attack-document.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ export function AttackDocument({ output, sources }: { output: AttackOutput; sour
3030
const severityLabel = t(`attackResults.${severity}`);
3131
const typeLabel = t(`attackResults.types.${weakness.type}`, { defaultValue: weakness.type });
3232
return {
33-
key: weakness.id || `${index}`,
33+
// Model ids can repeat, so the expansion state is keyed by position.
34+
key: `${index}`,
3435
title: weakness.description,
3536
summary: `${severityLabel} · ${typeLabel}`,
3637
body: (

0 commit comments

Comments
 (0)