Skip to content

Commit e6591ca

Browse files
committed
Merge remote-tracking branch 'origin/feat/native-ios' into native-ios/parity-r2-library
# Conflicts: # apps/mobile/AGENTS.md # apps/mobile/package.json # apps/mobile/scripts/screenshots.ts # apps/mobile/src/components/chip.tsx # apps/mobile/src/components/icon-paths.ts # package-lock.json
2 parents a6c5e50 + 2f7aae1 commit e6591ca

71 files changed

Lines changed: 4327 additions & 1219 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/mobile/AGENTS.md‎

Lines changed: 7 additions & 3 deletions
Large diffs are not rendered by default.

‎apps/mobile/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"@expo-google-fonts/fredoka": "^0.4.1",
1414
"@expo-google-fonts/nunito": "^0.4.2",
1515
"@expo/dom-webview": "~57.0.1",
16+
"@formatjs/intl-listformat": "^8.3.15",
1617
"@formatjs/intl-pluralrules": "^6.3.15",
1718
"@react-native-async-storage/async-storage": "2.2.0",
1819
"@supabase/supabase-js": "^2.49.1",

‎apps/mobile/scripts/screenshots.ts‎

Lines changed: 326 additions & 25 deletions
Large diffs are not rendered by default.

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ function Root() {
100100
<Stack.Screen name="new-note" options={sheet} />
101101
<Stack.Screen name="add-file" options={sheet} />
102102
<Stack.Screen name="source/[id]" options={sheet} />
103+
{/* The website's limit and model dialogs; short, so the sheet stops at half height. */}
104+
<Stack.Screen name="limit" options={{ ...sheet, sheetAllowedDetents: [0.56, 0.86] }} />
105+
<Stack.Screen name="save-case" options={{ ...sheet, sheetAllowedDetents: [0.56, 0.86] }} />
106+
<Stack.Screen name="context-review" options={sheet} />
103107
{/* Full screen above the tabs, as in mock 07. */}
104108
<Stack.Screen name="ask" />
105109
{/* History, full screen above the tabs like the documents it opens. */}

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

Lines changed: 180 additions & 50 deletions
Large diffs are not rendered by default.

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { backLabel } from '@/lib/back-label';
1212
import { formatWhen } from '@/lib/dates';
1313
import { copyText, sharePdf, shareText } from '@/lib/export';
1414
import { attackExportFileName, attackExportHtml } from '@/lib/export-html';
15+
import { crossfireFromAttack } from '@/lib/result-links';
1516
import { attackToText, discussAttackMessage } from '@/lib/result-text';
1617
import { isCompleteAttack } from '@/lib/results';
1718
import { arrivalNoticeText, useResultDocument } from '@/lib/use-result-document';
@@ -59,7 +60,13 @@ export default function AttackResultScreen() {
5960
};
6061
const discuss = () => router.push({ pathname: '/ask', params: { prompt: discussAttackMessage(output) } });
6162

63+
const openCrossfire = () => {
64+
crossfireFromAttack(typeof result.input.caseText === 'string' ? result.input.caseText : '', output);
65+
router.push({ pathname: '/crossfire', params: { from: 'attack' } });
66+
};
67+
6268
const actions: HeaderAction[] = [
69+
{ key: 'crossfire', label: t('crossfire.openFromResult'), symbol: 'person.2.wave.2', icon: 'swords', onPress: openCrossfire },
6370
...(doc.autoSaved.length > 0
6471
? [{ key: 'undo-sources', label: t('autoSavedSources.undo'), symbol: 'arrow.uturn.backward' as const, icon: 'history' as const, onPress: () => void doc.undoAutoSaved() }]
6572
: []),

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

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { attackDraftSections } from '@debatelab/core/ai/live-sections';
12
import type { AttackInput } from '@debatelab/core/ai/schemas/attack';
23
import { useLocalSearchParams, useRouter } from 'expo-router';
34
import { useCallback, useEffect, useRef, useState } from 'react';
@@ -11,14 +12,18 @@ import { Field } from '@/components/field';
1112
import { FormGroup, SelectRow, SwitchRow } from '@/components/form-rows';
1213
import { ModelPicker } from '@/components/model-picker';
1314
import { Text } from '@/components/text';
14-
import { ToolError, ToolProgress, ToolTitle, WorkingIn } from '@/components/tool-form';
15+
import { ContextLine } from '@/components/context-line';
16+
import { LiveToolRun, useLiveRun } from '@/components/live-run';
17+
import { ToolError, ToolTitle, WorkingIn } from '@/components/tool-form';
1518
import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request';
1619
import { useAuth } from '@/lib/auth';
1720
import { backLabel } from '@/lib/back-label';
21+
import { resetContextChoices } from '@/lib/context-plan';
1822
import { buildDebateContext } from '@/lib/context-request';
1923
import { useDebates } from '@/lib/debate-context';
2024
import { keepOpenResult, openResult } from '@/lib/open-results';
2125
import { caseToText } from '@/lib/result-text';
26+
import { openRefusal } from '@/lib/refusal';
2227
import { arrivalNotices, attackCase, autosaveSources, fetchResult, isCompleteCase, keepSourceAssessments, recordResult } from '@/lib/results';
2328
import { sessionSlot } from '@/lib/session-memory';
2429
import { useSettings } from '@/lib/settings';
@@ -35,8 +40,6 @@ const DEFAULT_FORM: AttackInput = {
3540
};
3641
const lastForm = sessionSlot<AttackInput>(() => DEFAULT_FORM);
3742

38-
// The website's one progress line while the tool runs.
39-
const PROGRESS = ['attack.working'];
4043

4144
/**
4245
* Attack, as the website's AttackPage. Opened from a case, the case's text
@@ -67,11 +70,14 @@ export default function AttackScreen() {
6770
return next;
6871
}), []);
6972

73+
const liveRun = useLiveRun();
74+
const endLiveRun = liveRun.end;
7075
const abortActive = useCallback(() => {
7176
requests.current += 1;
7277
abort.current?.abort();
7378
abort.current = null;
74-
}, []);
79+
endLiveRun();
80+
}, [endLiveRun]);
7581

7682
useEffect(() => () => {
7783
live.current = false;
@@ -121,7 +127,15 @@ export default function AttackScreen() {
121127
debateId ? buildDebateContext(forUser, debateId, 'attack', 'both') : Promise.resolve(undefined),
122128
]);
123129
if (!isCurrent()) return;
124-
const { result, discoveredSources, truncated, assessments } = await attackCase(input, { config, language: outputLanguage, debateContext, signal: controller.signal });
130+
const { result, discoveredSources, truncated, assessments } = await attackCase(input, {
131+
config,
132+
language: outputLanguage,
133+
debateContext,
134+
signal: controller.signal,
135+
live: liveRun.begin(),
136+
});
137+
// Review choices cover one request, as on the website.
138+
resetContextChoices('attack');
125139
if (!isCurrent()) return;
126140
const assessed = await keepSourceAssessments(forUser, debateId, assessments, controller.signal);
127141
if (!isCurrent()) return;
@@ -155,8 +169,11 @@ export default function AttackScreen() {
155169
const kind = cause instanceof AIError ? cause.kind : 'failed';
156170
if (!(cause instanceof AIError)) console.warn('Attack failed:', cause instanceof Error ? cause.message : cause);
157171
if (kind === 'debateMissing') void refresh();
158-
setError(kind);
172+
liveRun.end();
159173
setPhase('form');
174+
// A limit or model refusal opens the website's dialog as a sheet, not an inline error.
175+
if (openRefusal(router, cause, 'attack')) return;
176+
setError(kind);
160177
} finally {
161178
if (requests.current === request) abort.current = null;
162179
}
@@ -172,13 +189,15 @@ export default function AttackScreen() {
172189
<ToolTitle title={t('nav.attack')} subtitle={t('attack.emptyDesc')} />
173190
<WorkingIn />
174191
{attacking ? (
175-
<ToolProgress
176-
pose="skeptical"
177-
messages={PROGRESS.map((key) => t(key))}
192+
<LiveToolRun
193+
label={t('attack.working')}
194+
run={liveRun.state}
195+
sections={liveRun.state.draft ? attackDraftSections(liveRun.state.draft, t) : []}
178196
onCancel={() => {
179197
abortActive();
180198
setPhase('form');
181199
}}
200+
pose="skeptical"
182201
/>
183202
) : (
184203
<>
@@ -236,6 +255,7 @@ export default function AttackScreen() {
236255
/>
237256
</FormGroup>
238257
<ModelPicker feature="attack" />
258+
<ContextLine feature="attack" side={'both'} />
239259
{error ? <ToolError title={t('attack.failed')} error={error} onRetry={() => void submit()} /> : null}
240260
</>
241261
)}

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

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { requestSide } from '@debatelab/core/contract/debate-context';
2+
import { brainstormDraftSections } from '@debatelab/core/ai/live-sections';
13
import type { BrainstormInput } from '@debatelab/core/ai/schemas/brainstorm';
24
import { useLocalSearchParams, useRouter } from 'expo-router';
35
import { useCallback, useEffect, useRef, useState } from 'react';
@@ -11,15 +13,19 @@ import { Field } from '@/components/field';
1113
import { FormGroup, SelectRow } from '@/components/form-rows';
1214
import { ModelPicker } from '@/components/model-picker';
1315
import { Text } from '@/components/text';
14-
import { ToolError, ToolProgress, ToolTitle, WorkingIn } from '@/components/tool-form';
16+
import { ContextLine } from '@/components/context-line';
17+
import { LiveToolRun, useLiveRun } from '@/components/live-run';
18+
import { ToolError, ToolTitle, WorkingIn } from '@/components/tool-form';
1519
import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request';
1620
import { useAuth } from '@/lib/auth';
1721
import { backLabel } from '@/lib/back-label';
22+
import { resetContextChoices } from '@/lib/context-plan';
1823
import { buildDebateContext } from '@/lib/context-request';
1924
import { useDebates } from '@/lib/debate-context';
2025
import { decideMotionTarget, shouldPrefillMotion } from '@/lib/motion';
2126
import { chooseMotionTarget } from '@/lib/motion-gate';
2227
import { keepOpenResult } from '@/lib/open-results';
28+
import { openRefusal } from '@/lib/refusal';
2329
import { arrivalNotices, autosaveSources, brainstormIdeas, keepSourceAssessments, recordResult } from '@/lib/results';
2430
import { sessionSlot } from '@/lib/session-memory';
2531
import { useSettings } from '@/lib/settings';
@@ -31,8 +37,6 @@ const DEFAULT_FORM: BrainstormInput = { motion: '', side: 'both', context: '', o
3137
// The last form stays for the session, as on the website; session memory forgets it when the account changes.
3238
const lastForm = sessionSlot<BrainstormInput>(() => DEFAULT_FORM);
3339

34-
// The website's one progress line while the tool runs.
35-
const PROGRESS = ['brainstorm.working'];
3640

3741
/** Brainstorm, as the website's BrainstormPage: a native form, a calm wait, then the ideas as a document. */
3842
export default function BrainstormScreen() {
@@ -60,11 +64,14 @@ export default function BrainstormScreen() {
6064
return next;
6165
}), []);
6266

67+
const liveRun = useLiveRun();
68+
const endLiveRun = liveRun.end;
6369
const abortActive = useCallback(() => {
6470
requests.current += 1;
6571
abort.current?.abort();
6672
abort.current = null;
67-
}, []);
73+
endLiveRun();
74+
}, [endLiveRun]);
6875

6976
useEffect(() => () => {
7077
live.current = false;
@@ -101,7 +108,15 @@ export default function BrainstormScreen() {
101108
debateId ? buildDebateContext(forUser, debateId, 'brainstorm', input.side) : Promise.resolve(undefined),
102109
]);
103110
if (!isCurrent()) return;
104-
const { result, discoveredSources, truncated, assessments } = await brainstormIdeas(input, { config, language: outputLanguage, debateContext, signal: controller.signal });
111+
const { result, discoveredSources, truncated, assessments } = await brainstormIdeas(input, {
112+
config,
113+
language: outputLanguage,
114+
debateContext,
115+
signal: controller.signal,
116+
live: liveRun.begin(),
117+
});
118+
// Review choices cover one request, as on the website.
119+
resetContextChoices('brainstorm');
105120
if (!isCurrent()) return;
106121
const assessed = await keepSourceAssessments(forUser, debateId, assessments, controller.signal);
107122
if (!isCurrent()) return;
@@ -145,8 +160,11 @@ export default function BrainstormScreen() {
145160
if (!(cause instanceof AIError)) console.warn('Brainstorm failed:', cause instanceof Error ? cause.message : cause);
146161
// A missing debate was deleted elsewhere; reload the list so the app stops using it.
147162
if (kind === 'debateMissing') void refresh();
148-
setError(kind);
163+
liveRun.end();
149164
setPhase('form');
165+
// A limit or model refusal opens the website's dialog as a sheet, not an inline error.
166+
if (openRefusal(router, cause, 'brainstorm')) return;
167+
setError(kind);
150168
} finally {
151169
if (requests.current === request) abort.current = null;
152170
}
@@ -206,7 +224,14 @@ export default function BrainstormScreen() {
206224
<ToolTitle title={t('nav.brainstorm')} subtitle={t('brainstorm.emptyDesc')} />
207225
<WorkingIn />
208226
{working ? (
209-
<ToolProgress pose="idea" messages={PROGRESS.map((key) => t(key))} detail={form.motion} onCancel={cancel} />
227+
<LiveToolRun
228+
label={t('brainstorm.working')}
229+
run={liveRun.state}
230+
sections={liveRun.state.draft ? brainstormDraftSections(liveRun.state.draft, t) : []}
231+
detail={form.motion}
232+
onCancel={cancel}
233+
pose="idea"
234+
/>
210235
) : (
211236
<>
212237
<Field
@@ -262,6 +287,7 @@ export default function BrainstormScreen() {
262287
style={styles.context}
263288
testID="brainstorm-context"
264289
/>
290+
<ContextLine feature="brainstorm" side={requestSide('brainstorm', form)} />
265291
{error ? <ToolError title={t('brainstorm.failed')} error={error} onRetry={() => void submit()} /> : null}
266292
</>
267293
)}

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { CaseSection } from '@debatelab/core/ai/generate-output';
12
import { useLocalSearchParams, useRouter } from 'expo-router';
23
import { useEffect, useState } from 'react';
34
import { useTranslation } from 'react-i18next';
@@ -9,11 +10,15 @@ import { openCitation } from '@/components/citation-target';
910
import { DocumentScreen, DocumentTitle } from '@/components/document';
1011
import { DocumentHeader, type HeaderAction } from '@/components/document-header';
1112
import { DocumentStatus } from '@/components/document-status';
13+
import { RegeneratingStrip } from '@/components/regenerating-strip';
1214
import { backLabel } from '@/lib/back-label';
15+
import { useCaseEdits } from '@/lib/case-edits';
16+
import { useDebates } from '@/lib/debate-context';
1317
import { formatWhen } from '@/lib/dates';
1418
import { copyText, sharePdf, shareText } from '@/lib/export';
1519
import { caseExportFileName, caseExportHtml } from '@/lib/export-html';
1620
import { caseToText, discussCaseMessage } from '@/lib/result-text';
21+
import { buildFlowchartFromCase, crossfireFromCase } from '@/lib/result-links';
1722
import { isCompleteCase } from '@/lib/results';
1823
import { arrivalNoticeText, useResultDocument } from '@/lib/use-result-document';
1924

@@ -23,8 +28,11 @@ export default function CaseScreen() {
2328
const router = useRouter();
2429
const { id, from, notice: arrival, failed } = useLocalSearchParams<{ id: string; from?: string; notice?: string; failed?: string }>();
2530
const doc = useResultDocument('case', id);
31+
const { activeDebateId } = useDebates();
2632
const [exportOpen, setExportOpen] = useState(false);
33+
const [regenerateOpen, setRegenerateOpen] = useState(false);
2734
const { setNotice } = doc;
35+
const edits = useCaseEdits(doc.state.status === 'ready' ? doc.state.key : null, setNotice);
2836

2937
// Problems from saving a fresh result are told once, on arrival.
3038
useEffect(() => {
@@ -59,7 +67,35 @@ export default function CaseScreen() {
5967
});
6068
};
6169

70+
const openCrossfire = () => {
71+
crossfireFromCase(typeof result.input.motion === 'string' ? result.input.motion : output.metadata?.motion ?? '', output);
72+
router.push({ pathname: '/crossfire', params: { from: 'case' } });
73+
};
74+
const buildFlowchart = () => {
75+
const outcome = buildFlowchartFromCase(output, result.debateId, activeDebateId, t);
76+
if (outcome === 'built') router.push({ pathname: '/flowchart', params: { from: 'case', built: '1' } });
77+
else setNotice(`${t('generate.flowchartImportFailed')}. ${t(outcome === 'noData' ? 'generate.flowchartNoData' : 'generate.flowchartOtherDebate')}`);
78+
};
79+
80+
// The website's section cards, in their order; the opening shows only when the case has one.
81+
const sections: { key: CaseSection; title: string }[] = [
82+
...(output.exordio && (output.exordio.hook || output.exordio.transition) ? [{ key: 'exordio' as const, title: t('generateResults.exordio') }] : []),
83+
{ key: 'contentions', title: t('generateResults.arguments') },
84+
{ key: 'structure', title: t('generateResults.caseStructure') },
85+
{ key: 'opponent', title: t('generateResults.predictedOpponent') },
86+
{ key: 'counters', title: t('generateResults.preparedCounters') },
87+
];
88+
const regeneratingTitle = sections.find((section) => section.key === edits.regenerating)?.title;
89+
6290
const actions: HeaderAction[] = [
91+
...(edits.regenerating
92+
? []
93+
: [{ key: 'regenerate', label: t('mobile.results.regenerateSection'), symbol: 'arrow.clockwise' as const, icon: 'reset' as const, onPress: () => setRegenerateOpen(true) }]),
94+
...(edits.canUndo && !edits.regenerating
95+
? [{ key: 'undo', label: t('generateResults.undo'), symbol: 'arrow.uturn.backward' as const, icon: 'history' as const, onPress: () => void edits.undo() }]
96+
: []),
97+
{ key: 'crossfire', label: t('crossfire.openFromResult'), symbol: 'person.2.wave.2', icon: 'swords', onPress: openCrossfire },
98+
{ key: 'flowchart', label: t('mobile.results.buildFlowchart'), symbol: 'point.3.connected.trianglepath.dotted', icon: 'branch', onPress: buildFlowchart },
6399
{ key: 'discuss', label: t('common.discuss'), symbol: 'bubble.left.and.bubble.right', icon: 'chat', onPress: () => router.push({ pathname: '/ask', params: { prompt: discussCaseMessage(output) } }) },
64100
...(result.savedId ? [] : [{ key: 'save', label: t('common.save'), symbol: 'square.and.arrow.down' as const, icon: 'download' as const, onPress: () => void doc.save() }]),
65101
...(doc.autoSaved.length > 0
@@ -73,6 +109,7 @@ export default function CaseScreen() {
73109
testID="case-document"
74110
outline
75111
notice={doc.notice}
112+
noticeTone={doc.noticeTone}
76113
header={<DocumentHeader backLabel={back} onShare={() => run(() => shareText(title, caseToText(output)))} actions={actions} />}
77114
// "Attack this case" is the longer label, so it gets the wider button.
78115
actionWidths={[1.3, 1]}
@@ -89,7 +126,17 @@ export default function CaseScreen() {
89126
</>
90127
}>
91128
<DocumentTitle title={title} meta={t('mobile.results.generatedMeta', { side, when: formatWhen(generatedAt) })} />
129+
{regeneratingTitle ? <RegeneratingStrip title={regeneratingTitle} /> : null}
92130
<CaseDocument output={output} library={doc.library} sources={result.sources} onCite={(target) => openCitation(router, target)} />
131+
<ActionMenu
132+
visible={regenerateOpen}
133+
title={t('mobile.results.regenerateSection')}
134+
testID="regenerate-menu"
135+
items={sections.map((section) => ({ key: section.key, label: t('common.regenerate', { title: section.title }), icon: 'reset' as const }))}
136+
cancelLabel={t('common.cancel')}
137+
onClose={() => setRegenerateOpen(false)}
138+
onSelect={(choice) => void edits.regenerate(choice as CaseSection)}
139+
/>
93140
<ActionMenu
94141
visible={exportOpen}
95142
title={t('common.export')}

0 commit comments

Comments
 (0)